diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9028c8c3..48050d3f 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -13,6 +13,7 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'off', 'unused-imports/no-unused-imports': 'error', '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', + 'react/prop-types': 'off', 'sort-imports': [ 'error', { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9cdb9f73..b535d17b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,29 +7,25 @@ import Sidebar from './components/app/Sidebar' import AppsPage from './pages/apps/AppsPage' import HomePage from './pages/home/HomePage' 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 { return ( - + - - - - } /> - } /> - } /> - - + + + + + } /> + } /> + } /> + + + diff --git a/src/renderer/src/components/Layout/index.ts b/src/renderer/src/components/Layout/index.ts new file mode 100644 index 00000000..e9246dca --- /dev/null +++ b/src/renderer/src/components/Layout/index.ts @@ -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` + 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)` + 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)` + justify-content: center; + align-items: center; +` + +export const HStack = styled(Stack)` + flex-direction: row; +` + +export const HSpaceBetweenStack = styled(HStack)` + justify-content: space-between; +` + +export const VStack = styled(Stack)` + 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` + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + flex: 1; + padding: ${(p) => p.padding ?? '0 18px'}; +` diff --git a/src/renderer/src/components/Popups/PromptPopup.tsx b/src/renderer/src/components/Popups/PromptPopup.tsx new file mode 100644 index 00000000..db6969d5 --- /dev/null +++ b/src/renderer/src/components/Popups/PromptPopup.tsx @@ -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 = ({ + 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 ( + + {message} + setValue(e.target.value)} + allowClear + autoFocus + onPressEnter={onOk} + {...inputProps} + /> + + ) +} + +export default class PromptPopup { + static topviewId = 0 + static hide() { + TopView.hide(this.topviewId) + } + static show(props: PromptPopupShowParams) { + return new Promise((resolve) => { + this.topviewId = TopView.show( + { + resolve(v) + this.hide() + }} + /> + ) + }) + } +} diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx new file mode 100644 index 00000000..1a317235 --- /dev/null +++ b/src/renderer/src/components/TopView/index.tsx @@ -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 = ({ children }) => { + const [elements, setElements] = useState([]) + + 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 && ( +
+
+ {elements.map(({ element: Element, key }) => + typeof Element === 'function' ? : Element + )} +
+ )} + + ) +} + +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 diff --git a/src/renderer/src/config/antd.ts b/src/renderer/src/config/antd.ts new file mode 100644 index 00000000..fbdc3381 --- /dev/null +++ b/src/renderer/src/config/antd.ts @@ -0,0 +1,9 @@ +import { theme, ThemeConfig } from 'antd' + +export const AntdThemeConfig: ThemeConfig = { + token: { + colorPrimary: '#00b96b', + borderRadius: 5 + }, + algorithm: [theme.darkAlgorithm, theme.compactAlgorithm] +} diff --git a/src/renderer/src/hooks/useAgents.ts b/src/renderer/src/hooks/useAgents.ts index fb33550e..5fc013b9 100644 --- a/src/renderer/src/hooks/useAgents.ts +++ b/src/renderer/src/hooks/useAgents.ts @@ -2,6 +2,7 @@ import { useAppDispatch, useAppSelector } from '@renderer/store' import { addTopic as _addTopic, removeTopic as _removeTopic, + updateTopic as _updateTopic, addAgent, removeAgent, updateAgent @@ -34,10 +35,13 @@ export function useAgent(id: string) { return { agent, addTopic: (topic: Topic) => { - dispatch(_addTopic({ agentId: agent?.id!, topic })) + dispatch(_addTopic({ agentId: agent?.id, 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 })) } } } diff --git a/src/renderer/src/pages/home/components/Chat/Chat.tsx b/src/renderer/src/pages/home/components/Chat/Chat.tsx index 0774ba7b..cfe574f6 100644 --- a/src/renderer/src/pages/home/components/Chat/Chat.tsx +++ b/src/renderer/src/pages/home/components/Chat/Chat.tsx @@ -1,5 +1,5 @@ import { Agent } from '@renderer/types' -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import styled from 'styled-components' import Inputbar from './Inputbar' import Conversations from './Conversations' @@ -15,6 +15,10 @@ const Chat: FC = (props) => { const { agent } = useAgent(props.agent.id) const [activeTopic, setActiveTopic] = useState(agent.topics[0]) + useEffect(() => { + setActiveTopic(agent.topics[0]) + }, [agent]) + if (!agent) { return null } diff --git a/src/renderer/src/pages/home/components/Chat/Conversations.tsx b/src/renderer/src/pages/home/components/Chat/Conversations.tsx index be482271..f4935bb6 100644 --- a/src/renderer/src/pages/home/components/Chat/Conversations.tsx +++ b/src/renderer/src/pages/home/components/Chat/Conversations.tsx @@ -18,22 +18,16 @@ const Conversations: FC = ({ agent, topic }) => { const [messages, setMessages] = useState([]) const [lastMessage, setLastMessage] = useState(null) - const { id: topicId } = topic - const onSendMessage = useCallback( (message: Message) => { const _messages = [...messages, message] setMessages(_messages) - - const topic = { - id: topicId, - name: 'Default Topic', + localforage.setItem(`topic:${topic.id}`, { + ...topic, messages: _messages - } - - localforage.setItem(`topic:${topicId}`, topic) + }) }, - [topicId, messages] + [messages, topic] ) const fetchChatCompletion = useCallback( @@ -49,7 +43,7 @@ const Conversations: FC = ({ agent, topic }) => { role: 'agent', content: '', agentId: agent.id, - topicId, + topicId: topic.id, createdAt: 'now' } @@ -66,7 +60,7 @@ const Conversations: FC = ({ agent, topic }) => { return _message }, - [agent.id, topicId] + [agent.id, topic] ) useEffect(() => { @@ -85,10 +79,10 @@ const Conversations: FC = ({ agent, topic }) => { useEffect(() => { runAsyncFunction(async () => { - const topic = await localforage.getItem(`topic:${topicId}`) - setMessages(topic ? topic.messages : []) + const _topic = await localforage.getItem(`topic:${topic.id}`) + setMessages(_topic ? _topic.messages : []) }) - }, [topicId]) + }, [topic]) useEffect(() => hljs.highlightAll()) diff --git a/src/renderer/src/pages/home/components/Chat/TopicList.tsx b/src/renderer/src/pages/home/components/Chat/TopicList.tsx index 2f0257e9..1ede5f72 100644 --- a/src/renderer/src/pages/home/components/Chat/TopicList.tsx +++ b/src/renderer/src/pages/home/components/Chat/TopicList.tsx @@ -1,6 +1,9 @@ +import PromptPopup from '@renderer/components/Popups/PromptPopup' +import { useAgent } from '@renderer/hooks/useAgents' import { useShowRightSidebar } from '@renderer/hooks/useStore' 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' interface Props { @@ -11,6 +14,40 @@ interface Props { const TopicList: FC = ({ agent, activeTopic, setActiveTopic }) => { const { showRightSidebar } = useShowRightSidebar() + const currentTopic = useRef(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) { return null @@ -18,13 +55,17 @@ const TopicList: FC = ({ agent, activeTopic, setActiveTopic }) => { return ( + Topics ({agent.topics.length}) {agent.topics.map((topic) => ( - setActiveTopic(topic)}> - {topic.name} - + onOpenChange={(open) => open && (currentTopic.current = topic)}> + setActiveTopic(topic)}> + {topic.name} + + ))} ) @@ -42,7 +83,7 @@ const Container = styled.div` ` const TopicListItem = styled.div` - padding: 8px 15px; + padding: 8px 10px; margin-bottom: 5px; cursor: pointer; 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 diff --git a/src/renderer/src/store/agents.ts b/src/renderer/src/store/agents.ts index 6362c34b..1665884a 100644 --- a/src/renderer/src/store/agents.ts +++ b/src/renderer/src/store/agents.ts @@ -44,10 +44,20 @@ const agentsSlice = createSlice({ } : 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