Browse Source

Add copy AI prompt to clipboard action

pull/4176/head
Thomas Kaul 8 months ago
parent
commit
a20237ab9d
  1. 2
      apps/api/src/app/app.module.ts
  2. 45
      apps/api/src/app/endpoints/ai/ai.controller.ts
  3. 51
      apps/api/src/app/endpoints/ai/ai.module.ts
  4. 60
      apps/api/src/app/endpoints/ai/ai.service.ts
  5. 3
      apps/api/src/app/user/user.service.ts
  6. 25
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  7. 32
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  8. 4
      apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts
  9. 5
      apps/client/src/app/services/data.service.ts
  10. 2
      libs/common/src/lib/interfaces/index.ts
  11. 3
      libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts
  12. 6
      libs/common/src/lib/permissions.ts

2
apps/api/src/app/app.module.ts

@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
@ -57,6 +58,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AiModule,
ApiKeysModule, ApiKeysModule,
AssetModule, AssetModule,
AuthDeviceModule, AuthDeviceModule,

45
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -0,0 +1,45 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import {
DEFAULT_LANGUAGE_CODE,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Headers, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AiService } from './ai.service';
@Controller('ai')
export class AiController {
public constructor(
private readonly aiService: AiService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('prompt')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
): Promise<AiPromptResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const prompt = await this.aiService.getPrompt({
impersonationId: impersonationUserId,
languageCode:
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});
return { prompt };
}
}

51
apps/api/src/app/endpoints/ai/ai.module.ts

@ -0,0 +1,51 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
controllers: [AiController],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
AiService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class AiModule {}

60
apps/api/src/app/endpoints/ai/ai.service.ts

@ -0,0 +1,60 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class AiService {
public constructor(private readonly portfolioService: PortfolioService) {}
public async getPrompt({
impersonationId,
languageCode,
userCurrency,
userId
}: {
impersonationId: string;
languageCode: string;
userCurrency: string;
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
impersonationId,
userId
});
const holdingsTable = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| --- | --- | --- | --- |',
...Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name,
symbol
}) => {
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
}
)
];
return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable,
'Structure your answer with these sections:',
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.',
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.',
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).',
'Optimization Ideas: Offer ideas to improve or complement the portfolio, ensuring they are constructive and neutral in tone.',
'Conclusion: Provide a concise summary, highlighting key insights.',
`Provide your answer in the following language: ${languageCode}.`
].join('\n');
}
}

3
apps/api/src/app/user/user.service.ts

