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:
parent
cc39664ce4
commit
d55a83888b
@ -5,8 +5,7 @@ NEXT_PUBLIC_DOWNLOAD_APP_IOS=#
|
||||
NEXT_PUBLIC_DOWNLOAD_APP_ANDROID=#
|
||||
NEXT_PUBLIC_GRAPHQL_ENGINE_URL=http://localhost:8080/v1/graphql
|
||||
NEXT_PUBLIC_GRAPHQL_ENGINE_WEB_SOCKET_URL=ws://localhost:8080/v1/graphql
|
||||
OPENAPI_ENDPOINT=http://host.docker.internal:8000/v1
|
||||
OPENAPI_KEY=openapikey
|
||||
NEXT_PUBLIC_OPENAPI_ENDPOINT=http://localhost:8000/v1/chat/completions
|
||||
KEYCLOAK_CLIENT_ID=hasura
|
||||
KEYCLOAK_CLIENT_SECRET=oMtCPAV7diKpE564SBspgKj4HqlKM4Hy
|
||||
AUTH_ISSUER=http://localhost:8088/realms/$KEYCLOAK_CLIENT_ID
|
||||
|
||||
@ -77,9 +77,7 @@ Replace above configuration with your actual infrastructure.
|
||||
| [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) | UI | ^0.5.9 |
|
||||
| [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 |
|
||||
| [mobx](https://mobx.js.org/README.html) | State management | ^6.10.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 |
|
||||
| [jotai](https://jotai.org/) | State management | ^2.4.0 |
|
||||
|
||||
|
||||
## Deploy to Netlify
|
||||
|
||||
@ -1,69 +1,29 @@
|
||||
"use client";
|
||||
import { useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { MenuAdvancedPrompt } from "../MenuAdvancedPrompt";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { CreateMessageDocument, CreateMessageMutation } from "@/graphql";
|
||||
|
||||
export const AdvancedPrompt: React.FC = observer(() => {
|
||||
const { register, handleSubmit } = useForm();
|
||||
const { historyStore } = useStore();
|
||||
|
||||
const onAdvancedPrompt = useCallback(() => {
|
||||
historyStore.toggleAdvancedPrompt();
|
||||
}, []);
|
||||
|
||||
const [createMessageMutation] = useMutation<CreateMessageMutation>(
|
||||
CreateMessageDocument
|
||||
);
|
||||
const onSubmit = (data: any) => {
|
||||
historyStore.sendControlNetPrompt(
|
||||
createMessageMutation,
|
||||
data.prompt,
|
||||
data.negativePrompt,
|
||||
data.fileInput[0]
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
import { MenuAdvancedPrompt } from "../MenuAdvancedPrompt";
|
||||
import { useForm } from "react-hook-form";
|
||||
import BasicPromptButton from "../BasicPromptButton";
|
||||
import PrimaryButton from "../PrimaryButton";
|
||||
|
||||
const AdvancedPrompt: React.FC = () => {
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const onSubmit = (data: any) => {};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-[288px] h-screen flex flex-col border-r border-gray-200"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<BasicPromptButton />
|
||||
<MenuAdvancedPrompt register={register} />
|
||||
<div className="py-3 px-2 flex flex-none gap-3 items-center justify-between border-t border-gray-200">
|
||||
<PrimaryButton
|
||||
fullWidth={true}
|
||||
title="Generate"
|
||||
onClick={() => handleSubmit(onSubmit)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedPrompt;
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
import { FieldValues, UseFormRegister } from "react-hook-form";
|
||||
|
||||
type Props = {
|
||||
formId?: string;
|
||||
height: number;
|
||||
title: string;
|
||||
placeholder: string;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
};
|
||||
|
||||
export const AdvancedTextArea: React.FC<Props> = ({
|
||||
formId = "",
|
||||
height,
|
||||
placeholder,
|
||||
title,
|
||||
register,
|
||||
}) => (
|
||||
<div className="w-full flex flex-col pt-3 gap-1">
|
||||
<label className="text-sm leading-5 text-gray-800">{title}</label>
|
||||
<textarea
|
||||
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"
|
||||
placeholder={placeholder}
|
||||
{...register(formId, { required: formId === "prompt" ? true : false })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { FieldValues, UseFormRegister } from "react-hook-form";
|
||||
|
||||
type Props = {
|
||||
formId?: string;
|
||||
height: number;
|
||||
title: string;
|
||||
placeholder: string;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
};
|
||||
|
||||
export const AdvancedTextArea: React.FC<Props> = ({
|
||||
formId = "",
|
||||
height,
|
||||
placeholder,
|
||||
title,
|
||||
register,
|
||||
}) => (
|
||||
<div className="w-full flex flex-col pt-3 gap-1">
|
||||
<label className="text-sm leading-5 text-gray-800">{title}</label>
|
||||
<textarea
|
||||
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"
|
||||
placeholder={placeholder}
|
||||
{...register(formId, { required: formId === "prompt" ? true : false })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,79 +1,79 @@
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import js from "react-syntax-highlighter/dist/esm/languages/hljs/javascript";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import useGetModelApiInfo from "@/_hooks/useGetModelApiInfo";
|
||||
|
||||
SyntaxHighlighter.registerLanguage("javascript", js);
|
||||
|
||||
const ApiPane: React.FC = () => {
|
||||
const [expend, setExpend] = useState(true);
|
||||
const { data } = useGetModelApiInfo();
|
||||
const [highlightCode, setHighlightCode] = useState(data[0]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative">
|
||||
<div className="absolute top-0 left-0 h-full w-full overflow-x-hidden scroll">
|
||||
<button
|
||||
onClick={() => setExpend(!expend)}
|
||||
className="flex items-center flex-none"
|
||||
>
|
||||
<Image
|
||||
src={"/icons/unicorn_angle-down.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
<span>Request</span>
|
||||
</button>
|
||||
<div
|
||||
className={`${
|
||||
expend ? "block" : "hidden"
|
||||
} bg-[#1F2A37] rounded-lg w-full flex-1`}
|
||||
>
|
||||
<div className="p-2 flex justify-between flex-1">
|
||||
<div className="flex">
|
||||
{data.map((item, index) => (
|
||||
<button
|
||||
className={`py-1 text-xs text-[#9CA3AF] px-2 flex gap-[10px] rounded ${
|
||||
highlightCode?.type === item.type
|
||||
? "bg-[#374151] text-white"
|
||||
: ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => setHighlightCode(item)}
|
||||
>
|
||||
{item.type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(highlightCode?.stringCode)
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={"/icons/unicorn_clipboard-alt.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
className="w-full bg-transparent overflow-x-hidden scroll resize-none"
|
||||
language="jsx"
|
||||
style={atomOneDark}
|
||||
customStyle={{ padding: "12px", background: "transparent" }}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{highlightCode?.stringCode}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import js from "react-syntax-highlighter/dist/esm/languages/hljs/javascript";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import useGetModelApiInfo from "@/_hooks/useGetModelApiInfo";
|
||||
|
||||
SyntaxHighlighter.registerLanguage("javascript", js);
|
||||
|
||||
const ApiPane: React.FC = () => {
|
||||
const [expend, setExpend] = useState(true);
|
||||
const { data } = useGetModelApiInfo();
|
||||
const [highlightCode, setHighlightCode] = useState(data[0]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative">
|
||||
<div className="absolute top-0 left-0 h-full w-full overflow-x-hidden scroll">
|
||||
<button
|
||||
onClick={() => setExpend(!expend)}
|
||||
className="flex items-center flex-none"
|
||||
>
|
||||
<Image
|
||||
src={"/icons/unicorn_angle-down.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
<span>Request</span>
|
||||
</button>
|
||||
<div
|
||||
className={`${
|
||||
expend ? "block" : "hidden"
|
||||
} bg-[#1F2A37] rounded-lg w-full flex-1`}
|
||||
>
|
||||
<div className="p-2 flex justify-between flex-1">
|
||||
<div className="flex">
|
||||
{data.map((item, index) => (
|
||||
<button
|
||||
className={`py-1 text-xs text-[#9CA3AF] px-2 flex gap-[10px] rounded ${
|
||||
highlightCode?.type === item.type
|
||||
? "bg-[#374151] text-white"
|
||||
: ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => setHighlightCode(item)}
|
||||
>
|
||||
{item.type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(highlightCode?.stringCode)
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={"/icons/unicorn_clipboard-alt.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
className="w-full bg-transparent overflow-x-hidden scroll resize-none"
|
||||
language="jsx"
|
||||
style={atomOneDark}
|
||||
customStyle={{ padding: "12px", background: "transparent" }}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{highlightCode?.stringCode}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiPane;
|
||||
@ -1,15 +1,15 @@
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const ApiStep: React.FC<Props> = ({ description, title }) => {
|
||||
return (
|
||||
<div className="gap-2 flex flex-col">
|
||||
<span className="text-[#8A8A8A]">{title}</span>
|
||||
<div className="flex flex-col gap-[10px] p-[18px] bg-[#F9F9F9] overflow-y-hidden">
|
||||
<pre className="text-sm leading-5 text-black">{description}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const ApiStep: React.FC<Props> = ({ description, title }) => {
|
||||
return (
|
||||
<div className="gap-2 flex flex-col">
|
||||
<span className="text-[#8A8A8A]">{title}</span>
|
||||
<div className="flex flex-col gap-[10px] p-[18px] bg-[#F9F9F9] overflow-y-hidden">
|
||||
<pre className="text-sm leading-5 text-black">{description}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
40
web-client/app/_components/BasicPromptAccessories/index.tsx
Normal file
40
web-client/app/_components/BasicPromptAccessories/index.tsx
Normal 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;
|
||||
20
web-client/app/_components/BasicPromptButton/index.tsx
Normal file
20
web-client/app/_components/BasicPromptButton/index.tsx
Normal 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);
|
||||
38
web-client/app/_components/BasicPromptInput/index.tsx
Normal file
38
web-client/app/_components/BasicPromptInput/index.tsx
Normal 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;
|
||||
@ -1,45 +1,39 @@
|
||||
import Image from "next/image";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type PropType = PropsWithChildren<
|
||||
React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>
|
||||
>;
|
||||
|
||||
export const PrevButton: React.FC<PropType> = (props) => {
|
||||
const { children, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="embla__button embla__button--prev"
|
||||
type="button"
|
||||
{...restProps}
|
||||
>
|
||||
<Image
|
||||
className="rotate-180"
|
||||
src={"/icons/chevron-right.svg"}
|
||||
width={20}
|
||||
height={20}
|
||||
alt=""
|
||||
/>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NextButton: React.FC<PropType> = (props) => {
|
||||
const { children, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="embla__button embla__button--next"
|
||||
type="button"
|
||||
{...restProps}
|
||||
>
|
||||
<Image src={"/icons/chevron-right.svg"} width={20} height={20} alt="" />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type PropType = PropsWithChildren<
|
||||
React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>
|
||||
>;
|
||||
|
||||
export const PrevButton: React.FC<PropType> = (props) => {
|
||||
const { children, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="embla__button embla__button--prev"
|
||||
type="button"
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronLeftIcon width={20} height={20} />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NextButton: React.FC<PropType> = (props) => {
|
||||
const { children, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="embla__button embla__button--next"
|
||||
type="button"
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronRightIcon width={20} height={20} />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { SunIcon, MoonIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const ThemeChanger: React.FC = () => {
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
const currentTheme = theme === "system" ? systemTheme : theme;
|
||||
|
||||
if (currentTheme === "dark") {
|
||||
return (
|
||||
<SunIcon
|
||||
className="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
onClick={() => setTheme("light")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MoonIcon
|
||||
className="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
onClick={() => setTheme("dark")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
import { useTheme } from "next-themes";
|
||||
import { SunIcon, MoonIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const ThemeChanger: React.FC = () => {
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
const currentTheme = theme === "system" ? systemTheme : theme;
|
||||
|
||||
if (currentTheme === "dark") {
|
||||
return (
|
||||
<SunIcon
|
||||
className="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
onClick={() => setTheme("light")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MoonIcon
|
||||
className="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
onClick={() => setTheme("dark")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,186 +1,47 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
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";
|
||||
"use client";
|
||||
|
||||
type Props = {
|
||||
onPromptSelected: (prompt: string) => void;
|
||||
};
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
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 ref = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
const { historyStore } = useStore();
|
||||
const refSmooth = useRef<HTMLDivElement>(null);
|
||||
const [heightContent, setHeightContent] = useState(0);
|
||||
const ChatBody: React.FC = () => {
|
||||
const messages = useAtomValue(currentChatMessagesAtom);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const { loading, hasMore } = useChatMessages(offset);
|
||||
const intersectObs = useRef<any>(null);
|
||||
|
||||
const refContent = useRef<HTMLDivElement>(null);
|
||||
const convo = historyStore.getActiveConversation();
|
||||
const [getConversationMessages] = useLazyQuery<GetConversationMessagesQuery>(
|
||||
GetConversationMessagesDocument
|
||||
const lastPostRef = useCallback(
|
||||
(message: ChatMessage) => {
|
||||
if (loading) return;
|
||||
|
||||
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(() => {
|
||||
refSmooth.current?.scrollIntoView({ behavior: "instant" });
|
||||
}, [heightContent]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (refContent.current) {
|
||||
setHeightContent(refContent.current?.offsetHeight);
|
||||
const content = messages.map((message, index) => {
|
||||
if (messages.length === index + 1) {
|
||||
return <ChatItem ref={lastPostRef} message={message} key={message.id} />;
|
||||
}
|
||||
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 (
|
||||
<div className="flex-grow flex flex-col h-fit" ref={ref}>
|
||||
{shouldShowSampleContainer && model ? (
|
||||
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 className="flex flex-col-reverse flex-1 py-4 overflow-y-auto scroll">
|
||||
{content}
|
||||
</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;
|
||||
|
||||
66
web-client/app/_components/ChatBody/renderChatMessage.tsx
Normal file
66
web-client/app/_components/ChatBody/renderChatMessage.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,91 +1,30 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChatBody } from "../ChatBody";
|
||||
import { InputToolbar } from "../InputToolbar";
|
||||
import { UserToolbar } from "../UserToolbar";
|
||||
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 ModelDetailSideBar from "../ModelDetailSideBar";
|
||||
import ProductOverview from "../ProductOverview";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
DeleteConversationMutation,
|
||||
DeleteConversationDocument,
|
||||
} from "@/graphql";
|
||||
import { useMutation } from "@apollo/client";
|
||||
getActiveConvoIdAtom,
|
||||
showingProductDetailAtom,
|
||||
} from "@/_helpers/JotaiWrapper";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const ChatContainer: React.FC = observer(() => {
|
||||
const [prefillPrompt, setPrefillPrompt] = useState("");
|
||||
const { historyStore } = useStore();
|
||||
const { user } = useGetCurrentUser();
|
||||
const showBodyChat = historyStore.activeConversationId != null;
|
||||
const conversation = historyStore.getActiveConversation();
|
||||
const [deleteConversation] = useMutation<DeleteConversationMutation>(
|
||||
DeleteConversationDocument
|
||||
);
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
historyStore.clearAllConversations();
|
||||
}
|
||||
}, [user]);
|
||||
export default function ChatContainer({ children }: Props) {
|
||||
const activeConvoId = useAtomValue(getActiveConvoIdAtom);
|
||||
const showingProductDetail = useAtomValue(showingProductDetailAtom);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
if (!activeConvoId) {
|
||||
return <ProductOverview />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 h-full overflow-y-hidden">
|
||||
<ConfirmDeleteConversationModal
|
||||
open={open}
|
||||
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 className="flex flex-1 overflow-hidden">
|
||||
{children}
|
||||
{showingProductDetail ? <ModelDetailSideBar /> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChatContainer;
|
||||
}
|
||||
|
||||
19
web-client/app/_components/ChatItem/index.tsx
Normal file
19
web-client/app/_components/ChatItem/index.tsx
Normal 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;
|
||||
@ -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 React from "react";
|
||||
|
||||
type Props = {
|
||||
imageUrl: string;
|
||||
isSelected: boolean;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
const CompactHistoryItem: React.FC<Props> = ({
|
||||
imageUrl,
|
||||
isSelected,
|
||||
conversationId,
|
||||
}) => {
|
||||
const { historyStore } = useStore();
|
||||
const onClick = () => {
|
||||
historyStore.setActiveConversationId(conversationId);
|
||||
};
|
||||
const CompactHistoryItem: React.FC<Props> = ({ imageUrl, conversationId }) => {
|
||||
const activeConvoId = useAtomValue(getActiveConvoIdAtom);
|
||||
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
|
||||
|
||||
const isSelected = activeConvoId === conversationId;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onClick={() => setActiveConvoId(conversationId)}
|
||||
className={`${
|
||||
isSelected ? "bg-gray-100" : "bg-transparent"
|
||||
} p-2 rounded-lg`}
|
||||
} w-14 h-14 rounded-lg`}
|
||||
>
|
||||
<Image
|
||||
className="rounded-full"
|
||||
className="rounded-full mx-auto"
|
||||
src={imageUrl}
|
||||
width={36}
|
||||
height={36}
|
||||
@ -36,4 +34,4 @@ const CompactHistoryItem: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CompactHistoryItem);
|
||||
export default CompactHistoryItem;
|
||||
|
||||
21
web-client/app/_components/CompactHistoryList/index.tsx
Normal file
21
web-client/app/_components/CompactHistoryList/index.tsx
Normal 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;
|
||||
@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import JanImage from "../JanImage";
|
||||
import { setActiveConvoIdAtom } from "@/_helpers/JotaiWrapper";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
const CompactLogo: React.FC = () => {
|
||||
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
|
||||
|
||||
const CompactLogo: React.FC<Props> = ({ onClick }) => {
|
||||
return (
|
||||
<button onClick={onClick}>
|
||||
<button onClick={() => setActiveConvoId(undefined)}>
|
||||
<JanImage imageUrl="/icons/app_icon.svg" width={28} height={28} />
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -1,33 +1,11 @@
|
||||
"use client"
|
||||
import { observer } from "mobx-react-lite";
|
||||
import CompactHistoryList from "../CompactHistoryList";
|
||||
import CompactLogo from "../CompactLogo";
|
||||
import CompactHistoryItem from "../CompactHistoryItem";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
|
||||
export const CompactSideBar: React.FC = observer(() => {
|
||||
const { historyStore } = useStore();
|
||||
const CompactSideBar: React.FC = () => (
|
||||
<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 = () => {
|
||||
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>
|
||||
);
|
||||
});
|
||||
export default CompactSideBar;
|
||||
|
||||
@ -1,27 +1,26 @@
|
||||
import { showConfirmDeleteConversationModalAtom } from "@/_helpers/JotaiWrapper";
|
||||
import useDeleteConversation from "@/_hooks/useDeleteConversation";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
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 = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onConfirmDelete: () => void;
|
||||
};
|
||||
|
||||
const ConfirmDeleteConversationModal: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
onConfirmDelete,
|
||||
}) => {
|
||||
const ConfirmDeleteConversationModal: React.FC = () => {
|
||||
const [show, setShow] = useAtom(showConfirmDeleteConversationModalAtom);
|
||||
const cancelButtonRef = useRef(null);
|
||||
const { deleteConvo } = useDeleteConversation();
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
deleteConvo().then(() => setShow(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Transition.Root show={show} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
initialFocus={cancelButtonRef}
|
||||
onClose={setOpen}
|
||||
onClose={setShow}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
@ -81,7 +80,7 @@ const ConfirmDeleteConversationModal: React.FC<Props> = ({
|
||||
<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"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => setShow(false)}
|
||||
ref={cancelButtonRef}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@ -1,22 +1,21 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { showConfirmSignOutModalAtom } from "@/_helpers/JotaiWrapper";
|
||||
import { useAtom } from "jotai";
|
||||
import useSignOut from "@/_hooks/useSignOut";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
const ConfirmSignOutModal: React.FC = () => {
|
||||
const [show, setShow] = useAtom(showConfirmSignOutModalAtom);
|
||||
const { signOut } = useSignOut();
|
||||
|
||||
const ConfirmSignOutModal: React.FC<Props> = ({ open, setOpen, onConfirm }) => {
|
||||
const onLogOutClick = () => {
|
||||
onConfirm();
|
||||
setOpen(false);
|
||||
signOut().then(() => setShow(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={setOpen}>
|
||||
<Transition.Root show={show} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={setShow}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@ -73,7 +72,7 @@ const ConfirmSignOutModal: React.FC<Props> = ({ open, setOpen, onConfirm }) => {
|
||||
<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"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => setShow(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ProductDetailFragment,
|
||||
} from "@/graphql";
|
||||
import useCreateConversation from "@/_hooks/useCreateConversation";
|
||||
import { Product } from "@/_models/Product";
|
||||
|
||||
type Props = {
|
||||
product: ProductDetailFragment;
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const ConversationalCard: React.FC<Props> = ({ product }) => {
|
||||
const { requestCreateConvo } = useCreateConversation();
|
||||
|
||||
const { name, image_url, description } = product;
|
||||
const { name, avatarUrl, description } = product;
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -25,7 +23,7 @@ const ConversationalCard: React.FC<Props> = ({ product }) => {
|
||||
<Image
|
||||
width={32}
|
||||
height={32}
|
||||
src={image_url ?? ""}
|
||||
src={avatarUrl ?? ""}
|
||||
className="rounded-full"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { Product } from "@/_models/Product";
|
||||
import ConversationalCard from "../ConversationalCard";
|
||||
import Image from "next/image";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import { ChatBubbleBottomCenterTextIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
products: ProductDetailFragment[];
|
||||
products: Product[];
|
||||
};
|
||||
|
||||
const ConversationalList: React.FC<Props> = ({ products }) => (
|
||||
<>
|
||||
<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">
|
||||
Conversational
|
||||
</span>
|
||||
</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) => (
|
||||
<ConversationalCard key={item.name} product={item} />
|
||||
<ConversationalCard key={item.slug} product={item} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,64 +1,64 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import Image from "next/image";
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
data: string[];
|
||||
};
|
||||
|
||||
export const DropdownsList: React.FC<Props> = ({ data, title }) => {
|
||||
const [checked, setChecked] = useState(data[0]);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative w-full text-left">
|
||||
<div className="pt-2 gap-2 flex flex-col">
|
||||
<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">
|
||||
{checked}
|
||||
<Image
|
||||
src={"/icons/unicorn_angle-down.svg"}
|
||||
width={12}
|
||||
height={12}
|
||||
alt=""
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
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">
|
||||
<div className="py-1">
|
||||
{data.map((item, index) => (
|
||||
<Menu.Item key={index}>
|
||||
{({ active }) => (
|
||||
<a
|
||||
onClick={() => setChecked(item)}
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
|
||||
"block px-4 py-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
import { Fragment, useState } from "react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import Image from "next/image";
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
data: string[];
|
||||
};
|
||||
|
||||
export const DropdownsList: React.FC<Props> = ({ data, title }) => {
|
||||
const [checked, setChecked] = useState(data[0]);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative w-full text-left">
|
||||
<div className="pt-2 gap-2 flex flex-col">
|
||||
<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">
|
||||
{checked}
|
||||
<Image
|
||||
src={"/icons/unicorn_angle-down.svg"}
|
||||
width={12}
|
||||
height={12}
|
||||
alt=""
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
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">
|
||||
<div className="py-1">
|
||||
{data.map((item, index) => (
|
||||
<Menu.Item key={index}>
|
||||
{({ active }) => (
|
||||
<a
|
||||
onClick={() => setChecked(item)}
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
|
||||
"block px-4 py-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
24
web-client/app/_components/ExpandableHeader/index.tsx
Normal file
24
web-client/app/_components/ExpandableHeader/index.tsx
Normal 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;
|
||||
@ -1,23 +1,21 @@
|
||||
import useCreateConversation from "@/_hooks/useCreateConversation";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import { useCallback } from "react";
|
||||
import { Product } from "@/_models/Product";
|
||||
|
||||
type Props = {
|
||||
product: ProductDetailFragment;
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const GenerateImageCard: React.FC<Props> = ({ product }) => {
|
||||
const { name, image_url } = product;
|
||||
const { name, avatarUrl } = product;
|
||||
const { requestCreateConvo } = useCreateConversation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
requestCreateConvo(product);
|
||||
}, [product]);
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className="relative active:opacity-50 text-left">
|
||||
<button
|
||||
onClick={() => requestCreateConvo(product)}
|
||||
className="relative active:opacity-50 text-left"
|
||||
>
|
||||
<img
|
||||
src={image_url ?? ""}
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="w-full h-full rounded-[8px] bg-gray-200 group-hover:opacity-75 object-cover object-center"
|
||||
/>
|
||||
|
||||
@ -1,33 +1,27 @@
|
||||
import Image from "next/image";
|
||||
import { Product } from "@/_models/Product";
|
||||
import GenerateImageCard from "../GenerateImageCard";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import { PhotoIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
products: ProductDetailFragment[];
|
||||
products: Product[];
|
||||
};
|
||||
|
||||
const GenerateImageList: React.FC<Props> = ({ products }) => {
|
||||
if (products.length === 0) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<div className="flex mt-4 justify-between">
|
||||
<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} />
|
||||
))}
|
||||
const GenerateImageList: React.FC<Props> = ({ products }) => (
|
||||
<>
|
||||
{products.length === 0 ? null : (
|
||||
<div className="flex items-center gap-3 mt-8 mb-2">
|
||||
<PhotoIcon width={24} height={24} className="ml-6" />
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
Generate Images
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
</>
|
||||
);
|
||||
|
||||
export default GenerateImageList;
|
||||
|
||||
@ -1,52 +1,49 @@
|
||||
import JanWelcomeTitle from "../JanWelcomeTitle";
|
||||
import { Product } from "@/_models/Product";
|
||||
import { Instance } from "mobx-state-tree";
|
||||
import { GetProductPromptsQuery, GetProductPromptsDocument } from "@/graphql";
|
||||
import { useQuery } from "@apollo/client";
|
||||
|
||||
type Props = {
|
||||
model: Instance<typeof Product>;
|
||||
onPromptSelected: (prompt: string) => void;
|
||||
};
|
||||
|
||||
export const GenerativeSampleContainer: React.FC<Props> = ({
|
||||
model,
|
||||
onPromptSelected,
|
||||
}) => {
|
||||
const { loading, error, data } = useQuery<GetProductPromptsQuery>(
|
||||
GetProductPromptsDocument,
|
||||
{
|
||||
variables: { productSlug: model.id },
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col max-w-2xl flex-shrink-0 mx-auto mt-6">
|
||||
<JanWelcomeTitle
|
||||
title={model.name}
|
||||
description={model.modelDescription ?? ""}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5">
|
||||
Create now
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
{data?.prompts.map((item) => (
|
||||
<button
|
||||
key={item.slug}
|
||||
onClick={() => onPromptSelected(item.content ?? "")}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<img
|
||||
style={{ objectFit: "cover" }}
|
||||
className="w-full h-full rounded col-span-1 flex flex-col"
|
||||
src={item.image_url ?? ""}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import JanWelcomeTitle from "../JanWelcomeTitle";
|
||||
import { GetProductPromptsQuery, GetProductPromptsDocument } from "@/graphql";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Product } from "@/_models/Product";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { currentPromptAtom } from "@/_helpers/JotaiWrapper";
|
||||
|
||||
type Props = {
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const GenerativeSampleContainer: React.FC<Props> = ({ product }) => {
|
||||
const setCurrentPrompt = useSetAtom(currentPromptAtom);
|
||||
const { data } = useQuery<GetProductPromptsQuery>(GetProductPromptsDocument, {
|
||||
variables: { productSlug: product.slug },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col max-w-2xl flex-shrink-0 mx-auto mt-6">
|
||||
<JanWelcomeTitle
|
||||
title={product.name}
|
||||
description={product.longDescription}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5">
|
||||
Create now
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
{data?.prompts.map((item) => (
|
||||
<button
|
||||
key={item.slug}
|
||||
onClick={() => setCurrentPrompt(item.content ?? "")}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<img
|
||||
style={{ objectFit: "cover" }}
|
||||
className="w-full h-full rounded col-span-1 flex flex-col"
|
||||
src={item.image_url ?? ""}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerativeSampleContainer;
|
||||
|
||||
22
web-client/app/_components/HamburgerButton/index.tsx
Normal file
22
web-client/app/_components/HamburgerButton/index.tsx
Normal 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);
|
||||
@ -1,61 +1,16 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||
import MobileMenuPane from "../MobileMenuPane";
|
||||
import ConfirmSignOutModal from "../ConfirmSignOutModal";
|
||||
import useSignOut from "@/_hooks/useSignOut";
|
||||
import { ThemeChanger } from "../ChangeTheme";
|
||||
import UserProfileDropDown from "../UserProfileDropDown";
|
||||
import useSignIn from "@/_hooks/useSignIn";
|
||||
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { signInWithKeyCloak } = useSignIn();
|
||||
const { user, loading } = useGetCurrentUser();
|
||||
const { signOut } = useSignOut();
|
||||
|
||||
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;
|
||||
import React from "react";
|
||||
import UserProfileDropDown from "../UserProfileDropDown";
|
||||
import LoginButton from "../LoginButton";
|
||||
import HamburgerButton from "../HamburgerButton";
|
||||
|
||||
const Header: React.FC = () => (
|
||||
<header className="flex border-b-[1px] border-gray-200 p-3 dark:bg-gray-800">
|
||||
<nav className="flex-1 justify-center">
|
||||
<HamburgerButton />
|
||||
</nav>
|
||||
<LoginButton />
|
||||
<UserProfileDropDown />
|
||||
</header>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
const HistoryEmpty: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full items-center justify-center gap-4">
|
||||
<Image
|
||||
src={"/icons/chats-circle-light.svg"}
|
||||
width={50}
|
||||
height={50}
|
||||
alt=""
|
||||
/>
|
||||
<p className="text-sm leading-5 text-center text-[#9CA3AF]">
|
||||
Jan allows you to use 100s of AIs on your mobile phone
|
||||
</p>
|
||||
<Link
|
||||
href="/ai"
|
||||
className="bg-[#1F2A37] py-[10px] px-5 gap-2 rounded-[8px] text-[14px] font-medium leading-[21px] text-white"
|
||||
>
|
||||
Explore AIs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(HistoryEmpty);
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
const HistoryEmpty: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full items-center justify-center gap-4">
|
||||
<Image
|
||||
src={"/icons/chats-circle-light.svg"}
|
||||
width={50}
|
||||
height={50}
|
||||
alt=""
|
||||
/>
|
||||
<p className="text-sm leading-5 text-center text-[#9CA3AF]">
|
||||
Jan allows you to use 100s of AIs on your mobile phone
|
||||
</p>
|
||||
<Link
|
||||
href="/ai"
|
||||
className="bg-[#1F2A37] py-[10px] px-5 gap-2 rounded-[8px] text-[14px] font-medium leading-[21px] text-white"
|
||||
>
|
||||
Explore AIs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(HistoryEmpty);
|
||||
|
||||
@ -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 JanImage from "../JanImage";
|
||||
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 { Conversation } from "@/_models/Conversation";
|
||||
|
||||
type Props = {
|
||||
conversationId: string;
|
||||
conversation: Conversation;
|
||||
avatarUrl: string;
|
||||
name: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
const HistoryItem: React.FC<Props> = observer(
|
||||
({ conversationId, avatarUrl, name, updatedAt }) => {
|
||||
const { historyStore } = useStore();
|
||||
const send = true; // TODO store this in mobx
|
||||
const onClick = () => {
|
||||
historyStore.setActiveConversationId(conversationId);
|
||||
};
|
||||
const HistoryItem: React.FC<Props> = ({
|
||||
conversation,
|
||||
avatarUrl,
|
||||
name,
|
||||
updatedAt,
|
||||
}) => {
|
||||
const conversationStates = useAtomValue(conversationStatesAtom);
|
||||
const activeConvoId = useAtomValue(getActiveConvoIdAtom);
|
||||
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
|
||||
const isSelected = activeConvoId === conversation.id;
|
||||
|
||||
const conversation = historyStore.getConversationById(conversationId);
|
||||
const isSelected = historyStore.activeConversationId === conversationId;
|
||||
const backgroundColor = isSelected
|
||||
? "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;
|
||||
const onClick = () => {
|
||||
if (activeConvoId !== conversation.id) {
|
||||
setActiveConvoId(conversation.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex flex-row items-center gap-[10px] rounded-lg p-2 ${backgroundColor}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
className="rounded-full aspect-square object-cover"
|
||||
src={avatarUrl}
|
||||
width={36}
|
||||
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)}
|
||||
const backgroundColor = isSelected
|
||||
? "bg-gray-100 dark:bg-gray-700"
|
||||
: "bg-white dark:bg-gray-500";
|
||||
|
||||
let rightImageUrl: string | undefined;
|
||||
if (conversationStates[conversation.id]?.waitingForResponse === true) {
|
||||
rightImageUrl = "/icons/loading.svg";
|
||||
} else if (
|
||||
conversation &&
|
||||
conversation.product.type === ProductType.GenerativeArt &&
|
||||
conversation.lastImageUrl &&
|
||||
conversation.lastImageUrl.trim().startsWith("https://")
|
||||
) {
|
||||
rightImageUrl = conversation.lastImageUrl;
|
||||
}
|
||||
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
{send ? (
|
||||
<>
|
||||
{rightImageUrl != null ? (
|
||||
<JanImage
|
||||
imageUrl={rightImageUrl ?? ""}
|
||||
className="rounded"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
{rightImageUrl != null ? (
|
||||
<JanImage
|
||||
imageUrl={rightImageUrl ?? ""}
|
||||
className="rounded"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryItem;
|
||||
|
||||
@ -1,58 +1,41 @@
|
||||
import HistoryItem from "../HistoryItem";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
interface IHistoryListProps {
|
||||
searchText: string;
|
||||
}
|
||||
const HistoryList: React.FC<IHistoryListProps> = observer((props) => {
|
||||
const { historyStore } = useStore();
|
||||
const [showHistory, setShowHistory] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full pl-1 pt-3">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center justify-between px-2"
|
||||
>
|
||||
<h2 className="text-[#9CA3AF] font-bold text-[12px] leading-[12px]">
|
||||
HISTORY
|
||||
</h2>
|
||||
<Image
|
||||
className={`${showHistory ? "" : "rotate-180"}`}
|
||||
src={"/icons/unicorn_angle-up.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
<div className={`flex-col gap-1 ${showHistory ? "flex" : "hidden"}`}>
|
||||
{historyStore.conversations
|
||||
.filter(
|
||||
(e) =>
|
||||
props.searchText === "" ||
|
||||
e.product.name
|
||||
.toLowerCase()
|
||||
.includes(props.searchText.toLowerCase()) ||
|
||||
e.product.description
|
||||
?.toLowerCase()
|
||||
.includes(props.searchText.toLowerCase())
|
||||
)
|
||||
.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;
|
||||
import HistoryItem from "../HistoryItem";
|
||||
import { useEffect, useState } from "react";
|
||||
import ExpandableHeader from "../ExpandableHeader";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { userConversationsAtom } from "@/_helpers/JotaiWrapper";
|
||||
import useGetUserConversations from "@/_hooks/useGetUserConversations";
|
||||
|
||||
const HistoryList: React.FC = () => {
|
||||
const conversations = useAtomValue(userConversationsAtom);
|
||||
const [expand, setExpand] = useState<boolean>(true);
|
||||
const { getUserConversations } = useGetUserConversations();
|
||||
|
||||
useEffect(() => {
|
||||
getUserConversations();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow pt-3 gap-2">
|
||||
<ExpandableHeader
|
||||
title="CHAT HISTORY"
|
||||
expanded={expand}
|
||||
onClick={() => setExpand(!expand)}
|
||||
/>
|
||||
<div
|
||||
className={`flex flex-col gap-1 mt-1 ${!expand ? "hidden " : "block"}`}
|
||||
>
|
||||
{conversations.map((convo) => (
|
||||
<HistoryItem
|
||||
key={convo.id}
|
||||
conversation={convo}
|
||||
avatarUrl={convo.product.avatarUrl}
|
||||
name={convo.product.name}
|
||||
updatedAt={convo.updatedAt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryList;
|
||||
|
||||
@ -1,146 +1,23 @@
|
||||
import SendButton from "../SendButton";
|
||||
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";
|
||||
"use client";
|
||||
|
||||
type Props = {
|
||||
prefillPrompt: string;
|
||||
};
|
||||
import BasicPromptInput from "../BasicPromptInput";
|
||||
import BasicPromptAccessories from "../BasicPromptAccessories";
|
||||
import { showingAdvancedPromptAtom } from "@/_helpers/JotaiWrapper";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
export const InputToolbar: React.FC<Props> = observer(({ prefillPrompt }) => {
|
||||
const { historyStore } = useStore();
|
||||
const [text, setText] = useState(prefillPrompt);
|
||||
const { user } = useGetCurrentUser();
|
||||
const { signInWithKeyCloak } = useSignIn();
|
||||
const InputToolbar: React.FC = () => {
|
||||
const showingAdvancedPrompt = useAtomValue(showingAdvancedPromptAtom);
|
||||
|
||||
const [createMessageMutation] = useMutation<CreateMessageMutation>(
|
||||
CreateMessageDocument
|
||||
);
|
||||
|
||||
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 (showingAdvancedPrompt) {
|
||||
return <div />;
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
className={`${
|
||||
historyStore.showAdvancedPrompt ? "hidden" : "block"
|
||||
} 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 className="mx-3 mb-3 flex-none overflow-hidden shadow-sm ring-1 ring-inset ring-gray-300 rounded-lg dark:bg-gray-800">
|
||||
<BasicPromptInput />
|
||||
<BasicPromptAccessories />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default InputToolbar;
|
||||
|
||||
19
web-client/app/_components/JanLogo/index.tsx
Normal file
19
web-client/app/_components/JanLogo/index.tsx
Normal 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);
|
||||
@ -1,20 +1,20 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const JanWelcomeTitle: React.FC<Props> = ({ title, description }) => (
|
||||
<div className="flex items-center flex-col gap-3">
|
||||
<h2 className="text-[22px] leading-7 font-bold">{title}</h2>
|
||||
<span className="flex items-center text-xs leading-[18px]">
|
||||
Operated by
|
||||
<Image src={"/icons/ico_logo.svg"} width={42} height={22} alt="" />
|
||||
</span>
|
||||
<span className="text-sm text-center font-normal">{description}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(JanWelcomeTitle);
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const JanWelcomeTitle: React.FC<Props> = ({ title, description }) => (
|
||||
<div className="flex items-center flex-col gap-3">
|
||||
<h2 className="text-[22px] leading-7 font-bold">{title}</h2>
|
||||
<span className="flex items-center text-xs leading-[18px]">
|
||||
Operated by
|
||||
<Image src={"/icons/ico_logo.svg"} width={42} height={22} alt="" />
|
||||
</span>
|
||||
<span className="text-sm text-center font-normal">{description}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(JanWelcomeTitle);
|
||||
|
||||
24
web-client/app/_components/LeftContainer/index.tsx
Normal file
24
web-client/app/_components/LeftContainer/index.tsx
Normal 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;
|
||||
20
web-client/app/_components/LeftSidebar/index.tsx
Normal file
20
web-client/app/_components/LeftSidebar/index.tsx
Normal 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;
|
||||
27
web-client/app/_components/LoginButton/index.tsx
Normal file
27
web-client/app/_components/LoginButton/index.tsx
Normal 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;
|
||||
13
web-client/app/_components/MainChat/index.tsx
Normal file
13
web-client/app/_components/MainChat/index.tsx
Normal 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;
|
||||
11
web-client/app/_components/MainChatHeader/index.tsx
Normal file
11
web-client/app/_components/MainChatHeader/index.tsx
Normal 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;
|
||||
@ -1,23 +1,21 @@
|
||||
import AdvancedPromptText from "../AdvancedPromptText";
|
||||
import AdvancedPromptImageUpload from "../AdvancedPromptImageUpload";
|
||||
import AdvancedPromptResolution from "../AdvancedPromptResolution";
|
||||
import AdvancedPromptGenerationParams from "../AdvancedPromptGenerationParams";
|
||||
import { FieldValues, UseFormRegister } from "react-hook-form";
|
||||
|
||||
type Props = {
|
||||
register: UseFormRegister<FieldValues>;
|
||||
};
|
||||
|
||||
export const MenuAdvancedPrompt: React.FC<Props> = ({ register }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<AdvancedPromptText register={register} />
|
||||
<hr className="my-5" />
|
||||
<AdvancedPromptImageUpload register={register} />
|
||||
<hr className="my-5" />
|
||||
<AdvancedPromptResolution />
|
||||
<hr className="my-5" />
|
||||
<AdvancedPromptGenerationParams />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import AdvancedPromptText from "../AdvancedPromptText";
|
||||
import AdvancedPromptImageUpload from "../AdvancedPromptImageUpload";
|
||||
import AdvancedPromptResolution from "../AdvancedPromptResolution";
|
||||
import AdvancedPromptGenerationParams from "../AdvancedPromptGenerationParams";
|
||||
import { FieldValues, UseFormRegister } from "react-hook-form";
|
||||
|
||||
type Props = {
|
||||
register: UseFormRegister<FieldValues>;
|
||||
};
|
||||
|
||||
export const MenuAdvancedPrompt: React.FC<Props> = ({ register }) => (
|
||||
<div className="flex flex-col flex-1 p-3 gap-[10px] overflow-x-hidden scroll">
|
||||
<AdvancedPromptText register={register} />
|
||||
<hr className="my-5" />
|
||||
<AdvancedPromptImageUpload register={register} />
|
||||
<hr className="my-5" />
|
||||
<AdvancedPromptResolution />
|
||||
<hr className="my-5" />
|
||||
<AdvancedPromptGenerationParams />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,56 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
|
||||
|
||||
type Props = {
|
||||
onLogOutClick: () => void;
|
||||
};
|
||||
|
||||
export const MenuHeader: React.FC<Props> = ({ onLogOutClick }) => {
|
||||
const { user } = useGetCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
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">
|
||||
<div className="py-3 px-4 gap-2 flex flex-col">
|
||||
<h2 className="text-[20px] leading-[25px] tracking-[-0.4px] font-bold text-[#111928]">
|
||||
{user.displayName}
|
||||
</h2>
|
||||
<span className="text-[#6B7280] leading-[17.5px] text-sm">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<button
|
||||
onClick={onLogOutClick}
|
||||
className="px-4 py-3 text-sm w-full text-left text-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
<hr />
|
||||
<div className="flex gap-2 px-4 py-2 justify-center items-center">
|
||||
<Link href="/privacy">
|
||||
<span className="text-[#6B7280] text-xs">Privacy</span>
|
||||
</Link>
|
||||
<div className="w-1 h-1 bg-[#D9D9D9] rounded-lg" />
|
||||
<Link href="/support">
|
||||
<span className="text-[#6B7280] text-xs">Support</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
import Link from "next/link";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { showConfirmSignOutModalAtom } from "@/_helpers/JotaiWrapper";
|
||||
|
||||
export const MenuHeader: React.FC = () => {
|
||||
const setShowConfirmSignOutModal = useSetAtom(showConfirmSignOutModalAtom);
|
||||
const { user } = useGetCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
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">
|
||||
<div className="py-3 px-4 gap-2 flex flex-col">
|
||||
<h2 className="text-[20px] leading-[25px] tracking-[-0.4px] font-bold text-[#111928]">
|
||||
{user.displayName}
|
||||
</h2>
|
||||
<span className="text-[#6B7280] leading-[17.5px] text-sm">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<button
|
||||
onClick={() => setShowConfirmSignOutModal(true)}
|
||||
className="px-4 py-3 text-sm w-full text-left text-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
<hr />
|
||||
<div className="flex gap-2 px-4 py-2 justify-center items-center">
|
||||
<Link href="/privacy">
|
||||
<span className="text-[#6B7280] text-xs">Privacy</span>
|
||||
</Link>
|
||||
<div className="w-1 h-1 bg-[#D9D9D9] rounded-lg" />
|
||||
<Link href="/support">
|
||||
<span className="text-[#6B7280] text-xs">Support</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
@ -21,7 +21,7 @@ const MobileDownload = () => {
|
||||
{/** Buttons */}
|
||||
<div className="flex w-full mt-4 justify-between">
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_IOS || "#"}
|
||||
href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_IOS || ""}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-[48%]"
|
||||
@ -42,7 +42,7 @@ const MobileDownload = () => {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_ANDROID || "#"}
|
||||
href={process.env.NEXT_PUBLIC_DOWNLOAD_APP_ANDROID || ""}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-[48%]"
|
||||
|
||||
@ -1,52 +1,60 @@
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import Image from "next/image";
|
||||
import { showingMobilePaneAtom } from "@/_helpers/JotaiWrapper";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
const MobileMenuPane: React.FC = () => {
|
||||
const [show, setShow] = useAtom(showingMobilePaneAtom);
|
||||
let loginRef = useRef(null);
|
||||
|
||||
const MobileMenuPane: React.FC<Props> = ({ open, setOpen }) => (
|
||||
<Dialog as="div" className="md:hidden" open={open} onClose={setOpen}>
|
||||
<div className="fixed inset-0 z-10" />
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="#" className="-m-1.5 p-1.5">
|
||||
<span className="sr-only">Your Company</span>
|
||||
<Image
|
||||
className="h-8 w-auto"
|
||||
width={32}
|
||||
height={32}
|
||||
src="/icons/app_icon.svg"
|
||||
alt=""
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 rounded-md p-2.5 text-gray-700"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span className="sr-only">Close menu</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 flow-root">
|
||||
<div className="-my-6 divide-y divide-gray-500/10">
|
||||
<div className="space-y-2 py-6"/>
|
||||
<div className="py-6">
|
||||
<a
|
||||
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>
|
||||
return (
|
||||
<Dialog
|
||||
as="div"
|
||||
open={show}
|
||||
initialFocus={loginRef}
|
||||
onClose={() => setShow(false)}
|
||||
>
|
||||
<div className="fixed inset-0 z-10" />
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="#" className="-m-1.5 p-1.5">
|
||||
<span className="sr-only">Your Company</span>
|
||||
<Image
|
||||
className="h-8 w-auto"
|
||||
width={32}
|
||||
height={32}
|
||||
src="/icons/app_icon.svg"
|
||||
alt=""
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 rounded-md p-2.5 text-gray-700"
|
||||
onClick={() => setShow(false)}
|
||||
>
|
||||
<span className="sr-only">Close menu</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 flow-root">
|
||||
<div className="-my-6 divide-y divide-gray-500/10">
|
||||
<div className="space-y-2 py-6" />
|
||||
<div className="py-6">
|
||||
<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>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileMenuPane;
|
||||
|
||||
@ -1,36 +1,9 @@
|
||||
import { FC, useRef } from "react";
|
||||
import OverviewPane from "../OverviewPane";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
import { Draggable } from "../Draggable";
|
||||
|
||||
type Props = {
|
||||
onPromptClick?: (prompt: string) => void;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
import OverviewPane from "../OverviewPane";
|
||||
|
||||
const ModelDetailSideBar: React.FC = () => (
|
||||
<div className="flex w-[473px] h-full border-l-[1px] border-[#E5E7EB]">
|
||||
<OverviewPane />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ModelDetailSideBar;
|
||||
|
||||
@ -1,42 +1,42 @@
|
||||
import Image from "next/image";
|
||||
import ModelInfoItem from "../ModelInfoItem";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
modelName: string;
|
||||
inferenceTime: string;
|
||||
hardware: string;
|
||||
pricing: string;
|
||||
};
|
||||
|
||||
const ModelInfo: React.FC<Props> = ({
|
||||
modelName,
|
||||
inferenceTime,
|
||||
hardware,
|
||||
pricing,
|
||||
}) => (
|
||||
<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">
|
||||
{modelName} is available via Jan API
|
||||
</h2>
|
||||
<div className="flex items-start gap-4">
|
||||
<ModelInfoItem description={inferenceTime} name="Inference Time" />
|
||||
<ModelInfoItem description={hardware} name="Hardware" />
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex justify-between items-center ">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl tracking-[-0.4px] font-semibold">{pricing}</h2>
|
||||
<span className="text-xs leading-[18px] text-[#6B7280]">
|
||||
Average Cost / Call
|
||||
</span>
|
||||
</div>
|
||||
<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="" />
|
||||
<span className="text-white text-sm font-medium">Get API Key</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(ModelInfo);
|
||||
import Image from "next/image";
|
||||
import ModelInfoItem from "../ModelInfoItem";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
modelName: string;
|
||||
inferenceTime: string;
|
||||
hardware: string;
|
||||
pricing: string;
|
||||
};
|
||||
|
||||
const ModelInfo: React.FC<Props> = ({
|
||||
modelName,
|
||||
inferenceTime,
|
||||
hardware,
|
||||
pricing,
|
||||
}) => (
|
||||
<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">
|
||||
{modelName} is available via Jan API
|
||||
</h2>
|
||||
<div className="flex items-start gap-4">
|
||||
<ModelInfoItem description={inferenceTime} name="Inference Time" />
|
||||
<ModelInfoItem description={hardware} name="Hardware" />
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex justify-between items-center ">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl tracking-[-0.4px] font-semibold">{pricing}</h2>
|
||||
<span className="text-xs leading-[18px] text-[#6B7280]">
|
||||
Average Cost / Call
|
||||
</span>
|
||||
</div>
|
||||
<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="" />
|
||||
<span className="text-white text-sm font-medium">Get API Key</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(ModelInfo);
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const ModelInfoItem: React.FC<Props> = ({ description, name }) => (
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-gray-500 font-normal text-sm">{name}</span>
|
||||
<span className="font-normal text-sm">{description}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(ModelInfoItem);
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const ModelInfoItem: React.FC<Props> = ({ description, name }) => (
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-gray-500 font-normal text-sm">{name}</span>
|
||||
<span className="font-normal text-sm">{description}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(ModelInfoItem);
|
||||
|
||||
@ -1,44 +1,46 @@
|
||||
import Image from "next/image";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
type Props = {
|
||||
onDeleteClick: () => void;
|
||||
onCreateConvClick: () => void;
|
||||
};
|
||||
|
||||
const ModelMenu: React.FC<Props> = observer(
|
||||
({ onDeleteClick, onCreateConvClick }) => {
|
||||
const { historyStore } = useStore();
|
||||
|
||||
const onModelInfoClick = useCallback(() => {
|
||||
historyStore.toggleModelDetail();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onCreateConvClick}>
|
||||
<Image src="/icons/unicorn_plus.svg" width={24} height={24} alt="" />
|
||||
</button>
|
||||
<button onClick={onDeleteClick}>
|
||||
<Image src="/icons/unicorn_trash.svg" width={24} height={24} alt="" />
|
||||
</button>
|
||||
<button onClick={onModelInfoClick}>
|
||||
<Image
|
||||
src={
|
||||
historyStore.showModelDetail
|
||||
? "/icons/ic_sidebar_fill.svg"
|
||||
: "/icons/ic_sidebar.svg"
|
||||
}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ModelMenu;
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
currentProductAtom,
|
||||
showConfirmDeleteConversationModalAtom,
|
||||
showingProductDetailAtom,
|
||||
} from "@/_helpers/JotaiWrapper";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import useCreateConversation from "@/_hooks/useCreateConversation";
|
||||
|
||||
const ModelMenu: React.FC = () => {
|
||||
const currentProduct = useAtomValue(currentProductAtom);
|
||||
const [active, setActive] = useAtom(showingProductDetailAtom);
|
||||
const { requestCreateConvo } = useCreateConversation();
|
||||
const setShowConfirmDeleteConversationModal = useSetAtom(
|
||||
showConfirmDeleteConversationModalAtom
|
||||
);
|
||||
|
||||
const onCreateConvoClick = () => {
|
||||
if (!currentProduct) return;
|
||||
requestCreateConvo(currentProduct, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => onCreateConvoClick()}>
|
||||
<PlusIcon width={24} height={24} color="#9CA3AF" />
|
||||
</button>
|
||||
<button onClick={() => setShowConfirmDeleteConversationModal(true)}>
|
||||
<TrashIcon width={24} height={24} color="#9CA3AF" />
|
||||
</button>
|
||||
<button onClick={() => setActive(!active)}>
|
||||
<Image
|
||||
src={active ? "/icons/ic_sidebar_fill.svg" : "/icons/ic_sidebar.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelMenu;
|
||||
|
||||
@ -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;
|
||||
@ -1,94 +1,58 @@
|
||||
import { GetProductPromptsDocument, GetProductPromptsQuery } from "@/graphql";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
"use client";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
technicalVersion?: string | null;
|
||||
technicalURL?: string | null;
|
||||
onPromptClick?: (prompt: string) => void;
|
||||
inAIModel?: number;
|
||||
};
|
||||
import { useAtomValue } from "jotai";
|
||||
import TryItYourself from "./TryItYourself";
|
||||
import React from "react";
|
||||
import { currentProductAtom } from "@/_helpers/JotaiWrapper";
|
||||
|
||||
const OverviewPane: React.FC<Props> = ({
|
||||
slug,
|
||||
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]);
|
||||
const OverviewPane: React.FC = () => {
|
||||
const product = useAtomValue(currentProductAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full flex flex-auto flex-col gap-6 overflow-x-hidden scroll"
|
||||
ref={ref}
|
||||
style={!inAIModel ? { height: `${height}px` } : { height: "100%" }}
|
||||
>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<h2 className="text-black font-bold">About this AI</h2>
|
||||
<p className={`text-[#6B7280] ${read ? "hidden-text-model" : ""}`}>
|
||||
{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">
|
||||
<div className="scroll overflow-y-auto">
|
||||
<div className="flex flex-col flex-grow gap-6 m-3">
|
||||
<AboutProductItem
|
||||
title={"About this AI"}
|
||||
value={product?.description ?? ""}
|
||||
/>
|
||||
<SmallItem title={"Model Version"} value={product?.version ?? ""} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[#6B7280]">Model URL</span>
|
||||
<a
|
||||
className="text-[#1C64F2] break-all pr-10"
|
||||
href={technicalURL || "#"}
|
||||
className="text-[#1C64F2]"
|
||||
href={product?.modelUrl ?? "#"}
|
||||
target="_blank_"
|
||||
>
|
||||
{technicalURL}
|
||||
{product?.modelUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<TryItYourself />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
25
web-client/app/_components/PrimaryButton/index.tsx
Normal file
25
web-client/app/_components/PrimaryButton/index.tsx
Normal 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;
|
||||
29
web-client/app/_components/ProductOverview/index.tsx
Normal file
29
web-client/app/_components/ProductOverview/index.tsx
Normal 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;
|
||||
14
web-client/app/_components/RightContainer/index.tsx
Normal file
14
web-client/app/_components/RightContainer/index.tsx
Normal 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;
|
||||
@ -1,46 +1,44 @@
|
||||
import { Instance } from "mobx-state-tree";
|
||||
import { Product } from "@/_models/Product";
|
||||
import JanWelcomeTitle from "../JanWelcomeTitle";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GetProductPromptsDocument, GetProductPromptsQuery } from "@/graphql";
|
||||
|
||||
type Props = {
|
||||
model: Instance<typeof Product>;
|
||||
onPromptSelected: (prompt: string) => void;
|
||||
};
|
||||
|
||||
const SampleLlmContainer: React.FC<Props> = ({ model, onPromptSelected }) => {
|
||||
const { loading, error, data } = useQuery<GetProductPromptsQuery>(
|
||||
GetProductPromptsDocument,
|
||||
{
|
||||
variables: { productSlug: model.id },
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col max-w-sm flex-shrink-0 gap-9 items-center pt-6 mx-auto">
|
||||
<JanWelcomeTitle
|
||||
title={model.name}
|
||||
description={model.description ?? ""}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5">
|
||||
Try now
|
||||
</h2>
|
||||
<div className="flex flex-col">
|
||||
{data?.prompts.map((item) => (
|
||||
<button
|
||||
onClick={() => onPromptSelected(item.content ?? "")}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SampleLlmContainer;
|
||||
import JanWelcomeTitle from "../JanWelcomeTitle";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GetProductPromptsDocument, GetProductPromptsQuery } from "@/graphql";
|
||||
import { Product } from "@/_models/Product";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { currentPromptAtom } from "@/_helpers/JotaiWrapper";
|
||||
|
||||
type Props = {
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const SampleLlmContainer: React.FC<Props> = ({ product }) => {
|
||||
const setCurrentPrompt = useSetAtom(currentPromptAtom);
|
||||
const { data } = useQuery<GetProductPromptsQuery>(GetProductPromptsDocument, {
|
||||
variables: { productSlug: product.slug },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col max-w-sm flex-shrink-0 gap-9 items-center pt-6 mx-auto">
|
||||
<JanWelcomeTitle
|
||||
title={product.name}
|
||||
description={product.description ?? ""}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl leading-6 tracking-[-0.4px] mb-5">
|
||||
Try now
|
||||
</h2>
|
||||
<div className="flex flex-col">
|
||||
{data?.prompts.map((item) => (
|
||||
<button
|
||||
onClick={() => setCurrentPrompt(item.content ?? "")}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SampleLlmContainer;
|
||||
|
||||
@ -1,24 +1,30 @@
|
||||
import Image from "next/image";
|
||||
|
||||
interface ISearchBarProps {
|
||||
onTextChanged: (text: string) => void;
|
||||
}
|
||||
const SearchBar: React.FC<ISearchBarProps> = (props) => {
|
||||
return (
|
||||
<div className="relative mt-3 flex items-center w-full">
|
||||
<div className="absolute top-0 left-2 h-full flex items-center">
|
||||
<Image src={"/icons/search.svg"} width={16} height={16} alt="" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
placeholder="Search (⌘K)"
|
||||
onChange={(e) => props.onTextChanged(e.target.value)}
|
||||
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;
|
||||
import { searchAtom } from "@/_helpers/JotaiWrapper";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
const SearchBar: React.FC = () => {
|
||||
const setText = useSetAtom(searchAtom);
|
||||
|
||||
return (
|
||||
<div className="relative mx-3 mt-3 flex items-center">
|
||||
<div className="absolute top-0 left-2 h-full flex items-center">
|
||||
<MagnifyingGlassIcon
|
||||
width={16}
|
||||
height={16}
|
||||
color="#3C3C43"
|
||||
opacity={0.6}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
placeholder="Search (⌘K)"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
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;
|
||||
|
||||
18
web-client/app/_components/SecondaryButton/index.tsx
Normal file
18
web-client/app/_components/SecondaryButton/index.tsx
Normal 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;
|
||||
@ -1,11 +1,19 @@
|
||||
import {
|
||||
currentConvoStateAtom,
|
||||
currentPromptAtom,
|
||||
} from "@/_helpers/JotaiWrapper";
|
||||
import useSendChatMessage from "@/_hooks/useSendChatMessage";
|
||||
import { useAtomValue } from "jotai";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
const SendButton: React.FC = () => {
|
||||
const currentPrompt = useAtomValue(currentPromptAtom);
|
||||
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 = {
|
||||
backgroundColor: "#FACA15",
|
||||
};
|
||||
@ -16,7 +24,7 @@ const SendButton: React.FC<Props> = ({ onClick, disabled = false }) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onClick={sendChatMessage}
|
||||
style={disabled ? disabledStyle : enabledStyle}
|
||||
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"
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import useCreateConversation from "@/_hooks/useCreateConversation";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import Image from "next/image";
|
||||
import { Product } from "@/_models/Product";
|
||||
|
||||
type Props = {
|
||||
product: ProductDetailFragment;
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const ShortcutItem: React.FC<Props> = ({ product }) => {
|
||||
@ -14,17 +15,22 @@ const ShortcutItem: React.FC<Props> = ({ product }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="flex items-center gap-2" onClick={onClickHandler}>
|
||||
{product.image_url && (
|
||||
<img
|
||||
src={product.image_url}
|
||||
<button
|
||||
className="flex items-center gap-2 mx-1 p-2"
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
{product.avatarUrl && (
|
||||
<Image
|
||||
width={36}
|
||||
height={36}
|
||||
src={product.avatarUrl}
|
||||
className="w-9 aspect-square rounded-full"
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col text-sm leading-[20px]">
|
||||
<span className="text-[#111928] dark:text-white">{product.name}</span>
|
||||
</div>
|
||||
<span className="text-gray-900 dark:text-white font-normal text-sm">
|
||||
{product.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,42 +1,52 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ShortcutItem from "../ShortcutItem";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import { GetProductsDocument, GetProductsQuery } 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 = {
|
||||
products: ProductDetailFragment[];
|
||||
};
|
||||
const ShortcutList: React.FC = () => {
|
||||
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 }) => {
|
||||
const [expand, setExpand] = React.useState<boolean>(true);
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div className="flex flex-col w-full px-3 pt-3">
|
||||
<button
|
||||
<div className="flex flex-col mt-6 gap-2">
|
||||
<ExpandableHeader
|
||||
title="START A NEW CHAT"
|
||||
expanded={expand}
|
||||
onClick={() => setExpand(!expand)}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<h2 className="text-[#9CA3AF] font-bold text-xs leading-[12px]">
|
||||
SHORTCUTS
|
||||
</h2>
|
||||
<Image
|
||||
className={`${expand ? "" : "rotate-180"}`}
|
||||
src={"/icons/unicorn_angle-up.svg"}
|
||||
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>
|
||||
/>
|
||||
{expand ? (
|
||||
<div className="flex flex-row mx-1 items-center rounded-lg hover:bg-hover-light">
|
||||
{featuredProducts.map((product) => (
|
||||
<ShortcutItem key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default ShortcutList;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
@ -1,105 +1,72 @@
|
||||
import Image from "next/image";
|
||||
import JanImage from "../JanImage";
|
||||
import { displayDate } from "@/_utils/datetime";
|
||||
import Link from "next/link";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
|
||||
import {
|
||||
CreateMessageMutation,
|
||||
CreateMessageDocument,
|
||||
GenerateImageMutation,
|
||||
GenerateImageDocument,
|
||||
} from "@/graphql";
|
||||
import { useMutation } from "@apollo/client";
|
||||
|
||||
type Props = {
|
||||
avatarUrl?: string;
|
||||
senderName: string;
|
||||
text?: string;
|
||||
createdAt: number;
|
||||
imageUrls: string[];
|
||||
};
|
||||
|
||||
const SimpleImageMessage: React.FC<Props> = ({
|
||||
avatarUrl = "",
|
||||
senderName,
|
||||
imageUrls,
|
||||
text,
|
||||
createdAt,
|
||||
}) => {
|
||||
const { historyStore } = useStore();
|
||||
const { user } = useGetCurrentUser();
|
||||
const [createMessageMutation] = useMutation<CreateMessageMutation>(
|
||||
CreateMessageDocument
|
||||
);
|
||||
const [imageGenerationMutation] = useMutation<GenerateImageMutation>(
|
||||
GenerateImageDocument
|
||||
);
|
||||
|
||||
const onRegenerate = () => {
|
||||
if (!user) {
|
||||
// TODO: we should show an error here
|
||||
return;
|
||||
}
|
||||
|
||||
historyStore.sendMessage(
|
||||
createMessageMutation,
|
||||
imageGenerationMutation,
|
||||
text ?? "",
|
||||
user.id,
|
||||
senderName,
|
||||
avatarUrl
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={avatarUrl}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 justify-start items-baseline">
|
||||
<div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px]">
|
||||
{senderName}
|
||||
</div>
|
||||
<div className="text-[11px] leading-[13.2px] font-medium text-gray-400 ml-2">
|
||||
{displayDate(createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<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;
|
||||
import Image from "next/image";
|
||||
import JanImage from "../JanImage";
|
||||
import { displayDate } from "@/_utils/datetime";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
avatarUrl?: string;
|
||||
senderName: string;
|
||||
text?: string;
|
||||
createdAt: number;
|
||||
imageUrls: string[];
|
||||
};
|
||||
|
||||
const SimpleImageMessage: React.FC<Props> = ({
|
||||
avatarUrl = "",
|
||||
senderName,
|
||||
imageUrls,
|
||||
text,
|
||||
createdAt,
|
||||
}) => {
|
||||
// TODO handle regenerate image case
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={avatarUrl}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 justify-start items-baseline">
|
||||
<div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px]">
|
||||
{senderName}
|
||||
</div>
|
||||
<div className="text-[11px] leading-[13.2px] font-medium text-gray-400 ml-2">
|
||||
{displayDate(createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<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={() => sendChatMessage()}
|
||||
>
|
||||
<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;
|
||||
|
||||
@ -1,56 +1,56 @@
|
||||
import React from "react";
|
||||
import { displayDate } from "@/_utils/datetime";
|
||||
import { TextCode } from "../TextCode";
|
||||
import { getMessageCode } from "@/_utils/message";
|
||||
|
||||
type Props = {
|
||||
avatarUrl?: string;
|
||||
senderName: string;
|
||||
createdAt: number;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const SimpleTextMessage: React.FC<Props> = ({
|
||||
senderName,
|
||||
createdAt,
|
||||
avatarUrl = "",
|
||||
text = "",
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={avatarUrl}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
/>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex gap-1 justify-start items-baseline">
|
||||
<div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px] dark:text-[#d1d5db]">
|
||||
{senderName}
|
||||
</div>
|
||||
<div className="text-[11px] leading-[13.2px] font-medium text-gray-400">
|
||||
{displayDate(createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
{text.includes("```") ? (
|
||||
getMessageCode(text).map((item, i) => (
|
||||
<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]">
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SimpleTextMessage);
|
||||
import React from "react";
|
||||
import { displayDate } from "@/_utils/datetime";
|
||||
import { TextCode } from "../TextCode";
|
||||
import { getMessageCode } from "@/_utils/message";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
avatarUrl: string;
|
||||
senderName: string;
|
||||
createdAt: number;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const SimpleTextMessage: React.FC<Props> = ({
|
||||
senderName,
|
||||
createdAt,
|
||||
avatarUrl = "",
|
||||
text = "",
|
||||
}) => (
|
||||
<div className="flex items-start gap-2 ml-3">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={avatarUrl}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
/>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex gap-1 justify-start items-baseline">
|
||||
<div className="text-[#1B1B1B] text-[13px] font-extrabold leading-[15.2px] dark:text-[#d1d5db]">
|
||||
{senderName}
|
||||
</div>
|
||||
<div className="text-[11px] leading-[13.2px] font-medium text-gray-400">
|
||||
{displayDate(createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
{text.includes("```") ? (
|
||||
getMessageCode(text).map((item, i) => (
|
||||
<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: text }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(SimpleTextMessage);
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import useCreateConversation from "@/_hooks/useCreateConversation";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import { Product } from "@/_models/Product";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
product: ProductDetailFragment;
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const Slide: React.FC<Props> = ({ product }) => {
|
||||
const { name, image_url, description } = product;
|
||||
const { name, avatarUrl, description } = product;
|
||||
const { requestCreateConvo } = useCreateConversation();
|
||||
|
||||
const onClick = () => {
|
||||
@ -17,9 +17,10 @@ const Slide: React.FC<Props> = ({ product }) => {
|
||||
return (
|
||||
<div className="w-full embla__slide h-[435px] relative">
|
||||
<Image
|
||||
className="object-cover w-full h-full embla__slide__img"
|
||||
src={image_url ?? ""}
|
||||
layout="fill"
|
||||
className="w-full h-auto embla__slide__img"
|
||||
src={avatarUrl}
|
||||
fill
|
||||
priority
|
||||
alt=""
|
||||
/>
|
||||
<div className="absolute bg-[rgba(0,0,0,0.7)] w-full text-white bottom-0 right-0">
|
||||
|
||||
@ -2,10 +2,10 @@ import { FC, useCallback, useEffect, useState } from "react";
|
||||
import Slide from "../Slide";
|
||||
import useEmblaCarousel, { EmblaCarouselType } from "embla-carousel-react";
|
||||
import { NextButton, PrevButton } from "../ButtonSlider";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import { Product } from "@/_models/Product";
|
||||
|
||||
type Props = {
|
||||
products: ProductDetailFragment[];
|
||||
products: Product[];
|
||||
};
|
||||
|
||||
const Slider: FC<Props> = ({ products }) => {
|
||||
@ -35,12 +35,12 @@ const Slider: FC<Props> = ({ products }) => {
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
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__container">
|
||||
{products.map((product) => {
|
||||
return <Slide key={product.slug} product={product} />;
|
||||
})}
|
||||
{products.map((product) => (
|
||||
<Slide key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="embla__buttons">
|
||||
|
||||
@ -1,19 +1,13 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { displayDate } from "@/_utils/datetime";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
import { StreamingText, useTextBuffer } from "nextjs-openai";
|
||||
import { MessageSenderType, MessageStatus } from "@/_models/ChatMessage";
|
||||
import { Role } from "@/_models/History";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { OpenAI } from "openai-streams";
|
||||
import {
|
||||
UpdateMessageDocument,
|
||||
UpdateMessageMutation,
|
||||
UpdateMessageMutationVariables,
|
||||
} from "@/graphql";
|
||||
import { TextCode } from "../TextCode";
|
||||
import { getMessageCode } from "@/_utils/message";
|
||||
import Image from "next/image";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentStreamingMessageAtom } from "@/_helpers/JotaiWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
id: string;
|
||||
avatarUrl?: string;
|
||||
senderName: string;
|
||||
createdAt: number;
|
||||
@ -25,67 +19,13 @@ const StreamTextMessage: React.FC<Props> = ({
|
||||
senderName,
|
||||
createdAt,
|
||||
avatarUrl = "",
|
||||
text = "",
|
||||
}) => {
|
||||
const [data, setData] = React.useState<any | undefined>();
|
||||
const { historyStore } = useStore();
|
||||
const conversation = historyStore?.getActiveConversation();
|
||||
const [updateMessage] = useMutation<UpdateMessageMutation>(
|
||||
UpdateMessageDocument
|
||||
);
|
||||
const [message, _] = useAtom(currentStreamingMessageAtom);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!conversation ||
|
||||
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
|
||||
return message?.text && message?.text?.length > 0 ? (
|
||||
<div className="flex items-start gap-2 ml-3">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src={avatarUrl}
|
||||
width={32}
|
||||
@ -101,9 +41,21 @@ const StreamTextMessage: React.FC<Props> = ({
|
||||
{displayDate(createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="leading-[20px] whitespace-break-spaces text-[14px] font-normal dark:text-[#d1d5db]">
|
||||
<StreamingText buffer={buffer} fade={100} />
|
||||
</div>
|
||||
{message.text.includes("```") ? (
|
||||
getMessageCode(message.text).map((item, i) => (
|
||||
<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>
|
||||
) : (
|
||||
|
||||
@ -1,35 +1,35 @@
|
||||
import Image from "next/image";
|
||||
type Props = {
|
||||
onTabClick: (clickedTab: "description" | "api") => void;
|
||||
tab: string;
|
||||
};
|
||||
|
||||
export const TabModelDetail: React.FC<Props> = ({ onTabClick, tab }) => {
|
||||
const btns = [
|
||||
{
|
||||
name: "api",
|
||||
icon: "/icons/unicorn_arrow.svg",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
icon: "/icons/unicorn_exclamation-circle.svg",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-[2px] rounded p-1 w-full bg-gray-200">
|
||||
{btns.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
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 ${
|
||||
tab !== item.name ? "" : "bg-white rounded shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<Image src={item.icon} width={20} height={20} alt="" />
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import Image from "next/image";
|
||||
type Props = {
|
||||
onTabClick: (clickedTab: "description" | "api") => void;
|
||||
tab: string;
|
||||
};
|
||||
|
||||
export const TabModelDetail: React.FC<Props> = ({ onTabClick, tab }) => {
|
||||
const btns = [
|
||||
{
|
||||
name: "api",
|
||||
icon: "/icons/unicorn_arrow.svg",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
icon: "/icons/unicorn_exclamation-circle.svg",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-[2px] rounded p-1 w-full bg-gray-200">
|
||||
{btns.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
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 ${
|
||||
tab !== item.name ? "" : "bg-white rounded shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<Image src={item.icon} width={20} height={20} alt="" />
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const TextCode: React.FC<Props> = ({ text }) => (
|
||||
<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">
|
||||
<button onClick={() => navigator.clipboard.writeText(text)}>
|
||||
<Image
|
||||
src={"/icons/unicorn_clipboard-alt.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
className="w-full overflow-x-hidden resize-none"
|
||||
language="jsx"
|
||||
style={atomOneDark}
|
||||
customStyle={{ padding: "12px", background: "transparent" }}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{text}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const TextCode: React.FC<Props> = ({ text }) => (
|
||||
<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">
|
||||
<button onClick={() => navigator.clipboard.writeText(text)}>
|
||||
<Image
|
||||
src={"/icons/unicorn_clipboard-alt.svg"}
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
className="w-full overflow-x-hidden resize-none"
|
||||
language="jsx"
|
||||
style={atomOneDark}
|
||||
customStyle={{ padding: "12px", background: "transparent" }}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{text}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
type Props = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TitleBlankState: React.FC<Props> = ({ title }) => {
|
||||
return (
|
||||
<h2 className="text-[#6B7280] text-[20px] leading-[25px] tracking-[-0.4px] font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
};
|
||||
type Props = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TitleBlankState: React.FC<Props> = ({ title }) => {
|
||||
return (
|
||||
<h2 className="text-[#6B7280] text-[20px] leading-[25px] tracking-[-0.4px] font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
};
|
||||
|
||||
39
web-client/app/_components/TryItYourself/index.tsx
Normal file
39
web-client/app/_components/TryItYourself/index.tsx
Normal 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;
|
||||
@ -1,102 +1,102 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { FieldValues, UseFormRegister } from "react-hook-form";
|
||||
|
||||
type Props = {
|
||||
register: UseFormRegister<FieldValues>;
|
||||
};
|
||||
|
||||
export const UploadFileImage: React.FC<Props> = ({ register }) => {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [checked, setChecked] = useState<boolean>(true);
|
||||
const [fileName, setFileName] = useState<string>("No selected file");
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (!file || file.type.split("/")[0] !== "image") return;
|
||||
|
||||
setImage(URL.createObjectURL(file));
|
||||
setFileName(file.name);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
ref.current?.click();
|
||||
};
|
||||
|
||||
const onSelectedFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
if (file.type.split("/")[0] !== "image") return;
|
||||
|
||||
setImage(URL.createObjectURL(file));
|
||||
setFileName(file.name);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setImage(null);
|
||||
setFileName("No file selected");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-[10px] py-3`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* {image ? (
|
||||
<div className="relative group">
|
||||
<Image
|
||||
style={{ width: "100%", height: "107px", objectFit: "cover" }}
|
||||
src={image}
|
||||
width={246}
|
||||
height={104}
|
||||
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)]">
|
||||
<button onClick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
) : ( */}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{/* <Image src={"/icons/ic_plus.svg"} width={14} height={14} alt="" />
|
||||
<span className="text-gray-700 font-normal text-sm">
|
||||
Drag an image here, or click to select
|
||||
</span> */}
|
||||
<input
|
||||
{...register("fileInput", { required: true })}
|
||||
// ref={ref}
|
||||
type="file"
|
||||
onChange={onSelectedFile}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
){/* } */}
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer"
|
||||
onClick={() => setChecked(!checked)}
|
||||
>
|
||||
<input
|
||||
checked={checked}
|
||||
className="rounded"
|
||||
type="checkbox"
|
||||
onChange={() => setChecked(!checked)}
|
||||
/>
|
||||
<span className="text-sm leading-5 text-[#111928] pointer-events-none">
|
||||
Crop center to fit output resolution
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React, { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { FieldValues, UseFormRegister } from "react-hook-form";
|
||||
|
||||
type Props = {
|
||||
register: UseFormRegister<FieldValues>;
|
||||
};
|
||||
|
||||
export const UploadFileImage: React.FC<Props> = ({ register }) => {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [checked, setChecked] = useState<boolean>(true);
|
||||
const [fileName, setFileName] = useState<string>("No selected file");
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (!file || file.type.split("/")[0] !== "image") return;
|
||||
|
||||
setImage(URL.createObjectURL(file));
|
||||
setFileName(file.name);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
ref.current?.click();
|
||||
};
|
||||
|
||||
const onSelectedFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
if (file.type.split("/")[0] !== "image") return;
|
||||
|
||||
setImage(URL.createObjectURL(file));
|
||||
setFileName(file.name);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setImage(null);
|
||||
setFileName("No file selected");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-[10px] py-3`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* {image ? (
|
||||
<div className="relative group">
|
||||
<Image
|
||||
style={{ width: "100%", height: "107px", objectFit: "cover" }}
|
||||
src={image}
|
||||
width={246}
|
||||
height={104}
|
||||
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)]">
|
||||
<button onClick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
) : ( */}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{/* <Image src={"/icons/ic_plus.svg"} width={14} height={14} alt="" />
|
||||
<span className="text-gray-700 font-normal text-sm">
|
||||
Drag an image here, or click to select
|
||||
</span> */}
|
||||
<input
|
||||
{...register("fileInput", { required: true })}
|
||||
// ref={ref}
|
||||
type="file"
|
||||
onChange={onSelectedFile}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
){/* } */}
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer"
|
||||
onClick={() => setChecked(!checked)}
|
||||
>
|
||||
<input
|
||||
checked={checked}
|
||||
className="rounded"
|
||||
type="checkbox"
|
||||
onChange={() => setChecked(!checked)}
|
||||
/>
|
||||
<span className="text-sm leading-5 text-[#111928] pointer-events-none">
|
||||
Crop center to fit output resolution
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
import { MenuHeader } from "../MenuHeader";
|
||||
import useGetCurrentUser from "@/_hooks/useGetCurrentUser";
|
||||
|
||||
type Props = {
|
||||
onLogOutClick: () => void;
|
||||
};
|
||||
|
||||
const UserProfileDropDown: React.FC<Props> = ({ onLogOutClick }) => {
|
||||
const UserProfileDropDown: React.FC = () => {
|
||||
const { loading, user } = useGetCurrentUser();
|
||||
|
||||
if (loading || !user) {
|
||||
@ -29,7 +27,7 @@ const UserProfileDropDown: React.FC<Props> = ({ onLogOutClick }) => {
|
||||
</h2>
|
||||
</div>
|
||||
</Popover.Button>
|
||||
<MenuHeader onLogOutClick={onLogOutClick} />
|
||||
<MenuHeader />
|
||||
</Popover>
|
||||
</Popover.Group>
|
||||
);
|
||||
|
||||
@ -1,23 +1,29 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useStore } from "@/_models/RootStore";
|
||||
|
||||
export const UserToolbar: React.FC = observer(() => {
|
||||
const { historyStore } = useStore();
|
||||
const conversation = historyStore.getActiveConversation();
|
||||
|
||||
const avatarUrl = conversation?.product.avatarUrl ?? "";
|
||||
const title = conversation?.product.name ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-1">
|
||||
<img
|
||||
className="rounded-full aspect-square w-8 h-8"
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
/>
|
||||
<span className="flex gap-[2px] leading-6 text-base font-semibold">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
"use client";
|
||||
|
||||
import { currentConversationAtom } from "@/_helpers/JotaiWrapper";
|
||||
import { useAtomValue } from "jotai";
|
||||
import Image from "next/image";
|
||||
|
||||
const UserToolbar: React.FC = () => {
|
||||
const currentConvo = useAtomValue(currentConversationAtom);
|
||||
|
||||
const avatarUrl = currentConvo?.product.avatarUrl ?? "";
|
||||
const title = currentConvo?.product.name ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-1">
|
||||
<Image
|
||||
className="rounded-full aspect-square w-8 h-8"
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
/>
|
||||
<span className="flex gap-[2px] leading-6 text-base font-semibold">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserToolbar;
|
||||
|
||||
215
web-client/app/_helpers/JotaiWrapper.tsx
Normal file
215
web-client/app/_helpers/JotaiWrapper.tsx
Normal 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);
|
||||
@ -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>;
|
||||
};
|
||||
19
web-client/app/_helpers/ModalWrapper.tsx
Normal file
19
web-client/app/_helpers/ModalWrapper.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
@ -7,10 +7,9 @@ type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ThemeWrapper: React.FC<Props> = ({ children }) => {
|
||||
return (
|
||||
<ThemeProvider enableSystem={false} attribute="class">
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
// consider to use next-themes or not. This caused the error Warning: Extra attributes from the server: class,style at html after hydration
|
||||
export const ThemeWrapper: React.FC<Props> = ({ children }) => (
|
||||
<ThemeProvider enableSystem={false} attribute="class">
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@ -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
|
||||
},
|
||||
})
|
||||
16
web-client/app/_hooks/useChatMessageSubscription.ts
Normal file
16
web-client/app/_hooks/useChatMessageSubscription.ts
Normal 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;
|
||||
73
web-client/app/_hooks/useChatMessages.ts
Normal file
73
web-client/app/_hooks/useChatMessages.ts
Normal 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;
|
||||
@ -1,17 +1,27 @@
|
||||
import {
|
||||
ProductDetailFragment,
|
||||
CreateConversationMutation,
|
||||
CreateConversationDocument,
|
||||
CreateConversationMutationVariables,
|
||||
} from "@/graphql";
|
||||
import { useStore } from "../_models/RootStore";
|
||||
import useGetCurrentUser from "./useGetCurrentUser";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { MessageSenderType, MessageType } from "@/_models/ChatMessage";
|
||||
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 { historyStore } = useStore();
|
||||
const [userConversations, setUserConversations] = useAtom(
|
||||
userConversationsAtom
|
||||
);
|
||||
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom);
|
||||
const addNewConvoState = useSetAtom(addNewConversationStateAtom);
|
||||
const { user } = useGetCurrentUser();
|
||||
const { signInWithKeyCloak } = useSignIn();
|
||||
const [createConversation] = useMutation<CreateConversationMutation>(
|
||||
@ -19,7 +29,7 @@ const useCreateConversation = () => {
|
||||
);
|
||||
|
||||
const requestCreateConvo = async (
|
||||
product: ProductDetailFragment,
|
||||
product: Product,
|
||||
forceCreate: boolean = false
|
||||
) => {
|
||||
if (!user) {
|
||||
@ -28,15 +38,15 @@ const useCreateConversation = () => {
|
||||
}
|
||||
|
||||
// search if any fresh convo with particular product id
|
||||
const convo = historyStore.conversations.find(
|
||||
(convo) =>
|
||||
convo.product.id === product.slug && convo.chatMessages.length <= 1
|
||||
const convo = userConversations.find(
|
||||
(convo) => convo.product.slug === product.slug
|
||||
);
|
||||
|
||||
if (convo && !forceCreate) {
|
||||
historyStore.setActiveConversationId(convo.id);
|
||||
setActiveConvoId(convo.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const variables: CreateConversationMutationVariables = {
|
||||
data: {
|
||||
product_id: product.id,
|
||||
@ -49,7 +59,7 @@ const useCreateConversation = () => {
|
||||
content: product.greeting || "Hello there 👋",
|
||||
sender: MessageSenderType.Ai,
|
||||
sender_name: product.name,
|
||||
sender_avatar_url: product.image_url ?? "",
|
||||
sender_avatar_url: product.avatarUrl,
|
||||
message_type: MessageType.Text,
|
||||
message_sender_type: MessageSenderType.Ai,
|
||||
},
|
||||
@ -60,14 +70,26 @@ const useCreateConversation = () => {
|
||||
const result = await createConversation({
|
||||
variables,
|
||||
});
|
||||
const newConvo = result.data?.insert_conversations_one;
|
||||
|
||||
if (result.data?.insert_conversations_one) {
|
||||
historyStore.createConversation(
|
||||
result.data.insert_conversations_one,
|
||||
product,
|
||||
user.id,
|
||||
user.displayName
|
||||
);
|
||||
if (newConvo) {
|
||||
const mappedConvo: Conversation = {
|
||||
id: newConvo.id,
|
||||
product: product,
|
||||
user: {
|
||||
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
|
||||
};
|
||||
|
||||
50
web-client/app/_hooks/useDeleteConversation.ts
Normal file
50
web-client/app/_hooks/useDeleteConversation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,15 +1,15 @@
|
||||
// @ts-nocheck
|
||||
import { DefaultUser, User } from "@/_models/User";
|
||||
import { Instance } from "mobx-state-tree";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSignOut from "./useSignOut";
|
||||
import { DefaultUser, User } from "@/_models/User";
|
||||
|
||||
export default function useGetCurrentUser() {
|
||||
const { data: session, status } = useSession();
|
||||
const { signOut } = useSignOut();
|
||||
const [loading, setLoading] = useState(status === "loading");
|
||||
const [user, setUser] = useState<Instance<typeof User>>();
|
||||
const [user, setUser] = useState<User>();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
status !== "loading" &&
|
||||
|
||||
24
web-client/app/_hooks/useGetProducts.ts
Normal file
24
web-client/app/_hooks/useGetProducts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 { useLazyQuery } from "@apollo/client";
|
||||
import { ConversationState, toConversation } from "@/_models/Conversation";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
conversationStatesAtom,
|
||||
userConversationsAtom,
|
||||
} from "@/_helpers/JotaiWrapper";
|
||||
|
||||
const useGetUserConversations = () => {
|
||||
const { historyStore } = useStore();
|
||||
const setConversationStates = useSetAtom(conversationStatesAtom);
|
||||
const setConversations = useSetAtom(userConversationsAtom);
|
||||
const [getConvos] = useLazyQuery<GetConversationsQuery>(
|
||||
GetConversationsDocument
|
||||
);
|
||||
|
||||
const getUserConversations = async (user: Instance<typeof User>) => {
|
||||
const getUserConversations = async () => {
|
||||
const results = await getConvos();
|
||||
if (!results || !results.data || results.data.conversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const convos = results.data.conversations;
|
||||
|
||||
const finalConvo: Instance<typeof Conversation>[] = [];
|
||||
// mapping
|
||||
const convos = results.data.conversations.map((e) => toConversation(e));
|
||||
const convoStates: Record<string, ConversationState> = {};
|
||||
convos.forEach((convo) => {
|
||||
const conversation = Conversation.create({
|
||||
id: convo.id!!,
|
||||
product: {
|
||||
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);
|
||||
convoStates[convo.id] = {
|
||||
hasMore: true,
|
||||
waitingForResponse: false,
|
||||
};
|
||||
});
|
||||
|
||||
historyStore.setConversations(finalConvo);
|
||||
setConversationStates(convoStates);
|
||||
setConversations(convos);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
383
web-client/app/_hooks/useSendChatMessage.ts
Normal file
383
web-client/app/_hooks/useSendChatMessage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
import { signOut as signOutNextAuth } from "next-auth/react";
|
||||
|
||||
export default function useSignOut() {
|
||||
const signOut = () => {
|
||||
fetch(`api/auth/logout`, { method: "GET" })
|
||||
.then(() => signOutNextAuth({ callbackUrl: "/" }))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
const signOut = async () => {
|
||||
try {
|
||||
await fetch(`api/auth/logout`, { method: "GET" });
|
||||
await signOutNextAuth({ callbackUrl: "/" });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return { signOut };
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { types } from "mobx-state-tree";
|
||||
import { withSetPropAction } from "../_helpers/withSetPropAction";
|
||||
import { MessageDetailFragment } from "@/graphql";
|
||||
import { remark } from "remark";
|
||||
import html from "remark-html";
|
||||
|
||||
export enum MessageType {
|
||||
Text = "Text",
|
||||
@ -18,18 +19,53 @@ export enum MessageStatus {
|
||||
Pending = "pending",
|
||||
}
|
||||
|
||||
export const ChatMessage = types
|
||||
.model("ChatMessage", {
|
||||
id: types.string,
|
||||
conversationId: types.string,
|
||||
messageType: types.enumeration(Object.values(MessageType)),
|
||||
messageSenderType: types.enumeration(Object.values(MessageSenderType)),
|
||||
senderUid: types.string,
|
||||
senderName: types.string,
|
||||
senderAvatarUrl: types.maybeNull(types.string),
|
||||
text: types.maybe(types.string),
|
||||
imageUrls: types.maybe(types.array(types.string)),
|
||||
createdAt: types.number,
|
||||
status: types.enumeration(Object.values(MessageStatus)),
|
||||
})
|
||||
.actions(withSetPropAction);
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
messageType: MessageType;
|
||||
messageSenderType: MessageSenderType;
|
||||
senderUid: string;
|
||||
senderName: string;
|
||||
senderAvatarUrl: string;
|
||||
text: string | undefined;
|
||||
imageUrls?: string[] | undefined;
|
||||
createdAt: number;
|
||||
status: MessageStatus;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,78 +1,38 @@
|
||||
import { Instance, castToSnapshot, types } from "mobx-state-tree";
|
||||
import { Product } from "./Product";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { User } from "../_models/User";
|
||||
import { withSetPropAction } from "../_helpers/withSetPropAction";
|
||||
import { mergeAndRemoveDuplicates } from "../_utils/message";
|
||||
import { ConversationDetailFragment } from "@/graphql";
|
||||
import { Product, toProduct } from "./Product";
|
||||
|
||||
export const Conversation = types
|
||||
.model("Conversation", {
|
||||
/**
|
||||
* Unique identifier for the conversation
|
||||
*/
|
||||
id: types.string,
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
product: Product;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
lastImageUrl?: string;
|
||||
lastTextMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI model that the conversation is using
|
||||
*/
|
||||
product: Product,
|
||||
/**
|
||||
* Store the state of conversation like fetching, waiting for response, etc.
|
||||
*/
|
||||
export type ConversationState = {
|
||||
hasMore: boolean;
|
||||
waitingForResponse: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Conversation's messages, should ordered by time (createdAt)
|
||||
*/
|
||||
chatMessages: types.optional(types.array(ChatMessage), []),
|
||||
|
||||
/**
|
||||
* User who initiate the chat with the above AI model
|
||||
*/
|
||||
user: User,
|
||||
|
||||
/**
|
||||
* Indicates whether the conversation is created by the user
|
||||
*/
|
||||
createdAt: types.number,
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
}));
|
||||
export const toConversation = (
|
||||
convo: ConversationDetailFragment
|
||||
): Conversation => {
|
||||
const product = convo.conversation_product;
|
||||
if (!product) {
|
||||
throw new Error("Product is not defined");
|
||||
}
|
||||
return {
|
||||
id: convo.id,
|
||||
product: toProduct(product),
|
||||
lastImageUrl: convo.last_image_url ?? undefined,
|
||||
lastTextMessage: convo.last_text_message ?? undefined,
|
||||
createdAt: new Date(convo.created_at).getTime(),
|
||||
updatedAt: convo.updated_at
|
||||
? new Date(convo.updated_at).getTime()
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
@ -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),
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
@ -1,22 +1,80 @@
|
||||
import { types } from "mobx-state-tree";
|
||||
import { InputModel } from "./Input";
|
||||
import { OutputModel } from "./Output";
|
||||
import { ProductDetailFragment } from "@/graphql";
|
||||
import { ProductInput } from "./ProductInput";
|
||||
import { ProductOutput } from "./ProductOutput";
|
||||
|
||||
export enum AiModelType {
|
||||
export enum ProductType {
|
||||
LLM = "LLM",
|
||||
GenerativeArt = "GenerativeArt",
|
||||
ControlNet = "ControlNet",
|
||||
}
|
||||
|
||||
export const Product = types.model("Product", {
|
||||
id: types.string, // TODO change to slug
|
||||
name: types.string,
|
||||
type: types.enumeration(Object.values(AiModelType)),
|
||||
description: types.maybeNull(types.string),
|
||||
avatarUrl: types.maybeNull(types.string),
|
||||
modelVersion: types.maybeNull(types.string),
|
||||
modelUrl: types.maybeNull(types.string),
|
||||
modelDescription: types.maybeNull(types.string),
|
||||
input: types.maybeNull(InputModel),
|
||||
output: types.maybeNull(OutputModel),
|
||||
});
|
||||
export interface Product {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
avatarUrl: string;
|
||||
longDescription: string;
|
||||
technicalDescription: string;
|
||||
author: string;
|
||||
version: string;
|
||||
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;
|
||||
}
|
||||
|
||||
23
web-client/app/_models/ProductInput.ts
Normal file
23
web-client/app/_models/ProductInput.ts
Normal 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[];
|
||||
};
|
||||
8
web-client/app/_models/ProductOutput.ts
Normal file
8
web-client/app/_models/ProductOutput.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ItemProperties } from "./ProductInput";
|
||||
|
||||
export interface ProductOutput {
|
||||
slug: string;
|
||||
type: string;
|
||||
properties: ItemProperties[];
|
||||
description: string;
|
||||
}
|
||||
@ -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";
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
@ -1,15 +1,18 @@
|
||||
import { types } from "mobx-state-tree";
|
||||
|
||||
export const User = types.model("User", {
|
||||
id: types.string,
|
||||
displayName: types.optional(types.string, "Anonymous"),
|
||||
avatarUrl: types.maybe(types.string),
|
||||
email: types.maybe(types.string),
|
||||
});
|
||||
export interface User {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const DefaultUser = {
|
||||
id: "0",
|
||||
displayName: "Anonymous",
|
||||
avatarUrl: undefined,
|
||||
avatarUrl: "/icons/app_icon.svg",
|
||||
email: "",
|
||||
};
|
||||
|
||||
export enum Role {
|
||||
User = "user",
|
||||
Assistant = "assistant",
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user