From 9dcedcdf6919e23ec5d76a1eb6f68b51751b515f Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Tue, 13 Jan 2026 10:44:38 +0100 Subject: [PATCH] WIP - search with AI --- .../ai-assistant/ai-assistant.controller.ts | 14 + .../src/ai-assistant/ai-assistant.service.ts | 240 +++++++++++- backend/src/ai-assistant/dto/ai-search.dto.ts | 22 ++ backend/src/object/object.service.ts | 369 +++++++++++++++--- backend/src/search/meilisearch.service.ts | 44 +++ frontend/components/views/ListView.vue | 5 + .../[objectName]/[[recordId]]/[[view]].vue | 58 ++- 7 files changed, 698 insertions(+), 54 deletions(-) create mode 100644 backend/src/ai-assistant/dto/ai-search.dto.ts diff --git a/backend/src/ai-assistant/ai-assistant.controller.ts b/backend/src/ai-assistant/ai-assistant.controller.ts index 3f88223..8fdb6ef 100644 --- a/backend/src/ai-assistant/ai-assistant.controller.ts +++ b/backend/src/ai-assistant/ai-assistant.controller.ts @@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { TenantId } from '../tenant/tenant.decorator'; import { AiAssistantService } from './ai-assistant.service'; import { AiChatRequestDto } from './dto/ai-chat.dto'; +import { AiSearchRequestDto } from './dto/ai-search.dto'; @Controller('ai') @UseGuards(JwtAuthGuard) @@ -24,4 +25,17 @@ export class AiAssistantController { payload.context, ); } + + @Post('search') + async search( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Body() payload: AiSearchRequestDto, + ) { + return this.aiAssistantService.searchRecords( + tenantId, + user.userId, + payload, + ); + } } diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index 2ae296b..865eb03 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -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 { HumanMessage, SystemMessage } from '@langchain/core/messages'; 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 { 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() export class AiAssistantService { 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( tenantId: string, userId: string, @@ -523,6 +649,112 @@ export class AiAssistantService { return extracted; } + private async buildSearchPlan( + tenantId: string, + message: string, + objectDefinition: any, + ): Promise { + 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 { + const model = new ChatOpenAI({ + apiKey: openAiConfig.apiKey, + model: this.normalizeChatModel(openAiConfig.model), + temperature: 0.2, + }); + + const parser = new JsonOutputParser(); + 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( fields: Record, fieldDefinitions: any[], @@ -899,12 +1131,18 @@ export class AiAssistantService { const providedValue = typeof value === 'string' ? value.trim() : value; if (providedValue && typeof providedValue === 'string') { + + console.log('providedValue:', providedValue); + const meiliMatch = await this.meilisearchService.searchRecord( resolvedTenantId, targetDefinition?.apiName || targetApiName, providedValue, displayField, ); + + console.log('MeiliSearch lookup for', meiliMatch); + if (meiliMatch?.id) { resolvedFields[field.apiName] = meiliMatch.id; continue; diff --git a/backend/src/ai-assistant/dto/ai-search.dto.ts b/backend/src/ai-assistant/dto/ai-search.dto.ts new file mode 100644 index 0000000..8a9f29f --- /dev/null +++ b/backend/src/ai-assistant/dto/ai-search.dto.ts @@ -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; +} diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index bd08e60..64957a2 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -10,6 +10,25 @@ import { User } from '../models/user.model'; import { ObjectMetadata } from './models/dynamic-model.factory'; 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() export class ObjectService { private readonly logger = new Logger(ObjectService.name); @@ -509,48 +528,35 @@ export class ObjectService { } } - // Runtime endpoints - CRUD operations - async getRecords( + private async buildAuthorizedQuery( tenantId: string, objectApiName: string, userId: string, - filters?: any, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - - // Get user with roles and permissions + const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); - + if (!user) { throw new NotFoundException('User not found'); } - - // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }) .withGraphFetched('fields'); - + if (!objectDefModel) { 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); - // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query(); - - // Apply authorization scope (modifies query in place) + await this.authService.applyScopeToQuery( query, objectDefModel, @@ -558,23 +564,190 @@ export class ObjectService { 'read', knex, ); - - // Build graph expression for lookup fields - const lookupFields = objectDefModel.fields?.filter(f => - f.type === 'LOOKUP' && f.referenceObject + + const lookupFields = objectDefModel.fields?.filter((field) => + field.type === 'LOOKUP' && field.referenceObject, ) || []; - + if (lookupFields.length > 0) { - // Build relation expression - use singular lowercase for relation name const relationExpression = lookupFields - .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) + .map((field) => field.apiName.replace(/Id$/, '').toLowerCase()) .filter(Boolean) .join(', '); - + if (relationExpression) { query = query.withGraphFetched(`[${relationExpression}]`); } } + + return { + query, + objectDefModel, + user, + knex, + }; + } + + private applySearchFilters( + query: any, + filters: SearchFilter[], + validFields: Set, + ) { + 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 const { @@ -603,36 +776,95 @@ export class ObjectService { } 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; - 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; + return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize }); + } - const totalCount = await query.clone().resultSize(); - - if (shouldPaginate) { - query = query.offset((safePage - 1) * safePageSize).limit(safePageSize); + async searchRecordsByIds( + tenantId: string, + objectApiName: string, + 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('*'); - - // Filter fields based on field-level permissions - const filteredRecords = await Promise.all( - records.map(record => - this.authService.filterReadableFields(record, objectDefModel.fields, user) - ) + const { query, objectDefModel, user } = await this.buildAuthorizedQuery( + tenantId, + objectApiName, + userId, ); - - return { - data: filteredRecords, - totalCount, - page: shouldPaginate ? safePage : 1, - pageSize: shouldPaginate ? safePageSize : filteredRecords.length, - }; + + query.whereIn('id', recordIds); + const orderBindings: any[] = ['id']; + const cases = recordIds + .map((id, index) => { + 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( @@ -1073,12 +1305,32 @@ export class ObjectService { .map((field: any) => field.apiName) .filter((apiName) => apiName && !this.isSystemField(apiName)); + console.log('Indexing record', { + tenantId, + objectApiName, + recordId: record.id, + fieldsToIndex, + }); + await this.meilisearchService.upsertRecord( tenantId, objectApiName, record, 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( @@ -1102,6 +1354,19 @@ export class ObjectService { ].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( tenantId: string, objectApiName: string, diff --git a/backend/src/search/meilisearch.service.ts b/backend/src/search/meilisearch.service.ts index c40d6b8..ecd5d2a 100644 --- a/backend/src/search/meilisearch.service.ts +++ b/backend/src/search/meilisearch.service.ts @@ -28,6 +28,8 @@ export class MeilisearchService { const indexName = this.buildIndexName(config, tenantId, objectApiName); const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`; + console.log('querying Meilisearch index:', { indexName, query, displayField }); + try { const response = await this.requestJson('POST', url, { q: query, @@ -66,6 +68,48 @@ export class MeilisearchService { 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( tenantId: string, objectApiName: string, diff --git a/frontend/components/views/ListView.vue b/frontend/components/views/ListView.vue index a40b538..23f43a6 100644 --- a/frontend/components/views/ListView.vue +++ b/frontend/components/views/ListView.vue @@ -23,6 +23,7 @@ interface Props { selectable?: boolean baseUrl?: string totalCount?: number + searchSummary?: string } const props = withDefaults(defineProps(), { @@ -30,6 +31,7 @@ const props = withDefaults(defineProps(), { loading: false, selectable: false, baseUrl: '/runtime/objects', + searchSummary: '', }) const emit = defineEmits<{ @@ -172,6 +174,9 @@ watch( @keyup.enter="handleSearch" /> +

+ {{ searchSummary }} +

diff --git a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue index fa05ed6..b42d7ef 100644 --- a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue +++ b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue @@ -145,6 +145,11 @@ const editConfig = computed(() => { const listPageSize = computed(() => listConfig.value?.pageSize ?? 25) 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 const fetchObjectDefinition = async () => { @@ -235,6 +240,36 @@ const loadListRecords = async ( 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 firstResult = await loadListRecords(1) const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length @@ -247,6 +282,10 @@ const initializeListRecords = async () => { } 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) if (page > loadedPages && totalCount.value > records.value.length) { await loadListRecords(page, { append: true, pageSize }) @@ -254,9 +293,24 @@ const handlePageChange = 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 }) } +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(() => route.params, async (newParams, oldParams) => { // Reset current record when navigating to 'new' @@ -325,14 +379,16 @@ onMounted(async () => { v-else-if="view === 'list' && listConfig" :config="listConfig" :data="records" - :loading="dataLoading" + :loading="dataLoading || searchLoading" :total-count="totalCount" + :search-summary="searchSummary" :base-url="`/runtime/objects`" selectable @row-click="handleRowClick" @create="handleCreate" @edit="handleEdit" @delete="handleDelete" + @search="handleSearch" @page-change="handlePageChange" @load-more="handleLoadMore" />