引言

首选得前端有SSR经验和react技巧

在当今快速发展的信息时代,人们对知识的获取和传播方式发生了翻天覆地的变化。技术的进步不仅改变了我们与世界的互动方式,也重新定义了学习、工作和生活的边界。面对海量的数据和繁琐的问题,如何高效、准确地获取所需信息成为一项关键能力。

步骤

分析现有架构

NextChat基于Next.js开发,当前采用CSR模式。需检查路由、API调用和状态管理机制,识别SSR适配点。

设置SSR支持

在next.config.js中启用SSR,页面组件需实现getServerSideProps。动态路由需配合getStaticPaths实现混合渲染。

资料获取改造

将客户端fetch迁移至getServerSideProps,处理身份验证token传递。流式响应需设计服务端到客户端的渐进传输方案。

状态管理适配

重构zustand等状态库,利用__NEXT_DATA__将服务端状态序列化到HTML,客户端直接复用初始状态。

流式响应处理

对OpenAI流式API,实现服务端SSE转发。服务端建立连接后,采用Transfer-Encoding: chunked逐步返回数据。

性能优化

非实时内容启用getStaticProps静态生成。引入SWR或React Query达成服务端数据预取,减少客户端重复请求。

部署调整

构建时输出serverless或edge function支持SSR。Dockerfile安装NODE_ENV=production优化bundle体积。

测试验证

检测首屏DOM内容,验证API请求是否发生在服务端。借助Lighthouse对比SEO和性能指标变化

作者改造部分代码地址可观看:服务器为免费 别攻击大佬们可以借阅能改造成啥样子

http://42.194.162.70:3000/

1.官网演示地址https://nextnext.chat/?models=gpt-5-mini%40nextchat&tools=net-search

2.github地址https://github.com/ChatGPTNextWeb/NextChat

开始

1.git clonehttps://github.com/ChatGPTNextWeb/NextChat.git 创建项目

2.项目结构

3.现在我们执行yarn install   或者npmi注意需要node脚本>=18.0.0

4.可以看到这个开源方案支撑响应式直接开发一套移动PC端就可以完成了

5.前端关注app包则就说前端页面包后面接着改造需要用到

6.找到AItsx文件进行改造

7.接着我们将官方的代码逻辑进行代码修改搞糟需要企业或者个人项目

8.将代码进行改造之后得到属于自己的ssr开源框架项目 这样就能用自己的地址或者大模型对接不需要使用官方的key

不需要官方的ky或者配置独立于企业自身大模型或者AI调教之后的改动部署基于SSR的代码框架实现前端自主修改框架就是以上

9.关于部署接下来我们打包

yarn install

拿到打包档案.nextt

10.将代码部署服务器关于ssr部署教程请移步  这样我们就实现了签到改造

当然也可能docker或者官方推荐部署结合自身环境来以上就是关于前端改造属于自身项目的框架推荐响应式减少编写时间

11.改造源码可参考

点个赞或者关于吧 谢谢大佬门了

