mirror of
https://github.com/coaidev/coai.git
synced 2025-05-28 09:20:18 +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;
|
||||
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 {
|
||||
|
@ -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 {
|
||||
|
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/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<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>
|
||||
);
|
||||
}
|
||||
import ChatWrapper from "../components/home/ChatWrapper.tsx";
|
||||
import SideBar from "../components/home/SideBar.tsx";
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
|
Loading…
Reference in New Issue
Block a user