|
|
@@ -0,0 +1,2344 @@
|
|
|
+import { useDebouncedCallback } from "use-debounce";
|
|
|
+import React, {
|
|
|
+ useState,
|
|
|
+ useRef,
|
|
|
+ useEffect,
|
|
|
+ useMemo,
|
|
|
+ useCallback,
|
|
|
+ Fragment,
|
|
|
+ RefObject,
|
|
|
+} from "react";
|
|
|
+import { HomeOutlined, MenuOutlined } from '@ant-design/icons';
|
|
|
+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 faviconSrc from "../icons/favicon.png";
|
|
|
+import LeftIcon from "../icons/left.svg";
|
|
|
+import Favicon from "../icons/favicon.svg";
|
|
|
+import LoadingIcon from "../icons/three-dots.svg";
|
|
|
+import LoadingButtonIcon from "../icons/loading.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 ImageIcon from "../icons/image.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 AddIcon from "../icons/add.svg";
|
|
|
+import SizeIcon from "../icons/size.svg";
|
|
|
+import PluginIcon from "../icons/plugin.svg";
|
|
|
+import avatarSrc from "../icons/avatar.png";
|
|
|
+
|
|
|
+import {
|
|
|
+ ChatMessage,
|
|
|
+ SubmitKey,
|
|
|
+ useChatStore,
|
|
|
+ BOT_HELLO,
|
|
|
+ createMessage,
|
|
|
+ useAccessStore,
|
|
|
+ Theme,
|
|
|
+ useAppConfig,
|
|
|
+ DEFAULT_TOPIC,
|
|
|
+ ModelType,
|
|
|
+} from "../store";
|
|
|
+
|
|
|
+import {
|
|
|
+ copyToClipboard,
|
|
|
+ selectOrCopy,
|
|
|
+ autoGrowTextArea,
|
|
|
+ useMobileScreen,
|
|
|
+ getMessageTextContent,
|
|
|
+ getMessageImages,
|
|
|
+ isVisionModel,
|
|
|
+ isDalle3,
|
|
|
+} from "../utils";
|
|
|
+
|
|
|
+import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
|
|
|
+
|
|
|
+import dynamic from "next/dynamic";
|
|
|
+
|
|
|
+import { ChatControllerPool } from "../client/controller";
|
|
|
+import { DalleSize } from "../typing";
|
|
|
+import { Prompt, usePromptStore } from "../store/prompt";
|
|
|
+import { useGlobalStore } from "../store";
|
|
|
+import Locale from "../locales";
|
|
|
+
|
|
|
+import { IconButton } from "./button";
|
|
|
+import styles from "./chat.module.scss";
|
|
|
+
|
|
|
+import {
|
|
|
+ List,
|
|
|
+ ListItem,
|
|
|
+ Modal,
|
|
|
+ Selector,
|
|
|
+ showConfirm,
|
|
|
+ showPrompt,
|
|
|
+ showToast,
|
|
|
+} from "./ui-lib";
|
|
|
+import { useNavigate, useLocation } from "react-router-dom";
|
|
|
+import {
|
|
|
+ CHAT_PAGE_SIZE,
|
|
|
+ LAST_INPUT_KEY,
|
|
|
+ Path,
|
|
|
+ REQUEST_TIMEOUT_MS,
|
|
|
+ UNFINISHED_INPUT,
|
|
|
+ ServiceProvider,
|
|
|
+ Plugin,
|
|
|
+} from "../constant";
|
|
|
+// Avatar组件替代实现
|
|
|
+import BotIcon from "../icons/bot.svg";
|
|
|
+import BlackBotIcon from "../icons/black-bot.svg";
|
|
|
+
|
|
|
+function Avatar(props: { model?: string; avatar?: string }) {
|
|
|
+ if (props.model) {
|
|
|
+ return (
|
|
|
+ <div className="no-dark">
|
|
|
+ {props.model?.startsWith("gpt-4") ? (
|
|
|
+ <BlackBotIcon className="user-avatar" />
|
|
|
+ ) : (
|
|
|
+ <BotIcon className="user-avatar" />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="user-avatar">
|
|
|
+ {/* 移除emoji头像,使用默认bot图标 */}
|
|
|
+ <BotIcon className="user-avatar" />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+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";
|
|
|
+import { Button, Collapse, Drawer, message, Popover, Select, Skeleton, Space } from 'antd';
|
|
|
+import {
|
|
|
+ FileOutlined,
|
|
|
+ FilePdfOutlined,
|
|
|
+ FileTextOutlined,
|
|
|
+ FileWordOutlined
|
|
|
+} from '@ant-design/icons';
|
|
|
+import { RightOutlined, CheckCircleOutlined, CaretRightOutlined, StarTwoTone } from '@ant-design/icons';
|
|
|
+import api from "@/app/api/api";
|
|
|
+
|
|
|
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
|
|
+ loading: () => <LoadingIcon />,
|
|
|
+});
|
|
|
+
|
|
|
+export function SessionConfigModel(props: { onClose: () => void }) {
|
|
|
+ const chatStore = useChatStore();
|
|
|
+ const session = chatStore.currentSession();
|
|
|
+ const maskStore = useMaskStore();
|
|
|
+ const navigate = useNavigate();
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="modal-mask">
|
|
|
+ <Modal
|
|
|
+ title={Locale.Context.Edit}
|
|
|
+ onClose={() => props.onClose()}
|
|
|
+ actions={[
|
|
|
+ <IconButton
|
|
|
+ key="reset"
|
|
|
+ icon={<ResetIcon />}
|
|
|
+ bordered
|
|
|
+ text={Locale.Chat.Config.Reset}
|
|
|
+ onClick={async () => {
|
|
|
+ if (await showConfirm(Locale.Memory.ResetConfirm)) {
|
|
|
+ chatStore.updateCurrentSession(
|
|
|
+ (session) => (session.memoryPrompt = ""),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />,
|
|
|
+ <IconButton
|
|
|
+ key="copy"
|
|
|
+ icon={<CopyIcon />}
|
|
|
+ bordered
|
|
|
+ text={Locale.Chat.Config.SaveAs}
|
|
|
+ onClick={() => {
|
|
|
+ navigate(Path.Masks);
|
|
|
+ setTimeout(() => {
|
|
|
+ maskStore.create(session.mask);
|
|
|
+ }, 500);
|
|
|
+ }}
|
|
|
+ />,
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <MaskConfig
|
|
|
+ mask={session.mask}
|
|
|
+ updateMask={(updater) => {
|
|
|
+ const mask = { ...session.mask };
|
|
|
+ updater(mask);
|
|
|
+ chatStore.updateCurrentSession((session) => (session.mask = mask));
|
|
|
+ }}
|
|
|
+ shouldSyncFromGlobal
|
|
|
+ extraListItems={
|
|
|
+ session.mask.modelConfig.sendMemory ? (
|
|
|
+ <ListItem
|
|
|
+ className="copyable"
|
|
|
+ title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
|
|
+ subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
|
|
+ ></ListItem>
|
|
|
+ ) : (
|
|
|
+ <></>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ ></MaskConfig>
|
|
|
+ </Modal>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// 提示词
|
|
|
+const CallWord = (props: {
|
|
|
+ setUserInput: (value: string) => void,
|
|
|
+ doSubmit: (userInput: string) => void,
|
|
|
+}) => {
|
|
|
+ const { setUserInput, doSubmit } = props
|
|
|
+ const list = [
|
|
|
+ {
|
|
|
+ title: '信息公布',
|
|
|
+ // text: '在哪里查看招聘信息?',
|
|
|
+ text: '今年上海建科工程咨询的校园招聘什么时候开始?如何查阅相关招聘信息?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '招聘岗位',
|
|
|
+ // text: '今年招聘的岗位有哪些?',
|
|
|
+ text: '今年招聘的岗位有哪些?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '专业要求',
|
|
|
+ // text: '招聘的岗位有什么专业要求?',
|
|
|
+ text: '招聘的岗位有什么专业要求?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '工作地点',
|
|
|
+ // text: '全国都有工作地点吗?',
|
|
|
+ text: '工作地点是如何确定的?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '薪资待遇',
|
|
|
+ // text: '企业可提供的薪资与福利待遇如何?',
|
|
|
+ text: '企业可提供的薪资与福利待遇如何?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '职业发展',
|
|
|
+ // text: '我应聘贵单位,你们能提供怎样的职业发展规划?',
|
|
|
+ text: '公司有哪些职业发展通道?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '落户政策',
|
|
|
+ // text: '公司是否能协助我落户?',
|
|
|
+ text: '关于落户支持?',
|
|
|
+ }
|
|
|
+ ]
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ {
|
|
|
+ list.map((item, index) => {
|
|
|
+ return <span
|
|
|
+ key={index}
|
|
|
+ style={{
|
|
|
+ padding: '5px 10px',
|
|
|
+ background: '#f6f7f8',
|
|
|
+ color: '#5e5e66',
|
|
|
+ borderRadius: 4,
|
|
|
+ margin: '0 5px 10px 0',
|
|
|
+ cursor: 'pointer',
|
|
|
+ fontSize: 12
|
|
|
+ }}
|
|
|
+ onClick={() => {
|
|
|
+ const plan: string = '2';
|
|
|
+ if (plan === '1') {
|
|
|
+ // 方案1.点击后出现在输入框内,用户自己点击发送
|
|
|
+ setUserInput(item.text);
|
|
|
+ } else {
|
|
|
+ // 方案2.点击后直接发送
|
|
|
+ doSubmit(item.text)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {item.title}
|
|
|
+ </span>
|
|
|
+ })
|
|
|
+ }
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function PromptToast(props: {
|
|
|
+ showToast?: boolean;
|
|
|
+ showModal?: boolean;
|
|
|
+ setShowModal: (_: boolean) => void;
|
|
|
+}) {
|
|
|
+ const chatStore = useChatStore();
|
|
|
+ const session = chatStore.currentSession();
|
|
|
+ const context = session.mask.context;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={styles["prompt-toast"]} key="prompt-toast">
|
|
|
+ {props.showToast && (
|
|
|
+ <div
|
|
|
+ className={styles["prompt-toast-inner"] + " clickable"}
|
|
|
+ role="button"
|
|
|
+ onClick={() => props.setShowModal(true)}
|
|
|
+ >
|
|
|
+ <BrainIcon />
|
|
|
+ <span className={styles["prompt-toast-content"]}>
|
|
|
+ {Locale.Context.Toast(context.length)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {props.showModal && (
|
|
|
+ <SessionConfigModel onClose={() => props.setShowModal(false)} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+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<HTMLTextAreaElement>) => {
|
|
|
+ // Fix Chinese input method "Enter" on Safari
|
|
|
+ if (e.keyCode == 229) return false;
|
|
|
+ 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 RenderPrompt = Pick<Prompt, "title" | "content">;
|
|
|
+
|
|
|
+export function PromptHints(props: {
|
|
|
+ prompts: RenderPrompt[];
|
|
|
+ onPromptSelect: (prompt: RenderPrompt) => void;
|
|
|
+}) {
|
|
|
+ const noPrompts = props.prompts.length === 0;
|
|
|
+ const [selectIndex, setSelectIndex] = useState(0);
|
|
|
+ const selectedRef = useRef<HTMLDivElement>(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 (
|
|
|
+ <div className={styles["prompt-hints"]}>
|
|
|
+ {props.prompts.map((prompt, i) => (
|
|
|
+ <div
|
|
|
+ ref={i === selectIndex ? selectedRef : null}
|
|
|
+ className={
|
|
|
+ styles["prompt-hint"] +
|
|
|
+ ` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
|
|
|
+ }
|
|
|
+ key={prompt.title + i.toString()}
|
|
|
+ onClick={() => props.onPromptSelect(prompt)}
|
|
|
+ onMouseEnter={() => setSelectIndex(i)}
|
|
|
+ >
|
|
|
+ <div className={styles["hint-title"]}>{prompt.title}</div>
|
|
|
+ <div className={styles["hint-content"]}>{prompt.content}</div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ClearContextDivider() {
|
|
|
+ const chatStore = useChatStore();
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={styles["clear-context"]}
|
|
|
+ onClick={() =>
|
|
|
+ chatStore.updateCurrentSession(
|
|
|
+ (session) => (session.clearContextIndex = undefined),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <div className={styles["clear-context-tips"]}>{Locale.Context.Clear}</div>
|
|
|
+ <div className={styles["clear-context-revert-btn"]}>
|
|
|
+ {Locale.Context.Revert}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function ChatAction(props: {
|
|
|
+ text: string;
|
|
|
+ icon: JSX.Element;
|
|
|
+ onClick: () => void;
|
|
|
+}) {
|
|
|
+ const iconRef = useRef<HTMLDivElement>(null);
|
|
|
+ const textRef = useRef<HTMLDivElement>(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 (
|
|
|
+ <div
|
|
|
+ className={`${styles["chat-input-action"]} clickable`}
|
|
|
+ onClick={() => {
|
|
|
+ props.onClick();
|
|
|
+ setTimeout(updateWidth, 1);
|
|
|
+ }}
|
|
|
+ onMouseEnter={updateWidth}
|
|
|
+ onTouchStart={updateWidth}
|
|
|
+ style={
|
|
|
+ {
|
|
|
+ "--icon-width": `${width.icon}px`,
|
|
|
+ "--full-width": `${width.full}px`,
|
|
|
+ } as React.CSSProperties
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <div ref={iconRef} className={styles["icon"]}>
|
|
|
+ {props.icon}
|
|
|
+ </div>
|
|
|
+ <div className={styles["text"]} ref={textRef}>
|
|
|
+ {props.text}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function useScrollToBottom(
|
|
|
+ scrollRef: RefObject<HTMLDivElement>,
|
|
|
+ detach: boolean = false,
|
|
|
+) {
|
|
|
+ // for auto-scroll
|
|
|
+
|
|
|
+ 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 && !detach) {
|
|
|
+ scrollDomToBottom();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ scrollRef,
|
|
|
+ autoScroll,
|
|
|
+ setAutoScroll,
|
|
|
+ scrollDomToBottom,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export function ChatActions(props: {
|
|
|
+ isClickStop: boolean,
|
|
|
+ sendStatus: boolean,
|
|
|
+ setSendStatus: (status: boolean) => void;
|
|
|
+ setUserInput: (value: string) => void;
|
|
|
+ doSubmit: (userInput: string) => void;
|
|
|
+ uploadImage: () => void;
|
|
|
+ setAttachImages: (images: string[]) => void;
|
|
|
+ setUploading: (uploading: boolean) => void;
|
|
|
+ showPromptModal: () => void;
|
|
|
+ scrollToBottom: () => void;
|
|
|
+ showPromptHints: () => void;
|
|
|
+ hitBottom: boolean;
|
|
|
+ uploading: 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 currentProviderName =
|
|
|
+ chatStore.currentSession().mask.modelConfig?.providerName ||
|
|
|
+ ServiceProvider.OpenAI;
|
|
|
+ const allModels = useAllModels();
|
|
|
+ const models = useMemo(() => {
|
|
|
+ const filteredModels = allModels.filter((m) => m.available);
|
|
|
+ const defaultModel = filteredModels.find((m) => m.isDefault);
|
|
|
+
|
|
|
+ if (defaultModel) {
|
|
|
+ const arr = [
|
|
|
+ defaultModel,
|
|
|
+ ...filteredModels.filter((m) => m !== defaultModel),
|
|
|
+ ];
|
|
|
+ return arr;
|
|
|
+ } else {
|
|
|
+ return filteredModels;
|
|
|
+ }
|
|
|
+ }, [allModels]);
|
|
|
+ const currentModelName = useMemo(() => {
|
|
|
+ const model = models.find(
|
|
|
+ (m) =>
|
|
|
+ m.name == currentModel &&
|
|
|
+ m?.provider?.providerName == currentProviderName,
|
|
|
+ );
|
|
|
+ return model?.displayName ?? "";
|
|
|
+ }, [models, currentModel, currentProviderName]);
|
|
|
+ const [showModelSelector, setShowModelSelector] = useState(false);
|
|
|
+ const [showPluginSelector, setShowPluginSelector] = useState(false);
|
|
|
+ const [showUploadImage, setShowUploadImage] = useState(false);
|
|
|
+
|
|
|
+ type GuessList = string[]
|
|
|
+ const [guessList, setGuessList] = useState<GuessList>([]);
|
|
|
+ const [showSizeSelector, setShowSizeSelector] = useState(false);
|
|
|
+ const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
|
|
|
+ const currentSize =
|
|
|
+ chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
|
|
|
+ const session = chatStore.currentSession();
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const show = isVisionModel(currentModel);
|
|
|
+ setShowUploadImage(show);
|
|
|
+ if (!show) {
|
|
|
+ props.setAttachImages([]);
|
|
|
+ props.setUploading(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ // if current model is not available
|
|
|
+ // switch to first available model
|
|
|
+ const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
|
|
+ if (isUnavaliableModel && models.length > 0) {
|
|
|
+ // show next model to default model if exist
|
|
|
+ let nextModel = models.find((model) => model.isDefault) || models[0];
|
|
|
+ chatStore.updateCurrentSession((session) => {
|
|
|
+ session.mask.modelConfig.model = nextModel.name;
|
|
|
+ session.mask.modelConfig.providerName = nextModel?.provider?.providerName as ServiceProvider;
|
|
|
+ });
|
|
|
+ showToast(
|
|
|
+ nextModel?.provider?.providerName == "ByteDance"
|
|
|
+ ? nextModel.displayName
|
|
|
+ : nextModel.name,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }, [chatStore, currentModel, models]);
|
|
|
+
|
|
|
+ const fetchGuessList = async (record: { content: string; role: string }) => {
|
|
|
+ try {
|
|
|
+ const data = {
|
|
|
+ appId: session.appId,
|
|
|
+ messages: [
|
|
|
+ {
|
|
|
+ content: record.content,
|
|
|
+ role: record.role,
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ chat_id: session.chat_id,
|
|
|
+ }
|
|
|
+ let url = '';
|
|
|
+ if (chatStore.chatMode === 'LOCAL') {
|
|
|
+ url = '/deepseek/api/async/completions';
|
|
|
+ } else {
|
|
|
+ url = '/bigmodel/api/async/completions';
|
|
|
+ }
|
|
|
+ const res = await api.post(url, data);
|
|
|
+ setGuessList(res.data);
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ setGuessList([]);
|
|
|
+ if (chatStore.message.content) {
|
|
|
+ fetchGuessList(chatStore.message);
|
|
|
+ }
|
|
|
+ }, [chatStore.message]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (props.isClickStop) {
|
|
|
+ props.setSendStatus(false);
|
|
|
+ setGuessList([]);
|
|
|
+ }
|
|
|
+ }, [props.isClickStop])
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={styles["chat-input-actions"]}>
|
|
|
+ {
|
|
|
+ props.sendStatus &&
|
|
|
+ <div style={{ color: '#8096ca', fontSize: 13, overflowX: 'auto' }}>
|
|
|
+ <div>
|
|
|
+ 你还可以尝试提问:
|
|
|
+ </div>
|
|
|
+ {
|
|
|
+ guessList.length === 0 ?
|
|
|
+ <Space style={{ margin: '10px 0' }}>
|
|
|
+ <Skeleton.Button size="small" active={true} />
|
|
|
+ <Skeleton.Button size="small" active={true} />
|
|
|
+ <Skeleton.Button size="small" active={true} />
|
|
|
+ </Space>
|
|
|
+ :
|
|
|
+ <div style={{ display: 'flex', margin: '10px 0', overflowX: 'auto' }}>
|
|
|
+ {
|
|
|
+ guessList.map((item, index) => {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ padding: '5px 10px',
|
|
|
+ background: '#f2f4f8',
|
|
|
+ borderRadius: 5,
|
|
|
+ margin: '0 10px 10px 0',
|
|
|
+ cursor: 'pointer',
|
|
|
+ }}
|
|
|
+ onClick={() => {
|
|
|
+ props.setUserInput(item);
|
|
|
+ props.doSubmit(item)
|
|
|
+ }}
|
|
|
+ key={index}
|
|
|
+ >
|
|
|
+ {item}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ {/* {couldStop && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={stopAll}
|
|
|
+ text={Locale.Chat.InputActions.Stop}
|
|
|
+ icon={<StopIcon />}
|
|
|
+ />
|
|
|
+ )} */}
|
|
|
+
|
|
|
+ {/* {!props.hitBottom && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={props.scrollToBottom}
|
|
|
+ text={Locale.Chat.InputActions.ToBottom}
|
|
|
+ icon={<BottomIcon />}
|
|
|
+ />
|
|
|
+ )} */}
|
|
|
+
|
|
|
+ {/* {props.hitBottom && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={props.showPromptModal}
|
|
|
+ text={Locale.Chat.InputActions.Settings}
|
|
|
+ icon={<SettingsIcon />}
|
|
|
+ />
|
|
|
+ )} */}
|
|
|
+
|
|
|
+ {/* {showUploadImage && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={props.uploadImage}
|
|
|
+ text={Locale.Chat.InputActions.UploadImage}
|
|
|
+ icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
|
|
|
+ />
|
|
|
+ )} */}
|
|
|
+
|
|
|
+ {/* <ChatAction
|
|
|
+ onClick={nextTheme}
|
|
|
+ text={Locale.Chat.InputActions.Theme[theme]}
|
|
|
+ icon={
|
|
|
+ <>
|
|
|
+ {theme === Theme.Auto ? (
|
|
|
+ <AutoIcon />
|
|
|
+ ) : theme === Theme.Light ? (
|
|
|
+ <LightIcon />
|
|
|
+ ) : theme === Theme.Dark ? (
|
|
|
+ <DarkIcon />
|
|
|
+ ) : null}
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ /> */}
|
|
|
+
|
|
|
+ {/* <CallWord */}
|
|
|
+ {/* setUserInput={props.setUserInput} */}
|
|
|
+ {/* doSubmit={props.doSubmit} */}
|
|
|
+ {/* /> */}
|
|
|
+
|
|
|
+ {/* <ChatAction
|
|
|
+ onClick={props.showPromptHints}
|
|
|
+ text={Locale.Chat.InputActions.Prompt}
|
|
|
+ icon={<PromptIcon />}
|
|
|
+ /> */}
|
|
|
+
|
|
|
+ {/* <ChatAction
|
|
|
+ onClick={() => {
|
|
|
+ navigate(Path.Masks);
|
|
|
+ }}
|
|
|
+ text={Locale.Chat.InputActions.Masks}
|
|
|
+ icon={<MaskIcon />}
|
|
|
+ /> */}
|
|
|
+
|
|
|
+ {/* <ChatAction
|
|
|
+ text={Locale.Chat.InputActions.Clear}
|
|
|
+ icon={<BreakIcon />}
|
|
|
+ onClick={() => {
|
|
|
+ chatStore.updateCurrentSession((session) => {
|
|
|
+ if (session.clearContextIndex === session.messages.length) {
|
|
|
+ session.clearContextIndex = undefined;
|
|
|
+ } else {
|
|
|
+ session.clearContextIndex = session.messages.length;
|
|
|
+ session.memoryPrompt = ""; // will clear memory
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ /> */}
|
|
|
+
|
|
|
+ {/* <ChatAction
|
|
|
+ onClick={() => setShowModelSelector(true)}
|
|
|
+ text={currentModelName}
|
|
|
+ icon={<RobotIcon />}
|
|
|
+ /> */}
|
|
|
+
|
|
|
+ {showModelSelector && (
|
|
|
+ <Selector
|
|
|
+ defaultSelectedValue={`${currentModel}@${currentProviderName}`}
|
|
|
+ items={models.map((m) => ({
|
|
|
+ title: `${m.displayName}${m?.provider?.providerName
|
|
|
+ ? "(" + m?.provider?.providerName + ")"
|
|
|
+ : ""
|
|
|
+ }`,
|
|
|
+ value: `${m.name}@${m?.provider?.providerName}`,
|
|
|
+ }))}
|
|
|
+ onClose={() => setShowModelSelector(false)}
|
|
|
+ onSelection={(s) => {
|
|
|
+ if (s.length === 0) return;
|
|
|
+ const [model, providerName] = s[0].split("@");
|
|
|
+ chatStore.updateCurrentSession((session) => {
|
|
|
+ session.mask.modelConfig.model = model as ModelType;
|
|
|
+ session.mask.modelConfig.providerName =
|
|
|
+ providerName as ServiceProvider;
|
|
|
+ session.mask.syncGlobalConfig = false;
|
|
|
+ });
|
|
|
+ if (providerName == "ByteDance") {
|
|
|
+ const selectedModel = models.find(
|
|
|
+ (m) =>
|
|
|
+ m.name == model && m?.provider?.providerName == providerName,
|
|
|
+ );
|
|
|
+ showToast(selectedModel?.displayName ?? "");
|
|
|
+ } else {
|
|
|
+ showToast(model);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {isDalle3(currentModel) && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={() => setShowSizeSelector(true)}
|
|
|
+ text={currentSize}
|
|
|
+ icon={<SizeIcon />}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {showSizeSelector && (
|
|
|
+ <Selector
|
|
|
+ defaultSelectedValue={currentSize}
|
|
|
+ items={dalle3Sizes.map((m) => ({
|
|
|
+ title: m,
|
|
|
+ value: m,
|
|
|
+ }))}
|
|
|
+ onClose={() => setShowSizeSelector(false)}
|
|
|
+ onSelection={(s) => {
|
|
|
+ if (s.length === 0) return;
|
|
|
+ const size = s[0];
|
|
|
+ chatStore.updateCurrentSession((session) => {
|
|
|
+ session.mask.modelConfig.size = size;
|
|
|
+ });
|
|
|
+ showToast(size);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* <ChatAction
|
|
|
+ onClick={() => setShowPluginSelector(true)}
|
|
|
+ text={Locale.Plugin.Name}
|
|
|
+ icon={<PluginIcon />}
|
|
|
+ /> */}
|
|
|
+
|
|
|
+ {showPluginSelector && (
|
|
|
+ <Selector
|
|
|
+ multiple
|
|
|
+ defaultSelectedValue={chatStore.currentSession().mask?.plugin}
|
|
|
+ items={[
|
|
|
+ {
|
|
|
+ title: Locale.Plugin.Artifacts,
|
|
|
+ value: Plugin.Artifacts,
|
|
|
+ },
|
|
|
+ ]}
|
|
|
+ onClose={() => setShowPluginSelector(false)}
|
|
|
+ onSelection={(s) => {
|
|
|
+ const plugin = s[0];
|
|
|
+ chatStore.updateCurrentSession((session) => {
|
|
|
+ session.mask.plugin = s;
|
|
|
+ });
|
|
|
+ if (plugin) {
|
|
|
+ showToast(plugin);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function EditMessageModal(props: { onClose: () => void }) {
|
|
|
+ const chatStore = useChatStore();
|
|
|
+ const session = chatStore.currentSession();
|
|
|
+ const [messages, setMessages] = useState(session.messages.slice());
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="modal-mask">
|
|
|
+ <Modal
|
|
|
+ title={Locale.Chat.EditMessage.Title}
|
|
|
+ onClose={props.onClose}
|
|
|
+ actions={[
|
|
|
+ <IconButton
|
|
|
+ text={Locale.UI.Cancel}
|
|
|
+ icon={<CancelIcon />}
|
|
|
+ key="cancel"
|
|
|
+ onClick={() => {
|
|
|
+ props.onClose();
|
|
|
+ }}
|
|
|
+ />,
|
|
|
+ <IconButton
|
|
|
+ type="primary"
|
|
|
+ text={Locale.UI.Confirm}
|
|
|
+ icon={<ConfirmIcon />}
|
|
|
+ key="ok"
|
|
|
+ onClick={() => {
|
|
|
+ chatStore.updateCurrentSession(
|
|
|
+ (session) => (session.messages = messages),
|
|
|
+ );
|
|
|
+ props.onClose();
|
|
|
+ }}
|
|
|
+ />,
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <List>
|
|
|
+ <ListItem
|
|
|
+ title={Locale.Chat.EditMessage.Topic.Title}
|
|
|
+ subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={session.topic}
|
|
|
+ onInput={(e) =>
|
|
|
+ chatStore.updateCurrentSession(
|
|
|
+ (session) => (session.topic = e.currentTarget.value),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ ></input>
|
|
|
+ </ListItem>
|
|
|
+ </List>
|
|
|
+ <ContextPrompts
|
|
|
+ context={messages}
|
|
|
+ updateContext={(updater) => {
|
|
|
+ const newMessages = messages.slice();
|
|
|
+ updater(newMessages);
|
|
|
+ setMessages(newMessages);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Modal>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function DeleteImageButton(props: { deleteImage: () => void }) {
|
|
|
+ return (
|
|
|
+ <div className={styles["delete-image"]} onClick={props.deleteImage}>
|
|
|
+ <DeleteIcon />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function _Chat() {
|
|
|
+ type RenderMessage = ChatMessage & { preview?: boolean };
|
|
|
+
|
|
|
+ const chatStore = useChatStore();
|
|
|
+ const session = chatStore.currentSession();
|
|
|
+ const config = useAppConfig();
|
|
|
+ config.sendPreviewBubble = false;
|
|
|
+ const fontSize = config.fontSize;
|
|
|
+ const fontFamily = config.fontFamily;
|
|
|
+
|
|
|
+ const [showExport, setShowExport] = useState(false);
|
|
|
+
|
|
|
+ const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
+ const [userInput, setUserInput] = useState("");
|
|
|
+ const [isLoading, setIsLoading] = useState(false);
|
|
|
+ const [sendStatus, setSendStatus] = useState(false);
|
|
|
+ const { submitKey, shouldSubmit } = useSubmitHandler();
|
|
|
+ const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
+ const isScrolledToBottom = scrollRef?.current
|
|
|
+ ? Math.abs(
|
|
|
+ scrollRef.current.scrollHeight -
|
|
|
+ (scrollRef.current.scrollTop + scrollRef.current.clientHeight),
|
|
|
+ ) <= 1
|
|
|
+ : false;
|
|
|
+ const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
|
|
|
+ scrollRef,
|
|
|
+ isScrolledToBottom,
|
|
|
+ );
|
|
|
+ const [hitBottom, setHitBottom] = useState(true);
|
|
|
+ const isMobileScreen = useMobileScreen();
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const [attachImages, setAttachImages] = useState<string[]>([]);
|
|
|
+ const [uploading, setUploading] = useState(false);
|
|
|
+
|
|
|
+ // prompt hints
|
|
|
+ const promptStore = usePromptStore();
|
|
|
+ const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]);
|
|
|
+ const onSearch = useDebouncedCallback(
|
|
|
+ (text: string) => {
|
|
|
+ const matchedPrompts = promptStore.search(text);
|
|
|
+ setPromptHints(matchedPrompts);
|
|
|
+ },
|
|
|
+ 100,
|
|
|
+ { leading: true, trailing: true },
|
|
|
+ );
|
|
|
+
|
|
|
+ type AppList = {
|
|
|
+ label: string,
|
|
|
+ value: string,
|
|
|
+ desc: string,
|
|
|
+ }[];
|
|
|
+
|
|
|
+ const [appList, setAppList] = useState<AppList>([]);
|
|
|
+ const [appValue, setAppValue] = useState<string>();
|
|
|
+ const globalStore = useGlobalStore();
|
|
|
+ type QuestionList = string[];
|
|
|
+ const [questionList, setQuestionList] = useState<QuestionList>([]);
|
|
|
+ const location = useLocation();
|
|
|
+ const [loading, setLoading] = useState<boolean>(false);
|
|
|
+
|
|
|
+ const fruits = [
|
|
|
+ { id: 'LOCAL', name: 'deepseek' },
|
|
|
+ { id: 'ONLINE', name: '智谱AI' },
|
|
|
+ ];
|
|
|
+
|
|
|
+ const [selectedFruit, setSelectedFruit] = React.useState(chatStore.chatMode);
|
|
|
+
|
|
|
+ // 获取应用列表
|
|
|
+ const fetchApplicationList = async (chatMode?: string) => {
|
|
|
+ let mode = chatMode || selectedFruit;
|
|
|
+ setLoading(true);
|
|
|
+ try {
|
|
|
+ let url = null;
|
|
|
+ if (mode === 'LOCAL') {
|
|
|
+ url = '/deepseek/api/application/list';
|
|
|
+ } else {
|
|
|
+ url = '/bigmodel/api/application/list';
|
|
|
+ }
|
|
|
+ const res = await api.get(url);
|
|
|
+ const list = res.data.filter((item: any) => item.appId !== '1234567890123456789').map((item: any) => {
|
|
|
+ return {
|
|
|
+ label: item.name,
|
|
|
+ value: item.appId,
|
|
|
+ desc: item.desc,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ setAppList(list);
|
|
|
+ const search = location.search;
|
|
|
+ const params = new URLSearchParams(search);
|
|
|
+ const appId = params.get('appId') || '';
|
|
|
+ if (appId) {
|
|
|
+ setAppValue(appId);
|
|
|
+ } else {
|
|
|
+ setAppValue(list[0]?.value);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取预设问题列表
|
|
|
+ const fetchDefaultQuestion = async (appId: string) => {
|
|
|
+ try {
|
|
|
+ let url = null;
|
|
|
+ if (selectedFruit === 'LOCAL') {
|
|
|
+ url = '/deepseek/api/presets';
|
|
|
+ } else {
|
|
|
+ url = '/bigmodel/api/presets';
|
|
|
+ }
|
|
|
+ const res = await api.get(url + `/${appId}`);
|
|
|
+ setQuestionList(res.data);
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const init = async (chatMode?: string) => {
|
|
|
+ await fetchApplicationList(chatMode);
|
|
|
+ }
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const search = location.search;
|
|
|
+ const params = new URLSearchParams(search);
|
|
|
+ const chatMode = params.get('chatMode');
|
|
|
+ if (!chatMode) {
|
|
|
+ init();
|
|
|
+ }
|
|
|
+ }, [selectedFruit])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const search = location.search;
|
|
|
+ const params = new URLSearchParams(search);
|
|
|
+ const chatMode = params.get('chatMode');
|
|
|
+
|
|
|
+ if (chatMode) {
|
|
|
+ setSelectedFruit(chatMode as "ONLINE" | "LOCAL");
|
|
|
+ const appId = params.get('appId');
|
|
|
+ if (appId) {
|
|
|
+ setAppValue(appId);
|
|
|
+ globalStore.setSelectedAppId(appId);
|
|
|
+ chatStore.updateCurrentSession((session) => {
|
|
|
+ session.appId = appId;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ init(chatMode);
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (appValue) {
|
|
|
+ fetchDefaultQuestion(appValue);
|
|
|
+ }
|
|
|
+ }, [appValue, chatStore.chatMode])
|
|
|
+
|
|
|
+ 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.MaskChat),
|
|
|
+ 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.match(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, attachImages).then(() => setIsLoading(false));
|
|
|
+ setAttachImages([]);
|
|
|
+ localStorage.setItem(LAST_INPUT_KEY, userInput);
|
|
|
+ setUserInput("");
|
|
|
+ setPromptHints([]);
|
|
|
+ if (!isMobileScreen) inputRef.current?.focus();
|
|
|
+ setAutoScroll(true);
|
|
|
+ setSendStatus(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ const onPromptSelect = (prompt: RenderPrompt) => {
|
|
|
+ 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<HTMLTextAreaElement>) => {
|
|
|
+ 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, getMessageTextContent(message))) {
|
|
|
+ if (userInput.length === 0) {
|
|
|
+ setUserInput(getMessageTextContent(message));
|
|
|
+ }
|
|
|
+
|
|
|
+ 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);
|
|
|
+ const textContent = getMessageTextContent(userMessage);
|
|
|
+ const images = getMessageImages(userMessage);
|
|
|
+ chatStore.onUserInput([], textContent, images).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!));
|
|
|
+ }
|
|
|
+ accessStore.update((access) => (access.useCustomConfig = true));
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } 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
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handlePaste = useCallback(
|
|
|
+ async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
|
+ const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
|
|
+ if (!isVisionModel(currentModel)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const items = (event.clipboardData || window.clipboardData).items;
|
|
|
+ for (const item of items) {
|
|
|
+ if (item.kind === "file" && item.type.startsWith("image/")) {
|
|
|
+ event.preventDefault();
|
|
|
+ const file = item.getAsFile();
|
|
|
+ if (file) {
|
|
|
+ const images: string[] = [];
|
|
|
+ images.push(...attachImages);
|
|
|
+ images.push(
|
|
|
+ ...(await new Promise<string[]>((res, rej) => {
|
|
|
+ setUploading(true);
|
|
|
+ const imagesData: string[] = [];
|
|
|
+ uploadImageRemote(file).then((dataUrl) => {
|
|
|
+ imagesData.push(dataUrl);
|
|
|
+ setUploading(false);
|
|
|
+ res(imagesData);
|
|
|
+ }).catch((e) => {
|
|
|
+ setUploading(false);
|
|
|
+ rej(e);
|
|
|
+ });
|
|
|
+ })),
|
|
|
+ );
|
|
|
+ const imagesLength = images.length;
|
|
|
+
|
|
|
+ if (imagesLength > 3) {
|
|
|
+ images.splice(3, imagesLength - 3);
|
|
|
+ }
|
|
|
+ setAttachImages(images);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [attachImages, chatStore],
|
|
|
+ );
|
|
|
+
|
|
|
+ async function uploadImage() {
|
|
|
+ const images: string[] = [];
|
|
|
+ images.push(...attachImages);
|
|
|
+
|
|
|
+ images.push(
|
|
|
+ ...(await new Promise<string[]>((res, rej) => {
|
|
|
+ const fileInput = document.createElement("input");
|
|
|
+ fileInput.type = "file";
|
|
|
+ fileInput.accept =
|
|
|
+ "image/png, image/jpeg, image/webp, image/heic, image/heif";
|
|
|
+ fileInput.multiple = true;
|
|
|
+ fileInput.onchange = (event: any) => {
|
|
|
+ setUploading(true);
|
|
|
+ const files = event.target.files;
|
|
|
+ const imagesData: string[] = [];
|
|
|
+ for (let i = 0; i < files.length; i++) {
|
|
|
+ const file = event.target.files[i];
|
|
|
+ uploadImageRemote(file).then((dataUrl) => {
|
|
|
+ imagesData.push(dataUrl);
|
|
|
+ if (
|
|
|
+ imagesData.length === 3 ||
|
|
|
+ imagesData.length === files.length
|
|
|
+ ) {
|
|
|
+ setUploading(false);
|
|
|
+ res(imagesData);
|
|
|
+ }
|
|
|
+ }).catch((e) => {
|
|
|
+ setUploading(false);
|
|
|
+ rej(e);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+ fileInput.click();
|
|
|
+ })),
|
|
|
+ );
|
|
|
+
|
|
|
+ const imagesLength = images.length;
|
|
|
+ if (imagesLength > 3) {
|
|
|
+ images.splice(3, imagesLength - 3);
|
|
|
+ }
|
|
|
+ setAttachImages(images);
|
|
|
+ }
|
|
|
+
|
|
|
+ const getAppName = () => {
|
|
|
+ const search = location.search;
|
|
|
+ const params = new URLSearchParams(search);
|
|
|
+ const chatMode = params.get('chatMode');
|
|
|
+ let appId = '';
|
|
|
+ if (chatMode) {
|
|
|
+ appId = globalStore.selectedAppId;
|
|
|
+ } else {
|
|
|
+ appId = appValue as string;
|
|
|
+ }
|
|
|
+ const item = appList.find(item => item.value === appId);
|
|
|
+ if (!item) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ return item.label;
|
|
|
+ }
|
|
|
+
|
|
|
+ const getDesc = () => {
|
|
|
+ const search = location.search;
|
|
|
+ const params = new URLSearchParams(search);
|
|
|
+ const chatMode = params.get('chatMode');
|
|
|
+ let appId = '';
|
|
|
+ if (chatMode) {
|
|
|
+ appId = globalStore.selectedAppId;
|
|
|
+ } else {
|
|
|
+ appId = appValue as string;
|
|
|
+ }
|
|
|
+ const item = appList.find(item => item.value === appId);
|
|
|
+ if (!item) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ return item.desc;
|
|
|
+ }
|
|
|
+
|
|
|
+ const couldStop = ChatControllerPool.hasPending();
|
|
|
+
|
|
|
+ const stopAll = () => ChatControllerPool.stopAll();
|
|
|
+
|
|
|
+ const [isClickStop, setIsClickStop] = useState(false);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!couldStop) {
|
|
|
+ setIsClickStop(false)
|
|
|
+ }
|
|
|
+ }, [couldStop])
|
|
|
+
|
|
|
+ interface FileIconProps {
|
|
|
+ fileName: string;
|
|
|
+ }
|
|
|
+
|
|
|
+ const FileIcon: React.FC<FileIconProps> = (props: FileIconProps) => {
|
|
|
+ const style = {
|
|
|
+ fontSize: '30px',
|
|
|
+ color: '#3875f6',
|
|
|
+ }
|
|
|
+
|
|
|
+ let icon = <FileOutlined style={style} />
|
|
|
+ if (props.fileName) {
|
|
|
+ const suffix = props.fileName.split('.').pop() || '';
|
|
|
+ switch (suffix) {
|
|
|
+ case 'pdf':
|
|
|
+ icon = <FilePdfOutlined style={style} />
|
|
|
+ break;
|
|
|
+ case 'txt':
|
|
|
+ icon = <FileTextOutlined style={style} />
|
|
|
+ break;
|
|
|
+ case 'doc':
|
|
|
+ case 'docx':
|
|
|
+ icon = <FileWordOutlined style={style} />
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return icon;
|
|
|
+ }
|
|
|
+
|
|
|
+ const [drawerOpen, setDrawerOpen] = useState(false);
|
|
|
+ const [drawerData, setDrawerData] = useState({
|
|
|
+ knowledge_id: '',
|
|
|
+ doc_name: '',
|
|
|
+ chunk_info: {
|
|
|
+ doc_id: '',
|
|
|
+ chunk_list: [],
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const SliceDrawer: React.FC = () => {
|
|
|
+ const [pageLoading, setPageLoading] = useState(false);
|
|
|
+ const [list, setList] = useState([]);
|
|
|
+
|
|
|
+ const init = async () => {
|
|
|
+ setPageLoading(true);
|
|
|
+ try {
|
|
|
+ const res: any = await api.post('/deepseek/api/slicePage', {
|
|
|
+ knowledge_id: drawerData.knowledge_id,
|
|
|
+ chunk_info: drawerData.chunk_info,
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 9999,
|
|
|
+ });
|
|
|
+ console.log(res.rows, 'res.rows');
|
|
|
+
|
|
|
+ setList(res.rows);
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ } finally {
|
|
|
+ setPageLoading(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ init()
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Drawer
|
|
|
+ title={drawerData.doc_name}
|
|
|
+ loading={pageLoading}
|
|
|
+ open={drawerOpen}
|
|
|
+ onClose={() => {
|
|
|
+ setDrawerOpen(false);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {list.map((item: any, index) => {
|
|
|
+ const score = parseFloat(item.rerankScore);
|
|
|
+ const formattedScore = isNaN(score) ? '0.00' : score.toFixed(2);
|
|
|
+ return <div
|
|
|
+ style={{
|
|
|
+ padding: 10,
|
|
|
+ background: '#fafafa',
|
|
|
+ borderRadius: 4,
|
|
|
+ marginBottom: 10
|
|
|
+ }}
|
|
|
+ key={item.sliceId}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'space-between',
|
|
|
+ alignItems: 'center',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div style={{ margin: '5px 0' }}>
|
|
|
+ 片段{index + 1}
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <StarTwoTone style={{ marginRight: 10 }} />
|
|
|
+ rerank得分{formattedScore}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ {item.sliceText}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ })}
|
|
|
+ </Drawer>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ // 当显示欢迎页面时,确保滚动到顶部
|
|
|
+ if (messages.length <= 1 && scrollRef.current) {
|
|
|
+ setTimeout(() => {
|
|
|
+ if (scrollRef.current) {
|
|
|
+ scrollRef.current.scrollTo(0, 0);
|
|
|
+ }
|
|
|
+ }, 0);
|
|
|
+ }
|
|
|
+ }, [messages.length]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={styles.chat} key={session.id}>
|
|
|
+ {
|
|
|
+ <div className="window-header" data-tauri-drag-region>
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center' }}
|
|
|
+ className={`window-header-title ${styles["chat-body-title"]}`}>
|
|
|
+ {
|
|
|
+ <div style={{ marginRight: 10 }}>
|
|
|
+ <Button
|
|
|
+ type='text'
|
|
|
+ icon={<MenuOutlined />}
|
|
|
+ onClick={() => {
|
|
|
+ globalStore.setShowMenu(!globalStore.showMenu);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ {
|
|
|
+ false &&
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
+ <Select
|
|
|
+ style={{ width: '100%', height: 38, marginRight: 10 }}
|
|
|
+ value={selectedFruit}
|
|
|
+ onChange={(value: "ONLINE" | "LOCAL") => {
|
|
|
+ chatStore.clearSessions();
|
|
|
+ chatStore.updateCurrentSession((values) => {
|
|
|
+ values.appId = globalStore.selectedAppId;
|
|
|
+ });
|
|
|
+ navigate({ pathname: '/newChat' });
|
|
|
+ setSelectedFruit(value);
|
|
|
+ chatStore.setChatMode(value);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {fruits.map(fruit => (
|
|
|
+ <Select.Option key={fruit.id} value={fruit.id}>
|
|
|
+ {fruit.name}
|
|
|
+ </Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ {
|
|
|
+ appList.length > 0 ?
|
|
|
+ <Select
|
|
|
+ style={{ width: '100%', height: 38, marginRight: 5 }}
|
|
|
+ placeholder='请选择'
|
|
|
+ options={appList}
|
|
|
+ value={appValue}
|
|
|
+ onChange={(value) => {
|
|
|
+ setAppValue(value);
|
|
|
+ globalStore.setSelectedAppId(value);
|
|
|
+ chatStore.clearSessions();
|
|
|
+ chatStore.updateCurrentSession((values) => {
|
|
|
+ values.appId = value;
|
|
|
+ });
|
|
|
+ useChatStore.setState({
|
|
|
+ message: {
|
|
|
+ content: '',
|
|
|
+ role: 'assistant',
|
|
|
+ }
|
|
|
+ });
|
|
|
+ setSendStatus(false);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ :
|
|
|
+ null
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ <div className="window-actions">
|
|
|
+ <div className="window-action-button">
|
|
|
+ <Popover
|
|
|
+ trigger="click"
|
|
|
+ title="分享该应用"
|
|
|
+ content={() => {
|
|
|
+ const url = `${window.location.origin}/#/knowledgeChat?showMenu=false&chatMode=${selectedFruit}&appId=${appValue}`
|
|
|
+ return <div>
|
|
|
+ <div style={{ marginBottom: 10 }}>
|
|
|
+ {url}
|
|
|
+ </div>
|
|
|
+ <Button onClick={() => {
|
|
|
+ navigator.clipboard.writeText(url);
|
|
|
+ message.success('分享链接已复制到剪贴板');
|
|
|
+ }}>
|
|
|
+ 复制
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ }>
|
|
|
+ <IconButton
|
|
|
+ icon={<ExportIcon />}
|
|
|
+ bordered
|
|
|
+ title='分享该应用'
|
|
|
+ />
|
|
|
+ </Popover>
|
|
|
+ </div>
|
|
|
+ {/* {showMaxIcon && (
|
|
|
+ <div className="window-action-button">
|
|
|
+ <IconButton
|
|
|
+ icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
|
|
+ bordered
|
|
|
+ title={Locale.Chat.Actions.FullScreen}
|
|
|
+ aria={Locale.Chat.Actions.FullScreen}
|
|
|
+ onClick={() => {
|
|
|
+ config.update(
|
|
|
+ (config) => (config.tightBorder = !config.tightBorder),
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )} */}
|
|
|
+ </div>
|
|
|
+ {/* <PromptToast
|
|
|
+ showToast={!hitBottom}
|
|
|
+ showModal={showPromptModal}
|
|
|
+ setShowModal={setShowPromptModal}
|
|
|
+ /> */}
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ <div
|
|
|
+ className={styles["chat-body"]}
|
|
|
+ ref={scrollRef}
|
|
|
+ onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
|
|
+ onMouseDown={() => inputRef.current?.blur()}
|
|
|
+ onTouchStart={() => {
|
|
|
+ inputRef.current?.blur();
|
|
|
+ setAutoScroll(false);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {
|
|
|
+ messages.length > 1 ?
|
|
|
+ <>
|
|
|
+ {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 (
|
|
|
+ <Fragment key={message.id}>
|
|
|
+ <div
|
|
|
+ className={
|
|
|
+ isUser ? styles["chat-message-user"] : styles["chat-message"]
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <div className={styles["chat-message-container"]}>
|
|
|
+ <div className={styles["chat-message-header"]}>
|
|
|
+ <div className={styles["chat-message-avatar"]}>
|
|
|
+ {isUser ? (
|
|
|
+ // 在这里换头像
|
|
|
+ <div style={{ position: 'relative' }}>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ zIndex: 2,
|
|
|
+ top: '50%',
|
|
|
+ left: '50%',
|
|
|
+ transform: ' translate(-110%, -100%)',
|
|
|
+ fontSize: 14,
|
|
|
+ }}>
|
|
|
+ 我
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ {["system"].includes(message.role) ? (
|
|
|
+ <Avatar avatar="2699-fe0f" />
|
|
|
+ ) : (
|
|
|
+ <MaskAvatar
|
|
|
+ avatar={session.mask.avatar}
|
|
|
+ model={
|
|
|
+ message.model || session.mask.modelConfig.model
|
|
|
+ }
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {/* {showActions && (
|
|
|
+ <div className={styles["chat-message-actions"]}>
|
|
|
+ <div className={styles["chat-input-actions"]}>
|
|
|
+ {message.streaming ? (
|
|
|
+ <ChatAction
|
|
|
+ text={Locale.Chat.Actions.Stop}
|
|
|
+ icon={<StopIcon />}
|
|
|
+ onClick={() => onUserStop(message.id ?? i)}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <ChatAction
|
|
|
+ text={Locale.Chat.Actions.Retry}
|
|
|
+ icon={<ResetIcon />}
|
|
|
+ onClick={() => onResend(message)}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ChatAction
|
|
|
+ text={Locale.Chat.Actions.Delete}
|
|
|
+ icon={<DeleteIcon />}
|
|
|
+ onClick={() => onDelete(message.id ?? i)}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ChatAction
|
|
|
+ text={Locale.Chat.Actions.Pin}
|
|
|
+ icon={<PinIcon />}
|
|
|
+ onClick={() => onPinMessage(message)}
|
|
|
+ />
|
|
|
+ <ChatAction
|
|
|
+ text={Locale.Chat.Actions.Copy}
|
|
|
+ icon={<CopyIcon />}
|
|
|
+ onClick={() =>
|
|
|
+ copyToClipboard(
|
|
|
+ getMessageTextContent(message),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )} */}
|
|
|
+ </div>
|
|
|
+ {
|
|
|
+ showTyping ?
|
|
|
+ <div className={styles["chat-message-status"]}>
|
|
|
+ {isUser ? '' : '正在查询文档…'}
|
|
|
+ </div>
|
|
|
+ :
|
|
|
+ <div className={styles["chat-message-status"]}>
|
|
|
+ {
|
|
|
+ message.role === 'assistant' && messages.length - 1 === i &&
|
|
|
+ <div>
|
|
|
+ <CheckCircleOutlined /> 文档搜索成功
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ {
|
|
|
+ message.sliceInfo &&
|
|
|
+ <div style={{ marginTop: 10 }}>
|
|
|
+ <Collapse
|
|
|
+ bordered={false}
|
|
|
+ expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
|
|
|
+ items={[
|
|
|
+ {
|
|
|
+ key: '1',
|
|
|
+ label: `查询到“${message.sliceInfo.doc.length}条”相关切片`,
|
|
|
+ children: <div>
|
|
|
+ {message.sliceInfo.doc.map((item, index) => {
|
|
|
+ return <div
|
|
|
+ style={{
|
|
|
+ padding: 10,
|
|
|
+ background: '#FFFFFF',
|
|
|
+ borderRadius: 4,
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'space-between',
|
|
|
+ alignItems: 'center',
|
|
|
+ marginTop: index ? 10 : 0,
|
|
|
+ cursor: 'pointer',
|
|
|
+ }}
|
|
|
+ key={item.doc_id}
|
|
|
+ onClick={() => {
|
|
|
+ setDrawerData({
|
|
|
+ knowledge_id: message.sliceInfo!.knowledge_id,
|
|
|
+ doc_name: item.doc_name,
|
|
|
+ chunk_info: {
|
|
|
+ doc_id: item.doc_id,
|
|
|
+ chunk_list: item.chunk_info_list,
|
|
|
+ }
|
|
|
+ });
|
|
|
+ setDrawerOpen(true);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center' }}>
|
|
|
+ <FileIcon fileName={item.doc_name} />
|
|
|
+ <div style={{ marginLeft: 10 }}>
|
|
|
+ {item.doc_name}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style={{
|
|
|
+ padding: 10,
|
|
|
+ background: '#FAFAFA',
|
|
|
+ borderRadius: 4,
|
|
|
+ marginLeft: 20,
|
|
|
+ }}>
|
|
|
+ {item.chunk_nums}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ })}
|
|
|
+ </div>,
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ {
|
|
|
+ drawerOpen &&
|
|
|
+ <SliceDrawer />
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ <div className={styles["chat-message-item"]}>
|
|
|
+ <Markdown
|
|
|
+ key={message.streaming ? "loading" : "done"}
|
|
|
+ content={getMessageTextContent(message)}
|
|
|
+ loading={
|
|
|
+ (message.preview || message.streaming) &&
|
|
|
+ message.content.length === 0 &&
|
|
|
+ !isUser
|
|
|
+ }
|
|
|
+ // onContextMenu={(e) => onRightClick(e, message)}
|
|
|
+ onDoubleClickCapture={() => {
|
|
|
+ if (!isMobileScreen) return;
|
|
|
+ setUserInput(getMessageTextContent(message));
|
|
|
+ }}
|
|
|
+ fontSize={fontSize}
|
|
|
+ fontFamily={fontFamily}
|
|
|
+ parentRef={scrollRef}
|
|
|
+ defaultShow={i >= messages.length - 6}
|
|
|
+ />
|
|
|
+ {getMessageImages(message).length == 1 && (
|
|
|
+ <img
|
|
|
+ className={styles["chat-message-item-image"]}
|
|
|
+ src={getMessageImages(message)[0]}
|
|
|
+ alt=""
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {getMessageImages(message).length > 1 && (
|
|
|
+ <div
|
|
|
+ className={styles["chat-message-item-images"]}
|
|
|
+ style={
|
|
|
+ {
|
|
|
+ "--image-count": getMessageImages(message).length,
|
|
|
+ } as React.CSSProperties
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {getMessageImages(message).map((image, index) => {
|
|
|
+ return (
|
|
|
+ <img
|
|
|
+ className={
|
|
|
+ styles["chat-message-item-image-multi"]
|
|
|
+ }
|
|
|
+ key={index}
|
|
|
+ src={image}
|
|
|
+ alt=""
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {/* <div className={styles["chat-message-action-date"]}>
|
|
|
+ {isContext
|
|
|
+ ? Locale.Chat.IsContext
|
|
|
+ : message.date.toLocaleString()}
|
|
|
+ </div> */}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {shouldShowClearContextDivider && <ClearContextDivider />}
|
|
|
+ </Fragment>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </>
|
|
|
+ :
|
|
|
+ <>
|
|
|
+ <div style={{ padding: '0 20px' }}>
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'center' }}>
|
|
|
+ <img style={{ width: 80, height: 80 }} src={avatarSrc.src} />
|
|
|
+ </div>
|
|
|
+ <h1 style={{ textAlign: 'center' }}>
|
|
|
+ {getAppName()}
|
|
|
+ </h1>
|
|
|
+ <p style={{ textAlign: 'center' }}>
|
|
|
+ {getDesc()}
|
|
|
+ </p>
|
|
|
+ <p>我猜您可能想问:</p>
|
|
|
+ {
|
|
|
+ questionList.map((item, index) => {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ padding: '10px',
|
|
|
+ marginBottom: '10px',
|
|
|
+ border: '1px solid #e6e8f1',
|
|
|
+ borderRadius: '10px',
|
|
|
+ fontSize: '16px',
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'space-between',
|
|
|
+ alignItems: 'center',
|
|
|
+ cursor: 'pointer'
|
|
|
+ }}
|
|
|
+ onClick={() => {
|
|
|
+ setUserInput(item)
|
|
|
+ doSubmit(item)
|
|
|
+ }}
|
|
|
+ key={index}
|
|
|
+ >
|
|
|
+ <div>
|
|
|
+ {item}
|
|
|
+ </div>
|
|
|
+ <RightOutlined />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ <div className={styles["chat-input-panel"]}>
|
|
|
+ {/* <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} /> */}
|
|
|
+ <ChatActions
|
|
|
+ isClickStop={isClickStop}
|
|
|
+ sendStatus={sendStatus}
|
|
|
+ setSendStatus={setSendStatus}
|
|
|
+ setUserInput={setUserInput}
|
|
|
+ doSubmit={doSubmit}
|
|
|
+ uploadImage={uploadImage}
|
|
|
+ setAttachImages={setAttachImages}
|
|
|
+ setUploading={setUploading}
|
|
|
+ showPromptModal={() => setShowPromptModal(true)}
|
|
|
+ scrollToBottom={scrollToBottom}
|
|
|
+ hitBottom={hitBottom}
|
|
|
+ uploading={uploading}
|
|
|
+ showPromptHints={() => {
|
|
|
+ // Click again to close
|
|
|
+ if (promptHints.length > 0) {
|
|
|
+ setPromptHints([]);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ inputRef.current?.focus();
|
|
|
+ setUserInput("/");
|
|
|
+ onSearch("");
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <label
|
|
|
+ className={`${styles["chat-input-panel-inner"]} ${attachImages.length != 0
|
|
|
+ ? styles["chat-input-panel-inner-attach"]
|
|
|
+ : ""
|
|
|
+ }`}
|
|
|
+ htmlFor="chat-input"
|
|
|
+ >
|
|
|
+ <textarea
|
|
|
+ id="chat-input"
|
|
|
+ ref={inputRef}
|
|
|
+ className={styles["chat-input"]}
|
|
|
+ placeholder={Locale.Chat.Input(submitKey)}
|
|
|
+ onInput={(e) => onInput(e.currentTarget.value)}
|
|
|
+ value={userInput}
|
|
|
+ onKeyDown={onInputKeyDown}
|
|
|
+ onFocus={scrollToBottom}
|
|
|
+ onClick={scrollToBottom}
|
|
|
+ onPaste={handlePaste}
|
|
|
+ rows={inputRows}
|
|
|
+ autoFocus={autoFocus}
|
|
|
+ style={{
|
|
|
+ fontSize: config.fontSize,
|
|
|
+ fontFamily: config.fontFamily,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {attachImages.length != 0 && (
|
|
|
+ <div className={styles["attach-images"]}>
|
|
|
+ {attachImages.map((image, index) => {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={index}
|
|
|
+ className={styles["attach-image"]}
|
|
|
+ style={{ backgroundImage: `url("${image}")` }}
|
|
|
+ >
|
|
|
+ <div className={styles["attach-image-mask"]}>
|
|
|
+ <DeleteImageButton
|
|
|
+ deleteImage={() => {
|
|
|
+ setAttachImages(
|
|
|
+ attachImages.filter((_, i) => i !== index),
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <IconButton
|
|
|
+ icon={couldStop ? <div style={{ width: 13, height: 13, background: '#FFFFFF', borderRadius: 2 }}></div> : <SendWhiteIcon />}
|
|
|
+ text={couldStop ? '停止' : '发送'}
|
|
|
+ className={styles["chat-input-send"]}
|
|
|
+ type="primary"
|
|
|
+ onClick={() => {
|
|
|
+ if (couldStop) {
|
|
|
+ stopAll();
|
|
|
+ setIsClickStop(true);
|
|
|
+ } else {
|
|
|
+ doSubmit(userInput);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div style={{ paddingBottom: 12, textAlign: 'center', color: '#888888', fontSize: 12 }}>
|
|
|
+ 内容由AI生成,仅供参考
|
|
|
+ </div>
|
|
|
+ {showExport && (
|
|
|
+ <ExportMessageModal onClose={() => setShowExport(false)} />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {isEditingMessage && (
|
|
|
+ <EditMessageModal
|
|
|
+ onClose={() => {
|
|
|
+ setIsEditingMessage(false);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function Chat() {
|
|
|
+ const globalStore = useGlobalStore();
|
|
|
+ const chatStore = useChatStore();
|
|
|
+ const location = useLocation();
|
|
|
+ const sessionIndex = chatStore.currentSessionIndex;
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ chatStore.setModel('BigModel');
|
|
|
+ const search = location.search;
|
|
|
+ const params = new URLSearchParams(search);
|
|
|
+ const showMenu = params.get('showMenu');
|
|
|
+ const chatMode = params.get('chatMode');
|
|
|
+
|
|
|
+ if (showMenu) {
|
|
|
+ if (showMenu === 'true') {
|
|
|
+ globalStore.setShowMenu(true);
|
|
|
+ } else if (showMenu === 'false') {
|
|
|
+ globalStore.setShowMenu(false);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ globalStore.setShowMenu(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (chatMode) {
|
|
|
+ chatStore.setChatMode(chatMode as "ONLINE" | "LOCAL");
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ return <_Chat key={sessionIndex}></_Chat>;
|
|
|
+}
|