Skip to content

Commit d88977d

Browse files
authored
feat(react-sdk): implement useAISearch hook for AI search state management (dotCMS#33851)
## Summary This PR implements the `useAISearch` React hook for managing AI search state in React applications and improves the AI search API response structure for better developer experience. ### Key Changes #### 1. New `useAISearch` React Hook (`libs/sdk/react/src/lib/next/hooks/useAISearch.ts`) - **State Management**: Manages AI search lifecycle with `idle`, `loading`, `success`, and `error` states - **Type-Safe**: Full TypeScript support with generic content type parameter - **API Integration**: Seamlessly integrates with the dotCMS client AI search API - **Exposed Methods**: - `search(prompt: string)` - Executes AI search with the provided prompt - `reset()` - Resets search state to idle - **Return Values**: - `response` - Full AI search response with metadata - `results` - Direct access to content results array - `status` - Current operation status with error details #### 2. API Response Refactoring - **Breaking Change**: Renamed `dotCMSResults` → `results` in `DotCMSAISearchResponse` - **Rationale**: More intuitive naming convention aligned with modern API design - **Internal Transformation**: Client SDK now transforms raw API response to user-facing format - **New Internal Type**: `DotCMSAISearchRawResponse` for backend compatibility - **Updated**: All documentation and examples in README.md #### 3. Content Drive Improvements (Merged from dotCMS#33764) - **Enhanced Error Handling**: Upload failures now show specific error messages from API response - **UI Fix**: Toolbar grid layout adjusted to properly accommodate search input (2 columns + flexible space) - **Code Cleanup**: Removed unnecessary path state updates and unused test cases in sidebar store #### 4. Type System Enhancements - **New Generic Status Type**: `DotCMSEntityStatus` for consistent state management patterns - **Internal Types Export**: Added `internal.ts` exports for AI types to support SDK implementation - **Enhanced Type Safety**: Better type inference for AI search responses ### Files Changed **Core Implementation:** - `libs/sdk/react/src/lib/next/hooks/useAISearch.ts` - New React hook - `libs/sdk/react/src/lib/next/shared/types.ts` - Type definitions - `libs/sdk/react/src/index.ts` - Public API exports **API Layer:** - `libs/sdk/client/src/lib/client/ai/search/search.ts` - Response transformation - `libs/sdk/types/src/lib/ai/public.ts` - Public API types - `libs/sdk/types/src/lib/ai/internal.ts` - Internal types - `libs/sdk/types/src/lib/components/generic/public.ts` - Shared status type **Documentation:** - `libs/sdk/client/README.md` - Comprehensive examples and usage **Content Drive:** - `libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts` - `libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.scss` - `libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts` ## Test Plan - [ ] Verify `useAISearch` hook properly manages state transitions (idle → loading → success/error) - [ ] Test AI search with various prompts and configuration options - [ ] Confirm `results` property contains expected content data - [ ] Validate error handling when search fails - [ ] Test `reset()` functionality returns to idle state - [ ] Verify TypeScript types are correctly inferred for custom content types - [ ] Check Content Drive upload error messages display correctly - [ ] Validate toolbar layout renders properly across different screen sizes - [ ] Run existing AI search tests to ensure backward compatibility in SDK client - [ ] Verify documentation examples work as expected ## Breaking Changes ⚠️ **API Response Property Renamed**: `DotCMSAISearchResponse.dotCMSResults` → `DotCMSAISearchResponse.results` **Migration Guide:** ```typescript // Before const response = await client.ai.search('query', 'index'); response.dotCMSResults.forEach(item => console.log(item)); // After const response = await client.ai.search('query', 'index'); response.results.forEach(item => console.log(item)); ``` This PR fixes: dotCMS#33850
1 parent 229a197 commit d88977d

File tree

19 files changed

+898
-59
lines changed

19 files changed

+898
-59
lines changed

core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@
5959
width: 100%;
6060
grid-area: toolbar-group-top;
6161

62-
grid-template-columns: repeat(3, minmax(4.5rem, 14rem));
63-
62+
grid-template-columns: repeat(2, minmax(4.5rem, 14rem)) 1fr;
6463
.search-input {
6564
grid-column: 1 / 3;
6665
}

core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,46 @@ describe('DotContentDriveShellComponent', () => {
655655
});
656656
});
657657

