[ACS-11033] Knowledge Retrieval answer page displays general error instead of response (#5044)

* [ACS-11033] fix not displayed answer bug, improve unit test coverage

* [ACS-11033] replace deprecated retryWhen

* [ACS-11033] use search API for fetching ai references

* [ACS-11033] cr fixes

* [ACS-11033] copilot cr fixes

* [ACS-11033] undo accidental change

* [ACS-11033] copilot cr fixes 2

* [ACS-11033] cr fixes 2
This commit is contained in:
Grzegorz Jaśkowski
2026-02-24 13:24:39 +01:00
committed by GitHub
parent a1b96ae78a
commit 52d7f52bfb
5 changed files with 400 additions and 183 deletions
+4 -2
View File
@@ -710,7 +710,8 @@
"REFERENCED_DOCUMENTS_HEADER": "Referenced documents", "REFERENCED_DOCUMENTS_HEADER": "Referenced documents",
"REGENERATION_BUTTON_LABEL": "Regenerate", "REGENERATION_BUTTON_LABEL": "Regenerate",
"COPY_BUTTON_LABEL": "Copy", "COPY_BUTTON_LABEL": "Copy",
"COPY_MESSAGE": "Copied response to clipboard" "COPY_MESSAGE": "Copied response to clipboard",
"RELOAD_REFERENCES_BUTTON_LABEL": "Reload"
}, },
"AGENTS_BUTTON": { "AGENTS_BUTTON": {
"LABEL": "Ask Discovery", "LABEL": "Ask Discovery",
@@ -726,7 +727,8 @@
"AGENTS_FETCHING": "Error while fetching agents.", "AGENTS_FETCHING": "Error while fetching agents.",
"HX_INSIGHT_URL_FETCHING": "Error while fetching HX Insight URL.", "HX_INSIGHT_URL_FETCHING": "Error while fetching HX Insight URL.",
"LOADING_ERROR": "Hmm... something seems to have gone wrong.", "LOADING_ERROR": "Hmm... something seems to have gone wrong.",
"PAGE_NOT_AVAILABLE_ERROR": "Page is not available for these conditions." "PAGE_NOT_AVAILABLE_ERROR": "Page is not available for these conditions.",
"REFERENCES_LOADING_ERROR": "Something went wrong while loading the references."
}, },
"DISCARD_CHANGES": { "DISCARD_CHANGES": {
"WARNING": "Warning!", "WARNING": "Warning!",
@@ -1,13 +1,14 @@
<aca-page-layout> <aca-page-layout>
@if (!hasError && agentId) {
<aca-search-ai-input-container <aca-search-ai-input-container
class="aca-page-layout-header" class="aca-page-layout-header"
placeholder="KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.QUERY_INPUT_PLACEHOLDER" placeholder="KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.QUERY_INPUT_PLACEHOLDER"
[agentId]="agentId" [agentId]="agentId"
[usedInAiResultsPage]="true" [usedInAiResultsPage]="true" />
*ngIf="!hasError && agentId" /> }
<div
class="aca-page-layout-content" @if (!hasError) {
*ngIf="!hasError"> <div class="aca-page-layout-content">
<div class="aca-search-ai-results-container"> <div class="aca-search-ai-results-container">
<div <div
class="aca-search-ai-results-container-query" class="aca-search-ai-results-container-query"
@@ -17,10 +18,9 @@
<div <div
class="aca-search-ai-response-container" class="aca-search-ai-response-container"
[class.aca-search-ai-response-container-error]="hasAnsweringError"> [class.aca-search-ai-response-container-error]="hasAnsweringError">
<ng-container *ngIf="!loading else skeleton"> @if (!loading) {
<div @if (!hasAnsweringError) {
class="aca-search-ai-response-container-body" <div class="aca-search-ai-response-container-body">
*ngIf="!hasAnsweringError">
<markdown <markdown
class="aca-search-ai-response-container-body-response" class="aca-search-ai-response-container-body-response"
data-automation-id="aca-search-ai-results-response" data-automation-id="aca-search-ai-results-response"
@@ -44,36 +44,60 @@
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_BUTTON_LABEL' | translate"> [title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_BUTTON_LABEL' | translate">
<mat-icon>copy</mat-icon> <mat-icon>copy</mat-icon>
</button> </button>
<ng-container *ngIf="nodes?.length">
<mat-divider class="aca-search-ai-response-container-body-divider" aria-hidden="true" /> @if (references$ | async; as refs) {
@if (refs?.length || hasReferencesLoadingError) {
<mat-divider
class="aca-search-ai-response-container-body-divider"
[class.aca-search-ai-response-container-body-divider-error]="hasReferencesLoadingError"
aria-hidden="true"
/>
<div class="aca-search-ai-response-container-body-references-container"> <div class="aca-search-ai-response-container-body-references-container">
<p class="aca-search-ai-response-container-body-references-container-header"> <p class="aca-search-ai-response-container-body-references-container-header">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }} {{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }}
</p> </p>
@if (hasReferencesLoadingError) {
<div class="aca-search-ai-response-container-body-references-container-loading-error" role="alert">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.REFERENCES_LOADING_ERROR' | translate }}
<button
mat-flat-button
(click)="loadReferences()"
[attr.aria-label]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.RELOAD_REFERENCES_BUTTON_LABEL' | translate"
class="aca-search-ai-response-container-error-message-regeneration-button"
data-automation-id="aca-search-ai-response-container-body-references-container-retry-references-loading-button">
<mat-icon class="aca-search-ai-response-container-error-message-regeneration-button-icon">cached</mat-icon>
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.RELOAD_REFERENCES_BUTTON_LABEL' | translate }}
</button>
</div>
} @else {
<div class="aca-search-ai-response-container-body-references-container-documents"> <div class="aca-search-ai-response-container-body-references-container-documents">
@for (node of refs; track node.id) {
<a <a
class="aca-search-ai-response-container-body-references-container-documents-document" class="aca-search-ai-response-container-body-references-container-documents-document"
*ngFor="let node of nodes"
[attr.data-automation-id]="'aca-search-ai-results-' + node.id + '-document'" [attr.data-automation-id]="'aca-search-ai-results-' + node.id + '-document'"
role="link"
(click)="openFile(node.id)" (click)="openFile(node.id)"
(keyup.enter)="openFile(node.id)" (keyup.enter)="openFile(node.id)"
tabindex="0"> tabindex="0">
<mat-icon <mat-icon
mat-list-icon mat-list-icon
class="aca-search-ai-response-container-body-references-container-documents-document-icon"> class="aca-search-ai-response-container-body-references-container-documents-document-icon">
<img [alt]="node.content?.mimeType" [src]="mimeTypeIconsByNodeId[node.id]"/> <img [alt]="node.content?.mimeType" [src]="mimeTypeIconsByNodeId[node.id]" />
</mat-icon> </mat-icon>
<div class="aca-search-ai-response-container-body-references-container-documents-document-name"> <div class="aca-search-ai-response-container-body-references-container-documents-document-name">
{{ node.name }} {{ node.name }}
</div> </div>
</a> </a>
}
</div> </div>
}
</div> </div>
</ng-container> }
}
</div> </div>
<div } @else {
*ngIf="hasAnsweringError" <div class="aca-search-ai-response-container-error-message" role="alert">
class="aca-search-ai-response-container-error-message">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }} {{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }}
<button <button
mat-flat-button mat-flat-button
@@ -84,19 +108,19 @@
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate }} {{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate }}
</button> </button>
</div> </div>
</ng-container> }
</div> } @else {
</div>
</div>
<adf-empty-content
class="aca-page-layout-content"
icon="star"
title="KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.PAGE_NOT_AVAILABLE_ERROR"
*ngIf="hasError" />
</aca-page-layout>
<ng-template #skeleton>
<div class="adf-skeleton"></div> <div class="adf-skeleton"></div>
<div class="adf-skeleton"></div> <div class="adf-skeleton"></div>
<div class="adf-skeleton adf-skeleton-half"></div> <div class="adf-skeleton adf-skeleton-half"></div>
</ng-template> }
</div>
</div>
</div>
} @else {
<adf-empty-content
class="aca-page-layout-content"
icon="star"
title="KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.PAGE_NOT_AVAILABLE_ERROR" />
}
</aca-page-layout>
@@ -155,6 +155,10 @@
&-divider { &-divider {
margin-top: 9px; margin-top: 9px;
&-error {
border-color: var(--adf-error-color);
}
} }
&-references-container { &-references-container {
@@ -168,6 +172,14 @@
margin-bottom: 3px; margin-bottom: 3px;
} }
&-loading-error {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
padding-bottom: 3px;
}
&-documents { &-documents {
padding-right: 5px; padding-right: 5px;
padding-top: 5px; padding-top: 5px;
@@ -27,18 +27,18 @@ import { SearchAiResultsComponent } from './search-ai-results.component';
import { ActivatedRoute, Params, Router } from '@angular/router'; import { ActivatedRoute, Params, Router } from '@angular/router';
import { Observable, of, Subject, throwError } from 'rxjs'; import { Observable, of, Subject, throwError } from 'rxjs';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { EmptyContentComponent, UnitTestingUtils, UnsavedChangesGuard, UserPreferencesService } from '@alfresco/adf-core'; import { ClipboardService, EmptyContentComponent, UnitTestingUtils, UnsavedChangesGuard, UserPreferencesService } from '@alfresco/adf-core';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { AppTestingModule } from '../../../../testing/app-testing.module'; import { AppTestingModule } from '../../../../testing/app-testing.module';
import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatIconTestingModule } from '@angular/material/icon/testing';
import { AgentService, NodesApiService, SearchAiService } from '@alfresco/adf-content-services'; import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { By } from '@angular/platform-browser';
import { ModalAiService } from '../../../../services/modal-ai.service'; import { ModalAiService } from '../../../../services/modal-ai.service';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { AiAnswerEntry, QuestionModel } from '@alfresco/js-api/typings'; import { AiAnswerEntry, Node, QuestionModel, ResultSetPaging } from '@alfresco/js-api/typings';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component'; import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getAppSelection, getCurrentFolder, ViewNodeAction } from '@alfresco/aca-shared/store'; import { getAppSelection, getCurrentFolder, ViewNodeAction } from '@alfresco/aca-shared/store';
import { ContentApiService } from '@alfresco/aca-shared';
import { ViewerService } from '@alfresco/aca-content/viewer'; import { ViewerService } from '@alfresco/aca-content/viewer';
import { DebugElement } from '@angular/core'; import { DebugElement } from '@angular/core';
import { MarkdownComponent, MarkdownModule, MARKED_OPTIONS } from 'ngx-markdown'; import { MarkdownComponent, MarkdownModule, MARKED_OPTIONS } from 'ngx-markdown';
@@ -68,6 +68,8 @@ describe('SearchAiResultsComponent', () => {
let viewerService: ViewerService; let viewerService: ViewerService;
let unsavedChangesGuard: UnsavedChangesGuard; let unsavedChangesGuard: UnsavedChangesGuard;
let unitTestingUtils: UnitTestingUtils; let unitTestingUtils: UnitTestingUtils;
let clipboardService: ClipboardService;
let contentApiService: ContentApiService;
afterEach(() => { afterEach(() => {
store.resetSelectors(); store.resetSelectors();
@@ -79,12 +81,6 @@ describe('SearchAiResultsComponent', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule, MarkdownModule.forRoot()], imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule, MarkdownModule.forRoot()],
providers: [ providers: [
{
provide: NodesApiService,
useValue: {
getNode: () => of({ id: 'someId', isFolder: true }).pipe(delay(50))
}
},
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { useValue: {
@@ -110,6 +106,8 @@ describe('SearchAiResultsComponent', () => {
userPreferencesService = TestBed.inject(UserPreferencesService); userPreferencesService = TestBed.inject(UserPreferencesService);
viewerService = TestBed.inject(ViewerService); viewerService = TestBed.inject(ViewerService);
unsavedChangesGuard = TestBed.inject(UnsavedChangesGuard); unsavedChangesGuard = TestBed.inject(UnsavedChangesGuard);
clipboardService = TestBed.inject(ClipboardService);
contentApiService = TestBed.inject(ContentApiService);
store = TestBed.inject(MockStore); store = TestBed.inject(MockStore);
store.overrideSelector(getAppSelection, { store.overrideSelector(getAppSelection, {
nodes: [], nodes: [],
@@ -126,7 +124,7 @@ describe('SearchAiResultsComponent', () => {
}); });
describe('query params change', () => { describe('query params change', () => {
const getEmptyContentElement = (): DebugElement => fixture.debugElement.query(By.directive(EmptyContentComponent)); const getEmptyContentElement = (): DebugElement => unitTestingUtils.getByDirective(EmptyContentComponent);
it('should perform ai search and sets agents on query params change', () => { it('should perform ai search and sets agents on query params change', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
@@ -166,7 +164,7 @@ describe('SearchAiResultsComponent', () => {
it('should not get query answer and display an error when getAnswer throws error', fakeAsync(() => { it('should not get query answer and display an error when getAnswer throws error', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValue(throwError('error').pipe(delay(100))); spyOn(searchAiService, 'getAnswer').and.returnValue(throwError(() => 'error').pipe(delay(100)));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000); tick(30000);
@@ -178,7 +176,10 @@ describe('SearchAiResultsComponent', () => {
it('should get query answer and not display an error when getAnswer throws one error and one successful response', fakeAsync(() => { it('should get query answer and not display an error when getAnswer throws one error and one successful response', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(throwError('error'), of(getAiAnswerEntry())); spyOn(searchAiService, 'getAnswer').and.returnValues(
throwError(() => 'error'),
of(getAiAnswerEntry())
);
mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(3000); tick(3000);
@@ -189,7 +190,7 @@ describe('SearchAiResultsComponent', () => {
it('should display and answer and not display an error when getAnswer throws nine errors and one successful response', fakeAsync(() => { it('should display and answer and not display an error when getAnswer throws nine errors and one successful response', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(throwError('error')), of(getAiAnswerEntry())); spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(throwError(() => 'error')), of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(50000); tick(50000);
@@ -200,7 +201,7 @@ describe('SearchAiResultsComponent', () => {
it('should not display an answer and display an error when getAnswer throws ten errors', fakeAsync(() => { it('should not display an answer and display an error when getAnswer throws ten errors', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(14).fill(throwError('error')), of(getAiAnswerEntry(true))); spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(14).fill(throwError(() => 'error')), of(getAiAnswerEntry(true)));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000); tick(30000);
@@ -254,6 +255,21 @@ describe('SearchAiResultsComponent', () => {
expect(getEmptyContentElement()).toBeNull(); expect(getEmptyContentElement()).toBeNull();
}); });
describe('when queryAnswer already exists', () => {
it('should not re-run search when returning from viewer', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(searchAiService.getAnswer).toHaveBeenCalledTimes(1);
mockQueryParams.next({ query: 'test', agentId: 'agentId1', location: 'viewer' });
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(searchAiService.getAnswer).toHaveBeenCalledTimes(1);
}));
});
describe('when query params contains location', () => { describe('when query params contains location', () => {
let params: Params; let params: Params;
@@ -269,7 +285,7 @@ describe('SearchAiResultsComponent', () => {
mockQueryParams.next(params); mockQueryParams.next(params);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(SearchAiInputComponent))).toBeNull(); expect(unitTestingUtils.getByDirective(SearchAiInputComponent)).toBeNull();
}); });
it('should not render empty content', () => { it('should not render empty content', () => {
@@ -285,7 +301,7 @@ describe('SearchAiResultsComponent', () => {
mockQueryParams.next(params); mockQueryParams.next(params);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-query"]`)).nativeElement.textContent.trim()).toBe(''); expect(unitTestingUtils.getByDataAutomationId('aca-search-ai-results-query').nativeElement.textContent.trim()).toBe('');
}); });
it('should not call searchAiService.ask', () => { it('should not call searchAiService.ask', () => {
@@ -340,7 +356,7 @@ describe('SearchAiResultsComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-regeneration-button"]`)).nativeElement.click(); unitTestingUtils.getByDataAutomationId('aca-search-ai-results-regeneration-button').nativeElement.click();
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function)); expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
expect(component.displayedAnswer).toEqual('Some answer'); expect(component.displayedAnswer).toEqual('Some answer');
}); });
@@ -508,35 +524,55 @@ describe('SearchAiResultsComponent', () => {
}); });
describe('References', () => { describe('References', () => {
let documentElement: HTMLDivElement; let documentElement: DebugElement;
let nodesOrder: string[];
const nodeId = 'someId'; const nodeId = 'someId';
const secondNodeId = 'someId1';
const url = 'some-url'; const url = 'some-url';
beforeEach(fakeAsync(() => { const node1 = { id: nodeId, isFolder: true } as Node;
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder ??= passedNodesOrder)); const node2 = { id: secondNodeId, isFolder: false } as Node;
spyOn(userPreferencesService, 'set'); const nodeError = throwError(() => 'error');
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
const answer = getAiAnswerEntry(); const toSearchResult = (nodes: Node[]): Observable<ResultSetPaging> =>
answer.entry.objectReferences = [ of({
{ list: {
objectId: nodeId, entries: nodes.map((node) => ({ entry: node }))
references: []
} }
]; } as ResultSetPaging).pipe(delay(50));
spyOn(searchAiService, 'getAnswer').and.returnValues(throwError('error'), of(answer));
const setupReferencesTest = (objectIds: string[] = [], searchResult: Observable<ResultSetPaging>[] = []) => {
spyOn(contentApiService, 'search').and.returnValues(...searchResult);
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
const answer = getAiAnswerEntry();
answer.entry.objectReferences = objectIds.map((id) => ({
objectId: id,
references: []
}));
spyOn(searchAiService, 'getAnswer').and.returnValues(
throwError(() => 'error'),
of(answer)
);
mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(3051); tick(3051);
fixture.detectChanges(); fixture.detectChanges();
documentElement = fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-someId-document"]`)).nativeElement;
spyOn(store, 'dispatch');
spyOnProperty(TestBed.inject(Router), 'url').and.returnValue(url);
}));
it('should dispatch ViewNodeAction on store when clicked', () => { tick(51);
documentElement.click(); fixture.detectChanges();
documentElement = unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`);
spyOnProperty(TestBed.inject(Router), 'url').and.returnValue(url);
};
it('should dispatch ViewNodeAction on store when clicked', fakeAsync(() => {
spyOn(store, 'dispatch');
setupReferencesTest([nodeId], [toSearchResult([node1])]);
documentElement.nativeElement.click();
expect(store.dispatch).toHaveBeenCalledWith( expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({ jasmine.objectContaining({
...new ViewNodeAction(nodeId, { ...new ViewNodeAction(nodeId, {
@@ -544,10 +580,13 @@ describe('SearchAiResultsComponent', () => {
}) })
}) })
); );
}); }));
it('should dispatch ViewNodeAction on store when pressed enter', () => { it('should dispatch ViewNodeAction on store when pressed enter', fakeAsync(() => {
documentElement.dispatchEvent( spyOn(store, 'dispatch');
setupReferencesTest([nodeId], [toSearchResult([node1])]);
documentElement.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { new KeyboardEvent('keyup', {
key: 'Enter' key: 'Enter'
}) })
@@ -559,14 +598,85 @@ describe('SearchAiResultsComponent', () => {
}) })
}) })
); );
}); }));
it('should assign nodes ids to customNodesOrder for ViewerService', fakeAsync(() => {
let nodesOrder: string[];
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder = passedNodesOrder));
setupReferencesTest([nodeId], [toSearchResult([node1])]);
it('should assign nodes ids to customNodesOrder for ViewerService', () => {
expect(nodesOrder).toEqual([nodeId]); expect(nodesOrder).toEqual([nodeId]);
}); }));
it('should call set on userPreferencesService with correct parameters', fakeAsync(() => {
spyOn(userPreferencesService, 'set');
setupReferencesTest([nodeId], [toSearchResult([node1])]);
it('should call set on userPreferencesService with correct parameters', () => {
expect(userPreferencesService.set).toHaveBeenCalledWith('aiReferences', JSON.stringify([nodeId])); expect(userPreferencesService.set).toHaveBeenCalledWith('aiReferences', JSON.stringify([nodeId]));
}));
it('should display answer and reference nodes when all are fetched', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([node1, node2])]);
expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasReferencesLoadingError).toBeFalse();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`)).toBeTruthy();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${secondNodeId}-document`)).toBeTruthy();
}));
describe('Reload References', () => {
const getReloadButton = (): DebugElement =>
unitTestingUtils.getByDataAutomationId('aca-search-ai-response-container-body-references-container-retry-references-loading-button');
it('should set hasReferencesLoadingError and display reload button when not all references are fetched', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([node1])]);
const reloadButton = getReloadButton();
expect(component.hasReferencesLoadingError).toBeTrue();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`)).toBeFalsy();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${secondNodeId}-document`)).toBeFalsy();
expect(reloadButton).toBeTruthy();
}));
it('should set hasReferencesLoadingError and display reload button when search request fails', fakeAsync(() => {
setupReferencesTest([nodeId], [nodeError]);
const reloadButton = getReloadButton();
expect(component.hasReferencesLoadingError).toBeTrue();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`)).toBeFalsy();
expect(reloadButton).toBeTruthy();
}));
it('should call search api when reload references button is clicked', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([node1])]);
getReloadButton().nativeElement.click();
expect(contentApiService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: {
query: `ID:"${nodeId}" OR ID:"${secondNodeId}"`,
language: 'afts'
}
})
);
}));
it('should not display reload button after references are successfully reloaded', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([]), toSearchResult([node1, node2])]);
const reloadButton = getReloadButton();
reloadButton.nativeElement.click();
tick(51);
fixture.detectChanges();
expect(getReloadButton()).toBeNull();
}));
it('should not display reload button when there are no references', fakeAsync(() => {
setupReferencesTest();
expect(component.hasReferencesLoadingError).toBeFalse();
expect(getReloadButton()).toBeNull();
}));
}); });
}); });
@@ -612,4 +722,21 @@ describe('SearchAiResultsComponent', () => {
}); });
}); });
}); });
it('should copy answer to clipboard and show notification on copy button click', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
spyOn(clipboardService, 'copyContentToClipboard');
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
fixture.detectChanges();
const copyButton = unitTestingUtils.getByDataAutomationId('aca-search-ai-results-copying-button').nativeElement;
copyButton.click();
expect(clipboardService.copyContentToClipboard).toHaveBeenCalledWith(
component.displayedAnswer,
'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_MESSAGE'
);
});
}); });
@@ -24,15 +24,14 @@
import { Component, ElementRef, OnInit, ViewEncapsulation } from '@angular/core'; import { Component, ElementRef, OnInit, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageComponent, PageLayoutComponent } from '@alfresco/aca-shared'; import { PageComponent, PageLayoutComponent, ContentApiService } from '@alfresco/aca-shared';
import { concatMap, delay, filter, finalize, retryWhen, skipWhile, switchMap } from 'rxjs/operators'; import { catchError, delay, filter, finalize, map, retry, shareReplay, switchMap, tap } from 'rxjs/operators';
import { ClipboardService, EmptyContentComponent, ThumbnailService, UnsavedChangesGuard } from '@alfresco/adf-core'; import { ClipboardService, EmptyContentComponent, ThumbnailService, UnsavedChangesGuard } from '@alfresco/adf-core';
import { AiAnswer, Node } from '@alfresco/js-api'; import { AiAnswer, Node } from '@alfresco/js-api';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component'; import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NodesApiService } from '@alfresco/adf-content-services'; import { from, Observable, of, throwError } from 'rxjs';
import { forkJoin, Observable, of, throwError } from 'rxjs';
import { SelectionState } from '@alfresco/adf-extensions'; import { SelectionState } from '@alfresco/adf-extensions';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@@ -78,17 +77,20 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
private static readonly MERMAID_BLOCK_REGEX = /```mermaid([\s\S]*?)```/g; private static readonly MERMAID_BLOCK_REGEX = /```mermaid([\s\S]*?)```/g;
private static readonly LATEX_BLOCK_REGEX = /```latex([\s\S]*?)```/g; private static readonly LATEX_BLOCK_REGEX = /```latex([\s\S]*?)```/g;
references$: Observable<Node[]> = of([]);
private _agentId: string; private _agentId: string;
private _hasAnsweringError = false; private _hasAnsweringError = false;
private _hasError = false; private _hasError = false;
private _loading = false; private _loading = false;
private _mimeTypeIconsByNodeId: { [key: string]: string } = {}; private _mimeTypeIconsByNodeId: { [key: string]: string } = {};
private _nodes: Node[] = [];
private openedViewer = false; private openedViewer = false;
private _selectedNodesState: SelectionState; private _selectedNodesState: SelectionState;
private _searchQuery = ''; private _searchQuery = '';
private queryAnswer: AiAnswer; private queryAnswer: AiAnswer;
private _displayedAnswer: string; private _displayedAnswer: string;
private _hasReferencesLoadingError = false;
private _referencesLoading = false;
get agentId(): string { get agentId(): string {
return this._agentId; return this._agentId;
@@ -110,10 +112,6 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
return this._mimeTypeIconsByNodeId; return this._mimeTypeIconsByNodeId;
} }
get nodes(): Node[] {
return this._nodes;
}
get searchQuery(): string { get searchQuery(): string {
return this._searchQuery; return this._searchQuery;
} }
@@ -122,16 +120,20 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
return this._displayedAnswer; return this._displayedAnswer;
} }
get hasReferencesLoadingError(): boolean {
return this._hasReferencesLoadingError;
}
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly clipboardService: ClipboardService, private readonly clipboardService: ClipboardService,
private readonly thumbnailService: ThumbnailService, private readonly thumbnailService: ThumbnailService,
private readonly nodesApiService: NodesApiService,
private readonly translateService: TranslateService, private readonly translateService: TranslateService,
private readonly unsavedChangesGuard: UnsavedChangesGuard, private readonly unsavedChangesGuard: UnsavedChangesGuard,
private readonly modalAiService: ModalAiService, private readonly modalAiService: ModalAiService,
private readonly viewerService: ViewerService, private readonly viewerService: ViewerService,
private readonly elementRef: ElementRef private readonly elementRef: ElementRef,
private readonly contentApi: ContentApiService
) { ) {
super(); super();
} }
@@ -182,6 +184,7 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
performAiSearch(): void { performAiSearch(): void {
this._loading = true; this._loading = true;
this._hasAnsweringError = false;
this.searchAiService this.searchAiService
.ask({ .ask({
@@ -191,30 +194,25 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
}) })
.pipe( .pipe(
switchMap((response) => this.searchAiService.getAnswer(response.questionId)), switchMap((response) => this.searchAiService.getAnswer(response.questionId)),
switchMap((response) => { tap((response) => {
if (!response.entry?.answer) { if (!response.entry?.answer) {
return throwError((e) => e); throw new Error();
} }
this.queryAnswer = response.entry; this.queryAnswer = response.entry;
this._displayedAnswer = this.preprocessMarkdownFormat(response.entry.answer); this._displayedAnswer = this.preprocessMarkdownFormat(response.entry.answer);
return forkJoin(this.queryAnswer.objectReferences.map((reference) => this.nodesApiService.getNode(reference.objectId))); this.loadReferences();
}),
retry({
delay: (error: Error, retryCount) => this.aiSearchRetryDelay(error, retryCount)
}),
finalize(() => {
this._loading = false;
}), }),
retryWhen((errors: Observable<Error>) => this.aiSearchRetryWhen(errors)),
finalize(() => (this._loading = false)),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe( .subscribe({
(nodes) => { error: () => (this._hasAnsweringError = true)
nodes.forEach((node) => {
this._mimeTypeIconsByNodeId[node.id] = this.thumbnailService.getMimeTypeIcon(node.content?.mimeType);
}); });
this._nodes = nodes;
const nodesIds = nodes.map((node) => node.id);
this.viewerService.customNodesOrder = nodesIds;
this.userPreferencesService.set('aiReferences', JSON.stringify(nodesIds));
},
() => (this._hasAnsweringError = true)
);
} }
openFile(id: string): void { openFile(id: string): void {
@@ -230,6 +228,22 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
this.setTooltip(SearchAiResultsComponent.LATEX_BLOCK_REGEX, '.katex'); this.setTooltip(SearchAiResultsComponent.LATEX_BLOCK_REGEX, '.katex');
} }
loadReferences(): void {
if (this._referencesLoading) {
return;
}
this._referencesLoading = true;
this.references$ = this.fetchReferences(this.queryAnswer).pipe(
tap((nodes) => this.updateNodes(nodes)),
finalize(() => {
this._referencesLoading = false;
}),
shareReplay({ bufferSize: 1, refCount: true })
);
}
private setTooltip(codeBlockRegexp: RegExp, targetElementsSelector: string): void { private setTooltip(codeBlockRegexp: RegExp, targetElementsSelector: string): void {
const codeBlocks = [...this.queryAnswer.answer.matchAll(codeBlockRegexp)].map((match) => match[0].trim()); const codeBlocks = [...this.queryAnswer.answer.matchAll(codeBlockRegexp)].map((match) => match[0].trim());
const elements: HTMLElement[] = this.elementRef.nativeElement.querySelectorAll(targetElementsSelector); const elements: HTMLElement[] = this.elementRef.nativeElement.querySelectorAll(targetElementsSelector);
@@ -238,23 +252,17 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
} }
} }
private aiSearchRetryWhen(errors: Observable<Error>): Observable<Error> { private aiSearchRetryDelay(error: Error, retryCount: number): Observable<void> {
this._hasAnsweringError = false; this._hasAnsweringError = false;
const delayBetweenRetries = 3000; const delayBetweenRetries = 3000;
const maxRetries = 9; const maxRetries = 9;
return errors.pipe( if (retryCount > maxRetries) {
skipWhile(() => this.hasAnsweringError),
delay(delayBetweenRetries),
concatMap((e, index) => {
if (index === maxRetries) {
this._hasAnsweringError = true; this._hasAnsweringError = true;
this._loading = false; return throwError(() => error);
return throwError(e);
} }
return of(null);
}) return of(undefined).pipe(delay(delayBetweenRetries));
);
} }
private preprocessMarkdownFormat(answer: string): string { private preprocessMarkdownFormat(answer: string): string {
@@ -281,4 +289,48 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
private transformLatex(answer: string): string { private transformLatex(answer: string): string {
return answer.replace(SearchAiResultsComponent.LATEX_BLOCK_REGEX, (_, latexContent: string) => `$$${latexContent.trim()}$$`); return answer.replace(SearchAiResultsComponent.LATEX_BLOCK_REGEX, (_, latexContent: string) => `$$${latexContent.trim()}$$`);
} }
private fetchReferences(answer?: AiAnswer): Observable<Node[]> {
this._hasReferencesLoadingError = false;
const objectIds = answer?.objectReferences?.map((reference) => reference.objectId);
if (!objectIds?.length) {
return of([]);
}
const query = objectIds.map((id) => `ID:"${id}"`).join(' OR ');
return from(
this.contentApi.search({
query: {
query,
language: 'afts'
}
})
).pipe(
map((result) => {
const nodes = result.list.entries.map((entry) => entry.entry as Node);
if (nodes.length !== objectIds.length) {
this._hasReferencesLoadingError = true;
return [];
}
return nodes;
}),
catchError(() => {
this._hasReferencesLoadingError = true;
return of([]);
})
);
}
private updateNodes(nodes: Node[]): void {
const nodesIds: string[] = [];
nodes.forEach((node) => {
nodesIds.push(node.id);
this._mimeTypeIconsByNodeId[node.id] = this.thumbnailService.getMimeTypeIcon(node.content?.mimeType);
});
this.viewerService.customNodesOrder = nodesIds;
this.userPreferencesService.set('aiReferences', JSON.stringify(nodesIds));
}
} }