feat: rename topic

This commit is contained in:
kangfenmao 2024-07-02 10:31:03 +08:00
parent 4b17e4cd16
commit ceb816bc2a
11 changed files with 416 additions and 44 deletions

View File

@ -13,6 +13,7 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off', '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'react/prop-types': 'off',
'sort-imports': [ 'sort-imports': [
'error', 'error',
{ {

View File

@ -7,29 +7,25 @@ import Sidebar from './components/app/Sidebar'
import AppsPage from './pages/apps/AppsPage' import AppsPage from './pages/apps/AppsPage'
import HomePage from './pages/home/HomePage' import HomePage from './pages/home/HomePage'
import SettingsPage from './pages/settings/SettingsPage' import SettingsPage from './pages/settings/SettingsPage'
import { ConfigProvider, theme } from 'antd' import { ConfigProvider } from 'antd'
import TopViewContainer from './components/TopView'
import { AntdThemeConfig } from './config/antd'
function App(): JSX.Element { function App(): JSX.Element {
return ( return (
<ConfigProvider <ConfigProvider theme={AntdThemeConfig}>
theme={{
token: {
colorPrimary: '#00b96b',
borderRadius: 5,
colorBgContainer: '#f6ffed'
},
algorithm: [theme.darkAlgorithm, theme.compactAlgorithm]
}}>
<Provider store={store}> <Provider store={store}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
<BrowserRouter> <TopViewContainer>
<Sidebar /> <BrowserRouter>
<Routes> <Sidebar />
<Route path="" element={<HomePage />} /> <Routes>
<Route path="/apps" element={<AppsPage />} /> <Route path="" element={<HomePage />} />
<Route path="/settings/*" element={<SettingsPage />} /> <Route path="/apps" element={<AppsPage />} />
</Routes> <Route path="/settings/*" element={<SettingsPage />} />
</BrowserRouter> </Routes>
</BrowserRouter>
</TopViewContainer>
</PersistGate> </PersistGate>
</Provider> </Provider>
</ConfigProvider> </ConfigProvider>

View File

@ -0,0 +1,167 @@
import styled from 'styled-components'
interface ContainerProps {
padding?: string
}
type PxValue = number | string
export interface BoxProps {
width?: PxValue
height?: PxValue
w?: PxValue
h?: PxValue
color?: string
background?: string
flex?: string | number
position?: string
left?: PxValue
top?: PxValue
right?: PxValue
bottom?: PxValue
opacity?: string | number
borderRadius?: PxValue
border?: string
gap?: PxValue
mt?: PxValue
marginTop?: PxValue
mb?: PxValue
marginBottom?: PxValue
ml?: PxValue
marginLeft?: PxValue
mr?: PxValue
marginRight?: PxValue
m?: string
margin?: string
pt?: PxValue
paddingTop?: PxValue
pb?: PxValue
paddingBottom?: PxValue
pl?: PxValue
paddingLeft?: PxValue
pr?: PxValue
paddingRight?: PxValue
p?: string
padding?: string
}
export interface StackProps extends BoxProps {
justifyContent?: 'center' | 'flex-start' | 'flex-end' | 'space-between'
alignItems?: 'center' | 'flex-start' | 'flex-end' | 'space-between'
flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse'
}
export interface ButtonProps extends StackProps {
color?: string
isDisabled?: boolean
isLoading?: boolean
background?: string
border?: string
fontSize?: string
}
const cssRegex = /(px|vw|vh|%|auto)$/g
const getElementValue = (value?: PxValue) => {
if (!value) {
return value
}
if (typeof value === 'number') {
return value + 'px'
}
if (value.match(cssRegex)) {
return value
}
return value + 'px'
}
export const Box = styled.div<BoxProps>`
width: ${(props) => (props.width || props.w ? getElementValue(props.width ?? props.w) : 'auto')};
height: ${(props) => (props.height || props.h ? getElementValue(props.height || props.h) : 'auto')};
color: ${(props) => props.color || 'default'};
background: ${(props) => props.background || 'default'};
flex: ${(props) => props.flex || 'none'};
position: ${(props) => props.position || 'default'};
left: ${(props) => getElementValue(props.left) || 'auto'};
right: ${(props) => getElementValue(props.right) || 'auto'};
bottom: ${(props) => getElementValue(props.bottom) || 'auto'};
top: ${(props) => getElementValue(props.top) || 'auto'};
gap: ${(p) => (p.gap ? getElementValue(p.gap) : 0)};
opacity: ${(props) => props.opacity ?? 1};
border-radius: ${(props) => getElementValue(props.borderRadius) || 0};
box-sizing: border-box;
border: ${(props) => props?.border || 'none'};
gap: ${(p) => (p.gap ? getElementValue(p.gap) : 0)};
margin: ${(props) => (props.m || props.margin ? props.m ?? props.margin : 'none')};
margin-top: ${(props) => (props.mt || props.marginTop ? getElementValue(props.mt || props.marginTop) : 'default')};
margin-bottom: ${(props) =>
props.mb || props.marginBottom ? getElementValue(props.mb ?? props.marginBottom) : 'default'};
margin-left: ${(props) => (props.ml || props.marginLeft ? getElementValue(props.ml ?? props.marginLeft) : 'default')};
margin-right: ${(props) =>
props.mr || props.marginRight ? getElementValue(props.mr ?? props.marginRight) : 'default'};
padding: ${(props) => (props.p || props.padding ? props.p ?? props.padding : 'none')};
padding-top: ${(props) => (props.pt || props.paddingTop ? getElementValue(props.pt ?? props.paddingTop) : 'auto')};
padding-bottom: ${(props) =>
props.pb || props.paddingBottom ? getElementValue(props.pb ?? props.paddingBottom) : 'auto'};
padding-left: ${(props) => (props.pl || props.paddingLeft ? getElementValue(props.pl ?? props.paddingLeft) : 'auto')};
padding-right: ${(props) =>
props.pr || props.paddingRight ? getElementValue(props.pr ?? props.paddingRight) : 'auto'};
`
export const Stack = styled(Box)<StackProps>`
display: flex;
justify-content: ${(props) => props.justifyContent ?? 'flex-start'};
align-items: ${(props) => props.alignItems ?? 'flex-start'};
flex-direction: ${(props) => props.flexDirection ?? 'row'};
`
export const Center = styled(Stack)<StackProps>`
justify-content: center;
align-items: center;
`
export const HStack = styled(Stack)<StackProps>`
flex-direction: row;
`
export const HSpaceBetweenStack = styled(HStack)<StackProps>`
justify-content: space-between;
`
export const VStack = styled(Stack)<StackProps>`
flex-direction: column;
`
export const BaseTypography = styled(Box)<{
fontSize?: number
lineHeight?: string
fontWeigth?: number | string
color?: string
textAlign?: string
}>`
font-size: ${(props) => (props.fontSize ? getElementValue(props.fontSize) : '16px')};
line-height: ${(props) => (props.lineHeight ? getElementValue(props.lineHeight) : 'normal')};
font-weight: ${(props) => props.fontWeigth || 'normal'};
color: ${(props) => props.color || '#fff'};
text-align: ${(props) => props.textAlign || 'left'};
`
export const TypographyNormal = styled(BaseTypography)`
font-family: 'Poppins';
`
export const TypographyBold = styled(BaseTypography)`
font-family: 'Poppins Bold';
`
export const Container = styled.main<ContainerProps>`
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
flex: 1;
padding: ${(p) => p.padding ?? '0 18px'};
`

View File

@ -0,0 +1,75 @@
import { Input, InputProps, Modal } from 'antd'
import { useState } from 'react'
import { TopView } from '../TopView'
import { Box } from '../Layout'
interface PromptPopupShowParams {
title: string
message: string
defaultValue?: string
inputPlaceholder?: string
inputProps?: InputProps
}
interface Props extends PromptPopupShowParams {
resolve: (value: string) => void
}
const PromptPopupContainer: React.FC<Props> = ({
title,
message,
defaultValue = '',
inputPlaceholder = '',
inputProps = {},
resolve
}) => {
const [value, setValue] = useState(defaultValue)
const [open, setOpen] = useState(true)
const onOk = () => {
setOpen(false)
}
const handleCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(value)
}
return (
<Modal title={title} open={open} onOk={onOk} onCancel={handleCancel} afterClose={onClose}>
<Box mb={8}>{message}</Box>
<Input
placeholder={inputPlaceholder}
value={value}
onChange={(e) => setValue(e.target.value)}
allowClear
autoFocus
onPressEnter={onOk}
{...inputProps}
/>
</Modal>
)
}
export default class PromptPopup {
static topviewId = 0
static hide() {
TopView.hide(this.topviewId)
}
static show(props: PromptPopupShowParams) {
return new Promise<string>((resolve) => {
this.topviewId = TopView.show(
<PromptPopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>
)
})
}
}

View File

@ -0,0 +1,64 @@
import { findIndex, pullAt } from 'lodash'
import React, { useState } from 'react'
let id = 0
let onPop = () => {}
let onShow = ({ element, key }: { element: React.FC | React.ReactNode; key: number }) => {}
let onHide = ({ key }: { key: number }) => {}
interface Props {
children?: React.ReactNode
}
type ElementItem = {
key: number
element: React.FC | React.ReactNode
}
const TopViewContainer: React.FC<Props> = ({ children }) => {
const [elements, setElements] = useState<ElementItem[]>([])
onPop = () => {
const views = [...elements]
views.pop()
setElements(views)
}
onShow = ({ element, key }: { element: React.FC | React.ReactNode; key: number }) => {
setElements(elements.concat([{ element, key }]))
}
onHide = ({ key }: { key: number }) => {
const views = [...elements]
pullAt(views, findIndex(views, { key }))
setElements(views)
}
return (
<>
{children}
{elements.length > 0 && (
<div style={{ display: 'flex', flex: 1, position: 'absolute', width: '100%', height: '100%' }}>
<div style={{ position: 'absolute', width: '100%', height: '100%' }} onClick={onPop} />
{elements.map(({ element: Element, key }) =>
typeof Element === 'function' ? <Element key={`TOPVIEW_${key}`} /> : Element
)}
</div>
)}
</>
)
}
export const TopView = {
show: (element: React.FC | React.ReactNode) => {
id = id + 1
onShow({ element, key: id })
return id
},
hide: (key: number) => {
onHide({ key })
},
pop: onPop
}
export default TopViewContainer

View File

@ -0,0 +1,9 @@
import { theme, ThemeConfig } from 'antd'
export const AntdThemeConfig: ThemeConfig = {
token: {
colorPrimary: '#00b96b',
borderRadius: 5
},
algorithm: [theme.darkAlgorithm, theme.compactAlgorithm]
}

View File

@ -2,6 +2,7 @@ import { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
addTopic as _addTopic, addTopic as _addTopic,
removeTopic as _removeTopic, removeTopic as _removeTopic,
updateTopic as _updateTopic,
addAgent, addAgent,
removeAgent, removeAgent,
updateAgent updateAgent
@ -34,10 +35,13 @@ export function useAgent(id: string) {
return { return {
agent, agent,
addTopic: (topic: Topic) => { addTopic: (topic: Topic) => {
dispatch(_addTopic({ agentId: agent?.id!, topic })) dispatch(_addTopic({ agentId: agent?.id, topic }))
}, },
removeTopic: (topic: Topic) => { removeTopic: (topic: Topic) => {
dispatch(_removeTopic({ agentId: agent?.id!, topic })) dispatch(_removeTopic({ agentId: agent?.id, topic }))
},
updateTopic: (topic: Topic) => {
dispatch(_updateTopic({ agentId: agent?.id, topic }))
} }
} }
} }

View File

@ -1,5 +1,5 @@
import { Agent } from '@renderer/types' import { Agent } from '@renderer/types'
import { FC, useState } from 'react' import { FC, useEffect, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Inputbar from './Inputbar' import Inputbar from './Inputbar'
import Conversations from './Conversations' import Conversations from './Conversations'
@ -15,6 +15,10 @@ const Chat: FC<Props> = (props) => {
const { agent } = useAgent(props.agent.id) const { agent } = useAgent(props.agent.id)
const [activeTopic, setActiveTopic] = useState(agent.topics[0]) const [activeTopic, setActiveTopic] = useState(agent.topics[0])
useEffect(() => {
setActiveTopic(agent.topics[0])
}, [agent])
if (!agent) { if (!agent) {
return null return null
} }

View File

@ -18,22 +18,16 @@ const Conversations: FC<Props> = ({ agent, topic }) => {
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [lastMessage, setLastMessage] = useState<Message | null>(null) const [lastMessage, setLastMessage] = useState<Message | null>(null)
const { id: topicId } = topic
const onSendMessage = useCallback( const onSendMessage = useCallback(
(message: Message) => { (message: Message) => {
const _messages = [...messages, message] const _messages = [...messages, message]
setMessages(_messages) setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, {
const topic = { ...topic,
id: topicId,
name: 'Default Topic',
messages: _messages messages: _messages
} })
localforage.setItem<Topic>(`topic:${topicId}`, topic)
}, },
[topicId, messages] [messages, topic]
) )
const fetchChatCompletion = useCallback( const fetchChatCompletion = useCallback(
@ -49,7 +43,7 @@ const Conversations: FC<Props> = ({ agent, topic }) => {
role: 'agent', role: 'agent',
content: '', content: '',
agentId: agent.id, agentId: agent.id,
topicId, topicId: topic.id,
createdAt: 'now' createdAt: 'now'
} }
@ -66,7 +60,7 @@ const Conversations: FC<Props> = ({ agent, topic }) => {
return _message return _message
}, },
[agent.id, topicId] [agent.id, topic]
) )
useEffect(() => { useEffect(() => {
@ -85,10 +79,10 @@ const Conversations: FC<Props> = ({ agent, topic }) => {
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
const topic = await localforage.getItem<Topic>(`topic:${topicId}`) const _topic = await localforage.getItem<Topic>(`topic:${topic.id}`)
setMessages(topic ? topic.messages : []) setMessages(_topic ? _topic.messages : [])
}) })
}, [topicId]) }, [topic])
useEffect(() => hljs.highlightAll()) useEffect(() => hljs.highlightAll())

View File

@ -1,6 +1,9 @@
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { useAgent } from '@renderer/hooks/useAgents'
import { useShowRightSidebar } from '@renderer/hooks/useStore' import { useShowRightSidebar } from '@renderer/hooks/useStore'
import { Agent, Topic } from '@renderer/types' import { Agent, Topic } from '@renderer/types'
import { FC } from 'react' import { Dropdown, MenuProps } from 'antd'
import { FC, useRef } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
@ -11,6 +14,40 @@ interface Props {
const TopicList: FC<Props> = ({ agent, activeTopic, setActiveTopic }) => { const TopicList: FC<Props> = ({ agent, activeTopic, setActiveTopic }) => {
const { showRightSidebar } = useShowRightSidebar() const { showRightSidebar } = useShowRightSidebar()
const currentTopic = useRef<Topic | null>(null)
const { removeTopic, updateTopic } = useAgent(agent.id)
const items: MenuProps['items'] = [
{
label: 'AI Rename',
key: 'ai-rename'
},
{
label: 'Rename',
key: 'rename',
async onClick() {
const name = await PromptPopup.show({
title: 'Rename Topic',
message: 'Please enter the new name',
defaultValue: currentTopic.current?.name || ''
})
if (name && currentTopic.current && currentTopic.current?.name !== name) {
updateTopic({ ...currentTopic.current, name })
}
}
},
{
label: 'Delete',
danger: true,
key: 'delete',
onClick() {
if (agent.topics.length === 1) return
currentTopic.current && removeTopic(currentTopic.current)
currentTopic.current = null
setActiveTopic(agent.topics[0])
}
}
]
if (!showRightSidebar) { if (!showRightSidebar) {
return null return null
@ -18,13 +55,17 @@ const TopicList: FC<Props> = ({ agent, activeTopic, setActiveTopic }) => {
return ( return (
<Container className={showRightSidebar ? '' : 'collapsed'}> <Container className={showRightSidebar ? '' : 'collapsed'}>
<TopicTitle>Topics ({agent.topics.length})</TopicTitle>
{agent.topics.map((topic) => ( {agent.topics.map((topic) => (
<TopicListItem <Dropdown
menu={{ items }}
trigger={['contextMenu']}
key={topic.id} key={topic.id}
className={topic.id === activeTopic?.id ? 'active' : ''} onOpenChange={(open) => open && (currentTopic.current = topic)}>
onClick={() => setActiveTopic(topic)}> <TopicListItem className={topic.id === activeTopic?.id ? 'active' : ''} onClick={() => setActiveTopic(topic)}>
{topic.name} {topic.name}
</TopicListItem> </TopicListItem>
</Dropdown>
))} ))}
</Container> </Container>
) )
@ -42,7 +83,7 @@ const Container = styled.div`
` `
const TopicListItem = styled.div` const TopicListItem = styled.div`
padding: 8px 15px; padding: 8px 10px;
margin-bottom: 5px; margin-bottom: 5px;
cursor: pointer; cursor: pointer;
border-radius: 5px; border-radius: 5px;
@ -55,4 +96,11 @@ const TopicListItem = styled.div`
} }
` `
const TopicTitle = styled.div`
font-weight: bold;
margin-bottom: 5px;
font-size: 14px;
color: var(--color-text-1);
`
export default TopicList export default TopicList

View File

@ -44,10 +44,20 @@ const agentsSlice = createSlice({
} }
: agent : agent
) )
},
updateTopic: (state, action: PayloadAction<{ agentId: string; topic: Topic }>) => {
state.agents = state.agents.map((agent) =>
agent.id === action.payload.agentId
? {
...agent,
topics: agent.topics.map((topic) => (topic.id === action.payload.topic.id ? action.payload.topic : topic))
}
: agent
)
} }
} }
}) })
export const { addAgent, removeAgent, updateAgent, addTopic, removeTopic } = agentsSlice.actions export const { addAgent, removeAgent, updateAgent, addTopic, removeTopic, updateTopic } = agentsSlice.actions
export default agentsSlice.reducer export default agentsSlice.reducer