diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index 335cb7025..255c6a233 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -468,6 +468,8 @@ describe('AiService', () => { expect(result.answer).toContain('Next-step allocation:'); expect(result.answer).toContain('AAPL'); + expect(result.answer).toContain('Option 1 (new money first):'); + expect(result.answer).toContain('Option 2 (sell and rebalance):'); expect(result.toolCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ tool: 'portfolio_analysis' }), diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.html b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.html index 778b4fa23..4a1d45518 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.html +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.html @@ -71,97 +71,114 @@ {{ message.createdAt | date: 'shortTime' }} + + @if (message.role === 'assistant' && message.response) { + + }
{{ message.content }}
- @if (message.response) { -
-
- Confidence: - {{ message.response.confidence.score * 100 | number: '1.0-0' - }}% ({{ message.response.confidence.band }}) -
+ @if (message.feedback) { +
+ + - @if (message.response.citations.length > 0) { -
- Citations -
    - @for (citation of message.response.citations; track $index) { -
  • - {{ - citation.source - }} - - - {{ citation.snippet }} -
  • - } -
-
+ @if (message.feedback.isSubmitting) { + Saving feedback... + } @else if (message.feedback.feedbackId) { + Feedback saved } +
+ } +
+ } + - @if (message.response.verification.length > 0) { -
- Verification - -
- } + +
+ @if (activeResponseDetails; as details) { +
+ Confidence: + {{ details.confidence.score * 100 | number: '1.0-0' }}% + ({{ details.confidence.band }}) +
- @if (message.response.observability) { -
- Observability: - {{ message.response.observability.latencyInMs }}ms, - ~{{ - message.response.observability.tokenEstimate.total - }} - tokens -
- } + @if (details.citations.length > 0) { +
+ Citations +
    + @for (citation of details.citations; track $index) { +
  • + {{ citation.source }} + - + {{ citation.snippet }} +
  • + } +
+
+ } - @if (message.feedback) { - - } + @if (details.observability) { +
+ Observability: + {{ details.observability.latencyInMs }}ms, ~{{ + details.observability.tokenEstimate.total + }} + tokens
} -
- } - + } @else { + No response details available. + } + +
} diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.scss b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.scss index dd5ff1c29..ff253e55d 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.scss +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.scss @@ -43,29 +43,39 @@ .chat-message-content { color: var(--ai-chat-message-text); + margin-top: 0.25rem; white-space: pre-wrap; word-break: break-word; } .chat-message-content::selection, .chat-message-header::selection, -.chat-metadata::selection, -.chat-metadata li::selection, -.chat-metadata strong::selection, +.response-details-panel::selection, +.response-details-panel li::selection, +.response-details-panel strong::selection, textarea::selection { background: var(--ai-chat-selection-background); color: var(--ai-chat-selection-text); } .chat-message-header { + align-items: center; color: var(--ai-chat-muted-text) !important; + display: flex; + flex-wrap: wrap; } -.chat-metadata { - border-top: 1px solid var(--ai-chat-border-color); +.chat-details-trigger { color: var(--ai-chat-muted-text); - font-size: 0.85rem; - padding-top: 0.75rem; + height: 1.625rem; + line-height: 1; + width: 1.625rem; +} + +.chat-details-trigger mat-icon { + font-size: 1rem; + height: 1rem; + width: 1rem; } .prompt-list { @@ -78,5 +88,32 @@ textarea::selection { .feedback-controls { gap: 0.25rem; - margin-top: 0.5rem; +} + +.response-details-panel { + color: var(--ai-chat-message-text); + max-height: min(24rem, calc(100vh - 8rem)); + max-width: min(26rem, calc(100vw - 2rem)); + min-width: min(18rem, calc(100vw - 2rem)); + overflow-y: auto; + white-space: normal; +} + +.response-details-section { + color: var(--ai-chat-muted-text); + font-size: 0.85rem; +} + +.response-details-section + .response-details-section { + border-top: 1px solid var(--ai-chat-border-color); + margin-top: 0.75rem; + padding-top: 0.75rem; +} + +.response-details-list { + margin-top: 0.25rem; +} + +.response-details-list li + li { + margin-top: 0.25rem; } diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts index 234e14519..98a626a72 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts @@ -1,7 +1,14 @@ import { AiAgentChatResponse } from '@ghostfolio/common/interfaces'; import { DataService } from '@ghostfolio/ui/services'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of, throwError } from 'rxjs'; import { GfAiChatPanelComponent } from './ai-chat-panel.component'; @@ -77,6 +84,8 @@ describe('GfAiChatPanelComponent', () => { postAiChat: jest.Mock; postAiChatFeedback: jest.Mock; }; + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; beforeEach(async () => { localStorage.clear(); @@ -87,10 +96,13 @@ describe('GfAiChatPanelComponent', () => { }; await TestBed.configureTestingModule({ - imports: [GfAiChatPanelComponent], + imports: [GfAiChatPanelComponent, NoopAnimationsModule], providers: [{ provide: DataService, useValue: dataService }] }).compileComponents(); + overlayContainer = TestBed.inject(OverlayContainer); + overlayContainerElement = overlayContainer.getContainerElement(); + fixture = TestBed.createComponent(GfAiChatPanelComponent); component = fixture.componentInstance; component.hasPermissionToReadAiPrompt = true; @@ -99,6 +111,7 @@ describe('GfAiChatPanelComponent', () => { afterEach(() => { localStorage.clear(); + overlayContainer.ngOnDestroy(); }); it('sends a chat query and appends assistant response', () => { @@ -138,6 +151,51 @@ describe('GfAiChatPanelComponent', () => { ).toHaveLength(2); }); + it('shows diagnostics in info popover instead of inline message body', fakeAsync(() => { + dataService.postAiChat.mockReturnValue( + of( + createChatResponse({ + answer: 'You are concentrated in one position.', + sessionId: 'session-details', + turns: 1 + }) + ) + ); + component.query = 'Help me diversify'; + + component.onSubmit(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const chatLogText = nativeElement.querySelector('.chat-log')?.textContent ?? ''; + + expect(nativeElement.querySelector('.chat-metadata')).toBeNull(); + expect(chatLogText).not.toContain('Confidence'); + expect(chatLogText).not.toContain('Citations'); + expect(chatLogText).not.toContain('Verification'); + + const detailsTrigger = nativeElement.querySelector( + '.chat-details-trigger' + ) as HTMLButtonElement | null; + + expect(detailsTrigger).toBeTruthy(); + + detailsTrigger?.click(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const overlayText = overlayContainerElement.textContent ?? ''; + + expect(overlayText).toContain('Confidence'); + expect(overlayText).toContain('Citations'); + expect(overlayText).toContain('Verification'); + expect(overlayText).toContain('2 holdings analyzed'); + expect(overlayText).toContain('market_data_coverage'); + })); + it('reuses session id across consecutive prompts', () => { dataService.postAiChat .mockReturnValueOnce( diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts index f0034e59c..7244bcf5a 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts @@ -13,7 +13,9 @@ import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Subject } from 'rxjs'; import { finalize, takeUntil } from 'rxjs/operators'; @@ -45,7 +47,9 @@ type StoredAiChatMessage = Omit & { MatButtonModule, MatCardModule, MatFormFieldModule, + MatIconModule, MatInputModule, + MatMenuModule, MatProgressSpinnerModule ], selector: 'gf-ai-chat-panel', @@ -60,6 +64,7 @@ export class GfAiChatPanelComponent implements OnDestroy { @Input() hasPermissionToReadAiPrompt = false; public readonly assistantRoleLabel = $localize`Assistant`; + public activeResponseDetails: AiAgentChatResponse | undefined; public chatMessages: AiChatMessage[] = []; public errorMessage: string; public isSubmitting = false; @@ -152,6 +157,10 @@ export class GfAiChatPanelComponent implements OnDestroy { } } + public onOpenResponseDetails(response?: AiAgentChatResponse) { + this.activeResponseDetails = response; + } + public onSubmit() { const normalizedQuery = this.query?.trim();