From 4a71ac40c8148d090b1e78cb6a0d46e5cd9385f1 Mon Sep 17 00:00:00 2001 From: Ross Kuehl <168792663+shoyu-ramen@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:40:46 -0400 Subject: [PATCH] Bind AI chat consent to portfolio scope --- .../portfolio/analysis/ai-chat.component.html | 12 +- .../analysis/ai-chat.component.spec.ts | 262 +++++++++++++++--- .../portfolio/analysis/ai-chat.component.ts | 49 +++- 3 files changed, 265 insertions(+), 58 deletions(-) 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 index c8ebb608ed..f585e5fd8d 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html @@ -45,8 +45,14 @@

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. + in this browser tab. Closing the panel, leaving the page, or + refreshing the tab clears it. +

+

+ Your consent applies to the portfolio scope shown above. If the + displayed filters or date range differ from the scope you consented + to, any active response is stopped, the conversation is cleared, and + you are asked to consent again.

OpenRouter and upstream model providers may process or retain @@ -68,7 +74,7 @@ type="button" (click)="onAcceptConsent()" > - Continue to AI chat + Consent and continue 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 index 1048d924a4..cd66fe5d54 100644 --- 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 @@ -1,5 +1,6 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { Filter } from '@ghostfolio/common/interfaces'; +import { DateRange } from '@ghostfolio/common/types'; import { DataService } from '@ghostfolio/ui/services'; import { HttpParams } from '@angular/common/http'; @@ -14,12 +15,18 @@ import { GfAiChatComponent } from './ai-chat.component'; +let mockPrepareSendMessagesRequest: (options: { + headers: Record; + messages: UIMessage[]; +}) => { api: string }; + jest.mock('@ai-sdk/angular', () => { const { signal } = jest.requireActual('@angular/core'); class ChatMock { public error: Error | undefined; + public sendMessageCallCount = 0; public status = 'ready'; public stopCallCount = 0; private readonly messagesSignal = signal([]); @@ -42,6 +49,8 @@ jest.mock('@ai-sdk/angular', () => { } public sendMessage() { + this.sendMessageCallCount += 1; + return Promise.resolve(); } @@ -72,7 +81,15 @@ jest.mock('@ionic/angular/standalone', () => { }); jest.mock('ai', () => { - class DefaultChatTransportMock {} + class DefaultChatTransportMock { + public constructor({ + prepareSendMessagesRequest + }: { + prepareSendMessagesRequest: typeof mockPrepareSendMessagesRequest; + }) { + mockPrepareSendMessagesRequest = prepareSendMessagesRequest; + } + } return { DefaultChatTransport: DefaultChatTransportMock, @@ -95,9 +112,61 @@ jest.mock('ionicons/icons', () => { }); describe('GfAiChatComponent', () => { + const consentKey = 'ghostfolio.ai-chat.consent:openai/test-model'; let component: GfAiChatComponent; let fixture: ComponentFixture; + const acceptConsent = () => { + const consentButton = getButton('Consent and continue'); + + expect(consentButton).toBeDefined(); + consentButton?.click(); + fixture.detectChanges(); + }; + + const createComponent = ({ + filters = [], + range = 'ytd' + }: { + filters?: Filter[]; + range?: DateRange; + } = {}) => { + fixture = TestBed.createComponent(GfAiChatComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('filters', filters); + fixture.componentRef.setInput('model', 'openai/test-model'); + fixture.componentRef.setInput('range', range); + fixture.detectChanges(); + TestBed.flushEffects(); + }; + + const getButton = (label: string) => { + return [...fixture.nativeElement.querySelectorAll('button')].find( + (button: HTMLButtonElement) => { + return button.textContent?.includes(label); + } + ) as HTMLButtonElement | undefined; + }; + + const getComponentInternals = () => { + return component as unknown as { + chat: { + messages: UIMessage[]; + sendMessageCallCount: number; + status: string; + stopCallCount: number; + }; + getApiUrl: () => string; + hasConsent: () => boolean; + prompt: { + (): string; + set: (value: string) => void; + }; + sendMessage: () => void; + scopeSignature: () => string; + }; + }; + beforeEach(async () => { window.sessionStorage.clear(); @@ -134,53 +203,34 @@ describe('GfAiChatComponent', () => { ] }).compileComponents(); - fixture = TestBed.createComponent(GfAiChatComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('model', 'openai/test-model'); - fixture.componentRef.setInput('range', 'ytd'); - fixture.detectChanges(); - TestBed.flushEffects(); + createComponent(); }); - it('stores only the model-specific session consent marker', () => { + it('binds the model-specific session consent marker to the scope', () => { expect(fixture.nativeElement.textContent).toContain('Before you start'); expect(fixture.nativeElement.textContent).toContain('OpenRouter'); + expect(fixture.nativeElement.textContent).toContain( + 'Your consent applies to the portfolio scope shown above' + ); + expect(fixture.nativeElement.textContent).toContain( + 'you are asked to consent again' + ); 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(); + acceptConsent(); - expect( - window.sessionStorage.getItem( - 'ghostfolio.ai-chat.consent:openai/test-model' - ) - ).toBe('true'); + expect(window.sessionStorage.getItem(consentKey)).toBe( + getComponentInternals().scopeSignature() + ); 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[] }; - }; + acceptConsent(); - componentInternals.chat.messages = [ + getComponentInternals().chat.messages = [ { id: 'assistant-message', parts: [ @@ -216,19 +266,80 @@ describe('GfAiChatComponent', () => { ]); fixture.detectChanges(); - const componentInternals = component as unknown as { - getApiUrl: () => string; - }; - - expect(componentInternals.getApiUrl()).toBe( + expect(getComponentInternals().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[] }; - }; + it.each([ + { + changeScope: () => fixture.componentRef.setInput('range', '1y'), + scopePart: 'date range' + }, + { + changeScope: () => + fixture.componentRef.setInput('filters', [ + { id: 'account-1', type: 'ACCOUNT' } + ]), + scopePart: 'filters' + } + ])( + 'invalidates consent and context when the $scopePart changes', + ({ changeScope }) => { + acceptConsent(); + + const componentInternals = getComponentInternals(); + + componentInternals.chat.messages = [ + { + id: 'message-1', + parts: [{ text: 'How am I doing?', type: 'text' }], + role: 'user' + } + ]; + componentInternals.chat.status = 'streaming'; + componentInternals.prompt.set('Compare my holdings'); + const stopCallCount = componentInternals.chat.stopCallCount; + + changeScope(); + fixture.detectChanges(); + TestBed.flushEffects(); + fixture.detectChanges(); + + expect(componentInternals.chat.stopCallCount).toBe(stopCallCount + 1); + expect(componentInternals.chat.messages).toEqual([]); + expect(componentInternals.prompt()).toBe(''); + expect(window.sessionStorage.getItem(consentKey)).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Before you start'); + } + ); + + it('blocks sending as soon as the scope input changes', () => { + acceptConsent(); + + const componentInternals = getComponentInternals(); + + componentInternals.prompt.set('Compare my holdings'); + + fixture.componentRef.setInput('range', '1y'); + + expect(componentInternals.hasConsent()).toBe(false); + + componentInternals.sendMessage(); + + expect(componentInternals.chat.sendMessageCallCount).toBe(0); + }); + + it('preserves consent and context for equivalent filter changes', () => { + fixture.componentRef.setInput('filters', [ + { id: 'account-1', label: 'Brokerage', type: 'ACCOUNT' }, + { id: 'tag-1', label: 'Long term', type: 'TAG' } + ]); + fixture.detectChanges(); + TestBed.flushEffects(); + acceptConsent(); + + const componentInternals = getComponentInternals(); componentInternals.chat.messages = [ { @@ -237,12 +348,75 @@ describe('GfAiChatComponent', () => { role: 'user' } ]; + const consentScopeSignature = window.sessionStorage.getItem(consentKey); + const stopCallCount = componentInternals.chat.stopCallCount; + + fixture.componentRef.setInput('filters', [ + { id: 'tag-1', label: 'Retirement', type: 'TAG' }, + { id: 'account-1', label: 'Primary account', type: 'ACCOUNT' } + ]); + fixture.detectChanges(); + TestBed.flushEffects(); + + expect(componentInternals.chat.stopCallCount).toBe(stopCallCount); + expect(componentInternals.chat.messages).toHaveLength(1); + expect(window.sessionStorage.getItem(consentKey)).toBe( + consentScopeSignature + ); + expect(fixture.nativeElement.textContent).not.toContain('Before you start'); + }); + + it('restores consent only when a reopened panel has the same scope', () => { + acceptConsent(); + + const consentScopeSignature = window.sessionStorage.getItem(consentKey); + + fixture.destroy(); + createComponent(); + + expect(window.sessionStorage.getItem(consentKey)).toBe( + consentScopeSignature + ); + expect(fixture.nativeElement.textContent).not.toContain('Before you start'); + + fixture.destroy(); + createComponent({ range: '1y' }); + + expect(window.sessionStorage.getItem(consentKey)).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Before you start'); + }); + + it('removes a legacy model-only consent marker', () => { + fixture.destroy(); + window.sessionStorage.setItem(consentKey, 'true'); + + createComponent(); + + expect(window.sessionStorage.getItem(consentKey)).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Before you start'); + }); + + it('stores renewed consent for the changed request scope', () => { + fixture.componentRef.setInput('filters', [ + { id: 'account-1', type: 'ACCOUNT' } + ]); + fixture.detectChanges(); + TestBed.flushEffects(); + acceptConsent(); fixture.componentRef.setInput('range', '1y'); fixture.detectChanges(); TestBed.flushEffects(); + fixture.detectChanges(); - expect(componentInternals.chat.messages).toEqual([]); + acceptConsent(); + + expect(window.sessionStorage.getItem(consentKey)).toBe( + getComponentInternals().scopeSignature() + ); + expect( + mockPrepareSendMessagesRequest({ headers: {}, messages: [] }).api + ).toBe('/api/v1/ai/chat?accounts=account-1&range=1y'); }); it('stops the active stream and clears messages when destroyed', () => { 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 index 15df221115..db2fd0ec97 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts @@ -378,7 +378,11 @@ export class GfAiChatComponent implements OnDestroy { } }) }); - protected readonly hasConsent = signal(false); + protected readonly hasConsent = computed(() => { + return ( + !!this.model() && this.consentedScopeSignature() === this.scopeSignature() + ); + }); protected readonly isBusy = computed(() => { return this.chat.status === 'streaming' || this.chat.status === 'submitted'; }); @@ -403,6 +407,9 @@ export class GfAiChatComponent implements OnDestroy { private readonly promptElement = viewChild>('promptInput'); + private readonly consentedScopeSignature = signal( + undefined + ); private readonly shouldFocusPrompt = signal(true); private readonly scopeSignature = computed(() => { const filters = this.filters() @@ -430,16 +437,33 @@ export class GfAiChatComponent implements OnDestroy { const scopeSignature = this.scopeSignature(); untracked(() => { - this.hasConsent.set( - !!model && - window.sessionStorage.getItem(this.getConsentKey(model)) === 'true' - ); - - if ( + const hasScopeChanged = previousScopeSignature !== undefined && - previousScopeSignature !== scopeSignature - ) { + previousScopeSignature !== scopeSignature; + const consentKey = model ? this.getConsentKey(model) : undefined; + const storedScopeSignature = consentKey + ? window.sessionStorage.getItem(consentKey) + : null; + + if (hasScopeChanged) { this.resetChat(); + + if (consentKey) { + window.sessionStorage.removeItem(consentKey); + } + + this.consentedScopeSignature.set(undefined); + } else { + const hasConsent = + !!consentKey && storedScopeSignature === scopeSignature; + + if (consentKey && storedScopeSignature !== null && !hasConsent) { + window.sessionStorage.removeItem(consentKey); + } + + this.consentedScopeSignature.set( + hasConsent ? scopeSignature : undefined + ); } previousScopeSignature = scopeSignature; @@ -490,9 +514,12 @@ export class GfAiChatComponent implements OnDestroy { } protected onAcceptConsent() { - window.sessionStorage.setItem(this.getConsentKey(this.model()), 'true'); + window.sessionStorage.setItem( + this.getConsentKey(this.model()), + this.scopeSignature() + ); this.shouldFocusPrompt.set(true); - this.hasConsent.set(true); + this.consentedScopeSignature.set(this.scopeSignature()); } protected onClear() {