diff --git a/components/apps/Marked/useMarked.ts b/components/apps/Marked/useMarked.ts index 98290759..76cfa617 100644 --- a/components/apps/Marked/useMarked.ts +++ b/components/apps/Marked/useMarked.ts @@ -7,7 +7,7 @@ import { useProcesses } from "contexts/process"; import { loadFiles } from "utils/functions"; import { useLinkHandler } from "hooks/useLinkHandler"; -type MarkedOptions = { +export type MarkedOptions = { headerIds: boolean; mangle: boolean; }; diff --git a/components/system/Taskbar/AI/AIButton.tsx b/components/system/Taskbar/AI/AIButton.tsx new file mode 100644 index 00000000..7b64518e --- /dev/null +++ b/components/system/Taskbar/AI/AIButton.tsx @@ -0,0 +1,36 @@ +import { + AI_STAGE, + AI_TITLE, + WINDOW_ID, +} from "components/system/Taskbar/AI/constants"; +import { AIIcon } from "components/system/Taskbar/AI/icons"; +import StyledAIButton from "components/system/Taskbar/AI/StyledAIButton"; +import { DIV_BUTTON_PROPS } from "utils/constants"; +import { label } from "utils/functions"; +import useTaskbarContextMenu from "components/system/Taskbar/useTaskbarContextMenu"; +import { useSession } from "contexts/session"; + +type AIButtonProps = { + aiVisible: boolean; + toggleAI: () => void; +}; + +const AIButton: FC = ({ aiVisible, toggleAI }) => { + const { removeFromStack } = useSession(); + + return ( + { + toggleAI(); + if (aiVisible) removeFromStack(WINDOW_ID); + }} + {...DIV_BUTTON_PROPS} + {...label(`${AI_TITLE} (${AI_STAGE})`)} + {...useTaskbarContextMenu()} + > + + + ); +}; + +export default AIButton; diff --git a/components/system/Taskbar/AI/AIChat.tsx b/components/system/Taskbar/AI/AIChat.tsx new file mode 100644 index 00000000..1fd1e3b3 --- /dev/null +++ b/components/system/Taskbar/AI/AIChat.tsx @@ -0,0 +1,417 @@ +import { useTheme } from "styled-components"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + formatWebLlmProgress, + speakMessage, +} from "components/system/Taskbar/AI/functions"; +import { + AIIcon, + ChatIcon, + CopyIcon, + EditIcon, + PersonIcon, + SendFilledIcon, + SendIcon, + SpeakIcon, + StopIcon, + WarningIcon, +} from "components/system/Taskbar/AI/icons"; +import useAITransition from "components/system/Taskbar/AI/useAITransition"; +import { + AI_STAGE, + AI_TITLE, + AI_WORKER, + DEFAULT_CONVO_STYLE, + WINDOW_ID, +} from "components/system/Taskbar/AI/constants"; +import StyledAIChat from "components/system/Taskbar/AI/StyledAIChat"; +import { CloseIcon } from "components/system/Window/Titlebar/WindowActionIcons"; +import Button from "styles/common/Button"; +import { label, viewWidth } from "utils/functions"; +import { PREVENT_SCROLL } from "utils/constants"; +import { + type MessageTypes, + type ConvoStyles, + type Message, + type WorkerResponse, + type WebLlmProgress, + type AIResponse, +} from "components/system/Taskbar/AI/types"; +import useWorker from "hooks/useWorker"; +import useFocusable from "components/system/Window/useFocusable"; +import { useSession } from "contexts/session"; +import { useWindowAI } from "hooks/useWindowAI"; + +type AIChatProps = { + toggleAI: () => void; +}; + +const AIChat: FC = ({ toggleAI }) => { + const { + colors: { taskbar: taskbarColor }, + sizes: { taskbar: taskbarSize }, + } = useTheme(); + const getFullWidth = useCallback( + () => Math.min(taskbarSize.ai.chatWidth, viewWidth()), + [taskbarSize.ai.chatWidth] + ); + const [fullWidth, setFullWidth] = useState(getFullWidth); + const aiTransition = useAITransition(fullWidth); + const [convoStyle, setConvoStyle] = useState(DEFAULT_CONVO_STYLE); + const [primaryColor, secondaryColor, tertiaryColor] = + taskbarColor.ai[convoStyle]; + const [promptText, setPromptText] = useState(""); + const textAreaRef = useRef(null); + const sectionRef = useRef(null); + const typing = promptText.length > 0; + const [conversation, setConversation] = useState([]); + const addMessage = useCallback( + ( + text: string | undefined, + type: MessageTypes, + formattedText?: string + ): void => { + if (text) { + setConversation((prevMessages) => [ + ...prevMessages, + { formattedText: formattedText || text, text, type }, + ]); + } + }, + [] + ); + const addUserPrompt = useCallback(() => { + if (promptText) { + addMessage(promptText, "user"); + (textAreaRef.current as HTMLTextAreaElement).value = ""; + setPromptText(""); + } + }, [addMessage, promptText]); + const lastAiMessageIndex = useMemo( + () => + conversation.length - + [...conversation].reverse().findIndex(({ type }) => type === "ai") - + 1, + [conversation] + ); + const [responding, setResponding] = useState(false); + const [canceling, setCanceling] = useState(false); + const [failedSession, setFailedSession] = useState(false); + const sessionIdRef = useRef(0); + const hasWindowAI = useWindowAI(); + const aiWorker = useWorker(AI_WORKER); + const stopResponse = useCallback(() => { + if (aiWorker.current && responding) { + aiWorker.current.postMessage("cancel"); + setCanceling(true); + } + }, [aiWorker, responding]); + const newTopic = useCallback(() => { + stopResponse(); + sessionIdRef.current = 0; + setConversation([]); + setFailedSession(false); + }, [stopResponse]); + const changeConvoStyle = useCallback( + (newConvoStyle: ConvoStyles) => { + if (convoStyle !== newConvoStyle) { + newTopic(); + setConvoStyle(newConvoStyle); + textAreaRef.current?.focus(PREVENT_SCROLL); + } + }, + [convoStyle, newTopic] + ); + const [containerElement, setContainerElement] = + useState(); + const { removeFromStack } = useSession(); + const { zIndex, ...focusableProps } = useFocusable( + WINDOW_ID, + undefined, + containerElement + ); + const scrollbarVisible = useMemo( + () => + conversation.length > 0 && + sectionRef.current instanceof HTMLElement && + sectionRef.current.scrollHeight > sectionRef.current.clientHeight, + [conversation.length] + ); + const [copiedIndex, setCopiedIndex] = useState(-1); + const [progressMessage, setProgressMessage] = useState(""); + const autoSizeText = useCallback(() => { + const textArea = textAreaRef.current as HTMLTextAreaElement; + + textArea.style.height = "auto"; + textArea.style.height = `${textArea.scrollHeight}px`; + }, []); + + useEffect(() => { + textAreaRef.current?.focus(PREVENT_SCROLL); + }, []); + + useEffect(() => { + const updateFullWidth = (): void => setFullWidth(getFullWidth); + + window.addEventListener("resize", updateFullWidth); + + return () => window.removeEventListener("resize", updateFullWidth); + }, [getFullWidth]); + + useEffect(() => { + if (conversation.length > 0 || failedSession) { + requestAnimationFrame(() => + sectionRef.current?.scrollTo({ + behavior: "smooth", + top: sectionRef.current.scrollHeight, + }) + ); + } + }, [conversation, failedSession]); + + useEffect(() => { + if ( + aiWorker.current && + conversation.length > 0 && + conversation[conversation.length - 1].type === "user" + ) { + const { text } = conversation[conversation.length - 1]; + + setResponding(true); + + sessionIdRef.current ||= Date.now(); + + aiWorker.current.postMessage({ + hasWindowAI, + id: sessionIdRef.current, + style: convoStyle, + text, + }); + } + }, [aiWorker, conversation, convoStyle, hasWindowAI]); + + useEffect(() => { + const workerRef = aiWorker.current; + const workerResponse = ({ data }: WorkerResponse): void => { + const doneResponding = typeof data === "string" || "response" in data; + + setResponding(!doneResponding); + + if (data === "canceled") { + setCanceling(false); + } else if ((data as WebLlmProgress).progress) { + const { + progress: { text }, + } = data as WebLlmProgress; + + setProgressMessage(formatWebLlmProgress(text)); + } else if ((data as AIResponse).response) { + const { formattedResponse, response } = data as AIResponse; + + addMessage(response, "ai", formattedResponse); + } else if ((data as AIResponse).response === "") { + setFailedSession(true); + } + }; + + workerRef?.addEventListener("message", workerResponse); + + return () => workerRef?.removeEventListener("message", workerResponse); + }, [addMessage, aiWorker]); + + return ( + +
+
+ {`${AI_TITLE} (${AI_STAGE})`} + +
+
+
+
+
+ {AI_TITLE} +
+
+ Choose a conversation style +
+ + + +
+
+
+
+ {conversation.map(({ formattedText, type, text }, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {(index === 0 || conversation[index - 1].type !== type) && ( +
+ {type === "user" ? : } + {type === "user" ? "You" : "AI"} +
+ )} +
+
+ + {type === "user" && ( + + )} + {"speechSynthesis" in window && type === "ai" && ( + + )} +
+
+ ))} + {responding && ( +
+ +
+ )} + {failedSession && ( +
+ + It might be time to move onto a new topic. + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + Let's start over. +
+ )} +
+
+