diff --git a/.changeset/fancy-deserts-mate.md b/.changeset/fancy-deserts-mate.md new file mode 100644 index 0000000000000..00e6d01fbf297 --- /dev/null +++ b/.changeset/fancy-deserts-mate.md @@ -0,0 +1,11 @@ +--- +'@rocket.chat/ai-search-service': minor +'@rocket.chat/core-services': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ai-search': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds native Intelligent Search to the Rocket.Chat core — a semantic, vector-based search experience powered by an external AI pipeline and an optional LLM answer layer, accessible from the NavBar. diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 5a6a6f06cbbab..15d0f53a15e02 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -7,6 +7,7 @@ import './helpers/isUserFromParams'; import './helpers/parseJsonQuery'; import './default/info'; import './v1/assets'; +import './v1/ai-search'; import './v1/calendar'; import './v1/call-history'; import './v1/channels'; diff --git a/apps/meteor/app/api/server/v1/ai-search.ts b/apps/meteor/app/api/server/v1/ai-search.ts new file mode 100644 index 0000000000000..882ca548bb528 --- /dev/null +++ b/apps/meteor/app/api/server/v1/ai-search.ts @@ -0,0 +1,416 @@ +import { + AI_SEARCH_PAGE_SIZE, + MAX_INTELLIGENT_SEARCH_RESULTS, + MAX_SEARCH_FILTER_VALUES, + MAX_UNIFIED_SEARCH_RESULTS, +} from '@rocket.chat/ai-search'; +import { AISearch } from '@rocket.chat/core-services'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { + ajv, + isSearchAnswerProps, + isUnifiedSearchProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { UnifiedSearchIntelligentResult, UnifiedSearchMessageResult } from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { SystemLogger } from '../../../../server/lib/logger/system'; +import { messageSearch } from '../../../../server/methods/messageSearch'; +import { spotlightMethod } from '../../../../server/publications/spotlight'; +import { settings } from '../../../settings/server'; +import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +import { API } from '../api'; +import { getPaginationItems } from '../helpers/getPaginationItems'; + +const searchUsersSchema = { + 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 searchRoomsSchema = { + 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 unifiedSearchResponseSchema = ajv.compile<{ + users: Pick[]; + rooms: Pick[]; + messages: UnifiedSearchMessageResult[]; + intelligent: UnifiedSearchIntelligentResult[]; + meta: { + globalMessagesEnabled: boolean; + intelligentSearchEnabled: boolean; + intelligentSearchConfigured: boolean; + answerGenerationConfigured: boolean; + }; +}>({ + type: 'object', + properties: { + users: searchUsersSchema, + rooms: searchRoomsSchema, + messages: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { 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', 'rid'], + additionalProperties: true, + }, + }, + intelligent: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + 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', 'text'], + additionalProperties: true, + }, + }, + meta: { + type: 'object', + properties: { + globalMessagesEnabled: { type: 'boolean' }, + intelligentSearchEnabled: { type: 'boolean' }, + intelligentSearchConfigured: { type: 'boolean' }, + answerGenerationConfigured: { type: 'boolean' }, + }, + required: ['globalMessagesEnabled', 'intelligentSearchEnabled', 'intelligentSearchConfigured', 'answerGenerationConfigured'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'rooms', 'messages', 'intelligent', 'meta', 'success'], + additionalProperties: false, +}); + +const aiModelsResponseSchema = ajv.compile<{ data: { key: string; label: string }[] }>({ + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + key: { type: 'string' }, + label: { type: 'string' }, + }, + required: ['key', 'label'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const searchAnswerResponseSchema = ajv.compile<{ + answer: string; + provider: { name: string; model: string }; +}>({ + type: 'object', + properties: { + answer: { type: 'string' }, + provider: { + type: 'object', + properties: { + name: { type: 'string' }, + model: { type: 'string' }, + }, + required: ['name', 'model'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['answer', 'provider', 'success'], + additionalProperties: false, +}); + +const parseCommaList = (value: string | undefined): string[] => + String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, MAX_SEARCH_FILTER_VALUES); + +const parseQueryBoolean = (value: unknown, defaultValue = false): boolean => { + if (value === undefined || value === null) { + return defaultValue; + } + + return value === true || value === 'true'; +}; + +const parseQueryDate = (value: string | undefined): Date | undefined => { + if (!value) { + return undefined; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +}; + +const getRoomMap = async (roomIds: string[]): Promise>> => { + if (!roomIds.length) { + return new Map(); + } + + const rooms = await Rooms.findByIds([...new Set(roomIds)], { + projection: { _id: 1, t: 1, name: 1, fname: 1 }, + }).toArray(); + + return new Map(rooms.map((room) => [room._id, room])); +}; + +API.v1.get( + 'search.unified', + { + authRequired: true, + query: isUnifiedSearchProps, + rateLimiterOptions: { + numRequestsAllowed: 120, + intervalTimeInMS: 60000, + }, + response: { + 200: unifiedSearchResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + 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 requestedIntelligentCount = Number(this.queryParams.intelligentCount || AI_SEARCH_PAGE_SIZE); + const intelligentLimit = Math.min( + Math.max(Number.isFinite(requestedIntelligentCount) ? requestedIntelligentCount : AI_SEARCH_PAGE_SIZE, AI_SEARCH_PAGE_SIZE), + MAX_INTELLIGENT_SEARCH_RESULTS, + ); + const rid = this.queryParams.rid || undefined; + const rids = parseCommaList(this.queryParams.rids); + const roomNames = parseCommaList(this.queryParams.roomNames); + const fromUsername = this.queryParams.fromUsername || undefined; + const fromUsernames = parseCommaList(this.queryParams.fromUsernames); + const startDate = parseQueryDate(this.queryParams.startDate); + const endDate = parseQueryDate(this.queryParams.endDate); + const includeSpotlight = parseQueryBoolean(this.queryParams.includeSpotlight, true); + const includeMessages = parseQueryBoolean(this.queryParams.includeMessages); + const includeIntelligent = parseQueryBoolean(this.queryParams.includeIntelligent); + + const hasFilters = Boolean(rid || rids.length || roomNames.length || fromUsername || fromUsernames.length || startDate || endDate); + const filters = hasFilters ? { fromUsername, startDate, endDate } : undefined; + + const [spotlight, aiSearchStatus] = await Promise.all([ + rid || !includeSpotlight + ? Promise.resolve({ users: [], rooms: [] }) + : spotlightMethod({ + text: query, + userId: this.userId, + type: { users: true, rooms: true, includeFederatedRooms: true }, + }), + AISearch.status().catch((error) => { + SystemLogger.warn({ msg: 'AI search status unavailable', err: error }); + return { + hasIntelligentSearchLicense: false, + intelligentSearchEnabled: false, + intelligentSearchConfigured: false, + answerGenerationConfigured: false, + }; + }), + ]); + + const globalMessagesEnabled = settings.get('Search.defaultProvider.GlobalSearchEnabled') === true; + + let messages: UnifiedSearchMessageResult[] = []; + if (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) => ({ + _id: message._id, + rid: message.rid, + msg: message.msg, + ts: message.ts, + u: message.u, + ...(rooms.has(message.rid) && { room: rooms.get(message.rid) }), + })); + } + + let intelligent: UnifiedSearchIntelligentResult[] = []; + if ( + includeIntelligent && + aiSearchStatus.hasIntelligentSearchLicense && + aiSearchStatus.intelligentSearchEnabled && + aiSearchStatus.intelligentSearchConfigured + ) { + try { + intelligent = await AISearch.search({ + query, + userId: this.userId, + filters: { + rid, + rids, + roomNames, + fromUsername, + fromUsernames, + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString(), + }, + limit: intelligentLimit, + }); + } catch (error) { + SystemLogger.warn({ msg: 'AI search request failed', err: error }); + } + } else { + SystemLogger.debug({ + msg: 'AI search skipped at endpoint', + includeIntelligent, + hasIntelligentSearchLicense: aiSearchStatus.hasIntelligentSearchLicense, + intelligentSearchEnabled: aiSearchStatus.intelligentSearchEnabled, + intelligentSearchConfigured: aiSearchStatus.intelligentSearchConfigured, + }); + } + + return API.v1.success({ + users: spotlight.users, + rooms: spotlight.rooms, + messages, + intelligent, + meta: { + globalMessagesEnabled, + intelligentSearchEnabled: aiSearchStatus.intelligentSearchEnabled, + intelligentSearchConfigured: aiSearchStatus.intelligentSearchConfigured, + answerGenerationConfigured: aiSearchStatus.answerGenerationConfigured, + }, + }); + }, +); + +API.v1.get( + 'ai.llm.models', + { + authRequired: true, + permissionsRequired: ['view-privileged-setting'], + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + response: { + 200: aiModelsResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + return API.v1.success({ + data: await AISearch.models(), + }); + }, +); + +API.v1.post( + 'search.answer', + { + authRequired: true, + body: isSearchAnswerProps, + rateLimiterOptions: { + numRequestsAllowed: 10, + intervalTimeInMS: 60000, + }, + response: { + 200: searchAnswerResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { query, messages } = this.bodyParams; + let answer; + try { + answer = await AISearch.answer({ + query, + messages: messages.map(({ text, username, roomName, ts, score }) => ({ + text, + username, + roomName, + ts, + score, + })), + }); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (message.includes('error-ai-not-enabled')) { + throw new Meteor.Error('error-ai-not-enabled', 'AI Search is not enabled'); + } + if (message.includes('error-ai-provider-not-configured')) { + throw new Meteor.Error('error-ai-provider-not-configured', 'AI answer provider is not configured'); + } + if (message.includes('error-ai-provider-empty-response')) { + throw new Meteor.Error('error-ai-provider-empty-response', 'AI answer provider returned an empty response'); + } + throw new Meteor.Error('error-ai-provider-request-failed', 'AI answer provider request failed'); + } + + return API.v1.success(answer); + }, +); diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index d7b0be701a3f5..cf7a9a2d3d1bf 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -11,7 +11,6 @@ import { isMeteorCall, meSuccessResponseSchema, validateUnauthorizedErrorResponse, - validateForbiddenErrorResponse, validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings'; @@ -796,12 +795,10 @@ API.v1.post( 'fingerprint', { authRequired: true, - permissionsRequired: ['manage-cloud'], body: isFingerprintProps, response: { 200: fingerprintResponseSchema, 401: validateUnauthorizedErrorResponse, - 403: validateForbiddenErrorResponse, 400: validateBadRequestErrorResponse, }, }, diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index 3b93ca5c0bfb6..3dbd867859e8c 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -45,8 +45,8 @@ const getGroupDefaults = (_id: string, options: ISettingAddGroupOptions = {}): I i18nDescription: `${_id}_Description`, ...options, sorter: options.sorter || 0, - blocked: blockedSettings.has(_id), - hidden: hiddenSettings.has(_id), + blocked: options.blocked ?? blockedSettings.has(_id), + hidden: options.hidden ?? hiddenSettings.has(_id), type: 'group', ...(options.displayQuery && { displayQuery: JSON.stringify(options.displayQuery) }), }); @@ -206,9 +206,6 @@ export class SettingsRegistry { /* * Add a setting group */ - async addGroup(_id: string, cb?: addGroupCallback): Promise; - - // eslint-disable-next-line no-dupe-class-members async addGroup(_id: string, groupOptions: ISettingAddGroupOptions | addGroupCallback = {}, cb?: addGroupCallback): Promise { if (!_id || (groupOptions instanceof Function && cb)) { throw new Error('Invalid arguments'); diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx index 82dcfecf2af41..9db1efa08705e 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearch.tsx @@ -1,9 +1,20 @@ import { useFocusManager } from '@react-aria/focus'; import { useOverlayTrigger } from '@react-aria/overlays'; import { useOverlayTriggerState } from '@react-stately/overlays'; -import { Box, Icon, IconButton, TextInput } from '@rocket.chat/fuselage'; -import { useStableCallback, useMergedRefs } from '@rocket.chat/fuselage-hooks'; -import { useCallback, useEffect, useRef } from 'react'; +import { + AI_LICENSE_MODULE, + buildAppliedFilterChips, + emptySearchFilters, + extractCompletedSearchFilters, + getAISearchButtonTooltip, + mergeSearchFilters, + type NavBarSearchFormValues, +} from '@rocket.chat/ai-search'; +import { Box, Chip, Icon, IconButton, TextInput } from '@rocket.chat/fuselage'; +import { useMergedRefs, useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { useFeaturePreview } from '@rocket.chat/ui-client'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import tinykeys from 'tinykeys'; @@ -13,21 +24,40 @@ import { getShortcutLabel } from './getShortcutLabel'; import { useSearchClick } from './hooks/useSearchClick'; import { useSearchFocus } from './hooks/useSearchFocus'; import { useSearchInputNavigation } from './hooks/useSearchNavigation'; +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; const NavBarSearch = () => { const { t } = useTranslation(); const focusManager = useFocusManager(); const shortcut = getShortcutLabel(); + const aiSearchFeatureEnabled = useFeaturePreview('aiSearch'); + const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule(AI_LICENSE_MODULE); + const canUseAISearch = Boolean(hasIntelligentSearchLicense && aiSearchFeatureEnabled); + const canSearchWithAIFromTopBar = Boolean(canUseAISearch && intelligentSearchEnabled); + const [aiSearchRequested, setAISearchRequested] = useState(false); + const aiSearchActive = Boolean(aiSearchRequested && canSearchWithAIFromTopBar); + const aiSearchButtonTooltip = getAISearchButtonTooltip({ hasIntelligentSearchLicense, intelligentSearchEnabled, t }); - const placeholder = [t('Search_rooms'), shortcut].filter(Boolean).join(' '); + const searchLabel = canSearchWithAIFromTopBar ? t('Search_rooms_or_ask_AI') : t('Search_rooms'); + const placeholder = [searchLabel, shortcut].filter(Boolean).join(' '); - const methods = useForm({ defaultValues: { filterText: '' } }); + const methods = useForm({ defaultValues: { filterText: '', appliedFilters: emptySearchFilters() } }); const { formState: { isDirty }, register, resetField, setFocus, + setValue, + watch, } = methods; + const { filterText, appliedFilters } = watch(); + const appliedFilterChips = useMemo( + () => (aiSearchActive ? buildAppliedFilterChips(appliedFilters) : []), + [aiSearchActive, appliedFilters], + ); + const chipContainerRef = useRef(null); + const [chipContainerWidth, setChipContainerWidth] = useState(0); const { ref: filterRef, ...rest } = register('filterText'); @@ -38,20 +68,88 @@ const NavBarSearch = () => { const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'listbox' }, state, triggerRef); delete triggerProps.onPress; + useLayoutEffect(() => { + const element = chipContainerRef.current; + if (!element || appliedFilterChips.length === 0) { + setChipContainerWidth(0); + return; + } + + const updateWidth = (): void => setChipContainerWidth(Math.ceil(element.getBoundingClientRect().width)); + updateWidth(); + + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(element); + + return (): void => resizeObserver.disconnect(); + }, [appliedFilterChips]); + const handleKeyDown = useSearchInputNavigation(state); const handleFocus = useSearchFocus(state); const handleClick = useSearchClick(state); const handleEscSearch = useCallback(() => { resetField('filterText'); + setValue('appliedFilters', emptySearchFilters()); state.close(); - }, [resetField, state]); + }, [resetField, setValue, state]); const handleClearText = useStableCallback(() => { resetField('filterText'); + setValue('appliedFilters', emptySearchFilters()); setFocus('filterText'); }); + const handleRemoveFilter = useCallback( + (filterKey: string) => { + setValue( + 'appliedFilters', + { + ...appliedFilters, + ...(filterKey === 'in' && { roomNames: [], rids: [], rid: undefined }), + ...(filterKey === 'from' && { fromUsernames: [], fromUsername: undefined }), + ...(filterKey === 'after' && { startDate: undefined }), + ...(filterKey === 'before' && { endDate: undefined }), + }, + { shouldDirty: true }, + ); + setFocus('filterText'); + }, + [appliedFilters, setFocus, setValue], + ); + + const handleIntelligentSearchClick = useCallback(() => { + if (!canSearchWithAIFromTopBar) { + return; + } + + setAISearchRequested((current) => !current); + state.open(); + setFocus('filterText'); + }, [canSearchWithAIFromTopBar, setFocus, state]); + + useEffect(() => { + if (canSearchWithAIFromTopBar || !aiSearchRequested) { + return; + } + + setAISearchRequested(false); + }, [aiSearchRequested, canSearchWithAIFromTopBar]); + + useEffect(() => { + if (!aiSearchActive || !filterText) { + return; + } + + const { searchText, filters, hasCompletedFilters } = extractCompletedSearchFilters(filterText); + if (!hasCompletedFilters) { + return; + } + + setValue('appliedFilters', mergeSearchFilters(appliedFilters, filters), { shouldDirty: true }); + setValue('filterText', searchText, { shouldDirty: true }); + }, [aiSearchActive, appliedFilters, filterText, setValue]); + useEffect(() => { const unsubscribe = tinykeys(window, { '$mod+K': (event) => { @@ -75,7 +173,34 @@ const NavBarSearch = () => { return ( - + + {appliedFilterChips.length > 0 && ( + + {appliedFilterChips.map((filter) => ( + handleRemoveFilter(filter.key)} title={filter.label}> + + {filter.label} + + + ))} + + )} { aria-autocomplete='list' aria-keyshortcuts='Control+K Meta+K Control+P Meta+P' small + style={chipContainerWidth > 0 ? { paddingInlineStart: chipContainerWidth + 16 } : undefined} addon={ - isDirty ? ( - - ) : ( - - ) + + {isDirty ? ( + + ) : ( + + )} + {aiSearchFeatureEnabled && ( + + )} + } /> - {state.isOpen && } + {state.isOpen && } ); diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx index 1b3ef415fa414..e75fec93d4b87 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchListbox.tsx @@ -1,12 +1,22 @@ import type { OverlayTriggerAria } from '@react-aria/overlays'; import type { OverlayTriggerState } from '@react-stately/overlays'; -import { Box, Tile } from '@rocket.chat/fuselage'; +import { + mergeSearchFilters, + parseSearchFilterText, + serializeSearchQuery, + type NavBarSearchFormValues, + type SearchFilterSuggestion, +} from '@rocket.chat/ai-search'; +import { Box, Button, Icon, SidebarV2Item, SidebarV2ItemIcon, SidebarV2ItemTitle, Tile } from '@rocket.chat/fuselage'; import { useDebouncedValue, useStableCallback, useOutsideClick } from '@rocket.chat/fuselage-hooks'; import { CustomScrollbars } from '@rocket.chat/ui-client'; -import { useRef } from 'react'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import type { MouseEvent } from 'react'; +import { useCallback, useMemo, 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'; @@ -16,26 +26,85 @@ import ResultsLiveRegion from '../../components/ResultsLiveRegion'; type NavBarSearchListBoxProps = { state: OverlayTriggerState; overlayProps: OverlayTriggerAria['overlayProps']; + aiSearchActive?: boolean; }; -const NavBarSearchListBox = ({ state, overlayProps }: NavBarSearchListBoxProps) => { +const filterSuggestionGroupLabels = { + rooms: 'Search_filter_rooms', + users: 'Search_filter_users', + dates: 'Search_filter_dates', +} as const; + +const groupFilterSuggestions = (suggestions: SearchFilterSuggestion[]): [SearchFilterSuggestion['group'], SearchFilterSuggestion[]][] => { + const grouped: Record = { rooms: [], users: [], dates: [] }; + for (const suggestion of suggestions) { + grouped[suggestion.group].push(suggestion); + } + + const groups: [SearchFilterSuggestion['group'], SearchFilterSuggestion[]][] = []; + for (const group of ['rooms', 'users', 'dates'] as const) { + if (grouped[group].length > 0) { + groups.push([group, grouped[group]]); + } + } + + return groups; +}; + +const NavBarSearchListBox = ({ state, overlayProps, aiSearchActive = false }: 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 { getValues, resetField, setFocus, setValue, watch } = useFormContext(); + const { filterText, appliedFilters } = watch(); const debouncedFilter = useDebouncedValue(filterText, 500); const handleSelect = useStableCallback(() => { state.close(); resetField('filterText'); + setValue('appliedFilters', { roomNames: [], rids: [], fromUsernames: [] }); }); - const { data: items = [], isLoading } = useSearchItems(debouncedFilter); + const { + data: items = { + rooms: [], + intelligent: [], + filterSuggestions: [], + appliedFilters: [], + searchText: '', + filters: { roomNames: [], rids: [], fromUsernames: [] }, + }, + isLoading, + isFetching, + } = useSearchItems(debouncedFilter, appliedFilters, aiSearchActive); + const itemCount = items.rooms.length + items.intelligent.length + items.filterSuggestions.length; + const filterSuggestionGroups = useMemo(() => groupFilterSuggestions(items.filterSuggestions), [items.filterSuggestions]); + + const handleOpenAISearch = useCallback(() => { + const query = serializeSearchQuery(filterText, appliedFilters); + router.navigate({ + name: 'search', + search: query ? { q: query } : {}, + }); + state.close(); + }, [appliedFilters, filterText, router, state]); + + const handleFilterSuggestion = useCallback( + (event: MouseEvent, value: string) => { + event.preventDefault(); + event.stopPropagation(); + const { searchText, filters } = parseSearchFilterText(value); + setValue('appliedFilters', mergeSearchFilters(getValues('appliedFilters'), filters), { shouldDirty: true }); + setValue('filterText', searchText, { shouldDirty: true }); + setFocus('filterText'); + }, + [getValues, setFocus, setValue], + ); return ( - +
- {items.length === 0 && !isLoading && } - {items.length > 0 && ( + {items.intelligent.length > 0 && ( + + + {t('Intelligent_Search')} + + + {t('AI_Search_related_messages', { count: items.intelligent.length })} + + {items.intelligent.map((item) => ( + + ))} + + + + + )} + {filterSuggestionGroups.map(([group, suggestions]) => ( + + + {t(filterSuggestionGroupLabels[group])} + + {suggestions.map((item) => ( + handleFilterSuggestion(event, item.value)}> + } /> + {item.title} + + {item.description} + + + ))} + + ))} + {itemCount === 0 && !isLoading && !isFetching && } + {items.rooms.length > 0 && ( {filterText ? t('Results') : t('Recent')} )} - {items.map((item) => ( + {items.rooms.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..1e5b345f8722c --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchMessageRow.tsx @@ -0,0 +1,80 @@ +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..999c149027584 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -1,9 +1,25 @@ -import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { + AI_LICENSE_MODULE, + applySearchFilterToken, + buildAppliedFilterChips, + buildRoomSearchQuery, + emptySearchFilters, + getActiveSearchFilter, + mergeSearchFilters, + parseSearchFilterText, + type ActiveSearchFilter, + type SearchFilterChip, + type SearchFilters, + type SearchFilterSuggestion, +} from '@rocket.chat/ai-search'; +import type { UnifiedSearchIntelligentResult } from '@rocket.chat/rest-typings'; +import { useFeaturePreview } from '@rocket.chat/ui-client'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useMethod, useUserSubscriptions } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useMethod, 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,23 +32,184 @@ 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 => { - const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]); - const query = useMemo(() => { - const filterRegex = new RegExp(escapeRegExp(name), 'i'); +const emptySubscriptionQuery = { _id: '__ai_search_no_room_filter__' }; - return { - $or: [{ name: filterRegex }, { fname: filterRegex }], - ...(mention && { - t: mention === '@' ? 'd' : { $ne: 'd' }, - }), - }; - }, [name, mention]); +export type NavBarSearchItems = { + rooms: SubscriptionWithRoom[]; + intelligent: UnifiedSearchIntelligentResult[]; + filterSuggestions: SearchFilterSuggestion[]; + appliedFilters: SearchFilterChip[]; + searchText: string; + filters: SearchFilters; +}; + +const formatDate = (date: Date): string => date.toISOString().slice(0, 10); + +const getDateFilterSuggestions = ( + filterText: string, + activeFilter: ActiveSearchFilter, + key: 'after' | 'before', +): SearchFilterSuggestion[] => { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const lastWeek = new Date(today); + lastWeek.setDate(today.getDate() - 7); + + return [ + { label: 'Today', value: formatDate(today) }, + { label: 'Yesterday', value: formatDate(yesterday) }, + { label: 'Last 7 days', value: formatDate(lastWeek) }, + ].map(({ label, value }) => ({ + key: `${key}-${value}`, + group: 'dates', + title: `${key}:${value}`, + description: label, + value: applySearchFilterToken(filterText, activeFilter, key, value), + icon: 'calendar', + })); +}; + +const buildFilterSuggestions = ( + filterText: string, + activeFilter: ActiveSearchFilter | undefined, + rooms: SubscriptionWithRoom[], +): SearchFilterSuggestion[] => { + if (!activeFilter) { + return []; + } + + if (activeFilter.key === 'in') { + return rooms.slice(0, 5).map((room) => ({ + key: `in-${room.rid || room._id}`, + group: 'rooms', + title: `#${room.fname || room.name}`, + description: 'Search in this room', + value: applySearchFilterToken(filterText, activeFilter, 'in', room.name || room.fname || ''), + icon: 'hash', + })); + } + + if (activeFilter.key === 'from') { + return [ + { + key: 'from-current', + group: 'users', + title: activeFilter.value ? `from:${activeFilter.value.replace(/^@/, '')}` : 'from:username', + description: 'Search messages from this username', + value: applySearchFilterToken(filterText, activeFilter, 'from', activeFilter.value.replace(/^@/, '')), + icon: 'user', + }, + ]; + } + + return getDateFilterSuggestions(filterText, activeFilter, activeFilter.key); +}; + +const buildUserFilterSuggestions = ( + filterText: string, + activeFilter: ActiveSearchFilter | undefined, + users: { + _id: string; + name?: string; + username: string; + }[], +): SearchFilterSuggestion[] => { + if (activeFilter?.key !== 'from') { + return []; + } + + return users.slice(0, 5).map((user) => ({ + key: `from-${user._id}`, + group: 'users', + title: `@${user.username}`, + description: user.name || 'Search messages from this user', + value: applySearchFilterToken(filterText, activeFilter, 'from', user.username), + icon: 'user', + })); +}; + +const buildUsernameAutocompleteQuery = ( + term = '', +): { + selector: string; +} => ({ selector: JSON.stringify({ term, conditions: {}, exceptions: [] }) }); + +const mergeFilterSuggestions = (primary: SearchFilterSuggestion[], fallback: SearchFilterSuggestion[]): SearchFilterSuggestion[] => { + const existingValues = new Set(primary.map(({ value }) => value)); + return [...primary, ...fallback.filter(({ value }) => !existingValues.has(value))]; +}; + +export const useSearchItems = ( + filterText: string, + appliedSearchFilters: SearchFilters = emptySearchFilters(), + aiSearchActive = false, +): UseQueryResult => { + const getSpotlight = useMethod('spotlight'); + const unifiedSearch = useEndpoint('GET', '/v1/search.unified'); + const usersAutocomplete = useEndpoint('GET', '/v1/users.autocomplete'); + const aiSearchFeatureEnabled = useFeaturePreview('aiSearch'); + const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule(AI_LICENSE_MODULE); + const canUseAISearch = Boolean(hasIntelligentSearchLicense && aiSearchFeatureEnabled); + const canUseInlineFilters = Boolean(canUseAISearch && aiSearchActive); + const { searchText, filters } = useMemo(() => { + if (!canUseInlineFilters) { + return { searchText: filterText, filters: emptySearchFilters() }; + } + + const parsed = parseSearchFilterText(filterText); + return { searchText: parsed.searchText, filters: mergeSearchFilters(appliedSearchFilters, parsed.filters) }; + }, [appliedSearchFilters, canUseInlineFilters, filterText]); + const appliedFilters = useMemo(() => (canUseInlineFilters ? buildAppliedFilterChips(filters) : []), [canUseInlineFilters, filters]); + const [, mention, name] = useMemo(() => searchText.match(/(@|#)?(.*)/i) || [], [searchText]); + const activeFilter = useMemo( + () => (canUseInlineFilters ? getActiveSearchFilter(filterText) : undefined), + [canUseInlineFilters, filterText], + ); + const roomLookupText = useMemo(() => { + if (!canUseInlineFilters) { + return ''; + } + + if (activeFilter?.key === 'in') { + return activeFilter.value.replace(/^#/, ''); + } + + return filters.roomNames[filters.roomNames.length - 1] || ''; + }, [activeFilter, canUseInlineFilters, filters.roomNames]); + const query = useMemo(() => buildRoomSearchQuery(name, mention), [name, mention]); + const roomLookupQuery = useMemo( + () => (roomLookupText ? buildRoomSearchQuery(roomLookupText, '#') : emptySubscriptionQuery), + [roomLookupText], + ); const localRooms = useUserSubscriptions(query, options); + const roomFilterRooms = useUserSubscriptions(roomLookupQuery, options); + const selectedRooms = useMemo(() => { + if (!filters.roomNames.length) { + return []; + } + + return filters.roomNames + .map((roomName) => roomFilterRooms.find(({ name, fname }) => [name, fname].filter(Boolean).includes(roomName))) + .filter(Boolean) as SubscriptionWithRoom[]; + }, [filters.roomNames, roomFilterRooms]); + const resolvedFilters = useMemo( + () => ({ + ...filters, + rids: selectedRooms.map((room) => room.rid || room._id), + ...(selectedRooms[0] && { rid: selectedRooms[0].rid || selectedRooms[0]._id }), + ...(filters.fromUsernames[0] && { fromUsername: filters.fromUsernames[0] }), + }), + [filters, selectedRooms], + ); + const filterSuggestions = useMemo( + () => (canUseInlineFilters ? buildFilterSuggestions(filterText, activeFilter, roomFilterRooms) : []), + [activeFilter, canUseInlineFilters, filterText, roomFilterRooms], + ); - const usernamesFromClient = [...localRooms?.map(({ t, name }) => (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 === '@'; @@ -47,14 +224,66 @@ export const useSearchItems = (filterText: string): UseQueryResult _id + name)], + queryKey: [ + 'sidebar/search/spotlight', + name, + searchText, + resolvedFilters, + filterSuggestions, + appliedFilters, + usernamesFromClient, + type, + aiSearchActive, + hasIntelligentSearchLicense, + aiSearchFeatureEnabled, + intelligentSearchEnabled, + localRooms.map(({ _id, name }) => _id + name), + ], queryFn: async () => { + let intelligent: UnifiedSearchIntelligentResult[] = []; + const shouldSearchIntelligent = Boolean(aiSearchActive && name.trim() && !mention && canUseAISearch && intelligentSearchEnabled); + if (shouldSearchIntelligent) { + const result = await unifiedSearch({ + query: name, + count: 0, + includeSpotlight: false, + intelligentCount: 3, + includeMessages: false, + includeIntelligent: true, + ...(resolvedFilters.rid && { rid: resolvedFilters.rid }), + ...(resolvedFilters.rids.length && { rids: resolvedFilters.rids.join(',') }), + ...(resolvedFilters.roomNames.length && { roomNames: resolvedFilters.roomNames.join(',') }), + ...(resolvedFilters.fromUsername && { fromUsername: resolvedFilters.fromUsername }), + ...(resolvedFilters.fromUsernames.length && { fromUsernames: resolvedFilters.fromUsernames.join(',') }), + ...(resolvedFilters.startDate && { startDate: resolvedFilters.startDate }), + ...(resolvedFilters.endDate && { endDate: resolvedFilters.endDate }), + }); + intelligent = result.intelligent; + } + + const nextFilterSuggestions = + activeFilter?.key === 'from' + ? mergeFilterSuggestions( + buildUserFilterSuggestions( + filterText, + activeFilter, + (await usersAutocomplete(buildUsernameAutocompleteQuery(activeFilter.value.replace(/^@/, '')))).items, + ), + filterSuggestions, + ) + : filterSuggestions; + if (localRooms.length === LIMIT) { - return localRooms; + return { + rooms: localRooms, + intelligent, + filterSuggestions: nextFilterSuggestions, + appliedFilters, + searchText, + filters: resolvedFilters, + }; } const spotlight = await getSpotlight(name, usernamesFromClient, type); @@ -105,10 +334,18 @@ export const useSearchItems = (filterText: string): UseQueryResult [item.name, item.fname].includes(name)); - return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])); + return { + rooms: Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])), + intelligent, + filterSuggestions: nextFilterSuggestions, + appliedFilters, + searchText, + filters: resolvedFilters, + }; }, staleTime: 60_000, - placeholderData: (previousData) => previousData ?? localRooms, + placeholderData: (previousData) => + previousData ?? { rooms: localRooms, intelligent: [], filterSuggestions, appliedFilters, searchText, filters: resolvedFilters }, }); }; diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index 77f76158cb65d..67070ab460b1c 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -24,6 +24,7 @@ const OAuthAuthorizationPage = lazy(() => 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 new file mode 100644 index 0000000000000..1e54ffa201167 --- /dev/null +++ b/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx @@ -0,0 +1,147 @@ +/* eslint-disable react/no-multi-comp */ +import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search'; +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 GenericGroupPage from '../settings/groups/GenericGroupPage'; + +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(AI_LICENSE_MODULE); + const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + + 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('Available')}} + actionLabel={t('Manage')} + onClick={() => router.navigate('/admin/ai-center/llm-providers')} + /> + + + + + ); +}; + +const AISettingsSection = ({ section }: { section: 'Intelligent_Search' | 'AI_LLM_Provider' }): ReactElement => { + const router = useRouter(); + const titleBySection = { + Intelligent_Search: 'Intelligent_Search', + AI_LLM_Provider: 'AI_Center_LLM_Providers', + } as const; + const title = titleBySection[section]; + + return ( + + router.navigate('/admin/ai-center')} /> + + ); +}; + +const AICenterRoute = (): ReactElement => { + const hasPermission = useIsPrivilegedSettingsContext(); + const section = useRouteParameter('section'); + + if (!hasPermission) { + return ; + } + + if (section === 'search') { + return ; + } + + if (section === 'thread-summarization') { + return ; + } + + if (section === 'llm-providers') { + return ; + } + + return ; +}; + +export default AICenterRoute; diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index 60f35d50d3109..b12a37ec3aeec 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${`/${string}` | ''}`; + pattern: '/admin/ai-center/:section?'; + }; } } @@ -192,6 +196,11 @@ registerAdminRoute('/rooms/:context?/:id?', { component: lazy(() => import('./rooms/RoomsRoute')), }); +registerAdminRoute('/ai-center/:section?', { + 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/settings/hooks/useSettingsGroups.ts b/apps/meteor/client/views/admin/settings/hooks/useSettingsGroups.ts index 26f4794b17cba..fd4b7f8588b03 100644 --- a/apps/meteor/client/views/admin/settings/hooks/useSettingsGroups.ts +++ b/apps/meteor/client/views/admin/settings/hooks/useSettingsGroups.ts @@ -1,4 +1,5 @@ import type { ISetting } from '@rocket.chat/core-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSettings } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; @@ -16,12 +17,8 @@ export const useSettingsGroups = (filter: string): ISetting[] => { const getMatchableStrings = (setting: ISetting): string[] => [setting.i18nLabel && t(setting.i18nLabel as TranslationKey), t(setting._id as TranslationKey), setting._id].filter(Boolean); - try { - const filterRegex = new RegExp(filter, 'i'); - return (setting: ISetting): boolean => getMatchableStrings(setting).some((text) => filterRegex.test(text)); - } catch (e) { - return (setting: ISetting): boolean => getMatchableStrings(setting).some((text) => text.slice(0, filter.length) === filter); - } + const filterRegex = new RegExp(escapeRegExp(filter), 'i'); + return (setting: ISetting): boolean => getMatchableStrings(setting).some((text) => filterRegex.test(text)); }, [filter, t]); return useMemo(() => { diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index 3c8867ee26236..4b1e35db68359 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -46,6 +46,14 @@ export const { icon: 'team', permissionGranted: (): boolean => hasPermission('view-user-administration'), }, + { + href: '/admin/ai-center', + i18nLabel: 'AI_Center', + icon: 'stars', + tag: 'Beta', + permissionGranted: (): boolean => + hasAtLeastOnePermission(['view-privileged-setting', 'edit-privileged-setting', 'manage-selected-settings']), + }, { href: '/admin/invites', i18nLabel: 'Invites', diff --git a/apps/meteor/client/views/search/SearchPage.spec.tsx b/apps/meteor/client/views/search/SearchPage.spec.tsx new file mode 100644 index 0000000000000..2e550070bd055 --- /dev/null +++ b/apps/meteor/client/views/search/SearchPage.spec.tsx @@ -0,0 +1,42 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; + +import { SourceResult } from './SearchPage'; + +import '@testing-library/jest-dom'; + +jest.mock('../../lib/rooms/roomCoordinator', () => ({ + roomCoordinator: { + getRouteLink: jest.fn(() => '/channel/general'), + }, +})); + +describe('AI Search SourceResult', () => { + it('renders source messages as compact inline message results', () => { + render( + , + { wrapper: mockAppRoot().build() }, + ); + + expect(screen.getByText('Search User')).toBeInTheDocument(); + expect(screen.getByText('@search.user')).toBeInTheDocument(); + expect(screen.getByText('General')).toBeInTheDocument(); + expect(screen.getByText('61%')).toBeInTheDocument(); + expect(screen.getByText('Oranges').tagName).toBe('STRONG'); + expect(screen.getByText(/are green/)).toBeInTheDocument(); + expect( + screen.queryByText((_, element) => element?.tagName === 'P' && element.textContent === 'Oranges are green'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/search/SearchPage.tsx b/apps/meteor/client/views/search/SearchPage.tsx new file mode 100644 index 0000000000000..54f330b970051 --- /dev/null +++ b/apps/meteor/client/views/search/SearchPage.tsx @@ -0,0 +1,430 @@ +/* eslint-disable react/no-multi-comp */ +import { AI_LICENSE_MODULE, buildRoomSearchQuery, MAX_SOURCE_MESSAGE_LENGTH, parseSearchFilterText } from '@rocket.chat/ai-search'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Box, Button, Callout, Icon, Skeleton, Tag } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { MessageAvatar } from '@rocket.chat/ui-avatar'; +import { Page, PageHeader, PageScrollableContentWithShadow, useFeaturePreview } from '@rocket.chat/ui-client'; +import { useEndpoint, useSearchParameter, useSetting, useUserSubscriptions } from '@rocket.chat/ui-contexts'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import MarkdownText from '../../components/MarkdownText'; +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +type IntelligentResult = { + _id: string; + rid?: string; + msgId?: string; + text: string; + score?: number; + ts?: Date | string; + u?: Pick; + room?: Pick; +}; + +const roomLookupOptions = { sort: { lm: -1, name: 1 }, limit: 20 } as const; +const emptyRoomLookupQuery = { _id: '__ai_search_no_room_filter__' }; + +const formatMessageTime = (ts: Date | string | undefined): string => { + if (!ts) return ''; + const date = new Date(ts); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +}; + +const getMessageHref = (item: IntelligentResult): string | undefined => { + const { room } = item; + const href = roomCoordinator.getRouteLink(room?.t || 'c', { + rid: room?._id || item.rid, + name: room?.name, + }); + if (!href) return undefined; + return `${href}?msg=${encodeURIComponent(item.msgId || item._id)}`; +}; + +const trimSourceMessage = (text: string): string => + text.length > MAX_SOURCE_MESSAGE_LENGTH ? `${text.slice(0, MAX_SOURCE_MESSAGE_LENGTH).trimEnd()}...` : text; + +export const SourceResult = ({ item }: { item: IntelligentResult }): ReactElement => { + const { t } = useTranslation(); + const roomLabel = item.room?.fname || item.room?.name; + const href = getMessageHref(item); + const username = item.u?.username || item.u?.name || t('Unknown_User'); + const displayName = item.u?.name || username; + const relevanceScore = typeof item.score === 'number' ? Math.max(0, Math.min(100, Math.round(item.score * 100))) : undefined; + + return ( + + + + + + + + + {displayName} + + {item.u?.username && ( + + @{item.u.username} + + )} + {roomLabel && ( + + + + {roomLabel} + + + )} + {item.ts && ( + + {formatMessageTime(item.ts)} + + )} + + {typeof relevanceScore === 'number' && ( + + {relevanceScore}% + + )} + + + + + ); +}; + +const AnswerPanel = ({ + answer, + provider, + isLoading, + error, + disabled, + emptyReason, + onGenerate, +}: { + answer?: string; + provider?: { name: string; model: string }; + isLoading: boolean; + error?: unknown; + disabled: boolean; + emptyReason: string; + onGenerate: () => void; +}): ReactElement => { + const { t } = useTranslation(); + const answerContent = (): ReactElement => { + if (isLoading) { + return ( + + + + + + + + ); + } + + if (answer) { + return ; + } + + return ( + + {disabled ? emptyReason : t('Search_AI_answer_ready')} + + ); + }; + + return ( + + + + + {t('Search_AI_answer')} + + + + + {provider && ( + + {t('Search_AI_answer_provider', { provider: provider.name, model: provider.model })} + + )} + {Boolean(error) && ( + + {t('Search_AI_answer_error')} + + )} + {answerContent()} + + + ); +}; + +const SearchPage = (): ReactElement => { + const { t } = useTranslation(); + const queryParam = useSearchParameter('q') ?? ''; + const [intelligentCount, setIntelligentCount] = useState(8); + const parsedSearch = useMemo(() => parseSearchFilterText(queryParam), [queryParam]); + const roomLookupText = parsedSearch.filters.roomNames[parsedSearch.filters.roomNames.length - 1] || ''; + const roomLookupQuery = useMemo( + () => (roomLookupText ? buildRoomSearchQuery(roomLookupText, '#') : emptyRoomLookupQuery), + [roomLookupText], + ); + const roomFilterRooms = useUserSubscriptions(roomLookupQuery, roomLookupOptions); + const selectedRooms = useMemo(() => { + if (!parsedSearch.filters.roomNames.length) { + return []; + } + + return parsedSearch.filters.roomNames + .map((roomName) => roomFilterRooms.find(({ name, fname }) => [name, fname].filter(Boolean).includes(roomName))) + .filter(Boolean) as Array<(typeof roomFilterRooms)[number]>; + }, [parsedSearch.filters.roomNames, roomFilterRooms]); + const resolvedFilters = useMemo( + () => ({ + ...parsedSearch.filters, + rids: selectedRooms.map((room) => room.rid || room._id), + ...(selectedRooms[0] && { rid: selectedRooms[0].rid || selectedRooms[0]._id }), + ...(parsedSearch.filters.fromUsernames[0] && { fromUsername: parsedSearch.filters.fromUsernames[0] }), + }), + [parsedSearch.filters, selectedRooms], + ); + const debouncedQuery = useDebouncedValue(parsedSearch.searchText.trim(), 300); + const aiSearchFeatureEnabled = useFeaturePreview('aiSearch'); + const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + const { data: hasIntelligentSearchLicense = false } = useHasLicenseModule(AI_LICENSE_MODULE); + const canUseAISearch = Boolean(hasIntelligentSearchLicense && aiSearchFeatureEnabled); + const unifiedSearch = useEndpoint('GET', '/v1/search.unified'); + const generateAnswer = useEndpoint('POST', '/v1/search.answer'); + + useEffect(() => { + setIntelligentCount(8); + }, [queryParam]); + + const result = useQuery({ + queryKey: [ + 'search/intelligent/page', + debouncedQuery, + resolvedFilters, + hasIntelligentSearchLicense, + aiSearchFeatureEnabled, + intelligentSearchEnabled, + intelligentCount, + ], + queryFn: () => + unifiedSearch({ + query: debouncedQuery, + count: 0, + includeSpotlight: false, + intelligentCount, + includeMessages: false, + includeIntelligent: Boolean(canUseAISearch && intelligentSearchEnabled), + rid: resolvedFilters.rid, + rids: resolvedFilters.rids.join(','), + roomNames: resolvedFilters.roomNames.join(','), + fromUsername: resolvedFilters.fromUsername, + fromUsernames: resolvedFilters.fromUsernames.join(','), + startDate: resolvedFilters.startDate, + endDate: resolvedFilters.endDate, + }), + enabled: Boolean(debouncedQuery && canUseAISearch && intelligentSearchEnabled), + }); + + const intelligent = useMemo(() => (result.data?.intelligent as IntelligentResult[] | undefined) ?? [], [result.data?.intelligent]); + const answerMessages = useMemo( + () => + intelligent.slice(0, 12).map((item) => ({ + _id: item._id, + text: item.text, + username: item.u?.username, + roomName: item.room?.fname || item.room?.name, + ts: item.ts ? new Date(item.ts).toISOString() : undefined, + score: item.score, + })), + [intelligent], + ); + const answerKey = useMemo( + () => + JSON.stringify({ + query: debouncedQuery, + messages: answerMessages.map(({ _id, text, username, roomName, ts, score }) => ({ _id, text, username, roomName, ts, score })), + }), + [answerMessages, debouncedQuery], + ); + const answerMutation = useMutation({ + mutationFn: () => + generateAnswer({ + query: debouncedQuery, + messages: answerMessages, + }), + }); + const { data: answerData, error: answerError, isPending: answerPending, mutate: mutateAnswer, reset: resetAnswer } = answerMutation; + + useEffect(() => { + resetAnswer(); + }, [answerKey, resetAnswer]); + + const canGenerateAnswer = Boolean(result.data?.meta.answerGenerationConfigured && debouncedQuery && intelligent.length > 0); + const answerEmptyReason = useMemo(() => { + if (!debouncedQuery) { + return t('Search_AI_answer_start_from_top_bar'); + } + + if (result.isLoading) { + return t('Search_AI_answer_waiting_for_sources'); + } + + if (!intelligent.length) { + return t('Search_AI_answer_no_sources'); + } + + if (!result.data?.meta.answerGenerationConfigured) { + return t('Search_AI_answer_disabled'); + } + + return t('Search_AI_answer_ready'); + }, [debouncedQuery, intelligent.length, result.data?.meta.answerGenerationConfigured, result.isLoading, t]); + + useEffect(() => { + if (!canGenerateAnswer || answerPending || answerData || answerError) { + return; + } + + mutateAnswer(); + }, [answerData, answerError, answerPending, canGenerateAnswer, mutateAnswer]); + + return ( + + + + + + {debouncedQuery ? ( + + + + {t('Results')} + + {debouncedQuery} + + + + ) : ( + + {t('Intelligent_Search_page_empty_state')} + + )} + + + {t('Intelligent_Search_scope_all_rooms')} + + + {!hasIntelligentSearchLicense && ( + + {t('Intelligent_Search_upsell_description')} + + )} + {hasIntelligentSearchLicense && !aiSearchFeatureEnabled && ( + + {t('AI_Search_feature_disabled_description')} + + )} + {canUseAISearch && !intelligentSearchEnabled && ( + + {t('Intelligent_Search_disabled_description')} + + )} + {canUseAISearch && intelligentSearchEnabled && result.data && !result.data.meta.intelligentSearchConfigured && ( + + {t('Intelligent_Search_missing_configuration_description')} + + )} + {debouncedQuery && ( + mutateAnswer()} + /> + )} + + + {t('Sources')} · {intelligent.length} {t('Messages')} + + {intelligent.length >= intelligentCount && ( + + )} + + {!debouncedQuery && ( + + {t('Intelligent_Search_start_from_top_bar')} + + )} + {result.isLoading && ( + + {t('Loading')} + + )} + {debouncedQuery && !result.isLoading && intelligent.length === 0 && ( + + {t('No_results_found')} + + )} + {intelligent.map((item) => ( + + ))} + + + + ); +}; + +export default SearchPage; diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 1c2439c4c7837..fd6c51f545fc0 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -23,6 +23,8 @@ export default { '^react-virtuoso($|/.+)': '/node_modules/react-virtuoso$1', '^react-dom($|/.+)': '/node_modules/react-dom$1', '^react-i18next($|/.+)': '/node_modules/react-i18next$1', + '^@rocket.chat/ai-search$': '/../../packages/ai-search/src', + '^@rocket.chat/rest-typings$': '/../../packages/rest-typings/src', '^@rocket.chat/(.+)': '/node_modules/@rocket.chat/$1', '^@tanstack/(.+)': '/node_modules/@tanstack/$1', '^meteor/(.*)': '/tests/mocks/client/meteor.ts', diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 074b8f0169d67..c80e4746ef627 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -93,6 +93,7 @@ "@rocket.chat/abac": "workspace:^", "@rocket.chat/account-utils": "workspace:^", "@rocket.chat/agenda": "workspace:^", + "@rocket.chat/ai-search": "workspace:^", "@rocket.chat/api-client": "workspace:^", "@rocket.chat/apps": "workspace:^", "@rocket.chat/apps-engine": "workspace:^", diff --git a/apps/meteor/server/methods/messageSearch.ts b/apps/meteor/server/methods/messageSearch.ts index 8b43420f3d34f..0eb87a20760e0 100644 --- a/apps/meteor/server/methods/messageSearch.ts +++ b/apps/meteor/server/methods/messageSearch.ts @@ -1,4 +1,4 @@ -import type { ISubscription } from '@rocket.chat/core-typings'; +import type { ISubscription, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Logger } from '@rocket.chat/logger'; import { Messages, Subscriptions, Users } from '@rocket.chat/models'; @@ -21,12 +21,19 @@ declare module '@rocket.chat/ddp-client' { } } +export type MessageSearchFilters = { + fromUsername?: string; + startDate?: Date; + endDate?: Date; +}; + export const messageSearch = async function ( userId: string, text: string, rid?: string, limit?: number, offset?: number, + filters?: MessageSearchFilters, ): Promise { check(text, String); check(rid, Match.Maybe(String)); @@ -90,6 +97,26 @@ export const messageSearch = async function ( }; } + if (filters?.fromUsername) { + const username = filters.fromUsername.replace(/^@/, ''); + const fromUser = await Users.findOneByUsername>(username, { projection: { _id: 1 } }); + if (!fromUser) { + return { + message: { + docs: [], + }, + }; + } + query['u._id'] = fromUser._id; + } + + if (filters?.startDate || filters?.endDate) { + query.ts = { + ...(filters.startDate && { $gte: filters.startDate }), + ...(filters.endDate && { $lte: filters.endDate }), + }; + } + try { return { message: { diff --git a/apps/meteor/server/services/ai-search/service.ts b/apps/meteor/server/services/ai-search/service.ts new file mode 100644 index 0000000000000..d34fe979fd022 --- /dev/null +++ b/apps/meteor/server/services/ai-search/service.ts @@ -0,0 +1,395 @@ +import { + AI_LICENSE_MODULE, + AI_SEARCH_PAGE_SIZE, + buildIntelligentSearchPipelineFilters, + generateOpenAICompatibleSearchAnswer, + listOpenAICompatibleModels, + MAX_PIPELINE_ROOM_FILTER_VALUES, + MAX_SEARCH_ANSWER_MESSAGES, + MAX_SEARCH_ANSWER_TEXT_LENGTH, + MAX_SEARCH_FILTER_VALUES, + MAX_UNSCOPED_PIPELINE_RESULTS, + normalizeIntelligentSearchCandidates, + searchIntelligentPipeline, + type AIServiceFetch, + type IntelligentSearchFilters, + type IntelligentSearchPipelineConfig, + type OpenAICompatibleProviderConfig, + type SearchAnswerMessage, +} from '@rocket.chat/ai-search'; +import type { + AISearchAnswerMessage, + AISearchAnswerResult, + AISearchFilters, + AISearchModelOption, + AISearchStatus, + IAISearchService, +} from '@rocket.chat/core-services'; +import { License, ServiceClass, Settings } from '@rocket.chat/core-services'; +import type { IMessage, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import type { UnifiedSearchIntelligentResult } from '@rocket.chat/rest-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { SystemLogger } from '../../lib/logger/system'; + +const PIPELINE_ROOM_PREFETCH_LIMIT = MAX_PIPELINE_ROOM_FILTER_VALUES + 1; + +const aiServiceFetch: AIServiceFetch = (url, options) => fetch(url, options as Parameters[1]); + +const asString = (value: unknown): string => { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return ''; +}; + +const toDate = (value?: string | Date): Date | undefined => { + if (!value) { + return undefined; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +}; + +const limitFilterValues = (values: string[] | undefined): string[] | undefined => + values?.filter(Boolean).slice(0, MAX_SEARCH_FILTER_VALUES); + +const normalizeFilters = (filters: AISearchFilters = {}): IntelligentSearchFilters => ({ + rid: filters.rid, + rids: limitFilterValues(filters.rids), + roomNames: limitFilterValues(filters.roomNames), + fromUsername: filters.fromUsername, + fromUsernames: limitFilterValues(filters.fromUsernames), + startDate: toDate(filters.startDate), + endDate: toDate(filters.endDate), +}); + +export class AISearchService extends ServiceClass implements IAISearchService { + protected name = 'ai-search'; + + private async getPipelineConfig(): Promise { + const [baseUrl, pipelineId, apiKey, apiKeySecret, queryTemplate, minimumSimilarityPercent] = await Promise.all([ + 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'), + Settings.get('AI_Intelligent_Search_Query_Template'), + Settings.get('AI_Intelligent_Search_Min_Similarity_Percent'), + ]); + + const normalizedBaseUrl = asString(baseUrl).replace(/\/+$/, ''); + const normalizedPipelineId = asString(pipelineId); + const normalizedApiKey = asString(apiKey); + const normalizedApiKeySecret = asString(apiKeySecret); + + if (!normalizedBaseUrl || !normalizedPipelineId || !normalizedApiKey || !normalizedApiKeySecret) { + return undefined; + } + + return { + baseUrl: normalizedBaseUrl, + pipelineId: normalizedPipelineId, + apiKey: normalizedApiKey, + apiKeySecret: normalizedApiKeySecret, + queryTemplate: asString(queryTemplate), + minimumSimilarityPercent: Number(minimumSimilarityPercent || 0), + }; + } + + private async getAnswerProviderConfig(): Promise { + const [baseUrl, apiKey, model] = await Promise.all([ + Settings.get('AI_LLM_OpenAI_Base_URL'), + Settings.get('AI_LLM_OpenAI_API_Key'), + Settings.get('AI_LLM_OpenAI_Model'), + ]); + + const normalizedBaseUrl = asString(baseUrl).replace(/\/+$/, ''); + const normalizedApiKey = asString(apiKey); + const normalizedModel = asString(model); + + if (!normalizedBaseUrl || !normalizedApiKey || !normalizedModel) { + return undefined; + } + + return { + name: 'OpenAI compatible', + baseUrl: normalizedBaseUrl, + apiKey: normalizedApiKey, + model: normalizedModel, + }; + } + + async status(): Promise { + const [hasIntelligentSearchLicense, intelligentSearchEnabled, answerGenerationEnabled, pipelineConfig, answerProviderConfig] = + await Promise.all([ + License.hasModule(AI_LICENSE_MODULE), + Settings.get('AI_Intelligent_Search_Enabled'), + Settings.get('AI_Intelligent_Search_Answer_Enabled'), + this.getPipelineConfig(), + this.getAnswerProviderConfig(), + ]); + + return { + hasIntelligentSearchLicense, + intelligentSearchEnabled: intelligentSearchEnabled === true, + intelligentSearchConfigured: Boolean(pipelineConfig), + answerGenerationConfigured: answerGenerationEnabled === true && Boolean(answerProviderConfig), + }; + } + + private async getRoomMap(roomIds: string[]): Promise>> { + if (!roomIds.length) { + return new Map(); + } + + const rooms = await Rooms.findByIds([...new Set(roomIds)], { + projection: { _id: 1, t: 1, name: 1, fname: 1 }, + }).toArray(); + + return new Map(rooms.map((room) => [room._id, room])); + } + + private async getUserRoomIdsForPipeline(userId: string): Promise<{ roomIds: string[]; isComplete: boolean }> { + const subscriptions = await Subscriptions.findByUserId(userId, { + projection: { rid: 1 }, + limit: PIPELINE_ROOM_PREFETCH_LIMIT, + }).toArray(); + + return { + roomIds: subscriptions.map(({ rid }: Pick) => rid), + isComplete: subscriptions.length < PIPELINE_ROOM_PREFETCH_LIMIT, + }; + } + + private async getAccessibleRoomIdSet(userId: string, roomIds: string[]): Promise> { + const uniqueRoomIds = [...new Set(roomIds)].filter(Boolean); + if (!uniqueRoomIds.length) { + return new Set(); + } + + const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, { + projection: { rid: 1 }, + }).toArray(); + + return new Set(subscriptions.map(({ rid }) => rid)); + } + + private async normalizeIntelligentResults( + rawSearchResults: unknown, + userId: string, + prefilterRoomIds: string[] = [], + limit = AI_SEARCH_PAGE_SIZE, + candidateLimit = limit, + ): Promise { + const candidates = normalizeIntelligentSearchCandidates(rawSearchResults, prefilterRoomIds, candidateLimit, SystemLogger); + const msgIds = candidates.map(({ msgId }) => msgId).filter((msgId): msgId is string => Boolean(msgId)); + const messageMap = new Map(); + + if (msgIds.length > 0) { + const msgs = await Messages.findVisibleByIds(msgIds, { + projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 }, + }).toArray(); + for (const message of msgs) { + messageMap.set(String(message._id), message); + } + SystemLogger.debug({ msg: 'AI search messages fetched from DB', requested: msgIds.length, found: messageMap.size }); + } + + const rooms = await this.getRoomMap([ + ...candidates.map(({ rid }) => rid).filter((rid): rid is string => Boolean(rid)), + ...Array.from(messageMap.values()).map(({ rid }) => rid), + ]); + const accessibleRoomIds = await this.getAccessibleRoomIdSet(userId, [ + ...candidates.map(({ rid }) => rid).filter((rid): rid is string => Boolean(rid)), + ...Array.from(messageMap.values()).map(({ rid }) => rid), + ]); + + return candidates + .flatMap((result) => { + const dbMessage = result.msgId ? messageMap.get(result.msgId) : undefined; + const rid = dbMessage?.rid || result.rid; + if (!rid || !accessibleRoomIds.has(rid)) { + return []; + } + + return [ + { + _id: result.msgId || result._id, + rid, + msgId: result.msgId, + text: dbMessage?.msg || result.pipelineText || '', + ts: dbMessage?.ts, + u: dbMessage?.u ? { username: dbMessage.u.username, name: dbMessage.u.name } : undefined, + ...(Number.isFinite(result.score) && { score: result.score }), + ...(rid && rooms.has(rid) && { room: rooms.get(rid) }), + }, + ]; + }) + .slice(0, limit); + } + + private async getUserClassifications(userId: string): Promise { + const user = await Users.findOneById>(userId, { projection: { roles: 1 } }); + return Array.from(new Set(['user', ...(user?.roles || [])])); + } + + private async getAccessibleRoomIds(userId: string, roomIds: string[]): Promise { + return [...(await this.getAccessibleRoomIdSet(userId, roomIds))]; + } + + private async getAccessibleRoomIdsByName(userId: string, roomNames: string[] = []): Promise { + if (!roomNames.length) { + return []; + } + + const rooms = await Promise.all( + Array.from(new Set(roomNames)).map((roomName) => Rooms.findOneByNameOrFname(roomName, { projection: { _id: 1 } })), + ); + + return this.getAccessibleRoomIds( + userId, + rooms.map((room) => room?._id).filter((roomId): roomId is string => Boolean(roomId)), + ); + } + + async search({ + query, + userId, + filters: rawFilters, + limit = AI_SEARCH_PAGE_SIZE, + }: { + query: string; + userId: string; + filters?: AISearchFilters; + limit?: number; + }): Promise { + const [hasIntelligentSearchLicense, intelligentSearchEnabled, config] = await Promise.all([ + License.hasModule(AI_LICENSE_MODULE), + Settings.get('AI_Intelligent_Search_Enabled'), + this.getPipelineConfig(), + ]); + + if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || !config) { + SystemLogger.debug({ + msg: 'AI search skipped: unavailable', + hasIntelligentSearchLicense, + intelligentSearchEnabled: intelligentSearchEnabled === true, + intelligentSearchConfigured: Boolean(config), + }); + return []; + } + + const filters = normalizeFilters(rawFilters); + const requestedRoomIds = [...new Set([...(filters.rids || []), ...(filters.rid ? [filters.rid] : [])])]; + const roomNameIds = await this.getAccessibleRoomIdsByName(userId, filters.roomNames); + const scopedRoomIds = [...new Set([...requestedRoomIds, ...roomNameIds])]; + const accessibleScopedRoomIds = scopedRoomIds.length ? await this.getAccessibleRoomIds(userId, scopedRoomIds) : []; + const pipelineRoomScope = scopedRoomIds.length + ? { roomIds: accessibleScopedRoomIds, isComplete: true } + : await this.getUserRoomIdsForPipeline(userId); + + if (!pipelineRoomScope.roomIds.length) { + SystemLogger.debug({ msg: 'AI search skipped: user has no room subscriptions' }); + return []; + } + + const pipelineFilters = buildIntelligentSearchPipelineFilters(pipelineRoomScope.roomIds, { + ...filters, + rids: [...(filters.rids || []), ...roomNameIds], + }); + + if (!pipelineFilters) { + SystemLogger.debug({ msg: 'AI search skipped: no accessible rooms for filters', rid: filters.rid }); + return []; + } + + const classifications = await this.getUserClassifications(userId); + const pipelineLimit = pipelineRoomScope.isComplete ? limit : Math.min(Math.max(limit * 10, 50), MAX_UNSCOPED_PIPELINE_RESULTS); + const json = await searchIntelligentPipeline({ + query, + config, + classifications, + pipelineFilters, + limit: pipelineLimit, + fetch: aiServiceFetch, + logger: SystemLogger, + }); + + return this.normalizeIntelligentResults( + json, + userId, + pipelineRoomScope.isComplete ? pipelineRoomScope.roomIds : [], + limit, + pipelineLimit, + ); + } + + async answer({ query, messages }: { query: string; messages: AISearchAnswerMessage[] }): Promise { + const [hasIntelligentSearchLicense, intelligentSearchEnabled, answerGenerationEnabled, pipelineConfig, provider, systemPromptSetting] = + await Promise.all([ + License.hasModule(AI_LICENSE_MODULE), + Settings.get('AI_Intelligent_Search_Enabled'), + Settings.get('AI_Intelligent_Search_Answer_Enabled'), + this.getPipelineConfig(), + this.getAnswerProviderConfig(), + Settings.get('AI_Intelligent_Search_Answer_System_Prompt'), + ]); + + if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || answerGenerationEnabled !== true || !pipelineConfig) { + throw new Error('error-ai-not-enabled'); + } + + if (!provider) { + throw new Error('error-ai-provider-not-configured'); + } + + const systemPrompt = + asString(systemPromptSetting) || + ` + Given below user's query and the search results, provide a concise and accurate answer to the query based on the search results. Make sure to include relevant caveats and context. Add references to the search results in the format [N] after the relevant information. If you are unsure about the answer, say that you are not sure instead of making something up. + For formatting the answer, use markdown. For code snippets, use markdown code blocks with the appropriate language specified. Keep the answers as concise as possible, while still providing a complete answer to the user's question, and everything in a single column, without using tables or other formatting that may be hard to read in the Rocket.Chat client. + `; + + const sanitizedMessages: SearchAnswerMessage[] = messages.map(({ text, username, roomName, ts, score }) => ({ + text, + username, + roomName, + ts, + score, + })); + + return generateOpenAICompatibleSearchAnswer({ + query, + messages: sanitizedMessages, + provider, + systemPrompt, + fetch: aiServiceFetch, + logger: SystemLogger, + maxMessages: MAX_SEARCH_ANSWER_MESSAGES, + maxTextLength: MAX_SEARCH_ANSWER_TEXT_LENGTH, + }); + } + + async models(): Promise { + const [baseUrl, apiKey, selectedModel] = await Promise.all([ + Settings.get('AI_LLM_OpenAI_Base_URL'), + Settings.get('AI_LLM_OpenAI_API_Key'), + Settings.get('AI_LLM_OpenAI_Model'), + ]); + + return listOpenAICompatibleModels({ + provider: { + baseUrl: asString(baseUrl).replace(/\/+$/, ''), + apiKey: asString(apiKey), + }, + selectedModel: asString(selectedModel), + fetch: aiServiceFetch, + logger: SystemLogger, + }); + } +} diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index b0c14c2b49913..cd0f20731f577 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -5,6 +5,7 @@ import { MongoInternals } from 'meteor/mongo'; import { AuthorizationLivechat } from '../../app/livechat/server/roomAccessValidator.internalService'; import { isRunningMs } from '../lib/isRunningMs'; +import { AISearchService } from './ai-search/service'; import { AnalyticsService } from './analytics/service'; import { AppsEngineService } from './apps-engine/service'; import { BannerService } from './banner/service'; @@ -62,6 +63,10 @@ export const registerServices = async (): Promise => { api.registerService(new MediaCallService()); api.registerService(new CallHistoryService()); + if (!process.env.USE_EXTERNAL_AI_SEARCH_SERVICE) { + api.registerService(new AISearchService()); + } + // if the process is running in micro services mode we don't need to register services that will run separately if (!isRunningMs()) { const { Presence } = await import('@rocket.chat/presence'); diff --git a/apps/meteor/server/settings/ai.ts b/apps/meteor/server/settings/ai.ts new file mode 100644 index 0000000000000..379a110bcdb6a --- /dev/null +++ b/apps/meteor/server/settings/ai.ts @@ -0,0 +1,137 @@ +import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search'; + +import { settingsRegistry } from '../../app/settings/server'; + +export const createAISettings = () => + settingsRegistry.addGroup('AI_Center', { hidden: true }, async function () { + await this.section('AI_LLM_Provider', async function () { + await this.add('AI_LLM_OpenAI_Base_URL', 'https://api.openai.com/v1', { + type: 'string', + i18nLabel: 'AI_LLM_OpenAI_Base_URL', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + i18nDescription: 'AI_LLM_OpenAI_Base_URL_Description', + }); + + await this.add('AI_LLM_OpenAI_API_Key', '', { + type: 'password', + i18nLabel: 'AI_LLM_OpenAI_API_Key', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + i18nDescription: 'AI_LLM_OpenAI_API_Key_Description', + }); + + await this.add('AI_LLM_OpenAI_Model', '', { + type: 'lookup', + lookupEndpoint: 'v1/ai.llm.models', + i18nLabel: 'AI_LLM_OpenAI_Model', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + i18nDescription: 'AI_LLM_OpenAI_Model_Description', + }); + }); + + await this.section('Intelligent_Search', async function () { + await this.add('AI_Intelligent_Search_Enabled', false, { + type: 'boolean', + i18nLabel: 'AI_Intelligent_Search_Enabled', + public: true, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: false, + i18nDescription: 'AI_Intelligent_Search_Enabled_Description', + }); + + await this.add('AI_Intelligent_Search_Pipeline_Base_URL', '', { + type: 'string', + i18nLabel: 'AI_Intelligent_Search_Pipeline_Base_URL', + 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', + i18nLabel: 'AI_Intelligent_Search_Pipeline_ID', + 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', + i18nLabel: 'AI_Intelligent_Search_API_Key', + 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', + i18nLabel: 'AI_Intelligent_Search_API_Key_Secret', + 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', + i18nLabel: 'AI_Intelligent_Search_Min_Similarity_Percent', + 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.add('AI_Intelligent_Search_Query_Template', '', { + type: 'string', + i18nLabel: 'AI_Intelligent_Search_Query_Template', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + i18nDescription: 'AI_Intelligent_Search_Query_Template_Description', + }); + await this.add('AI_Intelligent_Search_Answer_Enabled', true, { + type: 'boolean', + i18nLabel: 'AI_Intelligent_Search_Answer_Enabled', + public: true, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: false, + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + i18nDescription: 'AI_Intelligent_Search_Answer_Enabled_Description', + }); + await this.add('AI_Intelligent_Search_Answer_System_Prompt', '', { + type: 'string', + i18nLabel: 'AI_Intelligent_Search_Answer_System_Prompt', + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: '', + enableQuery: { _id: 'AI_Intelligent_Search_Enabled', value: true }, + i18nDescription: 'AI_Intelligent_Search_Answer_System_Prompt_Description', + }); + }); + + await this.section('AI_Thread_Summarization', async function () { + await this.add('AI_Thread_Summarization_Enabled', false, { + type: 'boolean', + i18nLabel: 'AI_Thread_Summarization_Enabled', + 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/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 1196c95e24a49..113d00b546ac2 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -687,50 +687,78 @@ describe('miscellaneous', () => { }); }); - describe('/fingerprint', () => { - let unauthorizedUser: TestUser; - let unauthorizedUserCredentials: Credentials; - - before(async () => { - unauthorizedUser = await createUser(); - unauthorizedUserCredentials = await doLogin(unauthorizedUser.username, password); - }); - - after(async () => { - await deleteUser(unauthorizedUser); - }); - - it('should return 401 when called without authentication', async () => { - const res = await request.post(api('fingerprint')).send({ setDeploymentAs: 'updated-configuration' }); - - expect(res.status).to.equal(401); - expect(res.body).to.have.property('status', 'error'); - }); - - it('should return 403 when a user without the manage-cloud permission tries to acknowledge a deployment configuration change', async () => { - const res = await request - .post(api('fingerprint')) - .set(unauthorizedUserCredentials) - .send({ setDeploymentAs: 'updated-configuration' }); - - expect(res.status).to.equal(403); - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + describe('[/search.unified]', () => { + it('should fail when query param is missing', (done) => { + void request + .get(api('search.unified')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }) + .end(done); }); - it('should return 403 when a user without the manage-cloud permission tries to deregister the workspace as a new workspace', async () => { - const res = await request.post(api('fingerprint')).set(unauthorizedUserCredentials).send({ setDeploymentAs: 'new-workspace' }); - - expect(res.status).to.equal(403); - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + it('should return the unified search response shape for authenticated users', (done) => { + void request + .get(api('search.unified')) + .query({ + query: adminUsername, + includeMessages: false, + includeIntelligent: true, + intelligentCount: 8, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body).to.have.property('messages').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('intelligent').and.to.be.an('array'); + expect(res.body) + .to.have.property('meta') + .and.to.include.keys([ + 'globalMessagesEnabled', + 'intelligentSearchEnabled', + 'intelligentSearchConfigured', + 'answerGenerationConfigured', + ]); + }) + .end(done); }); - it('should return 200 when a user with the manage-cloud permission acknowledges a deployment configuration change', async () => { - const res = await request.post(api('fingerprint')).set(credentials).send({ setDeploymentAs: 'updated-configuration' }); - - expect(res.status).to.equal(200); - expect(res.body).to.have.property('success', true); + it('should allow suppressing spotlight results while keeping AI Search metadata', (done) => { + void request + .get(api('search.unified')) + .query({ + query: adminUsername, + includeSpotlight: false, + includeMessages: false, + includeIntelligent: true, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('rooms').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('messages').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('intelligent').and.to.be.an('array'); + expect(res.body) + .to.have.property('meta') + .and.to.include.keys([ + 'globalMessagesEnabled', + 'intelligentSearchEnabled', + 'intelligentSearchConfigured', + 'answerGenerationConfigured', + ]); + }) + .end(done); }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/settings.ts b/apps/meteor/tests/end-to-end/api/settings.ts index a8905e0533dbb..a45a6c7d72178 100644 --- a/apps/meteor/tests/end-to-end/api/settings.ts +++ b/apps/meteor/tests/end-to-end/api/settings.ts @@ -101,6 +101,7 @@ describe('[Settings]', () => { }) .end(done); }); + it('should return the default values of the settings when includeDefaults is true', async () => { return request .get(api('settings')) @@ -112,7 +113,8 @@ describe('[Settings]', () => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('settings'); expect(res.body).to.have.property('count'); - expect(res.body.settings[0]).to.have.property('packageValue'); + const setting = res.body.settings.find((setting: Record) => Object.hasOwn(setting, 'value')); + expect(setting).to.have.property('packageValue'); }); }); }); diff --git a/apps/meteor/tests/unit/app/settings/server/functions/settings.tests.ts b/apps/meteor/tests/unit/app/settings/server/functions/settings.tests.ts index 9775e36da035d..d2038a6d478d1 100644 --- a/apps/meteor/tests/unit/app/settings/server/functions/settings.tests.ts +++ b/apps/meteor/tests/unit/app/settings/server/functions/settings.tests.ts @@ -83,6 +83,34 @@ describe('Settings', () => { expect(Settings.findOne({ _id: 'my_setting2' }).value).to.be.equal(false); }); + it('should respect explicit group visibility options', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.addGroup('hidden_group', { hidden: true }, async function () { + await this.section('section', async function () { + await this.add('hidden_group_setting', true, { + type: 'boolean', + }); + }); + }); + + expect(Settings.findOne({ _id: 'hidden_group' })).to.include({ + type: 'group', + hidden: true, + blocked: false, + }); + expect(Settings.findOne({ _id: 'hidden_group_setting' })).to.include({ + group: 'hidden_group', + section: 'section', + hidden: false, + blocked: false, + packageValue: true, + }); + }); + it('should respect override via environment as int', async () => { const settings = new CachedSettings(); Settings.settings = settings; diff --git a/apps/meteor/tests/unit/server/services/ai-search/service.tests.ts b/apps/meteor/tests/unit/server/services/ai-search/service.tests.ts new file mode 100644 index 0000000000000..194b195485ab3 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/ai-search/service.tests.ts @@ -0,0 +1,296 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const License = { + hasModule: sinon.stub(), +}; + +const Settings = { + get: sinon.stub(), +}; + +const Messages = { + findVisibleByIds: sinon.stub(), +}; + +const Rooms = { + findByIds: sinon.stub(), + findOneByNameOrFname: sinon.stub(), +}; + +const Subscriptions = { + findByUserId: sinon.stub(), + findByUserIdAndRoomIds: sinon.stub(), +}; + +const Users = { + findOneById: sinon.stub(), +}; + +const serverFetch = sinon.stub(); + +const { AISearchService } = proxyquire.noCallThru().load('../../../../../server/services/ai-search/service', { + '@rocket.chat/core-services': { + License, + ServiceClass: class { + protected name = ''; + }, + Settings, + }, + '@rocket.chat/models': { + Messages, + Rooms, + Subscriptions, + Users, + }, + '@rocket.chat/server-fetch': { + serverFetch, + }, + '../../lib/logger/system': { + SystemLogger: { + debug: sinon.stub(), + warn: sinon.stub(), + }, + }, +}); + +type CursorResult = { + toArray(): Promise; +}; + +const cursor = (items: T[]): CursorResult => ({ + toArray: async () => items, +}); + +const settings: Record = { + AI_Intelligent_Search_Enabled: true, + AI_Intelligent_Search_Pipeline_Base_URL: 'https://pipeline.example.com', + AI_Intelligent_Search_Pipeline_ID: 'workspace', + AI_Intelligent_Search_API_Key: 'key', + AI_Intelligent_Search_API_Key_Secret: 'secret', + AI_Intelligent_Search_Query_Template: '', + AI_Intelligent_Search_Min_Similarity_Percent: 61, + AI_Intelligent_Search_Answer_Enabled: true, + AI_LLM_OpenAI_Base_URL: 'https://llm.example.com', + AI_LLM_OpenAI_API_Key: 'llm-key', + AI_LLM_OpenAI_Model: 'gpt-test', + AI_Intelligent_Search_Answer_System_Prompt: 'Use sources only.', +}; + +const createService = (): InstanceType => new AISearchService(); + +describe('AISearchService', () => { + beforeEach(() => { + License.hasModule.reset(); + Settings.get.reset(); + Messages.findVisibleByIds.reset(); + Rooms.findByIds.reset(); + Rooms.findOneByNameOrFname.reset(); + Subscriptions.findByUserId.reset(); + Subscriptions.findByUserIdAndRoomIds.reset(); + Users.findOneById.reset(); + serverFetch.reset(); + + License.hasModule.resolves(true); + Settings.get.callsFake(async (key: string) => settings[key]); + Users.findOneById.resolves({ roles: ['admin'] }); + Rooms.findByIds.callsFake((roomIds: string[]) => + cursor( + roomIds.map((roomId) => ({ + _id: roomId, + t: 'c', + name: roomId === 'allowed' ? 'general' : roomId, + fname: roomId === 'allowed' ? 'General' : roomId, + })), + ), + ); + Subscriptions.findByUserIdAndRoomIds.callsFake((_userId: string, roomIds: string[]) => + cursor(roomIds.filter((roomId) => roomId === 'allowed' || roomId === 'room-general').map((rid) => ({ rid }))), + ); + Messages.findVisibleByIds.callsFake((msgIds: string[]) => + cursor( + msgIds.map((msgId) => ({ + _id: msgId, + rid: msgId === 'blocked-msg' ? 'blocked' : 'allowed', + msg: `${msgId} from db`, + ts: new Date('2026-01-05T12:00:00.000Z'), + u: { username: 'alice', name: 'Alice' }, + })), + ), + ); + }); + + describe('status', () => { + it('reports availability from license, settings, pipeline, and LLM configuration', async () => { + expect(await createService().status()).to.deep.equal({ + hasIntelligentSearchLicense: true, + intelligentSearchEnabled: true, + intelligentSearchConfigured: true, + answerGenerationConfigured: true, + }); + }); + + it('marks answer generation unavailable when the answer setting is off', async () => { + Settings.get.callsFake(async (key: string) => (key === 'AI_Intelligent_Search_Answer_Enabled' ? false : settings[key])); + + expect(await createService().status()).to.include({ + answerGenerationConfigured: false, + }); + }); + }); + + describe('search', () => { + it('does not call the pipeline when license, setting, or configuration is unavailable', async () => { + License.hasModule.resolves(false); + + expect(await createService().search({ query: 'fruit', userId: 'user-id' })).to.deep.equal([]); + expect(serverFetch.called).to.be.false; + }); + + it('uses bounded overfetch and post-filters accessible rooms for broad searches', async () => { + Subscriptions.findByUserId.returns(cursor(Array.from({ length: 1001 }, (_, index) => ({ rid: `room-${index}` })))); + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ + results: [ + { metadata: { room_id: 'blocked', msg_id: 'blocked-msg' }, text: 'blocked pipeline text', score: 0.1 }, + { metadata: { room_id: 'allowed', msg_id: 'allowed-msg' }, text: 'allowed pipeline text', score: 0.39 }, + ], + }), + text: async () => '', + }); + + const results = await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 }); + + expect(results).to.deep.equal([ + { + _id: 'allowed-msg', + rid: 'allowed', + msgId: 'allowed-msg', + text: 'allowed-msg from db', + ts: new Date('2026-01-05T12:00:00.000Z'), + u: { username: 'alice', name: 'Alice' }, + score: 0.61, + room: { _id: 'allowed', t: 'c', name: 'general', fname: 'General' }, + }, + ]); + + const [, options] = serverFetch.firstCall.args; + const body = JSON.parse(options.body); + expect(body.params.k).to.equal(50); + expect(body.filters).to.deep.equal({}); + expect( + Subscriptions.findByUserId.calledWith('user-id', { + projection: { rid: 1 }, + limit: 1001, + }), + ).to.be.true; + expect( + Subscriptions.findByUserIdAndRoomIds.calledWith( + 'user-id', + sinon.match((roomIds: string[]) => roomIds.includes('blocked') && roomIds.includes('allowed')), + { projection: { rid: 1 } }, + ), + ).to.be.true; + }); + + it('resolves room-name filters before querying the pipeline', async () => { + Rooms.findOneByNameOrFname.resolves({ _id: 'room-general' }); + Subscriptions.findByUserId.returns(cursor([{ rid: 'room-general' }])); + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ + results: [ + { metadata: { room_id: 'room-general', msg_id: 'general-msg' }, text: 'general pipeline text', similarity: 0.8 }, + { metadata: { room_id: 'blocked', msg_id: 'blocked-msg' }, text: 'blocked pipeline text', similarity: 0.99 }, + ], + }), + text: async () => '', + }); + Messages.findVisibleByIds.returns( + cursor([ + { + _id: 'general-msg', + rid: 'room-general', + msg: 'general message from db', + ts: new Date('2026-01-05T12:00:00.000Z'), + u: { username: 'alice', name: 'Alice' }, + }, + ]), + ); + Rooms.findByIds.returns(cursor([{ _id: 'room-general', t: 'c', name: 'general', fname: 'General' }])); + + const results = await createService().search({ + query: 'fruit', + userId: 'user-id', + filters: { + roomNames: ['general'], + fromUsernames: ['alice'], + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2026-01-31T00:00:00.000Z', + }, + limit: 5, + }); + + expect(results).to.have.lengthOf(1); + expect(results[0]).to.include({ _id: 'general-msg', rid: 'room-general', score: 0.8 }); + expect( + Messages.findVisibleByIds.calledWith(['general-msg'], { + projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 }, + }), + ).to.be.true; + + const [, options] = serverFetch.firstCall.args; + expect(JSON.parse(options.body).filters).to.deep.equal({ + room_id: { $eq: 'room-general' }, + username: { $eq: 'alice' }, + timestamp: { + $ge: '2026-01-01T00:00:00.000Z', + $le: '2026-01-31T00:00:00.000Z', + }, + }); + }); + }); + + describe('answer', () => { + it('rejects answer generation when AI Search or answer generation is unavailable', async () => { + Settings.get.callsFake(async (key: string) => (key === 'AI_Intelligent_Search_Answer_Enabled' ? false : settings[key])); + + try { + await createService().answer({ query: 'fruit', messages: [{ text: 'oranges are green' }] }); + throw new Error('Expected answer generation to fail'); + } catch (error) { + expect((error as Error).message).to.equal('error-ai-not-enabled'); + } + expect(serverFetch.called).to.be.false; + }); + + it('generates an answer from source messages with the configured LLM provider', async () => { + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: 'Oranges are green.' } }] }), + text: async () => '', + }); + + expect( + await createService().answer({ + query: 'fruit colors', + messages: [{ text: 'oranges are green', username: 'alice', roomName: 'general', score: 0.61 }], + }), + ).to.deep.equal({ + answer: 'Oranges are green.', + provider: { name: 'OpenAI compatible', model: 'gpt-test' }, + }); + + const [url, options] = serverFetch.firstCall.args; + expect(url).to.equal('https://llm.example.com/chat/completions'); + expect(options.headers.Authorization).to.equal('Bearer llm-key'); + expect(JSON.parse(options.body).messages[0]).to.deep.equal({ role: 'system', content: 'Use sources only.' }); + }); + }); +}); diff --git a/docs/intelligent-search-core-vs-apps-engine.md b/docs/intelligent-search-core-vs-apps-engine.md new file mode 100644 index 0000000000000..51775092f2b3d --- /dev/null +++ b/docs/intelligent-search-core-vs-apps-engine.md @@ -0,0 +1,630 @@ +# Intelligent Search: Core vs. Apps Engine + +This document compares the two viable delivery paths for the Intelligent Search feature — a native +core implementation and the existing Apps Engine app — to help the team make an informed decision +about which direction to continue investing in. + +> **Context:** The current core implementation is new work built specifically for this branch. It +> is not a port of the marketplace app; it was written independently, building on domain knowledge +> from the existing app but sharing no code with it. + +--- + +## 1. What Exists Today + +### 1.1 Core Implementation (this branch) + +The feature is integrated directly into the Rocket.Chat shell. + +**Entry point — NavBar search bar (`NavBarSearch.tsx`)** + +The global search bar (`⌘K`) is always visible. When the user opts into the AI Search Feature +Preview, a `stars` icon button is labelled **Search with AI** and navigates to +`/search?q=`. If the `chat.rocket.rc-ai` add-on is missing, the same control opens the +platform upsell modal instead of navigating away. As the user types, the dropdown shows: + +| Dropdown section | Content | +|---|---| +| Applied filters | Removable `Chip` components for completed filter tokens (AI Search Feature Preview and licensed workspaces only) | +| AI Search preview | Up to 3 semantic results from the pipeline, with a `stars` icon and room label | +| Filter suggestions | Context-aware completions: room names for `in:`, users for `from:`, date shortcuts for `after:`/`before:` | +| Rooms / recent | Standard subscription results | + +**Inline filter system (`useSearchItems.ts`)** + +Filter tokens are parsed directly from the text input using a deterministic regex: + +``` +FILTER_PATTERN = /(?:^|\s)(in|from|after|before):(?:"([^"]*)"|(\S+))/gi +``` + +| Token | Effect | +|---|---| +| `in:general` / `in:"my room"` | Scopes search to that room | +| `from:alice` | Filters by sender username | +| `after:2024-01-01` | Results after this date (ISO) | +| `before:2024-06-01` | Results before this date | + +Tokens can be freely combined (`deploy issues in:devops from:bob after:2024-03`). Comma-separated +values are supported for repeated filters (`in:devops,release from:alice,bob`). Filter parsing is +license-gated in the client; without the `chat.rocket.rc-ai` add-on, the search input behaves as +the standard room search box. + +`parseSearchFilterText` strips tokens and produces `{ searchText, filters }`. Completed tokens are +moved into grouped chips through `extractCompletedSearchFilters` and `buildAppliedFilterChips`. +When `getActiveFilter` detects an in-progress token (e.g. `in:dev`), the dropdown shows live +autocomplete for that token. + +**Search page (`SearchPage.tsx`)** + +Route `/search` — a dedicated AI Search results page. Single list, no tabs. + +``` +┌──────────────────────────────────────────────────┐ +│ Page header: "AI Search" │ +│ Results │ +│ ✦ Searching rooms you can access where AI │ +│ Search is enabled │ +├──────────────────────────────────────────────────┤ +│ [Callouts — license / disabled / not configured]│ +├──────────────────────────────────────────────────┤ +│ AnswerPanel │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ✦ AI Answer [Generate] │ │ +│ │ ─────────────────────────────────────── │ │ +│ │ │ │ +│ │ powered by {provider} · {model} │ │ +│ └──────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────┤ +│ Sources · N Messages [Show more] │ +│ ┌─ Message-style source row ──────────────┐ │ +│ │ avatar user @username #room 89% │ │ +│ │ Trimmed Markdown message preview │ │ +│ └─────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +- **AnswerPanel** — auto-triggers `POST /v1/search.answer` once results arrive and answer + generation is enabled with a configured LLM; shows Skeleton animation during generation (can take + 5–20 seconds); renders response as Markdown; "Regenerate" button available; shows a clear empty + state when there are no source results to summarize. +- **Source rows** — use the standard message visual structure with avatar, display name, + `@username`, room label, formatted date, trimmed Markdown text, and a relevance tag. +- **"Show more"** — appends 8 more results per click by updating the `intelligentCount` query + param. No page reload. + +**Server: `GET /v1/search.unified`** + +The REST handler keeps HTTP concerns and the legacy search surfaces in `misc.ts`: + +1. Parses query params (`rid`, `rids`, `roomNames`, `fromUsername`, `fromUsernames`, `startDate`, + `endDate`, `includeSpotlight`, `includeMessages`, `includeIntelligent`, `intelligentCount`). +2. Calls `AISearch.status()` to return AI Search metadata and to decide whether semantic search + can run. +3. Runs standard spotlight search in the REST handler unless `includeSpotlight=false` or the + request is scoped to a specific `rid`. +4. Runs standard message search in the REST handler only when `includeMessages=true` and either a + `rid` is present or global message search is enabled. +5. Calls `AISearch.search({ query, userId, filters, limit })` only when `includeIntelligent=true`, + the license module exists, AI Search is enabled, and the pipeline is configured. +6. Returns `{ users, rooms, messages, intelligent, meta }`. + +Inside `AISearchService.search()`: + +1. `License.hasModule('chat.rocket.rc-ai')`, `Settings.get('AI_Intelligent_Search_Enabled')`, + `getPipelineConfig()`, and `getUserRoomIds(userId)` run in parallel. +2. `getUserRoomIds(userId)` queries `Subscriptions.findByUserId()` for the user's rooms; this is + the Rocket.Chat-side security boundary. +3. Room-name filters are resolved via `Rooms.findOneByNameOrFname()` for each unique room name and + intersected with the user's subscription room IDs. +4. `buildIntelligentSearchPipelineFilters()` constructs the pipeline filter object + (`room_id`, `username`, `timestamp`). Explicit room filters are always intersected with the + user's subscriptions. Broad searches include the room-id filter only while the caller's room set is + below the bounded payload limit; larger broad searches rely on mandatory post-filtering after the + pipeline response. +5. `getUserClassifications(userId)` returns `['user', ...roles]`; these classifications are sent + to the pipeline for role-based access policies. +6. `searchIntelligentPipeline()` posts to `{baseUrl}/pipelines/{id}/search` with the query + (optionally formatted via `AI_Intelligent_Search_Query_Template`), classifications, filters, and + similarity params. Timeout: **10 seconds**. +7. `normalizeIntelligentResults()` handles multiple pipeline response shapes, batch-fetches visible + messages via `Messages.findVisibleByIds(msgIds)`, resolves each result's room from the pipeline or + DB message, drops anything outside the user's subscriptions, and fetches room labels via + `Rooms.findByIds(roomIds)`. + +**Server: `POST /v1/search.answer`** + +The REST handler validates the request and delegates to `AISearch.answer({ query, messages })`. +The service verifies license and feature enablement, reads the LLM provider settings and answer +system prompt, then calls an OpenAI-compatible `/chat/completions` endpoint with the top hits as +context. Timeout: **20 seconds**. Returns `{ answer, provider: { name, model } }`. + +**Request flow sequence diagram** + +Both REST handlers reach the business logic through `proxify('ai-search')`, +which routes the call through the Moleculer service broker to `AISearchService` — either in-process +(monolith) or over the network (distributed). The diagram below shows both endpoints. + +```mermaid +sequenceDiagram + participant C as Client + participant H as REST Handler
(misc.ts) + participant P as proxify
('ai-search') + participant S as AISearchService + participant DB as MongoDB + participant VP as Vector Pipeline
(HTTP ·~10 s) + participant LM as LLM Provider
(HTTP ·~20 s) + + rect rgb(235, 245, 255) + Note over C,LM: GET /v1/search.unified + C->>H: GET /v1/search.unified?query=…&filters=… + H->>P: AISearch.status() + P->>S: ai-search.status [LocalBroker or Moleculer] + S->>S: License.hasModule + Settings.get() + S-->>P: AISearchStatus + P-->>H: status + par optional standard search + H->>H: spotlight / room message search + and optional AI Search + H->>P: AISearch.search({ query, userId, filters, limit }) + P->>S: ai-search.search [LocalBroker or Moleculer] + S->>S: License.hasModule + Settings.get() + S->>DB: Subscriptions.findByUserId(userId) + opt roomNames filter present + S->>DB: Rooms.findOneByNameOrFname() × N + end + S->>DB: Users.findOneById(userId) → roles / classifications + S->>VP: POST /pipelines/{id}/search + VP-->>S: candidate IDs + similarity scores + S->>DB: Messages.findVisibleByIds(msgIds) [batch] + S->>DB: Rooms.findByIds(roomIds) + S-->>P: UnifiedSearchIntelligentResult[] + P-->>H: intelligent[] + end + H-->>C: 200 { users, rooms, messages, intelligent, meta } + end + + rect rgb(245, 255, 235) + Note over C,LM: POST /v1/search.answer + C->>H: POST /v1/search.answer { query, messages[] } + H->>P: AISearch.answer({ query, messages }) + P->>S: ai-search.answer [LocalBroker or Moleculer] + S->>S: License.hasModule + Settings.get() × 5 + S->>LM: POST /chat/completions (OpenAI-compatible) + LM-->>S: generated answer text + S-->>P: AISearchAnswerResult + P-->>H: result + H-->>C: 200 { answer, provider } + end +``` + +**Deployment topology** + +The same `AISearchService` code runs in two modes controlled by a single environment variable: + +``` +┌─ Monolith (default) ──────────────────────────────────────┐ +│ Meteor process │ +│ REST handlers → proxify → LocalBroker → AISearchService │ +│ ↓ │ +│ MongoDB · Vector Pipeline │ +│ LLM Provider │ +└────────────────────────────────────────────────────────────┘ + +┌─ Distributed (USE_EXTERNAL_AI_SEARCH_SERVICE=true) ────────┐ +│ Meteor process ee/apps/ai-search-service │ +│ REST handlers (separate Node process) │ +│ → proxify AISearchService │ +│ ↕ NATS/TCP ↓ │ +│ Moleculer broker ─────→ MongoDB · Pipeline · LLM │ +│ /health on :3038 │ +└────────────────────────────────────────────────────────────┘ +``` + +**Settings (`ai.ts`) — 13 enterprise settings** + +All settings use `enterprise: true`, `modules: ['chat.rocket.rc-ai']`, and `invalidValue`, meaning +they silently fall back to their invalid value when the license module is inactive. + +| Section | Settings | +|---|---| +| `AI_LLM_Provider` / LLM Providers | OpenAI-compatible base URL, API key, model (lookup type — live dropdown from `/v1/ai.llm.models`) | +| `Intelligent_Search` | Enabled (public), Show in top bar (public), Pipeline URL, Pipeline ID, API key, API key secret, Min similarity %, Query template, Answer system prompt | +| `AI_Thread_Summarization` | Enabled (public) | + +**AI Center admin page (`AICenterRoute.tsx`)** + +Route `/admin/ai-center` with an overview of all AI capabilities as feature cards (Intelligent +AI Search and OpenAI-compatible LLM configuration) and sub-routes into the +relevant settings sections, rendered via the standard `GenericGroupPage` component. + +--- + +### 1.2 Existing Apps Engine App (`intelligent-search` repo) + +A fully implemented marketplace app (v0.2.0, `addon: chat.rocket.rc-ai`). Its key characteristics: + +**Entry point** + +Slash command (`/search`) and message-reaction trigger. No NavBar integration is possible within +Apps Engine. + +**Filter extraction** + +`LexicalFilterExtractor` — a deterministic NLP-style extractor that parses natural language for +`@username` mentions, `#room` mentions, relative/absolute time expressions, and self-references. +An optional LLM-assisted extraction layer (`enable_filter_extraction` setting) can be layered on +top when accuracy on complex queries matters. The clean query and extracted filters are sent to the +pipeline via `buildPipelineSearchPayload`, which produces the same payload shape as the core +implementation. + +**Message fetch** + +`SearchResultsPresenter` iterates results and calls `read.getMessageReader().getById(msgId)` for +each — one IPC round-trip per message. + +**Results presentation** + +UIKit `PreviewBlock` and `SectionBlock` layouts posted as thread replies on the original `/search` +message. Pagination is handled by `SearchPaginationService`, which posts "Prev / Next" `ActionsBlock` +buttons in the thread. The app also auto-deletes stale entries from the vector DB when a +`getById` call returns nothing (the message was deleted from Rocket.Chat). + +**LLM answer** + +The app immediately posts a "Searching…" message, then posts the answer as a thread reply when the +LLM finishes — a workaround for the UIKit interaction timeout. + +**Settings (17)** + +Five sections: Pipeline Connection, Mongo Connector, LLM Connection, Search Experience, Logging & +Access. All string/number/boolean/password/select types — no `lookup` type, no `enterprise` flag, +no `modules` gating. + +--- + +## 2. Capability Comparison + +``` +✅ Equivalent ⚠️ Partial / workaround ❌ Not achievable without platform changes +``` + +| Capability | Core | Apps Engine app | Notes | +|---|---|---|---| +| Outbound HTTP to pipeline | ✅ | ✅ | Both work equally well | +| Role-based pipeline classifications | ✅ | ✅ `IUserRead` + roles | No gap | +| Inline filter tokens (`in:`, `from:`, `after:`, `before:`) in NavBar | ✅ | ❌ | No NavBar surface in Apps Engine | +| Filter autocomplete suggestions in NavBar dropdown | ✅ | ❌ | No NavBar dropdown extension | +| Applied filter chips (removable) in NavBar dropdown | ✅ | ❌ | No NavBar dropdown extension | +| Intelligent preview (3 results) while typing in NavBar | ✅ | ❌ | No NavBar dropdown extension | +| Dedicated full-page search route (`/search`) | ✅ | ❌ | Apps have modal / contextualBar / home only | +| URL-bookmarkable search state | ✅ | ❌ | No URL for contextual bar | +| `SourceResult` cards (numbered, room, user, date, text) | ✅ React | ⚠️ UIKit PreviewBlock | Rich layout becomes constrained UIKit blocks | +| `AnswerPanel` — auto-triggered, Skeleton, Markdown | ✅ | ⚠️ Thread reply (manual trigger) | Must poll thread; no inline loading state | +| LLM answer — 20s+ without timeout | ✅ | ⚠️ Thread reply pattern | UIKit action handlers time out at ~5s | +| "Show more" pagination with page state | ✅ `useState` | ⚠️ Prev/Next buttons in thread | Different UX; no page-level state | +| Batch message hydration (1 DB query) | ✅ `Messages.findVisibleByIds()` | ⚠️ `getById()` × N calls | N sequential IPC round-trips | +| Room name → ID resolution | ✅ `Rooms.findOneByNameOrFname()` × N | ⚠️ `IRoomRead.getByName()` × N | Core still intersects with subscriptions server-side | +| AI Center integrated settings page | ✅ `/admin/ai-center` | ❌ `Apps > [App]` only | Isolated from the rest of AI Center | +| `enterprise: true` + `modules: [...]` + `invalidValue` | ✅ | ❌ No enterprise flag for app settings | Settings always visible regardless of license | +| `License.hasModule('chat.rocket.rc-ai')` server-side | ✅ | ❌ No license module accessor | Cannot gate features by module | +| `lookup` setting type (live model dropdown) | ✅ | ❌ Not available in Apps Engine | Model selection is a free-text field | +| Upsell modal for unlicensed users | ✅ `GenericUpsellModal` | ❌ | No standard upsell pattern | +| Workspace / index management UI | ❌ (not yet in core) | ✅ Workspace Manager modal | App has richer admin tooling here | +| Natural-language filter extraction (LLM-assisted) | ❌ (token-only today) | ✅ `LexicalFilterExtractor` | App supports fuzzier natural-language queries | +| Auto-delete stale vector DB entries | ❌ | ✅ On `getById` miss | App maintains index health proactively | + +--- + +## 3. The LLM Answer Timing Problem + +This is the most concrete functional gap when running inside Apps Engine. + +**Core (current behaviour)** + +``` +User arrives on /search → results load + → useEffect: canGenerateAnswer = true + → POST /v1/search.answer (no user interaction needed) + → Server: fetch() to LLM, timeout: 20 seconds + → AnswerPanel renders Skeleton animation throughout + → Answer arrives → MarkdownText renders inline +Total wait: 5–20s, visible loading state, inline failure / empty states +``` + +**Apps Engine contextual bar** + +``` +User opens contextual bar → clicks "Generate Answer" + → UIKit block action fires + → App handler starts IHttp.post() → LLM (5–20s) + → UIKit runtime expects handler response within ~5s + → Timeout error returned to client before LLM finishes +``` + +The only viable workaround is the thread-reply pattern the app currently uses: post a placeholder +message immediately, then post the answer as a thread reply when the LLM finishes. This works +reliably, but it changes the interaction model — the user leaves the search context, returns to +the channel, and reads the answer in a thread rather than seeing it appear inline on the same page. + +--- + +## 4. Effort Comparison + +### Option A — Continue with core implementation + +| Area | Status | +|---|---| +| Filter token parsing (`in:`, `from:`, `after:`, `before:`) | ✅ Done | +| `parseSearchFilterText` + `buildAppliedFilterChips` | ✅ Done | +| `getActiveFilter` + live filter suggestions in dropdown | ✅ Done | +| AI Search toggle + 3-result preview in NavBar dropdown | ✅ Done | +| Applied filter chips (removable) | ✅ Done | +| `GET /v1/search.unified` with filter params | ✅ Done | +| Server-side room name resolution | ✅ Done | +| User role classifications sent to pipeline | ✅ Done | +| Pipeline HTTP call with filters + classifications | ✅ Done | +| Multiple pipeline response shape normalisation | ✅ Done | +| Batch DB message hydration | ✅ Done | +| Security filter (subscription check) | ✅ Done | +| `@rocket.chat/ai-search` pure logic package | ✅ Done | +| `IAISearchService` core-services interface | ✅ Done | +| `ee/apps/ai-search-service` standalone service | ✅ Done | +| `POST /v1/search.answer` + auto-triggered LLM generation | ✅ Done | +| Message-style source rows + `AnswerPanel` + Skeleton | ✅ Done | +| "Show more" pagination | ✅ Done | +| AI Center overview + settings sub-pages | ✅ Done | +| 13 enterprise settings with `invalidValue` + module gating | ✅ Done | +| `AI_LLM_OpenAI_Model` lookup setting (live model dropdown) | ✅ Done | +| License-gated upsell modal | ✅ Done | +| Workspace / index management UI | ❌ Not yet implemented | +| Integration tests with live pipeline | ❌ Not yet done | + +**Estimated remaining work: 2–4 weeks** (workspace management UI, operational hardening, and live +pipeline integration testing can run in parallel). + +--- + +### Option B — Maintain and extend the existing Apps Engine app + +The app already exists and handles the core pipeline interaction well. Adopting it as the primary +path would mean: + +| Gap to close | Effort | Constraints | +|---|---|---| +| Replace NavBar entry point with slash command (accept regression) | — | Already done; this accepts the loss of NavBar integration | +| Inline LLM answer (overcome 5s UIKit timeout) | ❌ Not feasible without platform change | Thread-reply pattern is the only viable path | +| Full-page search route | ❌ Not feasible | No full-page route surface in Apps Engine | +| Rich result cards (Skeleton, Markdown) | ❌ Not feasible | UIKit layout blocks are the ceiling | +| License module gating (`enterprise: true` + `modules: [...]`) | ❌ Not feasible | Platform would need to expose `ILicenseRead.hasModule()` | +| Integrated AI Center settings page | ❌ Not feasible | App settings are isolated to `Apps > [App]` | +| `lookup` type for model dropdown | ❌ Not feasible | Not available in Apps Engine settings | +| Remaining existing gaps above | ~2–3 weeks | Only closeable gaps | + +**Current feature parity vs. core: ~45%** (the app handles pipeline calls, filter extraction, +results presentation, pagination, and workspace management well, but the NavBar, inline LLM +answer, rich page layout, and license integration are out of reach). + +--- + +### Option C — Apps Engine app + platform extensions + +To achieve genuine parity via Apps Engine, several platform capabilities would need to be built +first: + +| Extension needed | Estimate | +|---|---| +| NavBar UIKit surface (inject into global search input + dropdown) | 4–6 weeks | +| Async block action result delivery (overcome ~5s timeout) | 2–3 weeks | +| `IMessageRead.getByIds(ids[])` batch method in bridge | 3–5 days | +| `ILicenseRead.hasModule(moduleId)` accessor for apps | 1 week | +| App settings `enterprise: true` + `modules: [...]` + `invalidValue` | 1–2 weeks | +| `lookup` setting type in Apps Engine | 3–5 days | +| Admin panel page surface for apps | 5–8 weeks | + +**Platform work subtotal: ~14–21 weeks** +**App updates on top: ~3–4 weeks** +**Total: ~4–6 months** + +This path also introduces multi-team coordination dependencies, making the schedule less +predictable. + +--- + +## 5. UX Comparison + +| Dimension | Core | Apps Engine app | +|---|---|---| +| **Entry point** | Global NavBar `✦` button, always visible (`⌘K`) | `/search` slash command in a room | +| **Filter input** | Inline `in:room from:user after:date` tokens with autocomplete | Natural-language extraction (deterministic + optional LLM-assisted) | +| **Filter feedback** | Applied chips shown in NavBar dropdown, clickable to remove | Extracted filters logged; no visual chip feedback | +| **AI Search preview** | 3 results shown instantly in NavBar dropdown while typing | None before slash command is submitted | +| **Results view** | Dedicated full-page `/search`, URL-bookmarkable | Thread reply on the original search message | +| **Result cards** | Numbered, room label, `@username`, date, message text | UIKit PreviewBlock / SectionBlock | +| **LLM answer** | Auto-triggered inline, Skeleton loading, Markdown rendered | Thread reply posted when LLM finishes; user navigates away to read | +| **Pagination** | Inline "Show more" button, no reload | Prev/Next buttons in thread | +| **Admin settings** | Integrated into AI Center alongside OpenAI-compatible LLM configuration | Isolated under `Apps > Intelligent Search` | +| **License gating** | Module-level control; Locked/Disabled/Enabled tags; upsell modal | Always visible; no tiering | +| **Workspace management** | Not yet implemented | Full Workspace Manager modal (connector, room selection, backfill, pipeline config) | +| **Deep linking** | URL carries full search state | No URL; state lost on close | +| **Feature discoverability** | `✦` icon always in NavBar | Must know `/search` exists | + +--- + +## 6. Our Preference for Core + +The choice ultimately comes down to the kind of feature IS is, and where its primary value lives. + +**Feature class** + +Apps Engine is well-suited for third-party integrations — connectors, bots, and features that +augment the existing UI by adding room actions or responding to messages. The IS feature is +different: its primary value is in how users discover and invoke it (always-visible NavBar, inline +filter chips, semantic preview as you type) and in the quality of the results page (inline LLM +answer, rich result cards, Markdown rendering). Both of those are built on React surfaces that +Apps Engine cannot reach. + +**The 5s timeout is a hard constraint, not a configuration option** + +The inline LLM answer is one of the most visible differentiators of this feature. Presenting it +inline, auto-triggered, with a live loading state is what sets it apart from a basic keyword +search. The thread-reply workaround works as a fallback, but it changes the interaction model in a +way that weakens the core value proposition. This is not a gap that can be closed without platform +changes, and those changes would take significantly longer than completing the core implementation. + +**The existing app has real strengths** + +The Apps Engine app has capabilities the core implementation does not — the Workspace Manager +modal for index administration, LLM-assisted natural-language filter extraction, and proactive +index health maintenance. These are worth incorporating into the core path rather than maintaining +two separate implementations. + +**Integration with the AI Center family** + +AI Search and OpenAI-compatible LLM configuration live in the same `AI_Center` settings group under +the same `chat.rocket.rc-ai` license module. Keeping IS in the +same family means consistent settings management, consistent license gating, and a coherent admin +experience across all AI capabilities. + +**Summary** + +| | Core | Apps Engine (as-is) | Apps Engine + platform work | +|---|---|---|---| +| **Feature parity** | ~80% (workspace mgmt missing) | ~45% | ~95% | +| **Estimated remaining work** | 2–4 weeks | Accepts regressions | 4–6 months | +| **Inline LLM answer UX** | ✅ Auto-triggered, Markdown | ⚠️ Thread reply | ✅ (if platform delivers) | +| **NavBar integration** | ✅ Filter chips, preview, autocomplete | ❌ | ✅ (if platform delivers) | +| **AI Center integration** | ✅ | ❌ Isolated | ✅ (if platform delivers) | +| **License gating** | ✅ Module-level | ❌ None | ✅ (if platform delivers) | +| **Workspace manager** | ❌ Not yet | ✅ Full modal | ✅ | + +The preferred path is to continue with the core implementation, bring across the workspace +management and index health capabilities from the existing app, and retire the app once the core +feature reaches parity. + +--- + +## 7. Microservice Architecture Considerations + +### Current architecture + +The IS feature ships with two deployment modes (see the sequence diagram in Section 1.1): + +- **Monolith (default):** `AISearchService` is registered in the Meteor process. REST handlers in + `misc.ts` call it via `proxify('ai-search')` → Moleculer LocalBroker → service. +- **Distributed:** Set `USE_EXTERNAL_AI_SEARCH_SERVICE=true` in the Meteor app and start + `ee/apps/ai-search-service` as a separate Node process. It connects to the same Moleculer broker + over NATS or TCP and exposes a `/health` endpoint on port 3038. + +``` +Client → GET /v1/search.unified → proxify('ai-search') → AISearchService + ├── MongoDB + └── HTTP → Vector pipeline (~10 s) + +Client → POST /v1/search.answer → proxify('ai-search') → AISearchService + └── HTTP → LLM endpoint (~20 s) +``` + +The implementation keeps three layers separate: + +| Layer | Responsibility | +|---|---| +| `@rocket.chat/ai-search` | Pure, framework-independent logic for pipeline filters, pipeline calls, result normalization, OpenAI-compatible answers, and model lookup | +| `AISearchService` | Rocket.Chat orchestration: license and setting checks, subscription security boundary, room/user/message hydration, and calls to the pure package | +| REST handlers | HTTP validation, standard spotlight/message search, response shaping, and error translation | + +This works well at low to moderate concurrency and can be deployed as a separate service when AI +Search load should be isolated from the Meteor process. + +### What breaks under high concurrency + +Consider a workspace with 10,000 users where 1,000 users perform searches simultaneously. + +Each in-flight AI Search request can open: +- one outbound HTTP connection to the vector pipeline, held for up to the 10 second timeout +- one outbound HTTP connection to the LLM answer provider, held for up to the 20 second timeout + +The MongoDB queries within each request (Subscriptions, Messages, Rooms, Users) are *not* a +meaningful bottleneck here. The Node.js MongoDB driver checks out a connection per operation and +returns it to the pool as soon as the query completes — typically within a few milliseconds. While +the pipeline and LLM HTTP calls are in-flight, no MongoDB connections are held. This is the same +pattern that handles 10,000 active users already. + +The real pressure points at 1,000 concurrent requests: + +| Resource | Impact | +|---|---| +| Node.js event loop | Thousands of in-flight async callbacks, JSON parsing of large HTTP responses, and result normalisation — all on the same thread that handles messaging and notifications | +| Memory | 1,000 buffered HTTP response bodies held in-process simultaneously while awaiting pipeline and LLM responses | +| LLM endpoint | Rate limits hit immediately — no queuing or backpressure exists today; requests either fail or queue inside the LLM provider with no visibility | +| Process stability | Sustained memory pressure from long-lived HTTP connections can trigger OOM, taking down messaging and presence alongside search | + +The distributed service removes this pressure from the Meteor process, but request-level +backpressure, per-provider concurrency caps, and circuit breakers are still hardening work to add +before promoting the service as the default enterprise-scale deployment. + +### What a dedicated microservice provides + +Rocket.Chat's microservice model uses Moleculer as the broker (`@rocket.chat/core-services`). +Services communicate via `api.call()` (proxify) in a monolith or over the network in a +distributed deployment. The dedicated `AISearchService` now provides: + +| Concern | Benefit | +|---|---| +| **Process isolation** | IS load (memory + event loop pressure from long-lived HTTP connections) cannot degrade messaging, presence, or other RC operations | +| **Horizontal scaling** | Multiple IS instances behind Moleculer load balancing; scaled independently from the main RC cluster | +| **Deployment flexibility** | Same implementation runs in-process for simple deployments or out-of-process for larger installations | +| **Restart isolation** | IS service can be restarted or updated without restarting the full Rocket.Chat server | +| **Clear ownership boundary** | LLM, vector pipeline, and result hydration logic live behind `IAISearchService` rather than inside REST handlers | + +It does **not** yet configure a service-level queue, request concurrency cap, or circuit breaker. +Those should be treated as the next operational-hardening step rather than as already-delivered +behavior. + +### The LLM is the real ceiling — queuing is not optional + +Even with 5 IS service replicas, you cannot fire 1,000 concurrent LLM calls. The LLM provider +(whether OpenAI-compatible or self-hosted) has a finite throughput. The correct behaviour at +saturation is to **queue** requests with a concurrency cap, not to reject them or let them pile +up in-process. + +A microservice is the natural place to own this queue. Implementing it inside the Meteor API +handler is possible but awkward: it doesn't survive restarts cleanly, it's not observable, and +it mixes concerns with the HTTP routing layer. + +### Migration path + +The migration from REST-embedded logic to a service boundary is complete in this branch: + +| Step | Status | +|---|---| +| Extract pure pipeline and LLM logic into `@rocket.chat/ai-search` | ✅ Done | +| Define `IAISearchService` in `packages/core-services/src/types/IAISearchService.ts` | ✅ Done | +| Expose `AISearch = proxify('ai-search')` | ✅ Done | +| Implement `AISearchService` as a `ServiceClass` | ✅ Done | +| Register `AISearchService` in monolith mode | ✅ Done | +| Add `USE_EXTERNAL_AI_SEARCH_SERVICE` escape hatch for distributed deployments | ✅ Done | +| Add `ee/apps/ai-search-service` with tracing, model registration, broker startup, and `/health` | ✅ Done | +| Replace REST handler business logic with `AISearch.status/search/models/answer` calls | ✅ Done | + +The remaining work is not migration; it is production hardening: + +| Hardening item | Why it matters | +|---|---| +| Service-level concurrency caps for pipeline and LLM calls | Prevents large workspaces from overwhelming providers or exhausting memory | +| Queueing / backpressure with observable pending counts | Gives predictable behavior under bursts instead of failing unpredictably | +| Circuit breaker around pipeline and LLM failures | Avoids long waits and cascading failures when dependencies are down | +| Metrics for latency, errors, result counts, and answer generation | Needed for SRE visibility and capacity planning | +| Integration tests with a representative pipeline | Verifies filters, role classifications, result hydration, and answer generation end-to-end | + +### Recommendation + +| Deployment scale | Recommended architecture | +|---|---| +| Small / medium workspace | Monolith registration is acceptable; no extra operational component | +| Large workspace or AI-heavy usage | Run `ee/apps/ai-search-service` separately with `USE_EXTERNAL_AI_SEARCH_SERVICE=true` | +| Very large / multi-node deployment | Scale AI Search service replicas independently and add provider-level concurrency controls | + +The current service boundary is the right production shape. The next decision is operational: +whether to run it in-process for simple deployments or out-of-process when AI Search traffic needs +isolation and independent scaling. diff --git a/packages/ai-search/README.md b/packages/ai-search/README.md new file mode 100644 index 0000000000000..7e43c807b8f96 --- /dev/null +++ b/packages/ai-search/README.md @@ -0,0 +1,16 @@ +# @rocket.chat/ai-search + +Shared AI Search service primitives. + +This package keeps AI Search provider calls and normalization logic outside the +Meteor REST handlers. It is intentionally framework-light: callers inject +configuration, logger, and `fetch`, which makes the code usable from the current +monolith or from a future standalone service process. + +Current responsibilities: + +- OpenAI-compatible model listing. +- OpenAI-compatible answer generation for search results. +- Intelligent Search pipeline request construction. +- Pipeline filter construction. +- Pipeline response normalization and similarity score handling. diff --git a/packages/ai-search/jest.config.ts b/packages/ai-search/jest.config.ts new file mode 100644 index 0000000000000..c18c8ae02465c --- /dev/null +++ b/packages/ai-search/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/packages/ai-search/package.json b/packages/ai-search/package.json new file mode 100644 index 0000000000000..05d5cd026c751 --- /dev/null +++ b/packages/ai-search/package.json @@ -0,0 +1,31 @@ +{ + "name": "@rocket.chat/ai-search", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "typings": "./src/index.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "jest", + "testunit": "jest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tsconfig": "workspace:*", + "@types/jest": "~30.0.0", + "eslint": "~9.39.4", + "jest": "~30.2.0", + "typescript": "~5.9.3" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/ai-search/src/clientSearch.spec.ts b/packages/ai-search/src/clientSearch.spec.ts new file mode 100644 index 0000000000000..06ccb2a1f542d --- /dev/null +++ b/packages/ai-search/src/clientSearch.spec.ts @@ -0,0 +1,164 @@ +import { + buildAppliedFilterChips, + buildRoomSearchQuery, + emptySearchFilters, + extractCompletedSearchFilters, + getAISearchButtonTooltip, + mergeSearchFilters, + parseSearchFilterText, + serializeSearchQuery, +} from './clientSearch'; + +describe('AI Search client helpers', () => { + describe('parseSearchFilterText', () => { + it('extracts room, user, and date filters while preserving the free-text query', () => { + expect(parseSearchFilterText('in:general,dev from:@alice after:2026-01-01 before:2026-01-31 fruit colors')).toEqual({ + searchText: 'fruit colors', + filters: { + roomNames: ['general', 'dev'], + rids: [], + fromUsernames: ['alice'], + startDate: '2026-01-01', + endDate: '2026-01-31', + }, + }); + }); + + it('supports quoted filter values with spaces', () => { + expect(parseSearchFilterText('mongo in:"team room" from:"rocket user"')).toEqual({ + searchText: 'mongo', + filters: { + roomNames: ['team room'], + rids: [], + fromUsernames: ['rocket user'], + }, + }); + }); + }); + + describe('extractCompletedSearchFilters', () => { + it('keeps a single active trailing filter token editable', () => { + expect(extractCompletedSearchFilters('mongo from:ren')).toEqual({ + searchText: 'mongo from:ren', + filters: emptySearchFilters(), + hasCompletedFilters: false, + }); + }); + + it('extracts completed filters when the token is followed by whitespace', () => { + expect(extractCompletedSearchFilters('mongo from:ren ')).toEqual({ + searchText: 'mongo ', + filters: { + roomNames: [], + rids: [], + fromUsernames: ['ren'], + }, + hasCompletedFilters: true, + }); + }); + + it('extracts multiple completed filters in the same input', () => { + expect(extractCompletedSearchFilters('in:general from:ren')).toEqual({ + searchText: '', + filters: { + roomNames: ['general'], + rids: [], + fromUsernames: ['ren'], + }, + hasCompletedFilters: true, + }); + }); + }); + + describe('mergeSearchFilters', () => { + it('deduplicates repeated rooms and users while preserving latest date filters', () => { + expect( + mergeSearchFilters( + { roomNames: ['general'], rids: ['r1'], fromUsernames: ['alice'], startDate: '2026-01-01' }, + { roomNames: ['general', 'dev'], rids: ['r1', 'r2'], fromUsernames: ['alice', 'bob'], endDate: '2026-01-31' }, + ), + ).toEqual({ + roomNames: ['general', 'dev'], + rids: ['r1', 'r2'], + fromUsernames: ['alice', 'bob'], + startDate: '2026-01-01', + endDate: '2026-01-31', + }); + }); + }); + + describe('buildAppliedFilterChips', () => { + it('groups common filter types into a single readable chip', () => { + expect( + buildAppliedFilterChips({ + roomNames: ['general', 'dev'], + rids: [], + fromUsernames: ['alice', 'bob'], + startDate: '2026-01-01', + }), + ).toEqual([ + { key: 'in', values: ['general', 'dev'], label: 'in: #general, #dev' }, + { key: 'from', values: ['alice', 'bob'], label: 'from: @alice, @bob' }, + { key: 'after', values: ['2026-01-01'], label: 'after:2026-01-01' }, + ]); + }); + }); + + describe('serializeSearchQuery', () => { + it('quotes filter values with spaces and appends the free-text query', () => { + expect( + serializeSearchQuery('fruit colors', { + roomNames: ['team room'], + rids: [], + fromUsernames: ['alice'], + startDate: '2026-01-01', + }), + ).toBe('in:"team room" from:alice after:2026-01-01 fruit colors'); + }); + }); + + describe('buildRoomSearchQuery', () => { + it('builds indexed room-name and fname regex predicates and excludes direct rooms for channel mentions', () => { + expect(buildRoomSearchQuery('gen', '#')).toEqual({ + $or: [{ name: /gen/i }, { fname: /gen/i }], + t: { $ne: 'd' }, + }); + }); + + it('bounds the escaped regex pattern used for room lookup', () => { + const query = buildRoomSearchQuery('/'.repeat(128)); + const firstPredicate = query.$or[0]; + + if (!firstPredicate || !('name' in firstPredicate)) { + throw new Error('Expected the first room lookup predicate to match room names'); + } + + const nameRegex = firstPredicate.name; + if (!(nameRegex instanceof RegExp)) { + throw new Error('Expected the room name lookup predicate to use a regex'); + } + + expect(nameRegex.source).toBe('\\/'.repeat(64)); + }); + }); + + describe('getAISearchButtonTooltip', () => { + const t = (key: string): string => key; + + it('explains unavailable AI Search when the add-on is missing', () => { + expect(getAISearchButtonTooltip({ hasIntelligentSearchLicense: false, intelligentSearchEnabled: true, t })).toBe( + 'AI_Search_license_required_tooltip', + ); + }); + + it('explains disabled AI Search when the add-on is available', () => { + expect(getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: false, t })).toBe( + 'AI_Search_disabled_tooltip', + ); + }); + + it('uses the normal action label when AI Search can be toggled', () => { + expect(getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: true, t })).toBe('Search_with_AI'); + }); + }); +}); diff --git a/packages/ai-search/src/clientSearch.ts b/packages/ai-search/src/clientSearch.ts new file mode 100644 index 0000000000000..2a0564832c860 --- /dev/null +++ b/packages/ai-search/src/clientSearch.ts @@ -0,0 +1,257 @@ +import { MAX_ROOM_SEARCH_PATTERN_LENGTH } from './constants'; + +export type SearchFilters = { + roomNames: string[]; + rids: string[]; + fromUsernames: string[]; + startDate?: string; + endDate?: string; + rid?: string; + fromUsername?: string; +}; + +export type NavBarSearchFormValues = { + filterText: string; + appliedFilters: SearchFilters; +}; + +export type SearchFilterSuggestion = { + key: string; + group: 'rooms' | 'users' | 'dates'; + title: string; + description: string; + value: string; + icon: 'hash' | 'user' | 'calendar'; +}; + +export type SearchFilterChip = { + key: string; + label: string; + values: string[]; +}; + +export type ActiveSearchFilter = { + key: 'in' | 'from' | 'after' | 'before'; + value: string; + start: number; + end: number; +}; + +const FILTER_PATTERN = /(?:^|\s)(in|from|after|before):(?:"([^"]*)"|(\S+))/gi; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const normalizeFilterText = (value: string): string => value.replace(/\s+/g, ' ').trimStart(); + +const splitFilterValues = (value: string): string[] => + value + .split(',') + .map((item) => item.replace(/^[@#]/, '').trim()) + .filter(Boolean); + +const unique = (items: string[]): string[] => Array.from(new Set(items)); + +export const emptySearchFilters = (): SearchFilters => ({ roomNames: [], rids: [], fromUsernames: [] }); + +export const mergeSearchFilters = (...filtersList: SearchFilters[]): SearchFilters => + filtersList.reduce( + (result, filters) => ({ + roomNames: unique([...result.roomNames, ...filters.roomNames]), + rids: unique([...result.rids, ...filters.rids]), + fromUsernames: unique([...result.fromUsernames, ...filters.fromUsernames]), + startDate: filters.startDate || result.startDate, + endDate: filters.endDate || result.endDate, + rid: filters.rid || result.rid, + fromUsername: filters.fromUsername || result.fromUsername, + }), + emptySearchFilters(), + ); + +export const parseSearchFilterText = (filterText: string): { searchText: string; filters: SearchFilters } => { + const filters: SearchFilters = emptySearchFilters(); + const searchText = filterText + .replace(FILTER_PATTERN, (_match, key: string, quotedValue?: string, bareValue?: string) => { + const value = String(quotedValue || bareValue || '').trim(); + const values = splitFilterValues(value); + if (!values.length) { + return ' '; + } + + switch (key.toLowerCase()) { + case 'in': + filters.roomNames.push(...values); + break; + case 'from': + filters.fromUsernames.push(...values); + break; + case 'after': + filters.startDate = values[0]; + break; + case 'before': + filters.endDate = values[0]; + break; + } + + return ' '; + }) + .replace(/\s+/g, ' ') + .trim(); + + return { searchText, filters }; +}; + +export const extractCompletedSearchFilters = ( + filterText: string, +): { searchText: string; filters: SearchFilters; hasCompletedFilters: boolean } => { + const filters: SearchFilters = emptySearchFilters(); + let hasCompletedFilters = false; + const trimmedLength = filterText.trimEnd().length; + const shouldKeepTrailingTokenEditable = Array.from(filterText.matchAll(FILTER_PATTERN)).length <= 1; + const searchText = filterText + .replace(FILTER_PATTERN, (match, key: string, quotedValue?: string, bareValue?: string, offset?: number) => { + const start = typeof offset === 'number' ? offset : 0; + const end = start + match.length; + const isActiveToken = shouldKeepTrailingTokenEditable && end >= trimmedLength && !/\s$/.test(filterText); + if (isActiveToken) { + return match; + } + + const values = splitFilterValues(String(quotedValue || bareValue || '')); + if (!values.length) { + return ' '; + } + + hasCompletedFilters = true; + switch (key.toLowerCase()) { + case 'in': + filters.roomNames.push(...values); + break; + case 'from': + filters.fromUsernames.push(...values); + break; + case 'after': + filters.startDate = values[0]; + break; + case 'before': + filters.endDate = values[0]; + break; + } + + return ' '; + }) + .replace(/\s+/g, ' ') + .trimStart(); + + return { searchText, filters, hasCompletedFilters }; +}; + +export const getActiveSearchFilter = (filterText: string): ActiveSearchFilter | undefined => { + const match = /(?:^|\s)(in|from|after|before):([^\s]*)$/i.exec(filterText); + if (!match) { + return undefined; + } + + const tokenStart = filterText.lastIndexOf(match[1], filterText.length - match[2].length - 1); + return { + key: match[1].toLowerCase() as ActiveSearchFilter['key'], + value: match[2], + start: tokenStart, + end: filterText.length, + }; +}; + +export const formatSearchFilterValue = (key: ActiveSearchFilter['key'], value: string): string => + `${key}:${/\s/.test(value) ? `"${value}"` : value}`; + +export const applySearchFilterToken = ( + filterText: string, + activeFilter: ActiveSearchFilter | undefined, + key: ActiveSearchFilter['key'], + value: string, +): string => { + const token = formatSearchFilterValue(key, value); + if (activeFilter) { + return normalizeFilterText(`${filterText.slice(0, activeFilter.start)}${token} `); + } + + return normalizeFilterText(`${filterText.trim()} ${token} `); +}; + +const getFilterChipLabel = (key: ActiveSearchFilter['key'], value: string): string => { + switch (key) { + case 'in': + return `#${value}`; + case 'from': + return `@${value}`; + default: + return `${key}:${value}`; + } +}; + +export const buildAppliedFilterChips = (filters: SearchFilters): SearchFilterChip[] => + [ + filters.roomNames.length && { + key: 'in', + values: filters.roomNames, + label: `in: ${filters.roomNames.map((roomName) => `#${roomName}`).join(', ')}`, + }, + filters.fromUsernames.length && { + key: 'from', + values: filters.fromUsernames, + label: `from: ${filters.fromUsernames.map((username) => `@${username}`).join(', ')}`, + }, + filters.startDate && { + key: 'after', + values: [filters.startDate], + label: getFilterChipLabel('after', filters.startDate), + }, + filters.endDate && { + key: 'before', + values: [filters.endDate], + label: getFilterChipLabel('before', filters.endDate), + }, + ].filter(Boolean) as SearchFilterChip[]; + +export const serializeSearchQuery = (searchText: string, filters: SearchFilters): string => + normalizeFilterText( + [ + ...filters.roomNames.map((roomName) => formatSearchFilterValue('in', roomName)), + ...filters.fromUsernames.map((username) => formatSearchFilterValue('from', username)), + filters.startDate && formatSearchFilterValue('after', filters.startDate), + filters.endDate && formatSearchFilterValue('before', filters.endDate), + searchText, + ] + .filter(Boolean) + .join(' '), + ); + +export const buildRoomSearchQuery = (value: string, mention?: string) => { + const filterRegex = new RegExp(escapeRegExp(value.slice(0, MAX_ROOM_SEARCH_PATTERN_LENGTH)), 'i'); + + return { + $or: [{ name: filterRegex }, { fname: filterRegex }], + ...(mention && { + t: mention === '@' ? 'd' : { $ne: 'd' }, + }), + }; +}; + +export const getAISearchButtonTooltip = ({ + hasIntelligentSearchLicense, + intelligentSearchEnabled, + t, +}: { + hasIntelligentSearchLicense: boolean; + intelligentSearchEnabled: boolean; + t: (key: string) => string; +}): string => { + if (!hasIntelligentSearchLicense) { + return t('AI_Search_license_required_tooltip'); + } + + if (!intelligentSearchEnabled) { + return t('AI_Search_disabled_tooltip'); + } + + return t('Search_with_AI'); +}; diff --git a/packages/ai-search/src/constants.ts b/packages/ai-search/src/constants.ts new file mode 100644 index 0000000000000..015eff0ab4983 --- /dev/null +++ b/packages/ai-search/src/constants.ts @@ -0,0 +1,12 @@ +export const AI_LICENSE_MODULE = 'chat.rocket.rc-ai'; + +export const AI_SEARCH_PAGE_SIZE = 5; +export const MAX_INTELLIGENT_SEARCH_RESULTS = 50; +export const MAX_SEARCH_FILTER_VALUES = 25; +export const MAX_UNIFIED_SEARCH_RESULTS = 10; +export const MAX_ROOM_SEARCH_PATTERN_LENGTH = 64; +export const MAX_SOURCE_MESSAGE_LENGTH = 700; +export const MAX_SEARCH_ANSWER_MESSAGES = 12; +export const MAX_SEARCH_ANSWER_TEXT_LENGTH = 1600; +export const MAX_PIPELINE_ROOM_FILTER_VALUES = 1000; +export const MAX_UNSCOPED_PIPELINE_RESULTS = 100; diff --git a/packages/ai-search/src/index.ts b/packages/ai-search/src/index.ts new file mode 100644 index 0000000000000..e3f7bd0f029af --- /dev/null +++ b/packages/ai-search/src/index.ts @@ -0,0 +1,5 @@ +export * from './clientSearch'; +export * from './constants'; +export * from './intelligentSearch'; +export * from './llm'; +export type * from './types'; diff --git a/packages/ai-search/src/intelligentSearch.spec.ts b/packages/ai-search/src/intelligentSearch.spec.ts new file mode 100644 index 0000000000000..0a74cea8d31fe --- /dev/null +++ b/packages/ai-search/src/intelligentSearch.spec.ts @@ -0,0 +1,184 @@ +import { + buildIntelligentSearchPipelineFilters, + getSemanticDistanceThreshold, + normalizeIntelligentSearchCandidates, + normalizeSimilarityPercent, + searchIntelligentPipeline, +} from './intelligentSearch'; +import type { AIServiceFetch } from './types'; + +describe('AI Search intelligent search helpers', () => { + describe('normalizeSimilarityPercent', () => { + it('normalizes invalid and out-of-range values', () => { + expect(normalizeSimilarityPercent(undefined)).toBe(0); + expect(normalizeSimilarityPercent('abc')).toBe(0); + expect(normalizeSimilarityPercent(-10)).toBe(0); + expect(normalizeSimilarityPercent(101)).toBe(100); + expect(normalizeSimilarityPercent(72.9)).toBe(72); + }); + }); + + describe('getSemanticDistanceThreshold', () => { + it('converts minimum similarity to pipeline distance threshold', () => { + expect(getSemanticDistanceThreshold(89)).toBe(0.11); + expect(getSemanticDistanceThreshold(0)).toBe(1); + expect(getSemanticDistanceThreshold(100)).toBe(0); + }); + }); + + describe('normalizeIntelligentSearchCandidates', () => { + it('normalizes supported pipeline response shapes and score formats', () => { + const results = normalizeIntelligentSearchCandidates( + { + results: [ + { metadata: { room_id: 'r1', msg_id: 'm1', text: 'metadata text', score: 0.11 } }, + { external_identifier: 'r2:m2', content: 'content text', similarity: 0.49 }, + { id: 'm3', rid: 'r3', document: 'document text', distance: 12 }, + { text: 'missing ids' }, + ], + }, + [], + 10, + ); + + expect(results).toEqual([ + { _id: 'm1', rid: 'r1', msgId: 'm1', pipelineText: 'metadata text', score: 0.89 }, + { _id: 'm2', rid: 'r2', msgId: 'm2', pipelineText: 'content text', score: 0.49 }, + { _id: 'm3', rid: 'r3', msgId: 'm3', pipelineText: 'document text', score: 0.88 }, + ]); + }); + + it('filters by accessible room ids only when a prefilter is provided', () => { + const rawResults = [ + { metadata: { room_id: 'allowed', msg_id: 'm1' }, text: 'allowed' }, + { metadata: { room_id: 'blocked', msg_id: 'm2' }, text: 'blocked' }, + ]; + + expect(normalizeIntelligentSearchCandidates(rawResults, [], 10)).toHaveLength(2); + expect(normalizeIntelligentSearchCandidates(rawResults, ['allowed'], 10)).toEqual([ + { _id: 'm1', rid: 'allowed', msgId: 'm1', pipelineText: 'allowed' }, + ]); + }); + + it('honors the requested candidate limit after filtering invalid results', () => { + const results = normalizeIntelligentSearchCandidates( + [{ text: 'invalid' }, { metadata: { room_id: 'r1', msg_id: 'm1' } }, { metadata: { room_id: 'r2', msg_id: 'm2' } }], + [], + 1, + ); + + expect(results).toEqual([{ _id: 'm1', rid: 'r1', msgId: 'm1', pipelineText: '' }]); + }); + }); + + describe('buildIntelligentSearchPipelineFilters', () => { + it('returns undefined when no accessible room ids are available', () => { + expect(buildIntelligentSearchPipelineFilters([], {})).toBe(undefined); + }); + + it('serializes room, user, and date filters for the pipeline', () => { + const startDate = new Date('2026-01-01T00:00:00.000Z'); + const endDate = new Date('2026-01-31T23:59:59.000Z'); + + expect( + buildIntelligentSearchPipelineFilters(['r1', 'r2', 'r3'], { + rids: ['r1', 'r2'], + fromUsername: '@alice', + fromUsernames: ['bob', 'alice'], + startDate, + endDate, + }), + ).toEqual({ + room_id: { $in: ['r1', 'r2'] }, + username: { $in: ['bob', 'alice'] }, + timestamp: { $ge: startDate.toISOString(), $le: endDate.toISOString() }, + }); + }); + + it('rejects explicit room filters that are not accessible', () => { + expect(buildIntelligentSearchPipelineFilters(['r1'], { rid: 'r2' })).toBe(undefined); + }); + + it('omits broad room filters when the accessible room list is too large', () => { + const roomIds = Array.from({ length: 1001 }, (_, index) => `r${index}`); + + expect(buildIntelligentSearchPipelineFilters(roomIds, { fromUsername: 'alice' })).toEqual({ + username: { $eq: 'alice' }, + }); + }); + }); + + describe('searchIntelligentPipeline', () => { + it('sends the expected bounded request to the pipeline', async () => { + let requestUrl = ''; + let requestBody = ''; + const fetch: AIServiceFetch = async (url, options) => { + requestUrl = url; + requestBody = String(options.body); + return { + ok: true, + status: 200, + json: async () => ({ results: [] }), + text: async () => '', + }; + }; + + const result = await searchIntelligentPipeline({ + query: 'fruit colors', + config: { + baseUrl: 'https://pipeline.example.com/', + pipelineId: 'workspace pipeline', + apiKey: 'key', + apiKeySecret: 'secret', + queryTemplate: 'semantic: {query}', + minimumSimilarityPercent: 61, + }, + classifications: ['user', 'admin'], + pipelineFilters: { room_id: { $eq: 'r1' } }, + limit: 8, + fetch, + }); + + expect(result).toEqual({ results: [] }); + expect(requestUrl).toBe('https://pipeline.example.com/pipelines/workspace%20pipeline/search'); + expect(JSON.parse(requestBody)).toEqual({ + query: 'semantic: fruit colors', + type: 'similarity', + classification: { + classifications: ['user', 'admin'], + search_type: 2, + }, + filters: { room_id: { $eq: 'r1' } }, + params: { + k: 8, + threshold: 0.39, + }, + }); + }); + + it('returns an empty result set for non-2xx pipeline responses', async () => { + const fetch: AIServiceFetch = async () => ({ + ok: false, + status: 500, + json: async () => ({}), + text: async () => 'failed', + }); + + const result = await searchIntelligentPipeline({ + query: 'fruit colors', + config: { + baseUrl: 'https://pipeline.example.com', + pipelineId: 'workspace', + apiKey: 'key', + apiKeySecret: 'secret', + }, + classifications: ['user'], + pipelineFilters: {}, + limit: 5, + fetch, + }); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/ai-search/src/intelligentSearch.ts b/packages/ai-search/src/intelligentSearch.ts new file mode 100644 index 0000000000000..37f4a1962f965 --- /dev/null +++ b/packages/ai-search/src/intelligentSearch.ts @@ -0,0 +1,255 @@ +import { MAX_PIPELINE_ROOM_FILTER_VALUES } from './constants'; +import type { + AIServiceFetch, + AIServiceLogger, + IntelligentSearchCandidate, + IntelligentSearchFilters, + IntelligentSearchPipelineFilters, + IntelligentSearchPipelineRequest, +} from './types'; +import { trimTrailingSlashes } from './url'; + +type IntelligentSearchRawResult = Record & { metadata?: Record }; + +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; +}; + +export 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))); +}; + +export const getSemanticDistanceThreshold = (minimumSimilarityPercent: number): number => + Number((1 - minimumSimilarityPercent / 100).toFixed(4)); + +const normalizePipelineSimilarityScore = (value: number, type: 'distance' | 'similarity'): number => { + const normalizedValue = Math.abs(value) > 1 ? value / 100 : value; + const similarity = type === 'distance' ? 1 - normalizedValue : normalizedValue; + + return Math.min(1, Math.max(0, similarity)); +}; + +const extractPipelineSimilarityScore = (result: IntelligentSearchRawResult, metadata: Record): number | undefined => { + const similarity = firstNumber(result.similarity, metadata.similarity); + if (typeof similarity === 'number') { + return normalizePipelineSimilarityScore(similarity, 'similarity'); + } + + const distance = firstNumber(result.score, result.distance, metadata.score, metadata.distance); + if (typeof distance === 'number') { + return normalizePipelineSimilarityScore(distance, 'distance'); + } + + 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(':'); + 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 }; +}; + +export const normalizeIntelligentSearchCandidates = ( + rawSearchResults: unknown, + userRoomIds: string[] = [], + limit: number, + logger?: AIServiceLogger, +): IntelligentSearchCandidate[] => { + let rawResults: unknown[] = []; + const rawSearchResultsRecord = asRecord(rawSearchResults); + + if (Array.isArray(rawSearchResults)) { + rawResults = rawSearchResults; + } 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; + } + + logger?.debug?.({ + msg: 'Intelligent search normalizing results', + rawCount: rawResults.length, + rawKeys: Object.keys(rawSearchResultsRecord), + }); + + const userRoomIdSet = new Set(userRoomIds); + const shouldFilterByRoomIds = userRoomIdSet.size > 0; + + const candidates = rawResults + .map((rawResult: unknown, index: number): IntelligentSearchCandidate => { + const result = asRecord(rawResult) as IntelligentSearchRawResult; + const metadata = asRecord(result.metadata); + const { rid, msgId } = extractIntelligentResultIds(result); + const pipelineText = firstString(result.text, result.content, result.document, result.page_content, metadata.text) || ''; + const score = extractPipelineSimilarityScore(result, metadata); + + return { + _id: msgId || `intelligent-${index}`, + rid, + msgId, + pipelineText, + ...(typeof score === 'number' && { score }), + }; + }) + .filter((result) => { + if (!result.msgId && !result.rid) { + return false; + } + if (shouldFilterByRoomIds && result.rid && !userRoomIdSet.has(result.rid)) { + logger?.debug?.({ msg: 'Intelligent search result filtered: room not in user subscriptions', rid: result.rid }); + return false; + } + return true; + }) + .slice(0, limit); + + logger?.debug?.({ msg: 'Intelligent search after filter', candidateCount: candidates.length }); + + return candidates; +}; + +export const buildIntelligentSearchPipelineFilters = ( + userRoomIds: string[], + { rid, rids, fromUsername, fromUsernames, startDate, endDate }: Omit, +): IntelligentSearchPipelineFilters | undefined => { + if (!userRoomIds.length) { + return undefined; + } + + const requestedRoomIds = [...new Set([...(rids || []), ...(rid ? [rid] : [])])]; + const accessibleRoomIds = requestedRoomIds.length ? requestedRoomIds.filter((roomId) => userRoomIds.includes(roomId)) : userRoomIds; + const filters: IntelligentSearchPipelineFilters = {}; + + if (requestedRoomIds.length && !accessibleRoomIds.length) { + return undefined; + } + + if (requestedRoomIds.length || accessibleRoomIds.length <= MAX_PIPELINE_ROOM_FILTER_VALUES) { + filters.room_id = accessibleRoomIds.length === 1 ? { $eq: accessibleRoomIds[0] } : { $in: accessibleRoomIds }; + } + + const usernames = [ + ...new Set([...(fromUsernames || []), ...(fromUsername ? [fromUsername] : [])].map((username) => username.replace(/^@/, ''))), + ]; + if (usernames.length === 1) { + filters.username = { $eq: usernames[0] }; + } else if (usernames.length > 1) { + filters.username = { $in: usernames }; + } + + if (startDate || endDate) { + filters.timestamp = { + ...(startDate && { $ge: startDate.toISOString() }), + ...(endDate && { $le: endDate.toISOString() }), + }; + } + + return filters; +}; + +export const searchIntelligentPipeline = async ({ + query, + config, + classifications, + pipelineFilters, + limit, + fetch, + logger, +}: IntelligentSearchPipelineRequest): Promise => { + const minimumSimilarity = normalizeSimilarityPercent(config.minimumSimilarityPercent); + const formattedQuery = config.queryTemplate ? config.queryTemplate.replace('{query}', query) : query; + const url = `${trimTrailingSlashes(config.baseUrl)}/pipelines/${encodeURIComponent(config.pipelineId)}/search`; + + logger?.debug?.({ + msg: 'Intelligent search request', + url, + queryLength: formattedQuery.length, + hasQueryTemplate: Boolean(config.queryTemplate), + filterKeys: Object.keys(pipelineFilters), + classificationCount: classifications.length, + threshold: getSemanticDistanceThreshold(minimumSimilarity), + }); + + let response: Awaited>; + try { + response = await fetch(url, { + method: 'POST', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-API-KEY': config.apiKey, + 'X-API-KEY-SECRET': config.apiKeySecret, + }, + body: JSON.stringify({ + query: formattedQuery, + type: 'similarity', + classification: { + classifications, + search_type: 2, + }, + filters: pipelineFilters, + params: { + k: limit, + threshold: getSemanticDistanceThreshold(minimumSimilarity), + }, + }), + }); + } catch (fetchError: unknown) { + logger?.warn?.({ msg: 'Intelligent search fetch failed', url, err: fetchError }); + throw fetchError; + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + logger?.warn?.({ msg: 'Intelligent search pipeline returned error', url, status: response.status, body: body.slice(0, 500) }); + return []; + } + + const json = await response.json(); + logger?.debug?.({ msg: 'Intelligent search raw response received', resultKeys: Object.keys(asRecord(json)) }); + return json; +}; diff --git a/packages/ai-search/src/llm.spec.ts b/packages/ai-search/src/llm.spec.ts new file mode 100644 index 0000000000000..708f348acf188 --- /dev/null +++ b/packages/ai-search/src/llm.spec.ts @@ -0,0 +1,164 @@ +import { buildSearchAnswerPrompt, generateOpenAICompatibleSearchAnswer, listOpenAICompatibleModels } from './llm'; +import type { AIServiceFetch } from './types'; + +describe('AI Search LLM helpers', () => { + describe('buildSearchAnswerPrompt', () => { + it('limits messages and truncates source text deterministically', () => { + const prompt = buildSearchAnswerPrompt( + 'What did we decide?', + [ + { + text: 'A'.repeat(12), + username: 'alice', + roomName: 'general', + ts: '2026-01-01T00:00:00.000Z', + score: 0.615, + }, + { text: 'second result', username: 'bob' }, + { text: 'third result' }, + ], + { maxMessages: 2, maxTextLength: 5 }, + ); + + expect(prompt).toBe( + [ + 'User search query: What did we decide?', + 'Search results:', + '1. [from @alice, in #general, at 2026-01-01T00:00:00.000Z, score 62%] AAAAA', + '2. [from @bob] secon', + 'Answer using only the search results above. If the results do not contain enough information, say that clearly.', + ].join('\n\n'), + ); + }); + }); + + describe('generateOpenAICompatibleSearchAnswer', () => { + it('calls chat completions and returns the selected answer with provider metadata', async () => { + let requestUrl = ''; + let requestBody = ''; + const fetch: AIServiceFetch = async (url, options) => { + requestUrl = url; + requestBody = String(options.body); + return { + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: ' Answer from sources. ' } }] }), + text: async () => '', + }; + }; + + const result = await generateOpenAICompatibleSearchAnswer({ + query: 'fruit colors', + messages: [{ text: 'oranges are green', username: 'alice' }], + provider: { + name: 'OpenAI compatible', + baseUrl: 'https://llm.example.com/', + apiKey: 'secret', + model: 'gpt-test', + }, + systemPrompt: 'Use sources only.', + fetch, + maxMessages: 4, + maxTextLength: 200, + }); + + expect(result).toEqual({ answer: 'Answer from sources.', provider: { name: 'OpenAI compatible', model: 'gpt-test' } }); + expect(requestUrl).toBe('https://llm.example.com/chat/completions'); + expect(JSON.parse(requestBody)).toMatchObject({ + model: 'gpt-test', + temperature: 0.2, + }); + expect(JSON.parse(requestBody).messages[0]).toEqual({ role: 'system', content: 'Use sources only.' }); + }); + + it('throws on provider failures and empty responses', async () => { + const failedFetch: AIServiceFetch = async () => ({ + ok: false, + status: 500, + json: async () => ({}), + text: async () => 'failed', + }); + + await expect( + generateOpenAICompatibleSearchAnswer({ + query: 'fruit colors', + messages: [{ text: 'oranges are green' }], + provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' }, + systemPrompt: 'Use sources only.', + fetch: failedFetch, + maxMessages: 4, + maxTextLength: 200, + }), + ).rejects.toThrow('error-ai-provider-request-failed'); + + const emptyFetch: AIServiceFetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: ' ' } }] }), + text: async () => '', + }); + + await expect( + generateOpenAICompatibleSearchAnswer({ + query: 'fruit colors', + messages: [{ text: 'oranges are green' }], + provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' }, + systemPrompt: 'Use sources only.', + fetch: emptyFetch, + maxMessages: 4, + maxTextLength: 200, + }), + ).rejects.toThrow('error-ai-provider-empty-response'); + }); + }); + + describe('listOpenAICompatibleModels', () => { + it('returns the configured model when provider lookup is not configured', async () => { + const fetch: AIServiceFetch = async () => { + throw new Error('must not fetch'); + }; + + expect(await listOpenAICompatibleModels({ selectedModel: 'existing-model', fetch })).toEqual([ + { key: 'existing-model', label: 'existing-model' }, + ]); + }); + + it('sorts provider models and preserves the selected custom model', async () => { + const fetch: AIServiceFetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ data: [{ id: 'z-model' }, { id: 'a-model' }] }), + text: async () => '', + }); + + expect( + await listOpenAICompatibleModels({ + provider: { baseUrl: 'https://llm.example.com', apiKey: 'secret' }, + selectedModel: 'custom-model', + fetch, + }), + ).toEqual([ + { key: 'custom-model', label: 'custom-model' }, + { key: 'a-model', label: 'a-model' }, + { key: 'z-model', label: 'z-model' }, + ]); + }); + + it('falls back to the selected model when provider lookup fails', async () => { + const fetch: AIServiceFetch = async () => ({ + ok: false, + status: 401, + json: async () => ({}), + text: async () => 'unauthorized', + }); + + expect( + await listOpenAICompatibleModels({ + provider: { baseUrl: 'https://llm.example.com', apiKey: 'secret' }, + selectedModel: 'configured-model', + fetch, + }), + ).toEqual([{ key: 'configured-model', label: 'configured-model' }]); + }); + }); +}); diff --git a/packages/ai-search/src/llm.ts b/packages/ai-search/src/llm.ts new file mode 100644 index 0000000000000..cfe186a4bbbbf --- /dev/null +++ b/packages/ai-search/src/llm.ts @@ -0,0 +1,128 @@ +import type { AIServiceFetch, AIServiceLogger, OpenAICompatibleProviderConfig, SearchAnswerMessage, SearchAnswerResult } from './types'; +import { trimTrailingSlashes } from './url'; + +export const buildSearchAnswerPrompt = ( + query: string, + messages: SearchAnswerMessage[], + options: { + maxMessages: number; + maxTextLength: number; + }, +): string => + [ + `User search query: ${query}`, + 'Search results:', + ...messages.slice(0, options.maxMessages).map((message, index) => { + const metadata = [ + message.username && `from @${message.username}`, + message.roomName && `in #${message.roomName}`, + message.ts && `at ${message.ts}`, + typeof message.score === 'number' && `score ${Math.round(message.score * 100)}%`, + ] + .filter(Boolean) + .join(', '); + return `${index + 1}. ${metadata ? `[${metadata}] ` : ''}${message.text.slice(0, options.maxTextLength)}`; + }), + 'Answer using only the search results above. If the results do not contain enough information, say that clearly.', + ].join('\n\n'); + +export const generateOpenAICompatibleSearchAnswer = async ({ + query, + messages, + provider, + systemPrompt, + fetch, + logger, + maxMessages, + maxTextLength, +}: { + query: string; + messages: SearchAnswerMessage[]; + provider: OpenAICompatibleProviderConfig; + systemPrompt: string; + fetch: AIServiceFetch; + logger?: AIServiceLogger; + maxMessages: number; + maxTextLength: number; +}): Promise => { + const response = await fetch(`${trimTrailingSlashes(provider.baseUrl)}/chat/completions`, { + method: 'POST', + timeout: 20000, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${provider.apiKey}`, + }, + body: JSON.stringify({ + model: provider.model, + temperature: 0.2, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: buildSearchAnswerPrompt(query, messages, { maxMessages, maxTextLength }) }, + ], + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + logger?.warn?.({ msg: 'Search answer LLM provider returned error', status: response.status, body: body.slice(0, 500) }); + throw new Error('error-ai-provider-request-failed'); + } + + const json = (await response.json()) as { choices?: { message?: { content?: string } }[] }; + const answer = json.choices?.[0]?.message?.content?.trim(); + if (!answer) { + throw new Error('error-ai-provider-empty-response'); + } + + return { answer, provider: { name: provider.name, model: provider.model } }; +}; + +export const listOpenAICompatibleModels = async ({ + provider, + selectedModel, + fetch, + logger, +}: { + provider?: Pick; + selectedModel?: string; + fetch: AIServiceFetch; + logger?: AIServiceLogger; +}): Promise<{ key: string; label: string }[]> => { + const fallback = selectedModel ? [{ key: selectedModel, label: selectedModel }] : []; + if (!provider?.baseUrl || !provider.apiKey) { + return fallback; + } + + try { + const response = await fetch(`${trimTrailingSlashes(provider.baseUrl)}/models`, { + method: 'GET', + timeout: 10000, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${provider.apiKey}`, + }, + }); + + if (!response.ok) { + logger?.warn?.({ msg: 'AI LLM model lookup failed', status: response.status }); + return fallback; + } + + const json = (await response.json()) as { data?: { id?: string }[] }; + const data = (json.data || []) + .map(({ id }) => id) + .filter((id): id is string => Boolean(id)) + .sort((a, b) => a.localeCompare(b)) + .map((id) => ({ key: id, label: id })); + + if (selectedModel && !data.some(({ key }) => key === selectedModel)) { + data.unshift({ key: selectedModel, label: selectedModel }); + } + + return data; + } catch (error) { + logger?.warn?.({ msg: 'AI LLM model lookup request failed', err: error }); + return fallback; + } +}; diff --git a/packages/ai-search/src/types.ts b/packages/ai-search/src/types.ts new file mode 100644 index 0000000000000..a2593314589b4 --- /dev/null +++ b/packages/ai-search/src/types.ts @@ -0,0 +1,80 @@ +export type AIServiceFetchResponse = { + ok: boolean; + status: number; + json(): Promise; + text(): Promise; +}; + +export type AIServiceFetch = ( + url: string, + options: { + method: string; + timeout?: number; + headers?: Record; + body?: string; + }, +) => Promise; + +export type AIServiceLogger = { + debug?(payload: Record): void; + warn?(payload: Record): void; +}; + +export type OpenAICompatibleProviderConfig = { + name: string; + baseUrl: string; + apiKey: string; + model: string; +}; + +export type SearchAnswerMessage = { + text: string; + username?: string; + roomName?: string; + ts?: string; + score?: number; +}; + +export type SearchAnswerResult = { + answer: string; + provider: Pick; +}; + +export type IntelligentSearchPipelineConfig = { + baseUrl: string; + pipelineId: string; + apiKey: string; + apiKeySecret: string; + queryTemplate?: string; + minimumSimilarityPercent?: number; +}; + +export type IntelligentSearchFilters = { + rid?: string; + rids?: string[]; + roomNames?: string[]; + fromUsername?: string; + fromUsernames?: string[]; + startDate?: Date; + endDate?: Date; +}; + +export type IntelligentSearchPipelineFilters = Record; + +export type IntelligentSearchCandidate = { + _id: string; + rid?: string; + msgId?: string; + pipelineText: string; + score?: number; +}; + +export type IntelligentSearchPipelineRequest = { + query: string; + config: IntelligentSearchPipelineConfig; + classifications: string[]; + pipelineFilters: IntelligentSearchPipelineFilters; + limit: number; + fetch: AIServiceFetch; + logger?: AIServiceLogger; +}; diff --git a/packages/ai-search/src/url.ts b/packages/ai-search/src/url.ts new file mode 100644 index 0000000000000..93845bb00bfa2 --- /dev/null +++ b/packages/ai-search/src/url.ts @@ -0,0 +1,9 @@ +export const trimTrailingSlashes = (url: string): string => { + let end = url.length; + + while (end > 0 && url.charCodeAt(end - 1) === 47) { + end--; + } + + return url.slice(0, end); +}; diff --git a/packages/ai-search/tsconfig.json b/packages/ai-search/tsconfig.json new file mode 100644 index 0000000000000..92e39993f6d40 --- /dev/null +++ b/packages/ai-search/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index adab6a0aaf942..1e0284de6301b 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -1,4 +1,12 @@ import { proxify } from './lib/proxify'; +import type { + IAISearchService, + AISearchAnswerMessage, + AISearchAnswerResult, + AISearchFilters, + AISearchModelOption, + AISearchStatus, +} from './types/IAISearchService'; import type { IAbacService } from './types/IAbacService'; import type { IAccount, ILoginResult } from './types/IAccount'; import type { IAnalyticsService } from './types/IAnalyticsService'; @@ -146,6 +154,12 @@ export type { IUploadFileParams, IUploadService, ICalendarService, + IAISearchService, + AISearchAnswerMessage, + AISearchAnswerResult, + AISearchFilters, + AISearchModelOption, + AISearchStatus, ICallHistoryService, IOmnichannelTranscriptService, IQueueWorkerService, @@ -204,3 +218,4 @@ export const EnterpriseSettings = proxify('ee-settings'); export const FederationMatrix = proxify('federation-matrix'); export const Abac = proxify('abac'); +export const AISearch = proxify('ai-search'); diff --git a/packages/core-services/src/types/IAISearchService.ts b/packages/core-services/src/types/IAISearchService.ts new file mode 100644 index 0000000000000..f5ae4d74b21a8 --- /dev/null +++ b/packages/core-services/src/types/IAISearchService.ts @@ -0,0 +1,51 @@ +import type { UnifiedSearchIntelligentResult } from '@rocket.chat/rest-typings'; + +import type { IServiceClass } from './ServiceClass'; + +export type AISearchFilters = { + rid?: string; + rids?: string[]; + roomNames?: string[]; + fromUsername?: string; + fromUsernames?: string[]; + startDate?: string | Date; + endDate?: string | Date; +}; + +export type AISearchStatus = { + hasIntelligentSearchLicense: boolean; + intelligentSearchEnabled: boolean; + intelligentSearchConfigured: boolean; + answerGenerationConfigured: boolean; +}; + +export type AISearchAnswerMessage = { + text: string; + username?: string; + roomName?: string; + ts?: string; + score?: number; +}; + +export type AISearchAnswerResult = { + answer: string; + provider: { + name: string; + model: string; + }; +}; + +export type AISearchModelOption = { + key: string; + label: string; +}; + +export interface IAISearchService extends IServiceClass { + status(): Promise; + + search(params: { query: string; userId: string; filters?: AISearchFilters; limit?: number }): Promise; + + answer(params: { query: string; messages: AISearchAnswerMessage[] }): Promise; + + models(): Promise; +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2ca1a3a512464..abcc5de00c7f7 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -137,7 +137,61 @@ "Alternative_text": "Alternative text", "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": "AI", "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": "Configure one OpenAI-compatible endpoint and select the model used by AI Center features.", + "AI_LLM_Provider": "LLM Providers", + "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 AI Search and other premium AI capabilities.", + "AI_Center_license_required_title": "AI add-on required", + "AI_LLM_OpenAI_API_Key": "API key", + "AI_LLM_OpenAI_API_Key_Description": "API key for the OpenAI-compatible chat completions endpoint.", + "AI_LLM_OpenAI_Base_URL": "API base URL", + "AI_LLM_OpenAI_Base_URL_Description": "Base URL for an OpenAI-compatible API, for example https://api.openai.com/v1.", + "AI_LLM_OpenAI_Model": "Model", + "AI_LLM_OpenAI_Model_Description": "Model used by AI Center features. The list is loaded from the provider's /models endpoint.", + "AI_Intelligent_Search_API_Key": "Pipeline API key", + "AI_Intelligent_Search_API_Key_Secret": "Pipeline API key secret", + "AI_Intelligent_Search_Enabled": "Enable AI 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_Query_Template": "Query template", + "AI_Intelligent_Search_Query_Template_Description": "Optional template applied to search queries before sending to the pipeline. Use {query} as the placeholder. Leave blank to send the raw query.", + "AI_Intelligent_Search_Answer_Enabled": "Generate AI answers", + "AI_Intelligent_Search_Answer_Enabled_Description": "Generate answers from AI Search source messages when an LLM provider is configured.", + "AI_Intelligent_Search_Answer_System_Prompt": "Answer generation system prompt", + "AI_Intelligent_Search_Answer_System_Prompt_Description": "Optional system instructions for answer generation. Leave blank to use the default concise search-answer prompt.", + "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.", + "Intelligent_Search_page_empty_state": "Use the top search bar to ask a question.", + "Intelligent_Search_page_placeholder": "Ask about messages in your workspace", + "AI_Search_related_messages": "{{count}} related message", + "AI_Search_related_messages_plural": "{{count}} related messages", + "Intelligent_Search_scope_all_rooms": "Searching rooms you can access where AI Search is enabled", + "Intelligent_Search_start_from_top_bar": "Open AI Search from the top search bar to search semantic workspace knowledge.", + "Open_AI_Search": "Open AI Search", + "Search_filter_dates": "Date filters", + "Search_filter_rooms": "Rooms", + "Search_filter_users": "People", + "Search_rooms_or_ask_AI": "Search rooms or ask AI", + "Search_with_AI": "Search with AI", + "AI_Search_disabled_tooltip": "AI Search is turned off. Enable it in AI Center before searching with AI.", + "AI_Search_feature_disabled_description": "Turn on AI Search in Feature Preview before using the dedicated AI Search page.", + "AI_Search_feature_disabled_title": "AI Search experience is off", + "AI_Search_license_required_tooltip": "AI Search requires the Rocket.Chat AI add-on. Contact sales to enable it.", + "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", @@ -1309,6 +1363,7 @@ "Contact_identification": "Contact identification", "Contact_not_found": "Contact not found", "Contact_sales": "Contact sales", + "Contact_sales_for_Intelligent_Search": "Contact sales for AI 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.", @@ -1317,6 +1372,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", @@ -2475,6 +2533,7 @@ "Forgot_password_section": "Forgot password", "Forgot_E2EE_Password": "Forgot E2EE password?", "Format": "Format", + "Generate": "Generate", "Forward": "Forward", "Forward_chat": "Forward chat", "Forward_in_history": "Forward in history", @@ -2720,6 +2779,17 @@ "Installed": "Installed", "Installed_at": "Installed at", "Installing": "Installing", + "Intelligent_Search": "AI Search", + "Intelligent_Search_disabled_description": "Enable AI Search and configure the pipeline before semantic results appear in workspace search.", + "Intelligent_Search_disabled_title": "AI 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": "AI Search needs configuration", + "Intelligent_Search_Result": "AI Search result", + "Intelligent_Search_locked": "AI 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": "AI 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 AI Search", "Instance": "Instance", "Instance_Record": "Instance Record", "Instances": "Instances", @@ -3370,6 +3440,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", @@ -4060,6 +4131,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", @@ -4455,6 +4527,7 @@ "Register_Server_Info": "Use the preconfigured gateways and proxies provided by Rocket.Chat Technologies Corp.", "Register_Server_Opt_In": "Product and Security Updates", "Register_Server_Registered": "Register to access", + "Regenerate": "Regenerate", "Register_Server_Registered_I_Agree": "I agree with the", "Register_Server_Registered_Livechat": "Livechat omnichannel proxy", "Register_Server_Registered_Marketplace": "Apps Marketplace", @@ -4818,10 +4891,40 @@ "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", + "Search_filters": "Filters", + "Search_filter_in_room": "In room", + "Search_filter_room_placeholder": "Filter by room...", + "Search_filter_from_user": "From user", + "Search_filter_username_placeholder": "Filter by username...", + "Search_filter_date_from": "Date from", + "Search_filter_date_to": "Date to", + "Search_load_more_intelligent_results": "Load more AI results", + "Search_AI_answer": "AI answer", + "Search_AI_answer_disabled": "Configure an LLM provider in AI Center and run a search with message results to generate an answer.", + "Search_AI_answer_error": "Unable to generate an answer. Check the selected LLM provider configuration.", + "Search_AI_answer_no_sources": "No semantic results were found, so there is nothing to generate an AI answer from.", + "Search_AI_answer_provider": "Generated with {{provider}} · {{model}}", + "Search_AI_answer_ready": "Generate a concise answer from the semantic search results below.", + "Search_AI_answer_start_from_top_bar": "Start an intelligent search from the top search bar to generate an AI answer.", + "Search_AI_answer_waiting_for_sources": "Searching for source messages to generate an AI answer.", + "Search_clear_filters": "Clear filters", + "Search_in": "in: {{room}}", + "Search_from": "from: {{user}}", + "Search_after": "after: {{date}}", + "Search_before": "before: {{date}}", "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.", @@ -5387,6 +5490,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", @@ -5851,6 +5955,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", @@ -5858,6 +5963,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", diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 5df161c0e1c33..a4922c63bd9ef 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,157 @@ const SpotlightSchema = { export const isSpotlightProps = ajvQuery.compile(SpotlightSchema); +type UnifiedSearch = PaginatedRequest<{ + query: string; + includeMessages?: boolean; + includeIntelligent?: boolean; + includeSpotlight?: boolean; + intelligentCount?: number; + rid?: string; + rids?: string; + roomNames?: string; + fromUsername?: string; + fromUsernames?: string; + startDate?: string; + endDate?: string; +}>; + +const UnifiedSearchSchema = { + type: 'object', + properties: { + query: { + type: 'string', + minLength: 1, + maxLength: 500, + }, + includeMessages: { + type: 'boolean', + nullable: true, + }, + includeIntelligent: { + type: 'boolean', + nullable: true, + }, + includeSpotlight: { + type: 'boolean', + nullable: true, + }, + intelligentCount: { + type: 'number', + nullable: true, + }, + rid: { + type: 'string', + maxLength: 256, + nullable: true, + }, + rids: { + type: 'string', + maxLength: 4096, + nullable: true, + }, + roomNames: { + type: 'string', + maxLength: 4096, + nullable: true, + }, + fromUsername: { + type: 'string', + maxLength: 256, + nullable: true, + }, + fromUsernames: { + type: 'string', + maxLength: 4096, + nullable: true, + }, + startDate: { + type: 'string', + nullable: true, + }, + endDate: { + type: 'string', + 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 SearchAnswer = { + query: string; + messages: { + _id: string; + text: string; + username?: string; + roomName?: string; + ts?: string; + score?: number; + }[]; +}; + +const SearchAnswerSchema = { + type: 'object', + properties: { + query: { + type: 'string', + minLength: 1, + maxLength: 500, + }, + messages: { + type: 'array', + minItems: 1, + maxItems: 20, + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + text: { type: 'string', maxLength: 4000 }, + username: { type: 'string', nullable: true }, + roomName: { type: 'string', nullable: true }, + ts: { type: 'string', nullable: true }, + score: { type: 'number', nullable: true }, + }, + required: ['_id', 'text'], + additionalProperties: false, + }, + }, + }, + required: ['query', 'messages'], + additionalProperties: false, +}; + +export const isSearchAnswerProps = ajv.compile(SearchAnswerSchema); + +export type UnifiedSearchMessageResult = Pick & { + room?: Pick; +}; + +export type UnifiedSearchIntelligentResult = { + _id: string; + rid?: string; + msgId?: string; + text: string; + score?: number; + ts?: Date | string; + u?: { username: string; name?: string }; + room?: Pick; +}; + type Directory = PaginatedRequest<{ text: string; type: string; @@ -190,6 +341,35 @@ 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; + answerGenerationConfigured: boolean; + }; + }; + }; + + '/v1/search.answer': { + POST: (params: SearchAnswer) => { + answer: string; + provider: { + name: string; + model: string; + }; + }; + }; + + '/v1/ai.llm.models': { + GET: () => { data: { key: string; label: string }[] }; + }; + '/v1/pw.getPolicy': { GET: () => { enabled: boolean; diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index 78a32e0effb8a..8a4ec4af5bce0 100644 --- a/packages/ui-client/src/hooks/useFeaturePreviewList.ts +++ b/packages/ui-client/src/hooks/useFeaturePreviewList.ts @@ -1,12 +1,12 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts'; -export type FeaturesAvailable = 'secondarySidebar' | 'sidebarDrafts'; +export type FeaturesAvailable = 'secondarySidebar' | 'sidebarDrafts' | 'aiSearch'; export type FeaturePreviewProps = { name: FeaturesAvailable; i18n: TranslationKey; description: TranslationKey; - group: 'Message' | 'Navigation'; + group: 'AI' | 'Message' | 'Navigation'; imageUrl?: string; value: boolean; enabled: boolean; @@ -37,6 +37,14 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [ value: false, enabled: true, }, + { + name: 'aiSearch', + i18n: 'Intelligent_Search', + description: 'Intelligent_Search_upsell_description', + group: 'AI', + value: true, + enabled: true, + }, ]; export const enabledDefaultFeatures = defaultFeaturesPreview.filter((feature) => feature.enabled); diff --git a/yarn.lock b/yarn.lock index 716073c1253ba..1216a8fdab311 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8970,6 +8970,19 @@ __metadata: languageName: unknown linkType: soft +"@rocket.chat/ai-search@workspace:^, @rocket.chat/ai-search@workspace:packages/ai-search": + version: 0.0.0-use.local + resolution: "@rocket.chat/ai-search@workspace:packages/ai-search" + dependencies: + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/tsconfig": "workspace:*" + "@types/jest": "npm:~30.0.0" + eslint: "npm:~9.39.4" + jest: "npm:~30.2.0" + typescript: "npm:~5.9.3" + languageName: unknown + linkType: soft + "@rocket.chat/api-client@workspace:^, @rocket.chat/api-client@workspace:packages/api-client": version: 0.0.0-use.local resolution: "@rocket.chat/api-client@workspace:packages/api-client" @@ -9943,6 +9956,7 @@ __metadata: "@rocket.chat/abac": "workspace:^" "@rocket.chat/account-utils": "workspace:^" "@rocket.chat/agenda": "workspace:^" + "@rocket.chat/ai-search": "workspace:^" "@rocket.chat/api-client": "workspace:^" "@rocket.chat/apps": "workspace:^" "@rocket.chat/apps-engine": "workspace:^" @@ -11057,7 +11071,7 @@ __metadata: "@react-aria/toolbar": "*" "@rocket.chat/fuselage": "*" "@rocket.chat/icons": "*" - "@rocket.chat/ui-client": 31.0.0-rc.0 + "@rocket.chat/ui-client": 31.0.0 react: "*" react-dom: "*" languageName: unknown @@ -11328,7 +11342,7 @@ __metadata: peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.3.0 - "@rocket.chat/ui-contexts": 31.0.0-rc.0 + "@rocket.chat/ui-contexts": 31.0.0 "@tanstack/react-query": "*" react: "*" react-hook-form: "*"