@ -312,7 +312,8 @@ export class UserService {
currentPermissions = without( currentPermissions = without(
currentPermissions, currentPermissions,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createAccess permissions.createAccess,
permissions.readAiPrompt
); );
// Reset benchmark // Reset benchmark

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

@ -11,10 +11,13 @@ import {
ToggleOption, ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GroupBy } from '@ghostfolio/common/types'; import { GroupBy } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { Clipboard } from '@angular/cdk/clipboard';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { isNumber, sortBy } from 'lodash'; import { isNumber, sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -38,6 +41,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public dividendTimelineDataLabel = $localize`Dividend`; public dividendTimelineDataLabel = $localize`Dividend`;
public firstOrderDate: Date; public firstOrderDate: Date;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToReadAiPrompt: boolean;
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public investmentTimelineDataLabel = $localize`Investment`; public investmentTimelineDataLabel = $localize`Investment`;
public investmentsByGroup: InvestmentItem[]; public investmentsByGroup: InvestmentItem[];
@ -64,9 +68,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
) { ) {
const { benchmarks } = this.dataService.fetchInfo(); const { benchmarks } = this.dataService.fetchInfo();
@ -104,6 +110,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
return id === this.user.settings?.benchmark; return id === this.user.settings?.benchmark;
}); });
this.hasPermissionToReadAiPrompt = hasPermission(
this.user.permissions,
permissions.readAiPrompt
);
this.update(); this.update();
} }
}); });
@ -130,6 +141,20 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
} }
public onCopyPromptToClipboard() {
this.dataService.fetchPrompt().subscribe(({ prompt }) => {
this.clipboard.copy(prompt);
this.snackBar.open(
'✅ ' + $localize`AI prompt has been copied to the clipboard`,
undefined,
{
duration: 3000
}
);
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

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

@ -1,5 +1,37 @@
<div class="container"> <div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1>
@if (user?.settings?.isExperimentalFeatures) {
<div class="mb-3 row">
<div class="col-lg">
<div class="d-flex justify-content-end">
<button
class="mx-1 no-min-width px-2"
mat-stroked-button
[matMenuTriggerFor]="actionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu #actionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard()"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="mr-2" />
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
<ng-container i18n>Copy AI prompt to clipboard</ng-container>
</span>
</button>
</mat-menu>
</div>
</div>
</div>
}
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-lg"> <div class="col-lg">
<gf-benchmark-comparator <gf-benchmark-comparator

4
apps/client/src/app/pages/portfolio/analysis/analysis-page.module.ts

@ -7,7 +7,9 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module'; import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
@ -24,7 +26,9 @@ import { AnalysisPageComponent } from './analysis-page.component';
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfToggleModule, GfToggleModule,
GfValueComponent, GfValueComponent,
MatButtonModule,
MatCardModule, MatCardModule,
MatMenuModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

5
apps/client/src/app/services/data.service.ts

@ -22,6 +22,7 @@ import {
Access, Access,
AccountBalancesResponse, AccountBalancesResponse,
Accounts, Accounts,
AiPromptResponse,
ApiKeyResponse, ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
@ -637,6 +638,10 @@ export class DataService {
return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report'); return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report');
} }
public fetchPrompt() {
return this.http.get<AiPromptResponse>('/api/v1/ai/prompt');
}
public fetchPublicPortfolio(aAccessId: string) { public fetchPublicPortfolio(aAccessId: string) {
return this.http return this.http
.get<PublicPortfolioResponse>(`/api/v1/public/${aAccessId}/portfolio`) .get<PublicPortfolioResponse>(`/api/v1/public/${aAccessId}/portfolio`)

2
libs/common/src/lib/interfaces/index.ts

@ -38,6 +38,7 @@ import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Position } from './position.interface'; import type { Position } from './position.interface';
import type { Product } from './product'; import type { Product } from './product';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
@ -74,6 +75,7 @@ export {
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AdminUsers, AdminUsers,
AiPromptResponse,
ApiKeyResponse, ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,

3
libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts

@ -0,0 +1,3 @@
export interface AiPromptResponse {
prompt: string;
}

6
libs/common/src/lib/permissions.ts

@ -35,6 +35,7 @@ export const permissions = {
enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', enableSubscriptionInterstitial: 'enableSubscriptionInterstitial',
enableSystemMessage: 'enableSystemMessage', enableSystemMessage: 'enableSystemMessage',
impersonateAllUsers: 'impersonateAllUsers', impersonateAllUsers: 'impersonateAllUsers',
readAiPrompt: 'readAiPrompt',
readMarketData: 'readMarketData', readMarketData: 'readMarketData',
readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile',
readPlatforms: 'readPlatforms', readPlatforms: 'readPlatforms',
@ -76,6 +77,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.deletePlatform, permissions.deletePlatform,
permissions.deleteTag, permissions.deleteTag,
permissions.deleteUser, permissions.deleteUser,
permissions.readAiPrompt,
permissions.readMarketData, permissions.readMarketData,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms, permissions.readPlatforms,
@ -95,7 +97,8 @@ export function getPermissions(aRole: Role): string[] {
return [ return [
permissions.accessAssistant, permissions.accessAssistant,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createUserAccount permissions.createUserAccount,
permissions.readAiPrompt
]; ];
case 'USER': case 'USER':
@ -113,6 +116,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.deleteOwnUser, permissions.deleteOwnUser,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice, permissions.updateAuthDevice,

Loading…
Cancel
Save