fix: timeout memory leak (#9312)

* fix(MinappPopupContainer): 修复内存泄漏问题,清理未使用的定时器

在组件卸载时清理setTimeout定时器,避免潜在的内存泄漏

* fix(SelectModelButton): 修复模型选择后更新导致的卡顿问题

使用useRef存储定时器并在组件卸载时清理,避免内存泄漏

* fix(QuickPanel): 修复定时器未清理导致的内存泄漏问题

添加 clearSearchTimerRef 和 focusTimerRef 来管理定时器
在组件清理和状态变化时清理所有定时器

* fix(useInPlaceEdit): 修复编辑模式下定时器未清理的问题

添加清理定时器的逻辑,避免组件卸载时内存泄漏

* refactor(useKnowledge): 使用ref管理定时器并统一检查知识库逻辑

将分散的setTimeout调用统一为checkAllBases方法
使用useRef管理定时器并在组件卸载时清理

* fix(useScrollPosition): 修复滚动位置恢复时的内存泄漏问题

添加清理函数以清除未完成的定时器,防止组件卸载时内存泄漏

* fix(WebSearchProviderSetting): 清理定时器防止内存泄漏

在组件卸载时清理检查API有效性的定时器,避免潜在的内存泄漏问题

* fix(selection-toolbar): 修复选中文本时定时器未清理的问题

* fix(translate): 修复复制文本时定时器未清理的问题

添加 copyTimerRef 来管理复制操作的定时器,并在组件卸载时清理定时器

* fix(WebSearchSettings): 使用useRef管理订阅验证定时器以避免内存泄漏

* fix(MCPSettings): 修复定时器未清理导致的内存泄漏问题

添加 useRef 来存储定时器引用,并在组件卸载时清理定时器

* refactor(ThinkingBlock): 使用 useTemporaryValue 替换手动 setTimeout

移除手动设置的 setTimeout 来重置 copied 状态,改用 useTemporaryValue hook 自动处理

* refactor(ChatNavigation): 使用 useRef 替代 useState 管理定时器

简化定时器管理逻辑,避免不必要的状态更新

* fix(AddAssistantPopup): 清理创建助手时的定时器以避免内存泄漏

添加useEffect清理定时器,防止组件卸载时内存泄漏

* feat(hooks): 添加useTimer钩子管理定时器

实现一个自定义hook来集中管理setTimeout和setInterval定时器
自动在组件卸载时清理所有定时器防止内存泄漏

* refactor(Inputbar): 使用 useTimer 替换 setTimeout 实现延迟更新

将 setTimeout 替换为 useTimer 的自定义 setTimeoutTimer 方法,提高代码可维护性并统一计时器管理

* refactor(WindowFooter): 使用 useTimer 替换 setTimeout 以管理定时器

* docs(useTimer): 更新定时器hook的注释格式和描述

* feat(hooks): 为useTimer添加返回清理函数的功能

允许从setTimeoutTimer和setIntervalTimer返回清理函数,便于手动清除定时器

* refactor(ImportAgentPopup): 使用useTimer替换setTimeout以管理定时器

* refactor: 使用useTimer替代setTimeout以优化定时器管理

* refactor(SearchResults): 使用useTimer替换setTimeout以管理定时器

* refactor(消息组件): 使用useTimer替换setTimeout以管理定时器

* refactor: 使用useTimer替换setTimeout以优化定时器管理

* refactor(AssistantsDrawer): 使用useTimer替换setTimeout以优化定时器管理

* refactor(Inputbar): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MCPToolsButton): 使用useTimer优化定时器管理

* refactor(QuickPhrasesButton): 使用useTimer替换setTimeout以优化定时器管理

* refactor(ChatFlowHistory): 使用useTimer替换setTimeout以管理定时器

* refactor(Message): 使用useTimer替换setTimeout以管理定时器

* refactor(MessageAnchorLine): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MessageGroup): 使用useTimer替换setTimeout以优化定时器管理

* refactor(MessageMenubar): 使用 useTemporaryValue 替换手动 setTimeout 逻辑

* refactor(Messages): 使用 useTimer 优化定时器管理

* refactor(MessageTools): 使用useTimer替换setTimeout以管理定时器

* fix(SelectionBox): 修复鼠标移动时未清除定时器导致的内存泄漏

在鼠标移动事件处理中增加定时器清理逻辑,避免组件卸载时未清除定时器导致的内存泄漏问题

* refactor(ErrorBlock): 使用自定义hook替换setTimeout

使用useTimer中的setTimeoutTimer替代原生setTimeout,便于统一管理定时器

* refactor(GeneralSettings): 使用useTimer替换setTimeout以实现更好的定时器管理

* refactor(ShortcutSettings): 使用useTimer替换setTimeout以优化定时器管理

