snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchAnswerPanel, { type SearchAnswerPanelProps } from './SearchAnswerPanel';
import '@testing-library/jest-dom';
const defaultProps: SearchAnswerPanelProps = {
isLoading: false,
disabled: false,
emptyReason: 'No sources are available.',
onGenerate: jest.fn(),
};
const renderPanel = (props: Partial<SearchAnswerPanelProps> = {}) =>
render(<SearchAnswerPanel {...defaultProps} {...props} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Search_AI_answer: 'AI answer',
Search_AI_answer_ready: 'Generate an answer from these sources.',
Search_AI_answer_provider: 'Generated by {{provider}} using {{model}}',
Search_AI_answer_error: 'The answer could not be generated.',
Generate: 'Generate',
Regenerate: 'Regenerate',
Loading: 'Loading',
})
.build(),
});
describe('SearchAnswerPanel', () => {
it('offers to generate an answer when sources are available', async () => {
const user = userEvent.setup();
const onGenerate = jest.fn();
renderPanel({ onGenerate });
expect(screen.getByText('Generate an answer from these sources.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Generate' }));
expect(onGenerate).toHaveBeenCalledTimes(1);
});
it('renders the disabled reason when an answer cannot be generated', () => {
renderPanel({ disabled: true, emptyReason: 'No related messages were found.' });
expect(screen.getByText('No related messages were found.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled();
});
it('renders a loading state and disables generation', () => {
renderPanel({ isLoading: true });
expect(screen.getByLabelText('Loading')).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('button', { name: 'Loading' })).toBeDisabled();
});
it('renders markdown and provider metadata for a generated answer', () => {
renderPanel({
answer: '**Deployment** completed successfully.',
provider: { name: 'OpenAI compatible', model: 'example-model' },
});
expect(screen.getByText('Deployment').tagName).toBe('STRONG');
expect(screen.getByText('Generated by OpenAI compatible using example-model')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Regenerate' })).toBeEnabled();
});
it('renders a generation error instead of the answer content', () => {
renderPanel({ error: new Error('request failed') });
expect(screen.getByText('The answer could not be generated.')).toBeInTheDocument();
expect(screen.queryByText('Generate an answer from these sources.')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,82 @@
import { Box, Button, Card, CardBody, CardControls, CardHeader, CardTitle, FramedIcon, Skeleton } from '@rocket.chat/fuselage';
import type { ReactElement } from 'react';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import MarkdownText from '../../components/MarkdownText';
export type SearchAnswerPanelProps = {
answer?: string;
provider?: { name: string; model: string };
isLoading: boolean;
error?: unknown;
disabled: boolean;
emptyReason: string;
onGenerate: () => void;
};
const SearchAnswerPanel = ({
answer,
provider,
isLoading,
error,
disabled,
emptyReason,
onGenerate,
}: SearchAnswerPanelProps): ReactElement => {
const { t } = useTranslation();
const titleId = useId();
let content: ReactElement;
if (isLoading) {
content = (
<Box display='flex' flexDirection='column' gap={12} aria-busy='true' aria-label={t('Loading')}>
<Skeleton width='60%' />
<Skeleton width='100%' />
<Skeleton width='95%' />
<Skeleton width='88%' />
<Skeleton width='72%' />
</Box>
);
} else if (answer) {
content = <MarkdownText content={answer} parseEmoji fontScale='p2' />;
} else {
content = (
<Box color='hint' fontScale='p2'>
{disabled ? emptyReason : t('Search_AI_answer_ready')}
</Box>
);
}
return (
<Box marginBlockEnd={24}>
<Card role='region' aria-labelledby={titleId}>
<CardHeader>
<FramedIcon icon='stars' />
<CardTitle id={titleId}>{t('Search_AI_answer')}</CardTitle>
</CardHeader>
<CardBody flexDirection='column'>
{provider && (
<Box color='hint' fontScale='c1' marginBlockEnd={8}>
{t('Search_AI_answer_provider', { provider: provider.name, model: provider.model })}
</Box>
)}
{error && !isLoading ? (
<Box color='danger' fontScale='p2'>
{t('Search_AI_answer_error')}
</Box>
) : (
content
)}
</CardBody>
<CardControls>
<Button medium disabled={disabled || isLoading} onClick={onGenerate}>
{isLoading ? t('Loading') : t(answer ? 'Regenerate' : 'Generate')}
</Button>
</CardControls>
</Card>
</Box>
);
};
export default SearchAnswerPanel;

View File

@@ -0,0 +1,165 @@
import { AI_LICENSE_MODULE, MAX_SEARCH_ANSWER_MESSAGES } from '@rocket.chat/ai-search';
import { Box, Button, Callout, Icon } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow, useFeaturePreview } from '@rocket.chat/ui-client';
import { useSearchParameter, useSetting } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SearchAnswerPanel from './SearchAnswerPanel';
import SearchSourceResult from './SearchSourceResult';
import { useAISearchAnswer } from './hooks/useAISearchAnswer';
import { useAISearchResults } from './hooks/useAISearchResults';
import { useHasLicenseModule } from '../../hooks/useHasLicenseModule';
const SearchPage = (): ReactElement => {
const { t } = useTranslation();
const queryParam = useSearchParameter('q') ?? '';
const aiSearchFeatureEnabled = useFeaturePreview('aiSearch');
const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false);
const { data: hasIntelligentSearchLicense } = useHasLicenseModule(AI_LICENSE_MODULE);
const canUseAISearch = Boolean(hasIntelligentSearchLicense && aiSearchFeatureEnabled);
const {
query: debouncedQuery,
intelligent,
meta,
hasMoreResults,
loadMore,
isLoading,
isPlaceholderData,
error: searchError,
} = useAISearchResults(queryParam, canUseAISearch && intelligentSearchEnabled);
const answerMessages = useMemo(
() =>
intelligent.slice(0, MAX_SEARCH_ANSWER_MESSAGES).map((item) => ({
_id: item.msgId || item._id,
score: item.score,
})),
[intelligent],
);
const canGenerateAnswer = Boolean(meta?.answerGenerationConfigured && !isPlaceholderData && debouncedQuery && intelligent.length > 0);
const answerResult = useAISearchAnswer(debouncedQuery, answerMessages, canGenerateAnswer);
const answerEmptyReason = useMemo(() => {
if (!debouncedQuery) {
return t('Search_AI_answer_start_from_top_bar');
}
if (isLoading) {
return t('Search_AI_answer_waiting_for_sources');
}
if (searchError) {
return t('Search_AI_answer_sources_error');
}
if (!intelligent.length) {
return t('Search_AI_answer_no_sources');
}
if (!meta?.answerGenerationConfigured) {
return t('Search_AI_answer_disabled');
}
return t('Search_AI_answer_ready');
}, [debouncedQuery, intelligent.length, isLoading, meta?.answerGenerationConfigured, searchError, t]);
return (
<Page background='tint'>
<PageHeader title={t('Intelligent_Search')} />
<PageScrollableContentWithShadow padding={24}>
<Box marginInline='auto' width='full' maxWidth='x800'>
<Box display='flex' flexDirection='column' marginBlockEnd={16} gap={8}>
{debouncedQuery ? (
<Box display='flex' alignItems='center' fontScale='h4' gap={8} minWidth={0}>
<Icon name='magnifier' size='x18' />
<Box display='flex' alignItems='baseline' gap={8} minWidth={0}>
<Box flexShrink={0}>{t('Results')}</Box>
<Box color='hint' withTruncatedText>
{debouncedQuery}
</Box>
</Box>
</Box>
) : (
<Box color='hint' fontScale='p2'>
{t('Intelligent_Search_page_empty_state')}
</Box>
)}
<Box display='flex' alignItems='center' color='hint' fontScale='p2' gap={8}>
<Icon name='stars' size='x14' />
{t('Intelligent_Search_scope_all_rooms')}
</Box>
</Box>
{hasIntelligentSearchLicense === false && (
<Callout type='info' icon='stars' title={t('Intelligent_Search_upsell_title')} marginBlockEnd={16}>
{t('Intelligent_Search_upsell_description')}
</Callout>
)}
{hasIntelligentSearchLicense && !aiSearchFeatureEnabled && (
<Callout type='warning' icon='warning' title={t('AI_Search_feature_disabled_title')} marginBlockEnd={16}>
{t('AI_Search_feature_disabled_description')}
</Callout>
)}
{canUseAISearch && !intelligentSearchEnabled && (
<Callout type='warning' icon='warning' title={t('Intelligent_Search_disabled_title')} marginBlockEnd={16}>
{t('Intelligent_Search_disabled_description')}
</Callout>
)}
{canUseAISearch && intelligentSearchEnabled && meta && !meta.intelligentSearchConfigured && (
<Callout type='warning' icon='warning' title={t('Intelligent_Search_missing_configuration_title')} marginBlockEnd={16}>
{t('Intelligent_Search_missing_configuration_description')}
</Callout>
)}
{searchError && (
<Callout type='danger' icon='warning' marginBlockEnd={16}>
{t('AI_Search_request_failed')}
</Callout>
)}
{debouncedQuery && (
<SearchAnswerPanel
answer={answerResult.data?.answer}
provider={answerResult.data?.provider}
isLoading={answerResult.isFetching}
error={answerResult.error}
disabled={!canGenerateAnswer}
emptyReason={answerEmptyReason}
onGenerate={() => void answerResult.refetch()}
/>
)}
<Box display='flex' alignItems='center' justifyContent='space-between' marginBlockEnd={12}>
<Box is='h2' fontScale='h4'>
{t('Sources')} · {intelligent.length} {t('Messages')}
</Box>
{hasMoreResults && (
<Button small onClick={loadMore}>
{t('Show_more')}
</Button>
)}
</Box>
{!debouncedQuery && (
<Box display='flex' justifyContent='center' color='hint' padding={24}>
{t('Intelligent_Search_start_from_top_bar')}
</Box>
)}
{isLoading && (
<Box display='flex' justifyContent='center' color='hint' padding={24}>
{t('Loading')}
</Box>
)}
{debouncedQuery && !isLoading && !searchError && intelligent.length === 0 && (
<Box display='flex' justifyContent='center' color='hint' padding={24}>
{t('No_results_found')}
</Box>
)}
<Box role='list'>
{intelligent.map((item) => (
<SearchSourceResult key={item._id} item={item} />
))}
</Box>
</Box>
</PageScrollableContentWithShadow>
</Page>
);
};
export default SearchPage;

View File

@@ -0,0 +1,72 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import SearchSourceResult from './SearchSourceResult';
import '@testing-library/jest-dom';
jest.mock('../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRouteLink: jest.fn(() => '/channel/general'),
},
}));
describe('SearchSourceResult', () => {
it('renders source metadata, markdown, and a message link', () => {
render(
<SearchSourceResult
item={{
_id: 'message-id',
rid: 'room-id',
msgId: 'message-id',
text: '**Oranges** are green',
score: 0.61,
ts: '2026-01-05T12:00:00.000Z',
u: { name: 'Search User', username: 'search.user' },
room: { _id: 'room-id', t: 'c', name: 'general', fname: 'General' },
}}
/>,
{ 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.getByRole('link')).toHaveAttribute('href', '/channel/general?msg=message-id');
});
it('clamps an out-of-range relevance score to 100%', () => {
render(
<SearchSourceResult
item={{
_id: 'message-id',
text: 'plain text',
score: 1.5,
u: { name: 'Search User', username: 'search.user' },
room: { _id: 'room-id', t: 'c', name: 'general', fname: 'General' },
}}
/>,
{ wrapper: mockAppRoot().build() },
);
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('does not render a link when the source has no resolvable room', () => {
render(
<SearchSourceResult
item={{
_id: 'message-id',
text: 'orphan message',
u: { name: 'Search User', username: 'search.user' },
}}
/>,
{ wrapper: mockAppRoot().build() },
);
expect(screen.getByText('orphan message')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,145 @@
import { MAX_SOURCE_MESSAGE_LENGTH } from '@rocket.chat/ai-search';
import type { IRoom } from '@rocket.chat/core-typings';
import {
Box,
Icon,
Message,
MessageBody,
MessageContainer,
MessageContainerFixed,
MessageHeader,
MessageLeftContainer,
MessageName,
MessageRole,
MessageTimestamp,
MessageUsername,
} from '@rocket.chat/fuselage';
import type { AISearchResult } from '@rocket.chat/rest-typings';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import type { ComponentProps, ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import MarkdownText from '../../components/MarkdownText';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
const formatMessageTime = (ts: 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: AISearchResult): string | undefined => {
const { room } = item;
if (!room) {
return undefined;
}
const href = roomCoordinator.getRouteLink(room.t, {
rid: room._id || item.rid,
name: room.name,
});
return href ? `${href}?msg=${encodeURIComponent(item.msgId || item._id)}` : undefined;
};
const trimSourceMessage = (text: string): string =>
text.length > MAX_SOURCE_MESSAGE_LENGTH ? `${text.slice(0, MAX_SOURCE_MESSAGE_LENGTH).trimEnd()}...` : text;
const getRoomIcon = (roomType: IRoom['t'] | undefined): ComponentProps<typeof Icon>['name'] => {
if (roomType === 'd') {
return 'at';
}
if (roomType === 'p') {
return 'lock';
}
return 'hash';
};
export type SearchSourceResultProps = {
item: AISearchResult;
};
const SearchSourceResult = ({ item }: SearchSourceResultProps): 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 messageTime = formatMessageTime(item.ts);
const relevanceScore = typeof item.score === 'number' ? Math.max(0, Math.min(100, Math.round(item.score * 100))) : undefined;
return (
<Box
is='article'
role='listitem'
position='relative'
borderBlockEndWidth={1}
borderBlockEndStyle='solid'
borderBlockEndColor='extra-light'
paddingBlockStart={16}
paddingBlockEnd={16}
>
<Message clickable={Boolean(href)}>
<MessageLeftContainer>
<MessageAvatar username={username} size='x36' />
</MessageLeftContainer>
<MessageContainer>
<MessageHeader>
<MessageName title={displayName}>{displayName}</MessageName>
{item.u?.username && (
<>
{' '}
<MessageUsername>@{item.u.username}</MessageUsername>
</>
)}
{roomLabel && (
<MessageRole>
<Box display='flex' alignItems='center' gap={4}>
<Icon name={getRoomIcon(item.room?.t)} size='x12' />
{roomLabel}
</Box>
</MessageRole>
)}
{messageTime && <MessageTimestamp title={item.ts}>{messageTime}</MessageTimestamp>}
</MessageHeader>
<MessageBody>
<MarkdownText
content={trimSourceMessage(item.text || t('Intelligent_Search_Result'))}
variant='inline'
parseEmoji
wordBreak='break-word'
/>
</MessageBody>
</MessageContainer>
{typeof relevanceScore === 'number' && (
<MessageContainerFixed>
<MessageRole title={`${relevanceScore}%`}>{relevanceScore}%</MessageRole>
</MessageContainerFixed>
)}
</Message>
{href && (
<Box
is='a'
href={href}
aria-label={t('Jump_to_message')}
position='absolute'
insetBlockStart={0}
insetBlockEnd={0}
insetInlineStart={0}
insetInlineEnd={0}
/>
)}
</Box>
);
};
export default SearchSourceResult;

View File

@@ -0,0 +1,15 @@
import type { SearchAnswer } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useAISearchAnswer = (query: string, messages: SearchAnswer['messages'], enabled: boolean) => {
const generateAnswer = useEndpoint('POST', '/v1/ai.search.answer');
return useQuery({
queryKey: ['search/intelligent/answer', query, messages],
queryFn: ({ signal }) => generateAnswer({ query, messages }, { signal }),
enabled,
staleTime: Infinity,
retry: false,
});
};

View File

@@ -0,0 +1,53 @@
import { AI_SEARCH_RESULTS_PAGE_SIZE, MAX_INTELLIGENT_SEARCH_RESULTS, parseSearchFilterText } from '@rocket.chat/ai-search';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { AISearchResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
export const useAISearchResults = (queryParam: string, enabled: boolean) => {
const aiSearch = useEndpoint('GET', '/v1/ai.search');
const [resultCount, setResultCount] = useState(AI_SEARCH_RESULTS_PAGE_SIZE);
const parsedSearch = useMemo(() => parseSearchFilterText(queryParam), [queryParam]);
const query = useDebouncedValue(parsedSearch.searchText.trim(), 300);
const { filters } = parsedSearch;
useEffect(() => {
setResultCount(AI_SEARCH_RESULTS_PAGE_SIZE);
}, [queryParam]);
const result = useQuery({
queryKey: ['search/intelligent/page', query, filters, resultCount],
queryFn: () =>
aiSearch({
query,
intelligentCount: Math.min(resultCount + 1, MAX_INTELLIGENT_SEARCH_RESULTS),
roomNames: filters.roomNames.join(','),
fromUsernames: filters.fromUsernames.join(','),
startDate: filters.startDate,
endDate: filters.endDate,
}),
enabled: Boolean(query && enabled),
placeholderData: (previousData, previousQuery) => (previousQuery?.queryKey[1] === query ? previousData : undefined),
});
const intelligent = useMemo<AISearchResult[]>(
() => result.data?.intelligent.slice(0, resultCount) ?? [],
[result.data?.intelligent, resultCount],
);
const hasMoreResults = Boolean(result.data && result.data.intelligent.length > resultCount);
const loadMore = useCallback(() => {
setResultCount((current) => Math.min(current + AI_SEARCH_RESULTS_PAGE_SIZE, MAX_INTELLIGENT_SEARCH_RESULTS));
}, []);
return {
query,
intelligent,
meta: result.data?.meta,
hasMoreResults,
loadMore,
isLoading: result.isLoading,
isPlaceholderData: result.isPlaceholderData,
error: result.error,
};
};