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('Next-step allocation:');
expect(result.answer).toContain('AAPL'); 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(result.toolCalls).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ tool: 'portfolio_analysis' }), 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">{{ <span class="ml-2 timestamp">{{
message.createdAt | date: 'shortTime' message.createdAt | date: 'shortTime'
}}</span> }}</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>
<div class="chat-message-content">{{ message.content }}</div> <div class="chat-message-content">{{ message.content }}</div>
@if (message.response) { @if (message.feedback) {
<div class="chat-metadata mt-2"> <div class="align-items-center d-flex feedback-controls mt-2">
<div class="confidence mb-2"> <button
<strong i18n>Confidence</strong>: class="mr-2"
{{ message.response.confidence.score * 100 | number: '1.0-0' mat-stroked-button
}}% ({{ message.response.confidence.band }}) type="button"
</div> [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) { @if (message.feedback.isSubmitting) {
<div class="mb-2"> <span class="ml-2 text-muted" i18n>Saving feedback...</span>
<strong i18n>Citations</strong> } @else if (message.feedback.feedbackId) {
<ul class="mb-0 pl-3"> <span class="ml-2 text-muted" i18n>Feedback saved</span>
@for (citation of message.response.citations; track $index) {
<li>
<span class="font-weight-bold">{{
citation.source
}}</span>
-
{{ citation.snippet }}
</li>
}
</ul>
</div>
} }
</div>
}
</div>
}
</div>
@if (message.response.verification.length > 0) { <mat-menu #responseDetailsMenu="matMenu" class="no-max-width" xPosition="before">
<div class="mb-2"> <div class="response-details-panel p-3" (click)="$event.stopPropagation()">
<strong i18n>Verification</strong> @if (activeResponseDetails; as details) {
<ul class="mb-0 pl-3"> <div class="response-details-section">
@for (check of message.response.verification; track $index) { <strong i18n>Confidence</strong>:
<li> {{ details.confidence.score * 100 | number: '1.0-0' }}%
{{ check.status }} - {{ check.check }}: ({{ details.confidence.band }})
{{ check.details }} </div>
</li>
}
</ul>
</div>
}
@if (message.response.observability) { @if (details.citations.length > 0) {
<div class="mb-2"> <div class="response-details-section">
<strong i18n>Observability</strong>: <strong i18n>Citations</strong>
<span class="ml-1" <ul class="mb-0 pl-3 response-details-list">
>{{ message.response.observability.latencyInMs }}ms, @for (citation of details.citations; track $index) {
~{{ <li>
message.response.observability.tokenEstimate.total <span class="font-weight-bold">{{ citation.source }}</span>
}} -
tokens</span {{ citation.snippet }}
> </li>
</div> }
} </ul>
</div>
}
@if (message.feedback) { @if (details.verification.length > 0) {
<div class="align-items-center d-flex feedback-controls"> <div class="response-details-section">
<button <strong i18n>Verification</strong>
class="mr-2" <ul class="mb-0 pl-3 response-details-list">
mat-stroked-button @for (check of details.verification; track $index) {
type="button" <li>
[disabled]=" <span class="text-capitalize">{{ check.status }}</span>
message.feedback.isSubmitting || !!message.feedback.rating -
" {{ check.check }}:
(click)="onRateResponse({ index: $index, rating: 'up' })" {{ check.details }}
> </li>
<ng-container i18n>Helpful</ng-container> }
</button> </ul>
<button </div>
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.feedback.isSubmitting) { @if (details.observability) {
<span class="ml-2 text-muted" i18n>Saving feedback...</span> <div class="response-details-section">
} @else if (message.feedback.feedbackId) { <strong i18n>Observability</strong>:
<span class="ml-2 text-muted" i18n>Feedback saved</span> <span class="ml-1"
} >{{ details.observability.latencyInMs }}ms, ~{{
</div> details.observability.tokenEstimate.total
} }}
tokens</span
>
</div> </div>
} }
</div> } @else {
} <span class="text-muted" i18n>No response details available.</span>
</div> }
</div>
</mat-menu>
} }
</mat-card-content> </mat-card-content>
</mat-card> </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 { .chat-message-content {
color: var(--ai-chat-message-text); color: var(--ai-chat-message-text);
margin-top: 0.25rem;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.chat-message-content::selection, .chat-message-content::selection,
.chat-message-header::selection, .chat-message-header::selection,
.chat-metadata::selection, .response-details-panel::selection,
.chat-metadata li::selection, .response-details-panel li::selection,
.chat-metadata strong::selection, .response-details-panel strong::selection,
textarea::selection { textarea::selection {
background: var(--ai-chat-selection-background); background: var(--ai-chat-selection-background);
color: var(--ai-chat-selection-text); color: var(--ai-chat-selection-text);
} }
.chat-message-header { .chat-message-header {
align-items: center;
color: var(--ai-chat-muted-text) !important; color: var(--ai-chat-muted-text) !important;
display: flex;
flex-wrap: wrap;
} }
.chat-metadata { .chat-details-trigger {
border-top: 1px solid var(--ai-chat-border-color);
color: var(--ai-chat-muted-text); color: var(--ai-chat-muted-text);
font-size: 0.85rem; height: 1.625rem;
padding-top: 0.75rem; line-height: 1;
width: 1.625rem;
}
.chat-details-trigger mat-icon {
font-size: 1rem;
height: 1rem;
width: 1rem;
} }
.prompt-list { .prompt-list {
@ -78,5 +88,32 @@ textarea::selection {
.feedback-controls { .feedback-controls {
gap: 0.25rem; 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 { AiAgentChatResponse } from '@ghostfolio/common/interfaces';
import { DataService } from '@ghostfolio/ui/services'; 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 { of, throwError } from 'rxjs';
import { GfAiChatPanelComponent } from './ai-chat-panel.component'; import { GfAiChatPanelComponent } from './ai-chat-panel.component';
@ -77,6 +84,8 @@ describe('GfAiChatPanelComponent', () => {
postAiChat: jest.Mock; postAiChat: jest.Mock;
postAiChatFeedback: jest.Mock; postAiChatFeedback: jest.Mock;
}; };
let overlayContainer: OverlayContainer;
let overlayContainerElement: HTMLElement;
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); localStorage.clear();
@ -87,10 +96,13 @@ describe('GfAiChatPanelComponent', () => {
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [GfAiChatPanelComponent], imports: [GfAiChatPanelComponent, NoopAnimationsModule],
providers: [{ provide: DataService, useValue: dataService }] providers: [{ provide: DataService, useValue: dataService }]
}).compileComponents(); }).compileComponents();
overlayContainer = TestBed.inject(OverlayContainer);
overlayContainerElement = overlayContainer.getContainerElement();
fixture = TestBed.createComponent(GfAiChatPanelComponent); fixture = TestBed.createComponent(GfAiChatPanelComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
component.hasPermissionToReadAiPrompt = true; component.hasPermissionToReadAiPrompt = true;
@ -99,6 +111,7 @@ describe('GfAiChatPanelComponent', () => {
afterEach(() => { afterEach(() => {
localStorage.clear(); localStorage.clear();
overlayContainer.ngOnDestroy();
}); });
it('sends a chat query and appends assistant response', () => { it('sends a chat query and appends assistant response', () => {
@ -138,6 +151,51 @@ describe('GfAiChatPanelComponent', () => {
).toHaveLength(2); ).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', () => { it('reuses session id across consecutive prompts', () => {
dataService.postAiChat dataService.postAiChat
.mockReturnValueOnce( .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 { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { finalize, takeUntil } from 'rxjs/operators'; import { finalize, takeUntil } from 'rxjs/operators';
@ -45,7 +47,9 @@ type StoredAiChatMessage = Omit<AiChatMessage, 'createdAt'> & {
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatFormFieldModule, MatFormFieldModule,
MatIconModule,
MatInputModule, MatInputModule,
MatMenuModule,
MatProgressSpinnerModule MatProgressSpinnerModule
], ],
selector: 'gf-ai-chat-panel', selector: 'gf-ai-chat-panel',
@ -60,6 +64,7 @@ export class GfAiChatPanelComponent implements OnDestroy {
@Input() hasPermissionToReadAiPrompt = false; @Input() hasPermissionToReadAiPrompt = false;
public readonly assistantRoleLabel = $localize`Assistant`; public readonly assistantRoleLabel = $localize`Assistant`;
public activeResponseDetails: AiAgentChatResponse | undefined;
public chatMessages: AiChatMessage[] = []; public chatMessages: AiChatMessage[] = [];
public errorMessage: string; public errorMessage: string;
public isSubmitting = false; public isSubmitting = false;
@ -152,6 +157,10 @@ export class GfAiChatPanelComponent implements OnDestroy {
} }
} }
public onOpenResponseDetails(response?: AiAgentChatResponse) {
this.activeResponseDetails = response;
}
public onSubmit() { public onSubmit() {
const normalizedQuery = this.query?.trim(); const normalizedQuery = this.query?.trim();

Loading…
Cancel
Save