Browse Source

Add Analysis AI chat panel

pull/7444/head
Ross Kuehl 1 month ago
parent
commit
dd0426a9ae
Failed to extract signature
  1. 262
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html
  2. 145
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.scss
  3. 329
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.spec.ts
  4. 580
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts
  5. 102
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.spec.ts
  6. 68
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  7. 31
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html

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

@ -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>

145
apps/client/src/app/pages/portfolio/analysis/ai-chat.component.scss

@ -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);
}
}

329
apps/client/src/app/pages/portfolio/analysis/ai-chat.component.spec.ts

@ -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: [
'![Remote chart](https://tracker.example/chart.png)',
'<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'
]);
});
});

580
apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts

@ -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 });
}
}

102
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.spec.ts

@ -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);
});
});

68
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -8,6 +8,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { canOpenHoldingDetail } from '@ghostfolio/common/helper'; import { canOpenHoldingDetail } from '@ghostfolio/common/helper';
import { import {
Filter,
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
PortfolioInvestmentsResponse, PortfolioInvestmentsResponse,
@ -31,6 +32,7 @@ import {
Component, Component,
computed, computed,
DestroyRef, DestroyRef,
ElementRef,
inject, inject,
OnInit, OnInit,
signal, signal,
@ -46,15 +48,18 @@ import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { copyOutline, ellipsisVertical } from 'ionicons/icons'; import { copyOutline, ellipsisVertical, sparklesOutline } from 'ionicons/icons';
import { isNumber, sortBy } from 'lodash'; import { isNumber, sortBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfAiChatComponent } from './ai-chat.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfAiChatComponent,
GfBenchmarkComparatorComponent, GfBenchmarkComparatorComponent,
GfInvestmentChartComponent, GfInvestmentChartComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
@ -73,6 +78,8 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
}) })
export class GfAnalysisPageComponent implements OnInit { export class GfAnalysisPageComponent implements OnInit {
protected aiChatFilters: Filter[] = [];
protected readonly aiChatModel?: string;
protected benchmark?: Partial<SymbolProfile>; protected benchmark?: Partial<SymbolProfile>;
protected benchmarkDataItems: HistoricalDataItem[] = []; protected benchmarkDataItems: HistoricalDataItem[] = [];
protected readonly benchmarks: Partial<SymbolProfile>[]; protected readonly benchmarks: Partial<SymbolProfile>[];
@ -80,7 +87,9 @@ export class GfAnalysisPageComponent implements OnInit {
protected dividendsByGroup: InvestmentItem[]; protected dividendsByGroup: InvestmentItem[];
protected readonly dividendTimelineDataLabel = $localize`Dividend`; protected readonly dividendTimelineDataLabel = $localize`Dividend`;
protected hasImpersonationId: boolean; protected hasImpersonationId: boolean;
protected hasPermissionToAccessAiChat: boolean;
protected hasPermissionToReadAiPrompt: boolean; protected hasPermissionToReadAiPrompt: boolean;
protected readonly hasPermissionToUseAiChat: boolean;
protected investments: InvestmentItem[]; protected investments: InvestmentItem[];
protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; protected readonly investmentTimelineDataLabel = $localize`Invested Capital`;
protected investmentsByGroup: InvestmentItem[]; protected investmentsByGroup: InvestmentItem[];
@ -90,6 +99,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected isLoadingInvestmentChart: boolean; protected isLoadingInvestmentChart: boolean;
protected isLoadingInvestmentTimelineChart: boolean; protected isLoadingInvestmentTimelineChart: boolean;
protected isLoadingPortfolioPrompt: boolean; protected isLoadingPortfolioPrompt: boolean;
protected isAiChatOpen = false;
protected readonly mode = signal<GroupBy>('month'); protected readonly mode = signal<GroupBy>('month');
protected readonly modeOptions: ToggleOption[] = [ protected readonly modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' }, { label: $localize`Monthly`, value: 'month' },
@ -107,6 +117,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected user: User; protected user: User;
private readonly actionsMenuButton = viewChild.required(MatMenuTrigger); private readonly actionsMenuButton = viewChild.required(MatMenuTrigger);
private readonly aiChatTrigger =
viewChild<ElementRef<HTMLButtonElement>>('aiChatTrigger');
private readonly deviceType = computed( private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
@ -124,10 +136,26 @@ export class GfAnalysisPageComponent implements OnInit {
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
const { benchmarks } = this.dataService.fetchInfo(); const { aiChatModel, benchmarks, globalPermissions } =
this.dataService.fetchInfo();
this.aiChatModel = aiChatModel;
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.hasPermissionToUseAiChat = hasPermission(
globalPermissions,
permissions.enableAiChat
);
addIcons({ copyOutline, ellipsisVertical }); addIcons({ copyOutline, ellipsisVertical, sparklesOutline });
}
get canUseAiChat() {
return (
!!this.aiChatModel &&
this.hasPermissionToUseAiChat &&
this.hasPermissionToAccessAiChat &&
!!this.user?.settings?.isExperimentalFeatures &&
!this.hasImpersonationId
);
} }
get savingsRate() { get savingsRate() {
@ -152,6 +180,10 @@ export class GfAnalysisPageComponent implements OnInit {
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
if (this.hasImpersonationId) {
this.isAiChatOpen = false;
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -160,16 +192,34 @@ export class GfAnalysisPageComponent implements OnInit {
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.aiChatFilters = this.userService.getFilters().map((filter) => {
const label =
(filter.type === 'ACCOUNT'
? this.user.accounts.find(({ id }) => id === filter.id)?.name
: filter.type === 'TAG'
? this.user.tags.find(({ id }) => id === filter.id)?.name
: undefined) ?? undefined;
return { ...filter, label };
});
this.benchmark = this.benchmarks.find(({ id }) => { this.benchmark = this.benchmarks.find(({ id }) => {
return id === this.user.settings?.benchmark; return id === this.user.settings?.benchmark;
}); });
this.hasPermissionToAccessAiChat = hasPermission(
this.user.permissions,
permissions.accessAiChat
);
this.hasPermissionToReadAiPrompt = hasPermission( this.hasPermissionToReadAiPrompt = hasPermission(
this.user.permissions, this.user.permissions,
permissions.readAiPrompt permissions.readAiPrompt
); );
if (!this.canUseAiChat) {
this.isAiChatOpen = false;
}
this.update(); this.update();
} }
@ -198,6 +248,14 @@ export class GfAnalysisPageComponent implements OnInit {
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
} }
protected onCloseAiChat() {
this.isAiChatOpen = false;
queueMicrotask(() => {
this.aiChatTrigger()?.nativeElement.focus();
});
}
protected onCopyPromptToClipboard(mode: AiPromptMode) { protected onCopyPromptToClipboard(mode: AiPromptMode) {
if (mode === 'analysis') { if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = true; this.isLoadingAnalysisPrompt = true;
@ -241,6 +299,10 @@ export class GfAnalysisPageComponent implements OnInit {
}); });
} }
protected onToggleAiChat() {
this.isAiChatOpen = !this.isAiChatOpen;
}
private fetchDividendsAndInvestments() { private fetchDividendsAndInvestments() {
this.isLoadingDividendTimelineChart = true; this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true; this.isLoadingInvestmentTimelineChart = true;

31
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -3,7 +3,26 @@
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-lg"> <div class="col-lg">
<div class="d-flex justify-content-end"> <div class="d-flex flex-wrap justify-content-end">
@if (canUseAiChat) {
<button
#aiChatTrigger
aria-controls="ai-portfolio-chat-panel"
class="mx-1"
color="primary"
mat-stroked-button
type="button"
[attr.aria-expanded]="isAiChatOpen"
(click)="onToggleAiChat()"
>
<ion-icon class="mr-2" name="sparkles-outline" />
@if (isAiChatOpen) {
<ng-container i18n>Close AI portfolio chat</ng-container>
} @else {
<ng-container i18n>Ask AI about this portfolio</ng-container>
}
</button>
}
<button <button
#actionsMenuButton #actionsMenuButton
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
@ -75,6 +94,16 @@
</div> </div>
} }
@if (canUseAiChat && isAiChatOpen) {
<gf-ai-chat
class="mb-5"
[filters]="aiChatFilters"
[model]="aiChatModel"
[range]="user?.settings?.dateRange ?? 'max'"
(closed)="onCloseAiChat()"
/>
}
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-lg-4 mb-3 mb-lg-0"> <div class="col-lg-4 mb-3 mb-lg-0">
<mat-card appearance="outlined"> <mat-card appearance="outlined">

Loading…
Cancel
Save