diff --git a/app/src/assets/generation.less b/app/src/assets/generation.less index d9cc484b..224984c6 100644 --- a/app/src/assets/generation.less +++ b/app/src/assets/generation.less @@ -148,7 +148,6 @@ display: flex; flex-direction: row; width: 80%; - gap: 8px; margin: 0 auto; max-width: 680px; @@ -160,6 +159,7 @@ border-radius: var(--radius); border: 1px solid hsl(var(--border-hover)); letter-spacing: 1px; + margin-right: 8px; } .action { diff --git a/app/src/assets/home.less b/app/src/assets/home.less index 78a071ea..cfdfd8ad 100644 --- a/app/src/assets/home.less +++ b/app/src/assets/home.less @@ -274,12 +274,12 @@ align-items: center; flex-wrap: nowrap; width: 100%; - gap: 4px; height: min-content; .chat-box { position: relative; flex-grow: 1; + margin: 0 4px; } .input-box { diff --git a/app/src/components/home/ChatInterface.tsx b/app/src/components/home/ChatInterface.tsx new file mode 100644 index 00000000..4eaeeeb8 --- /dev/null +++ b/app/src/components/home/ChatInterface.tsx @@ -0,0 +1,77 @@ +import {useEffect, useRef, useState} from "react"; +import {Message} from "../../conversation/types.ts"; +import {useSelector} from "react-redux"; +import {selectCurrent, selectMessages} from "../../store/chat.ts"; +import {Button} from "../ui/button.tsx"; +import {ChevronDown} from "lucide-react"; +import MessageSegment from "../Message.tsx"; +import {connectionEvent} from "../../events/connection.ts"; + +function ChatInterface() { + const ref = useRef(null); + const [scroll, setScroll] = useState(false); + const messages: Message[] = useSelector(selectMessages); + const current: number = useSelector(selectCurrent); + + function listenScrolling() { + if (!ref.current) return; + const el = ref.current as HTMLDivElement; + const offset = el.scrollHeight - el.scrollTop - el.clientHeight; + setScroll(offset > 100); + } + + useEffect( + function () { + if (!ref.current) return; + const el = ref.current as HTMLDivElement; + el.scrollTop = el.scrollHeight; + listenScrolling(); + }, + [messages], + ); + + useEffect(() => { + if (!ref.current) return; + const el = ref.current as HTMLDivElement; + el.addEventListener("scroll", listenScrolling); + }, [ref]); + + return ( + <> +
+
+ +
+ + {messages.map((message, i) => ( + { + connectionEvent.emit({ + id: current, + event: e, + }); + }} + key={i} + /> + ))} +
+ + ); +} + +export default ChatInterface; diff --git a/app/src/components/home/ChatWrapper.tsx b/app/src/components/home/ChatWrapper.tsx new file mode 100644 index 00000000..6c7e7bcc --- /dev/null +++ b/app/src/components/home/ChatWrapper.tsx @@ -0,0 +1,172 @@ +import {useTranslation} from "react-i18next"; +import React, {useEffect, useRef, useState} from "react"; +import FileProvider, {FileObject} from "../FileProvider.tsx"; +import {useDispatch, useSelector} from "react-redux"; +import {selectAuthenticated, selectInit} from "../../store/auth.ts"; +import {selectMessages, selectModel, selectWeb, setWeb} from "../../store/chat.ts"; +import {manager} from "../../conversation/manager.ts"; +import {formatMessage} from "../../utils.ts"; +import ChatInterface from "./ChatInterface.tsx"; +import {Button} from "../ui/button.tsx"; +import router from "../../router.ts"; +import {ChevronRight, FolderKanban, Globe} from "lucide-react"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "../ui/tooltip.tsx"; +import {Toggle} from "../ui/toggle.tsx"; +import {Input} from "../ui/input.tsx"; +import EditorProvider from "../EditorProvider.tsx"; +import ModelSelector from "./ModelSelector.tsx"; + +function ChatWrapper() { + const { t } = useTranslation(); + const [file, setFile] = useState({ + name: "", + content: "", + }); + const [clearEvent, setClearEvent] = useState<() => void>(() => {}); + const [input, setInput] = useState(""); + const dispatch = useDispatch(); + const init = useSelector(selectInit); + const auth = useSelector(selectAuthenticated); + const model = useSelector(selectModel); + const web = useSelector(selectWeb); + const messages = useSelector(selectMessages); + const target = useRef(null); + manager.setDispatch(dispatch); + + function clearFile() { + clearEvent?.(); + } + + async function processSend( + data: string, + auth: boolean, + model: string, + web: boolean, + ): Promise { + const message: string = formatMessage(file, data); + if (message.length > 0 && data.trim().length > 0) { + if (await manager.send(t, auth, { message, web, model, type: "chat" })) { + clearFile(); + return true; + } + } + return false; + } + + async function handleSend(auth: boolean, model: string, web: boolean) { + // because of the function wrapper, we need to update the selector state using props. + if (await processSend(input, auth, model, web)) { + setInput(""); + } + } + + window.addEventListener("load", () => { + const el = document.getElementById("input"); + if (el) el.focus(); + }); + + useEffect(() => { + if (!init) return; + const search = new URLSearchParams(window.location.search); + const query = (search.get("q") || "").trim(); + if (query.length > 0) processSend(query, auth, model, web).then(); + window.history.replaceState({}, "", "/"); + }, [init]); + + return ( +
+
+ {messages.length > 0 ? ( + + ) : ( +
+ +
+ )} +
+
+ + + + + dispatch(setWeb(state)) + } + variant={`outline`} + > + + + + +

{t("chat.web")}

+
+
+
+
+ {auth && ( + + )} + ) => + setInput(e.target.value) + } + placeholder={t("chat.placeholder")} + onKeyDown={async (e: React.KeyboardEvent) => { + if (e.key === "Enter") await handleSend(auth, model, web); + }} + /> + +
+ +
+
+ +
+
+
+
+ ); +} + +export default ChatWrapper; diff --git a/app/src/components/home/SideBar.tsx b/app/src/components/home/SideBar.tsx new file mode 100644 index 00000000..b374c355 --- /dev/null +++ b/app/src/components/home/SideBar.tsx @@ -0,0 +1,259 @@ +import {useTranslation} from "react-i18next"; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "../../store"; +import {selectAuthenticated} from "../../store/auth.ts"; +import {selectCurrent, selectHistory} from "../../store/chat.ts"; +import {useRef, useState} from "react"; +import {ConversationInstance} from "../../conversation/types.ts"; +import {useToast} from "../ui/use-toast.ts"; +import {copyClipboard, extractMessage, filterMessage, mobile, useAnimation, useEffectAsync} from "../../utils.ts"; +import {deleteConversation, toggleConversation, updateConversationList} from "../../conversation/history.ts"; +import {Button} from "../ui/button.tsx"; +import {setMenu} from "../../store/menu.ts"; +import {Copy, LogIn, Plus, RotateCw} from "lucide-react"; +import ConversationSegment from "./ConversationSegment.tsx"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from "../ui/alert-dialog.tsx"; +import {shareConversation} from "../../conversation/sharing.ts"; +import {Input} from "../ui/input.tsx"; +import {login} from "../../conf.ts"; + +function SideBar() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const open = useSelector((state: RootState) => state.menu.open); + const auth = useSelector(selectAuthenticated); + const current = useSelector(selectCurrent); + const [operateConversation, setOperateConversation] = useState<{ + target: ConversationInstance | null; + type: string; + }>({ target: null, type: "" }); + const { toast } = useToast(); + const history: ConversationInstance[] = useSelector(selectHistory); + const refresh = useRef(null); + const [shared, setShared] = useState(""); + useEffectAsync(async () => { + await updateConversationList(dispatch); + }, []); + + // @ts-ignore + return ( +
+ {auth ? ( +
+
+ +
+ +
+
+ {history.length ? ( + history.map((conversation, i) => ( + + )) + ) : ( +
{t("conversation.empty")}
+ )} +
+ { + if (!open) setOperateConversation({ target: null, type: "" }); + }} + > + + + + {t("conversation.remove-title")} + + + {t("conversation.remove-description")} + + {extractMessage( + filterMessage(operateConversation?.target?.name || ""), + )} + + {t("end")} + + + + + {t("conversation.cancel")} + + { + e.preventDefault(); + e.stopPropagation(); + + if ( + await deleteConversation( + dispatch, + operateConversation?.target?.id || -1, + ) + ) + toast({ + title: t("conversation.delete-success"), + description: t("conversation.delete-success-prompt"), + }); + else + toast({ + title: t("conversation.delete-failed"), + description: t("conversation.delete-failed-prompt"), + }); + setOperateConversation({ target: null, type: "" }); + }} + > + {t("conversation.delete")} + + + + + + { + if (!open) setOperateConversation({ target: null, type: "" }); + }} + > + + + {t("share.title")} + + {t("share.description")} + + {extractMessage( + filterMessage(operateConversation?.target?.name || ""), + )} + + {t("end")} + + + + + {t("conversation.cancel")} + + { + e.preventDefault(); + e.stopPropagation(); + + const resp = await shareConversation( + operateConversation?.target?.id || -1, + ); + if (resp.status) + setShared(`${location.origin}/share/${resp.data}`); + else + toast({ + title: t("share.failed"), + description: resp.message, + }); + + setOperateConversation({ target: null, type: "" }); + }} + > + {t("share.title")} + + + + + + 0} + onOpenChange={(open) => { + if (!open) { + setShared(""); + setOperateConversation({ target: null, type: "" }); + } + }} + > + + + {t("share.success")} + +
+ + +
+
+
+ + {t("close")} + { + e.preventDefault(); + e.stopPropagation(); + window.open(shared, "_blank"); + }} + > + {t("share.view")} + + +
+
+
+ ) : ( + + )} +
+ ); +} + +export default SideBar; diff --git a/app/src/routes/Home.tsx b/app/src/routes/Home.tsx index 674e77ae..3ec682fa 100644 --- a/app/src/routes/Home.tsx +++ b/app/src/routes/Home.tsx @@ -1,527 +1,7 @@ import "../assets/home.less"; import "../assets/chat.less"; -import { Input } from "../components/ui/input.tsx"; -import { Toggle } from "../components/ui/toggle.tsx"; -import { - ChevronDown, - ChevronRight, - Copy, - FolderKanban, - Globe, - LogIn, - Plus, - RotateCw, -} from "lucide-react"; -import { Button } from "../components/ui/button.tsx"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../components/ui/tooltip.tsx"; -import { useDispatch, useSelector } from "react-redux"; -import type { RootState } from "../store"; -import { selectAuthenticated, selectInit } from "../store/auth.ts"; -import { login } from "../conf.ts"; -import { - deleteConversation, - toggleConversation, - updateConversationList, -} from "../conversation/history.ts"; -import { shareConversation } from "../conversation/sharing.ts"; -import React, { useEffect, useRef, useState } from "react"; -import { - filterMessage, - extractMessage, - formatMessage, - mobile, - useAnimation, - useEffectAsync, - copyClipboard, -} from "../utils.ts"; -import { useToast } from "../components/ui/use-toast.ts"; -import { ConversationInstance, Message } from "../conversation/types.ts"; -import { - selectCurrent, - selectModel, - selectHistory, - selectMessages, - selectWeb, - setWeb, -} from "../store/chat.ts"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../components/ui/alert-dialog.tsx"; -import { manager } from "../conversation/manager.ts"; -import { useTranslation } from "react-i18next"; -import MessageSegment from "../components/Message.tsx"; -import { setMenu } from "../store/menu.ts"; -import FileProvider, { FileObject } from "../components/FileProvider.tsx"; -import router from "../router.ts"; -import EditorProvider from "../components/EditorProvider.tsx"; -import ConversationSegment from "../components/home/ConversationSegment.tsx"; -import { connectionEvent } from "../events/connection.ts"; -import ModelSelector from "../components/home/ModelSelector.tsx"; - -function SideBar() { - const { t } = useTranslation(); - const dispatch = useDispatch(); - const open = useSelector((state: RootState) => state.menu.open); - const auth = useSelector(selectAuthenticated); - const current = useSelector(selectCurrent); - const [operateConversation, setOperateConversation] = useState<{ - target: ConversationInstance | null; - type: string; - }>({ target: null, type: "" }); - const { toast } = useToast(); - const history: ConversationInstance[] = useSelector(selectHistory); - const refresh = useRef(null); - const [shared, setShared] = useState(""); - useEffectAsync(async () => { - await updateConversationList(dispatch); - }, []); - - // @ts-ignore - return ( -
- {auth ? ( -
-
- -
- -
-
- {history.length ? ( - history.map((conversation, i) => ( - - )) - ) : ( -
{t("conversation.empty")}
- )} -
- { - if (!open) setOperateConversation({ target: null, type: "" }); - }} - > - - - - {t("conversation.remove-title")} - - - {t("conversation.remove-description")} - - {extractMessage( - filterMessage(operateConversation?.target?.name || ""), - )} - - {t("end")} - - - - - {t("conversation.cancel")} - - { - e.preventDefault(); - e.stopPropagation(); - - if ( - await deleteConversation( - dispatch, - operateConversation?.target?.id || -1, - ) - ) - toast({ - title: t("conversation.delete-success"), - description: t("conversation.delete-success-prompt"), - }); - else - toast({ - title: t("conversation.delete-failed"), - description: t("conversation.delete-failed-prompt"), - }); - setOperateConversation({ target: null, type: "" }); - }} - > - {t("conversation.delete")} - - - - - - { - if (!open) setOperateConversation({ target: null, type: "" }); - }} - > - - - {t("share.title")} - - {t("share.description")} - - {extractMessage( - filterMessage(operateConversation?.target?.name || ""), - )} - - {t("end")} - - - - - {t("conversation.cancel")} - - { - e.preventDefault(); - e.stopPropagation(); - - const resp = await shareConversation( - operateConversation?.target?.id || -1, - ); - if (resp.status) - setShared(`${location.origin}/share/${resp.data}`); - else - toast({ - title: t("share.failed"), - description: resp.message, - }); - - setOperateConversation({ target: null, type: "" }); - }} - > - {t("share.title")} - - - - - - 0} - onOpenChange={(open) => { - if (!open) { - setShared(""); - setOperateConversation({ target: null, type: "" }); - } - }} - > - - - {t("share.success")} - -
- - -
-
-
- - {t("close")} - { - e.preventDefault(); - e.stopPropagation(); - window.open(shared, "_blank"); - }} - > - {t("share.view")} - - -
-
-
- ) : ( - - )} -
- ); -} - -function ChatInterface() { - const ref = useRef(null); - const [scroll, setScroll] = useState(false); - const messages: Message[] = useSelector(selectMessages); - const current: number = useSelector(selectCurrent); - - function listenScrolling() { - if (!ref.current) return; - const el = ref.current as HTMLDivElement; - const offset = el.scrollHeight - el.scrollTop - el.clientHeight; - setScroll(offset > 100); - } - - useEffect( - function () { - if (!ref.current) return; - const el = ref.current as HTMLDivElement; - el.scrollTop = el.scrollHeight; - listenScrolling(); - }, - [messages], - ); - - useEffect(() => { - if (!ref.current) return; - const el = ref.current as HTMLDivElement; - el.addEventListener("scroll", listenScrolling); - }, [ref]); - - return ( - <> -
-
- -
- - {messages.map((message, i) => ( - { - connectionEvent.emit({ - id: current, - event: e, - }); - }} - key={i} - /> - ))} -
- - ); -} - -function ChatWrapper() { - const { t } = useTranslation(); - const [file, setFile] = useState({ - name: "", - content: "", - }); - const [clearEvent, setClearEvent] = useState<() => void>(() => {}); - const [input, setInput] = useState(""); - const dispatch = useDispatch(); - const init = useSelector(selectInit); - const auth = useSelector(selectAuthenticated); - const model = useSelector(selectModel); - const web = useSelector(selectWeb); - const messages = useSelector(selectMessages); - const target = useRef(null); - manager.setDispatch(dispatch); - - function clearFile() { - clearEvent?.(); - } - - async function processSend( - data: string, - auth: boolean, - model: string, - web: boolean, - ): Promise { - const message: string = formatMessage(file, data); - if (message.length > 0 && data.trim().length > 0) { - if (await manager.send(t, auth, { message, web, model, type: "chat" })) { - clearFile(); - return true; - } - } - return false; - } - - async function handleSend(auth: boolean, model: string, web: boolean) { - // because of the function wrapper, we need to update the selector state using props. - if (await processSend(input, auth, model, web)) { - setInput(""); - } - } - - window.addEventListener("load", () => { - const el = document.getElementById("input"); - if (el) el.focus(); - }); - - useEffect(() => { - if (!init) return; - const search = new URLSearchParams(window.location.search); - const query = (search.get("q") || "").trim(); - if (query.length > 0) processSend(query, auth, model, web).then(); - window.history.replaceState({}, "", "/"); - }, [init]); - - return ( -
-
- {messages.length > 0 ? ( - - ) : ( -
- -
- )} -
-
- - - - - dispatch(setWeb(state)) - } - variant={`outline`} - > - - - - -

{t("chat.web")}

-
-
-
-
- {auth && ( - - )} - ) => - setInput(e.target.value) - } - placeholder={t("chat.placeholder")} - onKeyDown={async (e: React.KeyboardEvent) => { - if (e.key === "Enter") await handleSend(auth, model, web); - }} - /> - -
- -
-
- -
-
-
-
- ); -} +import ChatWrapper from "../components/home/ChatWrapper.tsx"; +import SideBar from "../components/home/SideBar.tsx"; function Home() { return (