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