From d8d00d94d516061de01858f9606c2763ca4fc519 Mon Sep 17 00:00:00 2001 From: Dnouv Date: Wed, 20 May 2026 15:15:30 +0530 Subject: [PATCH 01/50] Add AI Center unified search --- apps/meteor/app/api/server/v1/misc.ts | 356 ++++++++++++++++-- .../navbar/NavBarSearch/NavBarSearch.tsx | 34 +- .../navbar/NavBarSearch/NavBarSearchItem.tsx | 2 +- .../NavBarSearch/NavBarSearchListbox.tsx | 59 ++- .../NavBarSearch/NavBarSearchMessageRow.tsx | 79 ++++ .../NavBarSearch/hooks/useSearchItems.ts | 150 +++++--- .../views/admin/aiCenter/AICenterRoute.tsx | 23 ++ apps/meteor/client/views/admin/routes.tsx | 9 + .../meteor/client/views/admin/sidebarItems.ts | 7 + apps/meteor/server/settings/ai.ts | 82 ++++ apps/meteor/server/settings/index.ts | 2 + packages/i18n/src/locales/en.i18n.json | 23 +- packages/rest-typings/src/v1/misc.ts | 69 +++- 13 files changed, 797 insertions(+), 98 deletions(-) create mode 100644 apps/meteor/client/navbar/NavBarSearch/NavBarSearchMessageRow.tsx create mode 100644 apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx create mode 100644 apps/meteor/server/settings/ai.ts diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index cf7a9a2d3d1bf..5cc916a550040 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,11 +1,12 @@ import crypto from 'node:crypto'; -import type { IDirectoryChannelResult, IDirectoryUserResult, IRoom, IUser } from '@rocket.chat/core-typings'; -import { Settings, Users, WorkspaceCredentials } from '@rocket.chat/models'; +import type { IDirectoryChannelResult, IDirectoryUserResult, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Settings, Subscriptions, Users, WorkspaceCredentials } from '@rocket.chat/models'; import { ajv, isShieldSvgProps, isSpotlightProps, + isUnifiedSearchProps, isDirectoryProps, isFingerprintProps, isMeteorCall, @@ -13,7 +14,8 @@ import { validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; -import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings'; +import type { MeApiSuccessResponse, UnifiedSearchIntelligentResult, UnifiedSearchMessageResult } from '@rocket.chat/rest-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { escapeHTML } from '@rocket.chat/string-helpers'; import EJSON from 'ejson'; import { check } from 'meteor/check'; @@ -23,6 +25,7 @@ import { Meteor } from 'meteor/meteor'; import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { browseChannelsMethod } from '../../../../server/methods/browseChannels'; +import { messageSearch } from '../../../../server/methods/messageSearch'; import { spotlightMethod } from '../../../../server/publications/spotlight'; import { resetAuditedSettingByUser, updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { passwordPolicy } from '../../../lib/server'; @@ -31,6 +34,7 @@ import { settings } from '../../../settings/server'; import { getBaseUserFields } from '../../../utils/server/functions/getBaseUserFields'; import { isSMTPConfigured } from '../../../utils/server/functions/isSMTPConfigured'; import { getURL } from '../../../utils/server/getURL'; +import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams } from '../helpers/getUserFromParams'; @@ -337,65 +341,371 @@ API.v1.get( }, ); +const spotlightUsersSchema = { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + status: { type: 'string' }, + statusText: { type: 'string' }, + avatarETag: { type: 'string' }, + }, + required: ['_id', 'name', 'username', 'status'], + additionalProperties: true, + }, +} as const; + +const spotlightRoomsSchema = { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + t: { type: 'string' }, + name: { type: 'string' }, + fname: { type: 'string' }, + lastMessage: { $ref: '#/components/schemas/IMessage' }, + }, + required: ['_id', 't'], + additionalProperties: true, + }, +} as const; + const spotlightResponseSchema = ajv.compile<{ users: Pick[]; - rooms: Pick[]; + rooms: Pick[]; }>({ type: 'object', properties: { - users: { + users: spotlightUsersSchema, + rooms: spotlightRoomsSchema, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'rooms', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'spotlight', + { + authRequired: true, + query: isSpotlightProps, + response: { + 200: spotlightResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { query } = this.queryParams; + + const result = await spotlightMethod({ text: query, userId: this.userId }); + + return API.v1.success(result); + }, +); + +const MAX_UNIFIED_SEARCH_RESULTS = 10; +const AI_SEARCH_PAGE_SIZE = 5; + +const unifiedSearchResponseSchema = ajv.compile<{ + users: Pick[]; + rooms: Pick[]; + messages: UnifiedSearchMessageResult[]; + intelligent: UnifiedSearchIntelligentResult[]; + meta: { + globalMessagesEnabled: boolean; + intelligentSearchEnabled: boolean; + intelligentSearchConfigured: boolean; + }; +}>({ + type: 'object', + properties: { + users: spotlightUsersSchema, + rooms: spotlightRoomsSchema, + messages: { type: 'array', items: { type: 'object', properties: { _id: { type: 'string' }, - name: { type: 'string' }, - username: { type: 'string' }, - status: { type: 'string' }, - statusText: { type: 'string' }, - avatarETag: { type: 'string' }, + rid: { type: 'string' }, + msg: { type: 'string', nullable: true }, + u: { type: 'object', nullable: true }, + room: { + type: 'object', + nullable: true, + properties: { + _id: { type: 'string' }, + t: { type: 'string' }, + name: { type: 'string', nullable: true }, + fname: { type: 'string', nullable: true }, + }, + required: ['_id', 't'], + additionalProperties: true, + }, }, - required: ['_id', 'name', 'username', 'status'], + required: ['_id', 'rid'], additionalProperties: true, }, }, - rooms: { + intelligent: { type: 'array', items: { type: 'object', properties: { _id: { type: 'string' }, - t: { type: 'string' }, - name: { type: 'string' }, - lastMessage: { $ref: '#/components/schemas/IMessage' }, + rid: { type: 'string', nullable: true }, + msgId: { type: 'string', nullable: true }, + text: { type: 'string' }, + score: { type: 'number', nullable: true }, + room: { + type: 'object', + nullable: true, + properties: { + _id: { type: 'string' }, + t: { type: 'string' }, + name: { type: 'string', nullable: true }, + fname: { type: 'string', nullable: true }, + }, + required: ['_id', 't'], + additionalProperties: true, + }, }, - required: ['_id', 't', 'name'], + required: ['_id', 'text'], additionalProperties: true, }, }, + meta: { + type: 'object', + properties: { + globalMessagesEnabled: { type: 'boolean' }, + intelligentSearchEnabled: { type: 'boolean' }, + intelligentSearchConfigured: { type: 'boolean' }, + }, + required: ['globalMessagesEnabled', 'intelligentSearchEnabled', 'intelligentSearchConfigured'], + additionalProperties: false, + }, success: { type: 'boolean', enum: [true] }, }, - required: ['users', 'rooms', 'success'], + required: ['users', 'rooms', 'messages', 'intelligent', 'meta', 'success'], additionalProperties: false, }); +const normalizeSimilarityPercent = (value: unknown): number => { + const numeric = Number(value); + + if (!Number.isFinite(numeric)) { + return 0; + } + + return Math.min(100, Math.max(0, Math.floor(numeric))); +}; + +const getSemanticDistanceThreshold = (minimumSimilarityPercent: number): number => Number((1 - minimumSimilarityPercent / 100).toFixed(4)); + +const getRoomMap = async (roomIds: string[]): Promise>> => { + if (!roomIds.length) { + return new Map(); + } + + const uniqueRoomIds = [...new Set(roomIds)]; + const rooms = await Rooms.findByIds(uniqueRoomIds, { + projection: { _id: 1, t: 1, name: 1, fname: 1 }, + }).toArray(); + + return new Map(rooms.map((room) => [room._id, room])); +}; + +const getUserRoomIds = async (userId: string): Promise => + ( + await Subscriptions.findByUserId(userId, { + projection: { rid: 1 }, + }).toArray() + ).map((subscription) => subscription.rid); + +const extractIntelligentResultIds = (result: any): { rid?: string; msgId?: string } => { + const metadata = result?.metadata ?? {}; + let rid = metadata.room_id || metadata.rid || result?.room_id || result?.rid; + let msgId = metadata.msg_id || metadata.message_id || result?.msg_id || result?.message_id || result?.id; + const externalIdentifier = typeof result?.external_identifier === 'string' ? result.external_identifier : ''; + + if ((!rid || !msgId) && externalIdentifier) { + const separator = externalIdentifier.indexOf(':'); + if (separator > 0 && separator < externalIdentifier.length - 1) { + rid = rid || externalIdentifier.slice(0, separator); + msgId = msgId || externalIdentifier.slice(separator + 1); + } else { + msgId = msgId || externalIdentifier; + } + } + + return { rid, msgId }; +}; + +const normalizeIntelligentResults = async (rawSearchResults: any, userRoomIds: string[]): Promise => { + let rawResults: any[] = []; + + if (Array.isArray(rawSearchResults)) { + rawResults = rawSearchResults; + } else if (Array.isArray(rawSearchResults?.results)) { + rawResults = rawSearchResults.results; + } else if (Array.isArray(rawSearchResults?.context)) { + rawResults = rawSearchResults.context; + } else if (Array.isArray(rawSearchResults?.documents)) { + rawResults = rawSearchResults.documents; + } + + const userRoomIdSet = new Set(userRoomIds); + const candidates = rawResults + .map((result: any, index: number) => { + const { rid, msgId } = extractIntelligentResultIds(result); + const metadata = result?.metadata ?? {}; + const text = String(result?.text || result?.content || result?.document || result?.page_content || metadata.text || ''); + const score = Number(result?.score ?? result?.distance ?? result?.similarity ?? metadata.score); + + return { + _id: `${rid || 'intelligent'}-${msgId || index}`, + rid, + msgId, + text, + ...(Number.isFinite(score) && { score }), + }; + }) + .filter((result: UnifiedSearchIntelligentResult) => result.text && (!result.rid || userRoomIdSet.has(result.rid))) + .slice(0, AI_SEARCH_PAGE_SIZE); + + const rooms = await getRoomMap(candidates.map(({ rid }) => rid).filter(Boolean) as string[]); + + return candidates.map((result) => ({ + ...result, + ...(result.rid && rooms.has(result.rid) && { room: rooms.get(result.rid) }), + })); +}; + +const searchIntelligent = async (query: string, userRoomIds: string[]): Promise => { + const baseUrl = String(settings.get('AI_Intelligent_Search_Pipeline_Base_URL') || '').replace(/\/+$/, ''); + const pipelineId = String(settings.get('AI_Intelligent_Search_Pipeline_ID') || ''); + const apiKey = String(settings.get('AI_Intelligent_Search_API_Key') || ''); + const apiKeySecret = String(settings.get('AI_Intelligent_Search_API_Key_Secret') || ''); + + if (!baseUrl || !pipelineId || !apiKey || !apiKeySecret || !userRoomIds.length) { + return []; + } + + const minimumSimilarity = normalizeSimilarityPercent(settings.get('AI_Intelligent_Search_Min_Similarity_Percent')); + const response = await fetch(`${baseUrl}/pipelines/${encodeURIComponent(pipelineId)}/search`, { + method: 'POST', + timeout: 10000, + ignoreSsrfValidation: false, + allowList: [], + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-API-KEY': apiKey, + 'X-API-KEY-SECRET': apiKeySecret, + }, + body: JSON.stringify({ + query, + type: 'similarity', + classification: { + classifications: [], + search_type: 2, + }, + filters: { + room_id: { + $in: userRoomIds, + }, + }, + params: { + k: AI_SEARCH_PAGE_SIZE, + threshold: getSemanticDistanceThreshold(minimumSimilarity), + }, + }), + }); + + if (!response.ok) { + return []; + } + + return normalizeIntelligentResults(await response.json(), userRoomIds); +}; + API.v1.get( - 'spotlight', + 'search.unified', { authRequired: true, - query: isSpotlightProps, + query: isUnifiedSearchProps, response: { - 200: spotlightResponseSchema, + 200: unifiedSearchResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { - const { query } = this.queryParams; + const query = this.queryParams.query.trim(); + const { count } = await getPaginationItems(this.queryParams); + const limit = Math.min(count || MAX_UNIFIED_SEARCH_RESULTS, MAX_UNIFIED_SEARCH_RESULTS); + + const [spotlight, userRoomIds] = await Promise.all([ + spotlightMethod({ + text: query, + userId: this.userId, + type: { users: true, rooms: true, includeFederatedRooms: true }, + }), + getUserRoomIds(this.userId), + ]); + + const globalMessagesEnabled = settings.get('Search.defaultProvider.GlobalSearchEnabled') === true; + const intelligentSearchEnabled = settings.get('AI_Intelligent_Search_Enabled') === true; + const intelligentSearchConfigured = Boolean( + settings.get('AI_Intelligent_Search_Pipeline_Base_URL') && + settings.get('AI_Intelligent_Search_Pipeline_ID') && + settings.get('AI_Intelligent_Search_API_Key') && + settings.get('AI_Intelligent_Search_API_Key_Secret'), + ); - const result = await spotlightMethod({ text: query, userId: this.userId }); + let messages: UnifiedSearchMessageResult[] = []; + if (this.queryParams.includeMessages && globalMessagesEnabled) { + const searchResult = await messageSearch(this.userId, query, undefined, limit, 0); + const docs = searchResult && searchResult.message ? await normalizeMessagesForUser(searchResult.message.docs, this.userId) : []; + const rooms = await getRoomMap(docs.map((message: IMessage) => message.rid)); + messages = docs.map((message: IMessage) => ({ + _id: message._id, + rid: message.rid, + msg: message.msg, + ts: message.ts, + u: message.u, + ...(rooms.has(message.rid) && { room: rooms.get(message.rid) }), + })); + } - return API.v1.success(result); + let intelligent: UnifiedSearchIntelligentResult[] = []; + if (this.queryParams.includeIntelligent && intelligentSearchEnabled && intelligentSearchConfigured) { + try { + intelligent = await searchIntelligent(query, userRoomIds); + } catch (error) { + SystemLogger.warn({ + msg: 'Intelligent search request failed', + err: error, + }); + } + } + + return API.v1.success({ + users: spotlight.users, + rooms: spotlight.rooms, + messages, + intelligent, + meta: { + globalMessagesEnabled, + intelligentSearchEnabled, + intelligentSearchConfigured, + }, + }); }, ); diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx index 9fc12fafa368f..c4f9f16fcc210 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx @@ -13,13 +13,18 @@ import { getShortcutLabel } from './getShortcutLabel'; import { useSearchClick } from './hooks/useSearchClick'; import { useSearchFocus } from './hooks/useSearchFocus'; import { useSearchInputNavigation } from './hooks/useSearchNavigation'; +import { useExternalLink } from '../../hooks/useExternalLink'; +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; +import { links } from '../../lib/links'; const NavBarSearch = () => { const { t } = useTranslation(); const focusManager = useFocusManager(); const shortcut = getShortcutLabel(); + const handleOpenLink = useExternalLink(); + const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule('rocket.chat-ai'); - const placeholder = [t('Search_rooms'), shortcut].filter(Boolean).join(' '); + const placeholder = [t('Search_users_rooms_messages'), shortcut].filter(Boolean).join(' '); const methods = useForm({ defaultValues: { filterText: '' } }); const { @@ -52,6 +57,14 @@ const NavBarSearch = () => { setFocus('filterText'); }); + const handleIntelligentSearchClick = useEffectEvent(() => { + if (hasIntelligentSearchLicense) { + return; + } + + handleOpenLink(links.go.contactSales); + }); + useEffect(() => { const unsubscribe = tinykeys(window, { '$mod+K': (event) => { @@ -90,11 +103,20 @@ const NavBarSearch = () => { aria-keyshortcuts='Control+K Meta+K Control+P Meta+P' small addon={ - isDirty ? ( - - ) : ( - - ) + + {isDirty ? ( + + ) : ( + + )} + + } /> {state.isOpen && } diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchItem.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchItem.tsx index 7c26ad4febcee..b33ff2d576cc9 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchItem.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchItem.tsx @@ -3,7 +3,7 @@ import type { HTMLAttributes, ReactElement, ReactNode } from 'react'; type NavBarSearchItemProps = { title: string; - avatar: ReactElement; + avatar?: ReactElement | null; icon: ReactNode; actions?: ReactElement; href?: string; diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx index 481081a82e14a..67663ae680b30 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx @@ -3,10 +3,12 @@ import type { OverlayTriggerState } from '@react-stately/overlays'; import { Box, Tile } from '@rocket.chat/fuselage'; import { useDebouncedValue, useEffectEvent, useOutsideClick } from '@rocket.chat/fuselage-hooks'; import { CustomScrollbars } from '@rocket.chat/ui-client'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import NavBarSearchMessageRow from './NavBarSearchMessageRow'; import NavBarSearchNoResults from './NavBarSearchNoResults'; import NavBarSearchRow from './NavBarSearchRow'; import { useSearchItems } from './hooks/useSearchItems'; @@ -35,7 +37,31 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) resetField('filterText'); }); - const { data: items = [], isLoading } = useSearchItems(debouncedFilter); + const { data, isLoading } = useSearchItems(debouncedFilter); + const items = data ?? { + recent: [], + users: [], + rooms: [], + messages: [], + intelligent: [], + meta: { + globalMessagesEnabled: false, + intelligentSearchEnabled: false, + intelligentSearchConfigured: false, + hasIntelligentSearchLicense: false, + showIntelligentSearch: false, + }, + }; + const hasFilter = Boolean(filterText.trim()); + const itemCount = hasFilter + ? items.users.length + items.rooms.length + items.messages.length + items.intelligent.length + : items.recent.length; + + const sectionLabel = (label: string) => ( + + {label} + + ); return ( - +
- {items.length === 0 && !isLoading && } - {items.length > 0 && ( - - {filterText ? t('Results') : t('Recent')} - - )} - {items.map((item) => ( - - ))} + {itemCount === 0 && !isLoading && } + {!hasFilter && items.recent.length > 0 && sectionLabel(t('Recent'))} + {!hasFilter && items.recent.map((item) => )} + {hasFilter && items.users.length > 0 && sectionLabel(t('Users'))} + {hasFilter && items.users.map((item) => )} + {hasFilter && items.rooms.length > 0 && sectionLabel(t('Rooms'))} + {hasFilter && + items.rooms.map((item) => ( + + ))} + {hasFilter && items.messages.length > 0 && sectionLabel(t('Messages'))} + {hasFilter && + items.messages.map((item) => ( + + ))} + {hasFilter && items.intelligent.length > 0 && sectionLabel(t('Intelligent_Search'))} + {hasFilter && + items.intelligent.map((item) => ( + + ))}
diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchMessageRow.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchMessageRow.tsx new file mode 100644 index 0000000000000..f26a4edc33f19 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchMessageRow.tsx @@ -0,0 +1,79 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Box, Icon, SidebarV2ItemIcon } from '@rocket.chat/fuselage'; +import type { UnifiedSearchIntelligentResult, UnifiedSearchMessageResult } from '@rocket.chat/rest-typings'; +import type { ReactElement } from 'react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import NavBarSearchItem from './NavBarSearchItem'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +type NavBarSearchMessageRowProps = { + item: UnifiedSearchMessageResult | UnifiedSearchIntelligentResult; + onClick: () => void; + type: 'message' | 'intelligent'; +}; + +const getMessageId = (item: UnifiedSearchMessageResult | UnifiedSearchIntelligentResult): string | undefined => + 'msgId' in item ? item.msgId : item._id; + +const getRoom = ( + item: UnifiedSearchMessageResult | UnifiedSearchIntelligentResult, +): Pick | undefined => item.room; + +const getText = (item: UnifiedSearchMessageResult | UnifiedSearchIntelligentResult): string => { + if ('text' in item) { + return item.text; + } + + return item.msg || ''; +}; + +const getHref = (item: UnifiedSearchMessageResult | UnifiedSearchIntelligentResult): string | undefined => { + const room = getRoom(item); + const rid = 'rid' in item ? item.rid : undefined; + const msgId = getMessageId(item); + + if (!room && !rid) { + return undefined; + } + + const href = roomCoordinator.getRouteLink(room?.t || 'c', { + rid: room?._id || rid, + name: room?.name, + }); + + if (!href) { + return undefined; + } + + return msgId ? `${href}?msg=${encodeURIComponent(msgId)}` : href; +}; + +const NavBarSearchMessageRow = ({ item, onClick, type }: NavBarSearchMessageRowProps): ReactElement => { + const { t } = useTranslation(); + const room = getRoom(item); + const text = getText(item); + const title = text.trim() || t(type === 'intelligent' ? 'Intelligent_Search_Result' : 'Message'); + const roomLabel = room?.fname || room?.name; + const href = getHref(item); + + return ( + } />} + actions={ + roomLabel ? ( + + {roomLabel} + + ) : undefined + } + /> + ); +}; + +export default memo(NavBarSearchMessageRow); diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index a44dceeb2f8f7..92b02db7c6bdc 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -1,9 +1,12 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { UnifiedSearchIntelligentResult, UnifiedSearchMessageResult } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useMethod, useUserSubscriptions } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetting, useUserSubscriptions } from '@rocket.chat/ui-contexts'; import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { getConfig } from '../../../lib/utils/getConfig'; const LIMIT = parseInt(String(getConfig('Sidebar_Search_Spotlight_LIMIT', 20))); @@ -16,8 +19,53 @@ const options = { limit: LIMIT, } as const; -// FIXME: the return type is UTTERLY wrong, but I'm not sure what it should be -export const useSearchItems = (filterText: string): UseQueryResult => { +type SearchRoom = Pick & { + uids?: string[]; + avatarETag?: string; +}; + +type SearchUser = Pick; + +export type NavBarSearchSections = { + recent: SubscriptionWithRoom[]; + users: SubscriptionWithRoom[]; + rooms: SearchRoom[]; + messages: UnifiedSearchMessageResult[]; + intelligent: UnifiedSearchIntelligentResult[]; + meta: { + globalMessagesEnabled: boolean; + intelligentSearchEnabled: boolean; + intelligentSearchConfigured: boolean; + hasIntelligentSearchLicense: boolean; + showIntelligentSearch: boolean; + }; +}; + +const emptySections = (recent: SubscriptionWithRoom[], hasLicense: boolean, showIntelligentSearch: boolean): NavBarSearchSections => ({ + recent, + users: [], + rooms: [], + messages: [], + intelligent: [], + meta: { + globalMessagesEnabled: false, + intelligentSearchEnabled: false, + intelligentSearchConfigured: false, + hasIntelligentSearchLicense: hasLicense, + showIntelligentSearch, + }, +}); + +const mapUserToRoom = (user: SearchUser): SubscriptionWithRoom => + ({ + _id: user._id, + t: 'd', + name: user.username, + fname: user.name, + avatarETag: user.avatarETag, + }) as SubscriptionWithRoom; + +export const useSearchItems = (filterText: string): UseQueryResult => { const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]); const query = useMemo(() => { const filterRegex = new RegExp(escapeRegExp(name), 'i'); @@ -32,32 +80,44 @@ export const useSearchItems = (filterText: string): UseQueryResult (t === 'd' ? name : null))].filter(Boolean) as string[]; + const usernamesFromClient = localRooms.map(({ t, name }) => (t === 'd' ? name : null)).filter(Boolean) as string[]; const searchForChannels = mention === '#'; const searchForDMs = mention === '@'; - const type = useMemo(() => { - if (searchForChannels) { - return { users: false, rooms: true, includeFederatedRooms: true }; - } - if (searchForDMs) { - return { users: true, rooms: false }; - } - return { users: true, rooms: true, includeFederatedRooms: true }; - }, [searchForChannels, searchForDMs]); - - const getSpotlight = useMethod('spotlight'); + const unifiedSearch = useEndpoint('GET', '/v1/search.unified'); + const globalSearchEnabled = useSetting('Search.defaultProvider.GlobalSearchEnabled', false); + const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + const showIntelligentSearch = useSetting('AI_Intelligent_Search_Show_In_Top_Bar', true); + const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule('rocket.chat-ai'); return useQuery({ - queryKey: ['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id, name }) => _id + name)], + queryKey: [ + 'sidebar/search/unified', + name, + usernamesFromClient, + searchForChannels, + searchForDMs, + globalSearchEnabled, + intelligentSearchEnabled, + hasIntelligentSearchLicense, + showIntelligentSearch, + localRooms.map(({ _id, name }) => _id + name), + ], queryFn: async () => { - if (localRooms.length === LIMIT) { - return localRooms; + const base = emptySections(localRooms, hasIntelligentSearchLicense, showIntelligentSearch); + + if (!name.trim() || localRooms.length === LIMIT) { + return base; } - const spotlight = await getSpotlight(name, usernamesFromClient, type); + const result = await unifiedSearch({ + query: name, + count: LIMIT, + includeMessages: Boolean(globalSearchEnabled && !mention), + includeIntelligent: Boolean(hasIntelligentSearchLicense && intelligentSearchEnabled && showIntelligentSearch && !mention), + }); const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => index === arr.findIndex((user) => _id === user._id); @@ -71,44 +131,24 @@ export const useSearchItems = (filterText: string): UseQueryResult !localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); - const userMap = (user: { - _id: string; - name: string; - username: string; - avatarETag?: string; - }): { - _id: string; - t: string; - name: string; - fname: string; - avatarETag?: string; - } => ({ - _id: user._id, - t: 'd', - name: user.username, - fname: user.name, - avatarETag: user.avatarETag, - }); - - type resultsFromServerType = { - _id: string; - t: string; - name: string; - teamMain?: boolean; - fname?: string; - avatarETag?: string | undefined; - uids?: string[] | undefined; - }[]; - - const resultsFromServer: resultsFromServerType = []; - resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersFilter).map(userMap)); - resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); - - const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name)); - return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])); + const users = searchForChannels ? [] : result.users.filter(filterUsersUnique).filter(usersFilter).map(mapUserToRoom); + const rooms = searchForDMs ? [] : (result.rooms as SearchRoom[]).filter(roomFilter); + + return { + recent: localRooms, + users, + rooms, + messages: result.messages, + intelligent: result.intelligent, + meta: { + ...result.meta, + hasIntelligentSearchLicense, + showIntelligentSearch, + }, + }; }, staleTime: 60_000, - placeholderData: (previousData) => previousData ?? localRooms, + placeholderData: (previousData) => previousData ?? emptySections(localRooms, hasIntelligentSearchLicense, showIntelligentSearch), }); }; diff --git a/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx b/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx new file mode 100644 index 0000000000000..40fc5cec51f00 --- /dev/null +++ b/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx @@ -0,0 +1,23 @@ +import { useIsPrivilegedSettingsContext, useRouter } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; + +import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; +import SettingsGroupSelector from '../settings/SettingsGroupSelector'; + +const AICenterRoute = (): ReactElement => { + const hasPermission = useIsPrivilegedSettingsContext(); + const router = useRouter(); + + if (!hasPermission) { + return ; + } + + return ( + + router.navigate('/admin')} /> + + ); +}; + +export default AICenterRoute; diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index 60f35d50d3109..30fa4850b2c3b 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -108,6 +108,10 @@ declare module '@rocket.chat/ui-contexts' { pathname: '/admin/ABAC'; pattern: '/admin/ABAC/:tab?/:context?/:id?'; }; + 'admin-ai-center': { + pathname: '/admin/ai-center'; + pattern: '/admin/ai-center'; + }; } } @@ -192,6 +196,11 @@ registerAdminRoute('/rooms/:context?/:id?', { component: lazy(() => import('./rooms/RoomsRoute')), }); +registerAdminRoute('/ai-center', { + name: 'admin-ai-center', + component: lazy(() => import('./aiCenter/AICenterRoute')), +}); + registerAdminRoute('/invites', { name: 'invites', component: lazy(() => import('./invites/InvitesRoute')), diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index 3c8867ee26236..dd81decd386cb 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -46,6 +46,13 @@ export const { icon: 'team', permissionGranted: (): boolean => hasPermission('view-user-administration'), }, + { + href: '/admin/ai-center', + i18nLabel: 'AI_Center', + icon: 'stars', + permissionGranted: (): boolean => + hasAtLeastOnePermission(['view-privileged-setting', 'edit-privileged-setting', 'manage-selected-settings']), + }, { href: '/admin/invites', i18nLabel: 'Invites', diff --git a/apps/meteor/server/settings/ai.ts b/apps/meteor/server/settings/ai.ts new file mode 100644 index 0000000000000..428a2577a40c0 --- /dev/null +++ b/apps/meteor/server/settings/ai.ts @@ -0,0 +1,82 @@ +import { settingsRegistry } from '../../app/settings/server'; + +const AI_LICENSE_MODULE = 'rocket.chat-ai' as const; + +export const createAISettings = () => + settingsRegistry.addGroup('AI_Center', async function () { + await this.section('Intelligent_Search', async function () { + await this.add('AI_Intelligent_Search_Enabled', false, { + type: 'boolean', + public: true, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: false, + i18nDescription: 'AI_Intelligent_Search_Enabled_Description', + }); + + await this.add('AI_Intelligent_Search_Show_In_Top_Bar', true, { + type: 'boolean', + public: true, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: false, + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + i18nDescription: 'AI_Intelligent_Search_Show_In_Top_Bar_Description', + }); + + await this.add('AI_Intelligent_Search_Pipeline_Base_URL', '', { + type: 'string', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + i18nDescription: 'AI_Intelligent_Search_Pipeline_Base_URL_Description', + }); + + await this.add('AI_Intelligent_Search_Pipeline_ID', '', { + type: 'string', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + i18nDescription: 'AI_Intelligent_Search_Pipeline_ID_Description', + }); + + await this.add('AI_Intelligent_Search_API_Key', '', { + type: 'password', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + }); + + await this.add('AI_Intelligent_Search_API_Key_Secret', '', { + type: 'password', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + }); + + await this.add('AI_Intelligent_Search_Min_Similarity_Percent', 0, { + type: 'int', + public: true, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: 0, + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + i18nDescription: 'AI_Intelligent_Search_Min_Similarity_Percent_Description', + }); + }); + + await this.section('AI_Thread_Summarization', async function () { + await this.add('AI_Thread_Summarization_Enabled', false, { + type: 'boolean', + public: true, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: false, + i18nDescription: 'AI_Thread_Summarization_Enabled_Description', + }); + }); + }); diff --git a/apps/meteor/server/settings/index.ts b/apps/meteor/server/settings/index.ts index 83d341552e634..ad07daccc77f1 100644 --- a/apps/meteor/server/settings/index.ts +++ b/apps/meteor/server/settings/index.ts @@ -1,4 +1,5 @@ import { createAccountSettings } from './accounts'; +import { createAISettings } from './ai'; import { createAnalyticsSettings } from './analytics'; import { createAssetsSettings } from './assets'; import { createBotsSettings } from './bots'; @@ -39,6 +40,7 @@ import { addMatrixBridgeFederationSettings } from '../services/federation/Settin await Promise.all([ createFederationServiceSettings(), createAccountSettings(), + createAISettings(), createAnalyticsSettings(), createAssetsSettings(), createBotsSettings(), diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index dff0975e48b58..633830c273bc9 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -122,6 +122,22 @@ "Alt_text_description": "Describe the image to give context, including for blind and low-vision users and when it fails to load.", "Enable_ABAC_and_LDAP_to_sync": "Enable ABAC and LDAP to be able to sync", "AI_Actions": "AI actions", + "AI_Center": "AI Center", + "AI_Intelligent_Search_API_Key": "Pipeline API key", + "AI_Intelligent_Search_API_Key_Secret": "Pipeline API key secret", + "AI_Intelligent_Search_Enabled": "Enable intelligent search", + "AI_Intelligent_Search_Enabled_Description": "Use the configured vector-search pipeline to add semantic results to workspace search.", + "AI_Intelligent_Search_Min_Similarity_Percent": "Minimum semantic similarity (%)", + "AI_Intelligent_Search_Min_Similarity_Percent_Description": "Higher values return fewer but closer semantic matches. Use 0 to keep the widest result set.", + "AI_Intelligent_Search_Pipeline_Base_URL": "Pipeline API base URL", + "AI_Intelligent_Search_Pipeline_Base_URL_Description": "Base URL for the intelligent-search pipeline API.", + "AI_Intelligent_Search_Pipeline_ID": "Pipeline ID", + "AI_Intelligent_Search_Pipeline_ID_Description": "Identifier of the target pipeline used for semantic search requests.", + "AI_Intelligent_Search_Show_In_Top_Bar": "Show intelligent search in top bar", + "AI_Intelligent_Search_Show_In_Top_Bar_Description": "Adds semantic results to the global top-bar search panel when intelligent search is configured.", + "AI_Thread_Summarization": "Thread summarization", + "AI_Thread_Summarization_Enabled": "Enable thread summarization", + "AI_Thread_Summarization_Enabled_Description": "Reserved for AI-generated thread summaries.", "API": "API", "API_Add_Personal_Access_Token": "Add new Personal Access Token", "API_Allow_Infinite_Count": "Allow Getting Everything", @@ -1293,6 +1309,7 @@ "Contact_identification": "Contact identification", "Contact_not_found": "Contact not found", "Contact_sales": "Contact sales", + "Contact_sales_for_Intelligent_Search": "Contact sales for intelligent search", "Contact_sales_renew_date": "<0>Contact sales to check plan renew date", "Contact_sales_start_using_VoIP": "Contact sales to start using VoIP.", "Contact_sales_trial": "Contact sales to finish your purchase and avoid <1>downgrade consequences.", @@ -2702,6 +2719,9 @@ "Installed": "Installed", "Installed_at": "Installed at", "Installing": "Installing", + "Intelligent_Search": "Intelligent search", + "Intelligent_Search_Result": "Intelligent search result", + "Intelligent_Search_locked": "Intelligent search locked", "Instance": "Instance", "Instance_Record": "Instance Record", "Instances": "Instances", @@ -4803,6 +4823,7 @@ "Search_options": "Search options", "Search_roles": "Search roles", "Search_rooms": "Search rooms", + "Search_users_rooms_messages": "Search users, rooms, and messages", "Searchable": "Searchable", "Seat_limit_reached": "Seat limit reached", "Seat_limit_reached_Description": "Your workspace reached its contractual seat limit. Buy more seats to add more users.", @@ -7237,4 +7258,4 @@ "Avatar_preview_updated": "Avatar preview updated", "Select_message_from_user": "Select message from {{username}}", "Select_message_from_user_with_preview": "Select message from {{username}}: {{message}}" -} \ No newline at end of file +} diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 5df161c0e1c33..b47af5b82df8d 100644 --- a/packages/rest-typings/src/v1/misc.ts +++ b/packages/rest-typings/src/v1/misc.ts @@ -1,4 +1,4 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { ajv, ajvQuery } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -53,6 +53,59 @@ const SpotlightSchema = { export const isSpotlightProps = ajvQuery.compile(SpotlightSchema); +type UnifiedSearch = PaginatedRequest<{ + query: string; + includeMessages?: boolean; + includeIntelligent?: boolean; +}>; + +const UnifiedSearchSchema = { + type: 'object', + properties: { + query: { + type: 'string', + minLength: 1, + }, + includeMessages: { + type: 'boolean', + nullable: true, + }, + includeIntelligent: { + type: 'boolean', + nullable: true, + }, + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + }, + required: ['query'], + additionalProperties: false, +}; + +export const isUnifiedSearchProps = ajvQuery.compile(UnifiedSearchSchema); + +export type UnifiedSearchMessageResult = Pick & { + room?: Pick; +}; + +export type UnifiedSearchIntelligentResult = { + _id: string; + rid?: string; + msgId?: string; + text: string; + score?: number; + room?: Pick; +}; + type Directory = PaginatedRequest<{ text: string; type: string; @@ -190,6 +243,20 @@ export type MiscEndpoints = { }; }; + '/v1/search.unified': { + GET: (params: UnifiedSearch) => { + users: (Pick, 'name' | 'status' | '_id' | 'username'> & Partial>)[]; + rooms: Pick, 't' | 'name' | '_id'>[]; + messages: UnifiedSearchMessageResult[]; + intelligent: UnifiedSearchIntelligentResult[]; + meta: { + globalMessagesEnabled: boolean; + intelligentSearchEnabled: boolean; + intelligentSearchConfigured: boolean; + }; + }; + }; + '/v1/pw.getPolicy': { GET: () => { enabled: boolean; From ac71d0ef852e560c6a43f8822a5b4b9132f98336 Mon Sep 17 00:00:00 2001 From: Dnouv Date: Wed, 20 May 2026 16:33:52 +0530 Subject: [PATCH 02/50] Improve AI search UX --- .../navbar/NavBarSearch/NavBarSearch.tsx | 65 +++- .../NavBarSearch/NavBarSearchListbox.tsx | 23 +- .../NavBarSearch/hooks/useSearchItems.ts | 2 +- apps/meteor/client/startup/routes.tsx | 14 + .../views/admin/aiCenter/AICenterRoute.tsx | 163 ++++++++- apps/meteor/client/views/admin/routes.tsx | 6 +- .../meteor/client/views/search/SearchPage.tsx | 329 ++++++++++++++++++ apps/meteor/server/settings/ai.ts | 2 +- packages/i18n/src/locales/en.i18n.json | 34 ++ 9 files changed, 617 insertions(+), 21 deletions(-) create mode 100644 apps/meteor/client/views/search/SearchPage.tsx diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx index c4f9f16fcc210..649eeaaf1e060 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx @@ -3,6 +3,8 @@ import { useOverlayTrigger } from '@react-aria/overlays'; import { useOverlayTriggerState } from '@react-stately/overlays'; import { Box, Icon, IconButton, TextInput } from '@rocket.chat/fuselage'; import { useEffectEvent, useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { useRouter, useSetModal } from '@rocket.chat/ui-contexts'; +import type { KeyboardEvent } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -13,22 +15,26 @@ import { getShortcutLabel } from './getShortcutLabel'; import { useSearchClick } from './hooks/useSearchClick'; import { useSearchFocus } from './hooks/useSearchFocus'; import { useSearchInputNavigation } from './hooks/useSearchNavigation'; -import { useExternalLink } from '../../hooks/useExternalLink'; +import { getURL } from '../../../app/utils/client/getURL'; +import GenericUpsellModal from '../../components/GenericUpsellModal'; +import { useUpsellActions } from '../../components/GenericUpsellModal/hooks'; import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; -import { links } from '../../lib/links'; const NavBarSearch = () => { const { t } = useTranslation(); const focusManager = useFocusManager(); const shortcut = getShortcutLabel(); - const handleOpenLink = useExternalLink(); - const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule('rocket.chat-ai'); + const router = useRouter(); + const setModal = useSetModal(); + const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule('chat.rocket.rc-ai'); + const { handleTalkToSales } = useUpsellActions(hasIntelligentSearchLicense); const placeholder = [t('Search_users_rooms_messages'), shortcut].filter(Boolean).join(' '); const methods = useForm({ defaultValues: { filterText: '' } }); const { formState: { isDirty }, + getValues, register, resetField, setFocus, @@ -43,10 +49,41 @@ const NavBarSearch = () => { const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'listbox' }, state, triggerRef); delete triggerProps.onPress; - const handleKeyDown = useSearchInputNavigation(state); + const handleSearchKeyDown = useSearchInputNavigation(state); const handleFocus = useSearchFocus(state); const handleClick = useSearchClick(state); + const navigateToSearch = useCallback( + (filterText: string, tab?: string): void => { + const searchParams = new URLSearchParams(); + if (filterText.trim()) { + searchParams.set('q', filterText.trim()); + } + if (tab) { + searchParams.set('tab', tab); + } + router.navigate({ + name: 'search', + search: Object.fromEntries(searchParams.entries()), + }); + state.close(); + }, + [router, state], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent): void => { + if (event.key === 'Enter') { + event.preventDefault(); + navigateToSearch(getValues('filterText')); + return; + } + + handleSearchKeyDown(event); + }, + [getValues, handleSearchKeyDown, navigateToSearch], + ); + const handleEscSearch = useCallback(() => { resetField('filterText'); state.close(); @@ -59,10 +96,24 @@ const NavBarSearch = () => { const handleIntelligentSearchClick = useEffectEvent(() => { if (hasIntelligentSearchLicense) { + navigateToSearch(getValues('filterText'), 'intelligent'); return; } - handleOpenLink(links.go.contactSales); + setModal( + setModal(null)} + onConfirm={handleTalkToSales} + onCancel={() => setModal(null)} + imgHeight={256} + />, + ); }); useEffect(() => { @@ -111,7 +162,7 @@ const NavBarSearch = () => { )} { const { t } = useTranslation(); + const router = useRouter(); const containerRef = useRef(null); const handleKeyDown = useListboxNavigation(state); @@ -37,6 +39,18 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) resetField('filterText'); }); + const handleOpenSearchPage = useEffectEvent(() => { + const searchParams = new URLSearchParams(); + if (filterText.trim()) { + searchParams.set('q', filterText.trim()); + } + router.navigate({ + name: 'search', + search: Object.fromEntries(searchParams.entries()), + }); + state.close(); + }); + const { data, isLoading } = useSearchItems(debouncedFilter); const items = data ?? { recent: [], @@ -102,6 +116,13 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) ))} + {hasFilter && ( + + + + )} ); }; diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index 92b02db7c6bdc..d206e913f72d7 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -89,7 +89,7 @@ export const useSearchItems = (filterText: string): UseQueryResult import('../views/oauth/OAuthAuthorizat const OAuthErrorPage = lazy(() => import('../views/oauth/OAuthErrorPage')); const NotFoundPage = lazy(() => import('../views/notFound/NotFoundPage')); const CallHistoryPage = lazy(() => import('../views/mediaCallHistory/CallHistoryPage')); +const SearchPage = lazy(() => import('../views/search/SearchPage')); declare module '@rocket.chat/ui-contexts' { interface IRouterPaths { @@ -111,6 +112,10 @@ declare module '@rocket.chat/ui-contexts' { pathname: `/call-history${`/details/${string}` | ''}`; pattern: '/call-history/:tab?/:historyId?'; }; + 'search': { + pathname: '/search'; + pattern: '/search'; + }; } } @@ -241,6 +246,15 @@ router.defineRoutes([ , ), }, + { + path: '/search', + id: 'search', + element: appLayout.wrap( + + + , + ), + }, { path: '*', id: 'not-found', diff --git a/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx b/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx index 40fc5cec51f00..e135951bdc292 100644 --- a/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx +++ b/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx @@ -1,23 +1,170 @@ -import { useIsPrivilegedSettingsContext, useRouter } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +/* eslint-disable react/no-multi-comp */ +import { Box, Button, Callout, Card, CardBody, CardControls, CardGrid, CardTitle, Icon, Tag } from '@rocket.chat/fuselage'; +import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client'; +import { useIsPrivilegedSettingsContext, useRouteParameter, useRouter, useSetting } from '@rocket.chat/ui-contexts'; +import type { ComponentProps, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; import EditableSettingsProvider from '../settings/EditableSettingsProvider'; -import SettingsGroupSelector from '../settings/SettingsGroupSelector'; +import GenericGroupPage from '../settings/groups/GenericGroupPage'; -const AICenterRoute = (): ReactElement => { - const hasPermission = useIsPrivilegedSettingsContext(); +type CapabilityCardProps = { + icon: ComponentProps['name']; + title: string; + description: string; + status?: ReactElement; + actionLabel: string; + disabled?: boolean; + onClick?: () => void; +}; + +const CapabilityCard = ({ icon, title, description, status, actionLabel, disabled, onClick }: CapabilityCardProps): ReactElement => ( + + + + + + + {status} + + {title} + + + {description} + + + + + + + +); + +const AICenterOverview = (): ReactElement => { + const { t } = useTranslation(); const router = useRouter(); + const { data: hasAILicense = false } = useHasLicenseModule('chat.rocket.rc-ai'); + const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + const threadSummarizationEnabled = useSetting('AI_Thread_Summarization_Enabled', false); - if (!hasPermission) { - return ; + let premiumStatus = {t('Disabled')}; + if (!hasAILicense) { + premiumStatus = {t('Locked')}; + } else if (intelligentSearchEnabled) { + premiumStatus = {t('Enabled')}; } + return ( + + + + + {!hasAILicense && ( + + + {t('AI_Center_license_required_description')} + + + + )} + + + {t('Capabilities')} + + + + router.navigate('/admin/ai-center/search')} + /> + {t('Enabled')} : {t('Disabled')}} + actionLabel={t('Configure')} + onClick={() => router.navigate('/admin/ai-center/thread-summarization')} + /> + {t('Coming_soon')}} + actionLabel={t('Manage')} + disabled + /> + {t('Coming_soon')}} + actionLabel={t('Manage')} + disabled + /> + {t('Coming_soon')}} + actionLabel={t('Manage')} + disabled + /> + + + + + ); +}; + +const AISettingsSection = ({ section }: { section: 'Intelligent_Search' | 'AI_Thread_Summarization' }): ReactElement => { + const { t } = useTranslation(); + const router = useRouter(); + const title = section === 'Intelligent_Search' ? 'Intelligent_Search' : 'Thread_Summarization'; + return ( - router.navigate('/admin')} /> + router.navigate('/admin/ai-center')} + headerButtons={ + + } + /> ); }; +const AICenterRoute = (): ReactElement => { + const hasPermission = useIsPrivilegedSettingsContext(); + const section = useRouteParameter('section'); + + if (!hasPermission) { + return ; + } + + if (section === 'search') { + return ; + } + + if (section === 'thread-summarization') { + return ; + } + + return ; +}; + export default AICenterRoute; diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index 30fa4850b2c3b..b12a37ec3aeec 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -109,8 +109,8 @@ declare module '@rocket.chat/ui-contexts' { pattern: '/admin/ABAC/:tab?/:context?/:id?'; }; 'admin-ai-center': { - pathname: '/admin/ai-center'; - pattern: '/admin/ai-center'; + pathname: `/admin/ai-center${`/${string}` | ''}`; + pattern: '/admin/ai-center/:section?'; }; } } @@ -196,7 +196,7 @@ registerAdminRoute('/rooms/:context?/:id?', { component: lazy(() => import('./rooms/RoomsRoute')), }); -registerAdminRoute('/ai-center', { +registerAdminRoute('/ai-center/:section?', { name: 'admin-ai-center', component: lazy(() => import('./aiCenter/AICenterRoute')), }); diff --git a/apps/meteor/client/views/search/SearchPage.tsx b/apps/meteor/client/views/search/SearchPage.tsx new file mode 100644 index 0000000000000..5569cccc47ab1 --- /dev/null +++ b/apps/meteor/client/views/search/SearchPage.tsx @@ -0,0 +1,329 @@ +/* eslint-disable react/no-multi-comp */ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Box, Button, Callout, Icon, SearchInput, Tabs, TabsItem, Tag } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { RoomAvatar, UserAvatar } from '@rocket.chat/ui-avatar'; +import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client'; +import { useEndpoint, useRouter, useSearchParameter, useSetting } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import type { ChangeEvent, FormEvent, ReactElement, ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +type SearchTab = 'all' | 'messages' | 'users' | 'rooms' | 'intelligent'; + +type SearchUser = Pick, 'name' | 'status' | '_id' | 'username'> & Partial>; + +type SearchRoom = Pick, 't' | 'name' | '_id'> & Partial>; + +type SearchMessageLike = { + _id: string; + rid?: string; + msg?: string; + msgId?: string; + text?: string; + score?: number; + room?: Pick; +}; + +const tabs: SearchTab[] = ['all', 'messages', 'users', 'rooms', 'intelligent']; + +const getValidTab = (tab?: string | null): SearchTab => (tabs.includes(tab as SearchTab) ? (tab as SearchTab) : 'all'); + +const getMessageText = (item: SearchMessageLike): string => { + if ('text' in item) { + return item.text || ''; + } + + return item.msg || ''; +}; + +const getMessageRoom = (item: SearchMessageLike): Pick | undefined => item.room; + +const getMessageId = (item: SearchMessageLike): string => item.msgId || item._id; + +const getMessageHref = (item: SearchMessageLike): string | undefined => { + const room = getMessageRoom(item); + const rid = 'rid' in item ? item.rid : undefined; + const href = roomCoordinator.getRouteLink(room?.t || 'c', { + rid: room?._id || rid, + name: room?.name, + }); + + if (!href) { + return undefined; + } + + return `${href}?msg=${encodeURIComponent(getMessageId(item))}`; +}; + +const SearchResultLink = ({ + href, + icon, + title, + subtitle, + meta, +}: { + href?: string; + icon: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + meta?: ReactNode; +}): ReactElement => ( + + + {icon} + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {meta && ( + + {meta} + + )} + +); + +const EmptySearchState = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +const Section = ({ title, count, children }: { title: string; count: number; children: ReactNode }): ReactElement | null => { + if (count === 0) { + return null; + } + + return ( + + + + {title} + + {count} + + + {children} + + + ); +}; + +const SearchPage = (): ReactElement => { + const { t } = useTranslation(); + const router = useRouter(); + const queryParam = useSearchParameter('q') ?? ''; + const tabParam = useSearchParameter('tab'); + const activeTab = getValidTab(tabParam); + const [query, setQuery] = useState(queryParam); + const debouncedQuery = useDebouncedValue(query.trim(), 300); + + const globalSearchEnabled = useSetting('Search.defaultProvider.GlobalSearchEnabled', false); + const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule('chat.rocket.rc-ai'); + const unifiedSearch = useEndpoint('GET', '/v1/search.unified'); + + useEffect(() => { + setQuery(queryParam); + }, [queryParam]); + + const result = useQuery({ + queryKey: ['search/unified/page', debouncedQuery, hasIntelligentSearchLicense, globalSearchEnabled, intelligentSearchEnabled], + queryFn: () => + unifiedSearch({ + query: debouncedQuery, + count: 20, + includeMessages: Boolean(globalSearchEnabled), + includeIntelligent: Boolean(hasIntelligentSearchLicense && intelligentSearchEnabled), + }), + enabled: debouncedQuery.length > 0, + }); + + const { data } = result; + const counts = useMemo( + () => ({ + users: data?.users.length ?? 0, + rooms: data?.rooms.length ?? 0, + messages: data?.messages.length ?? 0, + intelligent: data?.intelligent.length ?? 0, + all: (data?.users.length ?? 0) + (data?.rooms.length ?? 0) + (data?.messages.length ?? 0) + (data?.intelligent.length ?? 0), + }), + [data], + ); + + const navigateSearch = (nextQuery: string, nextTab = activeTab): void => { + const searchParams = new URLSearchParams(); + if (nextQuery.trim()) { + searchParams.set('q', nextQuery.trim()); + } + if (nextTab !== 'all') { + searchParams.set('tab', nextTab); + } + router.navigate({ + name: 'search', + search: Object.fromEntries(searchParams.entries()), + }); + }; + + const handleChange = (event: ChangeEvent): void => { + setQuery(event.currentTarget.value); + }; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + navigateSearch(query); + }; + + const handleTabClick = (tab: SearchTab) => (): void => { + navigateSearch(query, tab); + }; + + const showUsers = activeTab === 'all' || activeTab === 'users'; + const showRooms = activeTab === 'all' || activeTab === 'rooms'; + const showMessages = activeTab === 'all' || activeTab === 'messages'; + const showIntelligent = activeTab === 'all' || activeTab === 'intelligent'; + const hasQuery = Boolean(debouncedQuery); + const hasResults = counts[activeTab] > 0; + + return ( + + + + } + /> + + + {tabs.map((tab) => ( + + {t(`Search_tab_${tab}`)} + {hasQuery && ( + + {counts[tab]} + + )} + + ))} + + + + {!globalSearchEnabled && showMessages && ( + + {t('Search_messages_disabled_description')} + + )} + {!hasIntelligentSearchLicense && showIntelligent && ( + + + {t('Intelligent_Search_upsell_description')} + + + + )} + {hasIntelligentSearchLicense && !intelligentSearchEnabled && showIntelligent && ( + + + {t('Intelligent_Search_disabled_description')} + + + + )} + {hasIntelligentSearchLicense && intelligentSearchEnabled && data && !data.meta.intelligentSearchConfigured && showIntelligent && ( + + + {t('Intelligent_Search_missing_configuration_description')} + + + + )} + + {!hasQuery && {t('Search_page_empty_state')}} + {result.isLoading && {t('Loading')}} + {hasQuery && !result.isLoading && !hasResults && {t('No_results_found')}} + + {data && !result.isLoading && ( + <> + {showIntelligent && ( +
+ {data.intelligent.map((item) => ( + } + title={getMessageText(item)} + subtitle={item.room?.fname || item.room?.name || t('Intelligent_Search_Result')} + meta={typeof item.score === 'number' ? {Math.round(item.score * 100)}% : undefined} + /> + ))} +
+ )} + {showMessages && ( +
+ {data.messages.map((item) => ( + } + title={getMessageText(item)} + subtitle={item.room?.fname || item.room?.name || item.u?.username} + /> + ))} +
+ )} + {showUsers && ( +
+ {(data.users as SearchUser[]).map((user) => ( + } + title={user.name || user.username} + subtitle={`@${user.username}`} + meta={user.status ? {user.status} : undefined} + /> + ))} +
+ )} + {showRooms && ( +
+ {(data.rooms as SearchRoom[]).map((room) => ( + } + title={room.fname || room.name} + subtitle={room.t === 'd' ? t('Direct_message') : t('Room')} + /> + ))} +
+ )} + + )} +
+
+
+ ); +}; + +export default SearchPage; diff --git a/apps/meteor/server/settings/ai.ts b/apps/meteor/server/settings/ai.ts index 428a2577a40c0..5180a9985191a 100644 --- a/apps/meteor/server/settings/ai.ts +++ b/apps/meteor/server/settings/ai.ts @@ -1,6 +1,6 @@ import { settingsRegistry } from '../../app/settings/server'; -const AI_LICENSE_MODULE = 'rocket.chat-ai' as const; +const AI_LICENSE_MODULE = 'chat.rocket.rc-ai' as const; export const createAISettings = () => settingsRegistry.addGroup('AI_Center', async function () { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 633830c273bc9..70c61e8530fa6 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -123,6 +123,16 @@ "Enable_ABAC_and_LDAP_to_sync": "Enable ABAC and LDAP to be able to sync", "AI_Actions": "AI actions", "AI_Center": "AI Center", + "AI_Center_Agents": "Agents", + "AI_Center_Agents_card_description": "Build assistants with tools, skills, and channel access.", + "AI_Center_Intelligent_Search_card_description": "Search across users, rooms, messages, and semantic workspace knowledge.", + "AI_Center_LLM_Providers": "LLM Providers", + "AI_Center_LLM_Providers_card_description": "Connect language model providers and govern model access.", + "AI_Center_MCP_Connections": "MCP Connections", + "AI_Center_MCP_Connections_card_description": "Wire external tool servers into AI capabilities.", + "AI_Center_Thread_Summarization_card_description": "Prepare AI-generated summaries for long threads and busy conversations.", + "AI_Center_license_required_description": "The chat.rocket.rc-ai add-on is required to enable intelligent search and other premium AI capabilities.", + "AI_Center_license_required_title": "AI add-on required", "AI_Intelligent_Search_API_Key": "Pipeline API key", "AI_Intelligent_Search_API_Key_Secret": "Pipeline API key secret", "AI_Intelligent_Search_Enabled": "Enable intelligent search", @@ -1318,6 +1328,9 @@ "Contacts": "Contacts", "Contains_Security_Fixes": "Contains Security Fixes", "Content": "Content", + "Capabilities": "Capabilities", + "Coming_soon": "Coming soon", + "Configure": "Configure", "Continue": "Continue", "Continue_Adding": "Continue Adding?", "Continuous_sound_notifications_for_new_livechat_room": "Continuous sound notifications for new omnichannel room", @@ -2720,8 +2733,16 @@ "Installed_at": "Installed at", "Installing": "Installing", "Intelligent_Search": "Intelligent search", + "Intelligent_Search_disabled_description": "Enable intelligent search and configure the pipeline before semantic results appear in workspace search.", + "Intelligent_Search_disabled_title": "Intelligent search is disabled", + "Intelligent_Search_missing_configuration_description": "Add the pipeline base URL, pipeline ID, and credentials in AI Center.", + "Intelligent_Search_missing_configuration_title": "Intelligent search needs configuration", "Intelligent_Search_Result": "Intelligent search result", "Intelligent_Search_locked": "Intelligent search locked", + "Intelligent_Search_upsell_description": "Unlock semantic results that can find relevant messages even when the exact words are not used.", + "Intelligent_Search_upsell_modal_description": "Intelligent search connects Rocket.Chat to a vector-search pipeline so people can find users, rooms, messages, and semantic results from one search experience.", + "Intelligent_Search_upsell_modal_subtitle": "Bring AI-powered discovery into workspace search", + "Intelligent_Search_upsell_title": "Add intelligent search", "Instance": "Instance", "Instance_Record": "Instance Record", "Instances": "Instances", @@ -3372,6 +3393,7 @@ "Make_Admin": "Make Admin", "Make_sure_you_have_a_copy_of_your_codes_1": "Make sure you have a copy of your codes:", "Make_sure_you_have_a_copy_of_your_codes_2": "If you lose access to your authenticator app, you can use one of these codes to log in.", + "Locked": "Locked", "Manage": "Manage", "Manage_Devices": "Manage Devices", "Manage_Omnichannel": "Manage Omnichannel", @@ -4062,6 +4084,7 @@ "Oops!": "Oops", "Oops_page_not_found": "Oops, page not found", "Open": "Open", + "Overview": "Overview", "Open-source_conference_call_solution": "Open-source conference call solution.", "Open_Days": "Open days", "Open_Dialpad": "Open Dialpad", @@ -4819,10 +4842,18 @@ "Search_for_a_more_general_term": "Search for a more general term", "Search_for_a_more_specific_term": "Search for a more specific term", "Search_message_search_failed": "Search request failed", + "Search_messages_disabled_description": "Enable global message search in search provider settings to show message results here.", + "Search_messages_disabled_title": "Message results are disabled", "Search_on_marketplace": "Search on Marketplace", "Search_options": "Search options", + "Search_page_empty_state": "Search for users, rooms, messages, or intelligent results.", "Search_roles": "Search roles", "Search_rooms": "Search rooms", + "Search_tab_all": "All", + "Search_tab_intelligent": "Intelligent", + "Search_tab_messages": "Messages", + "Search_tab_rooms": "Rooms", + "Search_tab_users": "Users", "Search_users_rooms_messages": "Search users, rooms, and messages", "Searchable": "Searchable", "Seat_limit_reached": "Seat limit reached", @@ -5389,6 +5420,7 @@ "This_year": "This Year", "Thread_message": "Commented on *{{username}}'s* message: _ {{msg}} _", "Thread_message_list": "Thread message list", + "Thread_Summarization": "Thread summarization", "Threads": "Threads", "Threads_Description": "Threads allow organized discussions around a specific message.", "Threads_unavailable_for_federation": "Threads are unavailable for Federated rooms", @@ -5853,6 +5885,7 @@ "Videocall_declined": "Video Call Declined.", "Videocall_enabled": "Video Call Enabled", "Videos": "Videos", + "View_all_results": "View all results", "View_All": "View All Members", "View_Logs": "View Logs", "View_rooms": "View rooms", @@ -5860,6 +5893,7 @@ "View_full_conversation": "View full conversation", "View_mode": "View Mode", "View_original": "View Original", + "View_options": "View options", "View_the_Logs_for": "View the logs for: \"{{name}}\"", "View_thread": "View thread", "Viewing_room_administration": "Viewing room administration", From 17617f73bb643d007a2560b3b7315a0c2c33d72b Mon Sep 17 00:00:00 2001 From: Dnouv Date: Fri, 22 May 2026 11:46:42 +0530 Subject: [PATCH 03/50] Add unified intelligent search experience --- apps/meteor/app/api/server/v1/misc.ts | 326 ++++++++++++--- .../meteor/client/lib/2fa/process2faReturn.ts | 4 +- .../client/meteor/overrides/totpOnCall.ts | 4 +- .../navbar/NavBarSearch/NavBarSearch.tsx | 381 ++++++++++++++---- .../NavBarSearch/NavBarSearchListbox.tsx | 53 +-- .../views/admin/aiCenter/AICenterRoute.tsx | 16 +- .../meteor/client/views/search/SearchPage.tsx | 323 ++++++++++----- apps/meteor/server/methods/messageSearch.ts | 29 +- apps/meteor/server/settings/ai.ts | 8 + packages/i18n/src/locales/en.i18n.json | 14 + packages/rest-typings/src/v1/misc.ts | 22 + 11 files changed, 907 insertions(+), 273 deletions(-) diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 5cc916a550040..2f30ee00ca596 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,7 +1,8 @@ import crypto from 'node:crypto'; import type { IDirectoryChannelResult, IDirectoryUserResult, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; -import { Rooms, Settings, Subscriptions, Users, WorkspaceCredentials } from '@rocket.chat/models'; +import { License } from '@rocket.chat/license'; +import { Rooms, Settings, Subscriptions, Users, WorkspaceCredentials, Messages } from '@rocket.chat/models'; import { ajv, isShieldSvgProps, @@ -527,11 +528,43 @@ const getUserRoomIds = async (userId: string): Promise => }).toArray() ).map((subscription) => subscription.rid); -const extractIntelligentResultIds = (result: any): { rid?: string; msgId?: string } => { - const metadata = result?.metadata ?? {}; - let rid = metadata.room_id || metadata.rid || result?.room_id || result?.rid; - let msgId = metadata.msg_id || metadata.message_id || result?.msg_id || result?.message_id || result?.id; - const externalIdentifier = typeof result?.external_identifier === 'string' ? result.external_identifier : ''; +type IntelligentSearchRawResult = Record & { metadata?: Record }; + +type IntelligentSearchCandidate = { + _id: string; + rid?: string; + msgId?: string; + pipelineText: string; + score?: number; +}; + +const asRecord = (value: unknown): Record => + value && typeof value === 'object' ? (value as Record) : {}; + +const firstString = (...values: unknown[]): string | undefined => { + for (const value of values) { + if (typeof value === 'string' && value) { + return value; + } + } + return undefined; +}; + +const firstNumber = (...values: unknown[]): number | undefined => { + for (const value of values) { + const numberValue = Number(value); + if (Number.isFinite(numberValue)) { + return numberValue; + } + } + return undefined; +}; + +const extractIntelligentResultIds = (result: IntelligentSearchRawResult): { rid?: string; msgId?: string } => { + const metadata = asRecord(result.metadata); + let rid = firstString(metadata.room_id, metadata.rid, result.room_id, result.rid); + let msgId = firstString(metadata.msg_id, metadata.message_id, result.msg_id, result.message_id, result.id); + const externalIdentifier = firstString(result.external_identifier); if ((!rid || !msgId) && externalIdentifier) { const separator = externalIdentifier.indexOf(':'); @@ -546,92 +579,230 @@ const extractIntelligentResultIds = (result: any): { rid?: string; msgId?: strin return { rid, msgId }; }; -const normalizeIntelligentResults = async (rawSearchResults: any, userRoomIds: string[]): Promise => { - let rawResults: any[] = []; +const normalizeIntelligentResults = async (rawSearchResults: unknown, userRoomIds: string[]): Promise => { + let rawResults: unknown[] = []; + const rawSearchResultsRecord = asRecord(rawSearchResults); if (Array.isArray(rawSearchResults)) { rawResults = rawSearchResults; - } else if (Array.isArray(rawSearchResults?.results)) { - rawResults = rawSearchResults.results; - } else if (Array.isArray(rawSearchResults?.context)) { - rawResults = rawSearchResults.context; - } else if (Array.isArray(rawSearchResults?.documents)) { - rawResults = rawSearchResults.documents; + } else if (Array.isArray(rawSearchResultsRecord.results)) { + rawResults = rawSearchResultsRecord.results; + } else if (Array.isArray(rawSearchResultsRecord.context)) { + rawResults = rawSearchResultsRecord.context; + } else if (Array.isArray(rawSearchResultsRecord.documents)) { + rawResults = rawSearchResultsRecord.documents; + } else if (Array.isArray(rawSearchResultsRecord.hits)) { + rawResults = rawSearchResultsRecord.hits; + } else if (Array.isArray(rawSearchResultsRecord.data)) { + rawResults = rawSearchResultsRecord.data; } + SystemLogger.debug({ + msg: 'Intelligent search normalizing results', + rawCount: rawResults.length, + rawKeys: Object.keys(rawSearchResultsRecord), + }); + const userRoomIdSet = new Set(userRoomIds); + + // Extract IDs and scores from pipeline response. const candidates = rawResults - .map((result: any, index: number) => { + .map((rawResult: unknown, index: number): IntelligentSearchCandidate => { + const result = asRecord(rawResult) as IntelligentSearchRawResult; + const metadata = asRecord(result.metadata); const { rid, msgId } = extractIntelligentResultIds(result); - const metadata = result?.metadata ?? {}; - const text = String(result?.text || result?.content || result?.document || result?.page_content || metadata.text || ''); - const score = Number(result?.score ?? result?.distance ?? result?.similarity ?? metadata.score); + // Pipeline typically doesn't return message text, so we'll fetch it from the DB. + // Still capture it as fallback if the pipeline does return it. + const pipelineText = firstString(result.text, result.content, result.document, result.page_content, metadata.text) || ''; + const score = firstNumber(result.score, result.distance, result.similarity, metadata.score); return { - _id: `${rid || 'intelligent'}-${msgId || index}`, + _id: msgId || `intelligent-${index}`, rid, msgId, - text, - ...(Number.isFinite(score) && { score }), + pipelineText, + ...(typeof score === 'number' && { score }), }; }) - .filter((result: UnifiedSearchIntelligentResult) => result.text && (!result.rid || userRoomIdSet.has(result.rid))) + .filter((result) => { + // Must have at least a room or message ID to be useful. + if (!result.msgId && !result.rid) return false; + // Secondary security filter: if we have a room ID, verify user has access. + if (result.rid && !userRoomIdSet.has(result.rid)) { + SystemLogger.debug({ msg: 'Intelligent search result filtered: room not in user subscriptions', rid: result.rid }); + return false; + } + return true; + }) .slice(0, AI_SEARCH_PAGE_SIZE); + SystemLogger.debug({ msg: 'Intelligent search after filter', candidateCount: candidates.length }); + + // Fetch actual messages from DB to get text, sender, timestamp. + const msgIds = candidates.map(({ msgId }) => msgId).filter(Boolean) as string[]; + const messageMap = new Map(); + if (msgIds.length > 0) { + const msgs = await Messages.find({ _id: { $in: msgIds } }, { projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 } }).toArray(); + for (const m of msgs) { + messageMap.set(String(m._id), m); + } + SystemLogger.debug({ msg: 'Intelligent search messages fetched from DB', requested: msgIds.length, found: messageMap.size }); + } + const rooms = await getRoomMap(candidates.map(({ rid }) => rid).filter(Boolean) as string[]); - return candidates.map((result) => ({ - ...result, - ...(result.rid && rooms.has(result.rid) && { room: rooms.get(result.rid) }), - })); + return candidates.map((result) => { + const dbMsg = result.msgId ? messageMap.get(result.msgId) : undefined; + const rid = dbMsg?.rid || result.rid; + return { + _id: result.msgId || result._id, + rid, + msgId: result.msgId, + // Prefer DB message text; fall back to pipeline text + text: dbMsg?.msg || result.pipelineText || '', + ts: dbMsg?.ts, + u: dbMsg?.u ? { username: dbMsg.u.username, name: dbMsg.u.name } : undefined, + ...(Number.isFinite(result.score) && { score: result.score }), + ...(rid && rooms.has(rid) && { room: rooms.get(rid) }), + }; + }); +}; + +type IntelligentSearchFilters = { + rid?: string; + fromUsername?: string; + startDate?: Date; + endDate?: Date; +}; + +const getUserClassifications = async (userId: string): Promise => { + const user = await Users.findOneById>(userId, { projection: { roles: 1 } }); + return Array.from(new Set(['user', ...(user?.roles || [])])); +}; + +const buildIntelligentSearchFilters = ( + userRoomIds: string[], + { rid, fromUsername, startDate, endDate }: IntelligentSearchFilters, +): Record | undefined => { + let accessibleRoomIds = userRoomIds; + if (rid) { + accessibleRoomIds = userRoomIds.includes(rid) ? [rid] : []; + } + + if (!accessibleRoomIds.length) { + return undefined; + } + + const filters: Record = { + room_id: rid ? { $eq: rid } : { $in: accessibleRoomIds }, + }; + + if (fromUsername) { + filters.username = { $eq: fromUsername.replace(/^@/, '') }; + } + + if (startDate || endDate) { + filters.timestamp = { + ...(startDate && { $ge: startDate.toISOString() }), + ...(endDate && { $le: endDate.toISOString() }), + }; + } + + return filters; }; -const searchIntelligent = async (query: string, userRoomIds: string[]): Promise => { +const searchIntelligent = async ( + query: string, + userId: string, + userRoomIds: string[], + filters: IntelligentSearchFilters = {}, +): Promise => { const baseUrl = String(settings.get('AI_Intelligent_Search_Pipeline_Base_URL') || '').replace(/\/+$/, ''); const pipelineId = String(settings.get('AI_Intelligent_Search_Pipeline_ID') || ''); const apiKey = String(settings.get('AI_Intelligent_Search_API_Key') || ''); const apiKeySecret = String(settings.get('AI_Intelligent_Search_API_Key_Secret') || ''); - if (!baseUrl || !pipelineId || !apiKey || !apiKeySecret || !userRoomIds.length) { + if (!baseUrl || !pipelineId || !apiKey || !apiKeySecret) { + SystemLogger.debug({ + msg: 'Intelligent search skipped: missing configuration', + baseUrl: !!baseUrl, + pipelineId: !!pipelineId, + apiKey: !!apiKey, + apiKeySecret: !!apiKeySecret, + }); + return []; + } + + if (!userRoomIds.length) { + SystemLogger.debug({ msg: 'Intelligent search skipped: user has no room subscriptions' }); + return []; + } + + const pipelineFilters = buildIntelligentSearchFilters(userRoomIds, filters); + if (!pipelineFilters) { + SystemLogger.debug({ msg: 'Intelligent search skipped: no accessible rooms for filters', rid: filters.rid }); return []; } const minimumSimilarity = normalizeSimilarityPercent(settings.get('AI_Intelligent_Search_Min_Similarity_Percent')); - const response = await fetch(`${baseUrl}/pipelines/${encodeURIComponent(pipelineId)}/search`, { - method: 'POST', - timeout: 10000, - ignoreSsrfValidation: false, - allowList: [], - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-API-KEY': apiKey, - 'X-API-KEY-SECRET': apiKeySecret, - }, - body: JSON.stringify({ - query, - type: 'similarity', - classification: { - classifications: [], - search_type: 2, + const queryTemplate = String(settings.get('AI_Intelligent_Search_Query_Template') || ''); + const classifications = await getUserClassifications(userId); + // Apply query template if configured (e.g. "task: search result | query: {query}") + const formattedQuery = queryTemplate ? queryTemplate.replace('{query}', query) : query; + const url = `${baseUrl}/pipelines/${encodeURIComponent(pipelineId)}/search`; + + SystemLogger.debug({ + msg: 'Intelligent search request', + url, + formattedQuery, + userRoomCount: userRoomIds.length, + filterKeys: Object.keys(pipelineFilters), + classificationCount: classifications.length, + threshold: getSemanticDistanceThreshold(minimumSimilarity), + }); + + let response: Awaited>; + try { + response = await fetch(url, { + method: 'POST', + timeout: 10000, + // Admin-configured URL: SSRF validation disabled; admin is responsible for the configured endpoint + ignoreSsrfValidation: true, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-API-KEY': apiKey, + 'X-API-KEY-SECRET': apiKeySecret, }, - filters: { - room_id: { - $in: userRoomIds, + body: JSON.stringify({ + query: formattedQuery, + type: 'similarity', + classification: { + classifications, + search_type: 2, }, - }, - params: { - k: AI_SEARCH_PAGE_SIZE, - threshold: getSemanticDistanceThreshold(minimumSimilarity), - }, - }), - }); + filters: pipelineFilters, + params: { + k: AI_SEARCH_PAGE_SIZE, + threshold: getSemanticDistanceThreshold(minimumSimilarity), + }, + }), + }); + } catch (fetchError: unknown) { + SystemLogger.warn({ msg: 'Intelligent search fetch failed', url, err: fetchError }); + throw fetchError; + } if (!response.ok) { + const body = await response.text().catch(() => ''); + SystemLogger.warn({ msg: 'Intelligent search pipeline returned error', url, status: response.status, body }); return []; } - return normalizeIntelligentResults(await response.json(), userRoomIds); + const json = await response.json(); + SystemLogger.debug({ msg: 'Intelligent search raw response received', resultKeys: Object.keys(json ?? {}) }); + + return normalizeIntelligentResults(json, userRoomIds); }; API.v1.get( @@ -649,18 +820,29 @@ API.v1.get( const query = this.queryParams.query.trim(); const { count } = await getPaginationItems(this.queryParams); const limit = Math.min(count || MAX_UNIFIED_SEARCH_RESULTS, MAX_UNIFIED_SEARCH_RESULTS); + const rid = this.queryParams.rid || undefined; + const fromUsername = this.queryParams.fromUsername || undefined; + const startDate = this.queryParams.startDate ? new Date(this.queryParams.startDate) : undefined; + const endDate = this.queryParams.endDate ? new Date(this.queryParams.endDate) : undefined; + + const hasFilters = Boolean(rid || fromUsername || startDate || endDate); + const filters = hasFilters ? { fromUsername, startDate, endDate } : undefined; const [spotlight, userRoomIds] = await Promise.all([ - spotlightMethod({ - text: query, - userId: this.userId, - type: { users: true, rooms: true, includeFederatedRooms: true }, - }), + // Don't run spotlight when filtering to a specific room (not useful) + rid + ? Promise.resolve({ users: [], rooms: [] }) + : spotlightMethod({ + text: query, + userId: this.userId, + type: { users: true, rooms: true, includeFederatedRooms: true }, + }), getUserRoomIds(this.userId), ]); const globalMessagesEnabled = settings.get('Search.defaultProvider.GlobalSearchEnabled') === true; const intelligentSearchEnabled = settings.get('AI_Intelligent_Search_Enabled') === true; + const hasIntelligentSearchLicense = License.hasModule('chat.rocket.rc-ai'); const intelligentSearchConfigured = Boolean( settings.get('AI_Intelligent_Search_Pipeline_Base_URL') && settings.get('AI_Intelligent_Search_Pipeline_ID') && @@ -669,8 +851,9 @@ API.v1.get( ); let messages: UnifiedSearchMessageResult[] = []; - if (this.queryParams.includeMessages && globalMessagesEnabled) { - const searchResult = await messageSearch(this.userId, query, undefined, limit, 0); + // Room-specific search is always allowed; global search requires the setting + if (this.queryParams.includeMessages && (rid || globalMessagesEnabled)) { + const searchResult = await messageSearch(this.userId, query, rid, limit, 0, filters); const docs = searchResult && searchResult.message ? await normalizeMessagesForUser(searchResult.message.docs, this.userId) : []; const rooms = await getRoomMap(docs.map((message: IMessage) => message.rid)); messages = docs.map((message: IMessage) => ({ @@ -684,15 +867,28 @@ API.v1.get( } let intelligent: UnifiedSearchIntelligentResult[] = []; - if (this.queryParams.includeIntelligent && intelligentSearchEnabled && intelligentSearchConfigured) { + if (this.queryParams.includeIntelligent && hasIntelligentSearchLicense && intelligentSearchEnabled && intelligentSearchConfigured) { try { - intelligent = await searchIntelligent(query, userRoomIds); + intelligent = await searchIntelligent(query, this.userId, userRoomIds, { + rid, + fromUsername, + startDate, + endDate, + }); } catch (error) { SystemLogger.warn({ msg: 'Intelligent search request failed', err: error, }); } + } else { + SystemLogger.debug({ + msg: 'Intelligent search skipped at endpoint', + includeIntelligent: this.queryParams.includeIntelligent, + hasIntelligentSearchLicense, + intelligentSearchEnabled, + intelligentSearchConfigured, + }); } return API.v1.success({ diff --git a/apps/meteor/client/lib/2fa/process2faReturn.ts b/apps/meteor/client/lib/2fa/process2faReturn.ts index 50a0d729d770b..8dfe1a16f6387 100644 --- a/apps/meteor/client/lib/2fa/process2faReturn.ts +++ b/apps/meteor/client/lib/2fa/process2faReturn.ts @@ -77,7 +77,7 @@ export async function process2faReturn({ }; const validateCode = async (code: string, method: string): Promise => { - await onCode(code, method); + await onCode(method === 'password' ? SHA256(code) : code, method); }; await invokeTwoFactorModal(props, validateCode); @@ -108,7 +108,7 @@ export async function process2faAsyncReturn({ let result: TResult | undefined; const validateCode = async (code: string, method: string): Promise => { - result = await onCode(code, method); + result = await onCode(method === 'password' ? SHA256(code) : code, method); }; await invokeTwoFactorModal(props, validateCode); diff --git a/apps/meteor/client/meteor/overrides/totpOnCall.ts b/apps/meteor/client/meteor/overrides/totpOnCall.ts index e50fb22da8ff6..6dc1c415fcc8d 100644 --- a/apps/meteor/client/meteor/overrides/totpOnCall.ts +++ b/apps/meteor/client/meteor/overrides/totpOnCall.ts @@ -50,9 +50,11 @@ const withAsyncTOTP = Promise> try { return await callAsync(methodName, ...args); } catch (error: unknown) { + // Use the original (unwrapped) callAsync here to prevent recursive 2FA modal opening + // when the server returns totp-invalid (e.g. wrong code entered). return process2faAsyncReturn({ error, - onCode: (twoFactorCode, twoFactorMethod) => Meteor.callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod }), + onCode: (twoFactorCode, twoFactorMethod) => callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod }), emailOrUsername: undefined, }); } diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx index 649eeaaf1e060..a298d5560a682 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx @@ -1,12 +1,10 @@ -import { useFocusManager } from '@react-aria/focus'; +/* eslint-disable react/no-multi-comp */ import { useOverlayTrigger } from '@react-aria/overlays'; import { useOverlayTriggerState } from '@react-stately/overlays'; -import { Box, Icon, IconButton, TextInput } from '@rocket.chat/fuselage'; -import { useEffectEvent, useMergedRefs } from '@rocket.chat/fuselage-hooks'; -import { useRouter, useSetModal } from '@rocket.chat/ui-contexts'; -import type { KeyboardEvent } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { Box, Icon, IconButton } from '@rocket.chat/fuselage'; +import { useRouter, useSearchParameter, useSetModal, useUserSubscriptions } from '@rocket.chat/ui-contexts'; +import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import tinykeys from 'tinykeys'; @@ -20,83 +18,244 @@ import GenericUpsellModal from '../../components/GenericUpsellModal'; import { useUpsellActions } from '../../components/GenericUpsellModal/hooks'; import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; +type SearchFilters = { + rid?: string; + ridName?: string; + fromUser?: string; + afterDate?: string; + beforeDate?: string; +}; + +const roomLookupQuery = { open: { $ne: false } }; +const roomLookupOptions = { limit: 100 } as const; +const filterTokenRegex = /(^|\s)(in|from|after|before):([^\s]+)(?=\s)/gi; +const finalFilterTokenRegex = /(^|\s)(in|from|after|before):([^\s]+)$/i; +const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + +const getRoomFilter = (value: string, rooms: ReturnType): Pick => { + const normalizedValue = value.replace(/^#/, '').toLowerCase(); + const room = rooms.find(({ name, fname }) => [name, fname].some((roomName) => roomName?.toLowerCase() === normalizedValue)); + + if (!room) { + return { ridName: value.replace(/^#/, '') }; + } + + return { + rid: room.rid || room._id, + ridName: room.fname || room.name, + }; +}; + +const parseFilterTokens = (value: string, rooms: ReturnType, includeFinalToken = false) => { + const nextFilters: Partial = {}; + const parser = (match: string, leadingWhitespace: string, rawKind: string, rawTokenValue: string) => { + const kind = rawKind.toLowerCase(); + const tokenValue = rawTokenValue.trim(); + + if (!tokenValue) { + return match; + } + + if (kind === 'in') { + Object.assign(nextFilters, getRoomFilter(tokenValue, rooms)); + return leadingWhitespace; + } + + if (kind === 'from') { + nextFilters.fromUser = tokenValue.replace(/^@/, ''); + return leadingWhitespace; + } + + if ((kind === 'after' || kind === 'before') && dateRegex.test(tokenValue)) { + nextFilters[kind === 'after' ? 'afterDate' : 'beforeDate'] = tokenValue; + return leadingWhitespace; + } + + return match; + }; + + let remainingText = value + .replace(filterTokenRegex, (match, leadingWhitespace: string, rawKind: string, rawTokenValue: string) => { + return parser(match, leadingWhitespace, rawKind, rawTokenValue); + }) + .replace(/\s{2,}/g, ' ') + .trimStart(); + + if (includeFinalToken) { + remainingText = remainingText + .replace(finalFilterTokenRegex, (match, leadingWhitespace: string, rawKind: string, rawTokenValue: string) => { + return parser(match, leadingWhitespace, rawKind, rawTokenValue); + }) + .replace(/\s{2,}/g, ' ') + .trim(); + } + + return { remainingText, nextFilters }; +}; + +const SearchChip = ({ icon, label, onRemove }: { icon: string; label: string; onRemove: () => void }): ReactElement => ( + + + {label} + + +); + const NavBarSearch = () => { const { t } = useTranslation(); - const focusManager = useFocusManager(); const shortcut = getShortcutLabel(); const router = useRouter(); const setModal = useSetModal(); + const queryParam = useSearchParameter('q') ?? ''; + const ridParam = useSearchParameter('rid') ?? undefined; + const ridNameParam = useSearchParameter('ridName') ?? undefined; + const fromUserParam = useSearchParameter('fromUser') ?? undefined; + const afterDateParam = useSearchParameter('afterDate') ?? undefined; + const beforeDateParam = useSearchParameter('beforeDate') ?? undefined; const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule('chat.rocket.rc-ai'); const { handleTalkToSales } = useUpsellActions(hasIntelligentSearchLicense); + const rooms = useUserSubscriptions(roomLookupQuery, roomLookupOptions); const placeholder = [t('Search_users_rooms_messages'), shortcut].filter(Boolean).join(' '); - - const methods = useForm({ defaultValues: { filterText: '' } }); - const { - formState: { isDirty }, - getValues, - register, - resetField, - setFocus, - } = methods; - - const { ref: filterRef, ...rest } = register('filterText'); - - const triggerRef = useRef(null); - const mergedRefs = useMergedRefs(filterRef, triggerRef); - + const [filterText, setFilterText] = useState(''); + const [filters, setFilters] = useState({}); + const inputRef = useRef(null); + const triggerRef = useRef(null); const state = useOverlayTriggerState({}); const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'listbox' }, state, triggerRef); - delete triggerProps.onPress; + const { onPress: _onPress, ...boxTriggerProps } = triggerProps; + const isSearchRoute = router.getRouteName() === 'search'; const handleSearchKeyDown = useSearchInputNavigation(state); const handleFocus = useSearchFocus(state); const handleClick = useSearchClick(state); + useEffect(() => { + if (!isSearchRoute) { + return; + } + + setFilterText(queryParam); + setFilters({ + rid: ridParam, + ridName: ridNameParam || ridParam, + fromUser: fromUserParam, + afterDate: afterDateParam, + beforeDate: beforeDateParam, + }); + }, [afterDateParam, beforeDateParam, fromUserParam, isSearchRoute, queryParam, ridNameParam, ridParam]); + const navigateToSearch = useCallback( - (filterText: string, tab?: string): void => { + (nextFilterText: string, tab?: string): void => { const searchParams = new URLSearchParams(); - if (filterText.trim()) { - searchParams.set('q', filterText.trim()); + const { remainingText, nextFilters } = parseFilterTokens(nextFilterText, rooms, true); + const nextFiltersState = { ...filters, ...nextFilters }; + + if (remainingText.trim()) { + searchParams.set('q', remainingText.trim()); } if (tab) { searchParams.set('tab', tab); } + if (nextFiltersState.rid) { + searchParams.set('rid', nextFiltersState.rid); + } + if (nextFiltersState.ridName) { + searchParams.set('ridName', nextFiltersState.ridName); + } + if (nextFiltersState.fromUser) { + searchParams.set('fromUser', nextFiltersState.fromUser); + } + if (nextFiltersState.afterDate) { + searchParams.set('afterDate', nextFiltersState.afterDate); + } + if (nextFiltersState.beforeDate) { + searchParams.set('beforeDate', nextFiltersState.beforeDate); + } + router.navigate({ name: 'search', search: Object.fromEntries(searchParams.entries()), }); state.close(); }, - [router, state], + [filters, rooms, router, state], + ); + + const updateFilterText = useCallback( + (value: string) => { + const { remainingText, nextFilters } = parseFilterTokens(value, rooms); + setFilterText(remainingText); + if (Object.keys(nextFilters).length > 0) { + setFilters((currentFilters) => ({ ...currentFilters, ...nextFilters })); + } + }, + [rooms], + ); + + const handleChange = useCallback( + (event: ChangeEvent): void => { + updateFilterText(event.currentTarget.value); + }, + [updateFilterText], ); const handleKeyDown = useCallback( (event: KeyboardEvent): void => { if (event.key === 'Enter') { event.preventDefault(); - navigateToSearch(getValues('filterText')); + navigateToSearch(filterText); return; } handleSearchKeyDown(event); }, - [getValues, handleSearchKeyDown, navigateToSearch], + [filterText, handleSearchKeyDown, navigateToSearch], ); const handleEscSearch = useCallback(() => { - resetField('filterText'); + setFilterText(''); + setFilters({}); state.close(); - }, [resetField, state]); + }, [state]); + + const clearAll = useCallback(() => { + setFilterText(''); + setFilters({}); + inputRef.current?.focus(); + }, []); - const handleClearText = useEffectEvent(() => { - resetField('filterText'); - setFocus('filterText'); - }); + const handleRemoveFilter = useCallback((key: keyof SearchFilters) => { + setFilters((currentFilters) => { + if (key === 'rid' || key === 'ridName') { + return { ...currentFilters, rid: undefined, ridName: undefined }; + } - const handleIntelligentSearchClick = useEffectEvent(() => { + return { ...currentFilters, [key]: undefined }; + }); + inputRef.current?.focus(); + }, []); + + const handleIntelligentSearchClick = useCallback(() => { if (hasIntelligentSearchLicense) { - navigateToSearch(getValues('filterText'), 'intelligent'); + navigateToSearch(filterText, 'intelligent'); return; } @@ -114,17 +273,17 @@ const NavBarSearch = () => { imgHeight={256} />, ); - }); + }, [filterText, handleTalkToSales, hasIntelligentSearchLicense, navigateToSearch, setModal, t]); useEffect(() => { const unsubscribe = tinykeys(window, { '$mod+K': (event) => { event.preventDefault(); - setFocus('filterText'); + inputRef.current?.focus(); }, '$mod+P': (event) => { event.preventDefault(); - setFocus('filterText'); + inputRef.current?.focus(); }, 'Escape': (event) => { event.preventDefault(); @@ -135,44 +294,114 @@ const NavBarSearch = () => { return (): void => { unsubscribe(); }; - }, [focusManager, handleEscSearch, setFocus]); + }, [handleEscSearch]); + + const chips = useMemo( + () => + [ + filters.ridName + ? { + key: 'ridName' as const, + icon: 'hash' as const, + label: `in: #${filters.ridName}`, + } + : null, + filters.fromUser + ? { + key: 'fromUser' as const, + icon: 'at' as const, + label: `from: @${filters.fromUser}`, + } + : null, + filters.afterDate + ? { + key: 'afterDate' as const, + icon: 'calendar' as const, + label: `after: ${filters.afterDate}`, + } + : null, + filters.beforeDate + ? { + key: 'beforeDate' as const, + icon: 'calendar' as const, + label: `before: ${filters.beforeDate}`, + } + : null, + ].filter(Boolean) as Array<{ key: keyof SearchFilters; icon: 'hash' | 'at' | 'calendar'; label: string }>, + [filters.afterDate, filters.beforeDate, filters.fromUser, filters.ridName], + ); + const hasFilters = chips.length > 0; + const isDirty = Boolean(filterText.trim() || hasFilters); return ( - - - - {isDirty ? ( - - ) : ( - - )} - - - } - /> - {state.isOpen && } + + inputRef.current?.focus()} + > + + {chips.map(({ key, icon, label }) => ( + handleRemoveFilter(key)} /> + ))} + + + + {isDirty ? : } + + - + {state.isOpen && ( + navigateToSearch(filterText)} + /> + )} + ); }; diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx index f5e27a43788dd..e32bc53e4839f 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx @@ -1,12 +1,10 @@ import type { OverlayTriggerAria } from '@react-aria/overlays'; import type { OverlayTriggerState } from '@react-stately/overlays'; import { Box, Button, Tile } from '@rocket.chat/fuselage'; -import { useDebouncedValue, useEffectEvent, useOutsideClick } from '@rocket.chat/fuselage-hooks'; +import { useDebouncedValue, useOutsideClick } from '@rocket.chat/fuselage-hooks'; import { CustomScrollbars } from '@rocket.chat/ui-client'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useRouter } from '@rocket.chat/ui-contexts'; -import { useRef } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import NavBarSearchMessageRow from './NavBarSearchMessageRow'; @@ -19,37 +17,38 @@ import ResultsLiveRegion from '../../components/ResultsLiveRegion'; type NavBarSearchListBoxProps = { state: OverlayTriggerState; overlayProps: OverlayTriggerAria['overlayProps']; + filterText: string; + onFilterTextChange: (text: string) => void; + onSelect: () => void; + onNavigateToSearch: () => void; }; -const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) => { +const NavBarSearchListBox = ({ + state, + overlayProps, + filterText, + onFilterTextChange, + onSelect, + onNavigateToSearch, +}: NavBarSearchListBoxProps) => { const { t } = useTranslation(); - const router = useRouter(); const containerRef = useRef(null); const handleKeyDown = useListboxNavigation(state); useOutsideClick([containerRef], state.close); - const { resetField, watch } = useFormContext(); - const { filterText } = watch(); - const debouncedFilter = useDebouncedValue(filterText, 500); - const handleSelect = useEffectEvent(() => { + const handleSelect = useCallback(() => { state.close(); - resetField('filterText'); - }); + onFilterTextChange(''); + onSelect(); + }, [onFilterTextChange, onSelect, state]); - const handleOpenSearchPage = useEffectEvent(() => { - const searchParams = new URLSearchParams(); - if (filterText.trim()) { - searchParams.set('q', filterText.trim()); - } - router.navigate({ - name: 'search', - search: Object.fromEntries(searchParams.entries()), - }); + const handleOpenSearchPage = useCallback(() => { + onNavigateToSearch(); state.close(); - }); + }, [onNavigateToSearch, state]); const { data, isLoading } = useSearchItems(debouncedFilter); const items = data ?? { @@ -93,7 +92,15 @@ const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) > -
+