Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AI-Powered Selection Actions #410

Merged
merged 8 commits into from
Apr 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-seals-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mexit-webapp': patch
---

AI-Powered Selection Actions
4 changes: 2 additions & 2 deletions apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@floating-ui/react": "^0.18.0",
"@floating-ui/react-dom-interactions": "^0.9.3",
"@floating-ui/react": "^0.22.2",
"@floating-ui/react-dom-interactions": "^0.13.3",
"@iconify-icons/ri": "^1.1.1",
"@iconify/icons-bx": "^1.1.0",
"@iconify/icons-codicon": "^1.1.15",
Expand Down
25 changes: 25 additions & 0 deletions apps/webapp/src/Components/AIPop/AIHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { SupportedAIEventTypes, useHistoryStore } from '@mexit/core'

import { StyledAIHistory, StyledAIHistoryContainer } from './styled'

const DEFAULT_HISTORY_LENGTH = 20

const AIHistory = ({ onClick }) => {
const aiHistory = useHistoryStore((store) => store.ai)

return (
<StyledAIHistoryContainer>
{aiHistory?.slice(-DEFAULT_HISTORY_LENGTH)?.map((event, i) => {
const type = !event?.at(-1) ? undefined : event?.at(-1)?.type ?? SupportedAIEventTypes.PROMPT

return (
<StyledAIHistory key={i} onClick={() => onClick(i)} type={type}>
<span />
</StyledAIHistory>
)
})}
</StyledAIHistoryContainer>
)
}

export default AIHistory
17 changes: 17 additions & 0 deletions apps/webapp/src/Components/AIPop/AIResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { AIResult, StyledAIResults } from './styled'

type AIResultsProps = {
results: Array<string>
}

const AIResults: React.FC<AIResultsProps> = ({ results }) => {
return (
<StyledAIResults>
{results?.map((result) => {
return <AIResult>{result}</AIResult>
})}
</StyledAIResults>
)
}

export default AIResults
116 changes: 116 additions & 0 deletions apps/webapp/src/Components/AIPop/Floater.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

import {
arrow,
autoUpdate,
flip,
FloatingArrow,
FloatingFocusManager,
FloatingPortal,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
useRole
} from '@floating-ui/react'
import { getSelectionBoundingClientRect } from '@udecode/plate'
import { useTheme } from 'styled-components'

import { FloatingElementType, mog, useFloatingStore, useHistoryStore } from '@mexit/core'

import { FloaterContainer } from './styled'
import AIBlockPopover from '.'

const DefaultFloater = ({ onClose }) => {
const isOpen = useFloatingStore((store) => store.floatingElement === FloatingElementType.AI_POPOVER)
const setIsOpen = useFloatingStore((store) => store.setFloatingElement)

const theme = useTheme()
const arrowRef = useRef(null)

const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
onOpenChange: (isOpen) => {
const state = isOpen ? FloatingElementType.AI_POPOVER : null

if (!state && onClose) {
mog('called')
onClose()
}
setIsOpen(state)
},
middleware: [
shift({
crossAxis: true,
padding: 10
}),
flip(),
arrow({
element: arrowRef,
padding: 10
})
],
whileElementsMounted: autoUpdate
})

const click = useClick(context)
const dismiss = useDismiss(context)
const role = useRole(context)

const { getFloatingProps } = useInteractions([click, dismiss, role])

useEffect(() => {
const coords = getSelectionBoundingClientRect()

refs.setPositionReference({
getBoundingClientRect() {
return coords
}
})
}, [isOpen])

return (
<FloatingPortal>
{isOpen && (
<FloatingFocusManager context={context} closeOnFocusOut>
<FloaterContainer
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content'
}}
{...getFloatingProps()}
>
<FloatingArrow
height={8}
width={16}
radius={4}
fill={theme.tokens.surfaces.modal}
stroke={theme.tokens.surfaces.s[3]}
strokeWidth={0.1}
ref={arrowRef}
context={context}
/>
<AIBlockPopover />
</FloaterContainer>
</FloatingFocusManager>
)}
</FloatingPortal>
)
}

const Floater = () => {
const clearAIEventsHistory = useHistoryStore((s) => s.clearAIHistory)

return (
<ErrorBoundary FallbackComponent={() => <></>}>
<DefaultFloater onClose={clearAIEventsHistory} />
</ErrorBoundary>
)
}

export default Floater
161 changes: 161 additions & 0 deletions apps/webapp/src/Components/AIPop/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useEffect, useMemo } from 'react'

