diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 7ac2c5915..6d097aeff 100644 --- a/apps/api/src/app/app.module.ts +++ b/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 { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; +import { AiModule } from './endpoints/ai/ai.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module'; @@ -57,6 +58,7 @@ import { UserModule } from './user/user.module'; AdminModule, AccessModule, AccountModule, + AiModule, ApiKeysModule, AssetModule, AuthDeviceModule, diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts new file mode 100644 index 000000000..57d7cfd7e --- /dev/null +++ b/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 { + 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 }; + } +} diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts new file mode 100644 index 000000000..5a30f3264 --- /dev/null +++ b/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 {} diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts new file mode 100644 index 000000000..b41e0738f --- /dev/null +++ b/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'); + } +} diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 33e9a67da..b5c71179f 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -312,7 +312,8 @@ export class UserService { currentPermissions = without( currentPermissions, permissions.accessHoldingsChart, - permissions.createAccess + permissions.createAccess, + permissions.readAiPrompt ); // Reset benchmark 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 0bd4e85e3..7e27a05f9 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 @@ -11,10 +11,13 @@ import { ToggleOption, User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GroupBy } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; +import { Clipboard } from '@angular/cdk/clipboard'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { SymbolProfile } from '@prisma/client'; import { isNumber, sortBy } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; @@ -38,6 +41,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public dividendTimelineDataLabel = $localize`Dividend`; public firstOrderDate: Date; public hasImpersonationId: boolean; + public hasPermissionToReadAiPrompt: boolean; public investments: InvestmentItem[]; public investmentTimelineDataLabel = $localize`Investment`; public investmentsByGroup: InvestmentItem[]; @@ -64,9 +68,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, + private clipboard: Clipboard, private dataService: DataService, private deviceService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, + private snackBar: MatSnackBar, private userService: UserService ) { const { benchmarks } = this.dataService.fetchInfo(); @@ -104,6 +110,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { return id === this.user.settings?.benchmark; }); + this.hasPermissionToReadAiPrompt = hasPermission( + this.user.permissions, + permissions.readAiPrompt + ); + this.update(); } }); @@ -130,6 +141,20 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { 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() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); 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 087b3bd54..07ffa705d 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -1,5 +1,37 @@

Analysis

+ @if (user?.settings?.isExperimentalFeatures) { +
+
+
+ + + + +
+
+
+ }
('/api/v1/portfolio/report'); } + public fetchPrompt() { + return this.http.get('/api/v1/ai/prompt'); + } + public fetchPublicPortfolio(aAccessId: string) { return this.http .get(`/api/v1/public/${aAccessId}/portfolio`) diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index fa5eb25a5..7ad4948dc 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/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 { Product } from './product'; 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 { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; @@ -74,6 +75,7 @@ export { AdminMarketDataDetails, AdminMarketDataItem, AdminUsers, + AiPromptResponse, ApiKeyResponse, AssetProfileIdentifier, Benchmark, diff --git a/libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts b/libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts new file mode 100644 index 000000000..4b95bc871 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts @@ -0,0 +1,3 @@ +export interface AiPromptResponse { + prompt: string; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index d6676ec4e..d19b8daf0 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -35,6 +35,7 @@ export const permissions = { enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', enableSystemMessage: 'enableSystemMessage', impersonateAllUsers: 'impersonateAllUsers', + readAiPrompt: 'readAiPrompt', readMarketData: 'readMarketData', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readPlatforms: 'readPlatforms', @@ -76,6 +77,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deletePlatform, permissions.deleteTag, permissions.deleteUser, + permissions.readAiPrompt, permissions.readMarketData, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, @@ -95,7 +97,8 @@ export function getPermissions(aRole: Role): string[] { return [ permissions.accessAssistant, permissions.accessHoldingsChart, - permissions.createUserAccount + permissions.createUserAccount, + permissions.readAiPrompt ]; case 'USER': @@ -113,6 +116,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAuthDevice, permissions.deleteOrder, permissions.deleteOwnUser, + permissions.readAiPrompt, permissions.readMarketDataOfOwnAssetProfile, permissions.updateAccount, permissions.updateAuthDevice,