[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",
"REGENERATION_BUTTON_LABEL": "Regenerate",
"COPY_BUTTON_LABEL": "Copy",
"COPY_MESSAGE": "Copied response to clipboard"
"COPY_MESSAGE": "Copied response to clipboard",
"RELOAD_REFERENCES_BUTTON_LABEL": "Reload"
},
"AGENTS_BUTTON": {
"LABEL": "Ask Discovery",
@@ -726,7 +727,8 @@
"AGENTS_FETCHING": "Error while fetching agents.",
"HX_INSIGHT_URL_FETCHING": "Error while fetching HX Insight URL.",
"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": {
"WARNING": "Warning!",
@@ -1,102 +1,126 @@
<aca-page-layout>
<aca-search-ai-input-container
class="aca-page-layout-header"
placeholder="KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.QUERY_INPUT_PLACEHOLDER"
[agentId]="agentId"
[usedInAiResultsPage]="true"
*ngIf="!hasError && agentId" />
<div
class="aca-page-layout-content"
*ngIf="!hasError">
<div class="aca-search-ai-results-container">
<div
class="aca-search-ai-results-container-query"
data-automation-id="aca-search-ai-results-query">
{{ searchQuery }}
</div>
<div
class="aca-search-ai-response-container"
[class.aca-search-ai-response-container-error]="hasAnsweringError">
<ng-container *ngIf="!loading else skeleton">
<div
class="aca-search-ai-response-container-body"
*ngIf="!hasAnsweringError">
<markdown
class="aca-search-ai-response-container-body-response"
data-automation-id="aca-search-ai-results-response"
[data]="displayedAnswer"
(ready)="addSourceCodeTooltips()"
mermaid
katex />
<button
class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-regeneration"
mat-icon-button
(click)="checkUnsavedChangesAndSearch()"
data-automation-id="aca-search-ai-results-regeneration-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate">
<mat-icon>cached</mat-icon>
</button>
<button
class="aca-search-ai-response-container-body-response-action"
mat-icon-button
(click)="copyResponseToClipboard()"
data-automation-id="aca-search-ai-results-copying-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_BUTTON_LABEL' | translate">
<mat-icon>copy</mat-icon>
</button>
<ng-container *ngIf="nodes?.length">
<mat-divider class="aca-search-ai-response-container-body-divider" aria-hidden="true" />
<div class="aca-search-ai-response-container-body-references-container">
<p class="aca-search-ai-response-container-body-references-container-header">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }}
</p>
<div class="aca-search-ai-response-container-body-references-container-documents">
<a
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'"
(click)="openFile(node.id)"
(keyup.enter)="openFile(node.id)"
tabindex="0">
<mat-icon
mat-list-icon
class="aca-search-ai-response-container-body-references-container-documents-document-icon">
<img [alt]="node.content?.mimeType" [src]="mimeTypeIconsByNodeId[node.id]"/>
</mat-icon>
<div class="aca-search-ai-response-container-body-references-container-documents-document-name">
{{ node.name }}
@if (!hasError && agentId) {
<aca-search-ai-input-container
class="aca-page-layout-header"
placeholder="KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.QUERY_INPUT_PLACEHOLDER"
[agentId]="agentId"
[usedInAiResultsPage]="true" />
}
@if (!hasError) {
<div class="aca-page-layout-content">
<div class="aca-search-ai-results-container">
<div
class="aca-search-ai-results-container-query"
data-automation-id="aca-search-ai-results-query">
{{ searchQuery }}
</div>
<div
class="aca-search-ai-response-container"
[class.aca-search-ai-response-container-error]="hasAnsweringError">
@if (!loading) {
@if (!hasAnsweringError) {
<div class="aca-search-ai-response-container-body">
<markdown
class="aca-search-ai-response-container-body-response"
data-automation-id="aca-search-ai-results-response"
[data]="displayedAnswer"
(ready)="addSourceCodeTooltips()"
mermaid
katex />
<button
class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-regeneration"
mat-icon-button
(click)="checkUnsavedChangesAndSearch()"
data-automation-id="aca-search-ai-results-regeneration-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate">
<mat-icon>cached</mat-icon>
</button>
<button
class="aca-search-ai-response-container-body-response-action"
mat-icon-button
(click)="copyResponseToClipboard()"
data-automation-id="aca-search-ai-results-copying-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_BUTTON_LABEL' | translate">
<mat-icon>copy</mat-icon>
</button>
@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">
<p class="aca-search-ai-response-container-body-references-container-header">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }}
</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">
@for (node of refs; track node.id) {
<a
class="aca-search-ai-response-container-body-references-container-documents-document"
[attr.data-automation-id]="'aca-search-ai-results-' + node.id + '-document'"
role="link"
(click)="openFile(node.id)"
(keyup.enter)="openFile(node.id)"
tabindex="0">
<mat-icon
mat-list-icon
class="aca-search-ai-response-container-body-references-container-documents-document-icon">
<img [alt]="node.content?.mimeType" [src]="mimeTypeIconsByNodeId[node.id]" />
</mat-icon>
<div class="aca-search-ai-response-container-body-references-container-documents-document-name">
{{ node.name }}
</div>
</a>
}
</div>
}
</div>
</a>
</div>
}
}
</div>
</ng-container>
</div>
<div
*ngIf="hasAnsweringError"
class="aca-search-ai-response-container-error-message">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }}
<button
mat-flat-button
(click)="performAiSearch()"
class="aca-search-ai-response-container-error-message-regeneration-button"
data-automation-id="aca-search-ai-results-error-regeneration-button">
<mat-icon class="aca-search-ai-response-container-error-message-regeneration-button-icon">cached</mat-icon>
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate }}
</button>
</div>
</ng-container>
} @else {
<div class="aca-search-ai-response-container-error-message" role="alert">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }}
<button
mat-flat-button
(click)="performAiSearch()"
class="aca-search-ai-response-container-error-message-regeneration-button"
data-automation-id="aca-search-ai-results-error-regeneration-button">
<mat-icon class="aca-search-ai-response-container-error-message-regeneration-button-icon">cached</mat-icon>
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate }}
</button>
</div>
}
} @else {
<div class="adf-skeleton"></div>
<div class="adf-skeleton"></div>
<div class="adf-skeleton adf-skeleton-half"></div>
}
</div>
</div>
</div>
</div>
<adf-empty-content
class="aca-page-layout-content"
icon="star"
title="KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.PAGE_NOT_AVAILABLE_ERROR"
*ngIf="hasError" />
} @else {
<adf-empty-content
class="aca-page-layout-content"
icon="star"
title="KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.PAGE_NOT_AVAILABLE_ERROR" />
}
</aca-page-layout>
<ng-template #skeleton>
<div class="adf-skeleton"></div>
<div class="adf-skeleton"></div>
<div class="adf-skeleton adf-skeleton-half"></div>
</ng-template>
@@ -155,6 +155,10 @@
&-divider {
margin-top: 9px;
&-error {
border-color: var(--adf-error-color);
}
}
&-references-container {
@@ -168,6 +172,14 @@
margin-bottom: 3px;
}
&-loading-error {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
padding-bottom: 3px;
}
&-documents {
padding-right: 5px;
padding-top: 5px;
@@ -27,18 +27,18 @@ import { SearchAiResultsComponent } from './search-ai-results.component';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { Observable, of, Subject, throwError } from 'rxjs';
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 { AppTestingModule } from '../../../../testing/app-testing.module';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { AgentService, NodesApiService, SearchAiService } from '@alfresco/adf-content-services';
import { By } from '@angular/platform-browser';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { ModalAiService } from '../../../../services/modal-ai.service';
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 { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getAppSelection, getCurrentFolder, ViewNodeAction } from '@alfresco/aca-shared/store';
import { ContentApiService } from '@alfresco/aca-shared';
import { ViewerService } from '@alfresco/aca-content/viewer';
import { DebugElement } from '@angular/core';
import { MarkdownComponent, MarkdownModule, MARKED_OPTIONS } from 'ngx-markdown';
@@ -68,6 +68,8 @@ describe('SearchAiResultsComponent', () => {
let viewerService: ViewerService;
let unsavedChangesGuard: UnsavedChangesGuard;
let unitTestingUtils: UnitTestingUtils;
let clipboardService: ClipboardService;
let contentApiService: ContentApiService;
afterEach(() => {
store.resetSelectors();
@@ -79,12 +81,6 @@ describe('SearchAiResultsComponent', () => {
TestBed.configureTestingModule({
imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule, MarkdownModule.forRoot()],
providers: [
{
provide: NodesApiService,
useValue: {
getNode: () => of({ id: 'someId', isFolder: true }).pipe(delay(50))
}
},
{
provide: ActivatedRoute,
useValue: {
@@ -110,6 +106,8 @@ describe('SearchAiResultsComponent', () => {
userPreferencesService = TestBed.inject(UserPreferencesService);
viewerService = TestBed.inject(ViewerService);
unsavedChangesGuard = TestBed.inject(UnsavedChangesGuard);
clipboardService = TestBed.inject(ClipboardService);
contentApiService = TestBed.inject(ContentApiService);
store = TestBed.inject(MockStore);
store.overrideSelector(getAppSelection, {
nodes: [],
@@ -126,7 +124,7 @@ describe('SearchAiResultsComponent', () => {
});
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', () => {
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(() => {
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' });
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(() => {
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' });
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(() => {
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' });
tick(50000);
@@ -200,7 +201,7 @@ describe('SearchAiResultsComponent', () => {
it('should not display an answer and display an error when getAnswer throws ten errors', fakeAsync(() => {
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' });
tick(30000);
@@ -254,6 +255,21 @@ describe('SearchAiResultsComponent', () => {
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', () => {
let params: Params;
@@ -269,7 +285,7 @@ describe('SearchAiResultsComponent', () => {
mockQueryParams.next(params);
fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(SearchAiInputComponent))).toBeNull();
expect(unitTestingUtils.getByDirective(SearchAiInputComponent)).toBeNull();
});
it('should not render empty content', () => {
@@ -285,7 +301,7 @@ describe('SearchAiResultsComponent', () => {
mockQueryParams.next(params);
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', () => {
@@ -340,7 +356,7 @@ describe('SearchAiResultsComponent', () => {
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(component.displayedAnswer).toEqual('Some answer');
});
@@ -508,35 +524,55 @@ describe('SearchAiResultsComponent', () => {
});
describe('References', () => {
let documentElement: HTMLDivElement;
let nodesOrder: string[];
let documentElement: DebugElement;
const nodeId = 'someId';
const secondNodeId = 'someId1';
const url = 'some-url';
beforeEach(fakeAsync(() => {
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder ??= passedNodesOrder));
spyOn(userPreferencesService, 'set');
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
const answer = getAiAnswerEntry();
answer.entry.objectReferences = [
{
objectId: nodeId,
references: []
const node1 = { id: nodeId, isFolder: true } as Node;
const node2 = { id: secondNodeId, isFolder: false } as Node;
const nodeError = throwError(() => 'error');
const toSearchResult = (nodes: Node[]): Observable<ResultSetPaging> =>
of({
list: {
entries: nodes.map((node) => ({ entry: node }))
}
];
spyOn(searchAiService, 'getAnswer').and.returnValues(throwError('error'), of(answer));
} as ResultSetPaging).pipe(delay(50));
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' });
tick(3051);
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', () => {
documentElement.click();
tick(51);
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(
jasmine.objectContaining({
...new ViewNodeAction(nodeId, {
@@ -544,10 +580,13 @@ describe('SearchAiResultsComponent', () => {
})
})
);
});
}));
it('should dispatch ViewNodeAction on store when pressed enter', () => {
documentElement.dispatchEvent(
it('should dispatch ViewNodeAction on store when pressed enter', fakeAsync(() => {
spyOn(store, 'dispatch');
setupReferencesTest([nodeId], [toSearchResult([node1])]);
documentElement.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', {
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]);
});
}));
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]));
}));
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 { ActivatedRoute } from '@angular/router';
import { PageComponent, PageLayoutComponent } from '@alfresco/aca-shared';
import { concatMap, delay, filter, finalize, retryWhen, skipWhile, switchMap } from 'rxjs/operators';
import { PageComponent, PageLayoutComponent, ContentApiService } from '@alfresco/aca-shared';
import { catchError, delay, filter, finalize, map, retry, shareReplay, switchMap, tap } from 'rxjs/operators';
import { ClipboardService, EmptyContentComponent, ThumbnailService, UnsavedChangesGuard } from '@alfresco/adf-core';
import { AiAnswer, Node } from '@alfresco/js-api';
import { CommonModule } from '@angular/common';
import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NodesApiService } from '@alfresco/adf-content-services';
import { forkJoin, Observable, of, throwError } from 'rxjs';
import { from, Observable, of, throwError } from 'rxjs';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatIconModule } from '@angular/material/icon';
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 LATEX_BLOCK_REGEX = /```latex([\s\S]*?)```/g;
references$: Observable<Node[]> = of([]);
private _agentId: string;
private _hasAnsweringError = false;
private _hasError = false;
private _loading = false;
private _mimeTypeIconsByNodeId: { [key: string]: string } = {};
private _nodes: Node[] = [];
private openedViewer = false;
private _selectedNodesState: SelectionState;
private _searchQuery = '';
private queryAnswer: AiAnswer;
private _displayedAnswer: string;
private _hasReferencesLoadingError = false;
private _referencesLoading = false;
get agentId(): string {
return this._agentId;
@@ -110,10 +112,6 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
return this._mimeTypeIconsByNodeId;
}
get nodes(): Node[] {
return this._nodes;
}
get searchQuery(): string {
return this._searchQuery;
}
@@ -122,16 +120,20 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
return this._displayedAnswer;
}
get hasReferencesLoadingError(): boolean {
return this._hasReferencesLoadingError;
}
constructor(
private readonly route: ActivatedRoute,
private readonly clipboardService: ClipboardService,
private readonly thumbnailService: ThumbnailService,
private readonly nodesApiService: NodesApiService,
private readonly translateService: TranslateService,
private readonly unsavedChangesGuard: UnsavedChangesGuard,
private readonly modalAiService: ModalAiService,
private readonly viewerService: ViewerService,
private readonly elementRef: ElementRef
private readonly elementRef: ElementRef,
private readonly contentApi: ContentApiService
) {
super();
}
@@ -182,6 +184,7 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
performAiSearch(): void {
this._loading = true;
this._hasAnsweringError = false;
this.searchAiService
.ask({
@@ -191,30 +194,25 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
})
.pipe(
switchMap((response) => this.searchAiService.getAnswer(response.questionId)),
switchMap((response) => {
tap((response) => {
if (!response.entry?.answer) {
return throwError((e) => e);
throw new Error();
}
this.queryAnswer = response.entry;
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)
)
.subscribe(
(nodes) => {
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)
);
.subscribe({
error: () => (this._hasAnsweringError = true)
});
}
openFile(id: string): void {
@@ -230,6 +228,22 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
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 {
const codeBlocks = [...this.queryAnswer.answer.matchAll(codeBlockRegexp)].map((match) => match[0].trim());
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;
const delayBetweenRetries = 3000;
const maxRetries = 9;
return errors.pipe(
skipWhile(() => this.hasAnsweringError),
delay(delayBetweenRetries),
concatMap((e, index) => {
if (index === maxRetries) {
this._hasAnsweringError = true;
this._loading = false;
return throwError(e);
}
return of(null);
})
);
if (retryCount > maxRetries) {
this._hasAnsweringError = true;
return throwError(() => error);
}
return of(undefined).pipe(delay(delayBetweenRetries));
}
private preprocessMarkdownFormat(answer: string): string {
@@ -281,4 +289,48 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
private transformLatex(answer: string): string {
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));
}
}