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
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:
385
apps/meteor/server/api/v1/ai-search.ts
Normal file
385
apps/meteor/server/api/v1/ai-search.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
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');
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user