import {
deserializeMd,
focusEditor,
getEndPoint,
getPlateEditorRef,
insertNodes,
usePlateEditorRef
} from '@udecode/plate'
import Highlighter from 'web-highlighter'

import { IconButton } from '@workduck-io/mex-components'

import { camelCase, generateTempId, SupportedAIEventTypes, useFloatingStore, useHistoryStore } from '@mexit/core'
import { AutoComplete, DefaultMIcons, Group } from '@mexit/shared'

import { useAIOptions } from '../../Hooks/useAIOptions'
import { useCreateNewMenu } from '../../Hooks/useCreateNewMenu'
import Plateless from '../Editor/Plateless'

import AIHistory from './AIHistory'
import {
AIContainerFooter,
AIContainerHeader,
AIContainerSection,
AIResponseContainer,
StyledAIContainer
} from './styled'

const AIResponse = ({ aiResponse, index }) => {
const editor = usePlateEditorRef()
const selected = aiResponse?.at(index)?.at(0)

if (selected) {
const deserialize = deserializeMd(editor, selected?.content)

return (
<AIResponseContainer>
<Plateless key={`wd-mexit-ai-response-${index}`} content={deserialize} multiline />
</AIResponseContainer>
)
}

return <></>
}

interface AIPreviewProps {
onInsert?: (content: string) => void
}

const AIBlockPopover: React.FC<AIPreviewProps> = (props) => {
const aiEventsHistory = useHistoryStore((s) => s.ai)
const activeEventIndex = useHistoryStore((s) => s.activeEventIndex)
const setActiveEventIndex = useHistoryStore((s) => s.setActiveEventIndex)
const clearAIResponses = useHistoryStore((s) => s.clearAIResponses)
const setFloatingElement = useFloatingStore((s) => s.setFloatingElement)

const { performAIAction } = useAIOptions()
const { getAIMenuItems } = useCreateNewMenu()

const defaultItems = useMemo(() => {
return getAIMenuItems()
}, [])

const insertContent = (content: string, replace = true) => {
if (!content) return

const editor = getPlateEditorRef()
const deserialize = deserializeMd(editor, content)?.map((node) => ({
...node,
id: generateTempId()
}))

if (Array.isArray(deserialize) && deserialize.length > 0) {
const at = replace ? editor.selection : getEndPoint(editor, editor.selection)

insertNodes(editor, deserialize, {
at,
select: true
})

try {
focusEditor(editor)
} catch (err) {
console.error('Unable to focus editor', err)
}

setFloatingElement(undefined)
}
}

useEffect(() => {
return () => {
const state = useFloatingStore.getState().state?.AI_POPOVER
if (state?.range) {
const highlight = new Highlighter()
highlight.removeAll()
}
}
}, [])

const handleOnEnter = async (value: string) => {
try {
await performAIAction(SupportedAIEventTypes.PROMPT, value)
} catch (err) {
console.error('Unable generate prompt result', err)
}
}

const userQuery = aiEventsHistory?.at(activeEventIndex)?.at(-1)
const defaultValue =
!userQuery?.type || userQuery?.type === SupportedAIEventTypes.PROMPT
? userQuery?.content
: camelCase(userQuery?.type)

const disableMenu = useFloatingStore.getState().state?.AI_POPOVER?.disableMenu

return (
<StyledAIContainer>
<AIContainerHeader>
<AutoComplete
onEnter={handleOnEnter}
disableMenu={disableMenu}
clearOnEnter
defaultValue={defaultValue}
defaultItems={defaultItems}
/>
</AIContainerHeader>
<AIContainerSection>
<AIResponse index={activeEventIndex} aiResponse={aiEventsHistory} />
</AIContainerSection>
<AIContainerFooter>
<IconButton title="Clear History" size={12} icon="ri:time-line" onClick={clearAIResponses} />
<AIHistory onClick={(index: number) => setActiveEventIndex(index)} />
<Group>
<IconButton
title="Replace"
onClick={() => {
const content = aiEventsHistory?.at(activeEventIndex)?.at(0)?.content
insertContent(content)
}}
size={12}
icon={DefaultMIcons.INSERT.value}
/>
<IconButton
title="Insert"
size={12}
icon={DefaultMIcons.EMBED.value}
onClick={() => {
const content = aiEventsHistory?.at(activeEventIndex)?.at(0)?.content
insertContent(content, false)
}}
/>
</Group>
</AIContainerFooter>
</StyledAIContainer>
)
}

export default AIBlockPopover
Loading