From e5584dbd5f82c36ddb3893b79cec966564195fb4 Mon Sep 17 00:00:00 2001 From: jpwilson Date: Mon, 23 Feb 2026 22:49:27 -0600 Subject: [PATCH] Feature/add AI agent with chat endpoint and basic UI Add a new agent module to Ghostfolio with 3 tools (portfolio_summary, market_data, transaction_history) using Vercel AI SDK + OpenAI. Includes a chat UI served at /api/v1/agent/ui and an "Agent" link in the main nav. --- apps/api/src/app/agent/agent-chat.html | 315 ++++++++++++++++++ apps/api/src/app/agent/agent.controller.ts | 81 +++++ apps/api/src/app/agent/agent.module.ts | 61 ++++ apps/api/src/app/agent/agent.service.ts | 197 +++++++++++ apps/api/src/app/app.module.ts | 2 + .../components/header/header.component.html | 13 + package-lock.json | 23 +- package.json | 1 + 8 files changed, 690 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/agent/agent-chat.html create mode 100644 apps/api/src/app/agent/agent.controller.ts create mode 100644 apps/api/src/app/agent/agent.module.ts create mode 100644 apps/api/src/app/agent/agent.service.ts diff --git a/apps/api/src/app/agent/agent-chat.html b/apps/api/src/app/agent/agent-chat.html new file mode 100644 index 000000000..3dbbd516a --- /dev/null +++ b/apps/api/src/app/agent/agent-chat.html @@ -0,0 +1,315 @@ + + + + + + Ghostfolio Agent + + + +
+ ← Ghostfolio +

Agent

+ AI Financial Assistant +
Connected
+
+ +
+
+

Ask me about your portfolio

