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,13 @@
# @rocket.chat/ai-search
## 0.2.0
### Minor Changes
- ([#40890](https://github.com/RocketChat/Rocket.Chat/pull/40890)) Adds AI Search with semantic message results, optional OpenAI-compatible answers, and AI Center configuration.
## 0.2.0-rc.0
### Minor Changes
- ([#40890](https://github.com/RocketChat/Rocket.Chat/pull/40890)) Adds AI Search with semantic message results, optional OpenAI-compatible answers, and AI Center configuration.

View File

@@ -0,0 +1,17 @@
# @rocket.chat/ai-search
Shared AI Search 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:
- Client-side filter parsing and search-query serialization.
- 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.

View File

@@ -0,0 +1,6 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
} satisfies Config;

View File

@@ -0,0 +1,31 @@
{
"name": "@rocket.chat/ai-search",
"version": "0.2.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"typings": "./dist/index.d.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.5",
"jest": "~30.2.0",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,264 @@
import {
buildAppliedFilterChips,
buildFilterSuggestions,
buildRoomSearchQuery,
buildUserFilterSuggestions,
emptySearchFilters,
extractCompletedSearchFilters,
getAISearchButtonTooltip,
getFilterSearchState,
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,
});
});
it('keeps the trailing token editable while completing the preceding filters', () => {
expect(extractCompletedSearchFilters('in:general from:ren')).toEqual({
searchText: 'from:ren',
filters: {
roomNames: ['general'],
rids: [],
fromUsernames: [],
},
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', () => {
const t = (key: string, options?: Record<string, string>): string =>
({
Search_filter_in_rooms: `Messages in ${options?.rooms}`,
Search_filter_from_users: `Messages from ${options?.users}`,
Search_filter_after_date: `Messages after ${options?.date}`,
Search_filter_before_date: `Messages before ${options?.date}`,
})[key] || key;
expect(
buildAppliedFilterChips(
{
roomNames: ['general', 'dev'],
rids: [],
fromUsernames: ['alice', 'bob'],
startDate: '2026-01-01',
},
t,
),
).toEqual([
{ key: 'in', values: ['general', 'dev'], label: '#general, #dev', title: 'Messages in #general, #dev' },
{ key: 'from', values: ['alice', 'bob'], label: '@alice, @bob', title: 'Messages from @alice, @bob' },
{ key: 'after', values: ['2026-01-01'], label: 'after:2026-01-01', title: 'Messages 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('filter suggestions', () => {
const t = (key: string): string => key;
it('builds room suggestions from an active in filter', () => {
expect(
buildFilterSuggestions(
'deployment in:gen',
{ key: 'in', value: 'gen', start: 11, end: 17 },
[{ _id: 'room-id', name: 'general', fname: 'General' }],
t,
),
).toEqual([
{
key: 'in-room-id',
group: 'rooms',
title: '#General',
description: 'Search_in_this_room',
value: 'deployment in:general ',
icon: 'hash',
},
]);
});
it('builds user suggestions from an active from filter', () => {
expect(
buildUserFilterSuggestions(
'deployment from:ali',
{ key: 'from', value: 'ali', start: 11, end: 19 },
[{ _id: 'user-id', name: 'Example User', username: 'alice' }],
t,
),
).toEqual([
{
key: 'from-user-id',
group: 'users',
title: '@alice',
description: 'Example User',
value: 'deployment from:alice ',
icon: 'user',
},
]);
});
it('does not parse filters while AI Search is inactive', () => {
expect(getFilterSearchState('deployment in:general', emptySearchFilters(), false)).toEqual({
searchText: 'deployment in:general',
filters: emptySearchFilters(),
activeFilter: undefined,
});
});
it('parses and merges filters while AI Search is active', () => {
expect(
getFilterSearchState('deployment in:general from:bob', { roomNames: [], rids: ['room-id'], fromUsernames: ['alice'] }, true),
).toEqual({
searchText: 'deployment',
filters: {
roomNames: ['general'],
rids: ['room-id'],
fromUsernames: ['alice', 'bob'],
},
activeFilter: { key: 'from', value: 'bob', start: 22, end: 30 },
});
});
});
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, aiSearchActive: false, t }),
).toBe('AI_Search_license_required_tooltip');
});
it('explains disabled AI Search when the add-on is available', () => {
expect(
getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: false, aiSearchActive: false, t }),
).toBe('AI_Search_disabled_tooltip');
});
it('offers to enable AI Search when it is inactive', () => {
expect(
getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: true, aiSearchActive: false, t }),
).toBe('Enable_AI_Search');
});
it('offers to disable AI Search when it is active', () => {
expect(getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: true, aiSearchActive: true, t })).toBe(
'Disable_AI_Search',
);
});
});
});