658+
it('should show error message on upload failure with errors', () => {
659+
const error = {
660+
error: {
661+
errors: [{ message: 'Upload failed' }]
662+
}
663+
};
664+
uploadService.uploadDotAsset.mockReturnValue(throwError(() => error));
665+
store.selectedNode.mockReturnValue({
666+
...ALL_FOLDER,
667+
data: {
668+
hostname: MOCK_SITES[0].hostname,
669+
path: '',
670+
type: 'folder',
671+
id: MOCK_SITES[0].identifier
672+
}
673+
});
674+
const addSpy = jest.spyOn(messageService, 'add');
675+
676+
const fileInput = spectator.query('input[type="file"]') as HTMLInputElement;
677+
Object.defineProperty(fileInput, 'files', {
678+
value: [mockFile],
679+
writable: false
680+
});
681+
682+
spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput });
683+
684+
expect(addSpy).toHaveBeenCalledTimes(2);
685+
expect(addSpy).toHaveBeenNthCalledWith(1, {
686+
severity: 'info',
687+
summary: 'content-drive.file-upload-in-progress',
688+
detail: 'content-drive.file-upload-in-progress-detail'
689+
});
690+
expect(addSpy).toHaveBeenNthCalledWith(2, {
691+
severity: 'error',
692+
summary: 'content-drive.add-dotasset-error',
693+
detail: 'content-drive.add-dotasset-error-detail',
694+
life: ERROR_MESSAGE_LIFE
695+
});
696+
});
697+
658698
it('should not upload when no files are selected', () => {
659699
const fileInput = spectator.query('input[type="file"]') as HTMLInputElement;
660700
Object.defineProperty(fileInput, 'files', {

core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,9 +371,9 @@ export class DotContentDriveShellComponent {
371371
this.#messageService.add({
372372
severity: 'error',
373373
summary: this.#dotMessageService.get('content-drive.add-dotasset-error'),
374-
detail: this.#dotMessageService.get(
375-
'content-drive.add-dotasset-error-detail'
376-
),
374+
detail:
375+
error.error?.errors?.[0]?.message ??
376+
this.#dotMessageService.get('content-drive.add-dotasset-error-detail'),
377377
life: ERROR_MESSAGE_LIFE
378378
});
379379
}

core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -215,28 +215,6 @@ describe('withSidebar', () => {
215215
// Verify the service was not called since node has children
216216
expect(folderService.getFolders).not.toHaveBeenCalled();
217217
});
218-
219-
it('should update the store path when loadChildFolders is called', (done) => {
220-
const testPath = '/documents/images/';
221-
const host = 'demo.dotcms.com';
222-
223-
// Check initial path
224-
const initialPath = store.path();
225-
expect(initialPath).toBe('/test/path'); // From initialState
226-
227-
folderService.getFolders.mockReturnValue(of(mockFolders));
228-
229-
store.loadChildFolders(testPath, host).subscribe((result) => {
230-
// Verify the path was updated in the store
231-
expect(store.path()).toBe(testPath);
232-
expect(store.path()).not.toBe(initialPath);
233-
234-
// Also verify the service was called and returned expected data
235-
expect(result.parent).toEqual(mockFolders[0]);
236-
expect(result.folders).toHaveLength(2);
237-
done();
238-
});
239-
});
240218
});
241219

