Skip to content

Commit da4e4a3

Browse files
committed
feat: Implement file mention feature for assistant
1 parent 81982c6 commit da4e4a3

14 files changed

Lines changed: 418 additions & 15 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ node_modules
1414
!.yarn/releases
1515
!.yarn/sdks
1616
!.yarn/versions
17+
18+
# Worktrees
19+
.worktrees

packages/cozy-search/src/components/Conversations/ConversationBar.jsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ComposerPrimitive } from '@assistant-ui/react'
22
import cx from 'classnames'
3-
import React, { useRef } from 'react'
3+
import React, { useEffect, useRef, useState } from 'react'
44

55
import Button from 'cozy-ui/transpiled/react/Buttons'
66
import Icon from 'cozy-ui/transpiled/react/Icon'
@@ -12,6 +12,8 @@ import useEventListener from 'cozy-ui/transpiled/react/hooks/useEventListener'
1212
import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
1313
import { useI18n } from 'twake-i18n'
1414

15+
import { useFileMention } from './FileMentionContext'
16+
import FileMentionMenu from './FileMentionMenu'
1517
import styles from './styles.styl'
1618

1719
const ConversationBar = ({
@@ -21,11 +23,27 @@ const ConversationBar = ({
2123
onKeyDown,
2224
onSend,
2325
onCancel,
26+
onChange,
27+
composerRuntime,
2428
...props
2529
}) => {
2630
const { t } = useI18n()
2731
const { isMobile } = useBreakpoints()
2832
const inputRef = useRef()
33+
const { handleInputChange, hasMention, mentionSearchTerm } = useFileMention()
34+
const [anchorEl, setAnchorEl] = useState(null)
35+
36+
useEffect(() => {
37+
setAnchorEl(hasMention ? inputRef.current : null)
38+
}, [hasMention])
39+
40+
const handleChange = e => {
41+
const newValue = e.target.value
42+
handleInputChange(newValue)
43+
if (onChange) {
44+
onChange(e)
45+
}
46+
}
2947

3048
// to adjust input height for multiline when typing in it
3149
// eslint-disable-next-line react-hooks/refs
@@ -52,7 +70,7 @@ const ConversationBar = ({
5270
}
5371

5472
return (
55-
<div className="u-w-100 u-maw-7 u-mh-auto">
73+
<div className="u-w-100 u-maw-7 u-mh-auto" ref={inputRef}>
5674
<SearchBar
5775
{...props}
5876
className={cx(styles['conversationBar'], {
@@ -62,10 +80,10 @@ const ConversationBar = ({
6280
size="auto"
6381
placeholder={t('assistant.search.placeholder')}
6482
value={value}
83+
onChange={handleChange}
6584
disabledClear
6685
componentsProps={{
6786
inputBase: {
68-
inputRef: inputRef,
6987
className: 'u-pv-0',
7088
rows: 1,
7189
multiline: true,
@@ -102,6 +120,13 @@ const ConversationBar = ({
102120
}
103121
}}
104122
/>
123+
{hasMention && (
124+
<FileMentionMenu
125+
anchorEl={anchorEl}
126+
searchTerm={mentionSearchTerm}
127+
composerRuntime={composerRuntime}
128+
/>
129+
)}
105130
</div>
106131
)
107132
}

packages/cozy-search/src/components/Conversations/ConversationComposer.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'
1313
import ConversationBar from './ConversationBar'
1414
import AssistantSelection from '../Assistant/AssistantSelection'
1515
import { useAssistant } from '../AssistantProvider'
16+
import FileChipsList from './FileChipsList'
1617
import TwakeKnowledgeSelector from '../TwakeKnowledges/TwakeKnowledgeSelector'
1718

1819
const ConversationComposer = () => {
@@ -64,8 +65,11 @@ const ConversationComposer = () => {
6465
onKeyDown={handleKeyDown}
6566
onCancel={handleCancel}
6667
onSend={handleSend}
68+
composerRuntime={composerRuntime}
6769
/>
6870

71+
<FileChipsList />
72+
6973
<div className="u-flex u-flex-items-center u-flex-justify-between u-mt-1">
7074
{flag('cozy.assistant.create-assistant.enabled') && (
7175
<AssistantSelection disabled={!isThreadEmpty} />
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react'
2+
3+
import Chip from 'cozy-ui/transpiled/react/Chips'
4+
5+
import { useFileMention } from './FileMentionContext'
6+
7+
const FileChipsList = () => {
8+
const { selectedFiles, removeFile } = useFileMention()
9+
10+
if (selectedFiles.length === 0) return null
11+
12+
return (
13+
<div className="u-flex u-flex-wrap u-mt-half">
14+
{selectedFiles.map(file => (
15+
<Chip
16+
key={file.id}
17+
label={file.name}
18+
onDelete={() => removeFile(file.id)}
19+
className="u-mr-half u-mb-half"
20+
size="small"
21+
/>
22+
))}
23+
</div>
24+
)
25+
}
26+
27+
export default FileChipsList
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React, {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useMemo,
6+
useState
7+
} from 'react'
8+
9+
import { MENTION_MATCH_REGEX, MENTION_REPLACE_REGEX } from './mentionConstants'
10+
11+
const FileMentionContext = createContext(null)
12+
13+
export const FileMentionProvider = ({ children }) => {
14+
const [selectedFiles, setSelectedFiles] = useState([])
15+
const [inputValue, setInputValue] = useState('')
16+
17+
const addFile = useCallback(file => {
18+
setSelectedFiles(prev => [...prev, file])
19+
}, [])
20+
21+
const removeFile = useCallback(fileId => {
22+
setSelectedFiles(prev => prev.filter(f => f.id !== fileId))
23+
}, [])
24+
25+
const removeMentionText = useCallback(() => {
26+
setInputValue(prev => prev.replace(MENTION_REPLACE_REGEX, '$1'))
27+
}, [])
28+
29+
const handleInputChange = useCallback(newValue => {
30+
setInputValue(newValue)
31+
}, [])
32+
33+
const mentionMatch = inputValue.match(MENTION_MATCH_REGEX)
34+
const hasMention = mentionMatch !== null
35+
const mentionSearchTerm = mentionMatch ? mentionMatch[2] : ''
36+
37+
const getAttachmentsIDs = useCallback(
38+
() => selectedFiles.map(f => f.id),
39+
[selectedFiles]
40+
)
41+
42+
const value = useMemo(
43+
() => ({
44+
selectedFiles,
45+
inputValue,
46+
addFile,
47+
removeFile,
48+
removeMentionText,
49+
handleInputChange,
50+
hasMention,
51+
mentionSearchTerm,
52+
getAttachmentsIDs
53+
}),
54+
[
55+
selectedFiles,
56+
inputValue,
57+
addFile,
58+
removeFile,
59+
removeMentionText,
60+
handleInputChange,
61+
hasMention,
62+
mentionSearchTerm,
63+
getAttachmentsIDs
64+
]
65+
)
66+
67+
return (
68+
<FileMentionContext.Provider value={value}>
69+
{children}
70+
</FileMentionContext.Provider>
71+
)
72+
}
73+
74+
export const useFileMention = () => {
75+
const context = useContext(FileMentionContext)
76+
if (!context) {
77+
throw new Error('useFileMention must be used within FileMentionProvider')
78+
}
79+
return context
80+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import debounce from 'lodash/debounce'
2+
import React, { useEffect, useMemo, useState } from 'react'
3+
4+
import Icon from 'cozy-ui/transpiled/react/Icon'
5+
import List from 'cozy-ui/transpiled/react/List'
6+
import ListItem from 'cozy-ui/transpiled/react/ListItem'
7+
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
8+
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
9+
import Paper from 'cozy-ui/transpiled/react/Paper'
10+
import Popper from 'cozy-ui/transpiled/react/Popper'
11+
import { useFileMention } from './FileMentionContext'
12+
import { MENTION_REPLACE_REGEX, FILE_SEARCH_OPTIONS } from './mentionConstants'
13+
import { useFetchResult } from '../Search/useFetchResult'
14+
15+
const FileMentionMenu = ({ anchorEl, searchTerm, composerRuntime }) => {
16+
const { addFile, removeMentionText, handleInputChange } = useFileMention()
17+
const [selectedIndex, setSelectedIndex] = useState(0)
18+
19+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm)
20+
21+
const debouncedSearch = useMemo(
22+
() => debounce(setDebouncedSearchTerm, 250),
23+
[]
24+
)
25+
26+
useEffect(() => {
27+
debouncedSearch(searchTerm)
28+
return () => debouncedSearch.cancel()
29+
}, [searchTerm, debouncedSearch])
30+
31+
const { isLoading, results } = useFetchResult(
32+
debouncedSearchTerm,
33+
FILE_SEARCH_OPTIONS
34+
)
35+
36+
const fileResults = results || []
37+
38+
// eslint-disable-next-line react-hooks/set-state-in-effect
39+
useEffect(() => setSelectedIndex(0), [debouncedSearchTerm])
40+
41+
const clearMention = () => {
42+
if (composerRuntime) {
43+
const currentText = composerRuntime.getState().text
44+
const newText = currentText.replace(MENTION_REPLACE_REGEX, '$1')
45+
composerRuntime.setText(newText)
46+
handleInputChange(newText)
47+
} else {
48+
removeMentionText()
49+
}
50+
}
51+
52+
const handleSelect = file => {
53+
addFile({
54+
id: file.id,
55+
name: file.primary,
56+
path: file.secondary,
57+
icon: file.icon
58+
})
59+
clearMention()
60+
}
61+
62+
const handleKeyDown = e => {
63+
if (e.key === 'Tab') {
64+
e.preventDefault()
65+
if (fileResults[selectedIndex]) {
66+
handleSelect(fileResults[selectedIndex])
67+
}
68+
} else if (e.key === 'Escape') {
69+
e.preventDefault()
70+
clearMention()
71+
} else if (e.key === 'ArrowDown') {
72+
e.preventDefault()
73+
setSelectedIndex(prev =>
74+
prev < fileResults.length - 1 ? prev + 1 : prev
75+
)
76+
} else if (e.key === 'ArrowUp') {
77+
e.preventDefault()
78+
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
79+
} else if (e.key === 'Enter') {
80+
e.preventDefault()
81+
if (fileResults[selectedIndex]) {
82+
handleSelect(fileResults[selectedIndex])
83+
}
84+
}
85+
}
86+
87+
return (
88+
<Popper
89+
anchorEl={anchorEl}
90+
open={Boolean(anchorEl)}
91+
placement="bottom-start"
92+
style={{ zIndex: 'var(--zIndex-popover)' }}
93+
>
94+
<Paper
95+
square
96+
className="u-overflow-y-auto"
97+
style={{ maxHeight: '300px', overflow: 'auto' }}
98+
>
99+
<List>
100+
{isLoading && <ListItem>Loading...</ListItem>}
101+
{!isLoading && fileResults.length === 0 && (
102+
<ListItem>No files found</ListItem>
103+
)}
104+
{fileResults.map((file, idx) => (
105+
<ListItem
106+
key={file.id}
107+
button
108+
selected={selectedIndex === idx}
109+
onClick={() => handleSelect(file)}
110+
onKeyDown={handleKeyDown}
111+
tabIndex={0}
112+
>
113+
<ListItemIcon>
114+
{file.icon && file.icon.type === 'component' ? (
115+
<Icon icon={file.icon.component} size={32} />
116+
) : (
117+
file.icon
118+
)}
119+
</ListItemIcon>
120+
<ListItemText primary={file.primary} secondary={file.secondary} />
121+
</ListItem>
122+
))}
123+
</List>
124+
</Paper>
125+
</Popper>
126+
)
127+
}
128+
129+
export default FileMentionMenu
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React from 'react'
2+
3+
import Icon from 'cozy-ui/transpiled/react/Icon'
4+
import GlobalIcon from 'cozy-ui/transpiled/react/Icons/Global'
5+
import ListItem from 'cozy-ui/transpiled/react/ListItem'
6+
import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'
7+
import ListItemText from 'cozy-ui/transpiled/react/ListItemText'
8+
9+
import styles from './styles.styl'
10+
11+
const URLSourceItem = ({ source }) => {
12+
const { FileURL: url, Filename: title } = source
13+
14+
if (!url) {
15+
return null
16+
}
17+
18+
return (
19+
<ListItem
20+
className={styles.sourcesItem}
21+
component="a"
22+
href={url}
23+
target="_blank"
24+
rel="noopener noreferrer"
25+
button
26+
>
27+
<ListItemIcon>
28+
<Icon icon={GlobalIcon} size={32} />
29+
</ListItemIcon>
30+
<ListItemText primary={title || url} secondary={url} />
31+
</ListItem>
32+
)
33+
}
34+
35+
export default URLSourceItem
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Only match @ preceded by whitespace or at start of string (avoids matching emails like me@host)
2+
// Allows spaces in the search term so filenames like "my report.pdf" can be found
3+
export const MENTION_MATCH_REGEX = /(^|\s)@([\w. ]*)$/
4+
export const MENTION_REPLACE_REGEX = /(^|\s)@[\w. ]*$/
5+
6+
export const FILES_DOCTYPE = 'io.cozy.files'
7+
8+
export const FILE_SEARCH_OPTIONS = {
9+
doctypes: [FILES_DOCTYPE],
10+
excludeFilters: { type: 'directory' }
11+
}

0 commit comments

Comments
 (0)