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