From 509f68c917c277ebb005cad248f259ecb077693b Mon Sep 17 00:00:00 2001 From: Paul Tran-Van Date: Thu, 2 Apr 2026 11:08:04 +0200 Subject: [PATCH] feat: Implement file mention feature for assistant --- .gitignore | 3 + .../Conversations/ConversationBar.jsx | 37 +++- .../Conversations/ConversationComposer.jsx | 35 +++- .../Conversations/FileChipsList.jsx | 33 ++++ .../Conversations/FileMentionContext.jsx | 102 +++++++++++ .../Conversations/FileMentionMenu.jsx | 171 ++++++++++++++++++ .../Conversations/mentionConstants.js | 11 ++ .../CozyAssistantRuntimeProvider.tsx | 59 ++++-- .../src/components/Messages/UserMessage.jsx | 7 +- .../Messages/UserMessageAttachments.jsx | 38 ++++ .../src/components/Search/useFetchResult.jsx | 9 +- .../adapters/CozyRealtimeChatAdapter.ts | 16 +- .../cozy-search/src/components/queries.js | 2 +- packages/cozy-search/src/locales/en.json | 4 + packages/cozy-search/src/locales/fr.json | 4 + packages/cozy-search/src/locales/ru.json | 4 + packages/cozy-search/src/locales/vi.json | 4 + packages/cozy-search/src/tools/types.ts | 50 +++++ packages/cozy-search/tests/jest.config.js | 2 +- 19 files changed, 565 insertions(+), 26 deletions(-) create mode 100644 packages/cozy-search/src/components/Conversations/FileChipsList.jsx create mode 100644 packages/cozy-search/src/components/Conversations/FileMentionContext.jsx create mode 100644 packages/cozy-search/src/components/Conversations/FileMentionMenu.jsx create mode 100644 packages/cozy-search/src/components/Conversations/mentionConstants.js create mode 100644 packages/cozy-search/src/components/Messages/UserMessageAttachments.jsx create mode 100644 packages/cozy-search/src/tools/types.ts diff --git a/.gitignore b/.gitignore index dbfc00ebd8..aa3e73dd39 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ node_modules !.yarn/releases !.yarn/sdks !.yarn/versions + +# Worktrees +.worktrees diff --git a/packages/cozy-search/src/components/Conversations/ConversationBar.jsx b/packages/cozy-search/src/components/Conversations/ConversationBar.jsx index fe73208e51..f9ab03f275 100644 --- a/packages/cozy-search/src/components/Conversations/ConversationBar.jsx +++ b/packages/cozy-search/src/components/Conversations/ConversationBar.jsx @@ -1,6 +1,6 @@ import { ComposerPrimitive } from '@assistant-ui/react' import cx from 'classnames' -import React, { useRef } from 'react' +import React, { useEffect, useRef, useState } from 'react' import Button from 'cozy-ui/transpiled/react/Buttons' import Icon from 'cozy-ui/transpiled/react/Icon' @@ -12,6 +12,8 @@ import useEventListener from 'cozy-ui/transpiled/react/hooks/useEventListener' import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' +import { useFileMention } from './FileMentionContext' +import FileMentionMenu from './FileMentionMenu' import styles from './styles.styl' const ConversationBar = ({ @@ -21,11 +23,29 @@ const ConversationBar = ({ onKeyDown, onSend, onCancel, + onChange, + composerRuntime, ...props }) => { const { t } = useI18n() const { isMobile } = useBreakpoints() const inputRef = useRef() + const containerRef = useRef() + const { handleInputChange, hasMention, mentionSearchTerm, menuKeyDownRef } = + useFileMention() + const [anchorEl, setAnchorEl] = useState(null) + + useEffect(() => { + setAnchorEl(hasMention ? containerRef.current : null) + }, [hasMention]) + + const handleChange = e => { + const newValue = e.target.value + handleInputChange(newValue) + if (onChange) { + onChange(e) + } + } // to adjust input height for multiline when typing in it // eslint-disable-next-line react-hooks/refs @@ -46,13 +66,17 @@ const ConversationBar = ({ } const handleKeyDown = e => { + if (hasMention && menuKeyDownRef.current) { + menuKeyDownRef.current(e) + if (e.defaultPrevented) return + } if (isEmpty) return onKeyDown(e) } return ( -
+
+ {hasMention && ( + + )}
) } diff --git a/packages/cozy-search/src/components/Conversations/ConversationComposer.jsx b/packages/cozy-search/src/components/Conversations/ConversationComposer.jsx index 08d2f3c2df..5fde28b0fd 100644 --- a/packages/cozy-search/src/components/Conversations/ConversationComposer.jsx +++ b/packages/cozy-search/src/components/Conversations/ConversationComposer.jsx @@ -2,6 +2,7 @@ import { ComposerPrimitive, useComposerRuntime, useThread, + useThreadRuntime, useComposer } from '@assistant-ui/react' import cx from 'classnames' @@ -11,6 +12,8 @@ import flag from 'cozy-flags' import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import ConversationBar from './ConversationBar' +import FileChipsList from './FileChipsList' +import { useFileMention } from './FileMentionContext' import AssistantSelection from '../Assistant/AssistantSelection' import { useAssistant } from '../AssistantProvider' import TwakeKnowledgeSelector from '../TwakeKnowledges/TwakeKnowledgeSelector' @@ -18,16 +21,41 @@ import TwakeKnowledgeSelector from '../TwakeKnowledges/TwakeKnowledgeSelector' const ConversationComposer = () => { const { isMobile } = useBreakpoints() const composerRuntime = useComposerRuntime() + const threadRuntime = useThreadRuntime() const isRunning = useThread(state => state.isRunning) const isThreadEmpty = useThread(state => state.messages.length === 0) const { setOpenedKnowledgePanel } = useAssistant() + const { + selectedFiles, + reset: resetFileMention, + snapshotAttachmentsIDs + } = useFileMention() const value = useComposer(state => state.text) const isEmpty = useComposer(state => state.isEmpty) const handleSend = useCallback(() => { - composerRuntime.send() - }, [composerRuntime]) + if (isEmpty || isRunning) return + const text = composerRuntime.getState().text + const attachmentIDs = selectedFiles.map(f => f.id) + snapshotAttachmentsIDs() + const metadata = + attachmentIDs.length > 0 ? { custom: { attachmentIDs } } : undefined + threadRuntime.append({ + content: [{ type: 'text', text }], + metadata + }) + composerRuntime.setText('') + resetFileMention() + }, [ + composerRuntime, + threadRuntime, + selectedFiles, + resetFileMention, + snapshotAttachmentsIDs, + isEmpty, + isRunning + ]) const handleCancel = useCallback(() => { composerRuntime.cancel() @@ -64,8 +92,11 @@ const ConversationComposer = () => { onKeyDown={handleKeyDown} onCancel={handleCancel} onSend={handleSend} + composerRuntime={composerRuntime} /> + +
{flag('cozy.assistant.create-assistant.enabled') && ( diff --git a/packages/cozy-search/src/components/Conversations/FileChipsList.jsx b/packages/cozy-search/src/components/Conversations/FileChipsList.jsx new file mode 100644 index 0000000000..e51e0b98be --- /dev/null +++ b/packages/cozy-search/src/components/Conversations/FileChipsList.jsx @@ -0,0 +1,33 @@ +import React from 'react' + +import Chip from 'cozy-ui/transpiled/react/Chips' +import Icon from 'cozy-ui/transpiled/react/Icon' + +import { useFileMention } from './FileMentionContext' + +const FileChipsList = () => { + const { selectedFiles, removeFile } = useFileMention() + + if (selectedFiles.length === 0) return null + + return ( +
+ {selectedFiles.map(file => ( + + ) : undefined + } + onDelete={() => removeFile(file.id)} + className="u-mr-half u-mb-half" + size="small" + /> + ))} +
+ ) +} + +export default FileChipsList diff --git a/packages/cozy-search/src/components/Conversations/FileMentionContext.jsx b/packages/cozy-search/src/components/Conversations/FileMentionContext.jsx new file mode 100644 index 0000000000..bfc6e91d0a --- /dev/null +++ b/packages/cozy-search/src/components/Conversations/FileMentionContext.jsx @@ -0,0 +1,102 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState +} from 'react' + +import { MENTION_MATCH_REGEX, MENTION_REPLACE_REGEX } from './mentionConstants' + +const FileMentionContext = createContext(null) + +export const FileMentionProvider = ({ children }) => { + const [selectedFiles, setSelectedFiles] = useState([]) + const [inputValue, setInputValue] = useState('') + const menuKeyDownRef = useRef(null) + + const addFile = useCallback(file => { + setSelectedFiles(prev => { + if (prev.some(f => f.id === file.id)) return prev + return [...prev, file] + }) + }, []) + + const removeFile = useCallback(fileId => { + setSelectedFiles(prev => prev.filter(f => f.id !== fileId)) + }, []) + + const reset = useCallback(() => { + setSelectedFiles([]) + setInputValue('') + }, []) + + const removeMentionText = useCallback(() => { + setInputValue(prev => prev.replace(MENTION_REPLACE_REGEX, '$1')) + }, []) + + const handleInputChange = useCallback(newValue => { + setInputValue(newValue) + }, []) + + const mentionMatch = inputValue.match(MENTION_MATCH_REGEX) + const hasMention = mentionMatch !== null + const mentionSearchTerm = mentionMatch ? mentionMatch[2] : '' + + const pendingAttachmentsRef = useRef(null) + + const snapshotAttachmentsIDs = useCallback(() => { + pendingAttachmentsRef.current = selectedFiles.map(f => f.id) + }, [selectedFiles]) + + const getAttachmentsIDs = useCallback(() => { + const ids = pendingAttachmentsRef.current || [] + pendingAttachmentsRef.current = null + return ids + }, []) + + const value = useMemo( + () => ({ + selectedFiles, + inputValue, + addFile, + removeFile, + reset, + removeMentionText, + handleInputChange, + hasMention, + mentionSearchTerm, + snapshotAttachmentsIDs, + getAttachmentsIDs, + menuKeyDownRef + }), + [ + selectedFiles, + inputValue, + addFile, + removeFile, + reset, + removeMentionText, + handleInputChange, + hasMention, + mentionSearchTerm, + snapshotAttachmentsIDs, + getAttachmentsIDs + ] + ) + + return ( + + {children} + + ) +} + +export const useFileMention = () => { + const context = useContext(FileMentionContext) + if (!context) { + throw new Error('useFileMention must be used within FileMentionProvider') + } + return context +} diff --git a/packages/cozy-search/src/components/Conversations/FileMentionMenu.jsx b/packages/cozy-search/src/components/Conversations/FileMentionMenu.jsx new file mode 100644 index 0000000000..411f1ba53f --- /dev/null +++ b/packages/cozy-search/src/components/Conversations/FileMentionMenu.jsx @@ -0,0 +1,171 @@ +import debounce from 'lodash/debounce' +import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react' + +import Icon from 'cozy-ui/transpiled/react/Icon' +import List from 'cozy-ui/transpiled/react/List' +import ListItem from 'cozy-ui/transpiled/react/ListItem' +import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' +import ListItemText from 'cozy-ui/transpiled/react/ListItemText' +import Paper from 'cozy-ui/transpiled/react/Paper' +import Popper from 'cozy-ui/transpiled/react/Popper' +import { useI18n } from 'twake-i18n' + +import { useFileMention } from './FileMentionContext' +import { MENTION_REPLACE_REGEX, FILE_SEARCH_OPTIONS } from './mentionConstants' +import SuggestionItemTextHighlighted from '../ResultMenu/SuggestionItemTextHighlighted' +import { useFetchResult } from '../Search/useFetchResult' + +const FileMentionMenu = ({ + anchorEl, + searchTerm, + composerRuntime, + inputRef +}) => { + const { t } = useI18n() + const { addFile, removeMentionText, handleInputChange, menuKeyDownRef } = + useFileMention() + const [selectedIndex, setSelectedIndex] = useState(0) + + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm) + + const debouncedSearch = useMemo( + () => debounce(setDebouncedSearchTerm, 250), + [] + ) + + useEffect(() => { + debouncedSearch(searchTerm) + return () => debouncedSearch.cancel() + }, [searchTerm, debouncedSearch]) + + const { isLoading, results } = useFetchResult( + debouncedSearchTerm, + FILE_SEARCH_OPTIONS + ) + + const fileResults = results || [] + + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => setSelectedIndex(0), [debouncedSearchTerm]) + + const replaceMentionWith = text => { + if (composerRuntime) { + const currentText = composerRuntime.getState().text + const replacement = text ? `$1${text} ` : '$1' + const newText = currentText.replace(MENTION_REPLACE_REGEX, replacement) + composerRuntime.setText(newText) + handleInputChange(newText) + + // Trigger height recalc (handled by ConversationBar's input listener) + if (inputRef?.current) { + requestAnimationFrame(() => { + inputRef.current?.dispatchEvent(new Event('input', { bubbles: true })) + }) + } + } else { + removeMentionText() + } + } + + const clearMention = () => { + replaceMentionWith('') + } + + const handleSelect = file => { + addFile({ + id: file.id, + name: file.primary, + path: file.secondary, + icon: file.icon + }) + const nameWithoutExt = file.primary.replace(/\.[^.]+$/, '') + replaceMentionWith(`\u00AB${nameWithoutExt}\u00BB`) + } + + const handleKeyDown = e => { + if (e.key === 'Tab') { + if (!fileResults[selectedIndex]) return + e.preventDefault() + handleSelect(fileResults[selectedIndex]) + } else if (e.key === 'Escape') { + e.preventDefault() + clearMention() + } else if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(prev => + prev < fileResults.length - 1 ? prev + 1 : prev + ) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (fileResults[selectedIndex]) { + handleSelect(fileResults[selectedIndex]) + } + } + } + + // Register handler for input-level keyboard interception + useLayoutEffect(() => { + menuKeyDownRef.current = handleKeyDown + return () => { + menuKeyDownRef.current = null + } + }) + + return ( + + + + {isLoading && {t('assistant.mention.loading')}} + {!isLoading && fileResults.length === 0 && ( + {t('assistant.mention.no_files')} + )} + {fileResults.map((file, idx) => ( + e.preventDefault()} + onClick={() => handleSelect(file)} + > + + {file.icon && file.icon.type === 'component' ? ( + + ) : ( + file.icon + )} + + + } + secondary={ + + } + /> + + ))} + + + + ) +} + +export default FileMentionMenu diff --git a/packages/cozy-search/src/components/Conversations/mentionConstants.js b/packages/cozy-search/src/components/Conversations/mentionConstants.js new file mode 100644 index 0000000000..9ae056ceba --- /dev/null +++ b/packages/cozy-search/src/components/Conversations/mentionConstants.js @@ -0,0 +1,11 @@ +// Only match @ preceded by whitespace or at start of string (avoids matching emails like me@host) +// Allows spaces in the search term so filenames like "my report.pdf" can be found +export const MENTION_MATCH_REGEX = /(^|\s)@([^\n@]*)$/ +export const MENTION_REPLACE_REGEX = /(^|\s)@[^\n@]*$/ + +export const FILES_DOCTYPE = 'io.cozy.files' + +export const FILE_SEARCH_OPTIONS = { + doctypes: [FILES_DOCTYPE], + excludeFilters: { type: 'directory' } +} diff --git a/packages/cozy-search/src/components/CozyAssistantRuntimeProvider.tsx b/packages/cozy-search/src/components/CozyAssistantRuntimeProvider.tsx index 7e0d090730..0e66de0150 100644 --- a/packages/cozy-search/src/components/CozyAssistantRuntimeProvider.tsx +++ b/packages/cozy-search/src/components/CozyAssistantRuntimeProvider.tsx @@ -30,6 +30,10 @@ import Typography from 'cozy-ui/transpiled/react/Typography' import { useI18n } from 'twake-i18n' import { useAssistant } from './AssistantProvider' +import { + FileMentionProvider, + useFileMention +} from './Conversations/FileMentionContext' import { createCozyRealtimeChatAdapter } from './adapters/CozyRealtimeChatAdapter' import { StreamBridge } from './adapters/StreamBridge' import { DEFAULT_ASSISTANT } from './constants' @@ -47,6 +51,7 @@ interface ConversationMessage { role: 'user' | 'assistant' content: string sources?: Array<{ id: string; doctype?: string }> + attachmentIDs?: string[] } interface Conversation { @@ -70,15 +75,21 @@ const convertMessagesToThreadMessages = ( ): ThreadMessageLike[] => { if (!messages) return [] - return messages.map((msg, idx) => ({ - id: msg.id || `msg-${idx}`, - role: msg.role, - content: sanitizeChatContent(msg.content), - metadata: - msg.role === 'assistant' && msg.sources - ? { custom: { sources: msg.sources } } - : undefined - })) + return messages.map((msg, idx) => { + const custom: Record = {} + if (msg.role === 'assistant' && msg.sources) { + custom.sources = msg.sources + } + if (msg.role === 'user' && msg.attachmentIDs?.length) { + custom.attachmentIDs = msg.attachmentIDs + } + return { + id: msg.id || `msg-${idx}`, + role: msg.role, + content: sanitizeChatContent(msg.content), + metadata: Object.keys(custom).length > 0 ? { custom } : undefined + } + }) } const ConversationLoader = ({ @@ -129,7 +140,7 @@ const ConversationLoader = ({ ) } -const CozyAssistantRuntimeProviderInner = ({ +const FileMentionAwareRuntimeProvider = ({ children, conversationId, initialMessages @@ -144,6 +155,7 @@ const CozyAssistantRuntimeProviderInner = ({ const cancelledMessageIdsRef = useRef>(new Set()) const currentStreamingMessageIdRef = useRef(null) const { selectedAssistantId } = useAssistant() + const { getAttachmentsIDs } = useFileMention() useEffect(() => { messagesIdRef.current = initialMessages @@ -236,8 +248,6 @@ const CozyAssistantRuntimeProviderInner = ({ return } - // Track which message is currently streaming - // When a different message starts, mark the old one as cancelled if (res.object === 'delta') { if ( currentStreamingMessageIdRef.current && @@ -295,11 +305,12 @@ const CozyAssistantRuntimeProviderInner = ({ conversationId, // eslint-disable-next-line react-hooks/refs streamBridge: streamBridgeRef.current, - assistantId: selectedAssistantId + assistantId: selectedAssistantId, + getAttachmentsIDs }, t ), - [client, conversationId, selectedAssistantId, t] + [client, conversationId, selectedAssistantId, t, getAttachmentsIDs] ) const runtime = useLocalRuntime(adapter, { @@ -324,6 +335,26 @@ const CozyAssistantRuntimeProviderInner = ({ ) } +const CozyAssistantRuntimeProviderInner = ({ + children, + conversationId, + initialMessages +}: CozyAssistantRuntimeProviderProps & { + conversationId: string + initialMessages: ThreadMessageLike[] +}): JSX.Element => { + return ( + + + {children} + + + ) +} + /** * Provider that sets up assistant-ui runtime with Cozy's backend. * Must be used within a route that provides conversationId param. diff --git a/packages/cozy-search/src/components/Messages/UserMessage.jsx b/packages/cozy-search/src/components/Messages/UserMessage.jsx index 48ac932522..d8915626af 100644 --- a/packages/cozy-search/src/components/Messages/UserMessage.jsx +++ b/packages/cozy-search/src/components/Messages/UserMessage.jsx @@ -1,4 +1,4 @@ -import { MessagePrimitive } from '@assistant-ui/react' +import { MessagePrimitive, useMessage } from '@assistant-ui/react' import cx from 'classnames' import React from 'react' @@ -6,10 +6,12 @@ import Box from 'cozy-ui/transpiled/react/Box' import Typography from 'cozy-ui/transpiled/react/Typography' import { useCozyTheme } from 'cozy-ui-plus/dist/providers/CozyTheme' +import UserMessageAttachments from './UserMessageAttachments' import styles from './styles.styl' const UserMessage = () => { const { type: theme } = useCozyTheme() + const attachmentIDs = useMessage(s => s.metadata?.custom?.attachmentIDs) return ( @@ -29,6 +31,9 @@ const UserMessage = () => { Text: ({ text }) => {text} }} /> + {attachmentIDs?.length > 0 && ( + + )} ) diff --git a/packages/cozy-search/src/components/Messages/UserMessageAttachments.jsx b/packages/cozy-search/src/components/Messages/UserMessageAttachments.jsx new file mode 100644 index 0000000000..53c774da1d --- /dev/null +++ b/packages/cozy-search/src/components/Messages/UserMessageAttachments.jsx @@ -0,0 +1,38 @@ +import React from 'react' + +import { useQuery, isQueryLoading } from 'cozy-client' +import Chip from 'cozy-ui/transpiled/react/Chips' +import Icon from 'cozy-ui/transpiled/react/Icon' + +import { getDriveMimeTypeIcon } from '../Search/getIconForSearchResult' +import { buildFilesByIds } from '../queries' + +const UserMessageAttachments = ({ attachmentIDs }) => { + const enabled = attachmentIDs.length > 0 + const filesByIds = buildFilesByIds(attachmentIDs, enabled) + const { data: files, ...queryResult } = useQuery( + filesByIds.definition, + filesByIds.options + ) + + if (!enabled || isQueryLoading(queryResult) || !files?.length) return null + + return ( +
+ {files.map(file => ( + } + className="u-mr-half u-mb-half" + style={{ maxWidth: '100%' }} + classes={{ label: 'u-ellipsis' }} + size="small" + variant="ghost" + /> + ))} +
+ ) +} + +export default UserMessageAttachments diff --git a/packages/cozy-search/src/components/Search/useFetchResult.jsx b/packages/cozy-search/src/components/Search/useFetchResult.jsx index ae997f1a12..f731cee431 100644 --- a/packages/cozy-search/src/components/Search/useFetchResult.jsx +++ b/packages/cozy-search/src/components/Search/useFetchResult.jsx @@ -57,7 +57,7 @@ export const useFetchResult = (searchValue, searchOptions = {}) => { ) const results = searchResults.map(r => { - // Begin Retrocompatibility code, to be removed when following PR is merged: https://github.com/cozy/cozy-web-data-proxy/pull/10 + // Begin Retrocompatibility code, to be removed when following PR is merged: https://github.com/cozy/cozy-web-dataproxy/pull/10 r.slug = r.slug || r.type r.subTitle = r.subTitle || r.name // End Retrocompatibility code @@ -70,6 +70,7 @@ export const useFetchResult = (searchValue, searchOptions = {}) => { secondaryUrl: r.secondaryUrl, primary: r.title, secondary: r.subTitle, + type: r.type, // Preserve the type property (file/directory) onClick: () => { if (r.slug === client.appMetadata.slug) { try { @@ -94,10 +95,12 @@ export const useFetchResult = (searchValue, searchOptions = {}) => { fetch(searchValue, searchOptions) } } else { - // eslint-disable-next-line react-hooks/set-state-in-effect setState({ isLoading: true, results: null, searchValue: null }) } - }, [dataProxy, searchValue, state.searchValue, setState]) + // client.appMetadata.slug and navigate are stable refs used only in onClick callbacks + // searchOptions is intentionally excluded to avoid re-fetches from inline objects + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataProxy, searchValue, state.searchValue]) return { isLoading: state.isLoading, diff --git a/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts b/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts index d3d310032a..17613aa7d2 100644 --- a/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts +++ b/packages/cozy-search/src/components/adapters/CozyRealtimeChatAdapter.ts @@ -32,6 +32,7 @@ export interface CozyRealtimeChatAdapterOptions { conversationId: string streamBridge: StreamBridge assistantId?: string + getAttachmentsIDs?: () => string[] } /** @@ -67,9 +68,16 @@ export const createCozyRealtimeChatAdapter = ( messages, abortSignal }: ChatModelRunOptions): AsyncGenerator { - const { client, conversationId, streamBridge, assistantId } = options + const { + client, + conversationId, + streamBridge, + assistantId, + getAttachmentsIDs + } = options const userQuery = findUserQuery(messages) + const attachmentIDs = getAttachmentsIDs?.() || [] if (!userQuery) { log.error('No user message found in:', messages) return @@ -86,7 +94,11 @@ export const createCozyRealtimeChatAdapter = ( await client.stackClient.fetchJSON( 'POST', `/ai/chat/conversations/${conversationId}`, - { q: userQuery, assistantID: assistantId } + { + q: userQuery, + assistantID: assistantId, + ...(attachmentIDs.length > 0 && { attachmentIDs }) + } ) let fullText = '' diff --git a/packages/cozy-search/src/components/queries.js b/packages/cozy-search/src/components/queries.js index 83972b8d78..45dff54966 100644 --- a/packages/cozy-search/src/components/queries.js +++ b/packages/cozy-search/src/components/queries.js @@ -14,7 +14,7 @@ export const buildFilesByIds = (ids, enabled) => { return { definition: Q(FILES_DOCTYPE).getByIds(ids), options: { - as: `${FILES_DOCTYPE}/${ids.join('')}`, + as: `${FILES_DOCTYPE}/${[...ids].sort().join(',')}`, fetchPolicy: defaultFetchPolicy, enabled } diff --git a/packages/cozy-search/src/locales/en.json b/packages/cozy-search/src/locales/en.json index d2701db42d..ea80927c0a 100644 --- a/packages/cozy-search/src/locales/en.json +++ b/packages/cozy-search/src/locales/en.json @@ -55,6 +55,10 @@ "not_found_desc": "Click \"New Chat\" to begin a conversation or select from your recent chats", "close": "Close" }, + "mention": { + "loading": "Loading...", + "no_files": "No files found" + }, "message": { "welcome": "How can I help you today?", "running": "Assistant is running..." diff --git a/packages/cozy-search/src/locales/fr.json b/packages/cozy-search/src/locales/fr.json index d921884ab9..f695f9eea9 100644 --- a/packages/cozy-search/src/locales/fr.json +++ b/packages/cozy-search/src/locales/fr.json @@ -55,6 +55,10 @@ "not_found_desc": "Cliquez sur \"Nouvelle conversation\" pour commencer une conversation ou sélectionnez parmi vos discussions récentes", "close": "Fermer" }, + "mention": { + "loading": "Chargement...", + "no_files": "Aucun fichier trouvé" + }, "message": { "welcome": "Comment puis-je vous aider aujourd'hui ?", "running": "Assistant en cours..." diff --git a/packages/cozy-search/src/locales/ru.json b/packages/cozy-search/src/locales/ru.json index 624224a02d..8541a50a7e 100644 --- a/packages/cozy-search/src/locales/ru.json +++ b/packages/cozy-search/src/locales/ru.json @@ -55,6 +55,10 @@ "edit": "Редактировать", "edited": "Сообщение сохранено" }, + "mention": { + "loading": "Загрузка...", + "no_files": "Файлы не найдены" + }, "message": { "welcome": "Как я могу помочь вам сегодня?", "running": "Ассистент запущен..." diff --git a/packages/cozy-search/src/locales/vi.json b/packages/cozy-search/src/locales/vi.json index c0453cca82..06ea5223e5 100644 --- a/packages/cozy-search/src/locales/vi.json +++ b/packages/cozy-search/src/locales/vi.json @@ -46,6 +46,10 @@ "default_error": "Đã xảy ra lỗi, vui lòng thử lại", "hide": "Ẩn", "show": "Hiện", + "mention": { + "loading": "Đang tải...", + "no_files": "Không tìm thấy tệp" + }, "message": { "welcome": "Tôi có thể giúp gì cho bạn hôm nay?", "running": "Trợ lý đang chạy..." diff --git a/packages/cozy-search/src/tools/types.ts b/packages/cozy-search/src/tools/types.ts new file mode 100644 index 0000000000..b1980368ff --- /dev/null +++ b/packages/cozy-search/src/tools/types.ts @@ -0,0 +1,50 @@ +export interface AssistantTool { + name: string + category: string + description: string + parameters: Record // JSON Schema + label: string + execute: ( + params: Record, + client: unknown + ) => Promise + confirmationDetails: ( + params: Record, + t: (key: string, options?: Record) => string + ) => ConfirmationDetails + resultMessage: ( + params: Record, + result: ToolResult, + t: (key: string, options?: Record) => string + ) => string +} + +export interface ConfirmationDetails { + title: string + fields: Array<{ label: string; value: string }> +} + +export interface ToolResult { + success: boolean + message: string + data?: Record +} + +/** + * LLM-ready tool schema — only the fields the LLM needs. + * Produced by toToolSchemas() in helpers.ts. + */ +export interface ToolSchema { + name: string + description: string + parameters: Record +} + +/** + * A tool call received from the LLM via WebSocket. + */ +export interface ToolCall { + id: string + name: string + arguments: Record +} diff --git a/packages/cozy-search/tests/jest.config.js b/packages/cozy-search/tests/jest.config.js index f14548379a..f06a33f958 100644 --- a/packages/cozy-search/tests/jest.config.js +++ b/packages/cozy-search/tests/jest.config.js @@ -8,7 +8,7 @@ const config = { coverageDirectory: './tests/coverage', coveragePathIgnorePatterns: ['./tests'], rootDir: '../', - testMatch: ['./**/*.spec.{ts,tsx,js}'], + testMatch: ['./**/*.spec.{ts,tsx,js,jsx}'], testPathIgnorePatterns: ['/node_modules/', '/dist/'], coverageThreshold: { global: {