// 第三方库导入 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 "./chat.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 (
{ props.model?.startsWith( "gpt-4" ) ? ( ) : ( ) }
); } return (
{/* 移除emoji头像,使用默认bot图标 */ }
); } export function createMessage( override : Partial ) : 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: () => , } ); export function SessionConfigModel( props : { onClose : () => void } ) { const chatStore = useChatStore(); const session = chatStore.currentSession(); const maskStore = useMaskStore(); const navigate = useNavigate(); return (
props.onClose() } actions={ [ } bordered text={ Locale.Chat.Config.Reset } onClick={ async () => { if ( await showConfirm( Locale.Memory.ResetConfirm ) ) { chatStore.updateCurrentSession( ( session ) => ( session.memoryPrompt = "" ), ); } } } />, } bordered text={ Locale.Chat.Config.SaveAs } onClick={ () => { navigate( Path.Masks ); setTimeout( () => { maskStore.create( session.mask ); }, 500 ); } } />, ] } > { const mask = { ...session.mask }; updater( mask ); chatStore.updateCurrentSession( ( session ) => ( session.mask = mask ) ); } } shouldSyncFromGlobal extraListItems={ session.mask.modelConfig.sendMemory ? ( ) : ( <> ) } >
); } // 提示词 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 { const plan : string = '2'; if ( plan === '1' ) { // 方案1.点击后出现在输入框内,用户自己点击发送 setUserInput( item.text ); } else { // 方案2.点击后直接发送 doSubmit( item.text ) } } } > { item.title } } ) } ) } function PromptToast( props : { showToast? : boolean; showModal? : boolean; setShowModal : ( _ : boolean ) => void; } ) { const chatStore = useChatStore(); const session = chatStore.currentSession(); const context = session.mask.context; return (
{ props.showToast && (
props.setShowModal( true ) } > { Locale.Context.Toast( context.length ) }
) } { props.showModal && ( props.setShowModal( false ) } /> ) }
); } 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 ) => { // 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; export function PromptHints( props : { prompts : RenderPrompt[]; onPromptSelect : ( prompt : RenderPrompt ) => void; } ) { const noPrompts = props.prompts.length === 0; const [ selectIndex, setSelectIndex ] = useState( 0 ); const selectedRef = useRef( 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 (
{ props.prompts.map( ( prompt, i ) => (
props.onPromptSelect( prompt ) } onMouseEnter={ () => setSelectIndex( i ) } >
{ prompt.title }
{ prompt.content }
) ) }
); } function ClearContextDivider() { const chatStore = useChatStore(); return (
chatStore.updateCurrentSession( ( session ) => ( session.clearContextIndex = undefined ), ) } >
{ Locale.Context.Clear }
{ Locale.Context.Revert }
); } export function ChatAction( props : { text : string; icon : JSX.Element; onClick : () => void; } ) { const iconRef = useRef( null ); const textRef = useRef( 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 (
{ props.onClick(); setTimeout( updateWidth, 1 ); } } onMouseEnter={ updateWidth } onTouchStart={ updateWidth } style={ { "--icon-width": `${ width.icon }px`, "--full-width": `${ width.full }px`, } as React.CSSProperties } >
{ props.icon }
{ props.text }
); } function useScrollToBottom( scrollRef : RefObject, 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( [] ); 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 (
{ showModelSelector && ( ( { 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 ) && ( setShowSizeSelector( true ) } text={ currentSize } icon={ } /> ) } { showSizeSelector && ( ( { 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 && ( setShowPluginSelector( false ) } onSelection={ ( s ) => { const plugin = s[ 0 ]; chatStore.updateCurrentSession( ( session ) => { session.mask.plugin = s; } ); if ( plugin ) { showToast( plugin ); } } } /> ) }
); } export function EditMessageModal( props : { onClose : () => void } ) { const chatStore = useChatStore(); const session = chatStore.currentSession(); const [ messages, setMessages ] = useState( session.messages.slice() ); return (
} key="cancel" onClick={ () => { props.onClose(); } } />, } key="ok" onClick={ () => { chatStore.updateCurrentSession( ( session ) => ( session.messages = messages ), ); props.onClose(); } } />, ] } > chatStore.updateCurrentSession( ( session ) => ( session.topic = e.currentTarget.value ), ) } > { const newMessages = messages.slice(); updater( newMessages ); setMessages( newMessages ); } } />
); } export function DeleteImageButton( props : { deleteImage : () => void } ) { return (
); } 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( null ); const [ userInput, setUserInput ] = useState( "" ); const [ isLoading, setIsLoading ] = useState( false ); const { submitKey, shouldSubmit } = useSubmitHandler(); const scrollRef = useRef( 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( [] ); const [ uploading, setUploading ] = useState( false ); // prompt hints const promptStore = usePromptStore(); const [ promptHints, setPromptHints ] = useState( [] ); 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( 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), // 关闭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( [] ); if ( !isMobileScreen ) inputRef.current?.focus(); setAutoScroll( 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 ) => { 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 ) => { 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( ( 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( ( 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( [] ); // 上传配置 const uploadConfig : UploadProps = { action: '/deepseek-api' + '/upload/file', method: 'POST', accept: [ '.pdf', '.txt', '.doc', '.docx' ].join( ',' ), }; interface FileIconProps { fileName : string; } const FileIcon : React.FC = ( props : FileIconProps ) => { const style = { fontSize: '30px', color: '#3875f6', } let icon = if ( props.fileName ) { const suffix = props.fileName.split( '.' ).pop() || ''; switch ( suffix ) { case 'pdf': icon = break; case 'txt': icon = break; case 'doc': case 'docx': icon = break; default: break; } } return icon; } const [ isDeepThink, setIsDeepThink ] = useState( chatStore.isDeepThink ); // 切换聊天窗口后清理上传文件信息 useEffect( () => { setFileList( [] ) }, [ chatStore.currentSession() ] ) const couldStop = ChatControllerPool.hasPending(); const stopAll = () => ChatControllerPool.stopAll(); // 切换聊天窗口后清理上传文件信息 useEffect( () => { setWebSearch( false ); }, [ chatStore.currentSession() ] ) const [ webSearch, setWebSearch ] = useState( chatStore.web_search ); const [ drawerOpen, setDrawerOpen ] = useState( false ); type DrawerList = { title : string, content : string, web_url : string, }[] const [ drawerList, setDrawerList ] = useState( [] ); interface NetworkDrawerProps { list : DrawerList, } const NetworkDrawer : React.FC = ( props ) => { return ( { setDrawerOpen( false ); } } > { props.list.map( ( item, index ) => { return
{ window.open( item.web_url ); } } >
{ item.title }
{ item.content }
} ) }
) } return (
{ isMobileScreen && location.pathname !== '/' &&
} text={ Locale.NewChat.Return } onClick={ () => navigate( '/deepseekChat' ) } />
}
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 (
{ isUser ? ( // 在这里换头像
) : ( <> { [ "system" ].includes( message.role ) ? ( ) : ( ) } ) }
{ isUser && message.document && message.document.id &&
{ message.document.name }
} {/* {showTyping && (
正在输入…
)} */ } { message.networkInfo && message.networkInfo.list.length > 0 &&
{ drawerOpen && }
}
{ if ( !isMobileScreen ) return; setUserInput( getMessageTextContent( message ) ); } } fontSize={ fontSize } fontFamily={ fontFamily } parentRef={ scrollRef } defaultShow={ i >= messages.length - 6 } /> { getMessageImages( message ).length == 1 && ( ) } { getMessageImages( message ).length > 1 && (
{ getMessageImages( message ).map( ( image, index ) => { return ( ); } ) }
) }
{ shouldShowClearContextDivider && }
); } ) }
setShowPromptModal( true ) } scrollToBottom={ scrollToBottom } hitBottom={ hitBottom } uploading={ uploading } showPromptHints={ () => { if ( promptHints.length > 0 ) { setPromptHints( [] ); return; } inputRef.current?.focus(); setUserInput( "/" ); onSearch( "" ); } } /> { fileList.length > 0 &&
{ setFileList( fileList.filter( item => item.uid !== file.uid ) ); } } />
}