Browse Source

Bind AI chat consent to portfolio scope

pull/7444/head
Ross Kuehl 1 month ago
parent
commit
4a71ac40c8
Failed to extract signature
  1. 12
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.html
  2. 262
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.spec.ts
  3. 49
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts

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

@ -45,8 +45,14 @@
</p> </p>
<p i18n> <p i18n>
Ghostfolio keeps this conversation only in memory for this open panel Ghostfolio keeps this conversation only in memory for this open panel
in this browser tab. Closing the panel, leaving the page, refreshing in this browser tab. Closing the panel, leaving the page, or
the tab, or changing the portfolio scope clears it. refreshing the tab clears it.
</p>
<p i18n>
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.
</p> </p>
<p class="mb-4" i18n> <p class="mb-4" i18n>
OpenRouter and upstream model providers may process or retain OpenRouter and upstream model providers may process or retain
@ -68,7 +74,7 @@
type="button" type="button"
(click)="onAcceptConsent()" (click)="onAcceptConsent()"
> >
<ng-container i18n>Continue to AI chat</ng-container> <ng-container i18n>Consent and continue</ng-container>
</button> </button>
</div> </div>
</div> </div>

262
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 { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { HttpParams } from '@angular/common/http'; import { HttpParams } from '@angular/common/http';
@ -14,12 +15,18 @@ import {
GfAiChatComponent GfAiChatComponent
} from './ai-chat.component'; } from './ai-chat.component';
let mockPrepareSendMessagesRequest: (options: {
headers: Record<string, string>;
messages: UIMessage[];
}) => { api: string };
jest.mock('@ai-sdk/angular', () => { jest.mock('@ai-sdk/angular', () => {
const { signal } = const { signal } =
jest.requireActual<typeof import('@angular/core')>('@angular/core'); jest.requireActual<typeof import('@angular/core')>('@angular/core');
class ChatMock { class ChatMock {
public error: Error | undefined; public error: Error | undefined;
public sendMessageCallCount = 0;
public status = 'ready'; public status = 'ready';
public stopCallCount = 0; public stopCallCount = 0;
private readonly messagesSignal = signal<UIMessage[]>([]); private readonly messagesSignal = signal<UIMessage[]>([]);
@ -42,6 +49,8 @@ jest.mock('@ai-sdk/angular', () => {
} }
public sendMessage() { public sendMessage() {
this.sendMessageCallCount += 1;
return Promise.resolve(); return Promise.resolve();
} }
@ -72,7 +81,15 @@ jest.mock('@ionic/angular/standalone', () => {
}); });
jest.mock('ai', () => { jest.mock('ai', () => {
class DefaultChatTransportMock {} class DefaultChatTransportMock {
public constructor({
prepareSendMessagesRequest
}: {
prepareSendMessagesRequest: typeof mockPrepareSendMessagesRequest;
}) {
mockPrepareSendMessagesRequest = prepareSendMessagesRequest;
}
}
return { return {
DefaultChatTransport: DefaultChatTransportMock, DefaultChatTransport: DefaultChatTransportMock,
@ -95,9 +112,61 @@ jest.mock('ionicons/icons', () => {
}); });
describe('GfAiChatComponent', () => { describe('GfAiChatComponent', () => {
const consentKey = 'ghostfolio.ai-chat.consent:openai/test-model';
let component: GfAiChatComponent; let component: GfAiChatComponent;
let fixture: ComponentFixture<GfAiChatComponent>; let fixture: ComponentFixture<GfAiChatComponent>;
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 () => { beforeEach(async () => {
window.sessionStorage.clear(); window.sessionStorage.clear();
@ -134,53 +203,34 @@ describe('GfAiChatComponent', () => {
] ]
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(GfAiChatComponent); createComponent();
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', () => { it('binds the model-specific session consent marker to the scope', () => {
expect(fixture.nativeElement.textContent).toContain('Before you start'); expect(fixture.nativeElement.textContent).toContain('Before you start');
expect(fixture.nativeElement.textContent).toContain('OpenRouter'); 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( expect(fixture.nativeElement.textContent).toContain(
'may process or retain submitted data' 'may process or retain submitted data'
); );
const continueButton = [ acceptConsent();
...fixture.nativeElement.querySelectorAll('button')
].find((button: HTMLButtonElement) => {
return button.textContent?.includes('Continue to AI chat');
});
continueButton?.click();
fixture.detectChanges();
expect( expect(window.sessionStorage.getItem(consentKey)).toBe(
window.sessionStorage.getItem( getComponentInternals().scopeSignature()
'ghostfolio.ai-chat.consent:openai/test-model' );
)
).toBe('true');
expect(window.sessionStorage.length).toBe(1); expect(window.sessionStorage.length).toBe(1);
}); });
it('does not render remote images or raw HTML as active content', () => { it('does not render remote images or raw HTML as active content', () => {
const continueButton = [ acceptConsent();
...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 = [ getComponentInternals().chat.messages = [
{ {
id: 'assistant-message', id: 'assistant-message',
parts: [ parts: [
@ -216,19 +266,29 @@ describe('GfAiChatComponent', () => {
]); ]);
fixture.detectChanges(); fixture.detectChanges();
const componentInternals = component as unknown as { expect(getComponentInternals().getApiUrl()).toBe(
getApiUrl: () => string;
};
expect(componentInternals.getApiUrl()).toBe(
'/api/v1/ai/chat?accounts=account-1&tags=tag-1&range=ytd' '/api/v1/ai/chat?accounts=account-1&tags=tag-1&range=ytd'
); );
}); });
it('clears the in-memory conversation when the scope changes', () => { it.each([
const componentInternals = component as unknown as { {
chat: { messages: UIMessage[] }; 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 = [ componentInternals.chat.messages = [
{ {
@ -237,12 +297,126 @@ describe('GfAiChatComponent', () => {
role: 'user' role: 'user'
} }
]; ];
componentInternals.chat.status = 'streaming';
componentInternals.prompt.set('Compare my holdings');
const stopCallCount = componentInternals.chat.stopCallCount;
fixture.componentRef.setInput('range', '1y'); changeScope();
fixture.detectChanges(); fixture.detectChanges();
TestBed.flushEffects(); TestBed.flushEffects();
fixture.detectChanges();
expect(componentInternals.chat.stopCallCount).toBe(stopCallCount + 1);
expect(componentInternals.chat.messages).toEqual([]); 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 = [
{
id: 'message-1',
parts: [{ text: 'How am I doing?', type: 'text' }],
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();
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', () => { it('stops the active stream and clears messages when destroyed', () => {

49
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(() => { protected readonly isBusy = computed(() => {
return this.chat.status === 'streaming' || this.chat.status === 'submitted'; return this.chat.status === 'streaming' || this.chat.status === 'submitted';
}); });
@ -403,6 +407,9 @@ export class GfAiChatComponent implements OnDestroy {
private readonly promptElement = private readonly promptElement =
viewChild<ElementRef<HTMLTextAreaElement>>('promptInput'); viewChild<ElementRef<HTMLTextAreaElement>>('promptInput');
private readonly consentedScopeSignature = signal<string | undefined>(
undefined
);
private readonly shouldFocusPrompt = signal(true); private readonly shouldFocusPrompt = signal(true);
private readonly scopeSignature = computed(() => { private readonly scopeSignature = computed(() => {
const filters = this.filters() const filters = this.filters()
@ -430,16 +437,33 @@ export class GfAiChatComponent implements OnDestroy {
const scopeSignature = this.scopeSignature(); const scopeSignature = this.scopeSignature();
untracked(() => { untracked(() => {
this.hasConsent.set( const hasScopeChanged =
!!model &&
window.sessionStorage.getItem(this.getConsentKey(model)) === 'true'
);
if (
previousScopeSignature !== undefined && 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(); 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; previousScopeSignature = scopeSignature;
@ -490,9 +514,12 @@ export class GfAiChatComponent implements OnDestroy {
} }
protected onAcceptConsent() { protected onAcceptConsent() {
window.sessionStorage.setItem(this.getConsentKey(this.model()), 'true'); window.sessionStorage.setItem(
this.getConsentKey(this.model()),
this.scopeSignature()
);
this.shouldFocusPrompt.set(true); this.shouldFocusPrompt.set(true);
this.hasConsent.set(true); this.consentedScopeSignature.set(this.scopeSignature());
} }
protected onClear() { protected onClear() {

Loading…
Cancel
Save