Browse Source

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.
pull/6459/head
jpwilson 1 month ago
parent
commit
e5584dbd5f
  1. 315
      apps/api/src/app/agent/agent-chat.html
  2. 81
      apps/api/src/app/agent/agent.controller.ts
  3. 61
      apps/api/src/app/agent/agent.module.ts
  4. 197
      apps/api/src/app/agent/agent.service.ts
  5. 2
      apps/api/src/app/app.module.ts
  6. 13
      apps/client/src/app/components/header/header.component.html
  7. 23
      package-lock.json
  8. 1
      package.json

315
apps/api/src/app/agent/agent-chat.html

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ghostfolio Agent</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #e0e0e0;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: #16213e;
padding: 16px 24px;
border-bottom: 1px solid #2a2a4a;
display: flex;
align-items: center;
gap: 12px;
}
header a.back {
color: #a78bfa;
text-decoration: none;
font-size: 14px;
padding: 6px 14px;
border-radius: 6px;
border: 1px solid #2a2a4a;
transition: background 0.2s;
}
header a.back:hover { background: #222244; }
header h1 { font-size: 18px; font-weight: 600; }
header span { font-size: 12px; color: #888; }
.status {
margin-left: auto;
font-size: 12px;
color: #4ade80;
display: flex;
align-items: center;
gap: 6px;
}
.status::before {
content: '';
width: 8px;
height: 8px;
background: #4ade80;
border-radius: 50%;
display: inline-block;
}
.status.disconnected { color: #f87171; }
.status.disconnected::before { background: #f87171; }
#chat {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
font-size: 14px;
white-space: pre-wrap;
}
.message.user {
align-self: flex-end;
background: #0f3460;
border-bottom-right-radius: 4px;
}
.message.assistant {
align-self: flex-start;
background: #222244;
border-bottom-left-radius: 4px;
}
.message.assistant .tool-calls {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #333;
font-size: 11px;
color: #888;
}
.message.assistant .tool-calls span {
background: #1a1a3e;
padding: 2px 8px;
border-radius: 4px;
margin-right: 4px;
font-family: monospace;
color: #a78bfa;
}
.message.error {
align-self: center;
background: #3b1111;
color: #f87171;
font-size: 12px;
}
.typing {
align-self: flex-start;
color: #888;
font-size: 13px;
padding: 8px 16px;
}
.typing::after {
content: '...';
animation: dots 1.5s infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
#input-area {
padding: 16px 24px;
background: #16213e;
border-top: 1px solid #2a2a4a;
display: flex;
gap: 12px;
}
#input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #2a2a4a;
background: #1a1a2e;
color: #e0e0e0;
font-size: 14px;
outline: none;
font-family: inherit;
}
#input:focus { border-color: #4a4a8a; }
#input::placeholder { color: #555; }
button {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #0f3460;
color: #e0e0e0;
font-size: 14px;
cursor: pointer;
font-family: inherit;
}
button:hover { background: #1a4a80; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.welcome {
text-align: center;
color: #666;
margin: auto;
font-size: 14px;
line-height: 2;
}
.welcome h2 { color: #888; font-size: 16px; margin-bottom: 8px; }
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 12px;
}
.suggestions button {
padding: 8px 14px;
font-size: 12px;
background: #222244;
border: 1px solid #2a2a4a;
}
.suggestions button:hover { background: #2a2a5a; }
</style>
</head>
<body>
<header>
<a class="back" id="back-link" href="/en/">← Ghostfolio</a>
<h1>Agent</h1>
<span>AI Financial Assistant</span>
<div class="status" id="status">Connected</div>
</header>
<div id="chat">
<div class="welcome">
<h2>Ask me about your portfolio</h2>
<div>I can analyze your holdings, look up market data, and review your transactions.</div>
<div class="suggestions">
<button onclick="sendSuggestion(this.textContent)">What does my portfolio look like?</button>
<button onclick="sendSuggestion(this.textContent)">What have I bought recently?</button>
<button onclick="sendSuggestion(this.textContent)">Am I diversified enough?</button>
<button onclick="sendSuggestion(this.textContent)">What's my biggest holding?</button>
<button onclick="sendSuggestion(this.textContent)">How much cash do I have?</button>
<button onclick="sendSuggestion(this.textContent)">Am I too heavy in tech stocks?</button>
</div>
</div>
</div>
<div id="input-area">
<input id="input" type="text" placeholder="Ask about your portfolio..." autofocus />
<button id="send" onclick="send()">Send</button>
</div>
<script>
// Use relative URL so it works from any origin (port 4200 proxy or 3333 direct)
const API = '/api/v1/agent/chat';
// Auto-detect auth token from Ghostfolio's storage
function getToken() {
// Ghostfolio stores JWT as 'auth-token' in sessionStorage and localStorage
return sessionStorage.getItem('auth-token')
|| localStorage.getItem('auth-token')
|| '';
}
let TOKEN = getToken();
const messages = [];
const statusEl = document.getElementById('status');
if (!TOKEN) {
statusEl.textContent = 'Not authenticated';
statusEl.className = 'status disconnected';
const t = prompt('No auth token found. Please sign into Ghostfolio first, then reload this page.\n\nOr paste your JWT token here:');
if (t) {
TOKEN = t;
}
}
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
function sendSuggestion(text) {
input.value = text;
send();
}
async function send() {
const text = input.value.trim();
if (!text) return;
// Re-check token in case user logged in
if (!TOKEN) TOKEN = getToken();
if (!TOKEN) {
addMessage('Please sign into Ghostfolio first, then reload this page.', 'error');
return;
}
const welcome = chat.querySelector('.welcome');
if (welcome) welcome.remove();
addMessage(text, 'user');
messages.push({ role: 'user', content: text });
input.value = '';
sendBtn.disabled = true;
const typing = document.createElement('div');
typing.className = 'typing';
typing.textContent = 'Thinking';
chat.appendChild(typing);
chat.scrollTop = chat.scrollHeight;
try {
const res = await fetch(API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TOKEN}`
},
body: JSON.stringify({ messages })
});
typing.remove();
if (!res.ok) {
const err = await res.text();
addMessage(`Error ${res.status}: ${err}`, 'error');
sendBtn.disabled = false;
return;
}
const data = await res.json();
messages.push({ role: 'assistant', content: data.message });
addMessage(data.message, 'assistant', data.toolCalls);
} catch (err) {
typing.remove();
addMessage(`Network error: ${err.message}`, 'error');
}
sendBtn.disabled = false;
input.focus();
}
function addMessage(text, role, toolCalls) {
const div = document.createElement('div');
div.className = `message ${role}`;
div.textContent = text;
if (toolCalls?.length) {
const tc = document.createElement('div');
tc.className = 'tool-calls';
tc.innerHTML = 'Tools used: ' + toolCalls.map(t => `<span>${t.tool}</span>`).join('');
div.appendChild(tc);
}
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
}
</script>
</body>
</html>

81
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
);
}
}
}

61
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 {}

197
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
}))
)
};
}
}

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

@ -26,6 +26,7 @@ import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AgentModule } from './agent/agent.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
@ -62,6 +63,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AgentModule,
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetModule, AssetModule,

13
apps/client/src/app/components/header/header.component.html

@ -75,6 +75,14 @@
> >
</li> </li>
} }
<li class="list-inline-item">
<a
class="d-none d-sm-block"
mat-flat-button
href="/api/v1/agent/ui"
>Agent</a
>
</li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
@ -267,6 +275,11 @@
[routerLink]="routerLinkAccounts" [routerLink]="routerLinkAccounts"
>Accounts</a >Accounts</a
> >
<a
mat-menu-item
href="/api/v1/agent/ui"
>Agent</a
>
<a <a
i18n i18n
mat-menu-item mat-menu-item

23
package-lock.json

@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1.3.22",
"@angular/animations": "21.1.1", "@angular/animations": "21.1.1",
"@angular/cdk": "21.1.1", "@angular/cdk": "21.1.1",
"@angular/common": "21.1.1", "@angular/common": "21.1.1",
@ -176,6 +177,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ai-sdk/openai": {
"version": "1.3.22",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.22.tgz",
"integrity": "sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"@ai-sdk/provider-utils": "2.2.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.0.0"
}
},
"node_modules/@ai-sdk/provider": { "node_modules/@ai-sdk/provider": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz",
@ -11849,9 +11866,9 @@
} }
}, },
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stencil/core": { "node_modules/@stencil/core": {

1
package.json

@ -55,6 +55,7 @@
"workspace-generator": "nx workspace-generator" "workspace-generator": "nx workspace-generator"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1.3.22",
"@angular/animations": "21.1.1", "@angular/animations": "21.1.1",
"@angular/cdk": "21.1.1", "@angular/cdk": "21.1.1",
"@angular/common": "21.1.1", "@angular/common": "21.1.1",

Loading…
Cancel
Save