)
}
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: {