* refactor(AssistantModelSettings): 使用useTimer替换setTimeout以优化定时器管理

* refactor(DataSettings): 使用useTimer替换setTimeout以增强定时器管理

统一使用useTimer hook管理所有定时器操作,提高代码可维护性

* refactor(NutstoreSettings): 使用useTimer优化setTimeout管理

替换直接使用setTimeout为useTimer hook的setTimeoutTimer方法,提升定时器管理的可维护性

* refactor(MCPSettings): 使用useTimer替换setTimeout以提升代码可维护性

* refactor(ProviderSetting): 使用useTimer优化setTimeout管理

* refactor(ProviderSettings): 使用 useTimer 替换 setTimeout 以优化定时器管理

* refactor(InputBar): 使用useTimer替换setTimeout以实现更好的定时器管理

* refactor(MessageEditor): 使用useTimer替换setTimeout以管理定时器

使用自定义hook useTimer来替代原生setTimeout,便于统一管理和清理定时器

* docs(useTimer): 添加 useTimer hook 的使用示例和详细说明

* refactor(MinappPopupContainer): 使用useTimer替换setTimeout实现

替换直接使用setTimeout为自定义hook useTimer,简化组件清理逻辑

* refactor(AddAssistantPopup): 使用useTimer替换手动定时器管理

用useTimer钩子替代手动管理定时器,简化代码并提高可维护性

* refactor(WebSearchSettings): 使用 useTimer 替换手动定时器管理

移除手动管理的定时器逻辑,改用 useTimer hook 统一处理

* refactor(WebSearchProviderSetting): 使用自定义hook替代原生定时器

用useTimer hook替换原有的setTimeout和clearTimeout逻辑,提高代码可维护性

* refactor(translate): 使用 useTemporaryValue 替换手动实现的复制状态定时器

* refactor(SelectionToolbar): 使用 useTimer 钩子替换 setTimeout 和 clearTimeout

重构 SelectionToolbar 组件,使用自定义的 useTimer 钩子来管理定时器,提升代码可维护性
清理隐藏时的定时器逻辑,避免内存泄漏
This commit is contained in:
Phantom 2025-08-20 16:38:13 +08:00 committed by GitHub
parent 332ba5d678
commit 25531ecd76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 649 additions and 266 deletions

View File

@ -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)
}
}

View File

