From dd0426a9aea787def23dd61a2b7f9a46a987d335 Mon Sep 17 00:00:00 2001 From: Ross Kuehl <168792663+shoyu-ramen@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:52:45 -0400 Subject: [PATCH] Add Analysis AI chat panel --- .../portfolio/analysis/ai-chat.component.html | 262 ++++++++ .../portfolio/analysis/ai-chat.component.scss | 145 +++++ .../analysis/ai-chat.component.spec.ts | 329 ++++++++++ .../portfolio/analysis/ai-chat.component.ts | 580 ++++++++++++++++++ .../analysis/analysis-page.component.spec.ts | 102 +++ .../analysis/analysis-page.component.ts | 68 +- .../portfolio/analysis/analysis-page.html | 31 +- 7 files changed, 1513 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html create mode 100644 apps/client/src/app/pages/portfolio/analysis/ai-chat.component.scss create mode 100644 apps/client/src/app/pages/portfolio/analysis/ai-chat.component.spec.ts create mode 100644 apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts create mode 100644 apps/client/src/app/pages/portfolio/analysis/analysis-page.component.spec.ts diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html new file mode 100644 index 0000000000..c8ebb608ed --- /dev/null +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html @@ -0,0 +1,262 @@ + + + + AI portfolio chat + + + + + +
+ Scope + @for (chip of scopeChips(); track chip) { + {{ chip }} + } +
+ + @if (!hasConsent()) { + + } @else { +
+ @if (chat.messages.length === 0) { +
+

Ask about the portfolio shown above.

+ The assistant can use portfolio summary, holdings, and + performance data. +
+ } + + @for (message of chat.messages; track message.id) { + @if (getMessageText(message)) { +
+
+ @if (message.role === 'user') { + You + } @else { + AI + } +
+ + @if (message.role === 'assistant') { +
+ @for (block of getSafeMarkdownBlocks(message); track $index) { + @switch (block.type) { + @case ('heading') { +

+ +

+ } + @case ('ordered-list') { +
    + @for (item of block.items; track $index) { +
  1. + +
  2. + } +
+ } + @case ('unordered-list') { +
    + @for (item of block.items; track $index) { +
  • + +
  • + } +
+ } + @default { +

+ +

+ } + } + } +
+ } @else { +
{{ getMessageText(message) }}
+ } + + @if (getSourceLabels(message).length > 0) { +
+ Sources + @for (source of getSourceLabels(message); track source) { + {{ source }} + } +
+ } +
+ } + } + + @if (chat.status === 'submitted') { +
+ + Reviewing the portfolio data... +
+ } + + @if (chat.status === 'streaming') { + The AI response is being generated. + } @else if ( + chat.status === 'ready' && + chat.messages.length > 0 && + chat.messages.at(-1)?.role === 'assistant' + ) { + The AI response is complete. + } +
+ + @if (chat.status === 'error') { + + } + +
+ + Ask about this portfolio + + Enter to send, Shift+Enter for a new line + + +
+ + + @if (isBusy()) { + + } @else { + + } +
+
+ } + +

+ AI-generated information may be inaccurate and is not financial, tax, or + legal advice. +

