mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
feat: animate topic renaming (#6794)
All checks were successful
Stale Issue Management / stale (push) Has been skipped
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:
parent
aa0b7ed1a8
commit
8689c07888
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user