@ -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<Props> = ({ resolve }) => {
const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const { setTimeoutTimer } = useTimer()
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
@ -80,11 +82,11 @@ const PopupContainer: React.FC<Props> = ({ 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(() => {

View File

@ -68,6 +68,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
// 无匹配项自动关闭的定时器
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const clearSearchTimerRef = useRef<NodeJS.Timeout>(undefined)
const focusTimerRef = useRef<NodeJS.Timeout>(undefined)
// 处理搜索,过滤列表
const list = useMemo(() => {
@ -145,6 +147,8 @@ export const QuickPanelView: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ setInputText }) => {
textArea.removeEventListener('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
textArea.removeEventListener('compositionend', handleCompositionEnd)
setTimeout(() => {
clearTimeout(clearSearchTimerRef.current)
clearSearchTimerRef.current = setTimeout(() => {
setSearchText('')
}, 200) // 等待面板关闭动画结束后,再清空搜索词
}

View File

@ -26,13 +26,22 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
const [originalValue, setOriginalValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const editTimerRef = useRef<NodeJS.Timeout>(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()

View File

@ -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<NodeJS.Timeout>(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') || []

View File

@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
export default function useScrollPosition(key: string) {
const containerRef = useRef<HTMLDivElement>(null)
const scrollKey = `scroll:${key}`
const scrollTimerRef = useRef<NodeJS.Timeout>(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 }
}

View File

@ -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<string, NodeJS.Timeout>())
const intervalMapRef = useRef(new Map<string, NodeJS.Timeout>())
// 组件卸载时自动清理所有定时器
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<typeof setTimeout>) => {
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<typeof setInterval>) => {
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
}

View File

@ -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<Props> = ({ 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<Props> = ({ 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) {

View File

@ -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)
}
}

View File

@ -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<HTMLDivElement> {
const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => {
const { handleScroll, containerRef } = useScrollPosition('SearchResults')
const { setTimeoutTimer } = useTimer()
const [searchTerms, setSearchTerms] = useState<string[]>(
keywords
@ -112,7 +114,7 @@ const SearchResults: FC<Props> = ({ 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 }) => (

View File

@ -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<Props> = ({ 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<Props> = ({ 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 (

View File

@ -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> = (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> = (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> = (props) => {
}
const messagesComponentFirstUpdateHandler = () => {
setTimeout(() => (firstUpdateCompleted = true), 300)
setTimeoutTimer('messagesComponentFirstUpdateHandler', () => (firstUpdateCompleted = true), 300)
firstUpdateOrNoFirstUpdateHandler()
}

View File

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const onTranslated = (translatedText: string) => {
setText(translatedText)
setTimeout(() => resizeTextArea(), 0)
setTimeoutTimer('onTranslated', () => resizeTextArea(), 0)
}
const handleDragStart = (e: React.MouseEvent) => {

View File

@ -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<Props> = ({ 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<Props> = ({ 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(() => {

View File

@ -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 () => {

View File

@ -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<Props> = ({ 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 (
<Tooltip placement="top" title={t('chat.input.url_context')} arrow>

View File

@ -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<Props> = ({ 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)) {

View File

@ -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<Props> = ({ 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<Props> = ({ 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

View File

@ -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
)
}
}

View File

@ -39,7 +39,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
const [isNearButtons, setIsNearButtons] = useState(false)
const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null)
const hideTimerRef = useRef<NodeJS.Timeout>(undefined)
const [showChatHistory, setShowChatHistory] = useState(false)
const [manuallyClosedUntil, setManuallyClosedUntil] = useState<number | null>(null)
const currentTopicId = useSelector((state: RootState) => state.messages.currentTopicId)
@ -49,20 +49,16 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ 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<ChatNavigationProps> = ({ 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<ChatNavigationProps> = ({ containerId }) => {
} else {
window.removeEventListener('mousemove', handleMouseMove)
}
if (hideTimer) {
clearTimeout(hideTimer)
}
clearTimeout(hideTimerRef.current)
}
}, [
containerId,
hideTimer,
resetHideTimer,
isNearButtons,
handleMouseEnter,

View File

@ -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<Props> = ({
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
const messageContainerRef = useRef<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
const { setTimeoutTimer } = useTimer()
const isEditing = editingMessageId === message.id
useEffect(() => {
@ -119,18 +121,25 @@ const MessageItem: FC<Props> = ({
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)]

View File

@ -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<MessageLineProps> = ({ messages }) => {
const avatar = useAvatar()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { userName } = useSettings()
const { setTimeoutTimer } = useTimer()
const messagesListRef = useRef<HTMLDivElement>(null)
const messageItemsRef = useRef<Map<string, HTMLDivElement>>(new Map())
const containerRef = useRef<HTMLDivElement>(null)
const [mouseY, setMouseY] = useState<number | null>(null)
const [mouseY, setMouseY] = useState<number | null>(null)
const [listOffsetY, setListOffsetY] = useState(0)
const [containerHeight, setContainerHeight] = useState<number | null>(null)
@ -112,15 +114,19 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ 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(

View File

@ -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<Props> = ({ 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<Props> = ({ 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
)
}
}
}

View File

@ -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(() => {

View File

@ -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) => {
} = 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> = (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 () => {

View File

@ -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<Props> = ({ block }) => {
const { mcpServers, updateMCPServer } = useMCPServers()
const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null)
const [progress, setProgress] = useState<number>(0)
const { setTimeoutTimer } = useTimer()
const toolResponse = block.metadata?.rawMcpToolResponse
@ -130,7 +132,7 @@ const MessageTools: FC<Props> = ({ 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[]) => {

View File

@ -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<MessagesProps> = ({ 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<Message[]>([])
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isProcessingContext, setIsProcessingContext] = useState(false)
const messageElements = useRef<Map<string, HTMLElement>>(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<Message[]>(messages)
const { setTimeoutTimer } = useTimer()
const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic)
const messageElements = useRef<Map<string, HTMLElement>>(new Map())
const messagesRef = useRef<Message[]>(messages)
useEffect(() => {
messagesRef.current = messages
}, [messages])
@ -256,15 +259,19 @@ const Messages: React.FC<MessagesProps> = ({ 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)

View File

@ -26,6 +26,7 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
const DRAG_THRESHOLD = 5
useEffect(() => {
let handleMouseMoveTimer: NodeJS.Timeout
if (!isMultiSelectMode) return
const updateDragPos = (e: MouseEvent) => {
@ -106,7 +107,7 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
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<SelectionBoxProps> = ({
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
document.body.classList.remove('no-select')
clearTimeout(handleMouseMoveTimer)
}
}, [isMultiSelectMode, isDragging, isMouseDown, dragStart, scrollContainerRef, messageElements, handleSelectMessage])

View File

@ -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<Props> = ({
resolve
}) => {
const [open, setOpen] = useState(true)
const { setTimeoutTimer } = useTimer()
const onClose = () => {
setOpen(false)
setTimeout(resolve, 300)
setTimeoutTimer('onClose', resolve, 300)
}
AssistantsDrawer.hide = onClose

View File

@ -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<Props> = ({ assistant }) => {
const { model, updateAssistant } = useAssistant(assistant.id)
const { t } = useTranslation()
if (isLocalAi) {
return null
}
const timerRef = useRef<NodeJS.Timeout>(undefined)
const onSelectModel = async (event: React.MouseEvent<HTMLElement>) => {
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<Props> = ({ assistant }) => {
}
}
useEffect(() => {
return () => {
clearTimeout(timerRef.current)
}
}, [])
if (isLocalAi) {
return null
}
const providerName = getProviderName(model?.provider)
return (

View File

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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%' }}

View File

@ -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<string>('data')
const { setTimeoutTimer } = useTimer()
const _skipBackupFile = store.getState().settings.skipBackupFile
const [skipBackupFile, setSkipBackupFile] = useState<boolean>(_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<void>((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)

View File

@ -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<string | undefined>(undefined)
const [nutstorePass, setNutstorePass] = useState<string | undefined>(undefined)
const [storagePath, setStoragePath] = useState<string | undefined>(nutstorePath)
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
const [nsConnected, setNsConnected] = useState<boolean>(false)
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
const [nutSkipBackupFile, setNutSkipBackupFile] = useState<boolean>(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 } =

View File

@ -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<string | undefined>(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
)
}
})
}

View File

@ -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<AddMcpServerModalProps> = ({
const [importMethod, setImportMethod] = useState<'json' | 'dxt'>(initialImportMethod)
const [dxtFile, setDxtFile] = useState<File | null>(null)
const dispatch = useAppDispatch()
const { setTimeoutTimer } = useTimer()
// Update import method when initialImportMethod changes
useEffect(() => {
@ -159,21 +161,25 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
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({

View File

@ -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<Props> = ({ mini = false }) => {
const [binariesDir, setBinariesDir] = useState<string | null>(null)
const { t } = useTranslation()
const navigate = useNavigate()
const checkBinariesTimerRef = useRef<NodeJS.Timeout>(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<Props> = ({ 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<Props> = ({ mini = false }) => {
})
setIsInstallingBun(false)
}
setTimeout(checkBinaries, 1000)
clearTimeout(checkBinariesTimerRef.current)
checkBinariesTimerRef.current = setTimeout(checkBinaries, 1000)
}
useEffect(() => {

View File

@ -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<Props> = ({ 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<Props> = ({ 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',

View File

@ -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<Props> = ({ 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<Props> = ({ provider, resolve, reject }) => {
const onCancel = () => {
setOpen(false)
setTimeout(reject, 300)
setTimeoutTimer('onCancel', reject, 300)
}
const onClose = () => {

View File

@ -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<Record<string, InputRef>>({})
const [editingKey, setEditingKey] = useState<string | null>(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) => {

View File

@ -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({

View File

@ -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<Props> = ({ 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<Props> = ({ providerId }) => {
})
} finally {
setApiChecking(false)
setTimeout(() => setApiValid(false), 2500)
setTimeoutTimer('checkSearch', () => setApiValid(false), 2500)
}
}

View File

@ -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<string>('')
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)
}
// 控制历史记录点击

View File

@ -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<HTMLDivElement | null> }) => {
const inputRef = useRef<InputRef>(null)
const { setTimeoutTimer } = useTimer()
if (!loading) {
setTimeout(() => inputRef.current?.input?.focus(), 0)
setTimeoutTimer('focus', () => inputRef.current?.input?.focus(), 0)
}
return (
<InputWrapper ref={ref}>

View File

@ -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<FooterProps> = ({
const [isContainerHovered, setIsContainerHovered] = useState(false)
const [isShowMe, setIsShowMe] = useState(true)
const hideTimerRef = useRef<NodeJS.Timeout | null>(null)
const { setTimeoutTimer } = useTimer()
useEffect(() => {
window.addEventListener('focus', handleWindowFocus)
@ -83,9 +85,13 @@ const WindowFooter: FC<FooterProps> = ({
const handleEsc = () => {
setIsEscHovered(true)
setTimeout(() => {
setIsEscHovered(false)
}, 200)
setTimeoutTimer(
'handleEsc',
() => {
setIsEscHovered(false)
},
200
)
if (loading && onPause) {
onPause()
@ -96,9 +102,13 @@ const WindowFooter: FC<FooterProps> = ({
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<FooterProps> = ({
if (onRegenerate) {
//wait for a little time
setTimeout(() => {
onRegenerate()
}, 200)
setTimeoutTimer(
'handleRegenerate_2',
() => {
onRegenerate()
},
200
)
}
}
@ -120,9 +134,13 @@ const WindowFooter: FC<FooterProps> = ({
.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'))

View File

@ -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<NodeJS.Timeout | undefined>(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