diff --git a/chatgse/app/client/platforms/openai.ts b/chatgse/app/client/platforms/openai.ts index 01d97c6..2580217 100644 --- a/chatgse/app/client/platforms/openai.ts +++ b/chatgse/app/client/platforms/openai.ts @@ -7,7 +7,6 @@ import { ServiceProvider, } from "@/app/constant"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; - import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api"; import Locale from "../../locales"; import { @@ -17,6 +16,7 @@ import { import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; import { makeAzurePath } from "@/app/azure"; +import { EnvironmentPlugin } from "webpack"; import { useRAGStore } from "@/app/store/rag"; export interface OpenAIListModelResponse { @@ -182,7 +182,7 @@ export class ChatGPTApi implements LLMApi { try { const resJson = await res.clone().json(); extraInfo = prettyObject(resJson); - } catch {} + } catch { } if (res.status === 401) { responseTexts.push(Locale.Error.Unauthorized); @@ -229,11 +229,100 @@ export class ChatGPTApi implements LLMApi { }); } else { const res = await fetch(chatPath, chatPayload); - clearTimeout(requestTimeoutId); - const resJson = await res.json(); - const message = this.extractMessage(resJson); - options.onFinish(message); + + + if (options.config.model == "mistral-wasm") { + + const resJson = await res.json(); + let message = this.extractMessage(resJson); + const question = message.split("\n").slice(-1); + + let inputValue = (document.getElementById("chatui-input")); + //inputValue = question; + message = JSON.parse(chatPayload.body); + inputValue.value = JSON.parse(chatPayload.body)["messages"].slice(-1)[0].content; + //console.log("chat input is "+ inputValue.value); + + + + let sendbutton : HTMLElement | null = document.getElementById("chatui-send-btn"); + if (sendbutton){ + sendbutton.click(); + // console.log("send button clicked"); + } + + + let label = document.getElementById("chatui-info-label"); + //console.log("got chatui info label"); + const observerOptions = { + childList: true, + subtree: true, + characterData: true + }; + const updatemsgs = (mutationList:any, observer:any) => { + //console.log("mutation observer is on"); + for (const mutation of mutationList) { + if (mutation.type === "childList") { + //console.log("mutation childList") + let output = document.getElementById("chatui-chat"); + if (output){ + var outputText = (Array.from(output.childNodes).slice(-2)[0]); + if (outputText instanceof HTMLElement){ + var Text = (outputText).innerText; + const reformedtext = Text.replaceAll("\n", "").replaceAll(" ", "") + const message = reformedtext; + + options.onFinish(message); + clearTimeout(requestTimeoutId); + // console.log("mutation type is subtree") + observer.disconnect(); + } + } + + + } else { + //console.log("mutation others") + let output = document.getElementById("chatui-chat"); + const outputText = document.getElementById("chatui-status"); + const reformedtext = outputText.innerHTML.replaceAll("\n", "").replaceAll(" ", "") + const message = reformedtext; + + options.onFinish("\'" + message + "\'"); + clearTimeout(requestTimeoutId); + //console.log("mutation type is not subtree but " + mutation.type) + observer.disconnect(); + } + + } + + } + + + const observer = new MutationObserver(updatemsgs); + //console.log("mutation main"); + observer.observe(label , observerOptions); + //observer.disconnect(); + + + + + + } else { + + clearTimeout(requestTimeoutId); + const resJson = await res.json(); + const message = this.extractMessage(resJson); + options.onFinish(message); + + } + + + + + + + } } catch (e) { console.log("[Request] failed to make a chat request", e); diff --git a/chatgse/app/components/auth.tsx b/chatgse/app/components/auth.tsx index 7962d46..4e88493 100644 --- a/chatgse/app/components/auth.tsx +++ b/chatgse/app/components/auth.tsx @@ -16,6 +16,7 @@ export function AuthPage() { const goHome = () => navigate(Path.Home); const goChat = () => navigate(Path.Chat); + const goWebllm = () => navigate(Path.Webllm); const resetAccessCode = () => { accessStore.update((access) => { access.openaiApiKey = ""; diff --git a/chatgse/app/components/chat.tsx b/chatgse/app/components/chat.tsx index 048332e..3c73acc 100644 --- a/chatgse/app/components/chat.tsx +++ b/chatgse/app/components/chat.tsx @@ -1247,7 +1247,7 @@ function _Chat() { )} {showTyping && ( -
+
{Locale.Chat.Typing}
)} diff --git a/chatgse/app/components/home.module.scss b/chatgse/app/components/home.module.scss index fb35cc7..adece92 100644 --- a/chatgse/app/components/home.module.scss +++ b/chatgse/app/components/home.module.scss @@ -192,6 +192,9 @@ overflow-x: hidden; } +.chatui-hide { + display: none; +} .chat-item { padding: 10px 14px; background-color: var(--white); @@ -214,6 +217,7 @@ border-color: var(--primary); } + .chat-item-title { font-size: 14px; font-weight: bolder; diff --git a/chatgse/app/components/home.tsx b/chatgse/app/components/home.tsx index c5b03df..b89ef3a 100644 --- a/chatgse/app/components/home.tsx +++ b/chatgse/app/components/home.tsx @@ -33,11 +33,17 @@ import { useAccessStore } from "../store"; export function Loading(props: { noLogo?: boolean }) { return (
- {!props.noLogo && } + {!props.noLogo && } + +
+ ); } +const Webllm = dynamic(async () => (await import("./webllm")).Webllm, { + loading: () => , +}); const Settings = dynamic(async () => (await import("./settings")).Settings, { loading: () => , @@ -136,6 +142,7 @@ function Screen() { const isHome = location.pathname === Path.Home; const isAuth = location.pathname === Path.Auth; const isMobileScreen = useMobileScreen(); + const shouldTightBorder = getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen); @@ -147,11 +154,40 @@ function Screen() {
+
+
+ +
+ send +
+ + + + + + + {isAuth ? ( <> @@ -168,12 +204,14 @@ function Screen() { } /> } /> } /> + } /> } /> - -
+ +
- )} - + ) + } + ); } diff --git a/chatgse/app/components/webllm.module.scss b/chatgse/app/components/webllm.module.scss new file mode 100644 index 0000000..16790cc --- /dev/null +++ b/chatgse/app/components/webllm.module.scss @@ -0,0 +1,518 @@ +@import "../styles/animation.scss"; + +.chat-input-actions { + display: flex; + flex-wrap: wrap; + + .chat-input-action { + display: inline-flex; + border-radius: 20px; + font-size: 12px; + background-color: var(--white); + color: var(--black); + border: var(--border-in-light); + padding: 4px 10px; + animation: slide-in ease 0.3s; + box-shadow: var(--card-shadow); + transition: width ease 0.3s; + align-items: center; + height: 16px; + width: var(--icon-width); + overflow: hidden; + + &:not(:last-child) { + margin-right: 5px; + } + + .text { + white-space: nowrap; + padding-left: 5px; + opacity: 0; + transform: translateX(-5px); + transition: all ease 0.3s; + pointer-events: none; + } + + &:hover { + --delay: 0.5s; + width: var(--full-width); + transition-delay: var(--delay); + + .text { + transition-delay: var(--delay); + opacity: 1; + transform: translate(0); + } + } + + .text, + .icon { + display: flex; + align-items: center; + justify-content: center; + } + } +} + +.prompt-toast { + position: absolute; + bottom: -50px; + z-index: 999; + display: flex; + justify-content: center; + width: calc(100% - 40px); + + .prompt-toast-inner { + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; + background-color: var(--white); + color: var(--black); + + border: var(--border-in-light); + box-shadow: var(--card-shadow); + padding: 10px 20px; + border-radius: 100px; + + animation: slide-in-from-top ease 0.3s; + + .prompt-toast-content { + margin-left: 10px; + } + } +} + +.section-title { + font-size: 12px; + font-weight: bold; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + + .section-title-action { + display: flex; + align-items: center; + } +} + +.context-prompt { + .context-prompt-insert { + display: flex; + justify-content: center; + padding: 4px; + opacity: 0.2; + transition: all ease 0.3s; + background-color: rgba(0, 0, 0, 0); + cursor: pointer; + border-radius: 4px; + margin-top: 4px; + margin-bottom: 4px; + + &:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.05); + } + } + + .context-prompt-row { + display: flex; + justify-content: center; + width: 100%; + + &:hover { + .context-drag { + opacity: 1; + } + } + + .context-drag { + display: flex; + align-items: center; + opacity: 0.5; + transition: all ease 0.3s; + } + + .context-role { + margin-right: 10px; + } + + .context-content { + flex: 1; + max-width: 100%; + text-align: left; + } + + .context-delete-button { + margin-left: 10px; + } + } + + .context-prompt-button { + flex: 1; + } +} + +.memory-prompt { + margin: 20px 0; + + .memory-prompt-content { + background-color: var(--white); + color: var(--black); + border: var(--border-in-light); + border-radius: 10px; + padding: 10px; + font-size: 12px; + user-select: text; + } +} + +.clear-context { + margin: 20px 0 0 0; + padding: 4px 0; + + border-top: var(--border-in-light); + border-bottom: var(--border-in-light); + box-shadow: var(--card-shadow) inset; + + display: flex; + justify-content: center; + align-items: center; + + color: var(--black); + transition: all ease 0.3s; + cursor: pointer; + overflow: hidden; + position: relative; + font-size: 12px; + + animation: slide-in ease 0.3s; + + $linear: linear-gradient( + to right, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 1), + rgba(0, 0, 0, 0) + ); + mask-image: $linear; + + @mixin show { + transform: translateY(0); + position: relative; + transition: all ease 0.3s; + opacity: 1; + } + + @mixin hide { + transform: translateY(-50%); + position: absolute; + transition: all ease 0.1s; + opacity: 0; + } + + &-tips { + @include show; + opacity: 0.5; + } + + &-revert-btn { + color: var(--primary); + @include hide; + } + + &:hover { + opacity: 1; + border-color: var(--primary); + + .clear-context-tips { + @include hide; + } + + .clear-context-revert-btn { + @include show; + } + } +} + +.chat { + display: flex; + flex-direction: column; + position: relative; + height: 100%; +} + +.chat-body { + flex: 1; + overflow: auto; + overflow-x: hidden; + padding: 20px; + padding-bottom: 40px; + position: relative; + overscroll-behavior: none; +} + +.chat-body-main-title { + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +@media only screen and (max-width: 600px) { + .chat-body-title { + text-align: center; + } +} + +.chat-message { + display: flex; + flex-direction: row; + + &:last-child { + animation: slide-in ease 0.3s; + } +} + +.chat-message-user { + display: flex; + flex-direction: row-reverse; + + .chat-message-header { + flex-direction: row-reverse; + } +} + +.chat-message-header { + margin-top: 20px; + display: flex; + align-items: center; + + .chat-message-actions { + display: flex; + box-sizing: border-box; + font-size: 12px; + align-items: flex-end; + justify-content: space-between; + transition: all ease 0.3s; + transform: scale(0.9) translateY(5px); + margin: 0 10px; + opacity: 0; + pointer-events: none; + + .chat-input-actions { + display: flex; + flex-wrap: nowrap; + } + } +} + +.chat-message-container { + max-width: var(--message-max-width); + display: flex; + flex-direction: column; + align-items: flex-start; + + &:hover { + .chat-message-edit { + opacity: 0.9; + } + + .chat-message-actions { + opacity: 1; + pointer-events: all; + transform: scale(1) translateY(0); + } + } +} + +.chat-message-user > .chat-message-container { + align-items: flex-end; +} + +.chat-message-avatar { + position: relative; + + .chat-message-edit { + position: absolute; + height: 100%; + width: 100%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all ease 0.3s; + + button { + padding: 7px; + } + } + /* Specific styles for iOS devices */ + @media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) { + @supports (-webkit-touch-callout: none) { + .chat-message-edit { + top: -8%; + } + } + } +} + +.chat-message-status { + font-size: 12px; + color: #aaa; + line-height: 1.5; + margin-top: 5px; +} + +.chat-message-item { + box-sizing: border-box; + max-width: 100%; + margin-top: 10px; + border-radius: 10px; + background-color: rgba(0, 0, 0, 0.05); + padding: 10px; + font-size: 14px; + user-select: text; + word-break: break-word; + border: var(--border-in-light); + position: relative; + transition: all ease 0.3s; +} + +.chat-message-action-date { + font-size: 12px; + opacity: 0.2; + white-space: nowrap; + transition: all ease 0.6s; + color: var(--black); + text-align: right; + width: 100%; + box-sizing: border-box; + padding-right: 10px; + pointer-events: none; + z-index: 1; +} + +.chat-message-user > .chat-message-container > .chat-message-item { + background-color: var(--second); + + &:hover { + min-width: 0; + } +} + +.chat-input-panel { + position: relative; + width: 100%; + padding: 20px; + padding-top: 10px; + box-sizing: border-box; + flex-direction: column; + border-top: var(--border-in-light); + box-shadow: var(--card-shadow); + + .chat-input-actions { + .chat-input-action { + margin-bottom: 10px; + } + } +} + +@mixin single-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.prompt-hints { + min-height: 20px; + width: 100%; + max-height: 50vh; + overflow: auto; + display: flex; + flex-direction: column-reverse; + + background-color: var(--white); + border: var(--border-in-light); + border-radius: 10px; + margin-bottom: 10px; + box-shadow: var(--shadow); + + .prompt-hint { + color: var(--black); + padding: 6px 10px; + animation: slide-in ease 0.3s; + cursor: pointer; + transition: all ease 0.3s; + border: transparent 1px solid; + margin: 4px; + border-radius: 8px; + + &:not(:last-child) { + margin-top: 0; + } + + .hint-title { + font-size: 12px; + font-weight: bolder; + + @include single-line(); + } + .hint-content { + font-size: 12px; + + @include single-line(); + } + + &-selected, + &:hover { + border-color: var(--primary); + } + } +} + +.chat-input-panel-inner { + display: flex; + flex: 1; +} + +.chat-input { + height: 100%; + width: 100%; + border-radius: 10px; + border: var(--border-in-light); + box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03); + background-color: var(--white); + color: var(--black); + font-family: inherit; + padding: 10px 90px 10px 14px; + resize: none; + outline: none; + box-sizing: border-box; + min-height: 68px; +} + +.chat-input:focus { + border: 1px solid var(--primary); +} + +.chat-input-send { + background-color: var(--primary); + color: white; + + position: absolute; + right: 30px; + bottom: 32px; +} + +@media only screen and (max-width: 600px) { + .chat-input { + font-size: 16px; + } + + .chat-input-send { + bottom: 30px; + } +} diff --git a/chatgse/app/components/webllm.tsx b/chatgse/app/components/webllm.tsx new file mode 100644 index 0000000..11d587b --- /dev/null +++ b/chatgse/app/components/webllm.tsx @@ -0,0 +1,1353 @@ +import { useDebouncedCallback } from "use-debounce"; +import React, { + useState, + useRef, + useEffect, + useMemo, + useCallback, + Fragment, +} from "react"; +import SendWhiteIcon from "../icons/send-white.svg"; +import BrainIcon from "../icons/brain.svg"; +import RenameIcon from "../icons/rename.svg"; +import ExportIcon from "../icons/share.svg"; +import ReturnIcon from "../icons/return.svg"; +import CopyIcon from "../icons/copy.svg"; +import LoadingIcon from "../icons/three-dots.svg"; +import PromptIcon from "../icons/prompt.svg"; +import MaskIcon from "../icons/mask.svg"; +import MaxIcon from "../icons/max.svg"; +import MinIcon from "../icons/min.svg"; +import ResetIcon from "../icons/reload.svg"; +import BreakIcon from "../icons/break.svg"; +import SettingsIcon from "../icons/chat-settings.svg"; +import DeleteIcon from "../icons/clear.svg"; +import PinIcon from "../icons/pin.svg"; +import EditIcon from "../icons/rename.svg"; +import ConfirmIcon from "../icons/confirm.svg"; +import CancelIcon from "../icons/cancel.svg"; + +import LightIcon from "../icons/light.svg"; +import DarkIcon from "../icons/dark.svg"; +import AutoIcon from "../icons/auto.svg"; +import BottomIcon from "../icons/bottom.svg"; +import StopIcon from "../icons/pause.svg"; +import RobotIcon from "../icons/robot.svg"; + +import { + ChatMessage, + SubmitKey, + useChatStore, + BOT_HELLO, + createMessage, + useAccessStore, + Theme, + useAppConfig, + DEFAULT_TOPIC, + ModelType, +} from "../store"; + +import { + copyToClipboard, + selectOrCopy, + autoGrowTextArea, + useMobileScreen, +} from "../utils"; + +import dynamic from "next/dynamic"; + +import { ChatControllerPool } from "../client/controller"; +import { Prompt, usePromptStore } from "../store/prompt"; +import Locale from "../locales"; + +import { IconButton } from "./button"; +import styles from "./webllm.module.scss"; + +import { + List, + ListItem, + Modal, + Selector, + showConfirm, + showPrompt, + showToast, +} from "./ui-lib"; +import { useNavigate } from "react-router-dom"; +import { + CHAT_PAGE_SIZE, + LAST_INPUT_KEY, + Path, + REQUEST_TIMEOUT_MS, + UNFINISHED_INPUT, SlotID +} from "../constant"; +import { Avatar } from "./emoji"; +import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; +import { useMaskStore } from "../store/mask"; +import { ChatCommandPrefix, useChatCommand, useCommand } from "../command"; +import { prettyObject } from "../utils/format"; +import { ExportMessageModal } from "./exporter"; +import { getClientConfig } from "../config/client"; +import { useAllModels } from "../utils/hooks"; + +const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { + loading: () => , +}); + +export function SessionConfigModel(props: { onClose: () => void }) { + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const maskStore = useMaskStore(); + const navigate = useNavigate(); + + return ( +
+ props.onClose()} + actions={[ + } + bordered + text={Locale.Chat.Config.Reset} + onClick={async () => { + if (await showConfirm(Locale.Memory.ResetConfirm)) { + chatStore.updateCurrentSession( + (session) => (session.memoryPrompt = ""), + ); + } + }} + />, + } + bordered + text={Locale.Chat.Config.SaveAs} + onClick={() => { + navigate(Path.Masks); + setTimeout(() => { + maskStore.create(session.mask); + }, 500); + }} + />, + ]} + > + { + const mask = { ...session.mask }; + updater(mask); + chatStore.updateCurrentSession((session) => (session.mask = mask)); + }} + shouldSyncFromGlobal + extraListItems={ + session.mask.modelConfig.sendMemory ? ( + + ) : ( + <> + ) + } + > + +
+ ); +} + +function PromptToast(props: { + showToast?: boolean; + showModal?: boolean; + setShowModal: (_: boolean) => void; +}) { + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const context = session.mask.context; + + return ( +
+ {props.showToast && ( +
props.setShowModal(true)} + > + + + {Locale.Context.Toast(context.length)} + +
+ )} + {props.showModal && ( + props.setShowModal(false)} /> + )} +
+ ); +} + +function useSubmitHandler() { + const config = useAppConfig(); + const submitKey = config.submitKey; + const isComposing = useRef(false); + + useEffect(() => { + const onCompositionStart = () => { + isComposing.current = true; + }; + const onCompositionEnd = () => { + isComposing.current = false; + }; + + window.addEventListener("compositionstart", onCompositionStart); + window.addEventListener("compositionend", onCompositionEnd); + + return () => { + window.removeEventListener("compositionstart", onCompositionStart); + window.removeEventListener("compositionend", onCompositionEnd); + }; + }, []); + + const shouldSubmit = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return false; + if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current)) + return false; + return ( + (config.submitKey === SubmitKey.AltEnter && e.altKey) || + (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) || + (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) || + (config.submitKey === SubmitKey.MetaEnter && e.metaKey) || + (config.submitKey === SubmitKey.Enter && + !e.altKey && + !e.ctrlKey && + !e.shiftKey && + !e.metaKey) + ); + }; + + return { + submitKey, + shouldSubmit, + }; +} + +export type RenderPompt = Pick; + +export function PromptHints(props: { + prompts: RenderPompt[]; + onPromptSelect: (prompt: RenderPompt) => void; +}) { + const noPrompts = props.prompts.length === 0; + const [selectIndex, setSelectIndex] = useState(0); + const selectedRef = useRef(null); + + useEffect(() => { + setSelectIndex(0); + }, [props.prompts.length]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) { + return; + } + // arrow up / down to select prompt + const changeIndex = (delta: number) => { + e.stopPropagation(); + e.preventDefault(); + const nextIndex = Math.max( + 0, + Math.min(props.prompts.length - 1, selectIndex + delta), + ); + setSelectIndex(nextIndex); + selectedRef.current?.scrollIntoView({ + block: "center", + }); + }; + + if (e.key === "ArrowUp") { + changeIndex(1); + } else if (e.key === "ArrowDown") { + changeIndex(-1); + } else if (e.key === "Enter") { + const selectedPrompt = props.prompts.at(selectIndex); + if (selectedPrompt) { + props.onPromptSelect(selectedPrompt); + } + } + }; + + window.addEventListener("keydown", onKeyDown); + + return () => window.removeEventListener("keydown", onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.prompts.length, selectIndex]); + + if (noPrompts) return null; + return ( +
+ {props.prompts.map((prompt, i) => ( +
props.onPromptSelect(prompt)} + onMouseEnter={() => setSelectIndex(i)} + > +
{prompt.title}
+
{prompt.content}
+
+ ))} +
+ ); +} + +function ClearContextDivider() { + const chatStore = useChatStore(); + + return ( +
+ chatStore.updateCurrentSession( + (session) => (session.clearContextIndex = undefined), + ) + } + > +
{Locale.Context.Clear}
+
+ {Locale.Context.Revert} +
+
+ ); +} + +function ChatAction(props: { + text: string; + icon: JSX.Element; + onClick: () => void; +}) { + const iconRef = useRef(null); + const textRef = useRef(null); + const [width, setWidth] = useState({ + full: 16, + icon: 16, + }); + + function updateWidth() { + if (!iconRef.current || !textRef.current) return; + const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width; + const textWidth = getWidth(textRef.current); + const iconWidth = getWidth(iconRef.current); + setWidth({ + full: textWidth + iconWidth, + icon: iconWidth, + }); + } + + return ( +
{ + props.onClick(); + setTimeout(updateWidth, 1); + }} + onMouseEnter={updateWidth} + onTouchStart={updateWidth} + style={ + { + "--icon-width": `${width.icon}px`, + "--full-width": `${width.full}px`, + } as React.CSSProperties + } + > +
+ {props.icon} +
+
+ {props.text} +
+
+ ); +} + +function useScrollToBottom() { + // for auto-scroll + const scrollRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + function scrollDomToBottom() { + const dom = scrollRef.current; + if (dom) { + requestAnimationFrame(() => { + setAutoScroll(true); + dom.scrollTo(0, dom.scrollHeight); + }); + } + } + + // auto scroll + useEffect(() => { + if (autoScroll) { + scrollDomToBottom(); + } + }); + + return { + scrollRef, + autoScroll, + setAutoScroll, + scrollDomToBottom, + }; +} + +export function ChatActions(props: { + showPromptModal: () => void; + scrollToBottom: () => void; + showPromptHints: () => void; + hitBottom: boolean; +}) { + const config = useAppConfig(); + const navigate = useNavigate(); + const chatStore = useChatStore(); + + // switch themes + const theme = config.theme; + function nextTheme() { + const themes = [Theme.Auto, Theme.Light, Theme.Dark]; + const themeIndex = themes.indexOf(theme); + const nextIndex = (themeIndex + 1) % themes.length; + const nextTheme = themes[nextIndex]; + config.update((config) => (config.theme = nextTheme)); + } + + // stop all responses + const couldStop = ChatControllerPool.hasPending(); + const stopAll = () => ChatControllerPool.stopAll(); + + // switch model + const currentModel = chatStore.currentSession().mask.modelConfig.model; + const allModels = useAllModels(); + const models = useMemo( + () => allModels.filter((m) => m.available), + [allModels], + ); + const [showModelSelector, setShowModelSelector] = useState(false); + + useEffect(() => { + // if current model is not available + // switch to first available model + const isUnavaliableModel = !models.some((m) => m.name === currentModel); + if (isUnavaliableModel && models.length > 0) { + const nextModel = models[0].name as ModelType; + chatStore.updateCurrentSession( + (session) => (session.mask.modelConfig.model = nextModel), + ); + showToast(nextModel); + } + }, [chatStore, currentModel, models]); + + return ( +
+ {couldStop && ( + } + /> + )} + {!props.hitBottom && ( + } + /> + )} + {props.hitBottom && ( + } + /> + )} + + + {theme === Theme.Auto ? ( + + ) : theme === Theme.Light ? ( + + ) : theme === Theme.Dark ? ( + + ) : null} + + } + /> + + } + /> + + { + navigate(Path.Masks); + }} + text={Locale.Chat.InputActions.Masks} + icon={} + /> + + } + onClick={() => { + chatStore.updateCurrentSession((session) => { + if (session.clearContextIndex === session.messages.length) { + session.clearContextIndex = undefined; + } else { + session.clearContextIndex = session.messages.length; + session.memoryPrompt = ""; // will clear memory + } + }); + }} + /> + + setShowModelSelector(true)} + text={currentModel} + icon={} + /> + + {showModelSelector && ( + ({ + title: m.displayName, + value: m.name, + }))} + onClose={() => setShowModelSelector(false)} + onSelection={(s) => { + if (s.length === 0) return; + chatStore.updateCurrentSession((session) => { + session.mask.modelConfig.model = s[0] as ModelType; + session.mask.syncGlobalConfig = false; + }); + showToast(s[0]); + }} + /> + )} +
+ ); +} + +export function EditMessageModal(props: { onClose: () => void }) { + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const [messages, setMessages] = useState(session.messages.slice()); + + return ( +
+ } + key="cancel" + onClick={() => { + props.onClose(); + }} + />, + } + key="ok" + onClick={() => { + chatStore.updateCurrentSession( + (session) => (session.messages = messages), + ); + props.onClose(); + }} + />, + ]} + > + + + + chatStore.updateCurrentSession( + (session) => (session.topic = e.currentTarget.value), + ) + } + > + + + { + const newMessages = messages.slice(); + updater(newMessages); + setMessages(newMessages); + }} + /> + +
+ ); +} + +function _Webllm() { + type RenderMessage = ChatMessage & { preview?: boolean }; + + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const config = useAppConfig(); + const fontSize = config.fontSize; + + const [showExport, setShowExport] = useState(false); + + const inputRef = useRef(null); + const [userInput, setUserInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const { submitKey, shouldSubmit } = useSubmitHandler(); + const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom(); + const [hitBottom, setHitBottom] = useState(true); + const isMobileScreen = useMobileScreen(); + const navigate = useNavigate(); + + // prompt hints + const promptStore = usePromptStore(); + const [promptHints, setPromptHints] = useState([]); + const onSearch = useDebouncedCallback( + (text: string) => { + const matchedPrompts = promptStore.search(text); + setPromptHints(matchedPrompts); + }, + 100, + { leading: true, trailing: true }, + ); + + // auto grow input + const [inputRows, setInputRows] = useState(2); + const measure = useDebouncedCallback( + () => { + const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1; + const inputRows = Math.min( + 20, + Math.max(2 + Number(!isMobileScreen), rows), + ); + setInputRows(inputRows); + }, + 100, + { + leading: true, + trailing: true, + }, + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(measure, [userInput]); + + // chat commands shortcuts + const chatCommands = useChatCommand({ + new: () => chatStore.newSession(), + newm: () => navigate(Path.NewChat), + prev: () => chatStore.nextSession(-1), + next: () => chatStore.nextSession(1), + clear: () => + chatStore.updateCurrentSession( + (session) => (session.clearContextIndex = session.messages.length), + ), + del: () => chatStore.deleteSession(chatStore.currentSessionIndex), + }); + + // only search prompts when user input is short + const SEARCH_TEXT_LIMIT = 30; + const onInput = (text: string) => { + setUserInput(text); + const n = text.trim().length; + + // clear search results + if (n === 0) { + setPromptHints([]); + } else if (text.startsWith(ChatCommandPrefix)) { + setPromptHints(chatCommands.search(text)); + } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { + // check if need to trigger auto completion + if (text.startsWith("/")) { + let searchText = text.slice(1); + onSearch(searchText); + } + } + }; + + const doSubmit = (userInput: string) => { + if (userInput.trim() === "") return; + const matchCommand = chatCommands.match(userInput); + if (matchCommand.matched) { + setUserInput(""); + setPromptHints([]); + matchCommand.invoke(); + return; + } + setIsLoading(true); + chatStore.onUserInput(userInput).then(() => setIsLoading(false)); + localStorage.setItem(LAST_INPUT_KEY, userInput); + setUserInput(""); + setPromptHints([]); + if (!isMobileScreen) inputRef.current?.focus(); + setAutoScroll(true); + }; + + const onPromptSelect = (prompt: RenderPompt) => { + setTimeout(() => { + setPromptHints([]); + + const matchedChatCommand = chatCommands.match(prompt.content); + if (matchedChatCommand.matched) { + // if user is selecting a chat command, just trigger it + matchedChatCommand.invoke(); + setUserInput(""); + } else { + // or fill the prompt + setUserInput(prompt.content); + } + inputRef.current?.focus(); + }, 30); + }; + + // stop response + const onUserStop = (messageId: string) => { + ChatControllerPool.stop(session.id, messageId); + }; + + useEffect(() => { + chatStore.updateCurrentSession((session) => { + const stopTiming = Date.now() - REQUEST_TIMEOUT_MS; + session.messages.forEach((m) => { + // check if should stop all stale messages + if (m.isError || new Date(m.date).getTime() < stopTiming) { + if (m.streaming) { + m.streaming = false; + } + + if (m.content.length === 0) { + m.isError = true; + m.content = prettyObject({ + error: true, + message: "empty response", + }); + } + } + }); + + // auto sync mask config from global config + if (session.mask.syncGlobalConfig) { + console.log("[Mask] syncing from global, name = ", session.mask.name); + session.mask.modelConfig = { ...config.modelConfig }; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // check if should send message + const onInputKeyDown = (e: React.KeyboardEvent) => { + // if ArrowUp and no userInput, fill with last input + if ( + e.key === "ArrowUp" && + userInput.length <= 0 && + !(e.metaKey || e.altKey || e.ctrlKey) + ) { + setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? ""); + e.preventDefault(); + return; + } + if (shouldSubmit(e) && promptHints.length === 0) { + doSubmit(userInput); + e.preventDefault(); + } + }; + const onRightClick = (e: any, message: ChatMessage) => { + // copy to clipboard + if (selectOrCopy(e.currentTarget, message.content)) { + if (userInput.length === 0) { + setUserInput(message.content); + } + + e.preventDefault(); + } + }; + + const deleteMessage = (msgId?: string) => { + chatStore.updateCurrentSession( + (session) => + (session.messages = session.messages.filter((m) => m.id !== msgId)), + ); + }; + + const onDelete = (msgId: string) => { + deleteMessage(msgId); + }; + + const onResend = (message: ChatMessage) => { + // when it is resending a message + // 1. for a user's message, find the next bot response + // 2. for a bot's message, find the last user's input + // 3. delete original user input and bot's message + // 4. resend the user's input + + const resendingIndex = session.messages.findIndex( + (m) => m.id === message.id, + ); + + if (resendingIndex < 0 || resendingIndex >= session.messages.length) { + console.error("[Chat] failed to find resending message", message); + return; + } + + let userMessage: ChatMessage | undefined; + let botMessage: ChatMessage | undefined; + + if (message.role === "assistant") { + // if it is resending a bot's message, find the user input for it + botMessage = message; + for (let i = resendingIndex; i >= 0; i -= 1) { + if (session.messages[i].role === "user") { + userMessage = session.messages[i]; + break; + } + } + } else if (message.role === "user") { + // if it is resending a user's input, find the bot's response + userMessage = message; + for (let i = resendingIndex; i < session.messages.length; i += 1) { + if (session.messages[i].role === "assistant") { + botMessage = session.messages[i]; + break; + } + } + } + + if (userMessage === undefined) { + console.error("[Chat] failed to resend", message); + return; + } + + // delete the original messages + deleteMessage(userMessage.id); + deleteMessage(botMessage?.id); + + // resend the message + setIsLoading(true); + chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false)); + inputRef.current?.focus(); + }; + + const onPinMessage = (message: ChatMessage) => { + chatStore.updateCurrentSession((session) => + session.mask.context.push(message), + ); + + showToast(Locale.Chat.Actions.PinToastContent, { + text: Locale.Chat.Actions.PinToastAction, + onClick: () => { + setShowPromptModal(true); + }, + }); + }; + + const context: RenderMessage[] = useMemo(() => { + return session.mask.hideContext ? [] : session.mask.context.slice(); + }, [session.mask.context, session.mask.hideContext]); + const accessStore = useAccessStore(); + + if ( + context.length === 0 && + session.messages.at(0)?.content !== BOT_HELLO.content + ) { + const copiedHello = Object.assign({}, BOT_HELLO); + if (!accessStore.isAuthorized()) { + copiedHello.content = Locale.Error.Unauthorized; + } + context.push(copiedHello); + } + + // preview messages + const renderMessages = useMemo(() => { + return context + .concat(session.messages as RenderMessage[]) + .concat( + isLoading + ? [ + { + ...createMessage({ + role: "assistant", + content: "……", + }), + preview: true, + }, + ] + : [], + ) + .concat( + userInput.length > 0 && config.sendPreviewBubble + ? [ + { + ...createMessage({ + role: "user", + content: userInput, + }), + preview: true, + }, + ] + : [], + ); + }, [ + config.sendPreviewBubble, + context, + isLoading, + session.messages, + userInput, + ]); + + const [msgRenderIndex, _setMsgRenderIndex] = useState( + Math.max(0, renderMessages.length - CHAT_PAGE_SIZE), + ); + function setMsgRenderIndex(newIndex: number) { + newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex); + newIndex = Math.max(0, newIndex); + _setMsgRenderIndex(newIndex); + } + + const messages = useMemo(() => { + const endRenderIndex = Math.min( + msgRenderIndex + 3 * CHAT_PAGE_SIZE, + renderMessages.length, + ); + return renderMessages.slice(msgRenderIndex, endRenderIndex); + }, [msgRenderIndex, renderMessages]); + + const onChatBodyScroll = (e: HTMLElement) => { + const bottomHeight = e.scrollTop + e.clientHeight; + const edgeThreshold = e.clientHeight; + + const isTouchTopEdge = e.scrollTop <= edgeThreshold; + const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold; + const isHitBottom = + bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10); + + const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE; + const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE; + + if (isTouchTopEdge && !isTouchBottomEdge) { + setMsgRenderIndex(prevPageMsgIndex); + } else if (isTouchBottomEdge) { + setMsgRenderIndex(nextPageMsgIndex); + } + + setHitBottom(isHitBottom); + setAutoScroll(isHitBottom); + }; + + function scrollToBottom() { + setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); + scrollDomToBottom(); + } + + // clear context index = context length + index in messages + const clearContextIndex = + (session.clearContextIndex ?? -1) >= 0 + ? session.clearContextIndex! + context.length - msgRenderIndex + : -1; + + const [showPromptModal, setShowPromptModal] = useState(false); + + const clientConfig = useMemo(() => getClientConfig(), []); + + const autoFocus = !isMobileScreen; // wont auto focus on mobile screen + const showMaxIcon = !isMobileScreen && !clientConfig?.isApp; + + useCommand({ + fill: setUserInput, + submit: (text) => { + doSubmit(text); + }, + code: (text) => { + if (accessStore.disableFastLink) return; + console.log("[Command] got code from url: ", text); + showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => { + if (res) { + accessStore.update((access) => (access.accessCode = text)); + } + }); + }, + settings: (text) => { + if (accessStore.disableFastLink) return; + + try { + const payload = JSON.parse(text) as { + key?: string; + url?: string; + }; + + console.log("[Command] got settings from url: ", payload); + + if (payload.key || payload.url) { + showConfirm( + Locale.URLCommand.Settings + + `\n${JSON.stringify(payload, null, 4)}`, + ).then((res) => { + if (!res) return; + if (payload.key) { + accessStore.update( + (access) => (access.openaiApiKey = payload.key!), + ); + } + if (payload.url) { + accessStore.update((access) => (access.openaiUrl = payload.url!)); + } + }); + } + } catch { + console.error("[Command] failed to get settings from url: ", text); + } + }, + }); + + // edit / insert message modal + const [isEditingMessage, setIsEditingMessage] = useState(false); + + // remember unfinished input + useEffect(() => { + // try to load from local storage + const key = UNFINISHED_INPUT(session.id); + const mayBeUnfinishedInput = localStorage.getItem(key); + if (mayBeUnfinishedInput && userInput.length === 0) { + setUserInput(mayBeUnfinishedInput); + localStorage.removeItem(key); + } + + const dom = inputRef.current; + return () => { + localStorage.setItem(key, dom?.value ?? ""); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {isMobileScreen && ( +
+
+ } + bordered + title={Locale.Chat.Actions.ChatList} + onClick={() => navigate(Path.Home)} + /> +
+
+ )} + +
+
setIsEditingMessage(true)} + > + {!session.topic ? DEFAULT_TOPIC : session.topic} +
+
+ {Locale.Chat.SubTitle(session.messages.length)} +
+
+
+ {!isMobileScreen && ( +
+ } + bordered + onClick={() => setIsEditingMessage(true)} + /> +
+ )} +
+ } + bordered + title={Locale.Chat.Actions.Export} + onClick={() => { + setShowExport(true); + }} + /> +
+ {showMaxIcon && ( +
+ : } + bordered + onClick={() => { + config.update( + (config) => (config.tightBorder = !config.tightBorder), + ); + }} + /> +
+ )} +
+ + +
+ +
onChatBodyScroll(e.currentTarget)} + onMouseDown={() => inputRef.current?.blur()} + onTouchStart={() => { + inputRef.current?.blur(); + setAutoScroll(false); + }} + > + {messages.map((message, i) => { + const isUser = message.role === "user"; + const isContext = i < context.length; + const showActions = + i > 0 && + !(message.preview || message.content.length === 0) && + !isContext; + const showTyping = message.preview || message.streaming; + + const shouldShowClearContextDivider = i === clearContextIndex - 1; + + return ( + +
+
+
+
+
+ } + onClick={async () => { + const newMessage = await showPrompt( + Locale.Chat.Actions.Edit, + message.content, + 10, + ); + chatStore.updateCurrentSession((session) => { + const m = session.mask.context + .concat(session.messages) + .find((m) => m.id === message.id); + if (m) { + m.content = newMessage; + } + }); + }} + > +
+ {isUser ? ( + + ) : ( + <> + {["system"].includes(message.role) ? ( + + ) : ( + + )} + + )} +
+ + {showActions && ( +
+
+ {message.streaming ? ( + } + onClick={() => onUserStop(message.id ?? i)} + /> + ) : ( + <> + } + onClick={() => onResend(message)} + /> + + } + onClick={() => onDelete(message.id ?? i)} + /> + + } + onClick={() => onPinMessage(message)} + /> + } + onClick={() => copyToClipboard(message.content)} + /> + + )} +
+
+ )} +
+ {showTyping && ( +
+ {Locale.Chat.Typing} +
+ )} +
+ onRightClick(e, message)} + onDoubleClickCapture={() => { + if (!isMobileScreen) return; + setUserInput(message.content); + }} + fontSize={fontSize} + parentRef={scrollRef} + defaultShow={i >= messages.length - 6} + /> +
+ +
+ {isContext + ? Locale.Chat.IsContext + : message.date.toLocaleString()} +
+
+
+ {shouldShowClearContextDivider && } +
+ ); + })} +
+ +
+ + + setShowPromptModal(true)} + scrollToBottom={scrollToBottom} + hitBottom={hitBottom} + showPromptHints={() => { + // Click again to close + if (promptHints.length > 0) { + setPromptHints([]); + return; + } + + inputRef.current?.focus(); + setUserInput("/"); + onSearch(""); + }} + /> +
+ +