Files
upstream-rocketchat/apps/meteor/server/api/v1/ai-search.ts
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

386 lines
11 KiB
TypeScript

import { AI_SEARCH_PAGE_SIZE, MAX_INTELLIGENT_SEARCH_RESULTS, MAX_SEARCH_FILTER_VALUES } from '@rocket.chat/ai-search';
import { AISearch } from '@rocket.chat/core-services';
import { isBannedSubscription, type IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms, Subscriptions } from '@rocket.chat/models';
import {
ajv,
isSearchAnswerProps,
isAISearchProps,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import type { AISearchResult, SearchAnswer } from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { getSettingPermissionId } from '../../../app/authorization/lib';
import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
import { API } from '../api';
const aiSearchResponseSchema = ajv.compile<{
intelligent: AISearchResult[];
meta: {
intelligentSearchEnabled: boolean;
intelligentSearchConfigured: boolean;
answerGenerationConfigured: boolean;
};
}>({
type: 'object',
properties: {
intelligent: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
rid: { type: 'string' },
msgId: { type: 'string' },
text: { type: 'string' },
score: { type: 'number' },
room: {
type: 'object',
properties: {
_id: { type: 'string' },
t: { type: 'string' },
name: { type: 'string' },
fname: { type: 'string' },
},
required: ['_id', 't'],
additionalProperties: true,
},
},
required: ['_id', 'text'],
additionalProperties: true,
},
},
meta: {
type: 'object',
properties: {
intelligentSearchEnabled: { type: 'boolean' },
intelligentSearchConfigured: { type: 'boolean' },
answerGenerationConfigured: { type: 'boolean' },
},
required: ['intelligentSearchEnabled', 'intelligentSearchConfigured', 'answerGenerationConfigured'],
additionalProperties: false,
},
success: { type: 'boolean', enum: [true] },
},
required: ['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[] => {
if (!value) {
return [];
}
const items: string[] = [];
for (const item of value.split(',')) {
const normalizedItem = item.trim();
if (normalizedItem) {
items.push(normalizedItem);
if (items.length === MAX_SEARCH_FILTER_VALUES) {
break;
}
}
}
return items;
};
const parseQueryDate = (value: string | undefined): Date | undefined => (value ? new Date(value) : undefined);
const getRoomMap = async (roomIds: string[]): Promise<Map<string, Pick<IRoom, '_id' | 't' | 'name' | 'fname'>>> => {
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]));
};
const getSubscribedRoomIds = async (userId: string, roomIds: string[]): Promise<Set<string>> => {
const uniqueRoomIdSet = new Set<string>();
for (const roomId of roomIds) {
if (roomId) {
uniqueRoomIdSet.add(roomId);
}
}
const uniqueRoomIds = [...uniqueRoomIdSet];
if (!uniqueRoomIds.length) {
return new Set();
}
const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, {
projection: { rid: 1, status: 1 },
}).toArray();
const subscribedRoomIds = new Set<string>();
for (const subscription of subscriptions) {
if (!isBannedSubscription(subscription)) {
subscribedRoomIds.add(subscription.rid);
}
}
return subscribedRoomIds;
};
const getSearchAnswerMessagesForUser = async (userId: string, messages: SearchAnswer['messages']) => {
const messageIds: string[] = [];
const messageIdSet = new Set<string>();
const scoreByMessageId = new Map<string, number | undefined>();
const clampScore = (score: number | undefined): number | undefined =>
typeof score === 'number' && Number.isFinite(score) ? Math.min(1, Math.max(0, score)) : undefined;
for (const { _id, score } of messages) {
if (!_id) {
continue;
}
if (!messageIdSet.has(_id)) {
messageIdSet.add(_id);
messageIds.push(_id);
}
scoreByMessageId.set(_id, clampScore(score));
}
if (!messageIds.length) {
throw new Meteor.Error('error-invalid-search-answer-sources');
}
const docs = await Messages.findVisibleByIds(messageIds, {
projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 },
}).toArray();
const subscribedRoomIds = await getSubscribedRoomIds(
userId,
docs.map((message) => message.rid),
);
if (docs.length !== messageIds.length || docs.some((message) => !subscribedRoomIds.has(message.rid))) {
throw new Meteor.Error('error-invalid-search-answer-sources');
}
const normalizedDocs = await normalizeMessagesForUser(docs, userId);
const docsById = new Map(normalizedDocs.map((message) => [message._id, message]));
const rooms = await getRoomMap(normalizedDocs.map((message) => message.rid));
const answerMessages = [];
for (const messageId of messageIds) {
const message = docsById.get(messageId);
if (message?.msg) {
const score = scoreByMessageId.get(message._id);
const room = rooms.get(message.rid);
answerMessages.push({
text: message.msg,
username: message.u?.username,
roomName: room?.fname || room?.name,
ts: message.ts?.toISOString(),
...(Number.isFinite(score) && { score }),
});
}
}
if (!answerMessages.length) {
throw new Meteor.Error('error-invalid-search-answer-sources');
}
return answerMessages;
};
API.v1.get(
'ai.search',
{
authRequired: true,
query: isAISearchProps,
rateLimiterOptions: {
numRequestsAllowed: 120,
intervalTimeInMS: 60000,
},
response: {
200: aiSearchResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const query = this.queryParams.query.trim();
const requestedIntelligentCount = this.queryParams.intelligentCount ?? AI_SEARCH_PAGE_SIZE;
const intelligentLimit = Math.min(Math.max(Math.floor(requestedIntelligentCount), 1), 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 aiSearchStatus = await AISearch.status().catch((error) => {
this.logger.warn({ msg: 'AI search status unavailable', err: error });
return {
hasIntelligentSearchLicense: false,
intelligentSearchEnabled: false,
intelligentSearchConfigured: false,
answerGenerationConfigured: false,
};
});
let intelligentResults: AISearchResult[] = [];
if (
aiSearchStatus.hasIntelligentSearchLicense &&
aiSearchStatus.intelligentSearchEnabled &&
aiSearchStatus.intelligentSearchConfigured
) {
try {
intelligentResults = await AISearch.search({
query,
userId: this.userId,
filters: {
rid,
rids,
roomNames,
fromUsername,
fromUsernames,
startDate: startDate?.toISOString(),
endDate: endDate?.toISOString(),
},
limit: intelligentLimit,
});
} catch (error) {
this.logger.warn({ msg: 'AI search request failed', err: error });
intelligentResults = [];
}
}
return API.v1.success({
intelligent: intelligentResults,
meta: {
intelligentSearchEnabled: aiSearchStatus.intelligentSearchEnabled,
intelligentSearchConfigured: aiSearchStatus.intelligentSearchConfigured,
answerGenerationConfigured: aiSearchStatus.answerGenerationConfigured,
},
});
},
);
API.v1.get(
'ai.llm.models',
{
authRequired: true,
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
response: {
200: aiModelsResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const canAccessAllSettings = await hasAtLeastOnePermissionAsync(this.userId, ['view-privileged-setting', 'edit-privileged-setting']);
const canAccessModelSetting =
canAccessAllSettings ||
(await hasAllPermissionAsync(this.userId, ['manage-selected-settings', getSettingPermissionId('AI_LLM_OpenAI_Model')]));
if (!canAccessModelSetting) {
return API.v1.forbidden();
}
return API.v1.success({
data: await AISearch.models(),
});
},
);
API.v1.post(
'ai.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;
// gate before any DB work so users cannot drive message lookups for an unlicensed feature
const aiSearchStatus = await AISearch.status();
if (
!aiSearchStatus.hasIntelligentSearchLicense ||
!aiSearchStatus.intelligentSearchEnabled ||
!aiSearchStatus.answerGenerationConfigured
) {
throw new Meteor.Error('error-ai-not-enabled');
}
const answerMessages = await getSearchAnswerMessagesForUser(this.userId, messages);
try {
const answer = await AISearch.answer({
query,
messages: answerMessages,
});
return API.v1.success(answer);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message === 'error-ai-not-enabled') {
throw new Meteor.Error('error-ai-not-enabled');
}
if (message === 'error-ai-provider-not-configured') {
throw new Meteor.Error('error-ai-provider-not-configured');
}
if (message === 'error-ai-provider-empty-response') {
throw new Meteor.Error('error-ai-provider-empty-response');
}
throw new Meteor.Error('error-ai-provider-request-failed');
}
},
);