[ACS-9427] support md format of response for knowledge retrieval (#4502)

* [ACS-9427] Added library for markdown format

* [ACS-9427] Display tooltip with mermaid source code and preprocess markdown format

* [ACS-9427] Rendering mathematical formulas

* [ACS-9427] Styling tables

* [ACS-9427] Added support for programming code language in MD

* [ACS-9427] Fixed existing unit tests for search ai results component

* [ACS-9427] Unit tests for markdown data and rendering of markdown

* [ACS-9427] Unit tests for markdown containing mermaid and latex blocks

* [ACS-9427] Reverted unwanted change

* [ACS-9427] Fixed Sonar issues for regex

* [ACS-9427] Fixed Sonar issues for regex

* [ACS-9427] Fixed Sonar issues for regex

* [ACS-9427] Fixed Sonar issues for regex

* [ACS-9427] Fixed unit test

* [ACS-9427] Fixed unit test

* [ACS-9427] Fixed unit test
This commit is contained in:
AleksanderSklorz 2025-04-07 08:10:20 +02:00 committed by GitHub
parent 46a7113e32
commit 6a3c888f18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2413 additions and 46 deletions

View File

@ -89,10 +89,21 @@
"styles": [ "styles": [
"node_modules/cropperjs/dist/cropper.min.css", "node_modules/cropperjs/dist/cropper.min.css",
"node_modules/pdfjs-dist/web/pdf_viewer.css", "node_modules/pdfjs-dist/web/pdf_viewer.css",
"node_modules/katex/dist/katex.min.css",
"node_modules/prismjs/themes/prism-okaidia.css",
"projects/aca-content/src/lib/ui/application.scss", "projects/aca-content/src/lib/ui/application.scss",
"app/src/styles.scss" "app/src/styles.scss"
], ],
"scripts": ["node_modules/pdfjs-dist/build/pdf.js", "node_modules/pdfjs-dist/web/pdf_viewer.js"], "scripts": [
"node_modules/pdfjs-dist/build/pdf.js",
"node_modules/pdfjs-dist/web/pdf_viewer.js",
"node_modules/mermaid/dist/mermaid.min.js",
"node_modules/katex/dist/katex.min.js",
"node_modules/katex/dist/contrib/auto-render.min.js",
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-csharp.min.js",
"node_modules/prismjs/components/prism-css.min.js"
],
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,
"buildOptimizer": false, "buildOptimizer": false,

2122
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -54,9 +54,13 @@
"@ngrx/store-devtools": "17.0.1", "@ngrx/store-devtools": "17.0.1",
"@ngx-translate/core": "^14.0.0", "@ngx-translate/core": "^14.0.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"katex": "^0.16.21",
"material-icons": "^1.13.12", "material-icons": "^1.13.12",
"mermaid": "^11.5.0",
"minimatch-browser": "^1.0.0", "minimatch-browser": "^1.0.0",
"ngx-markdown": "^17.2.1",
"pdfjs-dist": "3.3.122", "pdfjs-dist": "3.3.122",
"prismjs": "^1.30.0",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"tslib": "2.8.1", "tslib": "2.8.1",
"zone.js": "0.14.8" "zone.js": "0.14.8"

View File

@ -11,5 +11,11 @@ module.exports = function (config) {
...baseConfig.coverageReporter, ...baseConfig.coverageReporter,
dir: join(__dirname, '../../coverage/aca-content'), dir: join(__dirname, '../../coverage/aca-content'),
}, },
files: [
'../../node_modules/katex/dist/katex.min.js',
'../../node_modules/katex/dist/contrib/auto-render.min.js',
'../../node_modules/katex/dist/katex.min.css',
'../../node_modules/mermaid/dist/mermaid.min.js'
]
}); });
}; };

View File