View File

@@ -0,0 +1,445 @@
import { AI_SEARCH_FILTER_SUGGESTION_LIMIT, 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 SearchRoomSuggestionSource = {
_id: string;
rid?: string;
name?: string;
fname?: string;
};
export type SearchUserSuggestionSource = {
_id: string;
name?: string;
username: string;
};
export type SearchFilterChip = {
key: string;
label: string;
title: 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 isSearchFilterKey = (value: string): value is ActiveSearchFilter['key'] =>
value === 'in' || value === 'from' || value === 'after' || value === 'before';
export const emptySearchFilters = (): SearchFilters => ({ roomNames: [], rids: [], fromUsernames: [] });
export const mergeSearchFilters = (...filtersList: SearchFilters[]): SearchFilters => {
const roomNames = new Set<string>();
const rids = new Set<string>();
const fromUsernames = new Set<string>();
let startDate: string | undefined;
let endDate: string | undefined;
let rid: string | undefined;
let fromUsername: string | undefined;
for (const filters of filtersList) {
filters.roomNames.forEach((roomName) => roomNames.add(roomName));
filters.rids.forEach((roomId) => rids.add(roomId));
filters.fromUsernames.forEach((username) => fromUsernames.add(username));
startDate = filters.startDate || startDate;
endDate = filters.endDate || endDate;
rid = filters.rid || rid;
fromUsername = filters.fromUsername || fromUsername;
}
return {
roomNames: [...roomNames],
rids: [...rids],
fromUsernames: [...fromUsernames],
...(startDate && { startDate }),
...(endDate && { endDate }),
...(rid && { rid }),
...(fromUsername && { fromUsername }),
};
};
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 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;
// the trailing token stays editable until followed by whitespace, even after completed tokens
const isActiveToken = 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 key = match[1].toLowerCase();
if (!isSearchFilterKey(key)) {
return undefined;
}
const tokenStart = filterText.lastIndexOf(match[1], filterText.length - match[2].length - 1);
return {
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 formatDate = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getDateFilterSuggestions = (
filterText: string,
activeFilter: ActiveSearchFilter,
key: 'after' | 'before',
t: (key: string) => string,
): 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: t('Today'), value: formatDate(today) },
{ label: t('Yesterday'), value: formatDate(yesterday) },
{ label: t('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',
}));
};
export const buildFilterSuggestions = (
filterText: string,
activeFilter: ActiveSearchFilter | undefined,
rooms: SearchRoomSuggestionSource[],
t: (key: string) => string,
): SearchFilterSuggestion[] => {
if (!activeFilter) {
return [];
}
if (activeFilter.key === 'in') {
return rooms.slice(0, AI_SEARCH_FILTER_SUGGESTION_LIMIT).map((room) => ({
key: `in-${room.rid || room._id}`,
group: 'rooms',
title: `#${room.fname || room.name}`,
description: t('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: t('Search_messages_from_this_username'),
value: applySearchFilterToken(filterText, activeFilter, 'from', activeFilter.value.replace(/^@/, '')),
icon: 'user',
},
];
}
return getDateFilterSuggestions(filterText, activeFilter, activeFilter.key, t);
};
export const buildUserFilterSuggestions = (
filterText: string,
activeFilter: ActiveSearchFilter | undefined,
users: SearchUserSuggestionSource[],
t: (key: string) => string,
): SearchFilterSuggestion[] => {
if (activeFilter?.key !== 'from') {
return [];
}
return users.slice(0, AI_SEARCH_FILTER_SUGGESTION_LIMIT).map((user) => ({
key: `from-${user._id}`,
group: 'users',
title: `@${user.username}`,
description: user.name || t('Search_messages_from_this_user'),
value: applySearchFilterToken(filterText, activeFilter, 'from', user.username),
icon: 'user',
}));
};
export 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 getFilterSearchState = (
filterText: string,
appliedSearchFilters: SearchFilters,
canUseInlineFilters: boolean,
): {
searchText: string;
filters: SearchFilters;
activeFilter: ActiveSearchFilter | undefined;
} => {
if (!canUseInlineFilters) {
return { searchText: filterText, filters: emptySearchFilters(), activeFilter: undefined };
}
const parsed = parseSearchFilterText(filterText);
return {
searchText: parsed.searchText,
filters: mergeSearchFilters(appliedSearchFilters, parsed.filters),
activeFilter: getActiveSearchFilter(filterText),
};
};
export const getRoomLookupText = (activeFilter: ActiveSearchFilter | undefined, canUseInlineFilters: boolean): string => {
if (!canUseInlineFilters || activeFilter?.key !== 'in') {
return '';
}
return activeFilter.value.replace(/^#/, '');
};
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,
t: (key: string, options?: Record<string, string>) => string,
): SearchFilterChip[] => {
const chips: SearchFilterChip[] = [];
if (filters.roomNames.length) {
const label = filters.roomNames.map((roomName) => getFilterChipLabel('in', roomName)).join(', ');
chips.push({
key: 'in',
values: filters.roomNames,
label,
title: t('Search_filter_in_rooms', { rooms: label }),
});
}
if (filters.fromUsernames.length) {
const label = filters.fromUsernames.map((username) => getFilterChipLabel('from', username)).join(', ');
chips.push({
key: 'from',
values: filters.fromUsernames,
label,
title: t('Search_filter_from_users', { users: label }),
});
}
if (filters.startDate) {
const label = getFilterChipLabel('after', filters.startDate);
chips.push({
key: 'after',
values: [filters.startDate],
label,
title: t('Search_filter_after_date', { date: filters.startDate }),
});
}
if (filters.endDate) {
const label = getFilterChipLabel('before', filters.endDate);
chips.push({
key: 'before',
values: [filters.endDate],
label,
title: t('Search_filter_before_date', { date: filters.endDate }),
});
}
return chips;
};
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,
aiSearchActive,
t,
}: {
hasIntelligentSearchLicense: boolean;
intelligentSearchEnabled: boolean;
aiSearchActive: 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(aiSearchActive ? 'Disable_AI_Search' : 'Enable_AI_Search');
};

View File

@@ -0,0 +1,13 @@
export const AI_LICENSE_MODULE = 'chat.rocket.rc-ai';
export const AI_SEARCH_PAGE_SIZE = 5;
export const AI_SEARCH_RESULTS_PAGE_SIZE = 8;
export const AI_SEARCH_FILTER_SUGGESTION_LIMIT = 5;
export const AI_SEARCH_ROOM_LOOKUP_LIMIT = 20;
export const MAX_INTELLIGENT_SEARCH_RESULTS = 50;
export const MAX_SEARCH_FILTER_VALUES = 25;
export const MAX_ROOM_SEARCH_PATTERN_LENGTH = 64;
export const MAX_AI_SERVICE_RESPONSE_SIZE = 5 * 1024 * 1024;
export const MAX_SOURCE_MESSAGE_LENGTH = 700;
export const MAX_SEARCH_ANSWER_MESSAGES = 20;
export const MAX_SEARCH_ANSWER_TEXT_LENGTH = 1600;

View File

@@ -0,0 +1,5 @@
export * from './clientSearch';
export * from './constants';
export * from './intelligentSearch';
export * from './llm';
export type * from './types';

View File

@@ -0,0 +1,195 @@
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 },
{ metadata: { room_id: 'r4', msg_id: 'm4', score: null, similarity: '' }, text: 'no numeric score' },
{ 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 },
{ _id: 'm4', rid: 'r4', msgId: 'm4', pipelineText: 'no numeric score' },
]);
});
it('filters by subscribed 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 subscribed 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 subscribed', () => {
expect(buildIntelligentSearchPipelineFilters(['r1'], { rid: 'r2' })).toBe(undefined);
});
it('passes broad subscribed room filters to the pipeline', () => {
const roomIds = Array.from({ length: 1001 }, (_, index) => `r${index}`);
expect(buildIntelligentSearchPipelineFilters(roomIds, { fromUsername: 'alice' })).toEqual({
room_id: { $in: roomIds },
username: { $eq: 'alice' },
});
});
it('intersects explicit room filters while preserving their order', () => {
const roomIds = Array.from({ length: 10_000 }, (_, index) => `r${index}`);
expect(buildIntelligentSearchPipelineFilters(roomIds, { rids: ['r9999', 'missing', 'r1'] })).toEqual({
room_id: { $in: ['r9999', 'r1'] },
});
});
});
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([]);
});
});
});