+
+
diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.scss b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.scss new file mode 100644 index 0000000000..576539b2ca --- /dev/null +++ b/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); + } +} diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.spec.ts b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.spec.ts new file mode 100644 index 0000000000..1048d924a4 --- /dev/null +++ b/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('@angular/core'); + + class ChatMock { + public error: Error | undefined; + public status = 'ready'; + public stopCallCount = 0; + private readonly messagesSignal = signal([]); + + 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('@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; + + 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)', + 'raw html', + '*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( + 'raw html' + ); + 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' + ]); + }); +}); diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts new file mode 100644 index 0000000000..15df221115 --- /dev/null +++ b/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 = { + 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 = { + 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(); + + 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') { + {{ part.text }} + } + @case ('emphasis') { + {{ part.text }} + } + @case ('code') { + {{ part.text }} + } + @case ('link') { + {{ part.text }} + } + @default { + {{ part.text }} + } + } + } + ` +}) +class GfAiChatInlineComponent { + public readonly parts = input.required(); +} + +@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([]); + public readonly model = input(''); + public readonly range = input(DEFAULT_DATE_RANGE); + + public readonly closed = output(); + + protected readonly chat = new Chat({ + transport: new DefaultChatTransport({ + api: '/api/v1/ai/chat', + credentials: 'same-origin', + headers: () => { + const headers: Record = { + [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>('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 = { + '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 }); + } +} diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.spec.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.spec.ts new file mode 100644 index 0000000000..a2413af9ae --- /dev/null +++ b/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('@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('@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); + }); +}); diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index c2d23dbc69..e872855297 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -8,6 +8,7 @@ import { } from '@ghostfolio/common/config'; import { canOpenHoldingDetail } from '@ghostfolio/common/helper'; import { + Filter, HistoricalDataItem, InvestmentItem, PortfolioInvestmentsResponse, @@ -31,6 +32,7 @@ import { Component, computed, DestroyRef, + ElementRef, inject, OnInit, signal, @@ -46,15 +48,18 @@ import { RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; import { SymbolProfile } from '@prisma/client'; import { addIcons } from 'ionicons'; -import { copyOutline, ellipsisVertical } from 'ionicons/icons'; +import { copyOutline, ellipsisVertical, sparklesOutline } from 'ionicons/icons'; import { isNumber, sortBy } from 'lodash'; import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { GfAiChatComponent } from './ai-chat.component'; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + GfAiChatComponent, GfBenchmarkComparatorComponent, GfInvestmentChartComponent, GfPremiumIndicatorComponent, @@ -73,6 +78,8 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './analysis-page.html' }) export class GfAnalysisPageComponent implements OnInit { + protected aiChatFilters: Filter[] = []; + protected readonly aiChatModel?: string; protected benchmark?: Partial; protected benchmarkDataItems: HistoricalDataItem[] = []; protected readonly benchmarks: Partial[]; @@ -80,7 +87,9 @@ export class GfAnalysisPageComponent implements OnInit { protected dividendsByGroup: InvestmentItem[]; protected readonly dividendTimelineDataLabel = $localize`Dividend`; protected hasImpersonationId: boolean; + protected hasPermissionToAccessAiChat: boolean; protected hasPermissionToReadAiPrompt: boolean; + protected readonly hasPermissionToUseAiChat: boolean; protected investments: InvestmentItem[]; protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; protected investmentsByGroup: InvestmentItem[]; @@ -90,6 +99,7 @@ export class GfAnalysisPageComponent implements OnInit { protected isLoadingInvestmentChart: boolean; protected isLoadingInvestmentTimelineChart: boolean; protected isLoadingPortfolioPrompt: boolean; + protected isAiChatOpen = false; protected readonly mode = signal('month'); protected readonly modeOptions: ToggleOption[] = [ { label: $localize`Monthly`, value: 'month' }, @@ -107,6 +117,8 @@ export class GfAnalysisPageComponent implements OnInit { protected user: User; private readonly actionsMenuButton = viewChild.required(MatMenuTrigger); + private readonly aiChatTrigger = + viewChild>('aiChatTrigger'); private readonly deviceType = computed( () => this.deviceDetectorService.deviceInfo().deviceType ); @@ -124,10 +136,26 @@ export class GfAnalysisPageComponent implements OnInit { private readonly userService = inject(UserService); public constructor() { - const { benchmarks } = this.dataService.fetchInfo(); + const { aiChatModel, benchmarks, globalPermissions } = + this.dataService.fetchInfo(); + this.aiChatModel = aiChatModel; this.benchmarks = benchmarks; + this.hasPermissionToUseAiChat = hasPermission( + globalPermissions, + permissions.enableAiChat + ); + + addIcons({ copyOutline, ellipsisVertical, sparklesOutline }); + } - addIcons({ copyOutline, ellipsisVertical }); + get canUseAiChat() { + return ( + !!this.aiChatModel && + this.hasPermissionToUseAiChat && + this.hasPermissionToAccessAiChat && + !!this.user?.settings?.isExperimentalFeatures && + !this.hasImpersonationId + ); } get savingsRate() { @@ -152,6 +180,10 @@ export class GfAnalysisPageComponent implements OnInit { .subscribe((impersonationId) => { this.hasImpersonationId = !!impersonationId; + if (this.hasImpersonationId) { + this.isAiChatOpen = false; + } + this.changeDetectorRef.markForCheck(); }); @@ -160,16 +192,34 @@ export class GfAnalysisPageComponent implements OnInit { .subscribe((state) => { if (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 }) => { return id === this.user.settings?.benchmark; }); + this.hasPermissionToAccessAiChat = hasPermission( + this.user.permissions, + permissions.accessAiChat + ); this.hasPermissionToReadAiPrompt = hasPermission( this.user.permissions, permissions.readAiPrompt ); + if (!this.canUseAiChat) { + this.isAiChatOpen = false; + } + this.update(); } @@ -198,6 +248,14 @@ export class GfAnalysisPageComponent implements OnInit { this.fetchDividendsAndInvestments(); } + protected onCloseAiChat() { + this.isAiChatOpen = false; + + queueMicrotask(() => { + this.aiChatTrigger()?.nativeElement.focus(); + }); + } + protected onCopyPromptToClipboard(mode: AiPromptMode) { if (mode === 'analysis') { this.isLoadingAnalysisPrompt = true; @@ -241,6 +299,10 @@ export class GfAnalysisPageComponent implements OnInit { }); } + protected onToggleAiChat() { + this.isAiChatOpen = !this.isAiChatOpen; + } + private fetchDividendsAndInvestments() { this.isLoadingDividendTimelineChart = true; this.isLoadingInvestmentTimelineChart = true; diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index ec90fccec6..3fa8a08ed0 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -3,7 +3,26 @@ @if (user?.settings?.isExperimentalFeatures) {
-
+
+ @if (canUseAiChat) { + + }