mirror of https://github.com/ghostfolio/ghostfolio
7 changed files with 1513 additions and 4 deletions
@ -0,0 +1,262 @@ |
|||||
|
<mat-card |
||||
|
appearance="outlined" |
||||
|
aria-labelledby="ai-portfolio-chat-heading" |
||||
|
class="ai-chat-card" |
||||
|
id="ai-portfolio-chat-panel" |
||||
|
role="region" |
||||
|
> |
||||
|
<mat-card-header> |
||||
|
<mat-card-title class="align-items-center d-flex"> |
||||
|
<span class="flex-grow-1" i18n id="ai-portfolio-chat-heading" |
||||
|
>AI portfolio chat</span |
||||
|
> |
||||
|
<button |
||||
|
aria-label="Close AI portfolio chat" |
||||
|
i18n-aria-label |
||||
|
mat-icon-button |
||||
|
type="button" |
||||
|
(click)="onClose()" |
||||
|
> |
||||
|
<ion-icon name="close-outline" /> |
||||
|
</button> |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
|
||||
|
<mat-card-content> |
||||
|
<div |
||||
|
aria-label="Portfolio scope" |
||||
|
class="ai-scope align-items-center d-flex flex-wrap mb-3" |
||||
|
i18n-aria-label |
||||
|
> |
||||
|
<span class="ai-scope-label mr-2" i18n>Scope</span> |
||||
|
@for (chip of scopeChips(); track chip) { |
||||
|
<span class="ai-scope-chip">{{ chip }}</span> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
@if (!hasConsent()) { |
||||
|
<div class="ai-consent"> |
||||
|
<h2 class="h5" i18n>Before you start</h2> |
||||
|
<p i18n> |
||||
|
Your question and the scoped portfolio data needed to answer it will |
||||
|
be sent through OpenRouter to model |
||||
|
<code>{{ model() }}</code |
||||
|
>. |
||||
|
</p> |
||||
|
<p i18n> |
||||
|
Ghostfolio keeps this conversation only in memory for this open panel |
||||
|
in this browser tab. Closing the panel, leaving the page, refreshing |
||||
|
the tab, or changing the portfolio scope clears it. |
||||
|
</p> |
||||
|
<p class="mb-4" i18n> |
||||
|
OpenRouter and upstream model providers may process or retain |
||||
|
submitted data under their own privacy and data-retention policies. |
||||
|
</p> |
||||
|
<div class="d-flex flex-wrap justify-content-end"> |
||||
|
<button |
||||
|
class="mb-2 mr-2" |
||||
|
mat-button |
||||
|
type="button" |
||||
|
(click)="onClose()" |
||||
|
> |
||||
|
<ng-container i18n>Not now</ng-container> |
||||
|
</button> |
||||
|
<button |
||||
|
class="mb-2" |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="button" |
||||
|
(click)="onAcceptConsent()" |
||||
|
> |
||||
|
<ng-container i18n>Continue to AI chat</ng-container> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
} @else { |
||||
|
<div |
||||
|
aria-label="AI chat messages" |
||||
|
class="ai-messages mb-3" |
||||
|
i18n-aria-label |
||||
|
> |
||||
|
@if (chat.messages.length === 0) { |
||||
|
<div class="ai-empty-state py-4 text-center"> |
||||
|
<p class="mb-1" i18n>Ask about the portfolio shown above.</p> |
||||
|
<small class="text-muted" i18n |
||||
|
>The assistant can use portfolio summary, holdings, and |
||||
|
performance data.</small |
||||
|
> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@for (message of chat.messages; track message.id) { |
||||
|
@if (getMessageText(message)) { |
||||
|
<article |
||||
|
class="ai-message" |
||||
|
[class.ai-message-assistant]="message.role === 'assistant'" |
||||
|
[class.ai-message-user]="message.role === 'user'" |
||||
|
> |
||||
|
<div class="ai-message-label"> |
||||
|
@if (message.role === 'user') { |
||||
|
<ng-container i18n>You</ng-container> |
||||
|
} @else { |
||||
|
<ng-container i18n>AI</ng-container> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
@if (message.role === 'assistant') { |
||||
|
<div class="ai-markdown"> |
||||
|
@for (block of getSafeMarkdownBlocks(message); track $index) { |
||||
|
@switch (block.type) { |
||||
|
@case ('heading') { |
||||
|
<h3 class="h6"> |
||||
|
<gf-ai-chat-inline [parts]="block.parts" /> |
||||
|
</h3> |
||||
|
} |
||||
|
@case ('ordered-list') { |
||||
|
<ol> |
||||
|
@for (item of block.items; track $index) { |
||||
|
<li> |
||||
|
<gf-ai-chat-inline [parts]="item" /> |
||||
|
</li> |
||||
|
} |
||||
|
</ol> |
||||
|
} |
||||
|
@case ('unordered-list') { |
||||
|
<ul> |
||||
|
@for (item of block.items; track $index) { |
||||
|
<li> |
||||
|
<gf-ai-chat-inline [parts]="item" /> |
||||
|
</li> |
||||
|
} |
||||
|
</ul> |
||||
|
} |
||||
|
@default { |
||||
|
<p> |
||||
|
<gf-ai-chat-inline [parts]="block.parts" /> |
||||
|
</p> |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</div> |
||||
|
} @else { |
||||
|
<div class="ai-user-text">{{ getMessageText(message) }}</div> |
||||
|
} |
||||
|
|
||||
|
@if (getSourceLabels(message).length > 0) { |
||||
|
<div |
||||
|
class="ai-sources align-items-center d-flex flex-wrap mt-2" |
||||
|
> |
||||
|
<span class="mr-2" i18n>Sources</span> |
||||
|
@for (source of getSourceLabels(message); track source) { |
||||
|
<span class="ai-source-chip">{{ source }}</span> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</article> |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@if (chat.status === 'submitted') { |
||||
|
<div |
||||
|
aria-live="polite" |
||||
|
class="ai-thinking align-items-center d-flex" |
||||
|
role="status" |
||||
|
> |
||||
|
<mat-spinner class="mr-2" color="accent" [diameter]="16" /> |
||||
|
<span i18n>Reviewing the portfolio data...</span> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@if (chat.status === 'streaming') { |
||||
|
<span |
||||
|
aria-live="polite" |
||||
|
class="cdk-visually-hidden" |
||||
|
i18n |
||||
|
role="status" |
||||
|
>The AI response is being generated.</span |
||||
|
> |
||||
|
} @else if ( |
||||
|
chat.status === 'ready' && |
||||
|
chat.messages.length > 0 && |
||||
|
chat.messages.at(-1)?.role === 'assistant' |
||||
|
) { |
||||
|
<span |
||||
|
aria-live="polite" |
||||
|
class="cdk-visually-hidden" |
||||
|
i18n |
||||
|
role="status" |
||||
|
>The AI response is complete.</span |
||||
|
> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
@if (chat.status === 'error') { |
||||
|
<div aria-live="assertive" class="ai-error mb-3" role="alert"> |
||||
|
<span i18n |
||||
|
>The AI response could not be completed. Please try again.</span |
||||
|
> |
||||
|
<button mat-button type="button" (click)="onRetry()"> |
||||
|
<ion-icon class="mr-1" name="refresh-outline" /> |
||||
|
<ng-container i18n>Retry</ng-container> |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<form (submit)="onSubmit($event)"> |
||||
|
<mat-form-field appearance="outline" class="ai-prompt w-100"> |
||||
|
<mat-label i18n>Ask about this portfolio</mat-label> |
||||
|
<textarea |
||||
|
#promptInput |
||||
|
aria-describedby="ai-chat-keyboard-hint ai-chat-advice-notice" |
||||
|
matInput |
||||
|
maxlength="2000" |
||||
|
rows="3" |
||||
|
[disabled]="isBusy()" |
||||
|
[value]="prompt()" |
||||
|
(input)="onInput($event)" |
||||
|
(keydown)="onKeydown($event)" |
||||
|
></textarea> |
||||
|
<mat-hint i18n id="ai-chat-keyboard-hint" |
||||
|
>Enter to send, Shift+Enter for a new line</mat-hint |
||||
|
> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
<div class="ai-actions align-items-center d-flex flex-wrap"> |
||||
|
<button |
||||
|
aria-label="Clear AI chat" |
||||
|
i18n-aria-label |
||||
|
mat-button |
||||
|
type="button" |
||||
|
[disabled]="chat.messages.length === 0 && !isBusy()" |
||||
|
(click)="onClear()" |
||||
|
> |
||||
|
<ion-icon class="mr-1" name="trash-outline" /> |
||||
|
<ng-container i18n>Clear</ng-container> |
||||
|
</button> |
||||
|
<span class="flex-grow-1"></span> |
||||
|
@if (isBusy()) { |
||||
|
<button mat-stroked-button type="button" (click)="onStop()"> |
||||
|
<ion-icon class="mr-1" name="stop-circle-outline" /> |
||||
|
<ng-container i18n>Stop</ng-container> |
||||
|
</button> |
||||
|
} @else { |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="!prompt().trim()" |
||||
|
> |
||||
|
<ion-icon class="mr-1" name="send-outline" /> |
||||
|
<ng-container i18n>Send</ng-container> |
||||
|
</button> |
||||
|
} |
||||
|
</div> |
||||
|
</form> |
||||
|
} |
||||
|
|
||||
|
<p class="ai-notice mb-0 mt-3 text-muted" i18n id="ai-chat-advice-notice"> |
||||
|
AI-generated information may be inaccurate and is not financial, tax, or |
||||
|
legal advice. |
||||
|
</p> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
@ -0,0 +1,145 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
|
||||
|
.ai-chat-card { |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
mat-card-title { |
||||
|
min-height: 2.5rem; |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.ai-scope { |
||||
|
gap: 0.375rem; |
||||
|
} |
||||
|
|
||||
|
.ai-scope-label { |
||||
|
font-size: 0.75rem; |
||||
|
font-weight: 600; |
||||
|
letter-spacing: 0.04em; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.ai-scope-chip, |
||||
|
.ai-source-chip { |
||||
|
background-color: rgba(var(--palette-foreground-base), 0.08); |
||||
|
border-radius: 1rem; |
||||
|
display: inline-flex; |
||||
|
font-size: 0.75rem; |
||||
|
line-height: 1.5; |
||||
|
padding: 0.125rem 0.625rem; |
||||
|
} |
||||
|
|
||||
|
.ai-consent { |
||||
|
margin: 0 auto; |
||||
|
max-width: 44rem; |
||||
|
} |
||||
|
|
||||
|
.ai-messages { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 1rem; |
||||
|
max-height: min(32rem, 55vh); |
||||
|
overflow-y: auto; |
||||
|
padding: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.ai-empty-state { |
||||
|
border: 1px dashed rgba(var(--palette-foreground-base), 0.2); |
||||
|
border-radius: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.ai-message { |
||||
|
border-radius: 0.75rem; |
||||
|
max-width: min(90%, 48rem); |
||||
|
padding: 0.75rem 1rem; |
||||
|
white-space: pre-wrap; |
||||
|
} |
||||
|
|
||||
|
.ai-message-user { |
||||
|
align-self: flex-end; |
||||
|
background-color: rgba(var(--palette-primary-500), 0.12); |
||||
|
} |
||||
|
|
||||
|
.ai-message-assistant { |
||||
|
align-self: flex-start; |
||||
|
background-color: rgba(var(--palette-foreground-base), 0.06); |
||||
|
} |
||||
|
|
||||
|
.ai-message-label { |
||||
|
font-size: 0.75rem; |
||||
|
font-weight: 600; |
||||
|
margin-bottom: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.ai-markdown { |
||||
|
white-space: normal; |
||||
|
|
||||
|
> :last-child { |
||||
|
margin-bottom: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.ai-user-text { |
||||
|
overflow-wrap: anywhere; |
||||
|
white-space: pre-wrap; |
||||
|
} |
||||
|
|
||||
|
.ai-sources { |
||||
|
font-size: 0.75rem; |
||||
|
gap: 0.375rem; |
||||
|
} |
||||
|
|
||||
|
.ai-source-chip { |
||||
|
background-color: rgba(var(--palette-accent-500), 0.12); |
||||
|
} |
||||
|
|
||||
|
.ai-thinking, |
||||
|
.ai-error { |
||||
|
border-radius: 0.5rem; |
||||
|
padding: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.ai-thinking { |
||||
|
align-self: flex-start; |
||||
|
background-color: rgba(var(--palette-foreground-base), 0.05); |
||||
|
} |
||||
|
|
||||
|
.ai-error { |
||||
|
background-color: rgba(var(--palette-warn-500), 0.1); |
||||
|
} |
||||
|
|
||||
|
.ai-prompt { |
||||
|
margin-bottom: -0.5rem; |
||||
|
} |
||||
|
|
||||
|
.ai-actions { |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.ai-notice { |
||||
|
font-size: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 575.98px) { |
||||
|
.ai-message { |
||||
|
max-width: 100%; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:host-context(.theme-dark) { |
||||
|
.ai-scope-chip { |
||||
|
background-color: rgba(var(--palette-foreground-base-dark), 0.1); |
||||
|
} |
||||
|
|
||||
|
.ai-empty-state { |
||||
|
border-color: rgba(var(--palette-foreground-base-dark), 0.2); |
||||
|
} |
||||
|
|
||||
|
.ai-message-assistant, |
||||
|
.ai-thinking { |
||||
|
background-color: rgba(var(--palette-foreground-base-dark), 0.08); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,329 @@ |
|||||
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; |
||||
|
import { Filter } from '@ghostfolio/common/interfaces'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { HttpParams } from '@angular/common/http'; |
||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'; |
||||
|
import '@angular/localize/init'; |
||||
|
import { provideNoopAnimations } from '@angular/platform-browser/animations'; |
||||
|
import type { UIMessage } from 'ai'; |
||||
|
|
||||
|
import { |
||||
|
buildAiChatRequestMessages, |
||||
|
getAiChatSourceLabels, |
||||
|
GfAiChatComponent |
||||
|
} from './ai-chat.component'; |
||||
|
|
||||
|
jest.mock('@ai-sdk/angular', () => { |
||||
|
const { signal } = |
||||
|
jest.requireActual<typeof import('@angular/core')>('@angular/core'); |
||||
|
|
||||
|
class ChatMock { |
||||
|
public error: Error | undefined; |
||||
|
public status = 'ready'; |
||||
|
public stopCallCount = 0; |
||||
|
private readonly messagesSignal = signal<UIMessage[]>([]); |
||||
|
|
||||
|
public get messages() { |
||||
|
return this.messagesSignal(); |
||||
|
} |
||||
|
|
||||
|
public set messages(messages: UIMessage[]) { |
||||
|
this.messagesSignal.set(messages); |
||||
|
} |
||||
|
|
||||
|
public clearError() { |
||||
|
this.error = undefined; |
||||
|
this.status = 'ready'; |
||||
|
} |
||||
|
|
||||
|
public regenerate() { |
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
|
||||
|
public sendMessage() { |
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
|
||||
|
public stop() { |
||||
|
this.stopCallCount += 1; |
||||
|
|
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { Chat: ChatMock }; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('@ionic/angular/standalone', () => { |
||||
|
const { Component } = |
||||
|
jest.requireActual<typeof import('@angular/core')>('@angular/core'); |
||||
|
|
||||
|
class IonIconMock {} |
||||
|
|
||||
|
Component({ |
||||
|
inputs: ['name'], |
||||
|
selector: 'ion-icon', |
||||
|
standalone: true, |
||||
|
template: '' |
||||
|
})(IonIconMock); |
||||
|
|
||||
|
return { IonIcon: IonIconMock }; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('ai', () => { |
||||
|
class DefaultChatTransportMock {} |
||||
|
|
||||
|
return { |
||||
|
DefaultChatTransport: DefaultChatTransportMock, |
||||
|
isTextUIPart: (part: { type: string }) => part.type === 'text' |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('ionicons', () => { |
||||
|
return { addIcons: () => undefined }; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('ionicons/icons', () => { |
||||
|
return { |
||||
|
closeOutline: '', |
||||
|
refreshOutline: '', |
||||
|
sendOutline: '', |
||||
|
stopCircleOutline: '', |
||||
|
trashOutline: '' |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('GfAiChatComponent', () => { |
||||
|
let component: GfAiChatComponent; |
||||
|
let fixture: ComponentFixture<GfAiChatComponent>; |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
window.sessionStorage.clear(); |
||||
|
|
||||
|
await TestBed.configureTestingModule({ |
||||
|
imports: [GfAiChatComponent], |
||||
|
providers: [ |
||||
|
provideNoopAnimations(), |
||||
|
{ |
||||
|
provide: DataService, |
||||
|
useValue: { |
||||
|
buildFiltersAsQueryParams: ({ |
||||
|
filters |
||||
|
}: { |
||||
|
filters?: Filter[]; |
||||
|
}) => { |
||||
|
let queryParams = new HttpParams(); |
||||
|
|
||||
|
for (const filter of filters ?? []) { |
||||
|
if (filter.type === 'ACCOUNT') { |
||||
|
queryParams = queryParams.append('accounts', filter.id); |
||||
|
} else if (filter.type === 'TAG') { |
||||
|
queryParams = queryParams.append('tags', filter.id); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return queryParams; |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
provide: TokenStorageService, |
||||
|
useValue: { getToken: () => 'test-token' } |
||||
|
} |
||||
|
] |
||||
|
}).compileComponents(); |
||||
|
|
||||
|
fixture = TestBed.createComponent(GfAiChatComponent); |
||||
|
component = fixture.componentInstance; |
||||
|
fixture.componentRef.setInput('model', 'openai/test-model'); |
||||
|
fixture.componentRef.setInput('range', 'ytd'); |
||||
|
fixture.detectChanges(); |
||||
|
TestBed.flushEffects(); |
||||
|
}); |
||||
|
|
||||
|
it('stores only the model-specific session consent marker', () => { |
||||
|
expect(fixture.nativeElement.textContent).toContain('Before you start'); |
||||
|
expect(fixture.nativeElement.textContent).toContain('OpenRouter'); |
||||
|
expect(fixture.nativeElement.textContent).toContain( |
||||
|
'may process or retain submitted data' |
||||
|
); |
||||
|
|
||||
|
const continueButton = [ |
||||
|
...fixture.nativeElement.querySelectorAll('button') |
||||
|
].find((button: HTMLButtonElement) => { |
||||
|
return button.textContent?.includes('Continue to AI chat'); |
||||
|
}); |
||||
|
|
||||
|
continueButton?.click(); |
||||
|
fixture.detectChanges(); |
||||
|
|
||||
|
expect( |
||||
|
window.sessionStorage.getItem( |
||||
|
'ghostfolio.ai-chat.consent:openai/test-model' |
||||
|
) |
||||
|
).toBe('true'); |
||||
|
expect(window.sessionStorage.length).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it('does not render remote images or raw HTML as active content', () => { |
||||
|
const continueButton = [ |
||||
|
...fixture.nativeElement.querySelectorAll('button') |
||||
|
].find((button: HTMLButtonElement) => { |
||||
|
return button.textContent?.includes('Continue to AI chat'); |
||||
|
}); |
||||
|
|
||||
|
continueButton?.click(); |
||||
|
fixture.detectChanges(); |
||||
|
|
||||
|
const componentInternals = component as unknown as { |
||||
|
chat: { messages: UIMessage[] }; |
||||
|
}; |
||||
|
|
||||
|
componentInternals.chat.messages = [ |
||||
|
{ |
||||
|
id: 'assistant-message', |
||||
|
parts: [ |
||||
|
{ |
||||
|
text: [ |
||||
|
'', |
||||
|
'<em id="raw-html">raw html</em>', |
||||
|
'*Safe emphasis* and [Safe link](https://example.com)' |
||||
|
].join('\n\n'), |
||||
|
type: 'text' |
||||
|
} |
||||
|
], |
||||
|
role: 'assistant' |
||||
|
} |
||||
|
]; |
||||
|
fixture.changeDetectorRef.markForCheck(); |
||||
|
fixture.detectChanges(); |
||||
|
|
||||
|
expect(fixture.nativeElement.querySelector('img')).toBeNull(); |
||||
|
expect(fixture.nativeElement.querySelector('#raw-html')).toBeNull(); |
||||
|
expect(fixture.nativeElement.textContent).toContain( |
||||
|
'<em id="raw-html">raw html</em>' |
||||
|
); |
||||
|
expect( |
||||
|
fixture.nativeElement.querySelector('a[href="https://example.com/"]') |
||||
|
).not.toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('uses the active range and filters in the API request URL', () => { |
||||
|
fixture.componentRef.setInput('filters', [ |
||||
|
{ id: 'account-1', type: 'ACCOUNT' }, |
||||
|
{ id: 'tag-1', type: 'TAG' } |
||||
|
]); |
||||
|
fixture.detectChanges(); |
||||
|
|
||||
|
const componentInternals = component as unknown as { |
||||
|
getApiUrl: () => string; |
||||
|
}; |
||||
|
|
||||
|
expect(componentInternals.getApiUrl()).toBe( |
||||
|
'/api/v1/ai/chat?accounts=account-1&tags=tag-1&range=ytd' |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('clears the in-memory conversation when the scope changes', () => { |
||||
|
const componentInternals = component as unknown as { |
||||
|
chat: { messages: UIMessage[] }; |
||||
|
}; |
||||
|
|
||||
|
componentInternals.chat.messages = [ |
||||
|
{ |
||||
|
id: 'message-1', |
||||
|
parts: [{ text: 'How am I doing?', type: 'text' }], |
||||
|
role: 'user' |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
fixture.componentRef.setInput('range', '1y'); |
||||
|
fixture.detectChanges(); |
||||
|
TestBed.flushEffects(); |
||||
|
|
||||
|
expect(componentInternals.chat.messages).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
it('stops the active stream and clears messages when destroyed', () => { |
||||
|
const componentInternals = component as unknown as { |
||||
|
chat: { messages: UIMessage[]; stopCallCount: number }; |
||||
|
}; |
||||
|
|
||||
|
componentInternals.chat.messages = [ |
||||
|
{ |
||||
|
id: 'message-1', |
||||
|
parts: [{ text: 'Streaming response', type: 'text' }], |
||||
|
role: 'assistant' |
||||
|
} |
||||
|
]; |
||||
|
const stopCallCount = componentInternals.chat.stopCallCount; |
||||
|
|
||||
|
fixture.destroy(); |
||||
|
|
||||
|
expect(componentInternals.chat.stopCallCount).toBe(stopCallCount + 1); |
||||
|
expect(componentInternals.chat.messages).toEqual([]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('AI chat request messages', () => { |
||||
|
it('sends only text messages and keeps the most recent twelve', () => { |
||||
|
const messages: UIMessage[] = Array.from({ length: 13 }, (_, index) => ({ |
||||
|
id: `message-${index}`, |
||||
|
parts: [ |
||||
|
{ text: `Question ${index}`, type: 'text' }, |
||||
|
{ text: 'private reasoning', type: 'reasoning' } |
||||
|
], |
||||
|
role: 'user' |
||||
|
})); |
||||
|
|
||||
|
const requestMessages = buildAiChatRequestMessages(messages); |
||||
|
|
||||
|
expect(requestMessages).toHaveLength(12); |
||||
|
expect(requestMessages[0]).toEqual({ |
||||
|
content: 'Question 1', |
||||
|
role: 'user' |
||||
|
}); |
||||
|
expect(requestMessages.at(-1)).toEqual({ |
||||
|
content: 'Question 12', |
||||
|
role: 'user' |
||||
|
}); |
||||
|
expect(JSON.stringify(requestMessages)).not.toContain('private reasoning'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('AI chat source labels', () => { |
||||
|
it('labels only tools that returned portfolio data', () => { |
||||
|
const message = { |
||||
|
id: 'assistant-message', |
||||
|
parts: [ |
||||
|
{ |
||||
|
input: {}, |
||||
|
state: 'output-available', |
||||
|
toolCallId: 'summary-call', |
||||
|
type: 'tool-getPortfolioSummary', |
||||
|
output: { privatePayload: true } |
||||
|
}, |
||||
|
{ |
||||
|
input: {}, |
||||
|
state: 'input-available', |
||||
|
toolCallId: 'holdings-call', |
||||
|
type: 'tool-getPortfolioHoldings' |
||||
|
}, |
||||
|
{ |
||||
|
input: {}, |
||||
|
state: 'output-available', |
||||
|
toolCallId: 'performance-call', |
||||
|
type: 'tool-getPortfolioPerformance', |
||||
|
output: { privatePayload: true } |
||||
|
} |
||||
|
], |
||||
|
role: 'assistant' |
||||
|
} as unknown as UIMessage; |
||||
|
|
||||
|
expect(getAiChatSourceLabels(message)).toEqual([ |
||||
|
'Portfolio summary', |
||||
|
'Performance' |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,580 @@ |
|||||
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; |
||||
|
import { |
||||
|
DEFAULT_DATE_RANGE, |
||||
|
HEADER_KEY_TIMEZONE, |
||||
|
HEADER_KEY_TOKEN |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { Filter } from '@ghostfolio/common/interfaces'; |
||||
|
import { DateRange } from '@ghostfolio/common/types'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { Chat } from '@ai-sdk/angular'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
computed, |
||||
|
ElementRef, |
||||
|
effect, |
||||
|
inject, |
||||
|
input, |
||||
|
OnDestroy, |
||||
|
output, |
||||
|
signal, |
||||
|
untracked, |
||||
|
viewChild |
||||
|
} from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; |
||||
|
import { IonIcon } from '@ionic/angular/standalone'; |
||||
|
import { DefaultChatTransport, isTextUIPart, UIMessage } from 'ai'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { |
||||
|
closeOutline, |
||||
|
refreshOutline, |
||||
|
sendOutline, |
||||
|
stopCircleOutline, |
||||
|
trashOutline |
||||
|
} from 'ionicons/icons'; |
||||
|
|
||||
|
const AI_CHAT_CONSENT_KEY_PREFIX = 'ghostfolio.ai-chat.consent'; |
||||
|
const AI_CHAT_MAX_MESSAGE_LENGTH = 2000; |
||||
|
const AI_CHAT_MAX_MESSAGES = 12; |
||||
|
|
||||
|
interface AiChatRequestMessage { |
||||
|
content: string; |
||||
|
role: 'assistant' | 'user'; |
||||
|
} |
||||
|
|
||||
|
interface SafeMarkdownInlinePart { |
||||
|
href?: string; |
||||
|
text: string; |
||||
|
type: 'code' | 'emphasis' | 'link' | 'strong' | 'text'; |
||||
|
} |
||||
|
|
||||
|
interface SafeMarkdownListBlock { |
||||
|
items: SafeMarkdownInlinePart[][]; |
||||
|
type: 'ordered-list' | 'unordered-list'; |
||||
|
} |
||||
|
|
||||
|
interface SafeMarkdownTextBlock { |
||||
|
parts: SafeMarkdownInlinePart[]; |
||||
|
type: 'heading' | 'paragraph'; |
||||
|
} |
||||
|
|
||||
|
type AiChatToolName = |
||||
|
'getPortfolioHoldings' | 'getPortfolioPerformance' | 'getPortfolioSummary'; |
||||
|
|
||||
|
type SafeMarkdownBlock = SafeMarkdownListBlock | SafeMarkdownTextBlock; |
||||
|
|
||||
|
const filterTypeLabels: Record<Filter['type'], string> = { |
||||
|
ACCOUNT: $localize`Account`, |
||||
|
ASSET_CLASS: $localize`Asset class`, |
||||
|
ASSET_SUB_CLASS: $localize`Asset subclass`, |
||||
|
DATA_SOURCE: $localize`Data source`, |
||||
|
HOLDING_TYPE: $localize`Holding type`, |
||||
|
PRESET_ID: $localize`Preset`, |
||||
|
SEARCH_QUERY: $localize`Search`, |
||||
|
SYMBOL: $localize`Symbol`, |
||||
|
TAG: $localize`Tag` |
||||
|
}; |
||||
|
|
||||
|
const sourceLabelsByToolName: Record<AiChatToolName, string> = { |
||||
|
getPortfolioHoldings: $localize`Holdings`, |
||||
|
getPortfolioPerformance: $localize`Performance`, |
||||
|
getPortfolioSummary: $localize`Portfolio summary` |
||||
|
}; |
||||
|
|
||||
|
export function buildAiChatRequestMessages( |
||||
|
messages: UIMessage[] |
||||
|
): AiChatRequestMessage[] { |
||||
|
const requestMessages: AiChatRequestMessage[] = []; |
||||
|
|
||||
|
for (const message of messages) { |
||||
|
if (message.role !== 'assistant' && message.role !== 'user') { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const content = message.parts |
||||
|
.filter(isTextUIPart) |
||||
|
.map((part) => part.text) |
||||
|
.join('\n\n') |
||||
|
.trim() |
||||
|
.slice(0, AI_CHAT_MAX_MESSAGE_LENGTH); |
||||
|
|
||||
|
if (content) { |
||||
|
requestMessages.push({ |
||||
|
content, |
||||
|
role: message.role |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return requestMessages.slice(-AI_CHAT_MAX_MESSAGES); |
||||
|
} |
||||
|
|
||||
|
export function getAiChatSourceLabels(message: UIMessage): string[] { |
||||
|
const sourceLabels = new Set<string>(); |
||||
|
|
||||
|
for (const part of message.parts) { |
||||
|
if (!('state' in part) || part.state !== 'output-available') { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const toolName = |
||||
|
part.type === 'dynamic-tool' |
||||
|
? part.toolName |
||||
|
: part.type.startsWith('tool-') |
||||
|
? part.type.slice('tool-'.length) |
||||
|
: undefined; |
||||
|
|
||||
|
if (toolName && toolName in sourceLabelsByToolName) { |
||||
|
sourceLabels.add(sourceLabelsByToolName[toolName as AiChatToolName]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return [...sourceLabels]; |
||||
|
} |
||||
|
|
||||
|
function getSafeLink(value: string) { |
||||
|
try { |
||||
|
const url = new URL(value); |
||||
|
|
||||
|
if (url.protocol === 'http:' || url.protocol === 'https:') { |
||||
|
return url.toString(); |
||||
|
} |
||||
|
} catch { |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
function parseSafeMarkdownInline(value: string) { |
||||
|
const parts: SafeMarkdownInlinePart[] = []; |
||||
|
let remainingValue = value; |
||||
|
|
||||
|
const appendText = (text: string) => { |
||||
|
const previousPart = parts.at(-1); |
||||
|
|
||||
|
if (previousPart?.type === 'text') { |
||||
|
previousPart.text += text; |
||||
|
} else { |
||||
|
parts.push({ text, type: 'text' }); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
while (remainingValue) { |
||||
|
const imageMatch = |
||||
|
/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)/.exec( |
||||
|
remainingValue |
||||
|
); |
||||
|
|
||||
|
if (imageMatch) { |
||||
|
const href = getSafeLink(imageMatch[2]); |
||||
|
const text = `${$localize`Image`}: ${imageMatch[1] || $localize`Untitled`}`; |
||||
|
|
||||
|
parts.push(href ? { href, text, type: 'link' } : { text, type: 'text' }); |
||||
|
remainingValue = remainingValue.slice(imageMatch[0].length); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const linkMatch = |
||||
|
/^\[([^\]]+)\]\(([^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)/.exec( |
||||
|
remainingValue |
||||
|
); |
||||
|
|
||||
|
if (linkMatch) { |
||||
|
const href = getSafeLink(linkMatch[2]); |
||||
|
|
||||
|
parts.push( |
||||
|
href |
||||
|
? { href, text: linkMatch[1], type: 'link' } |
||||
|
: { text: linkMatch[1], type: 'text' } |
||||
|
); |
||||
|
remainingValue = remainingValue.slice(linkMatch[0].length); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const strongMatch = |
||||
|
/^\*\*([^*\n]+)\*\*/.exec(remainingValue) ?? |
||||
|
/^__([^_\n]+)__/.exec(remainingValue); |
||||
|
|
||||
|
if (strongMatch) { |
||||
|
parts.push({ text: strongMatch[1], type: 'strong' }); |
||||
|
remainingValue = remainingValue.slice(strongMatch[0].length); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const emphasisMatch = |
||||
|
/^\*([^*\n]+)\*/.exec(remainingValue) ?? |
||||
|
/^_([^_\n]+)_/.exec(remainingValue); |
||||
|
|
||||
|
if (emphasisMatch) { |
||||
|
parts.push({ text: emphasisMatch[1], type: 'emphasis' }); |
||||
|
remainingValue = remainingValue.slice(emphasisMatch[0].length); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const codeMatch = /^`([^`\n]+)`/.exec(remainingValue); |
||||
|
|
||||
|
if (codeMatch) { |
||||
|
parts.push({ text: codeMatch[1], type: 'code' }); |
||||
|
remainingValue = remainingValue.slice(codeMatch[0].length); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
appendText(remainingValue[0]); |
||||
|
remainingValue = remainingValue.slice(1); |
||||
|
} |
||||
|
|
||||
|
return parts; |
||||
|
} |
||||
|
|
||||
|
export function parseSafeMarkdown(value: string): SafeMarkdownBlock[] { |
||||
|
const blocks: SafeMarkdownBlock[] = []; |
||||
|
let paragraphLines: string[] = []; |
||||
|
|
||||
|
const flushParagraph = () => { |
||||
|
if (paragraphLines.length > 0) { |
||||
|
blocks.push({ |
||||
|
parts: parseSafeMarkdownInline(paragraphLines.join(' ')), |
||||
|
type: 'paragraph' |
||||
|
}); |
||||
|
paragraphLines = []; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
for (const line of value.replace(/\r\n?/g, '\n').split('\n')) { |
||||
|
if (!line.trim()) { |
||||
|
flushParagraph(); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const headingMatch = /^#{1,6}\s+(.+)$/.exec(line); |
||||
|
|
||||
|
if (headingMatch) { |
||||
|
flushParagraph(); |
||||
|
blocks.push({ |
||||
|
parts: parseSafeMarkdownInline(headingMatch[1]), |
||||
|
type: 'heading' |
||||
|
}); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const unorderedListMatch = /^\s*[-+*]\s+(.+)$/.exec(line); |
||||
|
const orderedListMatch = /^\s*\d+[.)]\s+(.+)$/.exec(line); |
||||
|
const listMatch = unorderedListMatch ?? orderedListMatch; |
||||
|
|
||||
|
if (listMatch) { |
||||
|
flushParagraph(); |
||||
|
|
||||
|
const type = unorderedListMatch ? 'unordered-list' : 'ordered-list'; |
||||
|
const previousBlock = blocks.at(-1); |
||||
|
const item = parseSafeMarkdownInline(listMatch[1]); |
||||
|
|
||||
|
if (previousBlock?.type === type) { |
||||
|
previousBlock.items.push(item); |
||||
|
} else { |
||||
|
blocks.push({ items: [item], type }); |
||||
|
} |
||||
|
|
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
paragraphLines.push(line); |
||||
|
} |
||||
|
|
||||
|
flushParagraph(); |
||||
|
|
||||
|
return blocks; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
selector: 'gf-ai-chat-inline', |
||||
|
standalone: true, |
||||
|
template: ` |
||||
|
@for (part of parts(); track $index) { |
||||
|
@switch (part.type) { |
||||
|
@case ('strong') { |
||||
|
<strong>{{ part.text }}</strong> |
||||
|
} |
||||
|
@case ('emphasis') { |
||||
|
<em>{{ part.text }}</em> |
||||
|
} |
||||
|
@case ('code') { |
||||
|
<code>{{ part.text }}</code> |
||||
|
} |
||||
|
@case ('link') { |
||||
|
<a |
||||
|
rel="nofollow noopener noreferrer" |
||||
|
target="_blank" |
||||
|
[href]="part.href" |
||||
|
>{{ part.text }}</a |
||||
|
> |
||||
|
} |
||||
|
@default { |
||||
|
{{ part.text }} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
` |
||||
|
}) |
||||
|
class GfAiChatInlineComponent { |
||||
|
public readonly parts = input.required<SafeMarkdownInlinePart[]>(); |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
GfAiChatInlineComponent, |
||||
|
IonIcon, |
||||
|
MatButtonModule, |
||||
|
MatCardModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatProgressSpinnerModule |
||||
|
], |
||||
|
selector: 'gf-ai-chat', |
||||
|
styleUrls: ['./ai-chat.component.scss'], |
||||
|
templateUrl: './ai-chat.component.html' |
||||
|
}) |
||||
|
export class GfAiChatComponent implements OnDestroy { |
||||
|
public readonly filters = input<Filter[]>([]); |
||||
|
public readonly model = input(''); |
||||
|
public readonly range = input<DateRange>(DEFAULT_DATE_RANGE); |
||||
|
|
||||
|
public readonly closed = output<void>(); |
||||
|
|
||||
|
protected readonly chat = new Chat({ |
||||
|
transport: new DefaultChatTransport({ |
||||
|
api: '/api/v1/ai/chat', |
||||
|
credentials: 'same-origin', |
||||
|
headers: () => { |
||||
|
const headers: Record<string, string> = { |
||||
|
[HEADER_KEY_TIMEZONE]: |
||||
|
Intl?.DateTimeFormat().resolvedOptions().timeZone |
||||
|
}; |
||||
|
const token = this.tokenStorageService.getToken(); |
||||
|
|
||||
|
if (token) { |
||||
|
headers[HEADER_KEY_TOKEN] = `Bearer ${token}`; |
||||
|
} |
||||
|
|
||||
|
return headers; |
||||
|
}, |
||||
|
prepareSendMessagesRequest: ({ headers, messages }) => { |
||||
|
return { |
||||
|
api: this.getApiUrl(), |
||||
|
body: { |
||||
|
messages: buildAiChatRequestMessages(messages) |
||||
|
}, |
||||
|
credentials: 'same-origin', |
||||
|
headers |
||||
|
}; |
||||
|
} |
||||
|
}) |
||||
|
}); |
||||
|
protected readonly hasConsent = signal(false); |
||||
|
protected readonly isBusy = computed(() => { |
||||
|
return this.chat.status === 'streaming' || this.chat.status === 'submitted'; |
||||
|
}); |
||||
|
protected readonly prompt = signal(''); |
||||
|
protected readonly scopeChips = computed(() => { |
||||
|
const chips = [ |
||||
|
`${$localize`Date range`}: ${this.getDateRangeLabel(this.range())}` |
||||
|
]; |
||||
|
|
||||
|
for (const filter of this.filters()) { |
||||
|
chips.push( |
||||
|
`${filterTypeLabels[filter.type]}: ${filter.label ?? filter.id}` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (this.filters().length === 0) { |
||||
|
chips.push($localize`All portfolio data`); |
||||
|
} |
||||
|
|
||||
|
return chips; |
||||
|
}); |
||||
|
|
||||
|
private readonly promptElement = |
||||
|
viewChild<ElementRef<HTMLTextAreaElement>>('promptInput'); |
||||
|
private readonly shouldFocusPrompt = signal(true); |
||||
|
private readonly scopeSignature = computed(() => { |
||||
|
const filters = this.filters() |
||||
|
.map(({ id, type }) => ({ id, type })) |
||||
|
.sort((filterA, filterB) => { |
||||
|
return `${filterA.type}:${filterA.id}`.localeCompare( |
||||
|
`${filterB.type}:${filterB.id}` |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
return JSON.stringify({ |
||||
|
filters, |
||||
|
model: this.model(), |
||||
|
range: this.range() |
||||
|
}); |
||||
|
}); |
||||
|
private readonly tokenStorageService = inject(TokenStorageService); |
||||
|
private readonly dataService = inject(DataService); |
||||
|
|
||||
|
public constructor() { |
||||
|
let previousScopeSignature: string | undefined; |
||||
|
|
||||
|
effect(() => { |
||||
|
const model = this.model(); |
||||
|
const scopeSignature = this.scopeSignature(); |
||||
|
|
||||
|
untracked(() => { |
||||
|
this.hasConsent.set( |
||||
|
!!model && |
||||
|
window.sessionStorage.getItem(this.getConsentKey(model)) === 'true' |
||||
|
); |
||||
|
|
||||
|
if ( |
||||
|
previousScopeSignature !== undefined && |
||||
|
previousScopeSignature !== scopeSignature |
||||
|
) { |
||||
|
this.resetChat(); |
||||
|
} |
||||
|
|
||||
|
previousScopeSignature = scopeSignature; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
effect(() => { |
||||
|
const hasConsent = this.hasConsent(); |
||||
|
const promptElement = this.promptElement(); |
||||
|
const shouldFocusPrompt = this.shouldFocusPrompt(); |
||||
|
|
||||
|
if (hasConsent && promptElement && shouldFocusPrompt) { |
||||
|
queueMicrotask(() => { |
||||
|
promptElement.nativeElement.focus(); |
||||
|
untracked(() => { |
||||
|
this.shouldFocusPrompt.set(false); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
addIcons({ |
||||
|
closeOutline, |
||||
|
refreshOutline, |
||||
|
sendOutline, |
||||
|
stopCircleOutline, |
||||
|
trashOutline |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.resetChat(); |
||||
|
} |
||||
|
|
||||
|
protected getMessageText(message: UIMessage) { |
||||
|
return message.parts |
||||
|
.filter(isTextUIPart) |
||||
|
.map((part) => part.text) |
||||
|
.join('\n\n'); |
||||
|
} |
||||
|
|
||||
|
protected getSafeMarkdownBlocks(message: UIMessage) { |
||||
|
return parseSafeMarkdown(this.getMessageText(message)); |
||||
|
} |
||||
|
|
||||
|
protected getSourceLabels(message: UIMessage) { |
||||
|
return getAiChatSourceLabels(message); |
||||
|
} |
||||
|
|
||||
|
protected onAcceptConsent() { |
||||
|
window.sessionStorage.setItem(this.getConsentKey(this.model()), 'true'); |
||||
|
this.shouldFocusPrompt.set(true); |
||||
|
this.hasConsent.set(true); |
||||
|
} |
||||
|
|
||||
|
protected onClear() { |
||||
|
this.resetChat(); |
||||
|
} |
||||
|
|
||||
|
protected onClose() { |
||||
|
this.resetChat(); |
||||
|
this.closed.emit(); |
||||
|
} |
||||
|
|
||||
|
protected onInput(event: Event) { |
||||
|
this.prompt.set((event.target as HTMLTextAreaElement).value); |
||||
|
} |
||||
|
|
||||
|
protected onKeydown(event: KeyboardEvent) { |
||||
|
if ( |
||||
|
event.key === 'Enter' && |
||||
|
!event.shiftKey && |
||||
|
!event.isComposing && |
||||
|
!this.isBusy() |
||||
|
) { |
||||
|
event.preventDefault(); |
||||
|
this.sendMessage(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected onRetry() { |
||||
|
this.chat.clearError(); |
||||
|
void this.chat.regenerate(); |
||||
|
} |
||||
|
|
||||
|
protected onStop() { |
||||
|
void this.chat.stop(); |
||||
|
} |
||||
|
|
||||
|
protected onSubmit(event: Event) { |
||||
|
event.preventDefault(); |
||||
|
this.sendMessage(); |
||||
|
} |
||||
|
|
||||
|
private getApiUrl() { |
||||
|
const queryParams = this.dataService |
||||
|
.buildFiltersAsQueryParams({ filters: this.filters() }) |
||||
|
.append('range', this.range()); |
||||
|
|
||||
|
return `/api/v1/ai/chat?${queryParams.toString()}`; |
||||
|
} |
||||
|
|
||||
|
private getConsentKey(model: string) { |
||||
|
return `${AI_CHAT_CONSENT_KEY_PREFIX}:${model}`; |
||||
|
} |
||||
|
|
||||
|
private getDateRangeLabel(range: DateRange) { |
||||
|
const labels: Record<string, string> = { |
||||
|
'1d': $localize`Today`, |
||||
|
'1y': $localize`1 year`, |
||||
|
'5y': $localize`5 years`, |
||||
|
max: $localize`Max`, |
||||
|
mtd: $localize`Month to date`, |
||||
|
wtd: $localize`Week to date`, |
||||
|
ytd: $localize`Year to date` |
||||
|
}; |
||||
|
|
||||
|
return labels[range] ?? range; |
||||
|
} |
||||
|
|
||||
|
private resetChat() { |
||||
|
void this.chat.stop(); |
||||
|
this.chat.messages = []; |
||||
|
this.chat.clearError(); |
||||
|
this.prompt.set(''); |
||||
|
} |
||||
|
|
||||
|
private sendMessage() { |
||||
|
const prompt = this.prompt().trim(); |
||||
|
|
||||
|
if (!this.hasConsent() || !prompt || this.isBusy()) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.prompt.set(''); |
||||
|
void this.chat.sendMessage({ text: prompt }); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,102 @@ |
|||||
|
import '@angular/localize/init'; |
||||
|
|
||||
|
import { GfAnalysisPageComponent } from './analysis-page.component'; |
||||
|
|
||||
|
jest.mock('@ionic/angular/standalone', () => { |
||||
|
const { Component } = |
||||
|
jest.requireActual<typeof import('@angular/core')>('@angular/core'); |
||||
|
|
||||
|
class IonIconMock {} |
||||
|
|
||||
|
Component({ |
||||
|
inputs: ['name'], |
||||
|
selector: 'ion-icon', |
||||
|
standalone: true, |
||||
|
template: '' |
||||
|
})(IonIconMock); |
||||
|
|
||||
|
return { IonIcon: IonIconMock }; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('ionicons', () => { |
||||
|
return { addIcons: () => undefined }; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('ionicons/icons', () => { |
||||
|
return { |
||||
|
copyOutline: '', |
||||
|
diamondOutline: '', |
||||
|
ellipsisVertical: '', |
||||
|
sparklesOutline: '' |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('./ai-chat.component', () => { |
||||
|
const { Component } = |
||||
|
jest.requireActual<typeof import('@angular/core')>('@angular/core'); |
||||
|
|
||||
|
class GfAiChatComponentMock {} |
||||
|
|
||||
|
Component({ |
||||
|
selector: 'gf-ai-chat', |
||||
|
standalone: true, |
||||
|
template: '' |
||||
|
})(GfAiChatComponentMock); |
||||
|
|
||||
|
return { GfAiChatComponent: GfAiChatComponentMock }; |
||||
|
}); |
||||
|
|
||||
|
describe('GfAnalysisPageComponent AI chat eligibility', () => { |
||||
|
const canUseAiChat = ( |
||||
|
eligibility: { |
||||
|
hasGlobalPermission?: boolean; |
||||
|
hasImpersonationId?: boolean; |
||||
|
hasUserPermission?: boolean; |
||||
|
isExperimentalFeatures?: boolean; |
||||
|
model?: string; |
||||
|
} = {} |
||||
|
) => { |
||||
|
const { |
||||
|
hasGlobalPermission = true, |
||||
|
hasImpersonationId = false, |
||||
|
hasUserPermission = true, |
||||
|
isExperimentalFeatures = true |
||||
|
} = eligibility; |
||||
|
const model = Object.hasOwn(eligibility, 'model') |
||||
|
? eligibility.model |
||||
|
: 'openai/test-model'; |
||||
|
|
||||
|
const component = Object.create( |
||||
|
GfAnalysisPageComponent.prototype |
||||
|
) as unknown as { |
||||
|
aiChatModel?: string; |
||||
|
canUseAiChat: boolean; |
||||
|
hasImpersonationId: boolean; |
||||
|
hasPermissionToAccessAiChat: boolean; |
||||
|
hasPermissionToUseAiChat: boolean; |
||||
|
user: { settings: { isExperimentalFeatures: boolean } }; |
||||
|
}; |
||||
|
|
||||
|
component.aiChatModel = model; |
||||
|
component.hasImpersonationId = hasImpersonationId; |
||||
|
component.hasPermissionToAccessAiChat = hasUserPermission; |
||||
|
component.hasPermissionToUseAiChat = hasGlobalPermission; |
||||
|
component.user = { settings: { isExperimentalFeatures } }; |
||||
|
|
||||
|
return component.canUseAiChat; |
||||
|
}; |
||||
|
|
||||
|
it('allows a fully eligible user', () => { |
||||
|
expect(canUseAiChat({})).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it.each([ |
||||
|
{ model: undefined }, |
||||
|
{ hasGlobalPermission: false }, |
||||
|
{ hasUserPermission: false }, |
||||
|
{ isExperimentalFeatures: false }, |
||||
|
{ hasImpersonationId: true } |
||||
|
])('blocks an ineligible context (%o)', (eligibility) => { |
||||
|
expect(canUseAiChat(eligibility)).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue