WIP - search with AI

This commit is contained in:
Francisco Gaona
2026-01-13 10:44:38 +01:00
parent 47fa72451d
commit 9dcedcdf69
7 changed files with 698 additions and 54 deletions

View File

@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator'; import { TenantId } from '../tenant/tenant.decorator';
import { AiAssistantService } from './ai-assistant.service'; import { AiAssistantService } from './ai-assistant.service';
import { AiChatRequestDto } from './dto/ai-chat.dto'; import { AiChatRequestDto } from './dto/ai-chat.dto';
import { AiSearchRequestDto } from './dto/ai-search.dto';
@Controller('ai') @Controller('ai')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -24,4 +25,17 @@ export class AiAssistantController {
payload.context, payload.context,
); );
} }
@Post('search')
async search(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: AiSearchRequestDto,
) {
return this.aiAssistantService.searchRecords(
tenantId,
user.userId,
payload,
);
}
} }

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { JsonOutputParser } from '@langchain/core/output_parsers'; import { JsonOutputParser } from '@langchain/core/output_parsers';
import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { ChatOpenAI } from '@langchain/openai'; import { ChatOpenAI } from '@langchain/openai';
@@ -11,6 +11,30 @@ import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
import { AiAssistantReply, AiAssistantState } from './ai-assistant.types'; import { AiAssistantReply, AiAssistantState } from './ai-assistant.types';
import { MeilisearchService } from '../search/meilisearch.service'; import { MeilisearchService } from '../search/meilisearch.service';
type AiSearchFilter = {
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
};
type AiSearchPlan = {
strategy: 'keyword' | 'query';
explanation: string;
keyword?: string | null;
filters?: AiSearchFilter[];
sort?: { field: string; direction: 'asc' | 'desc' } | null;
};
type AiSearchPayload = {
objectApiName: string;
query: string;
page?: number;
pageSize?: number;
};
@Injectable() @Injectable()
export class AiAssistantService { export class AiAssistantService {
private readonly logger = new Logger(AiAssistantService.name); private readonly logger = new Logger(AiAssistantService.name);
@@ -70,6 +94,108 @@ export class AiAssistantService {
}; };
} }
async searchRecords(
tenantId: string,
userId: string,
payload: AiSearchPayload,
) {
const queryText = payload?.query?.trim();
if (!payload?.objectApiName || !queryText) {
throw new BadRequestException('objectApiName and query are required');
}
// Normalize tenant ID so Meilisearch index names align with indexed records
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const objectDefinition = await this.objectService.getObjectDefinition(
resolvedTenantId,
payload.objectApiName,
);
if (!objectDefinition) {
throw new BadRequestException(`Object ${payload.objectApiName} not found`);
}
const page = Number.isFinite(Number(payload.page)) ? Number(payload.page) : 1;
const pageSize = Number.isFinite(Number(payload.pageSize)) ? Number(payload.pageSize) : 20;
const plan = await this.buildSearchPlan(
resolvedTenantId,
queryText,
objectDefinition,
);
console.log('AI search plan:', plan);
if (plan.strategy === 'keyword') {
console.log('AI search plan (keyword):', plan);
const keyword = plan.keyword?.trim() || queryText;
if (this.meilisearchService.isEnabled()) {
const offset = (page - 1) * pageSize;
const meiliResults = await this.meilisearchService.searchRecords(
resolvedTenantId,
payload.objectApiName,
keyword,
{ limit: pageSize, offset },
);
console.log('Meilisearch results:', meiliResults);
const ids = meiliResults.hits
.map((hit: any) => hit?.id)
.filter(Boolean);
const records = ids.length
? await this.objectService.searchRecordsByIds(
resolvedTenantId,
payload.objectApiName,
userId,
ids,
{ page, pageSize },
)
: { data: [], totalCount: 0, page, pageSize };
return {
...records,
totalCount: meiliResults.total ?? records.totalCount ?? 0,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
const fallback = await this.objectService.searchRecordsByKeyword(
resolvedTenantId,
payload.objectApiName,
userId,
keyword,
{ page, pageSize },
);
return {
...fallback,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
console.log('AI search plan (query):', plan);
const filtered = await this.objectService.searchRecordsWithFilters(
resolvedTenantId,
payload.objectApiName,
userId,
plan.filters || [],
{ page, pageSize },
plan.sort || undefined,
);
return {
...filtered,
strategy: plan.strategy,
explanation: plan.explanation,
};
}
private async runAssistantGraph( private async runAssistantGraph(
tenantId: string, tenantId: string,
userId: string, userId: string,
@@ -523,6 +649,112 @@ export class AiAssistantService {
return extracted; return extracted;
} }
private async buildSearchPlan(
tenantId: string,
message: string,
objectDefinition: any,
): Promise<AiSearchPlan> {
const openAiConfig = await this.getOpenAiConfig(tenantId);
if (!openAiConfig) {
return this.buildSearchPlanFallback(message);
}
try {
return await this.buildSearchPlanWithAi(openAiConfig, message, objectDefinition);
} catch (error) {
this.logger.warn(`AI search planning failed: ${error.message}`);
return this.buildSearchPlanFallback(message);
}
}
private buildSearchPlanFallback(message: string): AiSearchPlan {
const trimmed = message.trim();
return {
strategy: 'keyword',
keyword: trimmed,
explanation: `Searched records that matches the word: "${trimmed}"`,
};
}
private async buildSearchPlanWithAi(
openAiConfig: OpenAIConfig,
message: string,
objectDefinition: any,
): Promise<AiSearchPlan> {
const model = new ChatOpenAI({
apiKey: openAiConfig.apiKey,
model: this.normalizeChatModel(openAiConfig.model),
temperature: 0.2,
});
const parser = new JsonOutputParser<AiSearchPlan>();
const fields = (objectDefinition.fields || []).map((field: any) => ({
apiName: field.apiName,
label: field.label,
type: field.type,
}));
console.log('fields:',fields);
const formatInstructions = parser.getFormatInstructions();
const today = new Date().toISOString();
const response = await model.invoke([
new SystemMessage(
`You are a CRM search assistant. Decide whether the user input is a keyword search or a structured query.` +
`\nReturn a JSON object with keys: strategy, explanation, keyword, filters, sort.` +
`\n- strategy must be "keyword" or "query".` +
`\n- explanation must be a short sentence explaining the approach.` +
`\n- keyword should be the search term when strategy is "keyword", otherwise null.` +
`\n- filters is an array of {field, operator, value, values, from, to}.` +
`\n- operators must be one of eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, notIn, isNull, notNull, between.` +
`\n- Use between with from/to when the user gives date ranges like "yesterday" or "last week".` +
`\n- sort should be {field, direction} when sorting is requested.` +
`\n- Only use field apiName values exactly as provided.` +
`\n${formatInstructions}`,
),
new HumanMessage(
`Object: ${objectDefinition.label || objectDefinition.apiName}.\n` +
`Fields: ${JSON.stringify(fields)}.\n` +
`Today is ${today}.\n` +
`User query: ${message}`,
),
]);
const content = typeof response.content === 'string' ? response.content : '{}';
const parsed = await parser.parse(content);
return this.normalizeSearchPlan(parsed, message);
}
private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan {
if (!plan || typeof plan !== 'object') {
return this.buildSearchPlanFallback(message);
}
const strategy = plan.strategy === 'query' ? 'query' : 'keyword';
const explanation = plan.explanation?.trim()
? plan.explanation.trim()
: strategy === 'keyword'
? `Searched records that matches the word: "${message.trim()}"`
: `Applied filters based on: "${message.trim()}"`;
if (strategy === 'keyword') {
return {
strategy,
keyword: plan.keyword?.trim() || message.trim(),
explanation,
};
}
return {
strategy,
explanation,
keyword: null,
filters: Array.isArray(plan.filters) ? plan.filters : [],
sort: plan.sort || null,
};
}
private sanitizeUserOwnerFields( private sanitizeUserOwnerFields(
fields: Record<string, any>, fields: Record<string, any>,
fieldDefinitions: any[], fieldDefinitions: any[],
@@ -899,12 +1131,18 @@ export class AiAssistantService {
const providedValue = typeof value === 'string' ? value.trim() : value; const providedValue = typeof value === 'string' ? value.trim() : value;
if (providedValue && typeof providedValue === 'string') { if (providedValue && typeof providedValue === 'string') {
console.log('providedValue:', providedValue);
const meiliMatch = await this.meilisearchService.searchRecord( const meiliMatch = await this.meilisearchService.searchRecord(
resolvedTenantId, resolvedTenantId,
targetDefinition?.apiName || targetApiName, targetDefinition?.apiName || targetApiName,
providedValue, providedValue,
displayField, displayField,
); );
console.log('MeiliSearch lookup for', meiliMatch);
if (meiliMatch?.id) { if (meiliMatch?.id) {
resolvedFields[field.apiName] = meiliMatch.id; resolvedFields[field.apiName] = meiliMatch.id;
continue; continue;

View File

@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
export class AiSearchRequestDto {
@IsString()
@IsNotEmpty()
objectApiName: string;
@IsString()
@IsNotEmpty()
query: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
}

View File

@@ -10,6 +10,25 @@ import { User } from '../models/user.model';
import { ObjectMetadata } from './models/dynamic-model.factory'; import { ObjectMetadata } from './models/dynamic-model.factory';
import { MeilisearchService } from '../search/meilisearch.service'; import { MeilisearchService } from '../search/meilisearch.service';
type SearchFilter = {
field: string;
operator: string;
value?: any;
values?: any[];
from?: string;
to?: string;
};
type SearchSort = {
field: string;
direction: 'asc' | 'desc';
};
type SearchPagination = {
page?: number;
pageSize?: number;
};
@Injectable() @Injectable()
export class ObjectService { export class ObjectService {
private readonly logger = new Logger(ObjectService.name); private readonly logger = new Logger(ObjectService.name);
@@ -509,48 +528,35 @@ export class ObjectService {
} }
} }
// Runtime endpoints - CRUD operations private async buildAuthorizedQuery(
async getRecords(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,
userId: string, userId: string,
filters?: any,
) { ) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex) const user = await User.query(knex)
.findById(userId) .findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex) const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName }) .findOne({ apiName: objectApiName })
.withGraphFetched('fields'); .withGraphFetched('fields');
if (!objectDefModel) { if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`); throw new NotFoundException(`Object ${objectApiName} not found`);
} }
const tableName = this.getTableName(
objectDefModel.apiName,
objectDefModel.label,
objectDefModel.pluralLabel,
);
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query(); let query = boundModel.query();
// Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery( await this.authService.applyScopeToQuery(
query, query,
objectDefModel, objectDefModel,
@@ -558,23 +564,190 @@ export class ObjectService {
'read', 'read',
knex, knex,
); );
// Build graph expression for lookup fields const lookupFields = objectDefModel.fields?.filter((field) =>
const lookupFields = objectDefModel.fields?.filter(f => field.type === 'LOOKUP' && field.referenceObject,
f.type === 'LOOKUP' && f.referenceObject
) || []; ) || [];
if (lookupFields.length > 0) { if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase()) .map((field) => field.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean) .filter(Boolean)
.join(', '); .join(', ');
if (relationExpression) { if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`); query = query.withGraphFetched(`[${relationExpression}]`);
} }
} }
return {
query,
objectDefModel,
user,
knex,
};
}
private applySearchFilters(
query: any,
filters: SearchFilter[],
validFields: Set<string>,
) {
if (!Array.isArray(filters) || filters.length === 0) {
return query;
}
for (const filter of filters) {
const field = filter?.field;
if (!field || !validFields.has(field)) {
continue;
}
const operator = String(filter.operator || 'eq').toLowerCase();
const value = filter.value;
const values = Array.isArray(filter.values) ? filter.values : undefined;
switch (operator) {
case 'eq':
query.where(field, value);
break;
case 'neq':
query.whereNot(field, value);
break;
case 'gt':
query.where(field, '>', value);
break;
case 'gte':
query.where(field, '>=', value);
break;
case 'lt':
query.where(field, '<', value);
break;
case 'lte':
query.where(field, '<=', value);
break;
case 'contains':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}%`]);
}
break;
case 'startswith':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `${String(value).toLowerCase()}%`]);
}
break;
case 'endswith':
if (value !== undefined && value !== null) {
query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}`]);
}
break;
case 'in':
if (values?.length) {
query.whereIn(field, values);
} else if (Array.isArray(value)) {
query.whereIn(field, value);
}
break;
case 'notin':
if (values?.length) {
query.whereNotIn(field, values);
} else if (Array.isArray(value)) {
query.whereNotIn(field, value);
}
break;
case 'isnull':
query.whereNull(field);
break;
case 'notnull':
query.whereNotNull(field);
break;
case 'between': {
const from = filter.from ?? (Array.isArray(value) ? value[0] : undefined);
const to = filter.to ?? (Array.isArray(value) ? value[1] : undefined);
if (from !== undefined && to !== undefined) {
query.whereBetween(field, [from, to]);
} else if (from !== undefined) {
query.where(field, '>=', from);
} else if (to !== undefined) {
query.where(field, '<=', to);
}
break;
}
default:
break;
}
}
return query;
}
private applyKeywordFilter(
query: any,
keyword: string,
fields: string[],
) {
const trimmed = keyword?.trim();
if (!trimmed || fields.length === 0) {
return query;
}
query.where((builder: any) => {
const lowered = trimmed.toLowerCase();
for (const field of fields) {
builder.orWhereRaw('LOWER(??) like ?', [field, `%${lowered}%`]);
}
});
return query;
}
private async finalizeRecordQuery(
query: any,
objectDefModel: any,
user: any,
pagination?: SearchPagination,
) {
const parsedPage = Number.isFinite(Number(pagination?.page)) ? Number(pagination?.page) : 1;
const parsedPageSize = Number.isFinite(Number(pagination?.pageSize))
? Number(pagination?.pageSize)
: 0;
const safePage = parsedPage > 0 ? parsedPage : 1;
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
const shouldPaginate = safePageSize > 0;
const totalCount = await query.clone().resultSize();
if (shouldPaginate) {
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
}
const records = await query.select('*');
const filteredRecords = await Promise.all(
records.map((record: any) =>
this.authService.filterReadableFields(record, objectDefModel.fields, user),
),
);
return {
data: filteredRecords,
totalCount,
page: shouldPaginate ? safePage : 1,
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
};
}
// Runtime endpoints - CRUD operations
async getRecords(
tenantId: string,
objectApiName: string,
userId: string,
filters?: any,
) {
let { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
// Extract pagination and sorting parameters from query string // Extract pagination and sorting parameters from query string
const { const {
@@ -603,36 +776,95 @@ export class ObjectService {
} }
if (sortField) { if (sortField) {
query = query.orderBy(sortField, sortDirection === 'desc' ? 'desc' : 'asc'); const direction = sortDirection === 'desc' ? 'desc' : 'asc';
query = query.orderBy(sortField, direction);
} }
const parsedPage = Number.isFinite(Number(page)) ? Number(page) : 1; return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize });
const parsedPageSize = Number.isFinite(Number(pageSize)) ? Number(pageSize) : 0; }
const safePage = parsedPage > 0 ? parsedPage : 1;
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
const shouldPaginate = safePageSize > 0;
const totalCount = await query.clone().resultSize(); async searchRecordsByIds(
tenantId: string,
if (shouldPaginate) { objectApiName: string,
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize); userId: string,
recordIds: string[],
pagination?: SearchPagination,
) {
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
data: [],
totalCount: 0,
page: pagination?.page ?? 1,
pageSize: pagination?.pageSize ?? 0,
};
} }
const records = await query.select('*'); const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
// Filter fields based on field-level permissions objectApiName,
const filteredRecords = await Promise.all( userId,
records.map(record =>
this.authService.filterReadableFields(record, objectDefModel.fields, user)
)
); );
return { query.whereIn('id', recordIds);
data: filteredRecords, const orderBindings: any[] = ['id'];
totalCount, const cases = recordIds
page: shouldPaginate ? safePage : 1, .map((id, index) => {
pageSize: shouldPaginate ? safePageSize : filteredRecords.length, orderBindings.push(id, index);
}; return 'when ? then ?';
})
.join(' ');
if (cases) {
query.orderByRaw(`case ?? ${cases} end`, orderBindings);
}
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
}
async searchRecordsWithFilters(
tenantId: string,
objectApiName: string,
userId: string,
filters: SearchFilter[],
pagination?: SearchPagination,
sort?: SearchSort,
) {
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
const validFields = new Set(objectDefModel.fields?.map((field: any) => field.apiName));
this.applySearchFilters(query, filters, validFields);
if (sort?.field && validFields.has(sort.field)) {
query.orderBy(sort.field, sort.direction === 'desc' ? 'desc' : 'asc');
}
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
}
async searchRecordsByKeyword(
tenantId: string,
objectApiName: string,
userId: string,
keyword: string,
pagination?: SearchPagination,
) {
const { query, objectDefModel, user } = await this.buildAuthorizedQuery(
tenantId,
objectApiName,
userId,
);
const keywordFields = (objectDefModel.fields || [])
.filter((field: any) => this.isKeywordField(field.type))
.map((field: any) => field.apiName);
this.applyKeywordFilter(query, keyword, keywordFields);
return this.finalizeRecordQuery(query, objectDefModel, user, pagination);
} }
async getRecord( async getRecord(
@@ -1073,12 +1305,32 @@ export class ObjectService {
.map((field: any) => field.apiName) .map((field: any) => field.apiName)
.filter((apiName) => apiName && !this.isSystemField(apiName)); .filter((apiName) => apiName && !this.isSystemField(apiName));
console.log('Indexing record', {
tenantId,
objectApiName,
recordId: record.id,
fieldsToIndex,
});
await this.meilisearchService.upsertRecord( await this.meilisearchService.upsertRecord(
tenantId, tenantId,
objectApiName, objectApiName,
record, record,
fieldsToIndex, fieldsToIndex,
); );
console.log('Indexed record successfully');
const meiliResults = await this.meilisearchService.searchRecords(
tenantId,
objectApiName,
record.name,
{ limit: 10 },
);
console.log('Meilisearch results:', meiliResults);
} }
private async removeIndexedRecord( private async removeIndexedRecord(
@@ -1102,6 +1354,19 @@ export class ObjectService {
].includes(apiName); ].includes(apiName);
} }
private isKeywordField(type: string | undefined): boolean {
const normalized = String(type || '').toUpperCase();
return [
'STRING',
'TEXT',
'EMAIL',
'PHONE',
'URL',
'RICH_TEXT',
'TEXTAREA',
].includes(normalized);
}
private async normalizePolymorphicRelatedObject( private async normalizePolymorphicRelatedObject(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,

View File

@@ -28,6 +28,8 @@ export class MeilisearchService {
const indexName = this.buildIndexName(config, tenantId, objectApiName); const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`; const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
console.log('querying Meilisearch index:', { indexName, query, displayField });
try { try {
const response = await this.requestJson('POST', url, { const response = await this.requestJson('POST', url, {
q: query, q: query,
@@ -66,6 +68,48 @@ export class MeilisearchService {
return null; return null;
} }
async searchRecords(
tenantId: string,
objectApiName: string,
query: string,
options?: { limit?: number; offset?: number },
): Promise<{ hits: any[]; total: number }> {
const config = this.getConfig();
if (!config) return { hits: [], total: 0 };
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20;
const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0;
try {
const response = await this.requestJson('POST', url, {
q: query,
limit,
offset,
}, this.buildHeaders(config));
console.log('Meilisearch response body:', response.body);
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch query failed for index ${indexName}: ${response.status}`,
);
return { hits: [], total: 0 };
}
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
const total =
response.body?.estimatedTotalHits ??
response.body?.nbHits ??
hits.length;
return { hits, total };
} catch (error) {
this.logger.warn(`Meilisearch query failed: ${error.message}`);
return { hits: [], total: 0 };
}
}
async upsertRecord( async upsertRecord(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,

View File

@@ -23,6 +23,7 @@ interface Props {
selectable?: boolean selectable?: boolean
baseUrl?: string baseUrl?: string
totalCount?: number totalCount?: number
searchSummary?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -30,6 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
selectable: false, selectable: false,
baseUrl: '/runtime/objects', baseUrl: '/runtime/objects',
searchSummary: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -172,6 +174,9 @@ watch(
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
</div> </div>
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
{{ searchSummary }}
</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@@ -145,6 +145,11 @@ const editConfig = computed(() => {
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25) const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500) const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
const searchQuery = ref('')
const searchSummary = ref('')
const searchLoading = ref(false)
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
// Fetch object definition // Fetch object definition
const fetchObjectDefinition = async () => { const fetchObjectDefinition = async () => {
@@ -235,6 +240,36 @@ const loadListRecords = async (
return result return result
} }
const searchListRecords = async (
page = 1,
options?: { append?: boolean; pageSize?: number }
) => {
if (!isSearchActive.value) {
return initializeListRecords()
}
searchLoading.value = true
try {
const pageSize = options?.pageSize ?? listPageSize.value
const response = await api.post('/ai/search', {
objectApiName: objectApiName.value,
query: searchQuery.value.trim(),
page,
pageSize,
})
const data = response?.data ?? []
const total = response?.totalCount ?? data.length
records.value = options?.append ? [...records.value, ...data] : data
totalCount.value = total
searchSummary.value = response?.explanation || ''
return response
} catch (e: any) {
error.value = e.message || 'Failed to search records'
return null
} finally {
searchLoading.value = false
}
}
const initializeListRecords = async () => { const initializeListRecords = async () => {
const firstResult = await loadListRecords(1) const firstResult = await loadListRecords(1)
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
@@ -247,6 +282,10 @@ const initializeListRecords = async () => {
} }
const handlePageChange = async (page: number, pageSize: number) => { const handlePageChange = async (page: number, pageSize: number) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: page > 1, pageSize })
return
}
const loadedPages = Math.ceil(records.value.length / pageSize) const loadedPages = Math.ceil(records.value.length / pageSize)
if (page > loadedPages && totalCount.value > records.value.length) { if (page > loadedPages && totalCount.value > records.value.length) {
await loadListRecords(page, { append: true, pageSize }) await loadListRecords(page, { append: true, pageSize })
@@ -254,9 +293,24 @@ const handlePageChange = async (page: number, pageSize: number) => {
} }
const handleLoadMore = async (page: number, pageSize: number) => { const handleLoadMore = async (page: number, pageSize: number) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: true, pageSize })
return
}
await loadListRecords(page, { append: true, pageSize }) await loadListRecords(page, { append: true, pageSize })
} }
const handleSearch = async (query: string) => {
const trimmed = query.trim()
searchQuery.value = trimmed
if (!trimmed) {
searchSummary.value = ''
await initializeListRecords()
return
}
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
}
// Watch for route changes // Watch for route changes
watch(() => route.params, async (newParams, oldParams) => { watch(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new' // Reset current record when navigating to 'new'
@@ -325,14 +379,16 @@ onMounted(async () => {
v-else-if="view === 'list' && listConfig" v-else-if="view === 'list' && listConfig"
:config="listConfig" :config="listConfig"
:data="records" :data="records"
:loading="dataLoading" :loading="dataLoading || searchLoading"
:total-count="totalCount" :total-count="totalCount"
:search-summary="searchSummary"
:base-url="`/runtime/objects`" :base-url="`/runtime/objects`"
selectable selectable
@row-click="handleRowClick" @row-click="handleRowClick"
@create="handleCreate" @create="handleCreate"
@edit="handleEdit" @edit="handleEdit"
@delete="handleDelete" @delete="handleDelete"
@search="handleSearch"
@page-change="handlePageChange" @page-change="handlePageChange"
@load-more="handleLoadMore" @load-more="handleLoadMore"
/> />