242220
describe('setSelectedNode', () => {

core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Observable, of } from 'rxjs';
1010

1111
import { inject } from '@angular/core';
1212

13-
import { catchError, take, tap } from 'rxjs/operators';
13+
import { catchError, take } from 'rxjs/operators';
1414

1515
import { DotFolderService } from '@dotcms/data-access';
1616
import { DotFolder } from '@dotcms/dotcms-models';
@@ -98,9 +98,7 @@ export function withSidebar() {
9898
const host = hostname || store.currentSite()?.hostname;
9999
const fullPath = `${host}${path}`;
100100

101-
return getFolderNodesByPath(fullPath, dotFolderService).pipe(
102-
tap(() => patchState(store, { path }))
103-
);
101+
return getFolderNodesByPath(fullPath, dotFolderService);
104102
},
105103
/**
106104
* Sets the selected node

core-web/libs/sdk/client/README.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -241,17 +241,17 @@ Before using AI-powered search, ensure your dotCMS instance is properly configur
241241
#### Basic AI Search
242242
```typescript
243243
// Search for content semantically related to your query
244-
const results = await client.ai.search(
244+
const response = await client.ai.search(
245245
'articles about machine learning',
246246
'content_index'
247247
);
248-
console.log(results.dotCMSResults);
248+
console.log(response.results);
249249
```
250250

251251
#### Customizing Search Parameters
252252
```typescript
253253
// Fine-tune search with query parameters
254-
const results = await client.ai.search(
254+
const response = await client.ai.search(
255255
'artificial intelligence tutorials',
256256
'content_index',
257257
{
@@ -270,7 +270,7 @@ const results = await client.ai.search(
270270
import { DISTANCE_FUNCTIONS } from '@dotcms/types';
271271

272272
// Customize AI search behavior with threshold and distance function
273-
const results = await client.ai.search(
273+
const response = await client.ai.search(
274274
'deep learning concepts',
275275
'content_index',
276276
{
@@ -286,7 +286,7 @@ const results = await client.ai.search(
286286
#### Complete Example with All Options
287287
```typescript
288288
// Combine query and AI parameters for precise control
289-
const results = await client.ai.search(
289+
const response = await client.ai.search(
290290
'best practices for content management',
291291
'articles_index',
292292
{
@@ -306,7 +306,7 @@ const results = await client.ai.search(
306306
);
307307

308308
// Access results with match scores
309-
results.dotCMSResults.forEach(result => {
309+
resp[onse].results.forEach(result => {
310310
console.log(result.title);
311311
console.log('Matches:', result.matches); // Distance and extracted text
312312
});
@@ -416,7 +416,7 @@ interface Article extends DotCMSBasicContentlet {
416416
}
417417

418418
// Type-safe AI search
419-
const results: DotCMSAISearchResponse<Article> = await client.ai.search<Article>(
419+
const response: DotCMSAISearchResponse<Article> = await client.ai.search<Article>(
420420
'machine learning tutorials',
421421
'content_index',
422422
{
@@ -432,7 +432,7 @@ const results: DotCMSAISearchResponse<Article> = await client.ai.search<Article>
432432
);
433433

434434
// Access typed results with match information
435-
results.dotCMSResults.forEach(article => {
435+
response.results.forEach(article => {
436436
console.log(article.title); // ✅ Type-safe: string
437437
console.log(article.category); // ✅ Type-safe: string
438438

@@ -818,7 +818,7 @@ search<T extends DotCMSBasicContentlet>(
818818

819819
```typescript
820820
interface DotCMSAISearchResponse<T> {
821-
dotCMSResults: Array<T & {
821+
results: Array<T & {
822822
matches?: Array<{
823823
distance: number; // Similarity score
824824
extractedText: string; // Matched text excerpt
@@ -867,9 +867,9 @@ client.ai.search(
867867
query: { limit: 10 },
868868
config: { threshold: 0.8 }
869869
}
870-
).then((results) => {
871-
console.log('Found:', results.dotCMSResults.length);
872-
return results;
870+
).then((response) => {
871+
console.log('Found:', response.results.length);
872+
return response;
873873
}).catch((error) => {
874874
console.error('Search failed:', error.message);
875875
});

core-web/libs/sdk/client/src/lib/client/ai/ai-api.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export class AIClient extends BaseApiClient {
6060
* @example
6161
* @example
6262
* ```typescript
63-
* const results = await client.ai.search('machine learning articles', 'content_index', {
63+
* const response = await client.ai.search('machine learning articles', 'content_index', {
6464
* query: {
6565
* limit: 20,
6666
* contentType: 'BlogPost',
@@ -83,8 +83,8 @@ export class AIClient extends BaseApiClient {
8383
* threshold: 0.7,
8484
* distanceFunction: DISTANCE_FUNCTIONS.cosine
8585
* }
86-
* }).then((results) => {
87-
* console.log(results);
86+
* }).then((response) => {
87+
* console.log(response.results);
8888
* }).catch((error) => {
8989
* console.error(error);
9090
* });

core-web/libs/sdk/client/src/lib/client/ai/search/search.spec.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DISTANCE_FUNCTIONS,
1111
DotCMSAISearchResponse
1212
} from '@dotcms/types';
13+
import { DotCMSAISearchRawResponse } from '@dotcms/types/internal';
1314

1415
import { AISearch } from './search';
1516

@@ -37,7 +38,15 @@ describe('AISearch', () => {
3738
...requestOptions
3839
};
3940

40-
const mockResponseData: DotCMSAISearchResponse<DotCMSBasicContentlet> = {
41+
const mockResponseDataRaw: DotCMSAISearchRawResponse<DotCMSBasicContentlet> = {
42+
timeToEmbeddings: 1000,
43+
total: 1,
44+
query: 'test query',
45+
threshold: 0.5,
46+
operator: DISTANCE_FUNCTIONS.cosine,
47+
offset: 0,
48+
limit: 1000,
49+
count: 1,
4150
dotCMSResults: [
4251
{
4352
identifier: '123',
@@ -73,6 +82,11 @@ describe('AISearch', () => {
7382
]
7483
};
7584

85+
const mockResponseData: DotCMSAISearchResponse<DotCMSBasicContentlet> = {
86+
...mockResponseDataRaw,
87+
results: mockResponseDataRaw.dotCMSResults
88+
};
89+
7690
beforeEach(() => {
7791
mockRequest.mockReset();
7892
MockedFetchHttpClient.mockImplementation(
@@ -82,7 +96,7 @@ describe('AISearch', () => {
8296
}) as Partial<FetchHttpClient> as FetchHttpClient
8397
);
8498

85-
mockRequest.mockResolvedValue(mockResponseData);
99+
mockRequest.mockResolvedValue(mockResponseDataRaw);
86100
});
87101

88102
it('should initialize with valid configuration', () => {
@@ -151,8 +165,8 @@ describe('AISearch', () => {
151165
const response = await aiSearch;
152166

153167
expect(response).toEqual(mockResponseData);
154-
expect(response.dotCMSResults).toHaveLength(1);
155-
expect(response.dotCMSResults[0].title).toBe('Test Content');
168+
expect(response.results).toHaveLength(1);
169+
expect(response.results[0].title).toBe('Test Content');
156170
});
157171

158172
it('should build a query with custom query parameters', async () => {

core-web/libs/sdk/client/src/lib/client/ai/search/search.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DotRequestOptions,
1111
DotCMSAISearchResponse
1212
} from '@dotcms/types';
13+
import { DotCMSAISearchRawResponse } from '@dotcms/types/internal';
1314

1415
import { appendMappedParams } from '../../../utils/params/utils';
1516
import { BaseApiClient } from '../../base/base-api';
@@ -87,13 +88,18 @@ export class AISearch<T extends DotCMSBasicContentlet> extends BaseApiClient {
8788
): Promise<DotCMSAISearchResponse<T> | DotErrorAISearch> {
8889
return this.fetch<T>().then(
8990
(data) => {
91+
const response: DotCMSAISearchResponse<T> = {
92+
...data,
93+
results: data.dotCMSResults
94+
};
9095
if (typeof onfulfilled === 'function') {
91-
const result = onfulfilled(data);
92-
// Ensure we always return a value, fallback to formattedResponse if callback returns undefined
93-
return result ?? data;
96+
const result = onfulfilled(response);
97+
// Ensure we always return a value, fallback to data if callback returns undefined
98+
99+
return result ?? response;
94100
}
95101

96-
return data;
102+
return response;
97103
},
98104
(error: unknown) => {
99105
// Wrap error in DotCMSContentError
@@ -130,12 +136,12 @@ export class AISearch<T extends DotCMSBasicContentlet> extends BaseApiClient {
130136
);
131137
}
132138

133-
private fetch<T extends DotCMSBasicContentlet>(): Promise<DotCMSAISearchResponse<T>> {
139+
private fetch<T extends DotCMSBasicContentlet>(): Promise<DotCMSAISearchRawResponse<T>> {
134140
const searchParams = this.buildSearchParams(this.#prompt, this.#params);
135141
const url = new URL('/api/v1/ai/search', this.dotcmsUrl);
136142
url.search = searchParams.toString();
137143

138-
return this.httpClient.request<DotCMSAISearchResponse<T>>(url.toString(), {
144+
return this.httpClient.request<DotCMSAISearchRawResponse<T>>(url.toString(), {
139145
...this.requestOptions,
140146
headers: {
141147
...this.requestOptions.headers

0 commit comments

Comments
 (0)