feat: animate topic renaming (#6794)
All checks were successful
Stale Issue Management / stale (push) Has been skipped

* feat: animate topic renaming

* fix: load messages before renaming a topic

* refactor: better error handling

* refactor: make function names more reasonable

* refactor: update shimmer colors

* refactor: use typing effect
This commit is contained in:
one 2025-06-12 18:41:15 +08:00 committed by GitHub
parent aa0b7ed1a8
commit 8689c07888
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 149 additions and 17 deletions

View File

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

View File

@ -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<Props> = ({ 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<string | null>(null)
@ -84,6 +87,20 @@ const Topics: FC<Props> = ({ 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<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
label: t('chat.topics.auto_rename'),
key: 'auto-rename',
icon: <i className="iconfont icon-business-smart-assistant" style={{ fontSize: '14px' }} />,
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<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
label: t('chat.topics.edit.title'),
key: 'rename',
icon: <EditOutlined />,
disabled: isRenaming(topic.id),
async onClick() {
const name = await PromptPopup.show({
title: t('chat.topics.edit.title'),
@ -388,6 +412,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
}, [
targetTopic,
t,
isRenaming,
exportMenuOptions.image,
exportMenuOptions.markdown,
exportMenuOptions.markdown_reason,
@ -430,6 +455,13 @@ const Topics: FC<Props> = ({ 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 (
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
@ -438,7 +470,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
style={{ borderRadius }}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
<TopicNameContainer>
<TopicName className="name" title={topicName}>
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
</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({

View File

@ -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<Topic>) => {
state.chat.activeTopic = action.payload
},
setRenamingTopics: (state, action: PayloadAction<string[]>) => {
state.chat.renamingTopics = action.payload
},
setNewlyRenamedTopics: (state, action: PayloadAction<string[]>) => {
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