feat: add topics history
This commit is contained in:
parent
2da3a3f010
commit
4cc140e4f2
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -71,7 +71,7 @@ export function useDefaultAssistant() {
|
||||
return {
|
||||
defaultAssistant: {
|
||||
...defaultAssistant,
|
||||
topics: [getDefaultTopic()]
|
||||
topics: [getDefaultTopic(defaultAssistant.id)]
|
||||
},
|
||||
updateDefaultAssistant: (assistant: Assistant) => dispatch(updateDefaultAssistant({ assistant }))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": "备份文件格式错误"
|
||||
|
||||
@ -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": "備份文件格式錯誤"
|
||||
|
||||
157
src/renderer/src/pages/history/HistoryPage.tsx
Normal file
157
src/renderer/src/pages/history/HistoryPage.tsx
Normal 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
|
||||
38
src/renderer/src/pages/history/components/SearchMessage.tsx
Normal file
38
src/renderer/src/pages/history/components/SearchMessage.tsx
Normal 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
|
||||
153
src/renderer/src/pages/history/components/SearchResults.tsx
Normal file
153
src/renderer/src/pages/history/components/SearchResults.tsx
Normal 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)
|
||||
64
src/renderer/src/pages/history/components/TopicMessages.tsx
Normal file
64
src/renderer/src/pages/history/components/TopicMessages.tsx
Normal 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
|
||||
114
src/renderer/src/pages/history/components/TopicsHistory.tsx
Normal file
114
src/renderer/src/pages/history/components/TopicsHistory.tsx
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
114
src/renderer/src/pages/home/Messages/MessageHeader.tsx
Normal file
114
src/renderer/src/pages/home/Messages/MessageHeader.tsx
Normal 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
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -22,7 +22,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 28,
|
||||
version: 29,
|
||||
blacklist: ['runtime'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ export type Message = {
|
||||
|
||||
export type Topic = {
|
||||
id: string
|
||||
assistantId: string
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user