import { useDebouncedCallback } from "use-debounce";
import React, {
  Fragment,
  RefObject,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import RenameIcon from "../icons/rename.svg";
import EditIcon from "../icons/rename.svg";
import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import SpeakIcon from "../icons/speak.svg";
import SpeakStopIcon from "../icons/speak-stop.svg";
import LoadingIcon from "../icons/three-dots.svg";
import LoadingButtonIcon from "../icons/loading.svg";
import PromptIcon from "../icons/prompt.svg";
import MaskIcon from "../icons/mask.svg";
import MaxIcon from "../icons/max.svg";
import MinIcon from "../icons/min.svg";
import ResetIcon from "../icons/reload.svg";
import ReloadIcon from "../icons/reload.svg";
import BreakIcon from "../icons/break.svg";
import SettingsIcon from "../icons/chat-settings.svg";
import DeleteIcon from "../icons/clear.svg";
import PinIcon from "../icons/pin.svg";
import ConfirmIcon from "../icons/confirm.svg";
import CloseIcon from "../icons/close.svg";
import CancelIcon from "../icons/cancel.svg";
import ImageIcon from "../icons/image.svg";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg";
import StopIcon from "../icons/pause.svg";
import RobotIcon from "../icons/robot.svg";
import SizeIcon from "../icons/size.svg";
import QualityIcon from "../icons/hd.svg";
import StyleIcon from "../icons/palette.svg";
import PluginIcon from "../icons/plugin.svg";
import ShortcutkeyIcon from "../icons/shortcutkey.svg";
import McpToolIcon from "../icons/tool.svg";
import HeadphoneIcon from "../icons/headphone.svg";
import {
  BOT_HELLO,
  ChatMessage,
  createMessage,
  DEFAULT_TOPIC,
  ModelType,
  SubmitKey,
  Theme,
  useAccessStore,
  useAppConfig,
  useChatStore,
  usePluginStore,
} from "../store";
import {
  autoGrowTextArea,
  copyToClipboard,
  getMessageImages,
  getMessageTextContent,
  isDalle3,
  isVisionModel,
  safeLocalStorage,
  getModelSizes,
  supportsCustomSize,
  useMobileScreen,
  selectOrCopy,
  showPlugins,
} from "../utils";
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller";
import { DalleQuality, DalleStyle, ModelSize } from "../typing";
import { Prompt, usePromptStore } from "../store/prompt";
import Locale from "../locales";
import { IconButton } from "./button";
import styles from "./chat.module.scss";
import {
  List,
  ListItem,
  Modal,
  Selector,
  showConfirm,
  showPrompt,
  showToast,
} from "./ui-lib";
import { useNavigate } from "react-router-dom";
import {
  CHAT_PAGE_SIZE,
  DEFAULT_TTS_ENGINE,
  ModelProvider,
  Path,
  REQUEST_TIMEOUT_MS,
  ServiceProvider,
  UNFINISHED_INPUT,
} from "../constant";
import { Avatar } from "./emoji";
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
import { useMaskStore } from "../store/mask";
import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
import { prettyObject } from "../utils/format";
import { ExportMessageModal } from "./exporter";
import { getClientConfig } from "../config/client";
import { useAllModels } from "../utils/hooks";
import { ClientApi, MultimodalContent } from "../client/api";
import { createTTSPlayer } from "../utils/audio";
import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
import { isEmpty } from "lodash-es";
import { getModelProvider } from "../utils/model";
import { RealtimeChat } from "@/app/components/realtime-chat";
import clsx from "clsx";
import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions";
import { log } from "util";
const localStorage = safeLocalStorage();
const ttsPlayer = createTTSPlayer();
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
  loading: () => ,
});
const MCPAction = () => {
  const navigate = useNavigate();
  const [count, setCount] = useState(0);
  const [mcpEnabled, setMcpEnabled] = useState(false);
  useEffect(() => {
    const checkMcpStatus = async () => {
      const enabled = await isMcpEnabled();
      setMcpEnabled(enabled);
      if (enabled) {
        const count = await getAvailableClientsCount();
        setCount(count);
      }
    };
    checkMcpStatus();
  }, []);
  if (!mcpEnabled) return null;
  return (
     navigate(Path.McpMarket)}
      text={`MCP${count ? ` (${count})` : ""}`}
      icon={}
    />
  );
};
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.updateTargetSession( session, (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.updateTargetSession( session, (session) => (session.mask = mask), ); }} shouldSyncFromGlobal extraListItems={ session.mask.modelConfig.sendMemory ? ( ) : ( <> ) } >
); } 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 && context.length > 0 && (
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(); const session = chatStore.currentSession(); return (
chatStore.updateTargetSession( session, (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, messages: ChatMessage[], ) { const [autoScroll, setAutoScroll] = useState(true); const latestAutoScrollRef = useRef(true); // 监听autoScroll状态变化并更新ref useEffect(() => { latestAutoScrollRef.current = autoScroll; console.log('AutoScroll updated to:', autoScroll); }, [autoScroll]); // 计算距离底部的距离 const getDistanceFromBottom = useCallback(() => { const dom = scrollRef.current; if (!dom) return 0; return dom.scrollHeight - dom.scrollTop - dom.clientHeight; }, [scrollRef]); // 滚动事件处理逻辑 useEffect(() => { const dom = scrollRef.current; if (!dom) return; // 初始检查位置 const initialDistance = getDistanceFromBottom(); setAutoScroll(initialDistance < 30); const handleScroll = () => { const distanceFromBottom = getDistanceFromBottom(); console.log('Distance from bottom:', distanceFromBottom); // 核心逻辑: // 1. 当距离底部小于30px时,启用autoScroll // 2. 当距离底部大于100px时,禁用autoScroll if (distanceFromBottom < 30) { setAutoScroll(true); console.log('Setting autoScroll to false, current latest value:', latestAutoScrollRef.current); } else if (distanceFromBottom > 100) { setAutoScroll(false); // 使用ref获取最新的状态值 console.log('Setting autoScroll to false, current latest value:', latestAutoScrollRef.current); // 注意:setState是异步的,直接访问autoScroll会得到旧值 } }; // 添加滚动事件监听 dom.addEventListener('scroll', handleScroll); return () => { dom.removeEventListener('scroll', handleScroll); }; }, [scrollRef, getDistanceFromBottom]); // 滚动到底部函数 const scrollDomToBottom = useCallback(() => { const dom = scrollRef.current; if (!dom) return; // 直接设置滚动位置,确保响应及时 dom.scrollTop = dom.scrollHeight; }, [scrollRef, autoScroll]); // 当消息数量变化时,只有在autoScroll为true时才滚动到底部 const lastMessagesLength = useRef(messages.length); useEffect(() => { // 只有当消息数量增加且autoScroll为true时才滚动到底部 if (messages.length > lastMessagesLength.current && autoScroll) { scrollDomToBottom(); } // 更新消息数量引用 lastMessagesLength.current = messages.length; }, [messages.length, scrollDomToBottom, autoScroll]); // 当autoScroll状态变为true时立即滚动到底部 useEffect(() => { if (autoScroll) { scrollDomToBottom(); } }, [autoScroll, scrollDomToBottom]); // 保留兼容性的isScrolledToBottom方法 const isScrolledToBottom = useCallback(() => { return getDistanceFromBottom() < 10; }, [getDistanceFromBottom]); return { scrollRef, latestAutoScrollRef, setAutoScroll, scrollDomToBottom, isScrolledToBottom, }; } export function ChatActions(props: { uploadImage: () => void; setAttachImages: (images: string[]) => void; setUploading: (uploading: boolean) => void; showPromptModal: () => void; scrollToBottom: () => void; showPromptHints: () => void; hitBottom: boolean; uploading: boolean; setShowShortcutKeyModal: React.Dispatch>; setUserInput: (input: string) => void; setShowChatSidePanel: React.Dispatch>; }) { const config = useAppConfig(); const navigate = useNavigate(); const chatStore = useChatStore(); const pluginStore = usePluginStore(); const session = chatStore.currentSession(); // 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 = session.mask.modelConfig.model; const currentProviderName = session.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); const [showSizeSelector, setShowSizeSelector] = useState(false); const [showQualitySelector, setShowQualitySelector] = useState(false); const [showStyleSelector, setShowStyleSelector] = useState(false); const modelSizes = getModelSizes(currentModel); const dalle3Qualitys: DalleQuality[] = ["standard", "hd"]; const dalle3Styles: DalleStyle[] = ["vivid", "natural"]; const currentSize = session.mask.modelConfig?.size ?? ("1024x1024" as ModelSize); const currentQuality = session.mask.modelConfig?.quality ?? "standard"; const currentStyle = session.mask.modelConfig?.style ?? "vivid"; const isMobileScreen = useMobileScreen(); 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 isUnavailableModel = !models.some((m) => m.name === currentModel); if (isUnavailableModel && models.length > 0) { // show next model to default model if exist let nextModel = models.find((model) => model.isDefault) || models[0]; chatStore.updateTargetSession(session, (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, session]); return (
<> {couldStop && ( } /> )} {!props.hitBottom && ( } /> )} {props.hitBottom && ( } /> )} {showUploadImage && ( : } /> )} {theme === Theme.Auto ? ( ) : theme === Theme.Light ? ( ) : theme === Theme.Dark ? ( ) : null} } /> } /> { navigate(Path.Masks); }} text={Locale.Chat.InputActions.Masks} icon={} /> } onClick={() => { chatStore.updateTargetSession(session, (session) => { if (session.clearContextIndex === session.messages.length) { session.clearContextIndex = undefined; } else { session.clearContextIndex = session.messages.length; session.memoryPrompt = ""; // will clear memory } }); }} /> setShowModelSelector(true)} text={currentModelName} icon={} /> {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] = getModelProvider(s[0]); chatStore.updateTargetSession(session, (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); } }} /> )} {supportsCustomSize(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.updateTargetSession(session, (session) => { session.mask.modelConfig.size = size; }); showToast(size); }} /> )} {isDalle3(currentModel) && ( setShowQualitySelector(true)} text={currentQuality} icon={} /> )} {showQualitySelector && ( ({ title: m, value: m, }))} onClose={() => setShowQualitySelector(false)} onSelection={(q) => { if (q.length === 0) return; const quality = q[0]; chatStore.updateTargetSession(session, (session) => { session.mask.modelConfig.quality = quality; }); showToast(quality); }} /> )} {isDalle3(currentModel) && ( setShowStyleSelector(true)} text={currentStyle} icon={} /> )} {showStyleSelector && ( ({ title: m, value: m, }))} onClose={() => setShowStyleSelector(false)} onSelection={(s) => { if (s.length === 0) return; const style = s[0]; chatStore.updateTargetSession(session, (session) => { session.mask.modelConfig.style = style; }); showToast(style); }} /> )} {showPlugins(currentProviderName, currentModel) && ( { if (pluginStore.getAll().length == 0) { navigate(Path.Plugins); } else { setShowPluginSelector(true); } }} text={Locale.Plugin.Name} icon={} /> )} {showPluginSelector && ( ({ title: `${item?.title}@${item?.version}`, value: item?.id, }))} onClose={() => setShowPluginSelector(false)} onSelection={(s) => { chatStore.updateTargetSession(session, (session) => { session.mask.plugin = s as string[]; }); }} /> )} {!isMobileScreen && ( props.setShowShortcutKeyModal(true)} text={Locale.Chat.ShortcutKey.Title} icon={} /> )} {!isMobileScreen && }
{config.realtimeConfig.enable && ( props.setShowChatSidePanel(true)} text={"Realtime Chat"} icon={} /> )}
); } 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.updateTargetSession( session, (session) => (session.messages = messages), ); props.onClose(); }} />, ]} > chatStore.updateTargetSession( session, (session) => (session.topic = e.currentTarget.value), ) } > { const newMessages = messages.slice(); updater(newMessages); setMessages(newMessages); }} />
); } export function DeleteImageButton(props: { deleteImage: () => void }) { return (
); } export function ShortcutKeyModal(props: { onClose: () => void }) { const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; const shortcuts = [ { title: Locale.Chat.ShortcutKey.newChat, keys: isMac ? ["⌘", "Shift", "O"] : ["Ctrl", "Shift", "O"], }, { title: Locale.Chat.ShortcutKey.focusInput, keys: ["Shift", "Esc"] }, { title: Locale.Chat.ShortcutKey.copyLastCode, keys: isMac ? ["⌘", "Shift", ";"] : ["Ctrl", "Shift", ";"], }, { title: Locale.Chat.ShortcutKey.copyLastMessage, keys: isMac ? ["⌘", "Shift", "C"] : ["Ctrl", "Shift", "C"], }, { title: Locale.Chat.ShortcutKey.showShortcutKey, keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"], }, { title: Locale.Chat.ShortcutKey.clearContext, keys: isMac ? ["⌘", "Shift", "backspace"] : ["Ctrl", "Shift", "backspace"], }, ]; return (
} key="ok" onClick={() => { props.onClose(); }} />, ]} >
{shortcuts.map((shortcut, index) => (
{shortcut.title}
{shortcut.keys.map((key, i) => (
{key}
))}
))}
); } function _Chat() { type RenderMessage = ChatMessage & { preview?: boolean }; const chatStore = useChatStore(); const session = chatStore.currentSession(); const config = useAppConfig(); 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 [isGenerating, setIsGenerating] = useState(false); const abortControllerRef = useRef(null); // 停止生成的函数 const handleStopGeneration = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; setIsGenerating(false); // 更新消息状态为已停止 chatStore.updateTargetSession(session, (session) => { const lastMessage = session.messages[session.messages.length - 1]; if (lastMessage && lastMessage.streaming) { lastMessage.streaming = 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 isTyping = userInput !== ""; // if user is typing, should auto scroll to bottom // if user is not typing, should auto scroll to bottom only if already at bottom // 保存latestAutoScrollRef到一个独立的变量,确保在所有地方都能正确访问 const scrollHookResult = useScrollToBottom( scrollRef, false, // 不再使用复杂的detach条件,而是通过内部的autoScroll状态控制 session.messages, ); // 从hook结果中解构出所需的属性 const { setAutoScroll, scrollDomToBottom, latestAutoScrollRef } = scrollHookResult; 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 }, ); // auto grow input 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.NewChat), prev: () => chatStore.nextSession(-1), next: () => chatStore.nextSession(1), clear: () => chatStore.updateTargetSession( session, (session) => (session.clearContextIndex = session.messages.length), ), fork: () => chatStore.forkSession(), 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 = async (userInput: string) => { if (userInput.trim() === "" && isEmpty(attachImages)) return; const matchCommand = chatCommands.match(userInput); if (matchCommand.matched) { setUserInput(""); setPromptHints([]); matchCommand.invoke(); return; } setIsLoading(true); // 创建用户消息并添加到会话 const userMessage = createMessage({ role: "user", content: userInput, }); // 如果有附加图片,处理图片内容 if (attachImages.length > 0) { const multimodalContent: any[] = [{ type: "text", text: userInput }]; attachImages.forEach(image => { multimodalContent.push({ type: "image_url", image_url: { url: image } }); }); userMessage.content = multimodalContent; } scrollDomToBottom(); // 更新会话添加用户消息 chatStore.updateTargetSession(session, (session) => { session.messages.push(userMessage); }); // 创建助手响应消息(初始为空) const botMessage = createMessage({ role: "assistant", content: "", streaming: true }); // 添加助手消息到会话 chatStore.updateTargetSession(session, (session) => { session.messages.push(botMessage); }); // 清理输入状态 setAttachImages([]); chatStore.setLastInput(userInput); setUserInput(""); setPromptHints([]); if (!isMobileScreen) inputRef.current?.focus(); setAutoScroll(true); setIsGenerating(true); try { const abortController = new AbortController(); abortControllerRef.current = abortController; let tokenCount = 0; let charCount = 0; let lastUpdateTime = Date.now(); // 构建请求payload const payload = { model: "x", messages: [{ role: "user", content: userInput }], stream: true, temperature: 0.7 }; // 使用Fetch API处理流式响应 const response = await fetch('xxxx', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer xxxx' }, body: JSON.stringify(payload), signal: abortController.signal }); if (!response.ok) { throw new Error(`API错误: ${response.status} ${response.statusText}`); } // 创建流式读取器 const reader = response.body!.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let fullContent = ''; // 读取流数据 while (true) { const { value, done } = await reader.read(); if (done) break; // 解码数据块 const chunk = decoder.decode(value, { stream: true }); buffer += chunk; // 处理数据块中的每一行 let lineEndIndex; while ((lineEndIndex = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, lineEndIndex).trim(); buffer = buffer.slice(lineEndIndex + 1); if (line.startsWith('data: ')) { const dataStr = line.slice(6); if (dataStr === '[DONE]') { // 流结束 break; } try { const data = JSON.parse(dataStr); // 处理增量内容 if (data.choices && (data.choices[0].delta.content || data.choices[0].delta.reasoning_content)) { const newContent = data.choices[0].delta.content || data.choices[0].delta.reasoning_content; fullContent += newContent || data.choices[0].delta.reasoning_content; // 更新消息内容 chatStore.updateTargetSession(session, (session) => { const msg = session.messages.find(m => m.id === botMessage.id); if (msg) { msg.content = fullContent; } }); // 只有当用户滚动到底部时才自动滚动 if (latestAutoScrollRef.current) { scrollDomToBottom(); } } } catch (e) { console.error('解析JSON失败:', e); } } } } // 完成流式传输 chatStore.updateTargetSession(session, (session) => { const msg = session.messages.find(m => m.id === botMessage.id); if (msg) { msg.streaming = false; } }); } catch (error) { // console.error('API调用失败:', error); // showToast(`请求失败: ${error instanceof Error ? error.message : String(error)}`); // // 更新消息为错误状态 // chatStore.updateTargetSession(session, (session) => { // const msg = session.messages.find(m => m.id === botMessage.id); // if (msg) { // msg.isError = true; // msg.content = `请求失败: ${error instanceof Error ? error.message : String(error)}`; // msg.streaming = false; // } // }); } finally { setIsLoading(false); setIsGenerating(false); abortControllerRef.current = null; } }; 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.updateTargetSession(session, (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 }, [session]); // check if should send message const onInputKeyDown = (e: React.KeyboardEvent) => { // if ArrowUp and no userInput, fill with last input if ( e.key === "ArrowUp" && userInput.length <= 0 && !(e.metaKey || e.altKey || e.ctrlKey) ) { setUserInput(chatStore.lastInput ?? ""); 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.updateTargetSession( session, (session) => (session.messages = session.messages.filter((m) => m.id !== msgId)), ); }; const onDelete = (msgId: string) => { deleteMessage(msgId); }; const onResend = async (message: ChatMessage) => { 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); // 创建新的用户消息并添加到会话 const newUserMessage = createMessage({ role: "user", content: textContent, }); // 如果有图片,处理图片内容 if (images.length > 0) { const multimodalContent: any[] = [{ type: "text", text: textContent }]; images.forEach(image => { multimodalContent.push({ type: "image_url", image_url: { url: image } }); }); newUserMessage.content = multimodalContent; } // 更新会话添加新的用户消息 chatStore.updateTargetSession(session, (session) => { session.messages.push(newUserMessage); }); // 创建助手响应消息(初始为空) const newBotMessage = createMessage({ role: "assistant", content: "", streaming: true }); // 添加助手消息到会话 chatStore.updateTargetSession(session, (session) => { session.messages.push(newBotMessage); }); inputRef.current?.focus(); setAutoScroll(true); setIsGenerating(true); try { const abortController = new AbortController(); abortControllerRef.current = abortController; // 构建请求payload const payload = { model: "x", messages: [{ role: "user", content: textContent }], stream: true, temperature: 0.7 }; // 使用Fetch API处理流式响应 const response = await fetch('xxx', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer x' }, body: JSON.stringify(payload), signal: abortController.signal }); if (!response.ok) { throw new Error(`API错误: ${response.status} ${response.statusText}`); } // 创建流式读取器 const reader = response.body!.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let fullContent = ''; // 读取流数据 while (true) { const { value, done } = await reader.read(); if (done) break; // 解码数据块 const chunk = decoder.decode(value, { stream: true }); buffer += chunk; // 处理数据块中的每一行 let lineEndIndex; while ((lineEndIndex = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, lineEndIndex).trim(); buffer = buffer.slice(lineEndIndex + 1); if (line.startsWith('data: ')) { const dataStr = line.slice(6); if (dataStr === '[DONE]') { // 流结束 break; } try { const data = JSON.parse(dataStr); // 处理增量内容 if (data.choices && data.choices[0].delta.content || data.choices[0].delta.reasoning_content) { const newContent = data.choices[0].delta.content || data.choices[0].delta.reasoning_content; fullContent += newContent; // 更新消息内容 chatStore.updateTargetSession(session, (session) => { const msg = session.messages.find(m => m.id === newBotMessage.id); if (msg) { msg.content = fullContent; } }); // 使用最新的autoScroll状态值进行判断 if (latestAutoScrollRef?.current) { scrollDomToBottom(); } } } catch (e) { console.error('解析JSON失败:', e); } } } } // 完成流式传输 chatStore.updateTargetSession(session, (session) => { const msg = session.messages.find(m => m.id === newBotMessage.id); if (msg) { msg.streaming = false; } }); } catch (error) { // console.error('API调用失败:', error); // showToast(`请求失败: ${error instanceof Error ? error.message : String(error)}`); // // 更新消息为错误状态 // chatStore.updateTargetSession(session, (session) => { // const msg = session.messages.find(m => m.id === newBotMessage.id); // if (msg) { // msg.isError = true; // msg.content = `请求失败: ${error instanceof Error ? error.message : String(error)}`; // msg.streaming = false; // } // }); } finally { setIsLoading(false); setIsGenerating(false); abortControllerRef.current = null; } }; const onPinMessage = (message: ChatMessage) => { chatStore.updateTargetSession(session, (session) => session.mask.context.push(message), ); showToast(Locale.Chat.Actions.PinToastContent, { text: Locale.Chat.Actions.PinToastAction, onClick: () => { setShowPromptModal(true); }, }); }; const accessStore = useAccessStore(); const [speechStatus, setSpeechStatus] = useState(false); const [speechLoading, setSpeechLoading] = useState(false); async function openaiSpeech(text: string) { if (speechStatus) { ttsPlayer.stop(); setSpeechStatus(false); } else { var api: ClientApi; api = new ClientApi(ModelProvider.GPT); const config = useAppConfig.getState(); setSpeechLoading(true); ttsPlayer.init(); let audioBuffer: ArrayBuffer; const { markdownToTxt } = require("markdown-to-txt"); const textContent = markdownToTxt(text); if (config.ttsConfig.engine !== DEFAULT_TTS_ENGINE) { const edgeVoiceName = accessStore.edgeVoiceName(); const tts = new MsEdgeTTS(); await tts.setMetadata( edgeVoiceName, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, ); audioBuffer = await tts.toArrayBuffer(textContent); } else { audioBuffer = await api.llm.speech({ model: config.ttsConfig.model, input: textContent, voice: config.ttsConfig.voice, speed: config.ttsConfig.speed, }); } setSpeechStatus(true); ttsPlayer .play(audioBuffer, () => { setSpeechStatus(false); }) .catch((e) => { console.error("[OpenAI Speech]", e); showToast(prettyObject(e)); setSpeechStatus(false); }) .finally(() => setSpeechLoading(false)); } } const context: RenderMessage[] = useMemo(() => { return session.mask.hideContext ? [] : session.mask.context.slice(); }, [session.mask.context, session.mask.hideContext]); 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( 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); } // 快捷键 shortcut keys const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // 打开新聊天 command + shift + o if ( (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "o" ) { event.preventDefault(); setTimeout(() => { chatStore.newSession(); navigate(Path.Chat); }, 10); } // 聚焦聊天输入 shift + esc else if (event.shiftKey && event.key.toLowerCase() === "escape") { event.preventDefault(); inputRef.current?.focus(); } // 复制最后一个代码块 command + shift + ; else if ( (event.metaKey || event.ctrlKey) && event.shiftKey && event.code === "Semicolon" ) { event.preventDefault(); const copyCodeButton = document.querySelectorAll(".copy-code-button"); if (copyCodeButton.length > 0) { copyCodeButton[copyCodeButton.length - 1].click(); } } // 复制最后一个回复 command + shift + c else if ( (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "c" ) { event.preventDefault(); const lastNonUserMessage = messages .filter((message) => message.role !== "user") .pop(); if (lastNonUserMessage) { const lastMessageContent = getMessageTextContent(lastNonUserMessage); copyToClipboard(lastMessageContent); } } // 展示快捷键 command + / else if ((event.metaKey || event.ctrlKey) && event.key === "/") { event.preventDefault(); setShowShortcutKeyModal(true); } // 清除上下文 command + shift + backspace else if ( (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "backspace" ) { event.preventDefault(); chatStore.updateTargetSession(session, (session) => { if (session.clearContextIndex === session.messages.length) { session.clearContextIndex = undefined; } else { session.clearContextIndex = session.messages.length; session.memoryPrompt = ""; // will clear memory } }); } }; document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [messages, chatStore, navigate, session]); const [showChatSidePanel, setShowChatSidePanel] = useState(false); return ( <>
{isMobileScreen && (
} bordered title={Locale.Chat.Actions.ChatList} onClick={() => navigate(Path.Home)} />
)}
setIsEditingMessage(true)} > {!session.topic ? DEFAULT_TOPIC : session.topic}
{Locale.Chat.SubTitle(session.messages.length)}
} bordered title={Locale.Chat.Actions.RefreshTitle} onClick={() => { showToast(Locale.Chat.Actions.RefreshToast); chatStore.summarizeSession(true, session); }} />
{!isMobileScreen && (
} bordered title={Locale.Chat.EditMessage.Title} aria={Locale.Chat.EditMessage.Title} onClick={() => setIsEditingMessage(true)} />
)}
} bordered title={Locale.Chat.Actions.Export} onClick={() => { setShowExport(true); }} />
{showMaxIcon && (
: } bordered title={Locale.Chat.Actions.FullScreen} aria={Locale.Chat.Actions.FullScreen} onClick={() => { config.update( (config) => (config.tightBorder = !config.tightBorder), ); }} />
)}
onChatBodyScroll(e.currentTarget)} onMouseDown={() => inputRef.current?.blur()} onTouchStart={() => { inputRef.current?.blur(); setAutoScroll(false); }} > {messages // TODO // .filter((m) => !m.isMcpResponse) .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 (
} aria={Locale.Chat.Actions.Edit} onClick={async () => { const newMessage = await showPrompt( Locale.Chat.Actions.Edit, getMessageTextContent(message), 10, ); let newContent: | string | MultimodalContent[] = newMessage; const images = getMessageImages(message); if (images.length > 0) { newContent = [ { type: "text", text: newMessage }, ]; for (let i = 0; i < images.length; i++) { newContent.push({ type: "image_url", image_url: { url: images[i], }, }); } } chatStore.updateTargetSession( session, (session) => { const m = session.mask.context .concat(session.messages) .find((m) => m.id === message.id); if (m) { m.content = newContent; } }, ); }} >
{isUser ? ( ) : ( <> {["system"].includes(message.role) ? ( ) : ( )} )}
{!isUser && (
{message.model}
)} {showActions && (
{message.streaming ? ( } onClick={() => onUserStop(message.id ?? i) } /> ) : ( <> } onClick={() => onResend(message)} /> } onClick={() => onDelete(message.id ?? i) } /> } onClick={() => onPinMessage(message)} /> } onClick={() => copyToClipboard( getMessageTextContent(message), ) } /> {config.ttsConfig.enable && ( ) : ( ) } onClick={() => openaiSpeech( getMessageTextContent(message), ) } /> )} )}
)}
{message?.tools?.length == 0 && showTyping && (
{Locale.Chat.Typing}
)} {/*@ts-ignore*/} {message?.tools?.length > 0 && (
{message?.tools?.map((tool) => (
{tool.isError === false ? ( ) : tool.isError === true ? ( ) : ( )} {tool?.function?.name}
))}
)}
onRightClick(e, message)} // hard to use onDoubleClickCapture={() => { 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 ( ); }, )}
)}
{message?.audio_url && (
)}
{isContext ? Locale.Chat.IsContext : message.date.toLocaleString()}
{shouldShowClearContextDivider && }
); })}
setShowPromptModal(true)} scrollToBottom={scrollToBottom} hitBottom={hitBottom} uploading={uploading} showPromptHints={() => { // Click again to close if (promptHints.length > 0) { setPromptHints([]); return; } inputRef.current?.focus(); setUserInput("/"); onSearch(""); }} setShowShortcutKeyModal={setShowShortcutKeyModal} setUserInput={setUserInput} setShowChatSidePanel={setShowChatSidePanel} />