mirror of
https://github.com/coaidev/coai.git
synced 2025-05-30 10:20:21 +09:00
browser adapter: fix gap in flexbox (chronium <84)
This commit is contained in:
parent
663fcf809b
commit
cb5829b85a
@ -148,7 +148,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 80%;
|
width: 80%;
|
||||||
gap: 8px;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
|
|
||||||
@ -160,6 +159,7 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid hsl(var(--border-hover));
|
border: 1px solid hsl(var(--border-hover));
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
|
@ -274,12 +274,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 4px;
|
|
||||||
height: min-content;
|
height: min-content;
|
||||||
|
|
||||||
.chat-box {
|
.chat-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-box {
|
.input-box {
|
||||||
|
77
app/src/components/home/ChatInterface.tsx
Normal file
77
app/src/components/home/ChatInterface.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className={`chat-content`} ref={ref}>
|
||||||
|
<div className={`scroll-action ${scroll ? "active" : ""}`}>
|
||||||
|
<Button
|
||||||
|
variant={`outline`}
|
||||||
|
size={`icon`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const el = ref.current as HTMLDivElement;
|
||||||
|
el.scrollTo({
|
||||||
|
top: el.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronDown className={`h-4 w-4`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messages.map((message, i) => (
|
||||||
|
<MessageSegment
|
||||||
|
message={message}
|
||||||
|
end={i === messages.length - 1}
|
||||||
|
onEvent={(e: string) => {
|
||||||
|
connectionEvent.emit({
|
||||||
|
id: current,
|
||||||
|
event: e,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
key={i}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatInterface;
|
172
app/src/components/home/ChatWrapper.tsx
Normal file
172
app/src/components/home/ChatWrapper.tsx
Normal file
@ -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<FileObject>({
|
||||||
|
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<boolean> {
|
||||||
|
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 (
|
||||||
|
<div className={`chat-container`}>
|
||||||
|
<div className={`chat-wrapper`}>
|
||||||
|
{messages.length > 0 ? (
|
||||||
|
<ChatInterface />
|
||||||
|
) : (
|
||||||
|
<div className={`chat-product`}>
|
||||||
|
<Button
|
||||||
|
variant={`outline`}
|
||||||
|
onClick={() => router.navigate("/generate")}
|
||||||
|
>
|
||||||
|
<FolderKanban className={`h-4 w-4 mr-1.5`} />
|
||||||
|
{t("generate.title")}
|
||||||
|
<ChevronRight className={`h-4 w-4 ml-2`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`chat-input`}>
|
||||||
|
<div className={`input-wrapper`}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Toggle
|
||||||
|
aria-label={t("chat.web-aria")}
|
||||||
|
defaultPressed={true}
|
||||||
|
onPressedChange={(state: boolean) =>
|
||||||
|
dispatch(setWeb(state))
|
||||||
|
}
|
||||||
|
variant={`outline`}
|
||||||
|
>
|
||||||
|
<Globe className={`h-4 w-4 web ${web ? "enable" : ""}`} />
|
||||||
|
</Toggle>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className={`tooltip`}>{t("chat.web")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<div className={`chat-box`}>
|
||||||
|
{auth && (
|
||||||
|
<FileProvider
|
||||||
|
id={`file`}
|
||||||
|
className={`file`}
|
||||||
|
onChange={setFile}
|
||||||
|
maxLength={4000 * 1.25}
|
||||||
|
setClearEvent={setClearEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
id={`input`}
|
||||||
|
className={`input-box`}
|
||||||
|
ref={target}
|
||||||
|
value={input}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setInput(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("chat.placeholder")}
|
||||||
|
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") await handleSend(auth, model, web);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EditorProvider
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
className={`editor`}
|
||||||
|
id={`editor`}
|
||||||
|
placeholder={t("chat.placeholder")}
|
||||||
|
maxLength={8000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size={`icon`}
|
||||||
|
variant="outline"
|
||||||
|
className={`send-button`}
|
||||||
|
onClick={() => handleSend(auth, model, web)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="m21.426 11.095-17-8A1 1 0 0 0 3.03 4.242l1.212 4.849L12 12l-7.758 2.909-1.212 4.849a.998.998 0 0 0 1.396 1.147l17-8a1 1 0 0 0 0-1.81z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={`input-options`}>
|
||||||
|
<ModelSelector />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatWrapper;
|
259
app/src/components/home/SideBar.tsx
Normal file
259
app/src/components/home/SideBar.tsx
Normal file
@ -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<string>("");
|
||||||
|
useEffectAsync(async () => {
|
||||||
|
await updateConversationList(dispatch);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return (
|
||||||
|
<div className={`sidebar ${open ? "open" : ""}`}>
|
||||||
|
{auth ? (
|
||||||
|
<div className={`sidebar-content`}>
|
||||||
|
<div className={`sidebar-action`}>
|
||||||
|
<Button
|
||||||
|
variant={`ghost`}
|
||||||
|
size={`icon`}
|
||||||
|
onClick={async () => {
|
||||||
|
await toggleConversation(dispatch, -1);
|
||||||
|
if (mobile) dispatch(setMenu(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className={`h-4 w-4`} />
|
||||||
|
</Button>
|
||||||
|
<div className={`grow`} />
|
||||||
|
<Button
|
||||||
|
className={`refresh-action`}
|
||||||
|
variant={`ghost`}
|
||||||
|
size={`icon`}
|
||||||
|
id={`refresh`}
|
||||||
|
ref={refresh}
|
||||||
|
onClick={() => {
|
||||||
|
const hook = useAnimation(refresh, "active", 500);
|
||||||
|
updateConversationList(dispatch)
|
||||||
|
.catch(() =>
|
||||||
|
toast({
|
||||||
|
title: t("conversation.refresh-failed"),
|
||||||
|
description: t("conversation.refresh-failed-prompt"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(hook);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCw className={`h-4 w-4`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={`conversation-list`}>
|
||||||
|
{history.length ? (
|
||||||
|
history.map((conversation, i) => (
|
||||||
|
<ConversationSegment
|
||||||
|
operate={setOperateConversation}
|
||||||
|
conversation={conversation}
|
||||||
|
current={current}
|
||||||
|
key={i}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={`empty`}>{t("conversation.empty")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AlertDialog
|
||||||
|
open={
|
||||||
|
operateConversation.type === "delete" &&
|
||||||
|
!!operateConversation.target
|
||||||
|
}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setOperateConversation({ target: null, type: "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("conversation.remove-title")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("conversation.remove-description")}
|
||||||
|
<strong className={`conversation-name`}>
|
||||||
|
{extractMessage(
|
||||||
|
filterMessage(operateConversation?.target?.name || ""),
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
{t("end")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("conversation.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async (e) => {
|
||||||
|
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")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={
|
||||||
|
operateConversation.type === "share" &&
|
||||||
|
!!operateConversation.target
|
||||||
|
}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setOperateConversation({ target: null, type: "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("share.title")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("share.description")}
|
||||||
|
<strong className={`conversation-name`}>
|
||||||
|
{extractMessage(
|
||||||
|
filterMessage(operateConversation?.target?.name || ""),
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
{t("end")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("conversation.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async (e) => {
|
||||||
|
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")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={shared.length > 0}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setShared("");
|
||||||
|
setOperateConversation({ target: null, type: "" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("share.success")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<div className={`share-wrapper mt-4 mb-2`}>
|
||||||
|
<Input value={shared} />
|
||||||
|
<Button
|
||||||
|
variant={`default`}
|
||||||
|
size={`icon`}
|
||||||
|
onClick={async () => {
|
||||||
|
await copyClipboard(shared);
|
||||||
|
toast({
|
||||||
|
title: t("share.copied"),
|
||||||
|
description: t("share.copied-description"),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className={`h-4 w-4`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("close")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(shared, "_blank");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("share.view")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button className={`login-action`} variant={`default`} onClick={login}>
|
||||||
|
<LogIn className={`h-3 w-3 mr-2`} /> {t("login")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SideBar;
|
@ -1,527 +1,7 @@
|
|||||||
import "../assets/home.less";
|
import "../assets/home.less";
|
||||||
import "../assets/chat.less";
|
import "../assets/chat.less";
|
||||||
import { Input } from "../components/ui/input.tsx";
|
import ChatWrapper from "../components/home/ChatWrapper.tsx";
|
||||||
import { Toggle } from "../components/ui/toggle.tsx";
|
import SideBar from "../components/home/SideBar.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<string>("");
|
|
||||||
useEffectAsync(async () => {
|
|
||||||
await updateConversationList(dispatch);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
return (
|
|
||||||
<div className={`sidebar ${open ? "open" : ""}`}>
|
|
||||||
{auth ? (
|
|
||||||
<div className={`sidebar-content`}>
|
|
||||||
<div className={`sidebar-action`}>
|
|
||||||
<Button
|
|
||||||
variant={`ghost`}
|
|
||||||
size={`icon`}
|
|
||||||
onClick={async () => {
|
|
||||||
await toggleConversation(dispatch, -1);
|
|
||||||
if (mobile) dispatch(setMenu(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className={`h-4 w-4`} />
|
|
||||||
</Button>
|
|
||||||
<div className={`grow`} />
|
|
||||||
<Button
|
|
||||||
className={`refresh-action`}
|
|
||||||
variant={`ghost`}
|
|
||||||
size={`icon`}
|
|
||||||
id={`refresh`}
|
|
||||||
ref={refresh}
|
|
||||||
onClick={() => {
|
|
||||||
const hook = useAnimation(refresh, "active", 500);
|
|
||||||
updateConversationList(dispatch)
|
|
||||||
.catch(() =>
|
|
||||||
toast({
|
|
||||||
title: t("conversation.refresh-failed"),
|
|
||||||
description: t("conversation.refresh-failed-prompt"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.finally(hook);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateCw className={`h-4 w-4`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className={`conversation-list`}>
|
|
||||||
{history.length ? (
|
|
||||||
history.map((conversation, i) => (
|
|
||||||
<ConversationSegment
|
|
||||||
operate={setOperateConversation}
|
|
||||||
conversation={conversation}
|
|
||||||
current={current}
|
|
||||||
key={i}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className={`empty`}>{t("conversation.empty")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AlertDialog
|
|
||||||
open={
|
|
||||||
operateConversation.type === "delete" &&
|
|
||||||
!!operateConversation.target
|
|
||||||
}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setOperateConversation({ target: null, type: "" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{t("conversation.remove-title")}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("conversation.remove-description")}
|
|
||||||
<strong className={`conversation-name`}>
|
|
||||||
{extractMessage(
|
|
||||||
filterMessage(operateConversation?.target?.name || ""),
|
|
||||||
)}
|
|
||||||
</strong>
|
|
||||||
{t("end")}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{t("conversation.cancel")}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async (e) => {
|
|
||||||
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")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={
|
|
||||||
operateConversation.type === "share" &&
|
|
||||||
!!operateConversation.target
|
|
||||||
}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setOperateConversation({ target: null, type: "" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("share.title")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("share.description")}
|
|
||||||
<strong className={`conversation-name`}>
|
|
||||||
{extractMessage(
|
|
||||||
filterMessage(operateConversation?.target?.name || ""),
|
|
||||||
)}
|
|
||||||
</strong>
|
|
||||||
{t("end")}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{t("conversation.cancel")}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async (e) => {
|
|
||||||
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")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={shared.length > 0}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setShared("");
|
|
||||||
setOperateConversation({ target: null, type: "" });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("share.success")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
<div className={`share-wrapper mt-4 mb-2`}>
|
|
||||||
<Input value={shared} />
|
|
||||||
<Button
|
|
||||||
variant={`default`}
|
|
||||||
size={`icon`}
|
|
||||||
onClick={async () => {
|
|
||||||
await copyClipboard(shared);
|
|
||||||
toast({
|
|
||||||
title: t("share.copied"),
|
|
||||||
description: t("share.copied-description"),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className={`h-4 w-4`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t("close")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
window.open(shared, "_blank");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("share.view")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button className={`login-action`} variant={`default`} onClick={login}>
|
|
||||||
<LogIn className={`h-3 w-3 mr-2`} /> {t("login")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<div className={`chat-content`} ref={ref}>
|
|
||||||
<div className={`scroll-action ${scroll ? "active" : ""}`}>
|
|
||||||
<Button
|
|
||||||
variant={`outline`}
|
|
||||||
size={`icon`}
|
|
||||||
onClick={() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const el = ref.current as HTMLDivElement;
|
|
||||||
el.scrollTo({
|
|
||||||
top: el.scrollHeight,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronDown className={`h-4 w-4`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{messages.map((message, i) => (
|
|
||||||
<MessageSegment
|
|
||||||
message={message}
|
|
||||||
end={i === messages.length - 1}
|
|
||||||
onEvent={(e: string) => {
|
|
||||||
connectionEvent.emit({
|
|
||||||
id: current,
|
|
||||||
event: e,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
key={i}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatWrapper() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [file, setFile] = useState<FileObject>({
|
|
||||||
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<boolean> {
|
|
||||||
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 (
|
|
||||||
<div className={`chat-container`}>
|
|
||||||
<div className={`chat-wrapper`}>
|
|
||||||
{messages.length > 0 ? (
|
|
||||||
<ChatInterface />
|
|
||||||
) : (
|
|
||||||
<div className={`chat-product`}>
|
|
||||||
<Button
|
|
||||||
variant={`outline`}
|
|
||||||
onClick={() => router.navigate("/generate")}
|
|
||||||
>
|
|
||||||
<FolderKanban className={`h-4 w-4 mr-1.5`} />
|
|
||||||
{t("generate.title")}
|
|
||||||
<ChevronRight className={`h-4 w-4 ml-2`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`chat-input`}>
|
|
||||||
<div className={`input-wrapper`}>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Toggle
|
|
||||||
aria-label={t("chat.web-aria")}
|
|
||||||
defaultPressed={true}
|
|
||||||
onPressedChange={(state: boolean) =>
|
|
||||||
dispatch(setWeb(state))
|
|
||||||
}
|
|
||||||
variant={`outline`}
|
|
||||||
>
|
|
||||||
<Globe className={`h-4 w-4 web ${web ? "enable" : ""}`} />
|
|
||||||
</Toggle>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className={`tooltip`}>{t("chat.web")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<div className={`chat-box`}>
|
|
||||||
{auth && (
|
|
||||||
<FileProvider
|
|
||||||
id={`file`}
|
|
||||||
className={`file`}
|
|
||||||
onChange={setFile}
|
|
||||||
maxLength={4000 * 1.25}
|
|
||||||
setClearEvent={setClearEvent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
id={`input`}
|
|
||||||
className={`input-box`}
|
|
||||||
ref={target}
|
|
||||||
value={input}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setInput(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={t("chat.placeholder")}
|
|
||||||
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter") await handleSend(auth, model, web);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<EditorProvider
|
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
className={`editor`}
|
|
||||||
id={`editor`}
|
|
||||||
placeholder={t("chat.placeholder")}
|
|
||||||
maxLength={8000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size={`icon`}
|
|
||||||
variant="outline"
|
|
||||||
className={`send-button`}
|
|
||||||
onClick={() => handleSend(auth, model, web)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="m21.426 11.095-17-8A1 1 0 0 0 3.03 4.242l1.212 4.849L12 12l-7.758 2.909-1.212 4.849a.998.998 0 0 0 1.396 1.147l17-8a1 1 0 0 0 0-1.81z"></path>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className={`input-options`}>
|
|
||||||
<ModelSelector />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
return (
|
return (
|
||||||
|
Loading…
Reference in New Issue
Block a user