+
I can analyze your holdings, look up market data, and review your transactions.
+
+ + + + + + +
+
+
+ +
+ + +
+ + + + diff --git a/apps/api/src/app/agent/agent.controller.ts b/apps/api/src/app/agent/agent.controller.ts new file mode 100644 index 000000000..9b146ce2f --- /dev/null +++ b/apps/api/src/app/agent/agent.controller.ts @@ -0,0 +1,81 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Get, + HttpException, + HttpStatus, + Inject, + Post, + Res, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { CoreMessage } from 'ai'; +import { Response } from 'express'; +import { join } from 'node:path'; + +import { AgentService } from './agent.service'; + +interface ChatRequestBody { + messages: CoreMessage[]; +} + +@Controller('agent') +export class AgentController { + public constructor( + private readonly agentService: AgentService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get('ui') + public serveChat(@Res() res: Response) { + const fs = require('node:fs'); + const path = require('node:path'); + + // Try source path first (dev), then dist path + const paths = [ + path.join(process.cwd(), 'apps', 'api', 'src', 'app', 'agent', 'agent-chat.html'), + path.join(__dirname, 'agent-chat.html') + ]; + + for (const p of paths) { + if (fs.existsSync(p)) { + return res.sendFile(p); + } + } + + return res.status(404).send('Chat UI not found'); + } + + @Post('chat') + @HasPermission(permissions.createOrder) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async chat(@Body() body: ChatRequestBody) { + if (!body.messages?.length) { + throw new HttpException( + 'Messages array is required', + HttpStatus.BAD_REQUEST + ); + } + + try { + const result = await this.agentService.chat({ + messages: body.messages, + userId: this.request.user.id + }); + + return result; + } catch (error) { + throw new HttpException( + `Agent error: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/apps/api/src/app/agent/agent.module.ts b/apps/api/src/app/agent/agent.module.ts new file mode 100644 index 000000000..bc679245b --- /dev/null +++ b/apps/api/src/app/agent/agent.module.ts @@ -0,0 +1,61 @@ +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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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 { PropertyModule } from '@ghostfolio/api/services/property/property.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 { AgentController } from './agent.controller'; +import { AgentService } from './agent.service'; + +@Module({ + controllers: [AgentController], + imports: [ + ApiModule, + BenchmarkModule, + ConfigurationModule, + DataProviderModule, + ExchangeRateDataModule, + I18nModule, + ImpersonationModule, + MarketDataModule, + OrderModule, + PortfolioSnapshotQueueModule, + PrismaModule, + PropertyModule, + RedisCacheModule, + SymbolModule, + SymbolProfileModule, + UserModule + ], + providers: [ + AccountBalanceService, + AccountService, + AgentService, + CurrentRateService, + MarketDataService, + PortfolioCalculatorFactory, + PortfolioService, + RulesService + ] +}) +export class AgentModule {} diff --git a/apps/api/src/app/agent/agent.service.ts b/apps/api/src/app/agent/agent.service.ts new file mode 100644 index 000000000..489291fb8 --- /dev/null +++ b/apps/api/src/app/agent/agent.service.ts @@ -0,0 +1,197 @@ +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; + +import { Injectable } from '@nestjs/common'; +import { openai } from '@ai-sdk/openai'; +import { generateText, tool, CoreMessage } from 'ai'; +import { z } from 'zod'; + +@Injectable() +export class AgentService { + public constructor( + private readonly portfolioService: PortfolioService, + private readonly orderService: OrderService, + private readonly symbolService: SymbolService + ) {} + + public async chat({ + messages, + userId + }: { + messages: CoreMessage[]; + userId: string; + }) { + // This is the ReAct loop. generateText with maxSteps lets the LLM + // call tools, observe results, think, and call more tools — up to + // maxSteps iterations. The LLM decides when it has enough info to + // respond to the user. + const result = await generateText({ + model: openai('gpt-4o-mini'), + system: `You are a helpful financial assistant for Ghostfolio, a portfolio management app. +You help users understand their investments by analyzing their portfolio, looking up market data, and reviewing their transaction history. +Always be factual and precise with numbers. If you don't have enough data to answer, say so. +When discussing financial topics, include appropriate caveats that this is not financial advice.`, + messages, + tools: { + // TOOL 1: Portfolio Summary + // The LLM reads this description to decide when to call this tool. + // This is why tool descriptions matter — they're prompts. + portfolio_summary: tool({ + description: + 'Get the current portfolio holdings with allocation percentages, asset classes, and performance. Use this when the user asks about their portfolio, holdings, allocation, diversification, or how their investments are doing.', + parameters: z.object({}), + execute: async () => { + try { + const details = await this.portfolioService.getDetails({ + filters: [], + impersonationId: undefined, + userId + }); + + const holdings = Object.values(details.holdings).map( + (holding) => ({ + name: holding.name, + symbol: holding.symbol, + currency: holding.currency, + assetClass: holding.assetClass, + assetSubClass: holding.assetSubClass, + allocationInPercentage: ( + holding.allocationInPercentage * 100 + ).toFixed(2), + marketPrice: holding.marketPrice, + quantity: holding.quantity, + valueInBaseCurrency: holding.valueInBaseCurrency + }) + ); + + return { + success: true, + holdings, + summary: details.summary + }; + } catch (error) { + return { + success: false, + error: `Failed to fetch portfolio: ${error.message}` + }; + } + } + }), + + // TOOL 2: Market Data Lookup + // Lets the agent look up current prices and info for any symbol. + market_data: tool({ + description: + 'Look up current market data for a stock, ETF, or cryptocurrency by searching for its name or symbol. Use this when the user asks about current prices, what a stock is trading at, or wants to look up a specific asset.', + parameters: z.object({ + query: z + .string() + .describe( + 'The stock symbol or company name to search for (e.g. "AAPL", "Apple", "VTI")' + ) + }), + execute: async ({ query }) => { + try { + const result = await this.symbolService.lookup({ + query, + user: { id: userId, settings: { settings: {} } } as any + }); + + if (!result?.items?.length) { + return { + success: false, + error: `No results found for "${query}"` + }; + } + + return { + success: true, + results: result.items.slice(0, 5).map((item) => ({ + symbol: item.symbol, + name: item.name, + currency: item.currency, + dataSource: item.dataSource, + assetClass: item.assetClass, + assetSubClass: item.assetSubClass + })) + }; + } catch (error) { + return { + success: false, + error: `Failed to look up symbol: ${error.message}` + }; + } + } + }), + + // TOOL 3: Transaction History + // Fetches the user's buy/sell/dividend activity. + transaction_history: tool({ + description: + 'Get the user\'s recent transaction history (buys, sells, dividends, fees). Use this when the user asks about their past trades, activity, transaction patterns, or what they have bought or sold recently.', + parameters: z.object({ + limit: z + .number() + .optional() + .default(20) + .describe('Maximum number of transactions to return') + }), + execute: async ({ limit }) => { + try { + const { activities } = + await this.orderService.getOrders({ + filters: [], + userCurrency: 'USD', + userId, + withExcludedAccountsAndActivities: false + }); + + const recentActivities = activities + .sort( + (a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ) + .slice(0, limit) + .map((activity) => ({ + date: activity.date, + type: activity.type, + symbol: activity.SymbolProfile?.symbol, + name: activity.SymbolProfile?.name, + quantity: activity.quantity, + unitPrice: activity.unitPrice, + currency: activity.SymbolProfile?.currency, + fee: activity.fee + })); + + return { + success: true, + transactions: recentActivities, + totalCount: activities.length + }; + } catch (error) { + return { + success: false, + error: `Failed to fetch transactions: ${error.message}` + }; + } + } + }) + }, + // maxSteps is what makes this an agent, not a chain. + // The LLM can call tools, see results, then decide to call + // more tools or respond. Up to 5 iterations of the ReAct loop. + maxSteps: 5 + }); + + return { + message: result.text, + toolCalls: result.steps.flatMap((step) => + step.toolCalls.map((tc) => ({ + tool: tc.toolName, + args: tc.args + })) + ) + }; + } +} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 89f52e1ea..c9eb5168b 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -26,6 +26,7 @@ import { join } from 'node:path'; import { AccessModule } from './access/access.module'; import { AccountModule } from './account/account.module'; import { AdminModule } from './admin/admin.module'; +import { AgentModule } from './agent/agent.module'; import { AppController } from './app.controller'; import { AssetModule } from './asset/asset.module'; import { AuthDeviceModule } from './auth-device/auth-device.module'; @@ -62,6 +63,7 @@ import { UserModule } from './user/user.module'; AdminModule, AccessModule, AccountModule, + AgentModule, AiModule, ApiKeysModule, AssetModule, diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 501119b31..1547f1621 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -75,6 +75,14 @@ > } +
  • + Agent +
  • Accounts + Agent =18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, "node_modules/@ai-sdk/provider": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", @@ -11849,9 +11866,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@stencil/core": { diff --git a/package.json b/package.json index 2bf5eebe0..369a04139 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "workspace-generator": "nx workspace-generator" }, "dependencies": { + "@ai-sdk/openai": "^1.3.22", "@angular/animations": "21.1.1", "@angular/cdk": "21.1.1", "@angular/common": "21.1.1",