@ -79,6 +79,7 @@ import { SearchResultsRowComponent } from './components/search/search-results-ro
import { BulkActionsDropdownComponent } from './components/bulk-actions-dropdown/bulk-actions-dropdown.component'; import { BulkActionsDropdownComponent } from './components/bulk-actions-dropdown/bulk-actions-dropdown.component';
import { AgentsButtonComponent } from './components/knowledge-retrieval/search-ai/agents-button/agents-button.component'; import { AgentsButtonComponent } from './components/knowledge-retrieval/search-ai/agents-button/agents-button.component';
import { SaveSearchSidenavComponent } from './components/search/search-save/sidenav/save-search-sidenav.component'; import { SaveSearchSidenavComponent } from './components/search/search-save/sidenav/save-search-sidenav.component';
import { MarkdownModule } from 'ngx-markdown';
@NgModule({ @NgModule({
imports: [ imports: [
@ -100,7 +101,8 @@ import { SaveSearchSidenavComponent } from './components/search/search-save/side
AcaFolderRulesModule, AcaFolderRulesModule,
CreateFromTemplateDialogComponent, CreateFromTemplateDialogComponent,
OpenInAppComponent, OpenInAppComponent,
UploadFilesDialogComponent UploadFilesDialogComponent,
MarkdownModule.forRoot()
], ],
providers: [ providers: [
{ provide: ContentVersionService, useClass: ContentUrlService }, { provide: ContentVersionService, useClass: ContentUrlService },

View File

@ -21,11 +21,13 @@
<div <div
class="aca-search-ai-response-container-body" class="aca-search-ai-response-container-body"
*ngIf="!hasAnsweringError"> *ngIf="!hasAnsweringError">
<div <markdown
class="aca-search-ai-response-container-body-response" class="aca-search-ai-response-container-body-response"
data-automation-id="aca-search-ai-results-response"> data-automation-id="aca-search-ai-results-response"
{{ queryAnswer?.answer }} [data]="displayedAnswer"
</div> (ready)="addSourceCodeTooltips()"
mermaid
katex />
<button <button
class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-regeneration" class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-regeneration"
mat-icon-button mat-icon-button

View File

@ -122,6 +122,35 @@
width: 24px; width: 24px;
} }
} }
table {
width: 100%;
border-collapse: collapse;
box-shadow: 0 2px 4px var(--theme-grey-divider-color);
border-radius: 4px;
overflow: hidden;
& th {
background-color: var(--adf-theme-mat-grey-color-a200);
text-align: left;
}
& th,
& td {
padding: 16px;
border-bottom: 1px solid var(--adf-theme-foreground-divider-color);
}
& tr {
&:hover {
background-color: var(--adf-theme-background-hover-color);
}
&:nth-child(even) {
background-color: var(--theme-card-background-grey-color);
}
}
}
} }
&-divider { &-divider {

View File

@ -25,9 +25,9 @@
import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing'; import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing';
import { SearchAiResultsComponent } from './search-ai-results.component'; import { SearchAiResultsComponent } from './search-ai-results.component';
import { ActivatedRoute, Params, Router } from '@angular/router'; import { ActivatedRoute, Params, Router } from '@angular/router';
import { of, Subject, throwError } from 'rxjs'; import { Observable, of, Subject, throwError } from 'rxjs';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { EmptyContentComponent, UnsavedChangesGuard, UserPreferencesService } from '@alfresco/adf-core'; import { EmptyContentComponent, UnitTestingUtils, UnsavedChangesGuard, UserPreferencesService } from '@alfresco/adf-core';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { AppTestingModule } from '../../../../testing/app-testing.module'; import { AppTestingModule } from '../../../../testing/app-testing.module';
import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatIconTestingModule } from '@angular/material/icon/testing';
@ -35,15 +35,15 @@ import { AgentService, NodesApiService, SearchAiService } from '@alfresco/adf-co
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { ModalAiService } from '../../../../services/modal-ai.service'; import { ModalAiService } from '../../../../services/modal-ai.service';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { AiAnswer, AiAnswerEntry, QuestionModel } from '@alfresco/js-api/typings'; import { AiAnswerEntry, QuestionModel } from '@alfresco/js-api/typings';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component'; import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getAppSelection, getCurrentFolder, ViewNodeAction } from '@alfresco/aca-shared/store'; import { getAppSelection, getCurrentFolder, ViewNodeAction } from '@alfresco/aca-shared/store';
import { ViewerService } from '@alfresco/aca-content/viewer'; import { ViewerService } from '@alfresco/aca-content/viewer';
import { DebugElement } from '@angular/core'; import { DebugElement } from '@angular/core';
import { MarkdownComponent, MarkdownModule } from 'ngx-markdown';
const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: { nodesIds: [] } }; const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: { nodesIds: [] } };
const aiAnswerMock: AiAnswer = { answer: 'Some answer', questionId: 'some id', references: [] };
const getAiAnswerEntry = (noAnswer?: boolean): AiAnswerEntry => { const getAiAnswerEntry = (noAnswer?: boolean): AiAnswerEntry => {
return { entry: { answer: noAnswer ? '' : 'Some answer', questionId: 'some id', references: [] } }; return { entry: { answer: noAnswer ? '' : 'Some answer', questionId: 'some id', references: [] } };
}; };
@ -59,6 +59,7 @@ describe('SearchAiResultsComponent', () => {
let store: MockStore; let store: MockStore;
let viewerService: ViewerService; let viewerService: ViewerService;
let unsavedChangesGuard: UnsavedChangesGuard; let unsavedChangesGuard: UnsavedChangesGuard;
let unitTestingUtils: UnitTestingUtils;
afterEach(() => { afterEach(() => {
store.resetSelectors(); store.resetSelectors();
@ -68,7 +69,7 @@ describe('SearchAiResultsComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule], imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule, MarkdownModule.forRoot()],
providers: [ providers: [
{ {
provide: NodesApiService, provide: NodesApiService,
@ -112,6 +113,7 @@ describe('SearchAiResultsComponent', () => {
spyOn(searchAiService, 'ask').and.returnValue(of(questionMock)); spyOn(searchAiService, 'ask').and.returnValue(of(questionMock));
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(of([])); spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(of([]));
component = fixture.componentInstance; component = fixture.componentInstance;
unitTestingUtils = new UnitTestingUtils(fixture.debugElement);
component.ngOnInit(); component.ngOnInit();
}); });
@ -161,7 +163,7 @@ describe('SearchAiResultsComponent', () => {
tick(30000); tick(30000);
expect(component.queryAnswer).toBeUndefined(); expect(component.displayedAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue(); expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse(); expect(component.loading).toBeFalse();
})); }));
@ -173,7 +175,7 @@ describe('SearchAiResultsComponent', () => {
tick(3000); tick(3000);
expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] }); expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasAnsweringError).toBeFalse(); expect(component.hasAnsweringError).toBeFalse();
})); }));
@ -184,7 +186,7 @@ describe('SearchAiResultsComponent', () => {
tick(50000); tick(50000);
expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] }); expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasAnsweringError).toBeFalse(); expect(component.hasAnsweringError).toBeFalse();
})); }));
@ -195,7 +197,7 @@ describe('SearchAiResultsComponent', () => {
tick(30000); tick(30000);
expect(component.queryAnswer).toBeUndefined(); expect(component.displayedAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue(); expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse(); expect(component.loading).toBeFalse();
})); }));
@ -207,7 +209,7 @@ describe('SearchAiResultsComponent', () => {
tick(30000); tick(30000);
expect(component.queryAnswer).toBeUndefined(); expect(component.displayedAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue(); expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse(); expect(component.loading).toBeFalse();
})); }));
@ -219,7 +221,7 @@ describe('SearchAiResultsComponent', () => {
tick(30000); tick(30000);
expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] }); expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasAnsweringError).toBeFalse(); expect(component.hasAnsweringError).toBeFalse();
})); }));
@ -332,10 +334,167 @@ describe('SearchAiResultsComponent', () => {
fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-regeneration-button"]`)).nativeElement.click(); fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-regeneration-button"]`)).nativeElement.click();
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function)); expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
expect(component.queryAnswer).toEqual(aiAnswerMock); expect(component.displayedAnswer).toEqual('Some answer');
}); });
}); });
describe('Markdown', () => {
const getMarkdown = (): MarkdownComponent => unitTestingUtils.getByDirective(MarkdownComponent)?.componentInstance;
const removeTabs = (answer: string): string =>
answer
.split('\n')
.map((line) => line.trim())
.join('\n');
let queryParams: Params;
let getAnswerSpyAnd: jasmine.SpyAnd<(questionId: string) => Observable<AiAnswerEntry>>;
let answerEntry: AiAnswerEntry;
beforeEach(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
queryParams = {
query: 'test',
agentId: 'agentId1'
};
getAnswerSpyAnd = spyOn(searchAiService, 'getAnswer').and;
answerEntry = getAiAnswerEntry();
});
it('should be rendered when answer is loaded successfully', fakeAsync(() => {
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown()).toBeTruthy();
}));
it('should not be rendered when answer loading is failed', fakeAsync(() => {
getAnswerSpyAnd.returnValue(throwError(() => 'error').pipe(delay(100)));
mockQueryParams.next(queryParams);
tick(30000);
fixture.detectChanges();
expect(getMarkdown()).toBeUndefined();
}));
it('should have assigned mermaid to true', fakeAsync(() => {
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().mermaid).toBeTrue();
}));
it('should have assigned katex to true', fakeAsync(() => {
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().katex).toBeTrue();
}));
it('should have assigned correct data', fakeAsync(() => {
const answer = '#### Some title\n\nSome description';
answerEntry.entry.answer = answer;
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().data).toEqual(answer);
}));
it('should have assigned correct data when answer contains mermaids', fakeAsync(() => {
answerEntry.entry.answer =
'First example:\\n\\n```mermaid\\ngraph LR\\n animal --> dog\\n animal --> cat\\n```\\n\\n' +
'Second example:\\n\\n```mermaid\\ngraph LR\\n animal[label="Animal"] --> dog[label="Dog"]\\n animal[label="Animal"] --> cat[label="Cat"]\\n```\\n\\n';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(removeTabs(getMarkdown().data)).toEqual(
removeTabs(`First example:\\n\\n\`\`\`mermaid
\\ngraph LR\\n animal --> dog\\n animal --> cat\\n
\`\`\`\\n\\nSecond example:\\n\\n\`\`\`mermaid
\\ngraph LR\\n animal[Animal] --> dog[Dog]\\n animal[Animal] --> cat[Cat]\\n
\`\`\`\\n\\n`)
);
}));
it('should have assigned correct data when answer contains latex', fakeAsync(() => {
answerEntry.entry.answer = '\n\n### Mathematical Formula\n\n```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx\n```';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().data).toEqual('\n\n### Mathematical Formula\n\n$$f(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx$$');
}));
it('should set source code tooltip for mermaid when answer contains mermaid', fakeAsync(() => {
answerEntry.entry.answer =
'First example:\\n\\n```mermaid\\ngraph LR\\n animal --> dog\\n animal --> cat\\n```\\n\\n' +
'Second example:\\n\\n```mermaid\\ngraph LR\\n animal[label="Animal"] --> dog[label="Dog"]\\n animal[label="Animal"] --> cat[label="Cat"]\\n```\\n\\n';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
const elements = [document.createElement('div'), document.createElement('div')];
spyOn(fixture.nativeElement, 'querySelectorAll').withArgs('.mermaid').and.returnValue(elements).and.returnValue([]);
getMarkdown().ready.emit();
expect(elements[0].title).toBe('```mermaid\\ngraph LR\\n animal --> dog\\n animal --> cat\\n```');
expect(elements[1].title).toBe(
'```mermaid\\ngraph LR\\n animal[label="Animal"] --> dog[label="Dog"]\\n animal[label="Animal"] --> cat[label="Cat"]\\n```'
);
}));
it('should set source code tooltip for mermaid when answer contains latex', fakeAsync(() => {
answerEntry.entry.answer =
'\n\n### Mathematical Formula 1\n\n```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx\n```' +
'\n\n### Mathematical Formula 2\n\n```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{5 (pi i Ixi x} dx\n```';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
const elements = [document.createElement('div'), document.createElement('div')];
spyOn(fixture.nativeElement, 'querySelectorAll').withArgs('.katex').and.returnValue(elements).and.returnValue([]);
getMarkdown().ready.emit();
expect(elements[0].title).toBe('```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx\n```');
expect(elements[1].title).toBe('```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{5 (pi i Ixi x} dx\n```');
}));
});
describe('References', () => { describe('References', () => {
let documentElement: HTMLDivElement; let documentElement: HTMLDivElement;
let nodesOrder: string[]; let nodesOrder: string[];

View File

@ -22,11 +22,11 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Component, OnInit, ViewEncapsulation } from '@angular/core'; import { Component, ElementRef, OnInit, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageComponent, PageLayoutComponent, ToolbarActionComponent, ToolbarComponent } from '@alfresco/aca-shared'; import { PageComponent, PageLayoutComponent } from '@alfresco/aca-shared';
import { concatMap, delay, filter, finalize, retryWhen, skipWhile, switchMap } from 'rxjs/operators'; import { concatMap, delay, filter, finalize, retryWhen, skipWhile, switchMap } from 'rxjs/operators';
import { AvatarComponent, ClipboardService, EmptyContentComponent, ThumbnailService, ToolbarModule, UnsavedChangesGuard } from '@alfresco/adf-core'; import { ClipboardService, EmptyContentComponent, ThumbnailService, UnsavedChangesGuard } from '@alfresco/adf-core';
import { AiAnswer, Node } from '@alfresco/js-api'; import { AiAnswer, Node } from '@alfresco/js-api';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component'; import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component';
@ -43,15 +43,13 @@ import { ModalAiService } from '../../../../services/modal-ai.service';
import { ViewNodeAction } from '@alfresco/aca-shared/store'; import { ViewNodeAction } from '@alfresco/aca-shared/store';
import { ViewerService } from '@alfresco/aca-content/viewer'; import { ViewerService } from '@alfresco/aca-content/viewer';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MarkdownComponent } from 'ngx-markdown';
@Component({ @Component({
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
PageLayoutComponent, PageLayoutComponent,
ToolbarActionComponent,
ToolbarModule,
ToolbarComponent,
SearchAiInputContainerComponent, SearchAiInputContainerComponent,
TranslateModule, TranslateModule,
MatIconModule, MatIconModule,
@ -59,8 +57,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
MatListModule, MatListModule,
EmptyContentComponent, EmptyContentComponent,
MatCardModule, MatCardModule,
AvatarComponent, MatTooltipModule,
MatTooltipModule MarkdownComponent
], ],
selector: 'aca-search-ai-results', selector: 'aca-search-ai-results',
templateUrl: './search-ai-results.component.html', templateUrl: './search-ai-results.component.html',
@ -69,6 +67,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
host: { class: 'aca-search-ai-results' } host: { class: 'aca-search-ai-results' }
}) })
export class SearchAiResultsComponent extends PageComponent implements OnInit { export class SearchAiResultsComponent extends PageComponent implements OnInit {
private static readonly MERMAID_BLOCK_REGEX = /```mermaid([\s\S]*?)```/g;
private static readonly LATEX_BLOCK_REGEX = /```latex([\s\S]*?)```/g;
private _agentId: string; private _agentId: string;
private _hasAnsweringError = false; private _hasAnsweringError = false;
private _hasError = false; private _hasError = false;
@ -78,7 +79,8 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
private openedViewer = false; private openedViewer = false;
private _selectedNodesState: SelectionState; private _selectedNodesState: SelectionState;
private _searchQuery = ''; private _searchQuery = '';
private _queryAnswer: AiAnswer; private queryAnswer: AiAnswer;
private _displayedAnswer: string;
get agentId(): string { get agentId(): string {
return this._agentId; return this._agentId;
@ -104,23 +106,24 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
return this._nodes; return this._nodes;
} }
get queryAnswer(): AiAnswer {
return this._queryAnswer;
}
get searchQuery(): string { get searchQuery(): string {
return this._searchQuery; return this._searchQuery;
} }
get displayedAnswer(): string {
return this._displayedAnswer;
}
constructor( constructor(
private route: ActivatedRoute, private readonly route: ActivatedRoute,
private clipboardService: ClipboardService, private readonly clipboardService: ClipboardService,
private thumbnailService: ThumbnailService, private readonly thumbnailService: ThumbnailService,
private nodesApiService: NodesApiService, private readonly nodesApiService: NodesApiService,
private translateService: TranslateService, private readonly translateService: TranslateService,
private unsavedChangesGuard: UnsavedChangesGuard, private readonly unsavedChangesGuard: UnsavedChangesGuard,
private modalAiService: ModalAiService, private readonly modalAiService: ModalAiService,
private viewerService: ViewerService private readonly viewerService: ViewerService,
private readonly elementRef: ElementRef
) { ) {
super(); super();
} }
@ -184,7 +187,8 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
if (!response.entry?.answer) { if (!response.entry?.answer) {
return throwError((e) => e); return throwError((e) => e);
} }
this._queryAnswer = response.entry; this.queryAnswer = response.entry;
this._displayedAnswer = this.preprocessMarkdownFormat(response.entry.answer);
return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId))); return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId)));
}), }),
retryWhen((errors: Observable<Error>) => this.aiSearchRetryWhen(errors)), retryWhen((errors: Observable<Error>) => this.aiSearchRetryWhen(errors)),
@ -213,6 +217,19 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
); );
} }
addSourceCodeTooltips(): void {
this.setTooltip(SearchAiResultsComponent.MERMAID_BLOCK_REGEX, '.mermaid');
this.setTooltip(SearchAiResultsComponent.LATEX_BLOCK_REGEX, '.katex');
}
private setTooltip(codeBlockRegexp: RegExp, targetElementsSelector: string): void {
const codeBlocks = [...this.queryAnswer.answer.matchAll(codeBlockRegexp)].map((match) => match[0].trim());
const elements: HTMLElement[] = this.elementRef.nativeElement.querySelectorAll(targetElementsSelector);
for (let i = 0; i < elements.length; i++) {
elements[i].title = codeBlocks[i];
}
}
private aiSearchRetryWhen(errors: Observable<Error>): Observable<Error> { private aiSearchRetryWhen(errors: Observable<Error>): Observable<Error> {
this._hasAnsweringError = false; this._hasAnsweringError = false;
const delayBetweenRetries = 3000; const delayBetweenRetries = 3000;
@ -231,4 +248,29 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit {
}) })
); );
} }
private preprocessMarkdownFormat(answer: string): string {
return this.transformLatex(this.transformMermaid(answer));
}
private transformMermaid(answer: string): string {
return answer.replace(SearchAiResultsComponent.MERMAID_BLOCK_REGEX, (_mermaidBlockRegex, blockContent: string) => {
const transformedLines = blockContent.split('\n').map((line) => {
const label = 'label="';
while (line.includes(label)) {
const labelIndex = line.indexOf(label);
const start = labelIndex + label.length;
const end = line.indexOf('"', start);
line = line.slice(0, labelIndex) + line.slice(start, end) + line.slice(end + 1);
}
return line;
});
return `\`\`\`mermaid\n${transformedLines.join('\n')}\n\`\`\``;
});
}
private transformLatex(answer: string): string {
return answer.replace(SearchAiResultsComponent.LATEX_BLOCK_REGEX, (_, latexContent: string) => `$$${latexContent.trim()}$$`);
}
} }

View File

@ -19,7 +19,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"typeRoots": ["node_modules/@types"], "typeRoots": ["node_modules/@types"],
"lib": ["es2019", "dom"], "lib": ["es2020", "dom"],
"paths": { "paths": {
"@alfresco/aca-content": ["projects/aca-content/src/public-api.ts"], "@alfresco/aca-content": ["projects/aca-content/src/public-api.ts"],
"@alfresco/aca-content/about": ["projects/aca-content/about/src/public-api.ts"], "@alfresco/aca-content/about": ["projects/aca-content/about/src/public-api.ts"],