View File

@@ -0,0 +1,286 @@
import { MAX_AI_SERVICE_RESPONSE_SIZE } from './constants';
import type {
AIServiceFetch,
AIServiceLogger,
IntelligentSearchCandidate,
IntelligentSearchFilters,
IntelligentSearchPipelineFilters,
IntelligentSearchPipelineRequest,
} from './types';
import { getErrorType } from './utils';
const buildEndpointUrl = (baseUrl: string, path: string): string =>
new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
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) {
if (typeof value !== 'number' && typeof value !== 'string') {
continue;
}
if (typeof value === 'string' && !value.trim()) {
continue;
}
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));
// pipeline contract: `score`/`distance` are cosine distances (lower is better, similarity = 1 - distance)
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: Record<string, unknown>, metadata: Record<string, unknown>): 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: Record<string, unknown>): { 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: IntelligentSearchCandidate[] = [];
for (let index = 0; index < rawResults.length && candidates.length < limit; index++) {
const result = asRecord(rawResults[index]);
const metadata = asRecord(result.metadata);
const { rid, msgId } = extractIntelligentResultIds(result);
if (!msgId && !rid) {
continue;
}
if (shouldFilterByRoomIds && rid && !userRoomIdSet.has(rid)) {
logger?.debug?.({ msg: 'Intelligent search result filtered: room not in user subscriptions', rid });
continue;
}
const score = extractPipelineSimilarityScore(result, metadata);
candidates.push({
_id: msgId || `intelligent-${index}`,
rid,
msgId,
pipelineText: firstString(result.text, result.content, result.document, result.page_content, metadata.text) || '',
...(typeof score === 'number' && { score }),
});
}
logger?.debug?.({ msg: 'Intelligent search after filter', candidateCount: candidates.length });
return candidates;
};
export const buildIntelligentSearchPipelineFilters = (
userRoomIds: string[],
{ rid, rids, fromUsername, fromUsernames, startDate, endDate }: Omit<IntelligentSearchFilters, 'roomNames'>,
): IntelligentSearchPipelineFilters | undefined => {
if (!userRoomIds.length) {
return undefined;
}
const requestedRoomIdSet = new Set(rids);
if (rid) {
requestedRoomIdSet.add(rid);
}
const requestedRoomIds = [...requestedRoomIdSet];
let subscribedRoomIds = userRoomIds;
if (requestedRoomIds.length) {
const matchedRoomIds = new Set<string>();
for (const roomId of userRoomIds) {
if (requestedRoomIdSet.has(roomId)) {
matchedRoomIds.add(roomId);
if (matchedRoomIds.size === requestedRoomIdSet.size) {
break;
}
}
}
subscribedRoomIds = requestedRoomIds.filter((roomId) => matchedRoomIds.has(roomId));
}
const filters: IntelligentSearchPipelineFilters = {};
if (requestedRoomIds.length && !subscribedRoomIds.length) {
return undefined;
}
filters.room_id = subscribedRoomIds.length === 1 ? { $eq: subscribedRoomIds[0] } : { $in: subscribedRoomIds };
const usernameSet = new Set<string>();
for (const username of fromUsernames || []) {
const normalizedUsername = username.replace(/^@/, '');
if (normalizedUsername) {
usernameSet.add(normalizedUsername);
}
}
if (fromUsername) {
const normalizedUsername = fromUsername.replace(/^@/, '');
if (normalizedUsername) {
usernameSet.add(normalizedUsername);
}
}
const usernames = [...usernameSet];
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<unknown> => {
const minimumSimilarity = normalizeSimilarityPercent(config.minimumSimilarityPercent);
const formattedQuery = config.queryTemplate ? config.queryTemplate.replace('{query}', query) : query;
const url = buildEndpointUrl(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<ReturnType<AIServiceFetch>>;
try {
response = await fetch(url, {
method: 'POST',
timeout: 10000,
size: MAX_AI_SERVICE_RESPONSE_SIZE,
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, errorType: getErrorType(fetchError) });
throw fetchError;
}
if (!response.ok) {
const body = await response.text().catch(() => '');
logger?.warn?.({ msg: 'Intelligent search pipeline returned error', url, status: response.status, bodyLength: body.length });
return [];
}
const json = await response.json();
logger?.debug?.({ msg: 'Intelligent search raw response received', resultKeys: Object.keys(asRecord(json)) });
return json;
};

View File

@@ -0,0 +1,232 @@
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 question (untrusted):\nWhat did we decide?',
'Source messages (untrusted):',
'[1] from @alice, in #general, at 2026-01-01T00:00:00.000Z, score 62%\nAAAAA',
'[2] from @bob\nsecon',
'Answer the question using only the source messages above and cite supporting sources as [N].',
].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('normalizes provider-specific source markers to numbered citations', async () => {
const fetch: AIServiceFetch = async () => ({
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content: 'Supported by both sources.【1†L1-L2】【2†L4-L5】' } }] }),
text: async () => '',
});
await expect(
generateOpenAICompatibleSearchAnswer({
query: 'What happened?',
messages: [{ text: 'First source' }, { text: 'Second source' }],
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
systemPrompt: 'Use numbered citations.',
fetch,
maxMessages: 4,
maxTextLength: 200,
}),
).resolves.toEqual({
answer: 'Supported by both sources. [1] [2]',
provider: { name: 'OpenAI compatible', model: 'gpt-test' },
});
});
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');
});
it('normalizes malformed provider JSON responses as request failures', async () => {
const malformedFetch: AIServiceFetch = async () => ({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError('Unexpected token');
},
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: malformedFetch,
maxMessages: 4,
maxTextLength: 200,
}),
).rejects.toThrow('error-ai-provider-request-failed');
});
it('rejects valid JSON with an invalid chat completion shape', async () => {
const invalidResponseFetch: AIServiceFetch = async () => ({
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content: 42 } }] }),
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: invalidResponseFetch,
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' }, { id: 'a-model' }, { id: 42 }, null] }),
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' }]);
});
});
});

