diff --git a/projects/aca-content/src/lib/components/files/files.component.ts b/projects/aca-content/src/lib/components/files/files.component.ts
index 549f9e471..62746a938 100644
--- a/projects/aca-content/src/lib/components/files/files.component.ts
+++ b/projects/aca-content/src/lib/components/files/files.component.ts
@@ -58,6 +58,7 @@ import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { DocumentListDirective } from '../../directives/document-list.directive';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@@ -73,6 +74,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
PaginationDirective,
PageLayoutComponent,
ToolbarComponent,
+ SearchAiInputContainerComponent,
DynamicColumnComponent,
BreadcrumbComponent,
UploadDragAreaComponent,
@@ -82,7 +84,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
CustomEmptyContentTemplateDirective
],
templateUrl: './files.component.html',
- encapsulation: ViewEncapsulation.None
+ encapsulation: ViewEncapsulation.None,
+ selector: 'aca-files'
})
export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
isValidPath = true;
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.html
new file mode 100644
index 000000000..6f3c1a25d
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.html
@@ -0,0 +1,37 @@
+
+
+
+
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.scss
new file mode 100644
index 000000000..f3aa6cc7a
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.scss
@@ -0,0 +1,74 @@
+aca-agents-button.aca-agents-button {
+ height: 32px;
+ display: block;
+
+ button {
+ &.aca-agents-menu-button {
+ display: flex;
+ align-items: end;
+
+ &.aca-agents-button-menu-trigger {
+ height: auto;
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ width: max-content;
+ padding: 0 4px 0 0;
+ }
+
+ .aca-agents-button-icon {
+ display: flex;
+ align-self: baseline;
+
+ svg {
+ height: 32px;
+ width: 32px;
+ position: absolute;
+ margin-left: -21px;
+ }
+ }
+ }
+ }
+}
+
+.aca-agents-button-menu {
+ padding-top: 2px;
+ padding-bottom: 1px;
+
+ .aca-agents-button-menu-list {
+ margin-left: -6px;
+ padding-top: 0;
+ padding-bottom: 0;
+
+ &-agent {
+ height: 40px;
+
+ &:not(:last-child) {
+ margin-bottom: 2px;
+ }
+
+ &-content {
+ display: flex;
+ align-items: center;
+
+ &-name {
+ width: 120px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ adf-avatar {
+ margin-right: 12px;
+ margin-bottom: 2px;
+ padding-left: 1px;
+ padding-top: 1px;
+
+ .adf-avatar__image {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts
new file mode 100644
index 000000000..49b670140
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts
@@ -0,0 +1,461 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see
.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AgentsButtonComponent } from './agents-button.component';
+import { AgentService, ContentTestingModule, SearchAiService } from '@alfresco/adf-content-services';
+import { Subject } from 'rxjs';
+import { By } from '@angular/platform-browser';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { getAppSelection, SearchAiActionTypes, ToggleAISearchInput } from '@alfresco/aca-shared/store';
+import { AvatarComponent, NotificationService } from '@alfresco/adf-core';
+import { SelectionState } from '@alfresco/adf-extensions';
+import { MatMenu, MatMenuPanel, MatMenuTrigger } from '@angular/material/menu';
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { MatSelectionListHarness } from '@angular/material/list/testing';
+import { MatMenuHarness } from '@angular/material/menu/testing';
+import { MatSelectionList } from '@angular/material/list';
+import { MatSnackBarRef } from '@angular/material/snack-bar';
+import { ChangeDetectorRef } from '@angular/core';
+import { Agent, KnowledgeRetrievalConfigEntry } from '@alfresco/js-api';
+
+describe('AgentsButtonComponent', () => {
+ let component: AgentsButtonComponent;
+ let fixture: ComponentFixture
;
+ let agents$: Subject;
+ let agentsMock: Agent[];
+ let checkSearchAvailabilitySpy: jasmine.Spy<(selectedNodesState: SelectionState, maxSelectedNodes?: number) => string>;
+ let selectionState: SelectionState;
+ let store: MockStore;
+ let config$: Subject;
+
+ const knowledgeRetrievalUrl = 'some url';
+
+ const getMenu = (): MatMenu => fixture.debugElement.query(By.directive(MatMenu)).componentInstance;
+
+ const getAgentsButton = (): HTMLButtonElement => fixture.debugElement.query(By.css('.aca-agents-menu-button'))?.nativeElement;
+
+ const runButtonActions = (eventName: string): void => {
+ let event: Event;
+ let notificationService: NotificationService;
+ let message: string;
+
+ beforeEach(() => {
+ config$.next({
+ entry: {
+ knowledgeRetrievalUrl
+ }
+ });
+ config$.complete();
+ event =
+ eventName === 'mouseup'
+ ? new MouseEvent(eventName)
+ : new KeyboardEvent(eventName, {
+ key: 'Enter'
+ });
+ agents$.next(agentsMock);
+ agents$.complete();
+ spyOn(window, 'open');
+ notificationService = TestBed.inject(NotificationService);
+ spyOn(notificationService, 'showError');
+ message = 'Some message';
+ component.avatarsMocked = false;
+ });
+
+ const getMenuTrigger = (): MatMenuPanel => fixture.debugElement.query(By.directive(MatMenuTrigger)).injector.get(MatMenuTrigger).menu;
+
+ const testButtonActions = (): void => {
+ it('should not display notification if checkSearchAvailability from SearchAiService returns empty message', () => {
+ message = '';
+ checkSearchAvailabilitySpy.and.returnValue(message);
+
+ getAgentsButton().dispatchEvent(event);
+ expect(notificationService.showError).not.toHaveBeenCalled();
+ });
+
+ it('should disable menu triggering if checkSearchAvailability from SearchAiService returns message', () => {
+ checkSearchAvailabilitySpy.and.returnValue('Some message');
+
+ getAgentsButton().dispatchEvent(event);
+ fixture.detectChanges();
+ expect(getMenuTrigger()).toBeNull();
+ });
+ };
+
+ describe('with selected nodes', () => {
+ beforeEach(() => {
+ selectionState.isEmpty = false;
+ });
+
+ it('should display notification if checkSearchAvailability from SearchAiService returns message', () => {
+ checkSearchAvailabilitySpy.and.returnValue(message);
+
+ getAgentsButton().dispatchEvent(event);
+ expect(notificationService.showError).toHaveBeenCalledWith(message);
+ });
+
+ testButtonActions();
+
+ it('should enable menu triggering if checkSearchAvailability from SearchAiService returns empty message', () => {
+ checkSearchAvailabilitySpy.and.returnValue('');
+
+ getAgentsButton().dispatchEvent(event);
+ fixture.detectChanges();
+ const menuTrigger = getMenuTrigger();
+ expect(menuTrigger).toBeTruthy();
+ expect(menuTrigger).toBe(getMenu());
+ });
+
+ it('should call checkSearchAvailability from SearchAiService with correct parameter', () => {
+ getAgentsButton().dispatchEvent(event);
+
+ expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(selectionState);
+ });
+
+ it('should not open new tab for url loaded from config', () => {
+ getAgentsButton().dispatchEvent(event);
+
+ expect(window.open).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('without selected nodes', () => {
+ it('should not display notification if checkSearchAvailability from SearchAiService returns message', () => {
+ checkSearchAvailabilitySpy.and.returnValue(message);
+
+ getAgentsButton().dispatchEvent(event);
+ expect(notificationService.showError).not.toHaveBeenCalled();
+ });
+
+ testButtonActions();
+
+ it('should disable menu triggering if checkSearchAvailability from SearchAiService returns empty message', () => {
+ checkSearchAvailabilitySpy.and.returnValue('');
+
+ getAgentsButton().dispatchEvent(event);
+ fixture.detectChanges();
+ expect(getMenuTrigger()).toBeNull();
+ });
+
+ it('should not call checkSearchAvailability from SearchAiService', () => {
+ getAgentsButton().dispatchEvent(event);
+
+ expect(checkSearchAvailabilitySpy).not.toHaveBeenCalled();
+ });
+
+ it('should open new tab for url loaded from config', () => {
+ getAgentsButton().dispatchEvent(event);
+
+ expect(window.open).toHaveBeenCalledWith(knowledgeRetrievalUrl);
+ });
+ });
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [AgentsButtonComponent, ContentTestingModule],
+ providers: [provideMockStore({})]
+ });
+
+ fixture = TestBed.createComponent(AgentsButtonComponent);
+ component = fixture.componentInstance;
+ store = TestBed.inject(MockStore);
+ agents$ = new Subject();
+ spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(agents$);
+ agentsMock = [
+ {
+ id: '1',
+ name: 'HR Agent',
+ description: 'Test 1',
+ avatarUrl: undefined
+ },
+ {
+ id: '2',
+ name: 'Policy Agent',
+ description: 'Test 2',
+ avatarUrl: undefined
+ }
+ ];
+ const searchAiService = TestBed.inject(SearchAiService);
+ checkSearchAvailabilitySpy = spyOn(searchAiService, 'checkSearchAvailability');
+ config$ = new Subject();
+ spyOn(searchAiService, 'getConfig').and.returnValue(config$);
+ selectionState = {
+ nodes: [],
+ isEmpty: true,
+ count: 0,
+ libraries: []
+ };
+ store.overrideSelector(getAppSelection, selectionState);
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ store.resetSelectors();
+ });
+
+ describe('Button', () => {
+ let notificationServiceSpy: jasmine.Spy<(message: string) => MatSnackBarRef>;
+
+ beforeEach(() => {
+ const notificationService = TestBed.inject(NotificationService);
+ notificationServiceSpy = spyOn(notificationService, 'showError').and.callThrough();
+ });
+
+ describe('loaded config', () => {
+ beforeEach(() => {
+ component.avatarsMocked = false;
+ config$.next({
+ entry: {
+ knowledgeRetrievalUrl
+ }
+ });
+ config$.complete();
+ });
+
+ it('should be rendered if any agentsMock are loaded', () => {
+ agents$.next(agentsMock);
+ agents$.complete();
+ fixture.detectChanges();
+
+ expect(getAgentsButton()).toBeTruthy();
+ });
+
+ it('should get agentsMock on component init', () => {
+ agents$.next(agentsMock);
+ agents$.complete();
+ component.ngOnInit();
+
+ expect(component.initialsByAgentId).toEqual({ 1: 'HA', 2: 'PA' });
+ expect(component.agents).toEqual(agentsMock);
+ expect(notificationServiceSpy).not.toHaveBeenCalled();
+ });
+
+ it('should run detectChanges when getting the agentsMock', () => {
+ const changeDetectorRef2 = fixture.debugElement.injector.get(ChangeDetectorRef);
+ const detectChangesSpy = spyOn(changeDetectorRef2.constructor.prototype, 'detectChanges');
+
+ component.ngOnInit();
+ agents$.next(agentsMock);
+
+ expect(detectChangesSpy).toHaveBeenCalled();
+ });
+
+ it('should show notification error on getAgents error', () => {
+ agents$.error('error');
+ component.ngOnInit();
+
+ expect(component.agents).toEqual([]);
+ expect(component.initialsByAgentId).toEqual({});
+ expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING');
+ });
+
+ it('should not be rendered if none agent is loaded', () => {
+ agentsMock = [];
+ agents$.next(agentsMock);
+ agents$.complete();
+
+ fixture.detectChanges();
+ expect(getAgentsButton()).toBeFalsy();
+ });
+
+ it('should have correct label', () => {
+ agents$.next(agentsMock);
+ agents$.complete();
+ fixture.detectChanges();
+
+ expect(getAgentsButton().textContent.trim()).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.AGENTS_BUTTON.LABEL');
+ });
+
+ it('should contain stars icon', () => {
+ agents$.next(agentsMock);
+ agents$.complete();
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('.aca-agents-menu-button adf-icon')).componentInstance.value).toBe('adf:colored-stars-ai');
+ });
+ });
+
+ describe('loaded config with error', () => {
+ beforeEach(() => {
+ config$.error('error');
+ config$.complete();
+ });
+
+ it('should not be rendered', () => {
+ agents$.next(agentsMock);
+ agents$.complete();
+ fixture.detectChanges();
+
+ expect(getAgentsButton()).toBeFalsy();
+ });
+
+ it('should show notification error', () => {
+ agents$.next(agentsMock);
+ agents$.complete();
+ component.ngOnInit();
+
+ expect(component.hxInsightUrl).toBeUndefined();
+ expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.HX_INSIGHT_URL_FETCHING');
+ });
+ });
+ });
+
+ const buttonKeyboardActions = (eventName: string): void => {
+ describe(`Button action - ${eventName} event`, () => {
+ runButtonActions(eventName);
+ });
+ };
+
+ ['mouseup', 'keydown'].forEach((eventName) => {
+ buttonKeyboardActions(eventName);
+ });
+
+ describe('Agents menu', () => {
+ let loader: HarnessLoader;
+
+ const prepareData = (agents: Agent[]): void => {
+ component.avatarsMocked = false;
+ config$.next({
+ entry: {
+ knowledgeRetrievalUrl
+ }
+ });
+ config$.complete();
+ loader = TestbedHarnessEnvironment.loader(fixture);
+ agents$.next(agents);
+ selectionState.isEmpty = false;
+ checkSearchAvailabilitySpy.and.returnValue('');
+ const button = getAgentsButton();
+ button.dispatchEvent(new MouseEvent('mouseup'));
+ fixture.detectChanges();
+ button.click();
+ fixture.detectChanges();
+ };
+
+ const getAvatar = (agentId: string): AvatarComponent =>
+ fixture.debugElement.query(By.css(`[data-automation-id=aca-agents-button-agent-${agentId}]`)).query(By.directive(AvatarComponent))
+ .componentInstance;
+
+ describe('Agents position', () => {
+ it('should have assigned before to xPosition', () => {
+ prepareData(agentsMock);
+ agents$.complete();
+ expect(getMenu().xPosition).toBe('before');
+ });
+ });
+
+ describe('Agents multi words name', () => {
+ beforeEach(() => {
+ prepareData(agentsMock);
+ agents$.complete();
+ });
+
+ const getAgentsListHarness = async (): Promise =>
+ (await loader.getHarness(MatMenuHarness)).getHarness(MatSelectionListHarness);
+
+ const selectAgent = async (): Promise =>
+ (await getAgentsListHarness()).selectItems({
+ fullText: 'PA Policy Agent'
+ });
+
+ const getAgentsList = (): MatSelectionList => fixture.debugElement.query(By.directive(MatSelectionList)).componentInstance;
+
+ it('should deselect selected agent after selecting other', async () => {
+ component.data = {
+ trigger: SearchAiActionTypes.ToggleAiSearchInput
+ };
+ const selectionList = getAgentsList();
+ spyOn(selectionList, 'deselectAll');
+ await selectAgent();
+
+ expect(selectionList.deselectAll).toHaveBeenCalled();
+ });
+
+ it('should dispatch on store selected agent', async () => {
+ component.data = {
+ trigger: SearchAiActionTypes.ToggleAiSearchInput
+ };
+ spyOn(store, 'dispatch');
+ await selectAgent();
+
+ expect(store.dispatch).toHaveBeenCalledWith({
+ type: SearchAiActionTypes.ToggleAiSearchInput,
+ agentId: '2'
+ });
+ });
+
+ it('should disallow selecting multiple agentsMock', () => {
+ expect(getAgentsList().multiple).toBeFalse();
+ });
+
+ it('should have hidden single selection indicator', () => {
+ expect(getAgentsList().hideSingleSelectionIndicator).toBeTrue();
+ });
+
+ it('should display option for each agent', async () => {
+ const agents = await (await getAgentsListHarness()).getItems();
+ expect(agents.length).toBe(2);
+ expect(await agents[0].getFullText()).toBe('HA HR Agent');
+ expect(await agents[1].getFullText()).toBe('PA Policy Agent');
+ });
+
+ it('should display avatar for each agent', () => {
+ expect(getAvatar('1')).toBeTruthy();
+ expect(getAvatar('2')).toBeTruthy();
+ });
+
+ it('should assign correct initials to each avatar for each agent with double section name', () => {
+ expect(getAvatar('1').initials).toBe('HA');
+ expect(getAvatar('2').initials).toBe('PA');
+ });
+ });
+
+ describe('Agents multi words name', () => {
+ it('should assign correct initials to each avatar for each agent with single section name', () => {
+ agentsMock = [
+ {
+ id: '1',
+ name: 'HR Agent',
+ description: 'Test 1',
+ avatarUrl: undefined
+ },
+ {
+ id: '2',
+ name: 'Policy Agent',
+ description: 'Test 2',
+ avatarUrl: undefined
+ }
+ ];
+ agentsMock[0].name = 'Adam';
+ agentsMock[1].name = 'Bob';
+ prepareData(agentsMock);
+
+ expect(getAvatar('1').initials).toBe('A');
+ expect(getAvatar('2').initials).toBe('B');
+ });
+ });
+ });
+});
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts
new file mode 100644
index 000000000..4d36d8a04
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts
@@ -0,0 +1,148 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { SelectionState } from '@alfresco/adf-extensions';
+import { Store } from '@ngrx/store';
+import { AppStore, getAppSelection } from '@alfresco/aca-shared/store';
+import { AvatarComponent, IconComponent, NotificationService } from '@alfresco/adf-core';
+import { forkJoin, Subject, throwError } from 'rxjs';
+import { catchError, take, takeUntil } from 'rxjs/operators';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatListModule, MatSelectionListChange } from '@angular/material/list';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
+import { Agent } from '@alfresco/js-api';
+import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { getAgentsWithMockedAvatars } from '../search-ai-utils';
+
+@Component({
+ standalone: true,
+ imports: [CommonModule, MatMenuModule, MatListModule, TranslateModule, AvatarComponent, IconComponent, MatTooltipModule],
+ selector: 'aca-agents-button',
+ templateUrl: './agents-button.component.html',
+ styleUrls: ['./agents-button.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ host: { class: 'aca-agents-button' }
+})
+export class AgentsButtonComponent implements OnInit, OnDestroy {
+ @Input()
+ data: { trigger: string };
+
+ private selectedNodesState: SelectionState;
+ private _agents: Agent[] = [];
+ private onDestroy$ = new Subject();
+ private _disabled = true;
+ private _initialsByAgentId: { [key: string]: string } = {};
+ private _hxInsightUrl: string;
+
+ avatarsMocked = true;
+
+ get agents(): Agent[] {
+ return this._agents;
+ }
+
+ get disabled(): boolean {
+ return this._disabled;
+ }
+
+ get initialsByAgentId(): { [key: string]: string } {
+ return this._initialsByAgentId;
+ }
+
+ get hxInsightUrl(): string {
+ return this._hxInsightUrl;
+ }
+
+ constructor(
+ private store: Store,
+ private notificationService: NotificationService,
+ private searchAiService: SearchAiService,
+ private agentService: AgentService,
+ private translateService: TranslateService,
+ private cd: ChangeDetectorRef
+ ) {}
+
+ ngOnInit(): void {
+ this.store
+ .select(getAppSelection)
+ .pipe(takeUntil(this.onDestroy$))
+ .subscribe((selection) => {
+ this.selectedNodesState = selection;
+ });
+ forkJoin({
+ agents: this.agentService.getAgents().pipe(
+ take(1),
+ catchError(() => throwError('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING'))
+ ),
+ config: this.searchAiService.getConfig().pipe(catchError(() => throwError('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.HX_INSIGHT_URL_FETCHING')))
+ }).subscribe(
+ (result) => {
+ this._hxInsightUrl = result.config.entry.knowledgeRetrievalUrl;
+ this._agents = result.agents;
+
+ // TODO remove mocked avatar images after backend is done (https://hyland.atlassian.net/browse/ACS-8769)
+ this._agents = getAgentsWithMockedAvatars(result.agents, this.avatarsMocked);
+
+ this.cd.detectChanges();
+
+ if (this.agents.length) {
+ this._initialsByAgentId = this.agents.reduce((initials, agent) => {
+ const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g));
+ initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`;
+ return initials;
+ }, {});
+ }
+ },
+ (error: string) => this.notificationService.showError(this.translateService.instant(error))
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ }
+
+ onClick(): void {
+ if (!this.selectedNodesState.isEmpty) {
+ const message = this.searchAiService.checkSearchAvailability(this.selectedNodesState);
+ if (message) {
+ this.notificationService.showError(message);
+ }
+ this._disabled = !!message;
+ return;
+ }
+ this._disabled = true;
+ open(this.hxInsightUrl);
+ }
+
+ onAgentSelection(change: MatSelectionListChange): void {
+ this.store.dispatch({
+ type: this.data.trigger,
+ agentId: change.options[0].value.id
+ });
+ change.source.deselectAll();
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.html
new file mode 100644
index 000000000..526f6ab3c
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.html
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.scss
new file mode 100644
index 000000000..6fd639575
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.scss
@@ -0,0 +1,18 @@
+aca-search-ai-input-container {
+ display: flex;
+ flex-direction: row;
+ flex: 1;
+ align-items: center;
+ width: 100%;
+
+ .aca-search-ai-input-container-divider {
+ height: 24px;
+ margin-left: 30px;
+ margin-right: 7px;
+ background: var(--adf-theme-foreground-text-color-025);
+ }
+
+ .aca-search-ai-input-container-close {
+ display: flex;
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.spec.ts
new file mode 100644
index 000000000..e09f40770
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.spec.ts
@@ -0,0 +1,186 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { SearchAiInputContainerComponent } from './search-ai-input-container.component';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
+import { By } from '@angular/platform-browser';
+import { AgentService, ContentTestingModule, SearchAiService } from '@alfresco/adf-content-services';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { of, Subject } from 'rxjs';
+import { MatDivider } from '@angular/material/divider';
+import { DebugElement } from '@angular/core';
+import { MatIconButton } from '@angular/material/button';
+import { MatIcon } from '@angular/material/icon';
+import { SearchAiNavigationService } from '../../../../services/search-ai-navigation.service';
+import { NavigationEnd, NavigationStart, Router, RouterEvent } from '@angular/router';
+import { getAppSelection } from '@alfresco/aca-shared/store';
+
+describe('SearchAiInputContainerComponent', () => {
+ let component: SearchAiInputContainerComponent;
+ let fixture: ComponentFixture;
+ let routingEvents$: Subject;
+ let searchAiService: SearchAiService;
+ let store: MockStore;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [SearchAiInputContainerComponent, ContentTestingModule],
+ providers: [
+ provideMockStore(),
+ {
+ provide: AgentService,
+ useValue: {
+ getAgents: () =>
+ of([
+ {
+ id: '1',
+ name: 'HR Agent',
+ description: 'HR Agent',
+ avatar: 'avatar1'
+ }
+ ])
+ }
+ }
+ ]
+ });
+
+ fixture = TestBed.createComponent(SearchAiInputContainerComponent);
+ component = fixture.componentInstance;
+ store = TestBed.inject(MockStore);
+ searchAiService = TestBed.inject(SearchAiService);
+ store.overrideSelector(getAppSelection, {
+ nodes: [],
+ isEmpty: true,
+ count: 0,
+ libraries: []
+ });
+ component.agentId = '1';
+ routingEvents$ = new Subject();
+ spyOnProperty(TestBed.inject(Router), 'events').and.returnValue(routingEvents$);
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ store.resetSelectors();
+ });
+
+ describe('Search ai input', () => {
+ let inputComponent: SearchAiInputComponent;
+
+ beforeEach(() => {
+ inputComponent = fixture.debugElement.query(By.directive(SearchAiInputComponent)).componentInstance;
+ });
+
+ it('should have assigned correct default placeholder', () => {
+ expect(inputComponent.placeholder).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.DEFAULT_PLACEHOLDER');
+ });
+
+ it('should have assigned correct placeholder if placeholder has been changed', () => {
+ component.placeholder = 'Some placeholder';
+ fixture.detectChanges();
+
+ expect(inputComponent.placeholder).toBe(component.placeholder);
+ });
+
+ it('should have assigned correct agentId', () => {
+ expect(inputComponent.agentId).toBe(component.agentId);
+ });
+
+ it('should have assigned correct useStoredNodes flag', () => {
+ component.useStoredNodes = true;
+ fixture.detectChanges();
+
+ expect(inputComponent.useStoredNodes).toBeTrue();
+ });
+
+ it('should call updateSearchAiInputState on SearchAiService when triggered searchSubmitted event', () => {
+ spyOn(searchAiService, 'updateSearchAiInputState');
+ inputComponent.searchSubmitted.emit();
+
+ expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
+ active: false
+ });
+ });
+ });
+
+ describe('Divider', () => {
+ it('should have a vertical divider', () => {
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.directive(MatDivider)).componentInstance.vertical).toBeTrue();
+ });
+ });
+
+ describe('Leaving button', () => {
+ let button: DebugElement;
+
+ beforeEach(() => {
+ button = fixture.debugElement.query(By.directive(MatIconButton));
+ });
+
+ it('should have correct title', () => {
+ expect(button.nativeElement.title).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_INPUT');
+ });
+
+ it('should contain close icon', () => {
+ expect(button.query(By.directive(MatIcon)).nativeElement.textContent).toBe('close');
+ });
+
+ it('should call updateSearchAiInputState on SearchAiService when clicked', () => {
+ spyOn(searchAiService, 'updateSearchAiInputState');
+ button.nativeElement.click();
+
+ expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
+ active: false
+ });
+ });
+
+ it('should call navigateToPreviousRoute on SearchAiNavigationService when clicked', () => {
+ const searchNavigationService = TestBed.inject(SearchAiNavigationService);
+ spyOn(searchNavigationService, 'navigateToPreviousRoute');
+ button.nativeElement.click();
+
+ expect(searchNavigationService.navigateToPreviousRoute).toHaveBeenCalled();
+ });
+ });
+
+ describe('Navigation', () => {
+ it('should call updateSearchAiInputState on SearchAiService when navigation starts', () => {
+ spyOn(searchAiService, 'updateSearchAiInputState');
+ routingEvents$.next(new NavigationStart(1, ''));
+
+ expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
+ active: false
+ });
+ });
+
+ it('should not call updateSearchAiInputState on SearchAiService when there is different event than navigation starts', () => {
+ spyOn(searchAiService, 'updateSearchAiInputState');
+ routingEvents$.next(new NavigationEnd(1, '', ''));
+
+ expect(searchAiService.updateSearchAiInputState).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.ts
new file mode 100644
index 000000000..af5fa2a16
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.ts
@@ -0,0 +1,81 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
+import { MatDividerModule } from '@angular/material/divider';
+import { SearchAiNavigationService } from '../../../../services/search-ai-navigation.service';
+import { NavigationStart, Router } from '@angular/router';
+import { filter, takeUntil } from 'rxjs/operators';
+import { SearchAiService } from '@alfresco/adf-content-services';
+import { TranslateModule } from '@ngx-translate/core';
+import { Subject } from 'rxjs';
+
+@Component({
+ standalone: true,
+ imports: [SearchAiInputComponent, MatIconModule, MatDividerModule, MatButtonModule, TranslateModule],
+ selector: 'aca-search-ai-input-container',
+ templateUrl: './search-ai-input-container.component.html',
+ styleUrls: ['./search-ai-input-container.component.scss'],
+ encapsulation: ViewEncapsulation.None
+})
+export class SearchAiInputContainerComponent implements OnInit, OnDestroy {
+ @Input()
+ placeholder = 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.DEFAULT_PLACEHOLDER';
+ @Input()
+ agentId: string;
+ @Input()
+ useStoredNodes: boolean;
+
+ private onDestroy$ = new Subject();
+
+ constructor(private searchAiService: SearchAiService, private searchNavigationService: SearchAiNavigationService, private router: Router) {}
+
+ ngOnInit(): void {
+ this.router.events
+ .pipe(
+ filter((event) => event instanceof NavigationStart),
+ takeUntil(this.onDestroy$)
+ )
+ .subscribe(() => this.hideSearchInput());
+ }
+
+ ngOnDestroy(): void {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ }
+
+ hideSearchInput(): void {
+ this.searchAiService.updateSearchAiInputState({
+ active: false
+ });
+ }
+
+ leaveSearchInput(): void {
+ this.searchNavigationService.navigateToPreviousRoute();
+ this.hideSearchInput();
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.html
new file mode 100644
index 000000000..b0c5a0163
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.html
@@ -0,0 +1,63 @@
+
+
+
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.scss
new file mode 100644
index 000000000..0e464ac42
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.scss
@@ -0,0 +1,183 @@
+@import '@alfresco/adf-core/lib/styles/mat-selectors';
+
+aca-search-ai-input {
+ width: 100%;
+ display: flex;
+ align-items: center;
+
+ .aca-search-ai-input-text {
+ margin-top: 4px;
+ flex: 1;
+ font-size: 20px;
+ margin-right: 167px;
+ border: none;
+ outline: none;
+
+ &:focus {
+ &::placeholder {
+ color: var(--theme-primary-color);
+ }
+ }
+ }
+
+ .aca-search-ai-asking-button {
+ display: flex;
+ align-items: center;
+ padding-left: 0;
+ padding-right: 12px;
+ height: 32px;
+ border-radius: 6px;
+ width: 92px;
+ font-weight: 600;
+
+ &-label {
+ vertical-align: super;
+ }
+
+ adf-icon {
+ margin-bottom: 3px;
+ margin-right: 7px;
+
+ svg {
+ width: 34px;
+ height: 34px;
+ margin-left: -6px;
+ margin-top: -4px;
+ }
+ }
+ }
+
+ .aca-search-ai-input-agent-select {
+ width: 149px;
+ height: 35px;
+ align-content: center;
+ border-radius: 16px;
+ padding-left: 3px;
+ padding-right: 10px;
+ background-color: var(--theme-grey-text-background-color);
+ color: var(--theme-text-light-color);
+ font-size: 15px;
+ margin-right: 26px;
+
+ #{$mat-select-trigger} {
+ height: auto;
+ margin-top: 4px;
+ }
+
+ &:focus {
+ outline: -webkit-focus-ring-color auto 1px;
+ }
+
+ &-displayed-value {
+ display: flex;
+ align-items: center;
+
+ &-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ adf-avatar {
+ margin-left: 2px;
+ margin-right: 6px;
+ padding-top: 1px;
+ padding-bottom: 3px;
+
+ .adf-avatar__image {
+ cursor: pointer;
+ }
+ }
+ }
+}
+
+.aca-search-ai-input-agent-select-options.aca-search-ai-input-agent-select-agents#{$mat-select-panel} {
+ margin-top: 9px;
+
+ .aca-search-ai-input-agent-select-options-option {
+ padding-left: 11px;
+ padding-right: 11px;
+
+ &-content {
+ display: flex;
+ align-items: center;
+ padding-top: 1px;
+ padding-bottom: 1px;
+
+ &-text {
+ width: 120px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ adf-avatar {
+ margin-right: 12px;
+ padding-left: 1px;
+
+ .adf-avatar__image {
+ cursor: pointer;
+ }
+ }
+ }
+}
+
+.aca-search-ai-input-agent-container {
+ position: relative;
+
+ .aca-search-ai-input-agent-popup-hover-card {
+ display: none;
+ position: absolute;
+ left: 0;
+ z-index: 1;
+
+ &-container {
+ width: 315px;
+ height: fit-content;
+ border-radius: 12px;
+ margin-top: 4px;
+
+ &-title {
+ display: flex;
+ align-items: center;
+ font-size: 20px;
+ font-weight: 700;
+ padding: 16px 16px 8px;
+ gap: 4px;
+
+ &-name {
+ margin: 0 12px;
+ }
+
+ img {
+ height: 50px;
+ width: 50px;
+ min-width: 50px;
+ min-height: 50px;
+ }
+
+ span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-right: 14px;
+ }
+ }
+
+ &-content {
+ display: flex;
+ color: var(--theme-content-color);
+ text-align: justify;
+ text-justify: inter-word;
+ }
+ }
+ }
+
+ &:hover {
+ .aca-search-ai-input-agent-popup-hover-card {
+ display: block;
+ }
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts
new file mode 100644
index 000000000..7434043a7
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts
@@ -0,0 +1,431 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { SearchAiInputComponent } from './search-ai-input.component';
+import { MatSelect, MatSelectModule } from '@angular/material/select';
+import { By } from '@angular/platform-browser';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { AgentService, ContentTestingModule, SearchAiService } from '@alfresco/adf-content-services';
+import { getAppSelection, SearchByTermAiAction } from '@alfresco/aca-shared/store';
+import { of, Subject } from 'rxjs';
+import { Agent, NodeEntry } from '@alfresco/js-api';
+import { FormControlDirective } from '@angular/forms';
+import { DebugElement } from '@angular/core';
+import { AvatarComponent, IconComponent, NotificationService, UnsavedChangesDialogComponent, UserPreferencesService } from '@alfresco/adf-core';
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { MatSelectHarness } from '@angular/material/select/testing';
+import { MatOptionHarness } from '@angular/material/core/testing';
+import { MatInput } from '@angular/material/input';
+import { MatButton } from '@angular/material/button';
+import { MatInputHarness } from '@angular/material/input/testing';
+import { SelectionState } from '@alfresco/adf-extensions';
+import { MatSnackBarRef } from '@angular/material/snack-bar';
+import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog';
+import { ActivatedRoute } from '@angular/router';
+import { ModalAiService } from '../../../../services/modal-ai.service';
+
+const agentList: Agent[] = [
+ {
+ id: '1',
+ name: 'HR Agent',
+ description: 'Test 1',
+ avatarUrl: undefined
+ },
+ {
+ id: '2',
+ name: 'Policy Agent',
+ description: 'Test 2',
+ avatarUrl: undefined
+ }
+];
+
+describe('SearchAiInputComponent', () => {
+ let component: SearchAiInputComponent;
+ let fixture: ComponentFixture;
+ let loader: HarnessLoader;
+ let selectionState: SelectionState;
+ let store: MockStore;
+ let agents$: Subject;
+ let dialog: MatDialog;
+
+ const prepareBeforeTest = (): void => {
+ selectionState = {
+ nodes: [],
+ isEmpty: true,
+ count: 0,
+ libraries: []
+ };
+ store.overrideSelector(getAppSelection, selectionState);
+ component.agentId = '2';
+ component.avatarsMocked = false;
+ component.ngOnInit();
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [SearchAiInputComponent, ContentTestingModule, MatSelectModule],
+ providers: [
+ provideMockStore(),
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ queryParams: { query: 'some query' }
+ }
+ }
+ }
+ ]
+ });
+
+ fixture = TestBed.createComponent(SearchAiInputComponent);
+ component = fixture.componentInstance;
+ store = TestBed.inject(MockStore);
+ loader = TestbedHarnessEnvironment.loader(fixture);
+ agents$ = new Subject();
+ dialog = TestBed.inject(MatDialog);
+ spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(agents$);
+ prepareBeforeTest();
+ });
+
+ afterEach(() => {
+ store.resetSelectors();
+ });
+
+ describe('Agent select box', () => {
+ let selectElement: DebugElement;
+ let notificationServiceSpy: jasmine.Spy<(message: string) => MatSnackBarRef>;
+
+ beforeEach(() => {
+ selectElement = fixture.debugElement.query(By.directive(MatSelect));
+ const notificationService = TestBed.inject(NotificationService);
+ notificationServiceSpy = spyOn(notificationService, 'showError').and.callThrough();
+ });
+
+ it('should have assigned formControl', () => {
+ expect(selectElement.injector.get(FormControlDirective).form).toBe(component.agentControl);
+ });
+
+ it('should have hidden single selection indicator', () => {
+ expect(selectElement.componentInstance.hideSingleSelectionIndicator).toBeTrue();
+ });
+
+ it('should get agents on init', () => {
+ agents$.next(agentList);
+ component.ngOnInit();
+ expect(component.agents).toEqual(agentList);
+ expect(component.initialsByAgentId).toEqual({ 1: 'HA', 2: 'PA' });
+ expect(notificationServiceSpy).not.toHaveBeenCalled();
+ });
+
+ it('should show notification on getAgents error', () => {
+ agents$.error('error');
+ component.ngOnInit();
+
+ expect(component.agents).toEqual([]);
+ expect(component.initialsByAgentId).toEqual({});
+ expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING');
+ });
+
+ it('should have selected correct agent', async () => {
+ agents$.next(agentList);
+ expect(await (await loader.getHarness(MatSelectHarness)).getValueText()).toBe('PAPolicy Agent');
+ const avatar = selectElement.query(By.directive(AvatarComponent))?.componentInstance;
+ expect(avatar.initials).toBe('PA');
+ expect(avatar.size).toBe('26px');
+ });
+
+ describe('Agents options', () => {
+ let options: MatOptionHarness[];
+
+ const getAvatarForAgent = (agentId: string): AvatarComponent =>
+ fixture.debugElement.query(By.css(`[data-automation-id=aca-search-ai-input-agent-${agentId}]`)).query(By.directive(AvatarComponent))
+ .componentInstance;
+
+ beforeEach(async () => {
+ agents$.next(agentList);
+ const selectHarness = await loader.getHarness(MatSelectHarness);
+ await selectHarness.open();
+ options = await selectHarness.getOptions();
+ });
+
+ it('should have correct number of agents', () => {
+ expect(options.length).toBe(2);
+ });
+
+ it('should have correct agent names', async () => {
+ expect(await options[0].getText()).toBe('HAHR Agent');
+ expect(await options[1].getText()).toBe('PAPolicy Agent');
+ });
+
+ it('should display avatar for each agent', () => {
+ expect(getAvatarForAgent('1')).toBeTruthy();
+ expect(getAvatarForAgent('2')).toBeTruthy();
+ });
+
+ it('should have correct initials for avatars for each of agent', () => {
+ expect(getAvatarForAgent('1').initials).toBe('HA');
+ expect(getAvatarForAgent('2').initials).toBe('PA');
+ });
+
+ it('should assign correct initials to each avatar for each agent with single section name', () => {
+ const newAgentList = [
+ { ...agentList[0], name: 'Adam' },
+ { ...agentList[1], name: 'Bob' }
+ ];
+ agents$.next(newAgentList);
+ fixture.detectChanges();
+
+ expect(getAvatarForAgent('1').initials).toBe('A');
+ expect(getAvatarForAgent('2').initials).toBe('B');
+ });
+ });
+ });
+
+ describe('Query input', () => {
+ let queryInput: DebugElement;
+
+ beforeEach(() => {
+ queryInput = fixture.debugElement.query(By.directive(MatInput));
+ agents$.next(agentList);
+ });
+
+ it('should have assigned formControl', () => {
+ fixture.detectChanges();
+
+ expect(queryInput.injector.get(FormControlDirective).form).toBe(component.queryControl);
+ });
+
+ it('should have assigned correct placeholder', () => {
+ component.placeholder = 'Please ask your question with as much detail as possible...';
+
+ expect(queryInput.componentInstance.placeholder).toBe(component.placeholder);
+ });
+
+ testSubmitting(false);
+ });
+
+ describe('Submit button', () => {
+ let submitButton: DebugElement;
+ let queryInput: MatInputHarness;
+
+ beforeEach(async () => {
+ submitButton = fixture.debugElement.query(By.directive(MatButton));
+ queryInput = await loader.getHarness(MatInputHarness);
+ agents$.next(agentList);
+ });
+
+ it('should be disabled by default', () => {
+ expect(submitButton.nativeElement.disabled).toBeTrue();
+ });
+
+ it('should be enabled if query input is filled', async () => {
+ await queryInput.setValue('Some question');
+
+ expect(submitButton.nativeElement.disabled).toBeFalse();
+ });
+
+ it('should be disabled if query input was filled but after that it was emptied', async () => {
+ await queryInput.setValue('Some question');
+ await queryInput.setValue('');
+
+ expect(submitButton.nativeElement.disabled).toBeTrue();
+ });
+
+ it('should contain stars icon', () => {
+ expect(submitButton.query(By.directive(IconComponent)).componentInstance.value).toBe('adf:three_magic_stars_ai');
+ });
+
+ it('should have correct label', () => {
+ expect(submitButton.nativeElement.textContent.trim()).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.ASK_BUTTON_LABEL');
+ });
+
+ testSubmitting();
+ });
+
+ function testSubmitting(useButton = true) {
+ describe('Submitting', () => {
+ let checkSearchAvailabilitySpy: jasmine.Spy<(selectedNodesState: SelectionState, maxSelectedNodes?: number) => string>;
+ let notificationService: NotificationService;
+ let userPreferencesService: UserPreferencesService;
+ let submitButton: DebugElement;
+ let queryInput: MatInputHarness;
+ let submittingTrigger: () => void;
+ const query = 'some query';
+ let dialogOpenSpy: jasmine.Spy<(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef>;
+ let modalAiService: ModalAiService;
+
+ beforeEach(async () => {
+ prepareBeforeTest();
+
+ modalAiService = TestBed.inject(ModalAiService);
+ checkSearchAvailabilitySpy = spyOn(TestBed.inject(SearchAiService), 'checkSearchAvailability');
+ notificationService = TestBed.inject(NotificationService);
+ userPreferencesService = TestBed.inject(UserPreferencesService);
+ spyOn(userPreferencesService, 'set');
+ spyOn(notificationService, 'showError');
+ queryInput = await loader.getHarness(MatInputHarness);
+ submitButton = fixture.debugElement.query(By.directive(MatButton));
+ await queryInput.setValue(query);
+ const inputElement = fixture.debugElement.query(By.directive(MatInput)).nativeElement;
+ dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({
+ afterClosed: () => of(true)
+ } as MatDialogRef);
+ submittingTrigger = useButton
+ ? () => submitButton.nativeElement.click()
+ : () =>
+ inputElement.dispatchEvent(
+ new KeyboardEvent('keyup', {
+ key: 'Enter'
+ })
+ );
+ });
+
+ it('should call showError on NotificationService if checkSearchAvailability from SearchAiService returns message', () => {
+ const message = 'Some message';
+ checkSearchAvailabilitySpy.and.returnValue(message);
+ submittingTrigger();
+
+ expect(notificationService.showError).toHaveBeenCalledWith(message);
+ });
+
+ it('should not call showError on NotificationService if checkSearchAvailability from SearchAiService returns empty message', () => {
+ checkSearchAvailabilitySpy.and.returnValue('');
+ submittingTrigger();
+
+ expect(notificationService.showError).not.toHaveBeenCalled();
+ });
+
+ it('should call checkSearchAvailability on SearchAiService with parameter based on value returned by store', () => {
+ submittingTrigger();
+
+ expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(selectionState);
+ });
+
+ it('should call checkSearchAvailability on SearchAiService with parameter based on value returned by UserPreferencesService', () => {
+ component.useStoredNodes = true;
+ const newSelectionState: SelectionState = {
+ ...selectionState,
+ file: {
+ entry: {
+ id: 'some-id'
+ }
+ } as NodeEntry
+ };
+ spyOn(userPreferencesService, 'get').and.returnValue(JSON.stringify(newSelectionState));
+ component.ngOnInit();
+ submittingTrigger();
+
+ expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(newSelectionState);
+ expect(userPreferencesService.get).toHaveBeenCalledWith('knowledgeRetrievalNodes');
+ });
+
+ it('should call set on UserPreferencesService with parameter based on value returned by store', () => {
+ submittingTrigger();
+
+ expect(userPreferencesService.set).toHaveBeenCalledWith('knowledgeRetrievalNodes', JSON.stringify(selectionState));
+ });
+
+ it('should call set on UserPreferencesService with parameter based on value returned by UserPreferencesService', () => {
+ component.useStoredNodes = true;
+ const newSelectionState: SelectionState = {
+ ...selectionState,
+ file: {
+ entry: {
+ id: 'some-id'
+ }
+ } as NodeEntry
+ };
+ spyOn(userPreferencesService, 'get').and.returnValue(JSON.stringify(newSelectionState));
+ component.ngOnInit();
+ submittingTrigger();
+
+ expect(userPreferencesService.get).toHaveBeenCalledWith('knowledgeRetrievalNodes');
+ expect(userPreferencesService.set).toHaveBeenCalledWith('knowledgeRetrievalNodes', JSON.stringify(newSelectionState));
+ });
+
+ it('should call dispatch on store with correct parameter', () => {
+ spyOn(store, 'dispatch');
+ submittingTrigger();
+
+ expect(store.dispatch).toHaveBeenCalledOnceWith(
+ new SearchByTermAiAction({
+ searchTerm: query,
+ agentId: component.agentId
+ })
+ );
+ });
+
+ it('should call dispatch on store with correct parameter if selected agent was changed', async () => {
+ spyOn(store, 'dispatch');
+ await (
+ await loader.getHarness(MatSelectHarness)
+ ).clickOptions({
+ text: 'HAHR Agent'
+ });
+ submittingTrigger();
+
+ expect(store.dispatch).toHaveBeenCalledOnceWith(
+ new SearchByTermAiAction({
+ searchTerm: query,
+ agentId: '1'
+ })
+ );
+ });
+
+ it('should reset query input', () => {
+ spyOn(component.queryControl, 'reset');
+ submittingTrigger();
+
+ expect(component.queryControl.reset).toHaveBeenCalled();
+ });
+
+ it('should emit searchSubmitted event', () => {
+ spyOn(component.searchSubmitted, 'emit');
+ submittingTrigger();
+
+ expect(component.searchSubmitted.emit).toHaveBeenCalled();
+ });
+
+ it('should call open modal if there was a previous search phrase in url', () => {
+ submittingTrigger();
+
+ expect(dialogOpenSpy).toHaveBeenCalled();
+ });
+
+ it('should open Unsaved Changes Modal and run callback successfully', () => {
+ const modalAiSpy = spyOn(modalAiService, 'openUnsavedChangesModal').and.callThrough();
+ spyOn(component.searchSubmitted, 'emit');
+
+ fixture.detectChanges();
+
+ submittingTrigger();
+ expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
+ expect(component.searchSubmitted.emit).toHaveBeenCalled();
+ });
+ });
+ }
+});
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts
new file mode 100644
index 000000000..c5cc14624
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts
@@ -0,0 +1,187 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewEncapsulation } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { A11yModule } from '@angular/cdk/a11y';
+import { AvatarComponent, IconComponent, NotificationService, UserPreferencesService } from '@alfresco/adf-core';
+import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { Subject } from 'rxjs';
+import { Store } from '@ngrx/store';
+import { AiSearchByTermPayload, AppStore, getAppSelection, SearchByTermAiAction } from '@alfresco/aca-shared/store';
+import { takeUntil } from 'rxjs/operators';
+import { SelectionState } from '@alfresco/adf-extensions';
+import { MatSelectModule } from '@angular/material/select';
+import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
+import { MatCardModule } from '@angular/material/card';
+import {
+ MAT_TOOLTIP_DEFAULT_OPTIONS,
+ MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY,
+ MatTooltipDefaultOptions,
+ MatTooltipModule
+} from '@angular/material/tooltip';
+import { ModalAiService } from '../../../../services/modal-ai.service';
+import { Agent } from '@alfresco/js-api';
+import { getAgentsWithMockedAvatars } from '../search-ai-utils';
+
+const MatTooltipOptions: MatTooltipDefaultOptions = {
+ ...MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY(),
+ disableTooltipInteractivity: true
+};
+
+@Component({
+ standalone: true,
+ imports: [
+ CommonModule,
+ TranslateModule,
+ MatButtonModule,
+ MatIconModule,
+ MatFormFieldModule,
+ MatInputModule,
+ A11yModule,
+ FormsModule,
+ ReactiveFormsModule,
+ MatSelectModule,
+ IconComponent,
+ AvatarComponent,
+ MatCardModule,
+ MatTooltipModule
+ ],
+ selector: 'aca-search-ai-input',
+ templateUrl: './search-ai-input.component.html',
+ styleUrls: ['./search-ai-input.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ providers: [{ provide: MAT_TOOLTIP_DEFAULT_OPTIONS, useValue: MatTooltipOptions }]
+})
+export class SearchAiInputComponent implements OnInit, OnDestroy {
+ @Input()
+ placeholder: string;
+
+ @Input()
+ agentId: string;
+
+ @Input()
+ useStoredNodes: boolean;
+
+ @Output()
+ searchSubmitted = new EventEmitter();
+
+ private readonly storedNodesKey = 'knowledgeRetrievalNodes';
+
+ private _agentControl = new FormControl(null);
+ private _agents: Agent[] = [];
+ private onDestroy$ = new Subject();
+ private selectedNodesState: SelectionState;
+ private _queryControl = new FormControl('');
+ private _initialsByAgentId: { [key: string]: string } = {};
+
+ avatarsMocked = true;
+
+ get agentControl(): FormControl {
+ return this._agentControl;
+ }
+
+ get agents(): Agent[] {
+ return this._agents;
+ }
+
+ get queryControl(): FormControl {
+ return this._queryControl;
+ }
+
+ get initialsByAgentId(): { [key: string]: string } {
+ return this._initialsByAgentId;
+ }
+
+ constructor(
+ private store: Store,
+ private searchAiService: SearchAiService,
+ private notificationService: NotificationService,
+ private agentService: AgentService,
+ private userPreferencesService: UserPreferencesService,
+ private translateService: TranslateService,
+ private modalAiService: ModalAiService
+ ) {}
+
+ ngOnInit(): void {
+ if (!this.useStoredNodes) {
+ this.store
+ .select(getAppSelection)
+ .pipe(takeUntil(this.onDestroy$))
+ .subscribe((selection) => {
+ this.selectedNodesState = selection;
+ });
+ } else {
+ this.selectedNodesState = JSON.parse(this.userPreferencesService.get(this.storedNodesKey));
+ }
+
+ this.agentService
+ .getAgents()
+ .pipe(takeUntil(this.onDestroy$))
+ .subscribe(
+ (agents) => {
+ // TODO remove mocked avatar images after backend is done (https://hyland.atlassian.net/browse/ACS-8769)
+ this._agents = getAgentsWithMockedAvatars(agents, this.avatarsMocked);
+
+ this.agentControl.setValue(this._agents.find((agent) => agent.id === this.agentId));
+ this._initialsByAgentId = this.agents.reduce((initials, agent) => {
+ const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g));
+ initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`;
+ return initials;
+ }, {});
+ },
+ () => this.notificationService.showError(this.translateService.instant('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING'))
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ }
+
+ onSearchSubmit() {
+ this.modalAiService.openUnsavedChangesModal(() => this.search());
+ }
+
+ search() {
+ const error = this.searchAiService.checkSearchAvailability(this.selectedNodesState);
+ if (error) {
+ this.notificationService.showError(error);
+ } else {
+ const payload: AiSearchByTermPayload = {
+ searchTerm: this.queryControl.value,
+ agentId: this.agentControl.value.id
+ };
+ this.userPreferencesService.set(this.storedNodesKey, JSON.stringify(this.selectedNodesState));
+ this.store.dispatch(new SearchByTermAiAction(payload));
+ this.queryControl.reset();
+ this.searchSubmitted.emit();
+ }
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.html
new file mode 100644
index 000000000..617e37afc
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.html
@@ -0,0 +1,116 @@
+
+
+
+
+
+ {{ searchQuery }}
+
+
+
+
+
+ {{ queryAnswer?.answer }}
+
+
+
+
+
+
+
+
+
+ {{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }}
+
+
+
+
+
+
+ {{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.scss
new file mode 100644
index 000000000..5a8ff684b
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.scss
@@ -0,0 +1,182 @@
+@import '@alfresco/adf-core/lib/styles/mat-selectors';
+
+.aca-search-ai-results {
+ aca-page-layout {
+ .aca-page-layout-content {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+ background-color: white;
+ border-top: 1px solid var(--theme-grey-background-color);
+ padding-top: 28px;
+
+ .aca-search-ai-results-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow-y: auto;
+ padding-right: 24%;
+ padding-left: 24%;
+ min-width: 51%;
+
+ &-query {
+ border-radius: 12px;
+ padding: 20px 15px 19px;
+ background: var(--theme-card-background-grey-color);
+ }
+ }
+
+ .aca-search-ai-response-container {
+ padding: 18px 20px;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--adf-card-view-border-color);
+ border-radius: 12px;
+ margin: 16px 0 75px;
+
+ &-references-container-header {
+ padding-left: 8px;
+ }
+
+ .adf-skeleton {
+ position: relative;
+ background-image: linear-gradient(
+ to left,
+ var(--theme-light-grey-1-color) 0%,
+ var(--theme-light-grey-2-color) 20%,
+ var(--theme-light-grey-3-color) 40%,
+ var(--theme-light-grey-1-color) 100%
+ );
+ background-size: 200%;
+ display: inline-block;
+ height: 1em;
+ overflow: hidden;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ border-radius: 0.25rem;
+
+ &-half {
+ width: 50%;
+ margin-bottom: 8px;
+ }
+
+ &::after {
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background-image: linear-gradient(90deg, rgba(white, 0) 0, rgba(white, 0.2) 20%, rgba(white, 0.5) 60%, rgba(white, 0));
+ animation: shimmer 2s infinite;
+ content: '';
+ }
+
+ @keyframes shimmer {
+ 100% {
+ transform: translateX(100%);
+ }
+ }
+ }
+
+ &-error {
+ border-color: var(--adf-error-color);
+ padding: 32px 18px;
+
+ &-message {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ &-regeneration-button {
+ background-color: var(--adf-secondary-button-background);
+
+ &-icon {
+ font-size: 24px;
+ height: 24px;
+ width: 23px;
+ }
+ }
+ }
+ }
+
+ &-body {
+ &-response {
+ margin-bottom: 17px;
+ overflow-wrap: break-word;
+
+ &-action {
+ width: max-content;
+ padding: 0;
+
+ mat-icon {
+ font-size: 17.25px;
+ }
+
+ &-regeneration {
+ margin-left: 2px;
+ margin-right: 2px;
+ }
+
+ &-thumb-down {
+ margin-left: 4px;
+ }
+
+ #{$mat-button-touch-target} {
+ width: 24px;
+ }
+ }
+ }
+
+ &-divider {
+ margin-top: 9px;
+ }
+
+ &-references-container {
+ padding-right: 8px;
+ padding-left: 8px;
+
+ &-header {
+ margin-top: 16px;
+ color: var(--theme-text-light-color);
+ font-weight: 400;
+ margin-bottom: 3px;
+ }
+
+ &-documents {
+ padding-right: 5px;
+ padding-top: 5px;
+ margin-left: -2px;
+ display: flex;
+ gap: 21px;
+
+ &-document {
+ display: flex;
+ flex-direction: row;
+ padding-top: 7px;
+ padding-bottom: 7px;
+
+ &-icon {
+ padding-right: 11px;
+ }
+
+ &-name {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ justify-content: center;
+ }
+
+ &:hover {
+ text-decoration: underline;
+ text-decoration-color: var(--theme-primary-color);
+ color: var(--theme-primary-color);
+ cursor: pointer;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts
new file mode 100644
index 000000000..ab6def533
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts
@@ -0,0 +1,372 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing';
+import { SearchAiResultsComponent } from './search-ai-results.component';
+import { ActivatedRoute, Params, Router } from '@angular/router';
+import { of, Subject, throwError } from 'rxjs';
+import { MatSnackBarModule } from '@angular/material/snack-bar';
+import { EmptyContentComponent, 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 { ModalAiService } from '../../../../services/modal-ai.service';
+import { delay } from 'rxjs/operators';
+import { AiAnswer, AiAnswerEntry, QuestionModel } 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 { ViewerService } from '@alfresco/aca-content/viewer';
+
+const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: { nodesIds: [] } };
+const aiAnswerMock: AiAnswer = { answer: 'Some answer', questionId: 'some id', references: [] };
+const getAiAnswerEntry = (noAnswer?: boolean): AiAnswerEntry => {
+ return { entry: { answer: noAnswer ? '' : 'Some answer', questionId: 'some id', references: [] } };
+};
+
+describe('SearchAiResultsComponent', () => {
+ const knowledgeRetrievalNodes = '{"isEmpty":"false","nodes":[{"entry":{"id": "someId","isFolder":"true"}}]}';
+ let fixture: ComponentFixture;
+ let component: SearchAiResultsComponent;
+ let userPreferencesService: UserPreferencesService;
+ let mockQueryParams = new Subject();
+ let modalAiService: ModalAiService;
+ let searchAiService: SearchAiService;
+ let store: MockStore;
+ let viewerService: ViewerService;
+
+ afterEach(() => {
+ store.resetSelectors();
+ mockQueryParams = new Subject();
+ fixture.destroy();
+ });
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule],
+ providers: [
+ {
+ provide: NodesApiService,
+ useValue: {
+ getNode: () => of({ id: 'someId', isFolder: true }).pipe(delay(50))
+ }
+ },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParams: mockQueryParams.asObservable(),
+ snapshot: {
+ queryParams: { query: 'testQuery' }
+ }
+ }
+ },
+ provideMockStore()
+ ]
+ });
+
+ fixture = TestBed.createComponent(SearchAiResultsComponent);
+ modalAiService = TestBed.inject(ModalAiService);
+ searchAiService = TestBed.inject(SearchAiService);
+ userPreferencesService = TestBed.inject(UserPreferencesService);
+ viewerService = TestBed.inject(ViewerService);
+ store = TestBed.inject(MockStore);
+ store.overrideSelector(getAppSelection, {
+ nodes: [],
+ isEmpty: true,
+ count: 0,
+ libraries: []
+ });
+ store.overrideSelector(getCurrentFolder, null);
+ spyOn(searchAiService, 'ask').and.returnValue(of(questionMock));
+ spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(of([]));
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ });
+
+ describe('query params change', () => {
+ it('should perform ai search and sets agents on query params change', () => {
+ spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ expect(component.searchQuery).toBe('test');
+ expect(component.agentId).toBe('agentId1');
+ expect(component.hasError).toBeFalse();
+ });
+
+ it('should throw an error if searchQuery not available', () => {
+ spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
+ mockQueryParams.next({ agentId: 'agentId1' });
+
+ expect(component.searchQuery).toBe('');
+ expect(component.agentId).toBe('agentId1');
+ expect(component.hasError).toBeTrue();
+ });
+
+ it('should not throw an error if selectedNodesState nodes not available', () => {
+ spyOn(userPreferencesService, 'get').and.returnValue('{}');
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ expect(component.searchQuery).toBe('test');
+ expect(component.agentId).toBe('agentId1');
+ expect(component.hasError).toBeFalse();
+ });
+
+ it('should throw an error if agentId not available', () => {
+ spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
+ mockQueryParams.next({ query: 'test' });
+
+ expect(component.searchQuery).toBe('test');
+ expect(component.agentId).toBe(undefined);
+ expect(component.hasError).toBeTrue();
+ });
+
+ 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)));
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ tick(30000);
+
+ expect(component.queryAnswer).toBeUndefined();
+ expect(component.hasAnsweringError).toBeTrue();
+ expect(component.loading).toBeFalse();
+ }));
+
+ 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()));
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ tick(3000);
+
+ expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
+ expect(component.hasAnsweringError).toBeFalse();
+ }));
+
+ 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()));
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ tick(50000);
+
+ expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
+ expect(component.hasAnsweringError).toBeFalse();
+ }));
+
+ 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)));
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ tick(30000);
+
+ expect(component.queryAnswer).toBeUndefined();
+ expect(component.hasAnsweringError).toBeTrue();
+ expect(component.loading).toBeFalse();
+ }));
+
+ it('should not display answer and display an error if received AiAnswerPaging without answer ten times', fakeAsync(() => {
+ spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
+ spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(10).fill(of(getAiAnswerEntry(true))));
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ tick(30000);
+
+ expect(component.queryAnswer).toBeUndefined();
+ expect(component.hasAnsweringError).toBeTrue();
+ expect(component.loading).toBeFalse();
+ }));
+
+ it('should not display error and display and answer if received AiAnswerPaging without answer nine times and with answer one time', fakeAsync(() => {
+ spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
+ spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(of(getAiAnswerEntry(true))), of(getAiAnswerEntry()));
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ tick(30000);
+
+ expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
+ expect(component.hasAnsweringError).toBeFalse();
+ }));
+
+ describe('when query params contains location', () => {
+ let params: Params;
+
+ beforeEach(() => {
+ params = {
+ query: 'test',
+ agentId: 'agentId1',
+ location: 'some-location'
+ };
+ });
+
+ it('should not render search ai input container', () => {
+ mockQueryParams.next(params);
+
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.directive(SearchAiInputComponent))).toBeNull();
+ });
+
+ it('should not render empty content', () => {
+ mockQueryParams.next({
+ location: 'some-location'
+ });
+
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.directive(EmptyContentComponent))).toBeNull();
+ });
+
+ it('should not display search query', () => {
+ mockQueryParams.next(params);
+
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-query"]`)).nativeElement.textContent.trim()).toBe('');
+ });
+
+ it('should not call searchAiService.ask', () => {
+ mockQueryParams.next(params);
+
+ fixture.detectChanges();
+ expect(searchAiService.ask).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('skeleton loader', () => {
+ const getSkeletonElementsLength = (): number => {
+ return fixture.nativeElement.querySelectorAll('.adf-skeleton').length;
+ };
+
+ beforeEach(() => {
+ spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
+ });
+
+ it('should display skeleton when loading is true', () => {
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ component.performAiSearch();
+ fixture.detectChanges();
+
+ expect(component.loading).toBeTrue();
+ expect(getSkeletonElementsLength()).toBe(3);
+ });
+
+ it('should not display skeleton when loading is false', fakeAsync(() => {
+ spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
+ mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
+
+ component.performAiSearch();
+ tick(30000);
+
+ expect(component.loading).toBeFalse();
+ expect(getSkeletonElementsLength()).toBe(0);
+ }));
+ });
+
+ describe('Unsaved Changes Modal', () => {
+ beforeEach(() => {
+ spyOn(userPreferencesService, 'get').and.returnValue('true');
+ });
+
+ it('should open Unsaved Changes Modal and run callback successfully', () => {
+ const modalAiSpy = spyOn(modalAiService, 'openUnsavedChangesModal').and.callThrough();
+
+ spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
+
+ fixture.detectChanges();
+
+ fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-regeneration-button"]`)).nativeElement.click();
+ expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
+ expect(component.queryAnswer).toEqual(aiAnswerMock);
+ });
+ });
+
+ describe('References', () => {
+ let documentElement: HTMLDivElement;
+ let nodesOrder: string[];
+
+ const nodeId = 'someId';
+ 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.references = [{ referenceId: nodeId, referenceText: 'some text' }];
+ 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();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new ViewNodeAction(nodeId, {
+ location: url
+ })
+ );
+ });
+
+ it('should dispatch ViewNodeAction on store when pressed enter', () => {
+ documentElement.dispatchEvent(
+ new KeyboardEvent('keyup', {
+ key: 'Enter'
+ })
+ );
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new ViewNodeAction(nodeId, {
+ location: url
+ })
+ );
+ });
+
+ it('should assign nodes ids to customNodesOrder for ViewerService', () => {
+ expect(nodesOrder).toEqual([nodeId]);
+ });
+
+ it('should call set on userPreferencesService with correct parameters', () => {
+ expect(userPreferencesService.set).toHaveBeenCalledWith('aiReferences', JSON.stringify([nodeId]));
+ });
+ });
+
+ describe('ngOnInit', () => {
+ it('should set customNodesOrder on ViewerService', () => {
+ spyOn(userPreferencesService, 'get').and.returnValue('["node1", "node2"]');
+ let nodesOrder: string[];
+ spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder = passedNodesOrder));
+
+ component.ngOnInit();
+
+ expect(nodesOrder).toEqual(['node1', 'node2']);
+ });
+ });
+});
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts
new file mode 100644
index 000000000..882d21a76
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts
@@ -0,0 +1,245 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { PageComponent, PageLayoutComponent, ToolbarActionComponent, ToolbarComponent } from '@alfresco/aca-shared';
+import { concatMap, delay, filter, finalize, retryWhen, skipWhile, switchMap, takeUntil } from 'rxjs/operators';
+import {
+ AvatarComponent,
+ ClipboardService,
+ EmptyContentComponent,
+ ThumbnailService,
+ ToolbarModule,
+ UnsavedChangesGuard,
+ UserPreferencesService
+} 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 { TranslateModule, TranslateService } from '@ngx-translate/core';
+import { NodesApiService } from '@alfresco/adf-content-services';
+import { forkJoin, Observable, of, throwError } from 'rxjs';
+import { SelectionState } from '@alfresco/adf-extensions';
+import { MatIconModule } from '@angular/material/icon';
+import { MatButtonModule } from '@angular/material/button';
+import { MatListModule } from '@angular/material/list';
+import { MatCardModule } from '@angular/material/card';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { ModalAiService } from '../../../../services/modal-ai.service';
+import { ViewNodeAction } from '@alfresco/aca-shared/store';
+import { ViewerService } from '@alfresco/aca-content/viewer';
+
+@Component({
+ standalone: true,
+ imports: [
+ CommonModule,
+ PageLayoutComponent,
+ ToolbarActionComponent,
+ ToolbarModule,
+ ToolbarComponent,
+ SearchAiInputContainerComponent,
+ TranslateModule,
+ MatIconModule,
+ MatButtonModule,
+ MatListModule,
+ EmptyContentComponent,
+ MatCardModule,
+ AvatarComponent,
+ MatTooltipModule
+ ],
+ selector: 'aca-search-ai-results',
+ templateUrl: './search-ai-results.component.html',
+ styleUrls: ['./search-ai-results.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ host: { class: 'aca-search-ai-results' }
+})
+export class SearchAiResultsComponent extends PageComponent implements OnInit, OnDestroy {
+ 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;
+
+ get agentId(): string {
+ return this._agentId;
+ }
+
+ get hasAnsweringError(): boolean {
+ return this._hasAnsweringError;
+ }
+
+ get hasError(): boolean {
+ return this._hasError;
+ }
+
+ get loading(): boolean {
+ return this._loading;
+ }
+
+ get mimeTypeIconsByNodeId(): { [key: string]: string } {
+ return this._mimeTypeIconsByNodeId;
+ }
+
+ get nodes(): Node[] {
+ return this._nodes;
+ }
+
+ get queryAnswer(): AiAnswer {
+ return this._queryAnswer;
+ }
+
+ get searchQuery(): string {
+ return this._searchQuery;
+ }
+
+ constructor(
+ private route: ActivatedRoute,
+ private clipboardService: ClipboardService,
+ private thumbnailService: ThumbnailService,
+ private nodesApiService: NodesApiService,
+ private userPreferencesService: UserPreferencesService,
+ private translateService: TranslateService,
+ private unsavedChangesGuard: UnsavedChangesGuard,
+ private modalAiService: ModalAiService,
+ private viewerService: ViewerService
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ this.viewerService.customNodesOrder = JSON.parse(this.userPreferencesService.get('aiReferences', '[]'));
+ this.route.queryParams
+ .pipe(
+ filter((params) => {
+ const openedViewerPreviously = this.openedViewer;
+ this.openedViewer = !!params.location;
+ return !this.openedViewer && (!openedViewerPreviously || !this.queryAnswer);
+ }),
+ takeUntil(this.onDestroy$)
+ )
+ .subscribe((params) => {
+ this._agentId = params.agentId;
+ this._searchQuery = params.query ? decodeURIComponent(params.query) : '';
+ if (!this.searchQuery || !this.agentId) {
+ this._hasError = true;
+ return;
+ }
+ this._selectedNodesState = JSON.parse(this.userPreferencesService.get('knowledgeRetrievalNodes'));
+ this.performAiSearch();
+ });
+ super.ngOnInit();
+
+ this.unsavedChangesGuard.unsaved = this.route.snapshot?.queryParams?.query?.length > 0;
+ this.unsavedChangesGuard.data = {
+ descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.CONVERSATION_DISCARDED',
+ confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.OKAY',
+ headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
+ };
+ }
+
+ ngOnDestroy(): void {
+ this.onDestroy$.next();
+ this.onDestroy$.complete();
+ }
+
+ copyResponseToClipboard(): void {
+ this.clipboardService.copyContentToClipboard(
+ this.queryAnswer.answer,
+ this.translateService.instant('KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_MESSAGE')
+ );
+ }
+
+ checkUnsavedChangesAndSearch(): void {
+ this.modalAiService.openUnsavedChangesModal(() => this.performAiSearch());
+ }
+
+ performAiSearch(): void {
+ this._loading = true;
+
+ this.searchAiService
+ .ask({
+ question: this.searchQuery,
+ nodeIds: this._selectedNodesState?.nodes?.length ? this._selectedNodesState.nodes.map((node) => node.entry.id) : [],
+ agentId: this._agentId
+ })
+ .pipe(
+ switchMap((response) => this.searchAiService.getAnswer(response.questionId)),
+ switchMap((response) => {
+ if (!response.entry?.answer) {
+ return throwError((e) => e);
+ }
+ this._queryAnswer = response.entry;
+ return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId)));
+ }),
+ retryWhen((errors: Observable) => this.aiSearchRetryWhen(errors)),
+ finalize(() => (this._loading = false)),
+ takeUntil(this.onDestroy$)
+ )
+ .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)
+ );
+ }
+
+ openFile(id: string): void {
+ this.store.dispatch(
+ new ViewNodeAction(id, {
+ location: this.router.url
+ })
+ );
+ }
+
+ private aiSearchRetryWhen(errors: Observable): Observable {
+ 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);
+ })
+ );
+ }
+}
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-utils.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-utils.ts
new file mode 100644
index 000000000..9f76d2e74
--- /dev/null
+++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-utils.ts
@@ -0,0 +1,35 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { Agent } from '@alfresco/js-api/typings';
+
+export const getAgentsWithMockedAvatars = (agents: Agent[], mocked: boolean) => {
+ if (mocked) {
+ const images = ['assets/images/avatars/Blue.png', 'assets/images/avatars/Gold.png', 'assets/images/avatars/Pink.png'];
+ return agents.map((agent, index) => {
+ return { ...agent, avatarUrl: images[index > 2 ? 2 : index] };
+ });
+ }
+ return agents;
+};
diff --git a/projects/aca-content/src/lib/components/recent-files/recent-files.component.html b/projects/aca-content/src/lib/components/recent-files/recent-files.component.html
index 80cfa618b..2a2a84fb8 100644
--- a/projects/aca-content/src/lib/components/recent-files/recent-files.component.html
+++ b/projects/aca-content/src/lib/components/recent-files/recent-files.component.html
@@ -1,9 +1,17 @@
diff --git a/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts b/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts
index 8db29b52c..c7ef0377a 100644
--- a/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts
+++ b/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts
@@ -39,6 +39,7 @@ import { DocumentListModule } from '@alfresco/adf-content-services';
import { DataTableModule, EmptyContentComponent, PaginationComponent } from '@alfresco/adf-core';
import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslateModule } from '@ngx-translate/core';
+import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@@ -54,11 +55,13 @@ import { TranslateModule } from '@ngx-translate/core';
PageLayoutComponent,
TranslateModule,
ToolbarComponent,
+ SearchAiInputContainerComponent,
EmptyContentComponent,
DynamicColumnComponent
],
templateUrl: './recent-files.component.html',
- encapsulation: ViewEncapsulation.None
+ encapsulation: ViewEncapsulation.None,
+ selector: 'aca-recent-files'
})
export class RecentFilesComponent extends PageComponent implements OnInit {
columns: DocumentListPresetRef[] = [];
diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.html b/projects/aca-content/src/lib/components/search/search-results/search-results.component.html
index 9274f94fd..d2b32cba2 100644
--- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.html
+++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.html
@@ -1,9 +1,15 @@
-
+
diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss b/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss
index 4b42cbfc1..112adcd66 100644
--- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss
+++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss
@@ -1,6 +1,13 @@
@import '../../../ui/mixins';
aca-search-results {
+ .aca-search-results-active-search-ai-input {
+ .aca-header-container,
+ .adf-search-results__content-header.aca-content {
+ display: none;
+ }
+ }
+
.aca-search-toolbar-spacer {
width: 100%;
}
diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts
index 2ddf74b44..0598ca69a 100644
--- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts
+++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts
@@ -77,6 +77,7 @@ import { MatIconModule } from '@angular/material/icon';
import { SearchResultsRowComponent } from '../search-results-row/search-results-row.component';
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component';
+import { SearchAiInputContainerComponent } from '../../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@@ -110,7 +111,8 @@ import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-a
DateColumnHeaderComponent,
CustomEmptyContentTemplateDirective,
ViewerToolbarComponent,
- BulkActionsDropdownComponent
+ BulkActionsDropdownComponent,
+ SearchAiInputContainerComponent
],
selector: 'aca-search-results',
templateUrl: './search-results.component.html',
diff --git a/projects/aca-content/src/lib/components/shared-files/shared-files.component.html b/projects/aca-content/src/lib/components/shared-files/shared-files.component.html
index e8f446754..33e6fe8b0 100644
--- a/projects/aca-content/src/lib/components/shared-files/shared-files.component.html
+++ b/projects/aca-content/src/lib/components/shared-files/shared-files.component.html
@@ -1,10 +1,18 @@
diff --git a/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts b/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts
index 3c8793aad..d84d0e36f 100644
--- a/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts
+++ b/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts
@@ -40,6 +40,7 @@ import { DocumentListModule } from '@alfresco/adf-content-services';
import { DataTableModule, EmptyContentComponent, PaginationComponent } from '@alfresco/adf-core';
import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslateModule } from '@ngx-translate/core';
+import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@@ -55,11 +56,13 @@ import { TranslateModule } from '@ngx-translate/core';
PageLayoutComponent,
TranslateModule,
ToolbarComponent,
+ SearchAiInputContainerComponent,
EmptyContentComponent,
DynamicColumnComponent
],
templateUrl: './shared-files.component.html',
- encapsulation: ViewEncapsulation.None
+ encapsulation: ViewEncapsulation.None,
+ selector: 'aca-shared-files'
})
export class SharedFilesComponent extends PageComponent implements OnInit {
columns: DocumentListPresetRef[] = [];
diff --git a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts
index 230387591..10c8a8b4e 100644
--- a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts
+++ b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts
@@ -118,5 +118,25 @@ describe('AcaExpansionPanel', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
+
+ it('should not navigate to first child if none is active route and acaExpansionPanel has canBeInactive property', () => {
+ const router: any = new RouterStub('dummy-route-2');
+ spyOn(router, 'navigate').and.callThrough();
+ const item = {
+ children: [{ url: 'dummy-route-1' }, { url: 'dummy-route-2' }],
+ data: {
+ canBeInactive: true
+ }
+ };
+
+ const directive = new ExpansionPanelDirective(mockStore, router, mockMatExpansionPanel);
+
+ directive.acaExpansionPanel = item;
+ mockMatExpansionPanel.expanded = true;
+
+ directive.onClick();
+
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
});
});
diff --git a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts
index dd4e27405..be559ff4b 100644
--- a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts
+++ b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts
@@ -42,7 +42,7 @@ export class ExpansionPanelDirective implements OnInit, OnDestroy {
@HostListener('click')
onClick() {
- if (this.expansionPanel.expanded && !this.hasActiveLinks()) {
+ if (this.expansionPanel.expanded && !this.hasActiveLinks() && !this.acaExpansionPanel.data?.canBeInactive) {
const firstChild = this.acaExpansionPanel.children[0];
if (firstChild.url) {
this.router.navigate(this.getNavigationCommands(firstChild.url));
diff --git a/projects/aca-content/src/lib/services/modal-ai.service.spec.ts b/projects/aca-content/src/lib/services/modal-ai.service.spec.ts
new file mode 100644
index 000000000..01f1658bc
--- /dev/null
+++ b/projects/aca-content/src/lib/services/modal-ai.service.spec.ts
@@ -0,0 +1,114 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see
.
+ */
+
+import { ModalAiService } from './modal-ai.service';
+import { TestBed } from '@angular/core/testing';
+import { ContentTestingModule } from '@alfresco/adf-content-services';
+import { MatDialog, MatDialogConfig, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { ActivatedRoute, Params } from '@angular/router';
+import { of, Subject } from 'rxjs';
+import { StorageService, UnsavedChangesDialogComponent } from '@alfresco/adf-core';
+
+describe('ModalAiService', () => {
+ const mockQueryParams = new Subject
();
+
+ let service: ModalAiService;
+ let dialogOpenSpy: jasmine.Spy<(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef>;
+ let dialog: MatDialog;
+
+ const setupBeforeEach = (query: string, storageGetItem: string) => {
+ TestBed.configureTestingModule({
+ imports: [ContentTestingModule, MatDialogModule],
+ providers: [
+ {
+ provide: StorageService,
+ useValue: {
+ getItem: () => storageGetItem,
+ setItem: () => storageGetItem
+ }
+ },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParams: mockQueryParams.asObservable(),
+ snapshot: {
+ queryParams: { query }
+ }
+ }
+ }
+ ]
+ });
+
+ dialog = TestBed.inject(MatDialog);
+ dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({
+ afterClosed: () => of(true)
+ } as MatDialogRef);
+ service = TestBed.inject(ModalAiService);
+ };
+
+ describe('when there is no previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in the storage', () => {
+ it('should not open unsaved changes modal when there is not previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in storage', () => {
+ setupBeforeEach('', '');
+ service.openUnsavedChangesModal(() => {});
+
+ expect(dialogOpenSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when there is no previous search and there is UNSAVED_CHANGES_MODAL_HIDDEN in storage', () => {
+ it('should open unsaved changes modal when there is previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in local storage', () => {
+ setupBeforeEach('test', '');
+ service.openUnsavedChangesModal(() => {});
+
+ expect(dialogOpenSpy).toHaveBeenCalledWith(UnsavedChangesDialogComponent, {
+ width: '345px',
+ data: {
+ descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.LOSE_RESPONSE',
+ confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.ASK_AI',
+ checkboxText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DO_NOT_SHOW_MESSAGE',
+ headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
+ }
+ });
+ });
+
+ it('should call callback after modal has been closed and change test value to true', () => {
+ let test = false;
+ const mockFunc = () => {
+ test = true;
+ };
+ setupBeforeEach('test', '');
+ service.openUnsavedChangesModal(mockFunc);
+ expect(test).toBeTrue();
+ });
+ });
+
+ describe('when there is previous search in query and UNSAVED_CHANGES_MODAL_HIDDEN is the storage', () => {
+ it('should not open unsaved changes modal when has previous search and there is UNSAVED_CHANGES_MODAL_HIDDEN in local storage', () => {
+ setupBeforeEach('test', 'true');
+ service.openUnsavedChangesModal(() => {});
+
+ expect(dialogOpenSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/projects/aca-content/src/lib/services/modal-ai.service.ts b/projects/aca-content/src/lib/services/modal-ai.service.ts
new file mode 100644
index 000000000..59649af5c
--- /dev/null
+++ b/projects/aca-content/src/lib/services/modal-ai.service.ts
@@ -0,0 +1,62 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { inject, Injectable } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { AppConfigValues, UnsavedChangesDialogComponent, UserPreferencesService } from '@alfresco/adf-core';
+import { MatDialog } from '@angular/material/dialog';
+
+@Injectable({ providedIn: 'root' })
+export class ModalAiService {
+ private route = inject(ActivatedRoute);
+ private dialog = inject(MatDialog);
+ private userPreferencesService = inject(UserPreferencesService);
+
+ openUnsavedChangesModal(callback: () => void): void {
+ const hasPreviousSearch = this.route.snapshot?.queryParams?.query?.length > 0;
+ const modalHidden = this.userPreferencesService.get(AppConfigValues.UNSAVED_CHANGES_MODAL_HIDDEN) === 'true';
+
+ if (!hasPreviousSearch || modalHidden) {
+ callback();
+ return;
+ }
+
+ this.dialog
+ .open(UnsavedChangesDialogComponent, {
+ width: '345px',
+ data: {
+ descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.LOSE_RESPONSE',
+ confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.ASK_AI',
+ checkboxText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DO_NOT_SHOW_MESSAGE',
+ headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
+ }
+ })
+ .afterClosed()
+ .subscribe((openModal: boolean) => {
+ if (openModal) {
+ callback();
+ }
+ });
+ }
+}
diff --git a/projects/aca-content/src/lib/services/search-ai-navigation.service.spec.ts b/projects/aca-content/src/lib/services/search-ai-navigation.service.spec.ts
new file mode 100644
index 000000000..7da4d1c80
--- /dev/null
+++ b/projects/aca-content/src/lib/services/search-ai-navigation.service.spec.ts
@@ -0,0 +1,134 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { SearchAiNavigationService } from './search-ai-navigation.service';
+import { Params, Router } from '@angular/router';
+import { TestBed } from '@angular/core/testing';
+import { ContentTestingModule } from '@alfresco/adf-content-services';
+
+describe('SearchAiNavigationService', () => {
+ let service: SearchAiNavigationService;
+ let router: Router;
+
+ const knowledgeRetrievalUrl = '/knowledge-retrieval';
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [ContentTestingModule]
+ });
+ service = TestBed.inject(SearchAiNavigationService);
+ router = TestBed.inject(Router);
+ });
+
+ describe('navigateToPreviousRoute', () => {
+ let urlSpy: jasmine.Spy<() => string>;
+ let navigateByUrlSpy: jasmine.Spy<(url: string) => Promise>;
+
+ const sourceUrl = '/some-url';
+ const personalFilesUrl = '/personal-files';
+
+ beforeEach(() => {
+ navigateByUrlSpy = spyOn(router, 'navigateByUrl');
+ urlSpy = spyOnProperty(router, 'url');
+ });
+
+ it('should navigate to personal files if there is not previous route and actual route is knowledge retrieval', () => {
+ urlSpy.and.returnValue(knowledgeRetrievalUrl);
+ service.navigateToPreviousRoute();
+
+ expect(navigateByUrlSpy).toHaveBeenCalledWith(personalFilesUrl);
+ });
+
+ it('should not navigate if there is not previous route and actual route is not knowledge retrieval', () => {
+ urlSpy.and.returnValue('/some-url');
+ service.navigateToPreviousRoute();
+
+ expect(navigateByUrlSpy).not.toHaveBeenCalled();
+ });
+
+ it('should navigate to previous route if there is some previous route and actual route is knowledge retrieval', () => {
+ urlSpy.and.returnValue(sourceUrl);
+ service.navigateToSearchAi({
+ agentId: 'some agent id'
+ });
+ urlSpy.and.returnValue(knowledgeRetrievalUrl);
+ navigateByUrlSpy.calls.reset();
+ service.navigateToPreviousRoute();
+
+ expect(navigateByUrlSpy).toHaveBeenCalledWith(sourceUrl);
+ });
+
+ it('should not navigate to previous route if there is some previous route but actual route is not knowledge retrieval', () => {
+ urlSpy.and.returnValue(sourceUrl);
+ service.navigateToSearchAi({
+ agentId: 'some agent id'
+ });
+ urlSpy.and.returnValue('/some-different-url');
+ navigateByUrlSpy.calls.reset();
+ service.navigateToPreviousRoute();
+
+ expect(navigateByUrlSpy).not.toHaveBeenCalled();
+ });
+
+ it('should navigate to personal files if previous route is knowledge retrieval and actual route is knowledge retrieval', () => {
+ urlSpy.and.returnValue(knowledgeRetrievalUrl);
+ service.navigateToSearchAi({
+ agentId: 'some agent id'
+ });
+ navigateByUrlSpy.calls.reset();
+ service.navigateToPreviousRoute();
+
+ expect(navigateByUrlSpy).toHaveBeenCalledWith(personalFilesUrl);
+ });
+
+ it('should not navigate if previous route is knowledge retrieval and actual route is different than knowledge retrieval', () => {
+ urlSpy.and.returnValue(knowledgeRetrievalUrl);
+ service.navigateToSearchAi({
+ agentId: 'some agent id'
+ });
+ urlSpy.and.returnValue(sourceUrl);
+ navigateByUrlSpy.calls.reset();
+ service.navigateToPreviousRoute();
+
+ expect(navigateByUrlSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('navigateToSearchAi', () => {
+ beforeEach(() => {
+ spyOn(router, 'navigate');
+ });
+
+ it('should navigate to search ai results page', () => {
+ const queryParams: Params = {
+ agentId: 'some agent id'
+ };
+ service.navigateToSearchAi(queryParams);
+
+ expect(router.navigate).toHaveBeenCalledWith([knowledgeRetrievalUrl], {
+ queryParams
+ });
+ });
+ });
+});
diff --git a/projects/aca-content/src/lib/services/search-ai-navigation.service.ts b/projects/aca-content/src/lib/services/search-ai-navigation.service.ts
new file mode 100644
index 000000000..6bc745e3c
--- /dev/null
+++ b/projects/aca-content/src/lib/services/search-ai-navigation.service.ts
@@ -0,0 +1,48 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { Injectable } from '@angular/core';
+import { Params, Router } from '@angular/router';
+
+@Injectable({ providedIn: 'root' })
+export class SearchAiNavigationService {
+ private readonly knowledgeRetrievalRoute = '/knowledge-retrieval';
+
+ private previousRoute = '';
+
+ constructor(private router: Router) {}
+
+ navigateToPreviousRoute(): void {
+ if (this.router.url.includes(this.knowledgeRetrievalRoute)) {
+ void this.router.navigateByUrl(this.previousRoute || '/personal-files');
+ }
+ }
+
+ navigateToSearchAi(queryParams: Params): void {
+ if (!this.router.url.includes(this.knowledgeRetrievalRoute)) {
+ this.previousRoute = this.router.url;
+ }
+ void this.router.navigate([this.knowledgeRetrievalRoute], { queryParams });
+ }
+}
diff --git a/projects/aca-content/src/lib/store/app-store.module.ts b/projects/aca-content/src/lib/store/app-store.module.ts
index 02297d68b..dfeb5d107 100644
--- a/projects/aca-content/src/lib/store/app-store.module.ts
+++ b/projects/aca-content/src/lib/store/app-store.module.ts
@@ -41,6 +41,7 @@ import {
ContextMenuEffects
} from './effects';
import { INITIAL_STATE } from './initial-state';
+import { SearchAiEffects } from './effects/search-ai.effects';
@NgModule({
imports: [
@@ -69,6 +70,8 @@ import { INITIAL_STATE } from './initial-state';
FavoriteEffects,
TemplateEffects,
ContextMenuEffects,
+ SearchAiEffects,
+ ContextMenuEffects,
SnackbarEffects,
RouterEffects
])
diff --git a/projects/aca-content/src/lib/store/effects.ts b/projects/aca-content/src/lib/store/effects.ts
index f8eb84022..423fed8d0 100644
--- a/projects/aca-content/src/lib/store/effects.ts
+++ b/projects/aca-content/src/lib/store/effects.ts
@@ -33,3 +33,4 @@ export * from './effects/upload.effects';
export * from './effects/upload.effects';
export * from './effects/template.effects';
export * from './effects/contextmenu.effects';
+export * from './effects/search-ai.effects';
diff --git a/projects/aca-content/src/lib/store/effects/search-ai.effects.ts b/projects/aca-content/src/lib/store/effects/search-ai.effects.ts
new file mode 100644
index 000000000..4670b8a77
--- /dev/null
+++ b/projects/aca-content/src/lib/store/effects/search-ai.effects.ts
@@ -0,0 +1,65 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { SearchAiActionTypes, SearchByTermAiAction, ToggleAISearchInput } from '@alfresco/aca-shared/store';
+import { map } from 'rxjs/operators';
+import { SearchAiNavigationService } from '../../services/search-ai-navigation.service';
+import { SearchAiService } from '@alfresco/adf-content-services';
+import { Params } from '@angular/router';
+
+@Injectable()
+export class SearchAiEffects {
+ constructor(private actions$: Actions, private searchNavigationService: SearchAiNavigationService, private searchAiService: SearchAiService) {}
+
+ searchByTerm$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(SearchAiActionTypes.SearchByTermAi),
+ map((action) => {
+ const queryParams: Params = {
+ query: encodeURIComponent(action.payload.searchTerm),
+ agentId: action.payload.agentId
+ };
+ this.searchNavigationService.navigateToSearchAi(queryParams);
+ })
+ ),
+ { dispatch: false }
+ );
+
+ toggleAISearchInput$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(SearchAiActionTypes.ToggleAiSearchInput),
+ map((action) =>
+ this.searchAiService.updateSearchAiInputState({
+ active: true,
+ selectedAgentId: action.agentId
+ })
+ )
+ ),
+ { dispatch: false }
+ );
+}
diff --git a/projects/aca-content/src/lib/ui/application.scss b/projects/aca-content/src/lib/ui/application.scss
index 0762b52a4..31eac5710 100644
--- a/projects/aca-content/src/lib/ui/application.scss
+++ b/projects/aca-content/src/lib/ui/application.scss
@@ -66,3 +66,11 @@ ng-component {
color: var(--adf-theme-foreground-text-color-087);
width: 100%;
}
+
+.aca-header-container {
+ display: flex;
+ flex-direction: row;
+ flex: 1;
+ align-items: center;
+ width: 100%;
+}
diff --git a/projects/aca-content/src/lib/ui/theme.scss b/projects/aca-content/src/lib/ui/theme.scss
index 34aa31e10..f34e93429 100644
--- a/projects/aca-content/src/lib/ui/theme.scss
+++ b/projects/aca-content/src/lib/ui/theme.scss
@@ -338,3 +338,9 @@ adf-dynamic-component {
min-width: 160px;
}
}
+
+.adf-unsaved-changes-dialog {
+ .adf-unsaved-changes-dialog-actions-discard-changes-button:is(button) {
+ background-color: var(--theme-blue-button-color);
+ }
+}
diff --git a/projects/aca-content/src/lib/ui/variables/variables.scss b/projects/aca-content/src/lib/ui/variables/variables.scss
index aa6ec1d4c..ad1fbe316 100644
--- a/projects/aca-content/src/lib/ui/variables/variables.scss
+++ b/projects/aca-content/src/lib/ui/variables/variables.scss
@@ -28,6 +28,7 @@ $blue-save-button-background: #1f74db;
$blue-checkbox-background: rgb(10, 96, 206);
$blue-active-table-row: rgb(10, 96, 206, 0.24);
$black-heading: #4e4c4c;
+$light-grey-content: #4b5563;
$theme-dropdown-background: darken($background-color, 5%);
$theme-dropdown-background-hover: darken($background-color, 10%);
$grey-divider: rgba(0, 0, 0, 0.22);
@@ -46,6 +47,11 @@ $disabled-chip-background-color: #f5f5f5;
$contrast-gray: mat.get-color-from-palette($foreground, 'secondary-tex');
$search-highlight-background-color: #ffd180;
$info-snackbar-background: #1f74db;
+$text-light-color: rgba(33, 35, 40, 0.7);
+$card-background-grey-color: rgb(248, 248, 248);
+$light-grey-1: #d5d5d5;
+$light-grey-2: #d9d9d9;
+$light-grey-3: #dedede;
// CSS Variables
$defaults: (
@@ -75,6 +81,7 @@ $defaults: (
--theme-blue-checkbox-color: $blue-checkbox-background,
--theme-blue-active-table-row-color: $blue-active-table-row,
--theme-heading-color: $black-heading,
+ --theme-content-color: $light-grey-content,
--theme-dropdown-color: $theme-dropdown-background,
--theme-dropdown-background-hover: $theme-dropdown-background-hover,
--theme-grey-divider-color: $grey-divider,
@@ -96,7 +103,12 @@ $defaults: (
--theme-search-chip-icon-color: $search-chip-icon-color,
--theme-disabled-chip-background-color: $disabled-chip-background-color,
--theme-secondary-text: $secondary-text,
- --theme-search-highlight-background-color: $search-highlight-background-color
+ --theme-search-highlight-background-color: $search-highlight-background-color,
+ --theme-text-light-color: $text-light-color,
+ --theme-card-background-grey-color: $card-background-grey-color,
+ --theme-light-grey-1-color: $light-grey-1,
+ --theme-light-grey-2-color: $light-grey-2,
+ --theme-light-grey-3-color: $light-grey-3
);
// propagates SCSS variables into the CSS variables scope
diff --git a/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts b/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts
index c3fd364e9..b68bdc6e8 100644
--- a/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts
+++ b/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts
@@ -215,4 +215,9 @@ describe('ViewerService', () => {
const ids = await viewerService.getFileIds('libraries', null);
expect(ids).toEqual([]);
});
+
+ it('should return custom nodes order if did not find nodes', async () => {
+ viewerService.customNodesOrder = ['someNode1', 'someNode2'];
+ expect(await viewerService.getFileIds('', null)).toEqual(['someNode1', 'someNode2']);
+ });
});
diff --git a/projects/aca-content/viewer/src/lib/services/viewer.service.ts b/projects/aca-content/viewer/src/lib/services/viewer.service.ts
index 3cf9a9aee..ad87ade59 100644
--- a/projects/aca-content/viewer/src/lib/services/viewer.service.ts
+++ b/projects/aca-content/viewer/src/lib/services/viewer.service.ts
@@ -36,6 +36,12 @@ interface AdjacentFiles {
providedIn: 'root'
})
export class ViewerService {
+ private _customNodesOrder: string[] = [];
+
+ set customNodesOrder(customNodesOrder: string[]) {
+ this._customNodesOrder = customNodesOrder;
+ }
+
constructor(private preferences: UserPreferencesService, private contentApi: ContentApiService) {}
recentFileFilters = [
@@ -103,7 +109,6 @@ export class ViewerService {
}
const isClient = this.preferences.get(`${source}.sorting.mode`) === 'client';
const [sortKey, sortDirection, previousSortKey, previousSortDir] = this.getSortKeyDir(source);
- let entries: Node[] | SharedLink[] = [];
let nodes: NodePaging | FavoritePaging | SharedLinkPaging | ResultSetPaging;
if (source === 'personal-files' || source === 'libraries-files') {
@@ -164,15 +169,7 @@ export class ViewerService {
};
nodes = await this.contentApi.search(query).toPromise();
}
-
- entries = nodes.list.entries.map((obj) => obj.entry.target?.file ?? obj.entry);
- if (isClient) {
- if (previousSortKey) {
- this.sort(entries, previousSortKey, previousSortDir);
- }
- this.sort(entries, sortKey, sortDirection);
- }
- return entries.map((entry) => entry.id ?? entry.nodeId);
+ return this.getCustomNodesOrderIfNoNodes(nodes, isClient, previousSortKey, previousSortDir, sortKey, sortDirection);
}
/**
@@ -224,4 +221,26 @@ export class ViewerService {
return ['modifiedAt', 'desc'];
}
}
+
+ private getCustomNodesOrderIfNoNodes(
+ nodes: NodePaging | FavoritePaging | SharedLinkPaging | ResultSetPaging,
+ isClient: boolean,
+ previousSortKey: string,
+ previousSortDir: string,
+ sortKey: string,
+ sortDirection: string
+ ): string[] {
+ if (nodes) {
+ const entries = nodes.list.entries.map((obj) => obj.entry.target?.file ?? obj.entry);
+ if (isClient) {
+ if (previousSortKey) {
+ this.sort(entries, previousSortKey, previousSortDir);
+ }
+ this.sort(entries, sortKey, sortDirection);
+ }
+ return entries.map((entry) => entry.id ?? entry.nodeId);
+ } else {
+ return this._customNodesOrder;
+ }
+ }
}
diff --git a/projects/aca-content/viewer/src/public-api.ts b/projects/aca-content/viewer/src/public-api.ts
index 21c382658..9bd7663c5 100644
--- a/projects/aca-content/viewer/src/public-api.ts
+++ b/projects/aca-content/viewer/src/public-api.ts
@@ -29,3 +29,4 @@
export * from './lib/components/viewer/viewer.component';
export * from './lib/components/preview/preview.component';
export * from './lib/viewer.module';
+export * from './lib/services/viewer.service';
diff --git a/projects/aca-shared/rules/src/app.rules.spec.ts b/projects/aca-shared/rules/src/app.rules.spec.ts
index aebdc0827..d5fe674a9 100644
--- a/projects/aca-shared/rules/src/app.rules.spec.ts
+++ b/projects/aca-shared/rules/src/app.rules.spec.ts
@@ -23,9 +23,9 @@
*/
import * as app from './app.rules';
-import { getFileExtension } from './app.rules';
import { TestRuleContext } from './test-rule-context';
import { NodeEntry, RepositoryInfo, StatusInfo } from '@alfresco/js-api';
+import { getFileExtension } from './app.rules';
describe('app.evaluators', () => {
let context: TestRuleContext;
@@ -540,6 +540,47 @@ describe('app.evaluators', () => {
});
});
+ describe('isKnowledgeRetrievalEnabled', () => {
+ it('should call context.appConfig.get with correct parameters', () => {
+ context.appConfig = { get: jasmine.createSpy() } as any;
+
+ app.canDisplayKnowledgeRetrievalButton(context);
+ expect(context.appConfig.get).toHaveBeenCalledWith('plugins.knowledgeRetrievalEnabled', true);
+ });
+
+ it('should return false if get from appConfig returns false', () => {
+ expect(
+ app.canDisplayKnowledgeRetrievalButton({
+ appConfig: {
+ get: () => false
+ }
+ } as any)
+ ).toBeFalse();
+ });
+
+ it('should return true if get from appConfig returns true and navigation is correct', () => {
+ expect(
+ app.canDisplayKnowledgeRetrievalButton({
+ navigation: { url: '/personal-files' },
+ appConfig: {
+ get: () => true
+ }
+ } as any)
+ ).toBeTrue();
+ });
+
+ it('should return false if get from appConfig returns true, but navigation is not correct', () => {
+ expect(
+ app.canDisplayKnowledgeRetrievalButton({
+ navigation: { url: '/my-special-files' },
+ appConfig: {
+ get: () => true
+ }
+ } as any)
+ ).toBeFalse();
+ });
+ });
+
describe('isContentServiceEnabled', () => {
it('should call context.appConfig.get with correct parameters', () => {
context.appConfig = { get: jasmine.createSpy() } as any;
diff --git a/projects/aca-shared/rules/src/app.rules.ts b/projects/aca-shared/rules/src/app.rules.ts
index d97b5f105..1b0db6e7b 100644
--- a/projects/aca-shared/rules/src/app.rules.ts
+++ b/projects/aca-shared/rules/src/app.rules.ts
@@ -629,3 +629,11 @@ export function isSmartFolder(context: RuleContext): boolean {
export const areTagsEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('plugins.tagsEnabled', true);
export const areCategoriesEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('plugins.categoriesEnabled', true);
+
+export const canDisplayKnowledgeRetrievalButton = (context: AcaRuleContext): boolean =>
+ context.appConfig.get('plugins.knowledgeRetrievalEnabled', false) &&
+ (navigation.isPersonalFiles(context) ||
+ navigation.isSharedFiles(context) ||
+ navigation.isRecentFiles(context) ||
+ navigation.isFavorites(context) ||
+ ((navigation.isSearchResults(context) || navigation.isLibraryContent(context)) && navigation.isNotLibraries(context)));
diff --git a/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts b/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts
index f404b68f3..1e8b88132 100644
--- a/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts
+++ b/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts
@@ -22,7 +22,14 @@
* from Hyland Software. If not, see .
*/
-import { DocumentListComponent, DocumentListService, ShareDataRow, UploadService } from '@alfresco/adf-content-services';
+import {
+ DocumentListComponent,
+ DocumentListService,
+ SearchAiInputState,
+ SearchAiService,
+ ShareDataRow,
+ UploadService
+} from '@alfresco/adf-content-services';
import { ShowHeaderMode } from '@alfresco/adf-core';
import { ContentActionRef, DocumentListPresetRef, SelectionState } from '@alfresco/adf-extensions';
import { OnDestroy, OnInit, OnChanges, ViewChild, SimpleChanges, Directive, inject, HostListener } from '@angular/core';
@@ -81,8 +88,17 @@ export abstract class PageComponent implements OnInit, OnDestroy, OnChanges {
protected router = inject(Router);
private autoDownloadService = inject(AutoDownloadService, { optional: true });
+ protected searchAiService: SearchAiService = inject(SearchAiService);
protected subscriptions: Subscription[] = [];
+ private _searchAiInputState: SearchAiInputState = {
+ active: false
+ };
+
+ get searchAiInputState(): SearchAiInputState {
+ return this._searchAiInputState;
+ }
+
ngOnInit() {
this.extensions
.getCreateActions()
@@ -135,6 +151,10 @@ export abstract class PageComponent implements OnInit, OnDestroy, OnChanges {
.subscribe((result) => {
this.isSmallScreen = result.matches;
});
+
+ this.searchAiService.toggleSearchAiInput$
+ .pipe(takeUntil(this.onDestroy$))
+ .subscribe((searchAiInputState) => (this._searchAiInputState = searchAiInputState));
}
ngOnChanges(changes: SimpleChanges) {
diff --git a/projects/aca-shared/store/src/actions/search-ai.actions.ts b/projects/aca-shared/store/src/actions/search-ai.actions.ts
new file mode 100644
index 000000000..e4d54683e
--- /dev/null
+++ b/projects/aca-shared/store/src/actions/search-ai.actions.ts
@@ -0,0 +1,42 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { Action } from '@ngrx/store';
+import { AiSearchByTermPayload } from '../models/ai-search-by-term-payload';
+
+export enum SearchAiActionTypes {
+ SearchByTermAi = 'SEARCH_BY_TERM_AI',
+ ToggleAiSearchInput = 'TOGGLE_AI_SEARCH_INPUT'
+}
+
+export class SearchByTermAiAction implements Action {
+ readonly type = SearchAiActionTypes.SearchByTermAi;
+ constructor(public payload: AiSearchByTermPayload) {}
+}
+
+export class ToggleAISearchInput implements Action {
+ readonly type = SearchAiActionTypes.ToggleAiSearchInput;
+
+ constructor(public agentId: string) {}
+}
diff --git a/projects/aca-shared/store/src/models/ai-search-by-term-payload.ts b/projects/aca-shared/store/src/models/ai-search-by-term-payload.ts
new file mode 100644
index 000000000..696d555a3
--- /dev/null
+++ b/projects/aca-shared/store/src/models/ai-search-by-term-payload.ts
@@ -0,0 +1,28 @@
+/*!
+ * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+export interface AiSearchByTermPayload {
+ searchTerm: string;
+ agentId: string;
+}
diff --git a/projects/aca-shared/store/src/public-api.ts b/projects/aca-shared/store/src/public-api.ts
index e23673eaf..b6ef1adba 100644
--- a/projects/aca-shared/store/src/public-api.ts
+++ b/projects/aca-shared/store/src/public-api.ts
@@ -37,10 +37,12 @@ export * from './actions/viewer.actions';
export * from './actions/metadata-aspect.actions';
export * from './actions/template.actions';
export * from './actions/contextmenu.actions';
+export * from './actions/search-ai.actions';
export * from './effects/router.effects';
export * from './effects/snackbar.effects';
+export * from './models/ai-search-by-term-payload';
export * from './models/delete-status.model';
export * from './models/deleted-node-info.model';
export * from './models/node-info.model';