Browse Source

feat(ui): hide ai diagnostics behind info popover

pull/6395/head
Max P 1 month ago
parent
commit
3b602e41cc
  1. 2
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  2. 177
      apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.html
  3. 53
      apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.scss
  4. 62
      apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts
  5. 9
      apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts

2
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' }),

177
apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.html

@ -71,97 +71,114 @@
<span class="ml-2 timestamp">{{
message.createdAt | date: 'shortTime'
}}</span>
@if (message.role === 'assistant' && message.response) {
<button
aria-label="Show response details"
class="chat-details-trigger ml-2"
i18n-aria-label
mat-icon-button
type="button"
[matMenuTriggerFor]="responseDetailsMenu"
(click)="onOpenResponseDetails(message.response)"
>
<mat-icon aria-hidden="true">info_outline</mat-icon>
</button>
}
</div>
<div class="chat-message-content">{{ message.content }}</div>
@if (message.response) {
<div class="chat-metadata mt-2">
<div class="confidence mb-2">
<strong i18n>Confidence</strong>:
{{ message.response.confidence.score * 100 | number: '1.0-0'
}}% ({{ message.response.confidence.band }})
</div>
@if (message.feedback) {
<div class="align-items-center d-flex feedback-controls mt-2">
<button
class="mr-2"
mat-stroked-button
type="button"
[disabled]="
message.feedback.isSubmitting || !!message.feedback.rating
"
(click)="onRateResponse({ index: $index, rating: 'up' })"
>
<ng-container i18n>Helpful</ng-container>
</button>
<button
mat-stroked-button
type="button"
[disabled]="
message.feedback.isSubmitting || !!message.feedback.rating
"
(click)="onRateResponse({ index: $index, rating: 'down' })"
>
<ng-container i18n>Needs work</ng-container>
</button>
@if (message.response.citations.length > 0) {
<div class="mb-2">
<strong i18n>Citations</strong>
<ul class="mb-0 pl-3">
@for (citation of message.response.citations; track $index) {
<li>
<span class="font-weight-bold">{{
citation.source
}}</span>
-
{{ citation.snippet }}
</li>
}
</ul>
</div>
@if (message.feedback.isSubmitting) {
<span class="ml-2 text-muted" i18n>Saving feedback...</span>
} @else if (message.feedback.feedbackId) {
<span class="ml-2 text-muted" i18n>Feedback saved</span>
}
</div>
}
</div>
}
</div>
@if (message.response.verification.length > 0) {
<div class="mb-2">
<strong i18n>Verification</strong>
<ul class="mb-0 pl-3">
@for (check of message.response.verification; track $index) {
<li>
{{ check.status }} - {{ check.check }}:
{{ check.details }}
</li>
}
</ul>
</div>
}
<mat-menu #responseDetailsMenu="matMenu" class="no-max-width" xPosition="before">
<div class="response-details-panel p-3" (click)="$event.stopPropagation()">
@if (activeResponseDetails; as details) {
<div class="response-details-section">
<strong i18n>Confidence</strong>:
{{ details.confidence.score * 100 | number: '1.0-0' }}%
({{ details.confidence.band }})
</div>
@if (message.response.observability) {
<div class="mb-2">
<strong i18n>Observability</strong>:
<span class="ml-1"
>{{ message.response.observability.latencyInMs }}ms,
~{{
message.response.observability.tokenEstimate.total
}}
tokens</span
>
</div>
}
@if (details.citations.length > 0) {
<div class="response-details-section">
<strong i18n>Citations</strong>
<ul class="mb-0 pl-3 response-details-list">
@for (citation of details.citations; track $index) {
<li>
<span class="font-weight-bold">{{ citation.source }}</span>
-
{{ citation.snippet }}
</li>
}
</ul>
</div>
}
@if (message.feedback) {
<div class="align-items-center d-flex feedback-controls">
<button
class="mr-2"
mat-stroked-button
type="button"
[disabled]="
message.feedback.isSubmitting || !!message.feedback.rating
"
(click)="onRateResponse({ index: $index, rating: 'up' })"
>
<ng-container i18n>Helpful</ng-container>
</button>
<button
mat-stroked-button
type="button"
[disabled]="
message.feedback.isSubmitting || !!message.feedback.rating
"
(click)="onRateResponse({ index: $index, rating: 'down' })"
>
<ng-container i18n>Needs work</ng-container>
</button>
@if (details.verification.length > 0) {
<div class="response-details-section">
<strong i18n>Verification</strong>
<ul class="mb-0 pl-3 response-details-list">
@for (check of details.verification; track $index) {
<li>
<span class="text-capitalize">{{ check.status }}</span>
-
{{ check.check }}:
{{ check.details }}
</li>
}
</ul>
</div>
}
@if (message.feedback.isSubmitting) {
<span class="ml-2 text-muted" i18n>Saving feedback...</span>
} @else if (message.feedback.feedbackId) {
<span class="ml-2 text-muted" i18n>Feedback saved</span>
}
</div>
}
@if (details.observability) {
<div class="response-details-section">
<strong i18n>Observability</strong>:
<span class="ml-1"
>{{ details.observability.latencyInMs }}ms, ~{{
details.observability.tokenEstimate.total
}}
tokens</span
>
</div>
}
</div>
}
</div>
} @else {
<span class="text-muted" i18n>No response details available.</span>
}
</div>
</mat-menu>
}
</mat-card-content>
</mat-card>

53
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;
}

62
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(

9
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<AiChatMessage, 'createdAt'> & {
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();

Loading…
Cancel
Save