|
|
@@ -0,0 +1,1966 @@
|
|
|
+// 第三方库导入
|
|
|
+import { useDebouncedCallback } from "use-debounce";
|
|
|
+import React, {
|
|
|
+ useState,
|
|
|
+ useRef,
|
|
|
+ useEffect,
|
|
|
+ useMemo,
|
|
|
+ useCallback,
|
|
|
+ Fragment,
|
|
|
+ RefObject,
|
|
|
+} from "react";
|
|
|
+import dynamic from "next/dynamic";
|
|
|
+import { useNavigate, useLocation } from "react-router-dom";
|
|
|
+
|
|
|
+// 本地组件和工具导入
|
|
|
+import { IconButton } from "./button";
|
|
|
+import { MaskAvatar } from "./mask";
|
|
|
+import styles from "./chatHomeOnly.module.scss";
|
|
|
+
|
|
|
+// 图标资源导入
|
|
|
+import LeftIcon from "../icons/left.svg";
|
|
|
+import SendWhiteIcon from "../icons/send-white.svg";
|
|
|
+import BrainIcon from "../icons/brain.svg";
|
|
|
+import CopyIcon from "../icons/copy.svg";
|
|
|
+import LoadingIcon from "../icons/three-dots.svg";
|
|
|
+import ResetIcon from "../icons/reload.svg";
|
|
|
+import DeleteIcon from "../icons/clear.svg";
|
|
|
+import ConfirmIcon from "../icons/confirm.svg";
|
|
|
+import CancelIcon from "../icons/cancel.svg";
|
|
|
+import SizeIcon from "../icons/size.svg";
|
|
|
+import avatar from "../icons/aiIcon.png";
|
|
|
+import sdsk from "../icons/sdsk.png";
|
|
|
+import sdsk_selected from "../icons/sdsk_selected.png";
|
|
|
+import hlw from "../icons/hlw.png";
|
|
|
+import hlw_selected from "../icons/hlw_selected.png";
|
|
|
+import BotIcon from "../icons/bot.svg";
|
|
|
+import BlackBotIcon from "../icons/black-bot.svg";
|
|
|
+
|
|
|
+// 状态管理和类型导入
|
|
|
+import {
|
|
|
+ ChatMessage,
|
|
|
+ SubmitKey,
|
|
|
+ useChatStore,
|
|
|
+ useAccessStore,
|
|
|
+ Theme,
|
|
|
+ useAppConfig,
|
|
|
+ DEFAULT_TOPIC,
|
|
|
+ ModelType,
|
|
|
+ useGlobalStore,
|
|
|
+} from "../store";
|
|
|
+import { Prompt, usePromptStore } from "../store/prompt";
|
|
|
+
|
|
|
+// 工具函数导入
|
|
|
+import {
|
|
|
+ copyToClipboard,
|
|
|
+ selectOrCopy,
|
|
|
+ autoGrowTextArea,
|
|
|
+ useMobileScreen,
|
|
|
+ getMessageTextContent,
|
|
|
+ getMessageImages,
|
|
|
+ isVisionModel,
|
|
|
+ isDalle3,
|
|
|
+} from "../utils";
|
|
|
+import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
|
|
|
+
|
|
|
+// 客户端和类型导入
|
|
|
+import { ChatControllerPool } from "../client/controller";
|
|
|
+import { DalleSize } from "../typing";
|
|
|
+import type { RequestMessage } from "../client/api";
|
|
|
+
|
|
|
+// UI 组件导入
|
|
|
+import {
|
|
|
+ List,
|
|
|
+ ListItem,
|
|
|
+ Modal,
|
|
|
+ Selector,
|
|
|
+ showConfirm,
|
|
|
+ showToast,
|
|
|
+} from "./ui-lib";
|
|
|
+
|
|
|
+// 常量和本地化
|
|
|
+import Locale from "../locales";
|
|
|
+import {
|
|
|
+ CHAT_PAGE_SIZE,
|
|
|
+ LAST_INPUT_KEY,
|
|
|
+ Path,
|
|
|
+ REQUEST_TIMEOUT_MS,
|
|
|
+ UNFINISHED_INPUT,
|
|
|
+ ServiceProvider,
|
|
|
+ Plugin,
|
|
|
+} from "../constant";
|
|
|
+import { ContextPrompts, 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 { nanoid } from "nanoid";
|
|
|
+import { message, Upload, UploadProps, Tooltip, Drawer, Button } from "antd";
|
|
|
+import {
|
|
|
+ PaperClipOutlined,
|
|
|
+ SendOutlined,
|
|
|
+ FileOutlined,
|
|
|
+ FilePdfOutlined,
|
|
|
+ FileTextOutlined,
|
|
|
+ FileWordOutlined,
|
|
|
+ RightOutlined
|
|
|
+} from '@ant-design/icons';
|
|
|
+
|
|
|
+// Avatar组件替代实现
|
|
|
+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>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function createMessage( override : Partial<ChatMessage> ) : ChatMessage {
|
|
|
+ return {
|
|
|
+ id: nanoid(),
|
|
|
+ date: new Date().toLocaleString(),
|
|
|
+ role: "user",
|
|
|
+ content: "",
|
|
|
+ ...override,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export const BOT_HELLO : ChatMessage = createMessage( {
|
|
|
+ role: "assistant",
|
|
|
+ content: '你好,我是小智~\n' +
|
|
|
+ '我可以帮助你快速查询作业指导书、规范条文、公司信息等内容,如需获取上述内容,请点击上方导航栏中的「专业知识」或「职能管理」,选择相应的智能体进行提问。无论是现场技术,还是制度流程,我都会尽力为你解答!\n' +
|
|
|
+ '请注意:在这个对话框内,我只能请DeepSeek来帮忙回答常见通用问题哦!',
|
|
|
+} );
|
|
|
+
|
|
|
+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 : {
|
|
|
+ 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 ] );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={ styles[ "chat-input-actions" ] }>
|
|
|
+ { 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 );
|
|
|
+ } }
|
|
|
+ />
|
|
|
+ ) }
|
|
|
+
|
|
|
+ { 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(props?: { onMessageSent?: () => void }) {
|
|
|
+ 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 { 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 },
|
|
|
+ );
|
|
|
+
|
|
|
+ useEffect( () => {
|
|
|
+ chatStore.updateCurrentSession( ( session ) => {
|
|
|
+ session.appId = '1881269958412521255';
|
|
|
+ } );
|
|
|
+ }, [] )
|
|
|
+
|
|
|
+ const [ inputRows, setInputRows ] = useState( isMobileScreen ? 1 : 2 );
|
|
|
+ const measure = useDebouncedCallback(
|
|
|
+ () => {
|
|
|
+ const rows = inputRef.current ? autoGrowTextArea( inputRef.current ) : 1;
|
|
|
+ const inputRows = Math.min(
|
|
|
+ 20,
|
|
|
+ Math.max( isMobileScreen ? 1 : (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), // 关闭mask入口 ,后续有需求再二开
|
|
|
+ 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( fileList, userInput, attachImages ).then( () => setIsLoading( false ) );
|
|
|
+ setAttachImages( [] );
|
|
|
+ localStorage.setItem( LAST_INPUT_KEY, userInput );
|
|
|
+ setUserInput( "" );
|
|
|
+ setPromptHints( [] );
|
|
|
+ // 取消输入框的选定状态
|
|
|
+ inputRef.current?.blur();
|
|
|
+ setAutoScroll( true );
|
|
|
+ // 通知父组件消息已发送
|
|
|
+ props?.onMessageSent?.();
|
|
|
+ };
|
|
|
+
|
|
|
+ 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 [ fileList, setFileList ] = useState<any[]>( [] );
|
|
|
+
|
|
|
+ // 上传配置
|
|
|
+ const uploadConfig : UploadProps = {
|
|
|
+ action: '/deepseek-api' + '/upload/file',
|
|
|
+ method: 'POST',
|
|
|
+ accept: [ '.pdf', '.txt', '.doc', '.docx' ].join( ',' ),
|
|
|
+ };
|
|
|
+
|
|
|
+ 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 [ isDeepThink, setIsDeepThink ] = useState<boolean>( chatStore.isDeepThink );
|
|
|
+
|
|
|
+ // 切换聊天窗口后清理上传文件信息
|
|
|
+ useEffect( () => {
|
|
|
+ setFileList( [] )
|
|
|
+ }, [ chatStore.currentSession() ] )
|
|
|
+
|
|
|
+ const couldStop = ChatControllerPool.hasPending();
|
|
|
+ const stopAll = () => ChatControllerPool.stopAll();
|
|
|
+
|
|
|
+ // 切换聊天窗口后清理上传文件信息
|
|
|
+ useEffect( () => {
|
|
|
+ setWebSearch( false );
|
|
|
+ }, [ chatStore.currentSession() ] )
|
|
|
+
|
|
|
+ const [ webSearch, setWebSearch ] = useState<boolean>( chatStore.web_search );
|
|
|
+
|
|
|
+ const [ drawerOpen, setDrawerOpen ] = useState( false );
|
|
|
+ type DrawerList = {
|
|
|
+ title : string,
|
|
|
+ content : string,
|
|
|
+ web_url : string,
|
|
|
+ }[]
|
|
|
+ const [ drawerList, setDrawerList ] = useState<DrawerList>( [] );
|
|
|
+
|
|
|
+ interface NetworkDrawerProps {
|
|
|
+ list : DrawerList,
|
|
|
+ }
|
|
|
+
|
|
|
+ const NetworkDrawer : React.FC<NetworkDrawerProps> = ( props ) => {
|
|
|
+ return (
|
|
|
+ <Drawer
|
|
|
+ title='网页搜索'
|
|
|
+ open={ drawerOpen }
|
|
|
+ onClose={ () => {
|
|
|
+ setDrawerOpen( false );
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ { props.list.map( ( item, index ) => {
|
|
|
+ return <div
|
|
|
+ style={ {
|
|
|
+ padding: 10,
|
|
|
+ background: '#fafafa',
|
|
|
+ borderRadius: 4,
|
|
|
+ marginBottom: 10,
|
|
|
+ cursor: 'pointer',
|
|
|
+ } }
|
|
|
+ key={ index }
|
|
|
+ onClick={ () => {
|
|
|
+ window.open( item.web_url );
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ <div style={ {
|
|
|
+ margin: '5px 0',
|
|
|
+ fontSize: 16,
|
|
|
+ display: '-webkit-box',
|
|
|
+ WebkitBoxOrient: 'vertical',
|
|
|
+ WebkitLineClamp: 2,// 限制显示两行
|
|
|
+ overflow: 'hidden',
|
|
|
+ } }>
|
|
|
+ { item.title }
|
|
|
+ </div>
|
|
|
+ <div style={ {
|
|
|
+ color: '#afafaf',
|
|
|
+ display: '-webkit-box',
|
|
|
+ WebkitBoxOrient: 'vertical',
|
|
|
+ WebkitLineClamp: 4,// 限制显示两行
|
|
|
+ overflow: 'hidden',
|
|
|
+ textOverflow: 'ellipsis',
|
|
|
+ } }>
|
|
|
+ { item.content }
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ } )
|
|
|
+ }
|
|
|
+ </Drawer>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const location = useLocation();
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={ styles.chat } key={ session.id }>
|
|
|
+ {
|
|
|
+ isMobileScreen && location.pathname !== '/' &&
|
|
|
+ <div className="window-header" data-tauri-drag-region>
|
|
|
+ <div style={ { display: 'flex', alignItems: 'center' } }
|
|
|
+ className={ `window-header-title ${ styles[ "chat-body-title" ] }` }>
|
|
|
+ <div>
|
|
|
+ <IconButton
|
|
|
+ style={ { padding: 0, marginRight: 20 } }
|
|
|
+ icon={ <LeftIcon /> }
|
|
|
+ text={ Locale.NewChat.Return }
|
|
|
+ onClick={ () => navigate( '/deepseekChat' ) }
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ <div
|
|
|
+ className={ styles[ "chat-body" ] }
|
|
|
+ ref={ scrollRef }
|
|
|
+ onScroll={ ( e ) => onChatBodyScroll( e.currentTarget ) }
|
|
|
+ onMouseDown={ () => inputRef.current?.blur() }
|
|
|
+ onTouchStart={ () => {
|
|
|
+ inputRef.current?.blur();
|
|
|
+ setAutoScroll( false );
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ <>
|
|
|
+ { messages.map( ( message, i ) => {
|
|
|
+ const isUser = message.role === "user";
|
|
|
+ const isContext = i < context.length;
|
|
|
+ const showActions =
|
|
|
+ i > 0 &&
|
|
|
+ !( message.preview || message.content.length === 0 ) &&
|
|
|
+ !isContext;
|
|
|
+ const showTyping = message.preview || message.streaming;
|
|
|
+
|
|
|
+ const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Fragment key={ message.id }>
|
|
|
+ <div
|
|
|
+ className={
|
|
|
+ isUser ? styles[ "chat-message-user" ] : styles[ "chat-message" ]
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <div className={ styles[ "chat-message-container" ] }
|
|
|
+ style={ { display: 'flex', flexDirection: 'column' } }>
|
|
|
+ <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>
|
|
|
+ </div>
|
|
|
+ {
|
|
|
+ isUser && message.document && message.document.id &&
|
|
|
+ <a style={ {
|
|
|
+ padding: '10px',
|
|
|
+ background: '#f7f7f7',
|
|
|
+ borderRadius: '10px',
|
|
|
+ textDecoration: 'none',
|
|
|
+ color: '#24292f',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center'
|
|
|
+ } } href={ message.document.url } target="_blank">
|
|
|
+ <FileIcon fileName={ message.document.name } />
|
|
|
+ <div style={ { marginLeft: 8, fontSize: '14px' } }>
|
|
|
+ { message.document.name }
|
|
|
+ </div>
|
|
|
+ </a>
|
|
|
+ }
|
|
|
+ {/* {showTyping && (
|
|
|
+ <div className={styles["chat-message-status"]}>
|
|
|
+ 正在输入…
|
|
|
+ </div>
|
|
|
+ )} */ }
|
|
|
+ {
|
|
|
+ message.networkInfo && message.networkInfo.list.length > 0 &&
|
|
|
+ <div style={ { marginTop: 10 } }>
|
|
|
+ <Button
|
|
|
+ icon={ <RightOutlined /> }
|
|
|
+ iconPosition='end'
|
|
|
+ onClick={ () => {
|
|
|
+ setDrawerList( message.networkInfo!.list );
|
|
|
+ setDrawerOpen( true );
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ 搜索到{ message.networkInfo.list.length }篇相关资料
|
|
|
+ </Button>
|
|
|
+ {
|
|
|
+ drawerOpen &&
|
|
|
+ <NetworkDrawer
|
|
|
+ list={ message.networkInfo.list }
|
|
|
+ />
|
|
|
+ }
|
|
|
+ </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
|
|
|
+ }
|
|
|
+ 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>
|
|
|
+ </div>
|
|
|
+ { shouldShowClearContextDivider && <ClearContextDivider /> }
|
|
|
+ </Fragment>
|
|
|
+ );
|
|
|
+ } ) }
|
|
|
+ </>
|
|
|
+ </div>
|
|
|
+ <div className={ styles[ "chat-input-panel" ] }>
|
|
|
+ <ChatActions
|
|
|
+ setUserInput={ setUserInput }
|
|
|
+ doSubmit={ doSubmit }
|
|
|
+ uploadImage={ uploadImage }
|
|
|
+ setAttachImages={ setAttachImages }
|
|
|
+ setUploading={ setUploading }
|
|
|
+ showPromptModal={ () => setShowPromptModal( true ) }
|
|
|
+ scrollToBottom={ scrollToBottom }
|
|
|
+ hitBottom={ hitBottom }
|
|
|
+ uploading={ uploading }
|
|
|
+ showPromptHints={ () => {
|
|
|
+ if ( promptHints.length > 0 ) {
|
|
|
+ setPromptHints( [] );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ inputRef.current?.focus();
|
|
|
+ setUserInput( "/" );
|
|
|
+ onSearch( "" );
|
|
|
+ } }
|
|
|
+ />
|
|
|
+ {
|
|
|
+ fileList.length > 0 &&
|
|
|
+ <div style={ { marginBottom: 20 } }>
|
|
|
+ <Upload
|
|
|
+ fileList={ fileList }
|
|
|
+ onRemove={ ( file ) => {
|
|
|
+ setFileList( fileList.filter( item => item.uid !== file.uid ) );
|
|
|
+ } }
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ <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-input2" ] }
|
|
|
+ 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>
|
|
|
+ ) }
|
|
|
+ {/* 修改样式:输入框内部按钮区域 */ }
|
|
|
+ {/* <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}> */ }
|
|
|
+ <div className={ styles[ "chat-input-bottom-bar" ] }>
|
|
|
+ {/* <div style={{ display: 'flex', alignItems: 'center' }}> */ }
|
|
|
+ <div className={ styles[ "left-options" ] }>
|
|
|
+
|
|
|
+ {/*深度思考R1按钮*/ }
|
|
|
+
|
|
|
+ {false&&<Tooltip
|
|
|
+ title={
|
|
|
+ <span style={ { fontSize: 12, lineHeight: 1.4, minHeight: 24, padding: '4px 8px' } }>
|
|
|
+ { isDeepThink ? '关闭深度思考模式' : '启用深度思考模式' }
|
|
|
+ </span>
|
|
|
+ }
|
|
|
+ placement="left"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ // className={styles["option-item"]}
|
|
|
+ style={ {
|
|
|
+ padding: '0 12px',
|
|
|
+ height: 28,
|
|
|
+ borderRadius: 18,
|
|
|
+ fontSize: 12,
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'center',
|
|
|
+ alignItems: 'center',
|
|
|
+ // marginRight: 10,
|
|
|
+ cursor: 'pointer',
|
|
|
+ background: isDeepThink ? '#dee9fc' : '#f3f4f6',
|
|
|
+ color: isDeepThink ? '#3875f6' : '#000000',
|
|
|
+ // border: `1px solid ${isDeepThink ? '#3875f6' : 'transparent'}`,
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
+ userSelect: 'none'
|
|
|
+ } }
|
|
|
+ onClick={ () => {
|
|
|
+ setIsDeepThink( !isDeepThink );
|
|
|
+ chatStore.setIsDeepThink( !isDeepThink );
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ <img src={ isDeepThink ? sdsk_selected.src : sdsk.src }
|
|
|
+ style={ {
|
|
|
+ width: 16,
|
|
|
+ height: 16,
|
|
|
+ } }
|
|
|
+ />
|
|
|
+ <span style={ { fontSize: 11, marginLeft: 5 } }>
|
|
|
+ 深度思考
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </Tooltip>}
|
|
|
+ {/*联网搜索按钮*/ }
|
|
|
+ <div style={ {
|
|
|
+ padding: isMobileScreen ? '0 8px' : '0 12px',
|
|
|
+ height: 28,
|
|
|
+ borderRadius: 18,
|
|
|
+ fontSize: 12,
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'center',
|
|
|
+ alignItems: 'center',
|
|
|
+ cursor: 'pointer',
|
|
|
+ background: webSearch ? '#dee9fc' : '#f3f4f6',
|
|
|
+ color: webSearch ? '#3875f6' : '#000000',
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
+ userSelect: 'none'
|
|
|
+ } }
|
|
|
+ onClick={ () => {
|
|
|
+ setWebSearch( !webSearch );
|
|
|
+ chatStore.setWebSearch( !webSearch );
|
|
|
+ } }
|
|
|
+ >
|
|
|
+
|
|
|
+ <img src={ webSearch ? hlw_selected.src : hlw.src }
|
|
|
+ style={ {
|
|
|
+ width: 16,
|
|
|
+ height: 16,
|
|
|
+ } }
|
|
|
+ />
|
|
|
+ {!isMobileScreen && (
|
|
|
+ <span style={ { fontSize: 11, marginLeft: 5, marginRight: 10 } }>
|
|
|
+ 联网搜索
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div style={ { display: 'flex', alignItems: 'center' } }>
|
|
|
+ {
|
|
|
+ !webSearch &&
|
|
|
+ <div style={ { marginRight: 10 } }>
|
|
|
+ <Upload
|
|
|
+ { ...uploadConfig }
|
|
|
+ showUploadList={ false }
|
|
|
+ maxCount={ 1 }
|
|
|
+ onChange={ ( info ) => {
|
|
|
+ const fileList = info.fileList.map( ( file ) => {
|
|
|
+ const data = file.response;
|
|
|
+ return {
|
|
|
+ ...file,
|
|
|
+ url: data?.document_url || file.url,
|
|
|
+ documentId: data?.document_id || '',
|
|
|
+ }
|
|
|
+ } );
|
|
|
+ setFileList( fileList );
|
|
|
+ if ( info.file.status === 'done' ) {// 上传成功
|
|
|
+ const { code, message: msg } = info.file.response;
|
|
|
+ if ( code === 200 ) {
|
|
|
+ message.success( '上传成功' );
|
|
|
+ } else {
|
|
|
+ message.error( msg );
|
|
|
+ }
|
|
|
+ } else if ( info.file.status === 'error' ) {// 上传失败
|
|
|
+ message.error( '上传失败' );
|
|
|
+ }
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ <Tooltip
|
|
|
+ title={
|
|
|
+ <div style={ { padding: '4px 8px' } }>
|
|
|
+ <div style={ {
|
|
|
+ fontSize: 12,
|
|
|
+ lineHeight: 1.4,
|
|
|
+ marginBottom: 6,
|
|
|
+ } }>
|
|
|
+ 上传附件 (识别文本和图表中的内容)
|
|
|
+ </div>
|
|
|
+ <div style={ {
|
|
|
+ fontSize: 10,
|
|
|
+ color: '#8c8c8c',
|
|
|
+ lineHeight: 1.4,
|
|
|
+ }}>
|
|
|
+ <span>
|
|
|
+ 仅支持单个PDF/Word/TXT文件格式
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ (单个文件≤50MB)
|
|
|
+ </span>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>}
|
|
|
+ placement="top"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={ {
|
|
|
+ width: 28,
|
|
|
+ height: 28,
|
|
|
+ borderRadius: '50%',
|
|
|
+ background: '#4357d2',
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'center',
|
|
|
+ alignItems: 'center',
|
|
|
+ cursor: 'pointer',
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
+ userSelect: 'none'
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ <PaperClipOutlined style={ { color: '#FFFFFF', fontSize: '18px' } } />
|
|
|
+ </div>
|
|
|
+ </Tooltip>
|
|
|
+ </Upload>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ <div
|
|
|
+ style={ {
|
|
|
+ width: 28,
|
|
|
+ height: 28,
|
|
|
+ borderRadius: '50%',
|
|
|
+ background: '#4357d2',
|
|
|
+ display: 'flex',
|
|
|
+ justifyContent: 'center',
|
|
|
+ alignItems: 'center',
|
|
|
+ cursor: 'pointer',
|
|
|
+ } }
|
|
|
+ onClick={ ( e ) => {
|
|
|
+ e.preventDefault();
|
|
|
+ e.stopPropagation();
|
|
|
+ if ( couldStop ) {
|
|
|
+ stopAll();
|
|
|
+ } else {
|
|
|
+ doSubmit( userInput );
|
|
|
+ }
|
|
|
+ } }
|
|
|
+ >
|
|
|
+ {
|
|
|
+ couldStop ?
|
|
|
+ <div style={ { width: 13, height: 13, background: '#FFFFFF', borderRadius: 2 } }></div>
|
|
|
+ :
|
|
|
+ <div style={ { transform: 'rotate(-45deg)', padding: '0px 0px 3px 5px' } }>
|
|
|
+ <SendOutlined style={ { color: '#FFFFFF' } } />
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </label>
|
|
|
+
|
|
|
+ {!isMobileScreen && (
|
|
|
+ <div style={ { marginTop: 8, textAlign: 'center', color: '#888888', fontSize: 12 } }>
|
|
|
+ 内容由AI生成,仅供参考
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {
|
|
|
+ showExport && (
|
|
|
+ <ExportMessageModal onClose={ () => setShowExport( false ) } />
|
|
|
+ )
|
|
|
+ }
|
|
|
+ {
|
|
|
+ isEditingMessage && (
|
|
|
+ <EditMessageModal
|
|
|
+ onClose={ () => {
|
|
|
+ setIsEditingMessage( false );
|
|
|
+ } }
|
|
|
+ />
|
|
|
+ )
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function Chat(props?: { onMessageSent?: () => void }) {
|
|
|
+ const globalStore = useGlobalStore();
|
|
|
+ const chatStore = useChatStore();
|
|
|
+ const sessionIndex = chatStore.currentSessionIndex;
|
|
|
+
|
|
|
+ useEffect( () => {
|
|
|
+ globalStore.setShowMenu( true );
|
|
|
+ chatStore.setModel( 'DeepSeek' );
|
|
|
+ chatStore.setWebSearch( false );
|
|
|
+ }, [] );
|
|
|
+
|
|
|
+ return <_Chat key={ sessionIndex } onMessageSent={props?.onMessageSent}></_Chat>;
|
|
|
+}
|
|
|
+
|