feat: add topics history

This commit is contained in:
kangfenmao 2024-10-02 21:27:20 +08:00
parent 2da3a3f010
commit 4cc140e4f2
24 changed files with 735 additions and 121 deletions

View File

@ -12,6 +12,7 @@ import { ThemeProvider } from './context/ThemeProvider'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HistoryPage from './pages/history/HistoryPage'
import HomePage from './pages/home/HomePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
@ -31,6 +32,7 @@ function App(): JSX.Element {
<Route path="/agents" element={<AgentsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/messages/*" element={<HistoryPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>

View File

@ -1,4 +1,4 @@
import { FolderOutlined, TranslationOutlined } from '@ant-design/icons'
import { FileSearchOutlined, FolderOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env'
import useAvatar from '@renderer/hooks/useAvatar'
@ -23,6 +23,7 @@ const Sidebar: FC = () => {
const { windowStyle } = useSettings()
const isRoute = (path: string): string => (pathname === path ? 'active' : '')
const isRoutes = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
const onEditUser = () => UserPopup.show()
@ -72,6 +73,11 @@ const Sidebar: FC = () => {
<FolderOutlined />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/messages')}>
<Icon className={isRoutes('/messages')}>
<FileSearchOutlined />
</Icon>
</StyledLink>
</Menus>
</MainMenus>
<Menus onClick={MinApp.onClose}>

View File

@ -71,7 +71,7 @@ export function useDefaultAssistant() {
return {
defaultAssistant: {
...defaultAssistant,
topics: [getDefaultTopic()]
topics: [getDefaultTopic(defaultAssistant.id)]
},
updateDefaultAssistant: (assistant: Assistant) => dispatch(updateDefaultAssistant({ assistant }))
}

View File

@ -1,5 +1,6 @@
import db from '@renderer/databases'
import { deleteMessageFiles } from '@renderer/services/messages'
import store from '@renderer/store'
import { Assistant, Topic } from '@renderer/types'
import { find } from 'lodash'
import { useEffect, useState } from 'react'
@ -8,9 +9,9 @@ import { useAssistant } from './useAssistant'
let _activeTopic: Topic
export function useActiveTopic(_assistant: Assistant) {
export function useActiveTopic(_assistant: Assistant, topic?: Topic) {
const { assistant } = useAssistant(_assistant.id)
const [activeTopic, setActiveTopic] = useState(_activeTopic || assistant?.topics[0])
const [activeTopic, setActiveTopic] = useState(topic || _activeTopic || assistant?.topics[0])
_activeTopic = activeTopic
@ -28,6 +29,14 @@ export function getTopic(assistant: Assistant, topicId: string) {
return assistant?.topics.find((topic) => topic.id === topicId)
}
export async function getTopicById(topicId: string) {
const assistants = store.getState().assistants.assistants
const topics = assistants.map((assistant) => assistant.topics).flat()
const topic = topics.find((topic) => topic.id === topicId)
const messages = await TopicManager.getTopicMessages(topicId)
return { ...topic, messages } as Topic
}
export class TopicManager {
static async getTopic(id: string) {
return await db.topics.get(id)

View File

@ -141,6 +141,14 @@
"tag.system": "System",
"tag.user": "Mine"
},
"minapp": {
"title": "MinApp"
},
"history": {
"title": "Topics Search",
"search.placeholder": "Search topics or messages...",
"continue_chat": "Continue Chatting"
},
"provider": {
"nvidia": "Nvidia",
"zhinao": "360AI",
@ -290,9 +298,6 @@
"keep_alive_time.placeholder": "Minutes",
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes."
},
"minapp": {
"title": "MinApp"
},
"error": {
"chat.response": "Something went wrong. Please check if you have set your API key in the Settings > Providers",
"backup.file_format": "Backup file format error"

View File

@ -141,6 +141,14 @@
"tag.system": "系统",
"tag.user": "我的"
},
"minapp": {
"title": "小程序"
},
"history": {
"title": "话题搜索",
"search.placeholder": "搜索话题或消息...",
"continue_chat": "继续聊天"
},
"provider": {
"nvidia": "英伟达",
"zhinao": "360智脑",
@ -290,9 +298,6 @@
"keep_alive_time.placeholder": "分钟",
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟"
},
"minapp": {
"title": "小程序"
},
"error": {
"chat.response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥",
"backup.file_format": "备份文件格式错误"

View File

@ -141,6 +141,14 @@
"tag.system": "系統",
"tag.user": "我的"
},
"minapp": {
"title": "小程序"
},
"history": {
"title": "搜尋話題",
"search.placeholder": "搜尋話題或訊息...",
"continue_chat": "繼續聊天"
},
"provider": {
"nvidia": "輝達",
"zhinao": "360智腦",
@ -290,9 +298,6 @@
"keep_alive_time.placeholder": "分鐘",
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。"
},
"minapp": {
"title": "小程序"
},
"error": {
"chat.response": "出現錯誤。如果尚未配置 API 密鑰,請前往設定 > 模型提供者中配置密鑰",
"backup.file_format": "備份文件格式錯誤"

View File

@ -0,0 +1,157 @@
import { ArrowLeftOutlined, EnterOutlined, SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { Message, Topic } from '@renderer/types'
import { Divider, Input } from 'antd'
import { last } from 'lodash'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SearchMessage from './components/SearchMessage'
import SearchResults from './components/SearchResults'
import TopicMessages from './components/TopicMessages'
import TopicsHistory from './components/TopicsHistory'
type Route = 'topics' | 'topic' | 'search' | 'message'
let _search = ''
let _stack: Route[] = ['topics']
let _topic: Topic | undefined
let _message: Message | undefined
const TopicsPage: FC = () => {
const { t } = useTranslation()
const [search, setSearch] = useState(_search)
const [stack, setStack] = useState<Route[]>(_stack)
const [topic, setTopic] = useState<Topic | undefined>(_topic)
const [message, setMessage] = useState<Message | undefined>(_message)
_search = search
_stack = stack
_topic = topic
_message = message
const goBack = () => {
const _stack = [...stack]
const route = _stack.pop()
setStack(_stack)
route === 'search' && setSearch('')
route === 'topic' && setTopic(undefined)
route === 'message' && setMessage(undefined)
}
const onSearch = () => {
setStack(['topics', 'search'])
setTopic(undefined)
}
const onTopicClick = (topic: Topic) => {
setStack((prev) => [...prev, 'topic'])
setTopic(topic)
}
const onMessageClick = (message: Message) => {
setStack(['topics', 'search', 'message'])
setMessage(message)
}
const isShow = (route: Route) => (last(stack) === route ? 'flex' : 'none')
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'flex-start' }}>{t('history.title')} </NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
<Header>
{stack.length > 1 && (
<HeaderLeft>
<MenuIcon onClick={goBack}>
<ArrowLeftOutlined />
</MenuIcon>
</HeaderLeft>
)}
<SearchInput
placeholder={t('history.search.placeholder')}
type="search"
value={search}
allowClear
onChange={(e) => setSearch(e.target.value.trimStart())}
suffix={search.length >= 2 ? <EnterOutlined /> : <SearchOutlined />}
onPressEnter={onSearch}
/>
</Header>
<Divider style={{ margin: 0 }} />
<TopicsHistory keywords={search} onClick={onTopicClick as any} style={{ display: isShow('topics') }} />
<TopicMessages topic={topic} style={{ display: isShow('topic') }} />
<SearchResults
keywords={search}
onMessageClick={onMessageClick}
onTopicClick={onTopicClick}
style={{ display: isShow('search') }}
/>
<SearchMessage message={message} style={{ display: isShow('message') }} />
</ContentContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
height: 100%;
overflow: hidden;
`
const Header = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 8px 20px;
width: 100%;
position: relative;
`
const HeaderLeft = styled.div`
display: flex;
flex-direction: row;
align-items: center;
position: absolute;
top: 8px;
left: 15px;
`
const MenuIcon = styled.div`
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
&:hover {
background-color: var(--color-background-mute);
.anticon {
color: var(--color-text-1);
}
}
`
const SearchInput = styled(Input)`
border-radius: 30px;
width: 800px;
height: 36px;
`
export default TopicsPage

View File

@ -0,0 +1,38 @@
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
import { Message } from '@renderer/types'
import { FC } from 'react'
import styled from 'styled-components'
interface Props extends React.HTMLAttributes<HTMLDivElement> {
message?: Message
}
const SearchMessage: FC<Props> = ({ message, ...props }) => {
if (!message) {
return null
}
return (
<MessagesContainer {...props}>
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
<MessageItem message={message} showMenu={false} />
</ContainerWrapper>
</MessagesContainer>
)
}
const MessagesContainer = styled.div`
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: scroll;
`
const ContainerWrapper = styled.div`
width: 800px;
display: flex;
flex-direction: column;
`
export default SearchMessage

View File

@ -0,0 +1,153 @@
import db from '@renderer/databases'
import { getTopicById } from '@renderer/hooks/useTopic'
import { Message, Topic } from '@renderer/types'
import { List, Typography } from 'antd'
import { useLiveQuery } from 'dexie-react-hooks'
import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
const { Text, Title } = Typography
interface Props extends React.HTMLAttributes<HTMLDivElement> {
keywords: string
onMessageClick: (message: Message) => void
onTopicClick: (topic: Topic) => void
}
const SearchResults: FC<Props> = ({ keywords, onMessageClick, onTopicClick, ...props }) => {
const containerRef = useRef<HTMLDivElement>(null)
const [searchTerms, setSearchTerms] = useState<string[]>(
keywords
.toLowerCase()
.split(' ')
.filter((term) => term.length > 0)
)
const topics = useLiveQuery(() => db.topics.toArray(), [])
const messages = useMemo(
() => (topics || [])?.map((topic) => topic.messages.filter((message) => message.role !== 'user')).flat(),
[topics]
)
const [searchResults, setSearchResults] = useState<{ message: Message; topic: Topic }[]>([])
const [searchStats, setSearchStats] = useState({ count: 0, time: 0 })
const removeMarkdown = (text: string) => {
return text
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/```[\s\S]*?```/g, '')
.replace(/`(.*?)`/g, '$1')
.replace(/#+\s/g, '')
.replace(/<[^>]*>/g, '')
}
const onSearch = useCallback(async () => {
setSearchResults([])
const startTime = performance.now()
const results: { message: Message; topic: Topic }[] = []
const newSearchTerms = keywords
.toLowerCase()
.split(' ')
.filter((term) => term.length > 0)
for (const message of messages) {
const cleanContent = removeMarkdown(message.content.toLowerCase())
if (newSearchTerms.every((term) => cleanContent.includes(term))) {
results.push({ message, topic: await getTopicById(message.topicId)! })
}
}
const endTime = performance.now()
setSearchResults(results)
setSearchStats({
count: results.length,
time: (endTime - startTime) / 1000
})
setSearchTerms(newSearchTerms)
}, [messages, keywords])
const highlightText = (text: string) => {
let highlightedText = removeMarkdown(text)
searchTerms.forEach((term) => {
const regex = new RegExp(term, 'gi')
highlightedText = highlightedText.replace(regex, (match) => `<mark>${match}</mark>`)
})
return <span dangerouslySetInnerHTML={{ __html: highlightedText }} />
}
useEffect(() => {
onSearch()
}, [onSearch])
return (
<Container ref={containerRef} {...props}>
<ContainerWrapper>
{searchResults.length > 0 && (
<SearchStats>
Found {searchStats.count} results in {searchStats.time.toFixed(3)} seconds
</SearchStats>
)}
<List
itemLayout="vertical"
dataSource={searchResults}
pagination={{
pageSize: 10,
onChange: () => {
setTimeout(() => containerRef.current?.scrollTo({ top: 0 }), 0)
}
}}
renderItem={({ message, topic }) => (
<List.Item>
<Title
level={5}
style={{ color: 'var(--color-primary)', cursor: 'pointer' }}
onClick={async () => {
const _topic = await getTopicById(topic.id)
onTopicClick(_topic)
}}>
{topic.name}
</Title>
<div style={{ cursor: 'pointer' }} onClick={() => onMessageClick(message)}>
<Text>{highlightText(message.content)}</Text>
</div>
<SearchResultTime>
<Text type="secondary">{new Date(message.createdAt).toLocaleString()}</Text>
</SearchResultTime>
</List.Item>
)}
/>
<div style={{ minHeight: 30 }}></div>
</ContainerWrapper>
</Container>
)
}
const Container = styled.div`
width: 100%;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: row;
justify-content: center;
`
const ContainerWrapper = styled.div`
width: 800px;
display: flex;
flex-direction: column;
`
const SearchStats = styled.div`
font-size: 13px;
color: var(--color-text-3);
`
const SearchResultTime = styled.div`
margin-top: 10px;
`
export default memo(SearchResults)

View File

@ -0,0 +1,64 @@
import { getAssistantById } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Topic } from '@renderer/types'
import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next'
import { FC } from 'react'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
import { default as MessageItem } from '../../home/Messages/Message'
interface Props extends React.HTMLAttributes<HTMLDivElement> {
topic?: Topic
}
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
const navigate = useNavigate()
const isEmpty = (topic?.messages || []).length === 0
if (!topic) {
return null
}
const onContinueChat = (topic: Topic) => {
const assistant = getAssistantById(topic.assistantId)
navigate('/', { state: { assistant, topic } })
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100)
}
return (
<MessagesContainer {...props}>
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
{topic?.messages.map((message) => (
<div key={message.id}>
<MessageItem message={message} showMenu={false} />
<Divider style={{ margin: '10px auto' }} />
</div>
))}
{isEmpty && <Empty />}
{!isEmpty && (
<Button type="link" onClick={() => onContinueChat(topic)}>
{t('history.continue_chat')}
</Button>
)}
</ContainerWrapper>
</MessagesContainer>
)
}
const MessagesContainer = styled.div`
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: scroll;
`
const ContainerWrapper = styled.div`
width: 800px;
display: flex;
flex-direction: column;
`
export default TopicMessages

View File

@ -0,0 +1,114 @@
import { useAssistants } from '@renderer/hooks/useAssistant'
import { getTopicById } from '@renderer/hooks/useTopic'
import { Topic } from '@renderer/types'
import { Divider, Empty } from 'antd'
import dayjs from 'dayjs'
import { groupBy, isEmpty, orderBy } from 'lodash'
import styled from 'styled-components'
type Props = {
keywords: string
onClick: (topic: Topic) => void
} & React.HTMLAttributes<HTMLDivElement>
const GroupedTopics: React.FC<Props> = ({ keywords, onClick, ...props }) => {
const { assistants } = useAssistants()
const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), 'createdAt', 'desc')
const filteredTopics = topics.filter((topic) => {
return topic.name.toLowerCase().includes(keywords.toLowerCase())
})
const groupedTopics = groupBy(filteredTopics, (topic) => {
return dayjs(topic.createdAt).format('MM/DD')
})
if (isEmpty(filteredTopics)) {
return (
<ListContainer {...props}>
<ContainerWrapper>
<Empty />
</ContainerWrapper>
</ListContainer>
)
}
return (
<ListContainer {...props}>
<ContainerWrapper>
{Object.entries(groupedTopics).map(([date, items]) => (
<ListItem key={date}>
<Date>{date}</Date>
<Divider style={{ margin: '5px 0' }} />
{items.map((topic) => (
<TopicItem
key={topic.id}
onClick={async () => {
const _topic = await getTopicById(topic.id)
onClick(_topic)
}}>
<TopicName>{topic.name.substring(0, 50)}</TopicName>
<TopicDate>{dayjs(topic.updatedAt).format('HH:mm')}</TopicDate>
</TopicItem>
))}
</ListItem>
))}
<div style={{ minHeight: 30 }}></div>
</ContainerWrapper>
</ListContainer>
)
}
GroupedTopics.displayName = 'GroupedTopics'
const ContainerWrapper = styled.div`
width: 800px;
display: flex;
flex-direction: column;
`
const ListContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: scroll;
width: 100%;
align-items: center;
padding-top: 20px;
padding-bottom: 20px;
`
const ListItem = styled.div`
display: flex;
flex-direction: column;
margin-bottom: 15px;
`
const Date = styled.div`
font-size: 26px;
font-weight: bold;
color: var(--color-text-3);
`
const TopicItem = styled.div`
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 30px;
`
const TopicName = styled.div`
font-size: 14px;
color: var(--color-text);
`
const TopicDate = styled.div`
font-size: 14px;
color: var(--color-text-3);
margin-left: 10px;
`
export default GroupedTopics

View File

@ -63,7 +63,7 @@ const Assistants: FC<Props> = ({
key: 'duplicate',
icon: <CopyIcon />,
onClick: async () => {
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic()] }
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic(assistant.id)] }
addAssistant(_assistant)
setActiveAssistant(_assistant)
}

View File

@ -3,6 +3,7 @@ import { useShowAssistants } from '@renderer/hooks/useStore'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import { Assistant } from '@renderer/types'
import { FC, useState } from 'react'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components'
import Chat from './Chat'
@ -13,9 +14,13 @@ let _activeAssistant: Assistant
const HomePage: FC = () => {
const { assistants } = useAssistants()
const [activeAssistant, setActiveAssistant] = useState(_activeAssistant || assistants[0])
const location = useLocation()
const state = location.state
const [activeAssistant, setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
const { activeTopic, setActiveTopic } = useActiveTopic(activeAssistant, state?.topic)
const { showAssistants } = useShowAssistants()
const { activeTopic, setActiveTopic } = useActiveTopic(activeAssistant)
_activeAssistant = activeAssistant

View File

@ -123,11 +123,11 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
}
const addNewTopic = useCallback(() => {
const topic = getDefaultTopic()
const topic = getDefaultTopic(assistant.id)
addTopic(topic)
setActiveTopic(topic)
db.topics.add({ id: topic.id, messages: [] })
}, [addTopic, setActiveTopic])
}, [addTopic, assistant.id, setActiveTopic])
const clearTopic = async () => {
if (generating) {

View File

@ -1,23 +1,15 @@
import UserPopup from '@renderer/components/Popups/UserPopup'
import { FONT_FAMILY } from '@renderer/config/constant'
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { startMinAppById } from '@renderer/config/minapps'
import { getModelLogo } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import useAvatar from '@renderer/hooks/useAvatar'
import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { Message } from '@renderer/types'
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
import { Avatar, Divider } from 'antd'
import dayjs from 'dayjs'
import { upperFirst } from 'lodash'
import { FC, memo, useCallback, useMemo } from 'react'
import { Divider } from 'antd'
import { FC, memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import MessageContent from './MessageContent'
import MessageHeader from './MessageHeader'
import MessageMenubar from './MessageMenubar'
import MessgeTokens from './MessageTokens'
@ -26,45 +18,26 @@ interface Props {
index?: number
total?: number
lastMessage?: boolean
showMenu?: boolean
onEditMessage?: (message: Message) => void
onDeleteMessage?: (message: Message) => void
}
const MessageItem: FC<Props> = ({ message, index, lastMessage, onEditMessage, onDeleteMessage }) => {
const avatar = useAvatar()
const MessageItem: FC<Props> = ({ message, index, lastMessage, showMenu = true, onEditMessage, onDeleteMessage }) => {
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(message.modelId)
const { userName, showMessageDivider, messageFont, fontSize } = useSettings()
const { theme } = useTheme()
const { showMessageDivider, messageFont, fontSize } = useSettings()
const isLastMessage = lastMessage || index === 0
const isAssistantMessage = message.role === 'assistant'
const getUserName = useCallback(() => {
if (isLocalAi && message.role !== 'user') return APP_NAME
if (message.role === 'assistant') return upperFirst(model?.name || model?.id)
return userName || t('common.you')
}, [message.role, model?.id, model?.name, t, userName])
const fontFamily = useMemo(() => {
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
}, [messageFont])
const messageBorder = showMessageDivider ? undefined : 'none'
const avatarSource = useMemo(() => {
if (isLocalAi) return AppLogo
return message.modelId ? getModelLogo(message.modelId) : undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message.modelId, theme])
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
if (message.type === 'clear') {
return (
<Divider dashed style={{ padding: '0 20px' }} plain>
@ -75,38 +48,10 @@ const MessageItem: FC<Props> = ({ message, index, lastMessage, onEditMessage, on
return (
<MessageContainer key={message.id} className="message">
<MessageHeader>
<AvatarWrapper>
{isAssistantMessage ? (
<Avatar
src={avatarSource}
size={35}
style={{
borderRadius: '20%',
cursor: 'pointer',
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
filter: theme === 'dark' ? 'invert(0.05)' : undefined
}}
onClick={showMiniApp}>
{avatarName}
</Avatar>
) : (
<Avatar
src={avatar}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
<UserWrap>
<UserName>{username}</UserName>
<MessageTime>{dayjs(message.createdAt).format('MM/DD HH:mm')}</MessageTime>
</UserWrap>
</AvatarWrapper>
</MessageHeader>
<MessageHeader message={message} assistant={assistant} model={model} />
<MessageContentContainer style={{ fontFamily, fontSize }}>
<MessageContent message={message} model={model} />
{!lastMessage && (
{!lastMessage && showMenu && (
<MessageFooter style={{ border: messageBorder, flexDirection: isLastMessage ? 'row-reverse' : undefined }}>
<MessgeTokens message={message} />
<MessageMenubar
@ -145,38 +90,6 @@ const MessageContainer = styled.div`
}
`
const MessageHeader = styled.div`
margin-right: 10px;
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: 4px;
justify-content: space-between;
`
const AvatarWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`
const UserWrap = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
margin-left: 12px;
`
const UserName = styled.div`
font-size: 14px;
font-weight: 600;
`
const MessageTime = styled.div`
font-size: 12px;
color: var(--color-text-3);
`
const MessageContentContainer = styled.div`
display: flex;
flex: 1;

View File

@ -0,0 +1,114 @@
import UserPopup from '@renderer/components/Popups/UserPopup'
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { startMinAppById } from '@renderer/config/minapps'
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 { Assistant, Message, Model } from '@renderer/types'
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
import { Avatar } from 'antd'
import dayjs from 'dayjs'
import { upperFirst } from 'lodash'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
message: Message
assistant: Assistant
model?: Model
}
const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
const avatar = useAvatar()
const { theme } = useTheme()
const { userName } = useSettings()
const { t } = useTranslation()
const isAssistantMessage = message.role === 'assistant'
const avatarSource = useMemo(() => {
if (isLocalAi) return AppLogo
return message.modelId ? getModelLogo(message.modelId) : undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message.modelId, theme])
const getUserName = useCallback(() => {
if (isLocalAi && message.role !== 'user') return APP_NAME
if (message.role === 'assistant') return upperFirst(model?.name || model?.id)
return userName || t('common.you')
}, [message.role, model?.id, model?.name, t, userName])
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
return (
<Container>
<AvatarWrapper>
{isAssistantMessage ? (
<Avatar
src={avatarSource}
size={35}
style={{
borderRadius: '20%',
cursor: 'pointer',
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
filter: theme === 'dark' ? 'invert(0.05)' : undefined
}}
onClick={showMiniApp}>
{avatarName}
</Avatar>
) : (
<Avatar
src={avatar}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
<UserWrap>
<UserName>{username}</UserName>
<MessageTime>{dayjs(message.createdAt).format('MM/DD HH:mm')}</MessageTime>
</UserWrap>
</AvatarWrapper>
</Container>
)
}
const Container = styled.div`
margin-right: 10px;
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: 4px;
justify-content: space-between;
`
const AvatarWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`
const UserWrap = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
margin-left: 12px;
`
const UserName = styled.div`
font-size: 14px;
font-weight: 600;
`
const MessageTime = styled.div`
font-size: 12px;
color: var(--color-text-3);
`
export default MessageHeader

View File

@ -164,7 +164,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
} as Message)
}),
EventEmitter.on(EVENT_NAMES.NEW_BRANCH, async (index: number) => {
const newTopic = getDefaultTopic()
const newTopic = getDefaultTopic(assistant.id)
newTopic.name = topic.name
const branchMessages = take(messages, messages.length - index)

View File

@ -33,13 +33,13 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveTopic }) => {
const { t } = useTranslation()
const addNewTopic = useCallback(() => {
const topic = getDefaultTopic()
const topic = getDefaultTopic(assistant.id)
addTopic(topic)
setActiveTopic(topic)
db.topics.add({ id: topic.id, messages: [] })
window.message.success({ content: t('message.topic.added'), key: 'topic-added' })
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
}, [addTopic, setActiveTopic, t])
}, [addTopic, assistant.id, setActiveTopic, t])
return (
<Navbar>

View File

@ -11,7 +11,7 @@ export function getDefaultAssistant(): Assistant {
id: 'default',
name: i18n.t('chat.default.name'),
prompt: '',
topics: [getDefaultTopic()]
topics: [getDefaultTopic('default')]
}
}
@ -19,9 +19,10 @@ export function getDefaultAssistantSettings() {
return store.getState().assistants.defaultAssistant.settings
}
export function getDefaultTopic(): Topic {
export function getDefaultTopic(assistantId: string): Topic {
return {
id: uuid(),
assistantId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
name: i18n.t('chat.default.topic.name'),
@ -86,10 +87,12 @@ export const getAssistantSettings = (assistant: Assistant): AssistantSettings =>
}
export function covertAgentToAssistant(agent: Agent): Assistant {
const id = agent.group === 'system' ? uuid() : String(agent.id)
return {
...getDefaultAssistant(),
...agent,
id: agent.group === 'system' ? uuid() : String(agent.id),
id,
topics: [getDefaultTopic(id)],
name: getAssistantNameWithAgent(agent),
settings: getDefaultAssistantSettings()
}
@ -129,3 +132,8 @@ export function syncAgentToAssistant(agent: Agent) {
)
}
}
export function getAssistantById(id: string) {
const assistants = store.getState().assistants.assistants
return assistants.find((a) => a.id === id)
}

View File

@ -94,7 +94,7 @@ const assistantsSlice = createSlice({
assistant.topics.forEach((topic) => TopicManager.removeTopic(topic.id))
return {
...assistant,
topics: [getDefaultTopic()]
topics: [getDefaultTopic(assistant.id)]
}
}
return assistant

View File

@ -22,7 +22,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 28,
version: 29,
blacklist: ['runtime'],
migrate
},

View File

@ -489,6 +489,21 @@ const migrateConfig = {
]
}
}
},
'29': (state: RootState) => {
return {
...state,
assistants: {
...state.assistants,
assistants: state.assistants.assistants.map((assistant) => {
assistant.topics = assistant.topics.map((topic) => ({
...topic,
assistantId: assistant.id
}))
return assistant
})
}
}
}
}

View File

@ -36,6 +36,7 @@ export type Message = {
export type Topic = {
id: string
assistantId: string
name: string
createdAt: string
updatedAt: string