diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 95f0f1b5d0..226598dc57 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -19,6 +19,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMinappsOpenLinkExternal } from '@renderer/store/settings' import { MinAppType } from '@renderer/types' @@ -170,6 +171,8 @@ const MinappPopupContainer: React.FC = () => { const isInDevelopment = process.env.NODE_ENV === 'development' + const { setTimeoutTimer } = useTimer() + useBridge() /** set the popup display status */ @@ -295,7 +298,7 @@ const MinappPopupContainer: React.FC = () => { window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) } if (appid == currentMinappId) { - setTimeout(() => setIsReady(true), 200) + setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200) } } diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index b3ea93662a..eecad5ec9c 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -1,6 +1,7 @@ import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import { useSystemAgents } from '@renderer/pages/agents' import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -33,6 +34,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const loadingRef = useRef(false) const [selectedIndex, setSelectedIndex] = useState(0) const containerRef = useRef(null) + const { setTimeoutTimer } = useTimer() const agents = useMemo(() => { const allAgents = [...userAgents, ...systemAgents] as Agent[] @@ -80,11 +82,11 @@ const PopupContainer: React.FC = ({ resolve }) => { assistant = await createAssistantFromAgent(agent) } - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) + setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) resolve(assistant) setOpen(false) }, - [resolve, addAssistant, setOpen] + [setTimeoutTimer, resolve, addAssistant] ) // 添加函数内使用的依赖项 // 键盘导航处理 useEffect(() => { diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 7119f75f36..74cbc763c1 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -68,6 +68,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { // 无匹配项自动关闭的定时器 const noMatchTimeoutRef = useRef(null) + const clearSearchTimerRef = useRef(undefined) + const focusTimerRef = useRef(undefined) // 处理搜索,过滤列表 const list = useMemo(() => { @@ -145,6 +147,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (noMatchTimeoutRef.current) { clearTimeout(noMatchTimeoutRef.current) noMatchTimeoutRef.current = null + clearTimeout(clearSearchTimerRef.current) + clearTimeout(focusTimerRef.current) } // 面板不可见时不设置新定时器 @@ -165,6 +169,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { clearTimeout(noMatchTimeoutRef.current) noMatchTimeoutRef.current = null } + clearTimeout(clearSearchTimerRef.current) + clearTimeout(focusTimerRef.current) } // eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染 }, [ctx.isVisible, searchText, list.length, ctx.close]) @@ -192,7 +198,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { newText = inputText.slice(0, start) + inputText.slice(end) setInputText(newText) - setTimeout(() => { + clearTimeout(focusTimerRef.current) + focusTimerRef.current = setTimeout(() => { textArea.focus() textArea.setSelectionRange(start, start) }, 0) @@ -333,7 +340,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { textArea.removeEventListener('input', handleInput) textArea.removeEventListener('compositionupdate', handleCompositionUpdate) textArea.removeEventListener('compositionend', handleCompositionEnd) - setTimeout(() => { + clearTimeout(clearSearchTimerRef.current) + clearSearchTimerRef.current = setTimeout(() => { setSearchText('') }, 200) // 等待面板关闭动画结束后,再清空搜索词 } diff --git a/src/renderer/src/hooks/useInPlaceEdit.ts b/src/renderer/src/hooks/useInPlaceEdit.ts index 077b6d21cb..537d89fc12 100644 --- a/src/renderer/src/hooks/useInPlaceEdit.ts +++ b/src/renderer/src/hooks/useInPlaceEdit.ts @@ -26,13 +26,22 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe const [originalValue, setOriginalValue] = useState('') const inputRef = useRef(null) + const editTimerRef = useRef(undefined) + + useEffect(() => { + return () => { + clearTimeout(editTimerRef.current) + } + }, []) + const startEdit = useCallback( (initialValue: string) => { setIsEditing(true) setEditValue(initialValue) setOriginalValue(initialValue) - setTimeout(() => { + clearTimeout(editTimerRef.current) + editTimerRef.current = setTimeout(() => { inputRef.current?.focus() if (autoSelectOnStart) { inputRef.current?.select() diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index a80db2659e..c1db47e502 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -20,7 +20,7 @@ import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@r import { runAsyncFunction } from '@renderer/utils' import dayjs from 'dayjs' import { cloneDeep } from 'lodash' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useAgents } from './useAgents' @@ -29,6 +29,7 @@ import { useAssistants } from './useAssistant' export const useKnowledge = (baseId: string) => { const dispatch = useAppDispatch() const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId)) + const checkTimerRef = useRef(undefined) // 重命名知识库 const renameKnowledgeBase = (name: string) => { @@ -40,34 +41,46 @@ export const useKnowledge = (baseId: string) => { dispatch(updateBase(base)) } + useEffect(() => { + return () => { + clearTimeout(checkTimerRef.current) + } + }, []) + + // 检查知识库 + const checkAllBases = () => { + clearTimeout(checkTimerRef.current) + checkTimerRef.current = setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + // 批量添加文件 const addFiles = (files: FileMetadata[]) => { dispatch(addFilesThunk(baseId, files)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 添加笔记 const addNote = async (content: string) => { await dispatch(addNoteThunk(baseId, content)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 添加URL const addUrl = (url: string) => { dispatch(addItemThunk(baseId, 'url', url)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 添加 Sitemap const addSitemap = (url: string) => { dispatch(addItemThunk(baseId, 'sitemap', url)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // Add directory support const addDirectory = (path: string) => { dispatch(addItemThunk(baseId, 'directory', path)) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } // 更新笔记内容 const updateNoteContent = async (noteId: string, content: string) => { @@ -133,7 +146,7 @@ export const useKnowledge = (baseId: string) => { uniqueId: undefined, updated_at: Date.now() }) - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } } @@ -229,7 +242,7 @@ export const useKnowledge = (baseId: string) => { throw new Error(`Failed to migrate files ${files}: ${error}`) } - setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + checkAllBases() } const fileItems = base?.items.filter((item) => item.type === 'file') || [] diff --git a/src/renderer/src/hooks/useScrollPosition.ts b/src/renderer/src/hooks/useScrollPosition.ts index b3f4b5d512..872004d58e 100644 --- a/src/renderer/src/hooks/useScrollPosition.ts +++ b/src/renderer/src/hooks/useScrollPosition.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react' export default function useScrollPosition(key: string) { const containerRef = useRef(null) const scrollKey = `scroll:${key}` + const scrollTimerRef = useRef(undefined) const handleScroll = throttle(() => { const position = containerRef.current?.scrollTop ?? 0 @@ -15,8 +16,15 @@ export default function useScrollPosition(key: string) { useEffect(() => { const scroll = () => containerRef.current?.scrollTo({ top: window.keyv.get(scrollKey) || 0 }) scroll() - setTimeout(scroll, 50) + clearTimeout(scrollTimerRef.current) + scrollTimerRef.current = setTimeout(scroll, 50) }, [scrollKey]) + useEffect(() => { + return () => { + clearTimeout(scrollTimerRef.current) + } + }, []) + return { containerRef, handleScroll } } diff --git a/src/renderer/src/hooks/useTimer.ts b/src/renderer/src/hooks/useTimer.ts new file mode 100644 index 0000000000..139c3e7143 --- /dev/null +++ b/src/renderer/src/hooks/useTimer.ts @@ -0,0 +1,152 @@ +import { useEffect, useRef } from 'react' + +/** + * 定时器管理 Hook,用于管理 setTimeout 和 setInterval 定时器,支持通过 key 来标识不同的定时器 + * + * - 在设置定时器时以前会自动清理相同key的定时器 + * - 组件卸载时会自动清理所有定时器,避免内存泄漏 + * + * 通常在 `useEffect` 中使用的定时器,可以通过清理函数处理。但是,在函数中使用的定时器则相对难以管理。 + * 这个 Hook 主要解决需要在函数中设置定时器的场景。然而,`setTimeoutTimer` 和 `setIntervalTimer` 同样也返回清理函数,因此可以用于 `useEffect` 中。 + * + * @example + * ```ts + * function MyComponent() { + * const { + * setTimeoutTimer, + * setIntervalTimer, + * clearTimeoutTimer, + * clearAllTimers + * } = useTimer(); + * + * useEffect(() => { + * // 设置一个3秒后执行的定时器 + * setTimeoutTimer('notify', () => { + * console.log('3秒后执行'); + * }, 3000); + * + * // 设置一个每5秒执行一次的定时器 + * const cleanup = setIntervalTimer('poll', () => { + * console.log('每5秒执行一次'); + * }, 5000); + * + * // 手动清理指定的定时器 + * clearTimeoutTimer('notify'); + * + * // 返回清理函数来停止轮询 + * return cleanup; + * }, []); + * } + * ``` + */ +export const useTimer = () => { + const timeoutMapRef = useRef(new Map()) + const intervalMapRef = useRef(new Map()) + + // 组件卸载时自动清理所有定时器 + useEffect(() => { + return clearAllTimers + }, []) + + /** + * 设置一个 setTimeout 定时器 + * @param key - 定时器标识符,用于标识和管理不同的定时器实例 + * @param args - setTimeout 的参数列表,包含回调函数和延迟时间(毫秒) + * @returns 返回一个清理函数,可以用来手动清除该定时器 + * @example + * ```ts + * const { setTimeoutTimer } = useTimer(); + * // 设置一个3秒后执行的定时器 + * const cleanup = setTimeoutTimer('myTimer', () => { + * console.log('Timer executed'); + * }, 3000); + * + * // 需要时可以提前清理定时器 + * cleanup(); + * ``` + */ + const setTimeoutTimer = (key: string, ...args: Parameters) => { + clearTimeout(timeoutMapRef.current.get(key)) + const timer = setTimeout(...args) + timeoutMapRef.current.set(key, timer) + return () => clearTimeoutTimer(key) + } + + /** + * 设置一个 setInterval 定时器 + * @param key - 定时器标识符,用于标识和管理不同的定时器实例 + * @param args - setInterval 的参数列表,包含回调函数和时间间隔(毫秒) + * @returns 返回一个清理函数,可以用来手动清除该定时器 + * @example + * ```ts + * const { setIntervalTimer } = useTimer(); + * // 设置一个每3秒执行一次的定时器 + * const cleanup = setIntervalTimer('myTimer', () => { + * console.log('Timer executed'); + * }, 3000); + * + * // 需要时可以停止定时器 + * cleanup(); + * ``` + */ + const setIntervalTimer = (key: string, ...args: Parameters) => { + clearInterval(intervalMapRef.current.get(key)) + const timer = setInterval(...args) + intervalMapRef.current.set(key, timer) + return () => clearIntervalTimer(key) + } + + /** + * 清除指定 key 的 setTimeout 定时器 + * @param key - 定时器标识符 + */ + const clearTimeoutTimer = (key: string) => { + clearTimeout(timeoutMapRef.current.get(key)) + timeoutMapRef.current.delete(key) + } + + /** + * 清除指定 key 的 setInterval 定时器 + * @param key - 定时器标识符 + */ + const clearIntervalTimer = (key: string) => { + clearInterval(intervalMapRef.current.get(key)) + intervalMapRef.current.delete(key) + } + + /** + * 清除所有 setTimeout 定时器 + */ + const clearAllTimeoutTimers = () => { + timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) + timeoutMapRef.current.clear() + } + + /** + * 清除所有 setInterval 定时器 + */ + const clearAllIntervalTimers = () => { + intervalMapRef.current.forEach((timer) => clearInterval(timer)) + intervalMapRef.current.clear() + } + + /** + * 清除所有定时器,包括 setTimeout 和 setInterval + */ + const clearAllTimers = () => { + timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) + intervalMapRef.current.forEach((timer) => clearInterval(timer)) + timeoutMapRef.current.clear() + intervalMapRef.current.clear() + } + + return { + setTimeoutTimer, + setIntervalTimer, + clearTimeoutTimer, + clearIntervalTimer, + clearAllTimeoutTimers, + clearAllIntervalTimers, + clearAllTimers + } as const +} diff --git a/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx b/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx index a940db5772..f638545acc 100644 --- a/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx +++ b/src/renderer/src/pages/agents/components/ImportAgentPopup.tsx @@ -1,5 +1,6 @@ import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' +import { useTimer } from '@renderer/hooks/useTimer' import { getDefaultModel } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Agent } from '@renderer/types' @@ -19,6 +20,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const { addAgent } = useAgents() const [importType, setImportType] = useState<'url' | 'file'>('url') const [loading, setLoading] = useState(false) + const { setTimeoutTimer } = useTimer() const onFinish = async (values: { url?: string }) => { setLoading(true) @@ -77,7 +79,7 @@ const PopupContainer: React.FC = ({ resolve }) => { key: 'agents-imported' }) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) + setTimeoutTimer('onFinish', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) setOpen(false) resolve(agents) } catch (error) { diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index bb4457438d..946f8be1ab 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -4,6 +4,7 @@ import ModelSelector from '@renderer/components/ModelSelector' import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' import { useCodeTools } from '@renderer/hooks/useCodeTools' import { useProviders } from '@renderer/hooks/useProvider' +import { useTimer } from '@renderer/hooks/useTimer' import { getProviderByModel } from '@renderer/services/AssistantService' import { loggerService } from '@renderer/services/LoggerService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -48,6 +49,7 @@ const CodeToolsPage: FC = () => { removeDir, selectFolder } = useCodeTools() + const { setTimeoutTimer } = useTimer() // 状态管理 const [isLaunching, setIsLaunching] = useState(false) @@ -159,7 +161,7 @@ const CodeToolsPage: FC = () => { } finally { setIsInstallingBun(false) // 重新检查安装状态 - setTimeout(checkBunInstallation, 1000) + setTimeoutTimer('handleInstallBun', checkBunInstallation, 1000) } } diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 2fd299a388..c33d8e56e0 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -1,5 +1,6 @@ import db from '@renderer/databases' import useScrollPosition from '@renderer/hooks/useScrollPosition' +import { useTimer } from '@renderer/hooks/useTimer' import { getTopicById } from '@renderer/hooks/useTopic' import { Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' @@ -18,6 +19,7 @@ interface Props extends React.HTMLAttributes { const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('SearchResults') + const { setTimeoutTimer } = useTimer() const [searchTerms, setSearchTerms] = useState( keywords @@ -112,7 +114,7 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p pagination={{ pageSize: 10, onChange: () => { - setTimeout(() => containerRef.current?.scrollTo({ top: 0 }), 0) + setTimeoutTimer('scroll', () => containerRef.current?.scrollTo({ top: 0 }), 0) } }} renderItem={({ message, topic, content }) => ( diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 8c98c458df..917a110b5d 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -4,6 +4,7 @@ import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' @@ -29,6 +30,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('TopicMessages') const dispatch = useAppDispatch() const { messageStyle } = useSettings() + const { setTimeoutTimer } = useTimer() useEffect(() => { topic && dispatch(loadTopicMessagesThunk(topic.id)) @@ -45,7 +47,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { SearchPopup.hide() const assistant = getAssistantById(topic.assistantId) navigate('/', { state: { assistant, topic } }) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) + setTimeoutTimer('onContinueChat', () => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) } return ( diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 93ff02fb48..1618d5e633 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -8,6 +8,7 @@ import { useChatContext } from '@renderer/hooks/useChatContext' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant, Topic } from '@renderer/types' import { classNames } from '@renderer/utils' import { Flex } from 'antd' @@ -43,6 +44,7 @@ const Chat: FC = (props) => { const [filterIncludeUser, setFilterIncludeUser] = useState(false) const maxWidth = useChatMaxWidth() + const { setTimeoutTimer } = useTimer() useHotkeys('esc', () => { contentSearchRef.current?.disable() @@ -79,10 +81,14 @@ const Chat: FC = (props) => { setFilterIncludeUser(!filterIncludeUser) requestAnimationFrame(() => { requestAnimationFrame(() => { - setTimeout(() => { - contentSearchRef.current?.search() - contentSearchRef.current?.focus() - }, 0) + setTimeoutTimer( + 'userOutlinedItemClickHandler', + () => { + contentSearchRef.current?.search() + contentSearchRef.current?.focus() + }, + 0 + ) }) }) } @@ -99,7 +105,7 @@ const Chat: FC = (props) => { } const messagesComponentFirstUpdateHandler = () => { - setTimeout(() => (firstUpdateCompleted = true), 300) + setTimeoutTimer('messagesComponentFirstUpdateHandler', () => (firstUpdateCompleted = true), 300) firstUpdateOrNoFirstUpdateHandler() } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index df690c03d0..82a157adba 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -20,6 +20,7 @@ import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' +import { useTimer } from '@renderer/hooks/useTimer' import useTranslate from '@renderer/hooks/useTranslate' import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -114,6 +115,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) const isVisionAssistant = useMemo(() => isVisionModel(model), [model]) const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model]) + const { setTimeoutTimer } = useTimer() const isVisionSupported = useMemo( () => @@ -254,14 +256,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = // Clear input setText('') setFiles([]) - setTimeout(() => setText(''), 500) - setTimeout(() => resizeTextArea(true), 0) + setTimeoutTimer('sendMessage_1', () => setText(''), 500) + setTimeoutTimer('sendMessage_2', () => resizeTextArea(true), 0) setExpand(false) } catch (error) { logger.warn('Failed to send message:', error as Error) parent?.recordException(error as Error) } - }, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, text, topic]) + }, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, setTimeoutTimer, text, topic]) const translate = useCallback(async () => { if (isTranslating) { @@ -272,13 +274,13 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setIsTranslating(true) const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage)) translatedText && setText(translatedText) - setTimeout(() => resizeTextArea(), 0) + setTimeoutTimer('translate', () => resizeTextArea(), 0) } catch (error) { logger.warn('Translation failed:', error as Error) } finally { setIsTranslating(false) } - }, [isTranslating, text, getLanguageByLangcode, targetLanguage, resizeTextArea]) + }, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea]) const openKnowledgeFileList = useCallback( (base: KnowledgeBase) => { @@ -428,10 +430,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setText(newText) // set cursor position in the next render cycle - setTimeout(() => { - textArea.selectionStart = textArea.selectionEnd = start + 1 - onInput() // trigger resizeTextArea - }, 0) + setTimeoutTimer( + 'handleKeyDown', + () => { + textArea.selectionStart = textArea.selectionEnd = start + 1 + onInput() // trigger resizeTextArea + }, + 0 + ) } } } @@ -457,20 +463,20 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = addTopic(topic) setActiveTopic(topic) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0) - }, [addTopic, assistant, setActiveTopic, setModel]) + setTimeoutTimer('addNewTopic', () => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0) + }, [addTopic, assistant.defaultModel, assistant.id, setActiveTopic, setModel, setTimeoutTimer]) const onQuote = useCallback( (text: string) => { const quotedText = formatQuotedText(text) setText((prevText) => { const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n` - setTimeout(() => resizeTextArea(), 0) + setTimeoutTimer('onQuote', () => resizeTextArea(), 0) return newText }) focusTextarea() }, - [resizeTextArea, focusTextarea] + [focusTextarea, setTimeoutTimer, resizeTextArea] ) const onPause = async () => { @@ -608,7 +614,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const onTranslated = (translatedText: string) => { setText(translatedText) - setTimeout(() => resizeTextArea(), 0) + setTimeoutTimer('onTranslated', () => resizeTextArea(), 0) } const handleDragStart = (e: React.MouseEvent) => { diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index b76525d429..b4589fbc24 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -1,6 +1,7 @@ import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMCPServers } from '@renderer/hooks/useMCPServers' +import { useTimer } from '@renderer/hooks/useTimer' import { EventEmitter } from '@renderer/services/EventService' import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types' import { Form, Input, Tooltip } from 'antd' @@ -116,6 +117,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar const [form] = Form.useForm() const { updateAssistant, assistant } = useAssistant(props.assistant.id) + const { setTimeoutTimer } = useTimer() // 使用 useRef 存储不需要触发重渲染的值 const isMountedRef = useRef(true) @@ -154,14 +156,18 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar const updateMcpEnabled = useCallback( (enabled: boolean) => { - setTimeout(() => { - updateAssistant({ - ...assistant, - mcpServers: enabled ? assistant.mcpServers || [] : [] - }) - }, 200) + setTimeoutTimer( + 'updateMcpEnabled', + () => { + updateAssistant({ + ...assistant, + mcpServers: enabled ? assistant.mcpServers || [] : [] + }) + }, + 200 + ) }, - [assistant, updateAssistant] + [assistant, setTimeoutTimer, updateAssistant] ) const menuItems = useMemo(() => { diff --git a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx index 0354032fa1..a6d0de67c6 100644 --- a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx @@ -1,6 +1,7 @@ import { useQuickPanel } from '@renderer/components/QuickPanel' import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/QuickPanel/types' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import QuickPhraseService from '@renderer/services/QuickPhraseService' import { useAppSelector } from '@renderer/store' import { QuickPhrase } from '@renderer/types' @@ -34,6 +35,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, state.assistants.assistants.find((a) => a.id === assistantObj.id)?.id || state.assistants.defaultAssistant.id ) const { assistant, updateAssistant } = useAssistant(activeAssistantId) + const { setTimeoutTimer } = useTimer() const loadQuickListPhrases = useCallback( async (regularPhrases: QuickPhrase[] = []) => { @@ -54,24 +56,32 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, const handlePhraseSelect = useCallback( (phrase: QuickPhrase) => { - setTimeout(() => { - setInputValue((prev) => { - const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement - const cursorPosition = textArea.selectionStart - const selectionStart = cursorPosition - const selectionEndPosition = cursorPosition + phrase.content.length - const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition) + setTimeoutTimer( + 'handlePhraseSelect_1', + () => { + setInputValue((prev) => { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + const cursorPosition = textArea.selectionStart + const selectionStart = cursorPosition + const selectionEndPosition = cursorPosition + phrase.content.length + const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition) - setTimeout(() => { - textArea.focus() - textArea.setSelectionRange(selectionStart, selectionEndPosition) - resizeTextArea() - }, 10) - return newText - }) - }, 10) + setTimeoutTimer( + 'handlePhraseSelect_2', + () => { + textArea.focus() + textArea.setSelectionRange(selectionStart, selectionEndPosition) + resizeTextArea() + }, + 10 + ) + return newText + }) + }, + 10 + ) }, - [setInputValue, resizeTextArea] + [setTimeoutTimer, setInputValue, resizeTextArea] ) const handleModalOk = async () => { diff --git a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx index 11cfde3f72..cfbf12f538 100644 --- a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx +++ b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx @@ -1,4 +1,5 @@ import { useAssistant } from '@renderer/hooks/useAssistant' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant } from '@renderer/types' import { Tooltip } from 'antd' import { Link } from 'lucide-react' @@ -18,14 +19,19 @@ interface Props { const UrlContextButton: FC = ({ assistant, ToolbarButton }) => { const { t } = useTranslation() const { updateAssistant } = useAssistant(assistant.id) + const { setTimeoutTimer } = useTimer() const urlContentNewState = !assistant.enableUrlContext const handleToggle = useCallback(() => { - setTimeout(() => { - updateAssistant({ ...assistant, enableUrlContext: urlContentNewState }) - }, 100) - }, [assistant, urlContentNewState, updateAssistant]) + setTimeoutTimer( + 'handleToggle', + () => { + updateAssistant({ ...assistant, enableUrlContext: urlContentNewState }) + }, + 100 + ) + }, [setTimeoutTimer, updateAssistant, assistant, urlContentNewState]) return ( diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 86d6525e15..1ed33e7475 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -1,3 +1,4 @@ +import { useTimer } from '@renderer/hooks/useTimer' import { getHttpMessageLabel } from '@renderer/i18n/label' import { useAppDispatch } from '@renderer/store' import { removeBlocksThunk } from '@renderer/store/thunk/messageThunk' @@ -19,11 +20,12 @@ const ErrorBlock: React.FC = ({ block, message }) => { const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }> = ({ block, message }) => { const { t, i18n } = useTranslation() const dispatch = useAppDispatch() + const { setTimeoutTimer } = useTimer() const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504] const onRemoveBlock = () => { - setTimeout(() => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350) + setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350) } if (block.error && HTTP_ERROR_CODES.includes(block.error?.status)) { diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 461f42c185..96f439fa2e 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -2,6 +2,7 @@ import { CheckOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import ThinkingEffect from '@renderer/components/ThinkingEffect' import { useSettings } from '@renderer/hooks/useSettings' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { Collapse, message as antdMessage, Tooltip } from 'antd' import { memo, useCallback, useEffect, useMemo, useState } from 'react' @@ -16,7 +17,7 @@ interface Props { } const ThinkingBlock: React.FC = ({ block }) => { - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const { t } = useTranslation() const { messageFont, fontSize, thoughtAutoCollapse } = useSettings() const [activeKey, setActiveKey] = useState<'thought' | ''>(thoughtAutoCollapse ? '' : 'thought') @@ -38,14 +39,13 @@ const ThinkingBlock: React.FC = ({ block }) => { .then(() => { antdMessage.success({ content: t('message.copied'), key: 'copy-message' }) setCopied(true) - setTimeout(() => setCopied(false), 2000) }) .catch((error) => { logger.error('Failed to copy text:', error) antdMessage.error({ content: t('message.copy.failed'), key: 'copy-message-error' }) }) } - }, [block.content, t]) + }, [block.content, setCopied, t]) if (!block.content) { return null diff --git a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx index 4197b0a957..9cd1db2163 100644 --- a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx +++ b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx @@ -7,6 +7,7 @@ import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { RootState } from '@renderer/store' import { selectMessagesForTopic } from '@renderer/store/newMessage' @@ -50,6 +51,8 @@ const TooltipFooter = styled.div` // 自定义节点组件 const CustomNode: FC<{ data: any }> = ({ data }) => { const { t } = useTranslation() + const { setTimeoutTimer } = useTimer() + const nodeType = data.type let borderColor = 'var(--color-border)' let title = '' @@ -114,9 +117,13 @@ const CustomNode: FC<{ data: any }> = ({ data }) => { // 让监听器处理标签切换 document.dispatchEvent(customEvent) - setTimeout(() => { - EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + data.messageId) - }, 250) + setTimeoutTimer( + 'handleNodeClick', + () => { + EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + data.messageId) + }, + 250 + ) } } diff --git a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx index 17b98cb0b2..7694942b4d 100644 --- a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx +++ b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx @@ -39,7 +39,7 @@ const ChatNavigation: FC = ({ containerId }) => { const { t } = useTranslation() const [isVisible, setIsVisible] = useState(false) const [isNearButtons, setIsNearButtons] = useState(false) - const [hideTimer, setHideTimer] = useState(null) + const hideTimerRef = useRef(undefined) const [showChatHistory, setShowChatHistory] = useState(false) const [manuallyClosedUntil, setManuallyClosedUntil] = useState(null) const currentTopicId = useSelector((state: RootState) => state.messages.currentTopicId) @@ -49,20 +49,16 @@ const ChatNavigation: FC = ({ containerId }) => { // Reset hide timer and make buttons visible const resetHideTimer = useCallback(() => { - if (hideTimer) { - clearTimeout(hideTimer) - } - setIsVisible(true) // Only set a hide timer if cursor is not near the buttons if (!isNearButtons) { - const timer = setTimeout(() => { + clearTimeout(hideTimerRef.current) + hideTimerRef.current = setTimeout(() => { setIsVisible(false) }, 1500) - setHideTimer(timer) } - }, [hideTimer, isNearButtons]) + }, [isNearButtons]) // Handle mouse entering button area const handleMouseEnter = useCallback(() => { @@ -74,21 +70,21 @@ const ChatNavigation: FC = ({ containerId }) => { setIsVisible(true) // Clear any existing hide timer - if (hideTimer) { - clearTimeout(hideTimer) - setHideTimer(null) - } - }, [hideTimer, manuallyClosedUntil]) + clearTimeout(hideTimerRef.current) + }, [manuallyClosedUntil]) // Handle mouse leaving button area const handleMouseLeave = useCallback(() => { setIsNearButtons(false) // Set a timer to hide the buttons - const timer = setTimeout(() => { + hideTimerRef.current = setTimeout(() => { setIsVisible(false) }, 500) - setHideTimer(timer) + + return () => { + clearTimeout(hideTimerRef.current) + } }, []) const handleChatHistoryClick = () => { @@ -322,13 +318,10 @@ const ChatNavigation: FC = ({ containerId }) => { } else { window.removeEventListener('mousemove', handleMouseMove) } - if (hideTimer) { - clearTimeout(hideTimer) - } + clearTimeout(hideTimerRef.current) } }, [ containerId, - hideTimer, resetHideTimer, isNearButtons, handleMouseEnter, diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index c874f1bb20..da595eebce 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -6,6 +6,7 @@ import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useModel } from '@renderer/hooks/useModel' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -71,6 +72,7 @@ const MessageItem: FC = ({ const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() + const { setTimeoutTimer } = useTimer() const isEditing = editingMessageId === message.id useEffect(() => { @@ -119,18 +121,25 @@ const MessageItem: FC = ({ const isAssistantMessage = message.role === 'assistant' const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing - const messageHighlightHandler = useCallback((highlight: boolean = true) => { - if (messageContainerRef.current) { - messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) - if (highlight) { - setTimeout(() => { - const classList = messageContainerRef.current?.classList - classList?.add('message-highlight') - setTimeout(() => classList?.remove('message-highlight'), 2500) - }, 500) + const messageHighlightHandler = useCallback( + (highlight: boolean = true) => { + if (messageContainerRef.current) { + messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) + if (highlight) { + setTimeoutTimer( + 'messageHighlightHandler_1', + () => { + const classList = messageContainerRef.current?.classList + classList?.add('message-highlight') + setTimeoutTimer('messageHighlightHandler_2', () => classList?.remove('message-highlight'), 2500) + }, + 500 + ) + } } - } - }, []) + }, + [setTimeoutTimer] + ) useEffect(() => { const unsubscribes = [EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, messageHighlightHandler)] diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index 7b116f8495..f4ac909b91 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -4,6 +4,7 @@ import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelName } from '@renderer/services/ModelService' import { useAppDispatch } from '@renderer/store' @@ -32,13 +33,14 @@ const MessageAnchorLine: FC = ({ messages }) => { const avatar = useAvatar() const { theme } = useTheme() const dispatch = useAppDispatch() - const { userName } = useSettings() + const { setTimeoutTimer } = useTimer() + const messagesListRef = useRef(null) const messageItemsRef = useRef>(new Map()) const containerRef = useRef(null) - const [mouseY, setMouseY] = useState(null) + const [mouseY, setMouseY] = useState(null) const [listOffsetY, setListOffsetY] = useState(0) const [containerHeight, setContainerHeight] = useState(null) @@ -112,15 +114,19 @@ const MessageAnchorLine: FC = ({ messages }) => { ) } - setTimeout(() => { - const messageElement = document.getElementById(`message-${message.id}`) - if (messageElement) { - messageElement.scrollIntoView({ behavior: 'auto', block: 'start' }) - } - }, 100) + setTimeoutTimer( + 'setSelectedMessage', + () => { + const messageElement = document.getElementById(`message-${message.id}`) + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'auto', block: 'start' }) + } + }, + 100 + ) } }, - [dispatch, messages] + [dispatch, messages, setTimeoutTimer] ) const scrollToMessage = useCallback( diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 170f754006..155312a368 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -4,6 +4,7 @@ import TranslateButton from '@renderer/components/TranslateButton' import { isGenerateImageModel, isVisionModel } from '@renderer/config/models' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import FileManager from '@renderer/services/FileManager' import PasteService from '@renderer/services/PasteService' import { useAppSelector } from '@renderer/store' @@ -51,6 +52,7 @@ const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onC const isUserMessage = message.role === 'user' const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, topicId)) + const { setTimeoutTimer } = useTimer() const couldAddImageFile = useMemo(() => { const relatedAssistantMessages = topicMessages.filter((m) => m.askId === message.id && m.role === 'assistant') @@ -247,9 +249,13 @@ const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onC handleTextChange(blockId, newText) // set cursor position in the next render cycle - setTimeout(() => { - textArea.selectionStart = textArea.selectionEnd = start + 1 - }, 0) + setTimeoutTimer( + 'handleKeyDown', + () => { + textArea.selectionStart = textArea.selectionEnd = start + 1 + }, + 0 + ) } } } diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 4db2a42747..4632c9ffb9 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -4,6 +4,7 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { MultiModelMessageStyle } from '@renderer/store/settings' import type { Topic } from '@renderer/types' @@ -32,6 +33,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() const { isMultiSelectMode } = useChatContext(topic) const maxWidth = useChatMaxWidth() + const { setTimeoutTimer } = useTimer() const isGrouped = isMultiSelectMode ? false : messageLength > 1 && messages.every((m) => m.role === 'assistant') @@ -68,14 +70,18 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { // 当前选中的消息 editMessage(message.id, { foldSelected: true }) - setTimeout(() => { - const messageElement = document.getElementById(`message-${message.id}`) - if (messageElement) { - messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - }, 200) + setTimeoutTimer( + 'setSelectedMessage', + () => { + const messageElement = document.getElementById(`message-${message.id}`) + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, + 200 + ) }, - [editMessage, selectedMessageId] + [editMessage, selectedMessageId, setTimeoutTimer] ) useEffect(() => { diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 0e3aa77872..4d99319132 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -9,6 +9,7 @@ import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { useEnableDeveloperMode, useMessageStyle } from '@renderer/hooks/useSettings' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import useTranslate from '@renderer/hooks/useTranslate' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle } from '@renderer/services/MessagesService' @@ -78,7 +79,7 @@ const MessageMenubar: FC = (props) => { } = props const { t } = useTranslation() const { toggleMultiSelectMode } = useChatContext(props.topic) - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const [isTranslating, setIsTranslating] = useState(false) const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) const [showDeleteTooltip, setShowDeleteTooltip] = useState(false) @@ -136,9 +137,8 @@ const MessageMenubar: FC = (props) => { window.message.success({ content: t('message.copied'), key: 'copy-message' }) setCopied(true) - setTimeout(() => setCopied(false), 2000) }, - [message, t] // message is needed for message.id and as a fallback. t is for translation. + [message, setCopied, t] // message is needed for message.id and as a fallback. t is for translation. ) const onNewBranch = useCallback(async () => { diff --git a/src/renderer/src/pages/home/Messages/MessageTools.tsx b/src/renderer/src/pages/home/Messages/MessageTools.tsx index 2dbcb65912..2a5077f48d 100644 --- a/src/renderer/src/pages/home/Messages/MessageTools.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTools.tsx @@ -3,6 +3,7 @@ import { CopyIcon, LoadingIcon } from '@renderer/components/Icons' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { isToolAutoApproved } from '@renderer/utils/mcp-tools' import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' @@ -52,6 +53,7 @@ const MessageTools: FC = ({ block }) => { const { mcpServers, updateMCPServer } = useMCPServers() const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null) const [progress, setProgress] = useState(0) + const { setTimeoutTimer } = useTimer() const toolResponse = block.metadata?.rawMcpToolResponse @@ -130,7 +132,7 @@ const MessageTools: FC = ({ block }) => { navigator.clipboard.writeText(content) antdMessage.success({ content: t('message.copied'), key: 'copy-message' }) setCopiedMap((prev) => ({ ...prev, [toolId]: true })) - setTimeout(() => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000) + setTimeoutTimer('copyContent', () => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000) } const handleCollapseChange = (keys: string | string[]) => { diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index d8a650412a..fbe0eb8e0b 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -9,6 +9,7 @@ import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessa import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' +import { useTimer } from '@renderer/hooks/useTimer' import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic' import SelectionBox from '@renderer/pages/home/Messages/SelectionBox' import { getDefaultTopic } from '@renderer/services/AssistantService' @@ -55,22 +56,24 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o const { containerRef: scrollContainerRef, handleScroll: handleScrollPosition } = useScrollPosition( `topic-${topic.id}` ) - const { t } = useTranslation() - const { showPrompt, messageNavigation } = useSettings() - const { updateTopic, addTopic } = useAssistant(assistant.id) - const dispatch = useAppDispatch() const [displayMessages, setDisplayMessages] = useState([]) const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [isProcessingContext, setIsProcessingContext] = useState(false) - const messageElements = useRef>(new Map()) + const { updateTopic, addTopic } = useAssistant(assistant.id) + const { showPrompt, messageNavigation } = useSettings() + const { t } = useTranslation() + const dispatch = useAppDispatch() const messages = useTopicMessages(topic.id) const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) - const messagesRef = useRef(messages) + const { setTimeoutTimer } = useTimer() const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic) + const messageElements = useRef>(new Map()) + const messagesRef = useRef(messages) + useEffect(() => { messagesRef.current = messages }, [messages]) @@ -256,15 +259,19 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o if (!hasMore || isLoadingMore) return setIsLoadingMore(true) - setTimeout(() => { - const currentLength = displayMessages.length - const newMessages = computeDisplayMessages(messages, currentLength, LOAD_MORE_COUNT) + setTimeoutTimer( + 'loadMoreMessages', + () => { + const currentLength = displayMessages.length + const newMessages = computeDisplayMessages(messages, currentLength, LOAD_MORE_COUNT) - setDisplayMessages((prev) => [...prev, ...newMessages]) - setHasMore(currentLength + LOAD_MORE_COUNT < messages.length) - setIsLoadingMore(false) - }, 300) - }, [displayMessages.length, hasMore, isLoadingMore, messages]) + setDisplayMessages((prev) => [...prev, ...newMessages]) + setHasMore(currentLength + LOAD_MORE_COUNT < messages.length) + setIsLoadingMore(false) + }, + 300 + ) + }, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer]) useShortcut('copy_last_message', () => { const lastMessage = last(messages) diff --git a/src/renderer/src/pages/home/Messages/SelectionBox.tsx b/src/renderer/src/pages/home/Messages/SelectionBox.tsx index 2d12f0305c..ed9673691a 100644 --- a/src/renderer/src/pages/home/Messages/SelectionBox.tsx +++ b/src/renderer/src/pages/home/Messages/SelectionBox.tsx @@ -26,6 +26,7 @@ const SelectionBox: React.FC = ({ const DRAG_THRESHOLD = 5 useEffect(() => { + let handleMouseMoveTimer: NodeJS.Timeout if (!isMultiSelectMode) return const updateDragPos = (e: MouseEvent) => { @@ -106,7 +107,7 @@ const SelectionBox: React.FC = ({ dragSelectedIds.current.add(id) newSelectedIds.add(id) el.classList.add('selection-highlight') - setTimeout(() => el.classList.remove('selection-highlight'), 300) + handleMouseMoveTimer = setTimeout(() => el.classList.remove('selection-highlight'), 300) } }) } @@ -129,6 +130,7 @@ const SelectionBox: React.FC = ({ window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) document.body.classList.remove('no-select') + clearTimeout(handleMouseMoveTimer) } }, [isMultiSelectMode, isDragging, isMouseDown, dragStart, scrollContainerRef, messageElements, handleSelectMessage]) diff --git a/src/renderer/src/pages/home/components/AssistantsDrawer.tsx b/src/renderer/src/pages/home/components/AssistantsDrawer.tsx index d35db2b125..d37f39a234 100644 --- a/src/renderer/src/pages/home/components/AssistantsDrawer.tsx +++ b/src/renderer/src/pages/home/components/AssistantsDrawer.tsx @@ -1,5 +1,6 @@ import { TopView } from '@renderer/components/TopView' import { isMac } from '@renderer/config/constant' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant, Topic } from '@renderer/types' import { Drawer } from 'antd' import { useState } from 'react' @@ -25,10 +26,11 @@ const PopupContainer: React.FC = ({ resolve }) => { const [open, setOpen] = useState(true) + const { setTimeoutTimer } = useTimer() const onClose = () => { setOpen(false) - setTimeout(resolve, 300) + setTimeoutTimer('onClose', resolve, 300) } AssistantsDrawer.hide = onClose diff --git a/src/renderer/src/pages/home/components/SelectModelButton.tsx b/src/renderer/src/pages/home/components/SelectModelButton.tsx index 82d27abce4..0d13a2d592 100644 --- a/src/renderer/src/pages/home/components/SelectModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectModelButton.tsx @@ -7,7 +7,7 @@ import { getProviderName } from '@renderer/services/ProviderService' import { Assistant } from '@renderer/types' import { Button } from 'antd' import { ChevronsUpDown } from 'lucide-react' -import { FC } from 'react' +import { FC, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -18,17 +18,15 @@ interface Props { const SelectModelButton: FC = ({ assistant }) => { const { model, updateAssistant } = useAssistant(assistant.id) const { t } = useTranslation() - - if (isLocalAi) { - return null - } + const timerRef = useRef(undefined) const onSelectModel = async (event: React.MouseEvent) => { event.currentTarget.blur() const selectedModel = await SelectModelPopup.show({ model }) if (selectedModel) { // 避免更新数据造成关闭弹框的卡顿 - setTimeout(() => { + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { const enabledWebSearch = isWebSearchModel(selectedModel) updateAssistant({ ...assistant, @@ -39,6 +37,16 @@ const SelectModelButton: FC = ({ assistant }) => { } } + useEffect(() => { + return () => { + clearTimeout(timerRef.current) + } + }, []) + + if (isLocalAi) { + return null + } + const providerName = getProviderName(model?.provider) return ( diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index b772469b9d..668f68e1e9 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -6,6 +6,7 @@ import { HStack } from '@renderer/components/Layout' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, MAX_CONTEXT_COUNT } from '@renderer/config/constant' +import { useTimer } from '@renderer/hooks/useTimer' import { SettingRow } from '@renderer/pages/settings' import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '@renderer/types' import { modalConfirm } from '@renderer/utils' @@ -42,6 +43,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA customParametersRef.current = customParameters const { t } = useTranslation() + const { setTimeoutTimer } = useTimer() const onTemperatureChange = (value) => { if (!isNaN(value as number)) { @@ -191,13 +193,13 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA // TODO: 需要根据配置来设置默认值 if (selectedModel.name.includes('kimi-k2')) { setTemperature(0.6) - setTimeout(() => updateAssistantSettings({ temperature: 0.6 }), 500) + setTimeoutTimer('onSelectModel_1', () => updateAssistantSettings({ temperature: 0.6 }), 500) } else if (selectedModel.name.includes('moonshot')) { setTemperature(0.3) - setTimeout(() => updateAssistantSettings({ temperature: 0.3 }), 500) + setTimeoutTimer('onSelectModel_2', () => updateAssistantSettings({ temperature: 0.3 }), 500) } } - }, [assistant, defaultModel, updateAssistant, updateAssistantSettings]) + }, [assistant, defaultModel, setTimeoutTimer, updateAssistant, updateAssistantSettings]) useEffect(() => { return () => updateAssistantSettings({ customParameters: customParametersRef.current }) @@ -275,7 +277,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setTemperature(value) - setTimeout(() => updateAssistantSettings({ temperature: value }), 500) + setTimeoutTimer('temperature_onChange', () => updateAssistantSettings({ temperature: value }), 500) } }} style={{ width: '100%' }} @@ -323,7 +325,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setTopP(value) - setTimeout(() => updateAssistantSettings({ topP: value }), 500) + setTimeoutTimer('topP_onChange', () => updateAssistantSettings({ topP: value }), 500) } }} style={{ width: '100%' }} @@ -352,7 +354,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setContextCount(value) - setTimeout(() => updateAssistantSettings({ contextCount: value }), 500) + setTimeoutTimer('contextCount_onChange', () => updateAssistantSettings({ contextCount: value }), 500) } }} style={{ width: '100%' }} @@ -413,7 +415,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(value) => { if (!isNull(value)) { setMaxTokens(value) - setTimeout(() => updateAssistantSettings({ maxTokens: value }), 1000) + setTimeoutTimer('maxTokens_onChange', () => updateAssistantSettings({ maxTokens: value }), 1000) } }} style={{ width: '100%' }} diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 5ea4b9ba01..ace64ab25f 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -13,6 +13,7 @@ import BackupPopup from '@renderer/components/Popups/BackupPopup' import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' +import { useTimer } from '@renderer/hooks/useTimer' import { reset } from '@renderer/services/BackupService' import store, { useAppDispatch } from '@renderer/store' import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings' @@ -54,6 +55,7 @@ const DataSettings: FC = () => { const { size, removeAllFiles } = useKnowledgeFiles() const { theme } = useTheme() const [menu, setMenu] = useState('data') + const { setTimeoutTimer } = useTimer() const _skipBackupFile = store.getState().settings.skipBackupFile const [skipBackupFile, setSkipBackupFile] = useState(_skipBackupFile) @@ -247,11 +249,15 @@ const DataSettings: FC = () => { content: t('settings.data.app_data.restart_notice'), duration: 2 }) - setTimeout(() => { - window.api.relaunchApp({ - args: ['--new-data-path=' + newPath] - }) - }, 500) + setTimeoutTimer( + 'doubleConfirmModalBeforeCopyData', + () => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, + 500 + ) } }) } @@ -333,11 +339,15 @@ const DataSettings: FC = () => { content: t('settings.data.app_data.restart_notice'), duration: 3 }) - setTimeout(() => { - window.api.relaunchApp({ - args: ['--new-data-path=' + newPath] - }) - }, 500) + setTimeoutTimer( + 'showMigrationConfirmModal_1', + () => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, + 500 + ) return } // 如果不复制数据,直接设置新的应用数据路径 @@ -348,11 +358,15 @@ const DataSettings: FC = () => { setAppInfo(await window.api.getAppInfo()) // 通知用户并重启应用 - setTimeout(() => { - window.message.success(t('settings.data.app_data.select_success')) - window.api.setStopQuitApp(false, '') - window.api.relaunchApp() - }, 500) + setTimeoutTimer( + 'showMigrationConfirmModal_2', + () => { + window.message.success(t('settings.data.app_data.select_success')) + window.api.setStopQuitApp(false, '') + window.api.relaunchApp() + }, + 500 + ) } catch (error) { window.api.setStopQuitApp(false, '') window.message.error({ @@ -456,7 +470,7 @@ const DataSettings: FC = () => { await window.api.flushAppData() // wait 2 seconds to flush app data - await new Promise((resolve) => setTimeout(resolve, 2000)) + await new Promise((resolve) => setTimeoutTimer('startMigration_1', resolve, 2000)) // 开始复制过程 const copyResult = await window.api.copy( @@ -476,15 +490,19 @@ const DataSettings: FC = () => { if (!copyResult.success) { // 延迟关闭加载模态框 await new Promise((resolve) => { - setTimeout(() => { - loadingModal.destroy() - window.message.error({ - content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error, - key: messageKey, - duration: 5 - }) - resolve() - }, 500) + setTimeoutTimer( + 'startMigration_2', + () => { + loadingModal.destroy() + window.message.error({ + content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error, + key: messageKey, + duration: 5 + }) + resolve() + }, + 500 + ) }) throw new Error(copyResult.error || 'Unknown error during copy') @@ -494,7 +512,7 @@ const DataSettings: FC = () => { await window.api.setAppDataPath(newPath) // 短暂延迟以显示100%完成 - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeoutTimer('startMigration_3', resolve, 500)) // 关闭加载模态框 loadingModal.destroy() @@ -529,13 +547,17 @@ const DataSettings: FC = () => { setAppInfo(await window.api.getAppInfo()) // 通知用户并重启应用 - setTimeout(() => { - window.message.success(t('settings.data.app_data.select_success')) - window.api.setStopQuitApp(false, '') - window.api.relaunchApp({ - args: ['--user-data-dir=' + newDataPath] - }) - }, 1000) + setTimeoutTimer( + 'handleDataMigration', + () => { + window.message.success(t('settings.data.app_data.select_success')) + window.api.setStopQuitApp(false, '') + window.api.relaunchApp({ + args: ['--user-data-dir=' + newDataPath] + }) + }, + 1000 + ) } catch (error) { window.api.setStopQuitApp(false, '') window.message.error({ @@ -552,7 +574,7 @@ const DataSettings: FC = () => { } handleDataMigration() - }, [t]) + }, [setTimeoutTimer, t]) const onSkipBackupFilesChange = (value: boolean) => { setSkipBackupFile(value) diff --git a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx index 13087359e3..e040b13ccc 100644 --- a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx @@ -6,6 +6,7 @@ import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' import { useNutstoreSSO } from '@renderer/hooks/useNutstoreSSO' +import { useTimer } from '@renderer/hooks/useTimer' import { backupToNutstore, checkConnection, @@ -51,17 +52,14 @@ const NutstoreSettings: FC = () => { const [nutstoreUsername, setNutstoreUsername] = useState(undefined) const [nutstorePass, setNutstorePass] = useState(undefined) const [storagePath, setStoragePath] = useState(nutstorePath) - const [checkConnectionLoading, setCheckConnectionLoading] = useState(false) const [nsConnected, setNsConnected] = useState(false) - const [syncInterval, setSyncInterval] = useState(nutstoreSyncInterval) - const [nutSkipBackupFile, setNutSkipBackupFile] = useState(nutstoreSkipBackupFile) + const [backupManagerVisible, setBackupManagerVisible] = useState(false) const nutstoreSSOHandler = useNutstoreSSO() - - const [backupManagerVisible, setBackupManagerVisible] = useState(false) + const { setTimeoutTimer } = useTimer() const handleClickNutstoreSSO = useCallback(async () => { const ssoUrl = await window.api.nutstore.getSSOUrl() @@ -120,7 +118,7 @@ const NutstoreSettings: FC = () => { setNsConnected(isConnectedToNutstore) setCheckConnectionLoading(false) - setTimeout(() => setNsConnected(false), 3000) + setTimeoutTimer('handleCheckConnection', () => setNsConnected(false), 3000) } const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } = diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index d7c5510359..8bc7354837 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -4,6 +4,7 @@ import { HStack } from '@renderer/components/Layout' import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useEnableDeveloperMode, useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { RootState, useAppDispatch } from '@renderer/store' import { @@ -48,6 +49,7 @@ const GeneralSettings: FC = () => { const [proxyBypassRules, setProxyBypassRules] = useState(storeProxyBypassRules) const { theme } = useTheme() const { enableDeveloperMode, setEnableDeveloperMode } = useEnableDeveloperMode() + const { setTimeoutTimer } = useTimer() const updateTray = (isShowTray: boolean) => { setTray(isShowTray) @@ -171,9 +173,13 @@ const GeneralSettings: FC = () => { } // 重启应用 - setTimeout(() => { - window.api.relaunchApp() - }, 500) + setTimeoutTimer( + 'handleHardwareAccelerationChange', + () => { + window.api.relaunchApp() + }, + 500 + ) } }) } diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index 5d4139cd38..60f9d26d56 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -2,6 +2,7 @@ import { UploadOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' import CodeEditor from '@renderer/components/CodeEditor' +import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMCPServerActive } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' @@ -72,6 +73,7 @@ const AddMcpServerModal: FC = ({ const [importMethod, setImportMethod] = useState<'json' | 'dxt'>(initialImportMethod) const [dxtFile, setDxtFile] = useState(null) const dispatch = useAppDispatch() + const { setTimeoutTimer } = useTimer() // Update import method when initialImportMethod changes useEffect(() => { @@ -159,21 +161,25 @@ const AddMcpServerModal: FC = ({ onClose() // Check server connectivity in background (with timeout) - setTimeout(() => { - window.api.mcp - .checkMcpConnectivity(newServer) - .then((isConnected) => { - logger.debug(`Connectivity check for ${newServer.name}: ${isConnected}`) - dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) - }) - .catch((connError: any) => { - logger.error(`Connectivity check failed for ${newServer.name}:`, connError) - // Don't show error for DXT servers as they might need additional setup - logger.warn( - `DXT server ${newServer.name} connectivity check failed, this is normal for servers requiring additional configuration` - ) - }) - }, 1000) // Delay to ensure server is properly added to store + setTimeoutTimer( + 'handleOk', + () => { + window.api.mcp + .checkMcpConnectivity(newServer) + .then((isConnected) => { + logger.debug(`Connectivity check for ${newServer.name}: ${isConnected}`) + dispatch(setMCPServerActive({ id: newServer.id, isActive: isConnected })) + }) + .catch((connError: any) => { + logger.error(`Connectivity check failed for ${newServer.name}:`, connError) + // Don't show error for DXT servers as they might need additional setup + logger.warn( + `DXT server ${newServer.name} connectivity check failed, this is normal for servers requiring additional configuration` + ) + }) + }, + 1000 + ) // Delay to ensure server is properly added to store } catch (error) { logger.error('DXT processing error:', error as Error) window.message.error({ diff --git a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx index 2f7e64bf34..f1a569a2f2 100644 --- a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx @@ -3,7 +3,7 @@ import { Center, VStack } from '@renderer/components/Layout' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsBunInstalled, setIsUvInstalled } from '@renderer/store/mcp' import { Alert, Button } from 'antd' -import { FC, useCallback, useEffect, useState } from 'react' +import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import styled from 'styled-components' @@ -26,6 +26,15 @@ const InstallNpxUv: FC = ({ mini = false }) => { const [binariesDir, setBinariesDir] = useState(null) const { t } = useTranslation() const navigate = useNavigate() + const checkBinariesTimerRef = useRef(undefined) + + // 清理定时器 + useEffect(() => { + return () => { + clearTimeout(checkBinariesTimerRef.current) + } + }, []) + const checkBinaries = useCallback(async () => { const uvExists = await window.api.isBinaryExist('uv') const bunExists = await window.api.isBinaryExist('bun') @@ -48,7 +57,8 @@ const InstallNpxUv: FC = ({ mini = false }) => { window.message.error({ content: `${t('settings.mcp.installError')}: ${error.message}`, key: 'mcp-install-error' }) setIsInstallingUv(false) } - setTimeout(checkBinaries, 1000) + clearTimeout(checkBinariesTimerRef.current) + checkBinariesTimerRef.current = setTimeout(checkBinaries, 1000) } const installBun = async () => { @@ -64,7 +74,8 @@ const InstallNpxUv: FC = ({ mini = false }) => { }) setIsInstallingBun(false) } - setTimeout(checkBinaries, 1000) + clearTimeout(checkBinariesTimerRef.current) + checkBinariesTimerRef.current = setTimeout(checkBinaries, 1000) } useEffect(() => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 8c496e64a1..fbb2806f01 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -6,6 +6,7 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { PROVIDER_URLS } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList' import { checkApi } from '@renderer/services/ApiService' @@ -53,6 +54,7 @@ const ProviderSetting: FC = ({ providerId }) => { const [apiVersion, setApiVersion] = useState(provider.apiVersion) const { t } = useTranslation() const { theme } = useTheme() + const { setTimeoutTimer } = useTimer() const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -170,9 +172,13 @@ const ProviderSetting: FC = ({ providerId }) => { }) setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.SUCCESS })) - setTimeout(() => { - setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED })) - }, 3000) + setTimeoutTimer( + 'onCheckApi', + () => { + setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED })) + }, + 3000 + ) } catch (error: any) { window.message.error({ key: 'api-check', diff --git a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx index b419cb5e5d..cf65c42448 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx @@ -1,6 +1,7 @@ import ModelSelector from '@renderer/components/ModelSelector' import { TopView } from '@renderer/components/TopView' import { isRerankModel } from '@renderer/config/models' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import { getModelUniqId } from '@renderer/services/ModelService' import { Model, Provider } from '@renderer/types' @@ -19,6 +20,7 @@ interface Props extends ShowParams { const PopupContainer: React.FC = ({ provider, resolve, reject }) => { const [open, setOpen] = useState(true) + const { setTimeoutTimer } = useTimer() // Keep the natural order of models const models = useMemo(() => provider.models.filter((m) => !isRerankModel(m)), [provider]) @@ -42,7 +44,7 @@ const PopupContainer: React.FC = ({ provider, resolve, reject }) => { const onCancel = () => { setOpen(false) - setTimeout(reject, 300) + setTimeoutTimer('onCancel', reject, 300) } const onClose = () => { diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 4eb91e9c05..834f5e283a 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout' import { isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useShortcuts } from '@renderer/hooks/useShortcuts' +import { useTimer } from '@renderer/hooks/useTimer' import { getShortcutLabel } from '@renderer/i18n/label' import { useAppDispatch } from '@renderer/store' import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts' @@ -22,6 +23,7 @@ const ShortcutSettings: FC = () => { const { shortcuts: originalShortcuts } = useShortcuts() const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) + const { setTimeoutTimer } = useTimer() //if shortcut is not available on all the platforms, block the shortcut here let shortcuts = originalShortcuts @@ -42,9 +44,13 @@ const ShortcutSettings: FC = () => { const handleAddShortcut = (record: Shortcut) => { setEditingKey(record.key) - setTimeout(() => { - inputRefs.current[record.key]?.focus() - }, 0) + setTimeoutTimer( + 'handleAddShortcut', + () => { + inputRefs.current[record.key]?.focus() + }, + 0 + ) } const isShortcutModified = (record: Shortcut) => { diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx index 8aa7783a44..de94587e2f 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' +import { useTimer } from '@renderer/hooks/useTimer' import { useBlacklist } from '@renderer/hooks/useWebSearchProviders' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setExcludeDomains } from '@renderer/store/websearch' @@ -47,6 +48,7 @@ const BlacklistSettings: FC = () => { name: source.name })) || [] ) + const { setTimeoutTimer } = useTimer() const dispatch = useAppDispatch() @@ -148,7 +150,7 @@ const BlacklistSettings: FC = () => { content: t('settings.tool.websearch.subscribe_update_success'), duration: 2 }) - setTimeout(() => setSubscribeValid(false), 3000) + setTimeoutTimer('updateSubscribe', () => setSubscribeValid(false), 3000) } else { setSubscribeValid(false) throw new Error('No valid sources updated') @@ -190,7 +192,7 @@ const BlacklistSettings: FC = () => { content: t('settings.tool.websearch.subscribe_add_success'), duration: 2 }) - setTimeout(() => setSubscribeValid(false), 3000) + setTimeoutTimer('handleAddSubscribe', () => setSubscribeValid(false), 3000) } catch (error) { setSubscribeValid(false) window.message.error({ diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 5c6faa7b78..d719b38a3e 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -6,6 +6,7 @@ import SearxngLogo from '@renderer/assets/images/search/searxng.svg' import TavilyLogo from '@renderer/assets/images/search/tavily.png' import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup' import { WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' +import { useTimer } from '@renderer/hooks/useTimer' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProviderId } from '@renderer/types' @@ -33,6 +34,7 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '') const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '') const [apiValid, setApiValid] = useState(false) + const { setTimeoutTimer } = useTimer() const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey @@ -125,7 +127,7 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { }) } finally { setApiChecking(false) - setTimeout(() => setApiValid(false), 2500) + setTimeoutTimer('checkSearch', () => setApiValid(false), 2500) } } diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index f6d528e3a4..b04c332f30 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -9,6 +9,7 @@ import { LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import useTranslate from '@renderer/hooks/useTranslate' import { estimateTextTokens } from '@renderer/services/TokenService' import { saveTranslateHistory, translateText } from '@renderer/services/TranslateService' @@ -51,7 +52,7 @@ const TranslatePage: FC = () => { // states const [text, setText] = useState(_text) const [renderedMarkdown, setRenderedMarkdown] = useState('') - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false) const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false) const [isBidirectional, setIsBidirectional] = useState(false) @@ -222,8 +223,7 @@ const TranslatePage: FC = () => { // 控制复制按钮 const onCopy = () => { navigator.clipboard.writeText(translatedContent) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + setCopied(false) } // 控制历史记录点击 diff --git a/src/renderer/src/windows/mini/home/components/InputBar.tsx b/src/renderer/src/windows/mini/home/components/InputBar.tsx index 31114062bb..cfd7d29457 100644 --- a/src/renderer/src/windows/mini/home/components/InputBar.tsx +++ b/src/renderer/src/windows/mini/home/components/InputBar.tsx @@ -1,4 +1,5 @@ import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import { useTimer } from '@renderer/hooks/useTimer' import { Assistant } from '@renderer/types' import { Input as AntdInput } from 'antd' import { InputRef } from 'rc-input/lib/interface' @@ -25,8 +26,9 @@ const InputBar = ({ handleChange }: InputBarProps & { ref?: React.RefObject }) => { const inputRef = useRef(null) + const { setTimeoutTimer } = useTimer() if (!loading) { - setTimeout(() => inputRef.current?.input?.focus(), 0) + setTimeoutTimer('focus', () => inputRef.current?.input?.focus(), 0) } return ( diff --git a/src/renderer/src/windows/selection/action/components/WindowFooter.tsx b/src/renderer/src/windows/selection/action/components/WindowFooter.tsx index ce22eb91ba..53f3d7818b 100644 --- a/src/renderer/src/windows/selection/action/components/WindowFooter.tsx +++ b/src/renderer/src/windows/selection/action/components/WindowFooter.tsx @@ -1,5 +1,6 @@ import { LoadingOutlined } from '@ant-design/icons' import { RefreshIcon } from '@renderer/components/Icons' +import { useTimer } from '@renderer/hooks/useTimer' import { CircleX, Copy, Pause } from 'lucide-react' import { FC, useEffect, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' @@ -27,6 +28,7 @@ const WindowFooter: FC = ({ const [isContainerHovered, setIsContainerHovered] = useState(false) const [isShowMe, setIsShowMe] = useState(true) const hideTimerRef = useRef(null) + const { setTimeoutTimer } = useTimer() useEffect(() => { window.addEventListener('focus', handleWindowFocus) @@ -83,9 +85,13 @@ const WindowFooter: FC = ({ const handleEsc = () => { setIsEscHovered(true) - setTimeout(() => { - setIsEscHovered(false) - }, 200) + setTimeoutTimer( + 'handleEsc', + () => { + setIsEscHovered(false) + }, + 200 + ) if (loading && onPause) { onPause() @@ -96,9 +102,13 @@ const WindowFooter: FC = ({ const handleRegenerate = () => { setIsRegenerateHovered(true) - setTimeout(() => { - setIsRegenerateHovered(false) - }, 200) + setTimeoutTimer( + 'handleRegenerate_1', + () => { + setIsRegenerateHovered(false) + }, + 200 + ) if (loading && onPause) { onPause() @@ -106,9 +116,13 @@ const WindowFooter: FC = ({ if (onRegenerate) { //wait for a little time - setTimeout(() => { - onRegenerate() - }, 200) + setTimeoutTimer( + 'handleRegenerate_2', + () => { + onRegenerate() + }, + 200 + ) } } @@ -120,9 +134,13 @@ const WindowFooter: FC = ({ .then(() => { window.message.success(t('message.copy.success')) setIsCopyHovered(true) - setTimeout(() => { - setIsCopyHovered(false) - }, 200) + setTimeoutTimer( + 'handleCopy', + () => { + setIsCopyHovered(false) + }, + 200 + ) }) .catch(() => { window.message.error(t('message.copy.failed')) diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 6a40498cb4..54f0fcd085 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -4,6 +4,7 @@ import { loggerService } from '@logger' import { AppLogo } from '@renderer/config/env' import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant' import { useSettings } from '@renderer/hooks/useSettings' +import { useTimer } from '@renderer/hooks/useTimer' import i18n from '@renderer/i18n' import type { ActionItem } from '@renderer/types/selectionTypes' import { defaultLanguage } from '@shared/config/constant' @@ -103,7 +104,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { const [animateKey, setAnimateKey] = useState(0) const [copyIconStatus, setCopyIconStatus] = useState<'normal' | 'success' | 'fail'>('normal') const [copyIconAnimation, setCopyIconAnimation] = useState<'none' | 'enter' | 'exit'>('none') - const copyIconTimeoutRef = useRef(undefined) + const { setTimeoutTimer, clearTimeoutTimer } = useTimer() const realActionItems = useMemo(() => { return actionItems?.filter((item) => item.enabled) @@ -113,18 +114,31 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { // [macOS] only macOS has the fullscreen mode const isFullScreen = useRef(false) + const onHideCleanUp = useCallback(() => { + setCopyIconStatus('normal') + setCopyIconAnimation('none') + clearTimeoutTimer('textSelection') + clearTimeoutTimer('copyIcon') + }, [clearTimeoutTimer]) + // listen to selectionService events useEffect(() => { + const cleanups: (() => void)[] = [] // TextSelection const textSelectionListenRemover = window.electron?.ipcRenderer.on( IpcChannel.Selection_TextSelected, (_, selectionData: TextSelectionData) => { selectedText.current = selectionData.text isFullScreen.current = selectionData.isFullscreen ?? false - setTimeout(() => { - //make sure the animation is active - setAnimateKey((prev) => prev + 1) - }, 400) + const cleanup = setTimeoutTimer( + 'textSelection', + () => { + //make sure the animation is active + setAnimateKey((prev) => prev + 1) + }, + 400 + ) + cleanups.push(cleanup) } ) @@ -142,8 +156,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { return () => { textSelectionListenRemover() toolbarVisibilityChangeListenRemover() + cleanups.forEach((cleanup) => cleanup()) } - }, [demo]) + }, [demo, onHideCleanUp, setTimeoutTimer]) //make sure the toolbar size is updated when the compact mode/actionItems is changed useEffect(() => { @@ -172,11 +187,22 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } }, [customCss, demo]) - const onHideCleanUp = () => { - setCopyIconStatus('normal') - setCopyIconAnimation('none') - clearTimeout(copyIconTimeoutRef.current) - } + // copy selected text to clipboard + const handleCopy = useCallback(async () => { + if (selectedText.current) { + const result = await window.api?.selection.writeToClipboard(selectedText.current) + + setCopyIconStatus(result ? 'success' : 'fail') + setCopyIconAnimation('enter') + setTimeoutTimer( + 'copyIcon', + () => { + setCopyIconAnimation('exit') + }, + 2000 + ) + } + }, [setTimeoutTimer]) const handleAction = useCallback( (action: ActionItem) => { @@ -200,22 +226,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { break } }, - [demo] + [demo, handleCopy] ) - // copy selected text to clipboard - const handleCopy = async () => { - if (selectedText.current) { - const result = await window.api?.selection.writeToClipboard(selectedText.current) - - setCopyIconStatus(result ? 'success' : 'fail') - setCopyIconAnimation('enter') - copyIconTimeoutRef.current = setTimeout(() => { - setCopyIconAnimation('exit') - }, 2000) - } - } - const handleSearch = (action: ActionItem) => { if (!action.searchEngine) return