diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index 611ab68555..7f2c409686 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -4,6 +4,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { deleteMessageFiles } from '@renderer/services/MessagesService' import store from '@renderer/store' import { updateTopic } from '@renderer/store/assistants' +import { setNewlyRenamedTopics, setRenamingTopics } from '@renderer/store/runtime' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Assistant, Topic } from '@renderer/types' import { findMainTextBlocks } from '@renderer/utils/messageUtils/find' @@ -13,8 +14,6 @@ import { useEffect, useState } from 'react' import { useAssistant } from './useAssistant' import { getStoreSetting } from './useSettings' -const renamingTopics = new Set() - let _activeTopic: Topic let _setActiveTopic: (topic: Topic) => void @@ -58,13 +57,51 @@ export async function getTopicById(topicId: string) { return { ...topic, messages } as Topic } +/** + * 开始重命名指定话题 + */ +export const startTopicRenaming = (topicId: string) => { + const currentIds = store.getState().runtime.chat.renamingTopics + if (!currentIds.includes(topicId)) { + store.dispatch(setRenamingTopics([...currentIds, topicId])) + } +} + +/** + * 完成重命名指定话题 + */ +export const finishTopicRenaming = (topicId: string) => { + const state = store.getState() + + // 1. 立即从 renamingTopics 移除 + const currentRenaming = state.runtime.chat.renamingTopics + store.dispatch(setRenamingTopics(currentRenaming.filter((id) => id !== topicId))) + + // 2. 立即添加到 newlyRenamedTopics + const currentNewlyRenamed = state.runtime.chat.newlyRenamedTopics + store.dispatch(setNewlyRenamedTopics([...currentNewlyRenamed, topicId])) + + // 3. 延迟从 newlyRenamedTopics 移除 + setTimeout(() => { + const current = store.getState().runtime.chat.newlyRenamedTopics + store.dispatch(setNewlyRenamedTopics(current.filter((id) => id !== topicId))) + }, 700) +} + +/** + * 判断指定话题是否正在重命名 + */ +export const isTopicRenaming = (topicId: string) => { + return store.getState().runtime.chat.renamingTopics.includes(topicId) +} + export const autoRenameTopic = async (assistant: Assistant, topicId: string) => { - if (renamingTopics.has(topicId)) { + if (isTopicRenaming(topicId)) { return } try { - renamingTopics.add(topicId) + startTopicRenaming(topicId) const topic = await getTopicById(topicId) const enableTopicNaming = getStoreSetting('enableTopicNaming') @@ -102,7 +139,7 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) => } } } finally { - renamingTopics.delete(topicId) + finishTopicRenaming(topicId) } } @@ -117,9 +154,18 @@ export const TopicManager = { return await db.topics.toArray() }, + /** + * 加载并返回指定话题的消息 + */ async getTopicMessages(id: string) { const topic = await TopicManager.getTopic(id) - return topic ? topic.messages : [] + if (!topic) return [] + + await store.dispatch(loadTopicMessagesThunk(id)) + + // 获取更新后的话题 + const updatedTopic = await TopicManager.getTopic(id) + return updatedTopic?.messages || [] }, async removeTopic(id: string) { diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 16d2b23de9..04930a624f 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -18,7 +18,7 @@ import { isMac } from '@renderer/config/constant' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { modelGenerating } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' -import { TopicManager } from '@renderer/hooks/useTopic' +import { finishTopicRenaming, startTopicRenaming, TopicManager } from '@renderer/hooks/useTopic' import { fetchMessagesSummary } from '@renderer/services/ApiService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import store from '@renderer/store' @@ -57,6 +57,9 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic const { t } = useTranslation() const { showTopicTime, pinTopicsToTop, setTopicPosition } = useSettings() + const renamingTopics = useSelector((state: RootState) => state.runtime.chat.renamingTopics) + const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics) + const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)' const [deletingTopicId, setDeletingTopicId] = useState(null) @@ -84,6 +87,20 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic [activeTopic.id, pendingTopics] ) + const isRenaming = useCallback( + (topicId: string) => { + return renamingTopics.includes(topicId) + }, + [renamingTopics] + ) + + const isNewlyRenamed = useCallback( + (topicId: string) => { + return newlyRenamedTopics.includes(topicId) + }, + [newlyRenamedTopics] + ) + const handleDeleteClick = useCallback((topicId: string, e: React.MouseEvent) => { e.stopPropagation() @@ -170,16 +187,22 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic label: t('chat.topics.auto_rename'), key: 'auto-rename', icon: , + disabled: isRenaming(topic.id), async onClick() { const messages = await TopicManager.getTopicMessages(topic.id) if (messages.length >= 2) { - const summaryText = await fetchMessagesSummary({ messages, assistant }) - if (summaryText) { - const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false } - updateTopic(updatedTopic) - topic.id === activeTopic.id && setActiveTopic(updatedTopic) - } else { - window.message?.error(t('message.error.fetchTopicName')) + startTopicRenaming(topic.id) + try { + const summaryText = await fetchMessagesSummary({ messages, assistant }) + if (summaryText) { + const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false } + updateTopic(updatedTopic) + topic.id === activeTopic.id && setActiveTopic(updatedTopic) + } else { + window.message?.error(t('message.error.fetchTopicName')) + } + } finally { + finishTopicRenaming(topic.id) } } } @@ -188,6 +211,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic label: t('chat.topics.edit.title'), key: 'rename', icon: , + disabled: isRenaming(topic.id), async onClick() { const name = await PromptPopup.show({ title: t('chat.topics.edit.title'), @@ -388,6 +412,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic }, [ targetTopic, t, + isRenaming, exportMenuOptions.image, exportMenuOptions.markdown, exportMenuOptions.markdown_reason, @@ -430,6 +455,13 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic const topicName = topic.name.replace('`', '') const topicPrompt = topic.prompt const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt + + const getTopicNameClassName = () => { + if (isRenaming(topic.id)) return 'shimmer' + if (isNewlyRenamed(topic.id)) return 'typing' + return '' + } + return ( setTargetTopic(topic)} @@ -438,7 +470,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic style={{ borderRadius }}> {isPending(topic.id) && !isActive && } - + {topicName} {isActive && !topic.pinned && ( @@ -544,6 +576,46 @@ const TopicName = styled.div` -webkit-box-orient: vertical; overflow: hidden; font-size: 13px; + position: relative; + will-change: background-position, width; + + --color-shimmer-mid: var(--color-text-1); + --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); + + &.shimmer { + background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end)); + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: shimmer 3s linear infinite; + } + + &.typing { + display: block; + -webkit-line-clamp: unset; + -webkit-box-orient: unset; + white-space: nowrap; + overflow: hidden; + animation: typewriter 0.5s steps(40, end); + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + + @keyframes typewriter { + from { + width: 0; + } + to { + width: 100%; + } + } ` const PendingIndicator = styled.div.attrs({ diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 207003127c..5c84ab8000 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -7,6 +7,10 @@ export interface ChatState { isMultiSelectMode: boolean selectedMessageIds: string[] activeTopic: Topic | null + /** topic ids that are currently being renamed */ + renamingTopics: string[] + /** topic ids that are newly renamed */ + newlyRenamedTopics: string[] } export interface UpdateState { @@ -65,7 +69,9 @@ const initialState: RuntimeState = { chat: { isMultiSelectMode: false, selectedMessageIds: [], - activeTopic: null + activeTopic: null, + renamingTopics: [], + newlyRenamedTopics: [] } } @@ -118,6 +124,12 @@ const runtimeSlice = createSlice({ }, setActiveTopic: (state, action: PayloadAction) => { state.chat.activeTopic = action.payload + }, + setRenamingTopics: (state, action: PayloadAction) => { + state.chat.renamingTopics = action.payload + }, + setNewlyRenamedTopics: (state, action: PayloadAction) => { + state.chat.newlyRenamedTopics = action.payload } } }) @@ -137,7 +149,9 @@ export const { // Chat related actions toggleMultiSelectMode, setSelectedMessageIds, - setActiveTopic + setActiveTopic, + setRenamingTopics, + setNewlyRenamedTopics } = runtimeSlice.actions export default runtimeSlice.reducer