View File

@@ -0,0 +1,176 @@
import { MAX_AI_SERVICE_RESPONSE_SIZE } from './constants';
import type { AIServiceFetch, AIServiceLogger, OpenAICompatibleProviderConfig, SearchAnswerMessage, SearchAnswerResult } from './types';
import { getErrorType } from './utils';
const buildEndpointUrl = (baseUrl: string, path: string): string =>
new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const getChatCompletionContent = (value: unknown): string | undefined => {
if (!isRecord(value) || !Array.isArray(value.choices)) {
return undefined;
}
const firstChoice = value.choices[0];
if (!isRecord(firstChoice) || !isRecord(firstChoice.message) || typeof firstChoice.message.content !== 'string') {
return undefined;
}
return firstChoice.message.content.trim() || undefined;
};
const getModelIds = (value: unknown): string[] => {
if (!isRecord(value) || !Array.isArray(value.data)) {
return [];
}
const modelIds = new Set<string>();
for (const model of value.data) {
if (isRecord(model) && typeof model.id === 'string' && model.id) {
modelIds.add(model.id);
}
}
return [...modelIds].sort((a, b) => a.localeCompare(b));
};
const normalizeSearchAnswerCitations = (answer: string): string =>
answer.replace(/【\s*(\d+)(?:†[^】\r\n]*)?\s*】/g, (_marker, sourceNumber: string, offset: number) => {
const leadingSpace = offset > 0 && !/\s/.test(answer[offset - 1]) ? ' ' : '';
return `${leadingSpace}[${sourceNumber}]`;
});
export const buildSearchAnswerPrompt = (
query: string,
messages: SearchAnswerMessage[],
options: {
maxMessages: number;
maxTextLength: number;
},
): string =>
[
`User question (untrusted):\n${query}`,
'Source messages (untrusted):',
...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}` : ''}\n${message.text.slice(0, options.maxTextLength)}`;
}),
'Answer the question using only the source messages above and cite supporting sources as [N].',
].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<SearchAnswerResult> => {
try {
const response = await fetch(buildEndpointUrl(provider.baseUrl, 'chat/completions'), {
method: 'POST',
timeout: 20000,
size: MAX_AI_SERVICE_RESPONSE_SIZE,
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, bodyLength: body.length });
throw new Error('error-ai-provider-request-failed');
}
const answer = getChatCompletionContent(await response.json());
if (!answer) {
throw new Error('error-ai-provider-empty-response');
}
return { answer: normalizeSearchAnswerCitations(answer), provider: { name: provider.name, model: provider.model } };
} catch (error) {
if (
error instanceof Error &&
(error.message === 'error-ai-provider-empty-response' || error.message === 'error-ai-provider-request-failed')
) {
throw error;
}
logger?.warn?.({ msg: 'Search answer LLM provider request failed', errorType: getErrorType(error) });
throw new Error('error-ai-provider-request-failed', { cause: error });
}
};
export const listOpenAICompatibleModels = async ({
provider,
selectedModel,
fetch,
logger,
}: {
provider?: Pick<OpenAICompatibleProviderConfig, 'baseUrl' | 'apiKey'>;
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(buildEndpointUrl(provider.baseUrl, 'models'), {
method: 'GET',
timeout: 10000,
size: MAX_AI_SERVICE_RESPONSE_SIZE,
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 modelIds = getModelIds(await response.json());
if (selectedModel && !modelIds.includes(selectedModel)) {
modelIds.unshift(selectedModel);
}
return modelIds.map((id) => ({ key: id, label: id }));
} catch (error) {
logger?.warn?.({ msg: 'AI LLM model lookup request failed', errorType: getErrorType(error) });
return fallback;
}
};

View File

@@ -0,0 +1,81 @@
export type AIServiceFetchResponse = {
ok: boolean;
status: number;
json(): Promise<unknown>;
text(): Promise<string>;
};
export type AIServiceFetch = (
url: string,
options: {
method: string;
timeout?: number;
size?: number;
headers?: Record<string, string>;
body?: string;
},
) => Promise<AIServiceFetchResponse>;
export type AIServiceLogger = {
debug?(payload: Record<string, unknown>): void;
warn?(payload: Record<string, unknown>): 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<OpenAICompatibleProviderConfig, 'name' | 'model'>;
};
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<string, unknown>;
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;
};

View File

@@ -0,0 +1 @@
export const getErrorType = (error: unknown): string => (error instanceof Error ? error.name : typeof error);

View File

@@ -0,0 +1,9 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true
},
"include": ["./src/**/*"]
}