Browse Source

feat: add optional AI agent integration for portfolio analysis

Add an AI-powered portfolio assistant that lets users ask natural-language
questions about their holdings, run allocation simulations, and receive
strategy recommendations via a terminal-themed chat UI.

The agent runs as a separate microservice (available as an npm package).
Ghostfolio proxies authenticated requests to it. The feature is fully
optional — if AGENT_SERVICE_URL is not set, all endpoints return 503
and the UI route is inactive.

Architecture:
- NestJS proxy module (REST + WebSocket + SSE streaming)
- Angular lazy-loaded chat page with real-time tool execution visibility
- Strategy questionnaire component for guided portfolio recommendations
- Zero impact on existing features when disabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pull/6457/head
RajatA98 1 month ago
parent
commit
a9c7afc654
  1. 3
      .env.example
  2. 2
      apps/api/src/app/app.module.ts
  3. 77
      apps/api/src/app/endpoints/agent/agent.controller.spec.ts
  4. 90
      apps/api/src/app/endpoints/agent/agent.controller.ts
  5. 150
      apps/api/src/app/endpoints/agent/agent.gateway.ts
  6. 20
      apps/api/src/app/endpoints/agent/agent.module.ts
  7. 89
      apps/api/src/app/endpoints/agent/agent.service.spec.ts
  8. 235
      apps/api/src/app/endpoints/agent/agent.service.ts
  9. 6
      apps/api/src/main.ts
  10. 1
      apps/api/src/services/configuration/configuration.service.ts
  11. 1
      apps/api/src/services/interfaces/environment.interface.ts
  12. 5
      apps/client/src/app/app.routes.ts
  13. 1194
      apps/client/src/app/pages/agent/agent-page.component.ts
  14. 279
      apps/client/src/app/pages/agent/agent-page.html
  15. 15
      apps/client/src/app/pages/agent/agent-page.routes.ts
  16. 708
      apps/client/src/app/pages/agent/agent-page.scss
  17. 47
      apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.html
  18. 107
      apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.scss
  19. 237
      apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.ts
  20. 105
      apps/client/src/app/pages/agent/components/sparkline/sparkline.component.ts
  21. 45
      apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.html
  22. 163
      apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.scss
  23. 22
      apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.ts
  24. 261
      apps/client/src/app/pages/agent/models/strategy-flow.data.ts
  25. 33
      apps/client/src/app/pages/agent/models/strategy-flow.types.ts
  26. 5
      libs/common/src/lib/routes/routes.ts

3
.env.example

@ -14,3 +14,6 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# AGENT (optional — set to enable the AI portfolio agent)
# AGENT_SERVICE_URL=http://localhost:3334

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

@ -31,6 +31,7 @@ import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { AgentModule } from './endpoints/agent/agent.module';
import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { AssetsModule } from './endpoints/assets/assets.module';
@ -62,6 +63,7 @@ import { UserModule } from './user/user.module';
AdminModule,
AccessModule,
AccountModule,
AgentModule,
AiModule,
ApiKeysModule,
AssetModule,

77
apps/api/src/app/endpoints/agent/agent.controller.spec.ts

@ -0,0 +1,77 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
import { AgentController } from './agent.controller';
import { AgentService } from './agent.service';
describe('AgentController', () => {
const makeRes = () => {
const response = {
json: jest.fn(),
status: jest.fn()
};
response.status.mockReturnValue(response);
return response;
};
it('returns 503 for auth status when agent service is disabled', async () => {
const request = {
headers: {
authorization: 'Bearer jwt-token'
},
user: {
id: 'user-1'
}
} as unknown as RequestWithUser;
const agentService = {
isEnabled: jest.fn().mockReturnValue(false),
proxyToAgent: jest.fn()
} as unknown as AgentService;
const controller = new AgentController(agentService, request);
const res = makeRes();
await controller.getAuthStatus(res as any);
expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith({
error: 'Agent service is not configured.'
});
expect(agentService.proxyToAgent).not.toHaveBeenCalled();
});
it('proxies chat request with user id and bearer token', async () => {
const request = {
headers: {
authorization: 'Bearer jwt-token'
},
user: {
id: 'user-1'
}
} as unknown as RequestWithUser;
const agentService = {
isEnabled: jest.fn().mockReturnValue(true),
proxyToAgent: jest.fn().mockResolvedValue({
status: 200,
data: { answer: 'done' }
})
} as unknown as AgentService;
const controller = new AgentController(agentService, request);
const res = makeRes();
const body = { message: 'hello' };
await controller.chat(body, res as any);
expect(agentService.proxyToAgent).toHaveBeenCalledWith({
method: 'POST',
path: '/api/chat',
ghostfolioUserId: 'user-1',
bearerToken: 'Bearer jwt-token',
body
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ answer: 'done' });
});
});

90
apps/api/src/app/endpoints/agent/agent.controller.ts

@ -0,0 +1,90 @@
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Inject,
Post,
Res,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { AgentService } from './agent.service';
@Controller('agent')
export class AgentController {
public constructor(
private readonly agentService: AgentService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
private getBearerToken(): string {
const headers = this.request.headers as unknown as Record<string, unknown>;
const auth =
headers[HEADER_KEY_TOKEN.toLowerCase()] ?? headers['authorization'];
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
return auth;
}
return typeof auth === 'string' ? `Bearer ${auth}` : '';
}
@Get('auth/status')
@UseGuards(AuthGuard('jwt'))
public async getAuthStatus(@Res() res: Response): Promise<void> {
if (!this.agentService.isEnabled()) {
res.status(503).json({ error: 'Agent service is not configured.' });
return;
}
const { status, data } = await this.agentService.proxyToAgent({
method: 'GET',
path: '/api/auth/status',
ghostfolioUserId: this.request.user.id,
bearerToken: this.getBearerToken()
});
res.status(status).json(data);
}
@Post('chat')
@UseGuards(AuthGuard('jwt'))
public async chat(
@Body() body: unknown,
@Res() res: Response
): Promise<void> {
if (!this.agentService.isEnabled()) {
res.status(503).json({ error: 'Agent service is not configured.' });
return;
}
const { status, data } = await this.agentService.proxyToAgent({
method: 'POST',
path: '/api/chat',
ghostfolioUserId: this.request.user.id,
bearerToken: this.getBearerToken(),
body
});
res.status(status).json(data);
}
@Post('chat/stream')
@UseGuards(AuthGuard('jwt'))
public async chatStream(
@Body() body: unknown,
@Res() res: Response
): Promise<void> {
if (!this.agentService.isEnabled()) {
res.status(503).json({ error: 'Agent service is not configured.' });
return;
}
await this.agentService.proxyStreamToAgent({
path: '/api/chat/stream',
ghostfolioUserId: this.request.user.id,
bearerToken: this.getBearerToken(),
body,
res
});
}
}

150
apps/api/src/app/endpoints/agent/agent.gateway.ts

@ -0,0 +1,150 @@
import type { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Server } from 'node:http';
import { WebSocketServer } from 'ws';
import { AgentService } from './agent.service';
const AGENT_WS_PATH = '/api/v1/agent/ws';
export function setupAgentWebSocket(
httpServer: Server,
app: INestApplication
): void {
const agentService = app.get(AgentService);
const jwtService = app.get(JwtService);
const wss = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
const url = request.url ?? '';
if (!url.startsWith(AGENT_WS_PATH)) {
socket.destroy();
return;
}
const parsed = new URL(
url,
`http://${request.headers.host ?? 'localhost'}`
);
const token = parsed.searchParams.get('token');
if (!token) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
const raw = token.startsWith('Bearer ') ? token.slice(7) : token;
let userId: string;
try {
const payload = jwtService.verify<{ id: string }>(raw);
userId = payload?.id;
if (!userId) {
throw new Error('Missing user id in token');
}
} catch {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
const bearerToken = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request, { userId, bearerToken });
});
});
wss.on(
'connection',
(
ws: import('ws').WebSocket,
_request: unknown,
context: { userId: string; bearerToken: string }
) => {
const { userId, bearerToken } = context;
let abortController: AbortController | null = null;
ws.on('message', async (data: Buffer | string) => {
if (ws.readyState !== 1) return; // OPEN
let msg: {
type: string;
conversationHistory?: { role: string; content: string }[];
message?: string;
};
try {
const raw = typeof data === 'string' ? data : data.toString('utf8');
msg = JSON.parse(raw) as {
type: string;
conversationHistory?: { role: string; content: string }[];
message?: string;
};
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
return;
}
if (msg.type === 'cancel') {
abortController?.abort();
return;
}
if (
msg.type !== 'chat' ||
!msg.message ||
!Array.isArray(msg.conversationHistory)
) {
ws.send(
JSON.stringify({
type: 'error',
message: 'Expected { type: "chat", message, conversationHistory }'
})
);
return;
}
if (!agentService.isEnabled()) {
ws.send(
JSON.stringify({
type: 'error',
message: 'Agent service is not configured.'
})
);
return;
}
abortController = new AbortController();
try {
for await (const event of agentService.proxyStreamToAgentEvents({
path: '/api/chat/stream',
ghostfolioUserId: userId,
bearerToken,
body: {
conversationHistory: msg.conversationHistory,
message: msg.message
},
signal: abortController.signal
})) {
if (ws.readyState !== 1) break;
ws.send(JSON.stringify(event));
}
} catch (error) {
const name = (error as Error)?.name;
if (name === 'AbortError') return;
const msg = error instanceof Error ? error.message : String(error);
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', message: msg }));
}
} finally {
abortController = null;
}
});
ws.on('close', () => {
abortController?.abort();
});
}
);
}

20
apps/api/src/app/endpoints/agent/agent.module.ts

@ -0,0 +1,20 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AgentController } from './agent.controller';
import { AgentService } from './agent.service';
@Module({
controllers: [AgentController],
imports: [
ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
})
],
providers: [AgentService]
})
export class AgentModule {}

89
apps/api/src/app/endpoints/agent/agent.service.spec.ts

@ -0,0 +1,89 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { AgentService } from './agent.service';
describe('AgentService', () => {
let service: AgentService;
let configurationService: Pick<ConfigurationService, 'get'>;
beforeEach(() => {
configurationService = {
get: jest.fn()
};
service = new AgentService(configurationService as ConfigurationService);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns trimmed agent base URL', () => {
(configurationService.get as jest.Mock).mockReturnValue(
' http://localhost:3334/ '
);
expect(service.getAgentBaseUrl()).toBe('http://localhost:3334/');
});
it('detects when the agent service is disabled', () => {
(configurationService.get as jest.Mock).mockReturnValue(' ');
expect(service.isEnabled()).toBe(false);
});
it('returns 503 when AGENT_SERVICE_URL is missing', async () => {
(configurationService.get as jest.Mock).mockReturnValue('');
await expect(
service.proxyToAgent({
method: 'GET',
path: '/api/auth/status',
ghostfolioUserId: 'user-1',
bearerToken: 'Bearer abc'
})
).resolves.toEqual({
status: 503,
data: {
error: 'Agent service is not configured (AGENT_SERVICE_URL).'
}
});
});
it('forwards auth and user-id headers for POST requests', async () => {
(configurationService.get as jest.Mock).mockReturnValue(
'http://localhost:3334'
);
const json = { answer: 'ok' };
const fetchSpy = jest.spyOn(global, 'fetch' as any).mockResolvedValue({
headers: {
get: () => 'application/json'
},
json: async () => json,
status: 200
} as unknown as Response);
const payload = { message: 'hello' };
await expect(
service.proxyToAgent({
method: 'POST',
path: '/api/chat',
ghostfolioUserId: 'user-123',
bearerToken: 'token-without-prefix',
body: payload
})
).resolves.toEqual({ status: 200, data: json });
expect(fetchSpy).toHaveBeenCalledWith('http://localhost:3334/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token-without-prefix',
'x-ghostfolio-user-id': 'user-123'
},
body: JSON.stringify(payload)
});
});
});

235
apps/api/src/app/endpoints/agent/agent.service.ts

@ -0,0 +1,235 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { Readable } from 'node:stream';
const X_GHOSTFOLIO_USER_ID = 'x-ghostfolio-user-id';
@Injectable()
export class AgentService {
public constructor(
private readonly configurationService: ConfigurationService
) {}
public getAgentBaseUrl(): string {
const url = this.configurationService.get('AGENT_SERVICE_URL');
return typeof url === 'string' ? url.trim() : '';
}
public isEnabled(): boolean {
return this.getAgentBaseUrl().length > 0;
}
/**
* Proxy a request to the ghostfolio-agent service with shared-auth headers.
* Forwards the user's Ghostfolio JWT and user id so the agent uses the same auth.
*/
public async proxyToAgent({
method,
path,
ghostfolioUserId,
bearerToken,
body
}: {
method: 'GET' | 'POST';
path: string;
ghostfolioUserId: string;
bearerToken: string;
body?: unknown;
}): Promise<{ status: number; data: unknown }> {
const baseUrl = this.getAgentBaseUrl();
if (!baseUrl) {
return {
status: 503,
data: { error: 'Agent service is not configured (AGENT_SERVICE_URL).' }
};
}
const url = `${baseUrl.replace(/\/$/, '')}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: bearerToken.startsWith('Bearer ')
? bearerToken
: `Bearer ${bearerToken}`,
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId
};
const init: RequestInit = { method, headers };
if (body !== undefined && method === 'POST') {
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
let data: unknown;
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
try {
data = await response.json();
} catch {
data = { error: 'Invalid JSON response from agent' };
}
} else {
data = { error: await response.text() };
}
return { status: response.status, data };
}
/**
* Proxy a streaming request to the agent's /api/chat/stream endpoint.
* Pipes the SSE stream from the agent to the Express response.
*/
public async proxyStreamToAgent({
path,
ghostfolioUserId,
bearerToken,
body,
res
}: {
path: string;
ghostfolioUserId: string;
bearerToken: string;
body: unknown;
res: Response;
}): Promise<void> {
const baseUrl = this.getAgentBaseUrl();
if (!baseUrl) {
res.status(503).json({
error: 'Agent service is not configured (AGENT_SERVICE_URL).'
});
return;
}
const url = `${baseUrl.replace(/\/$/, '')}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: bearerToken.startsWith('Bearer ')
? bearerToken
: `Bearer ${bearerToken}`,
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId
};
const agentRes = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
});
if (!agentRes.ok) {
const text = await agentRes.text();
res
.status(agentRes.status)
.json({ error: text || 'Agent request failed' });
return;
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
// Disable Nagle's algorithm for immediate delivery of SSE chunks
(res as any).socket?.setNoDelay?.(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeStream = Readable.fromWeb(agentRes.body as any);
nodeStream.pipe(res);
}
/**
* Proxy a streaming request to the agent and yield parsed SSE events.
* Used by the WebSocket gateway to forward events to the client.
*/
public async *proxyStreamToAgentEvents({
path,
ghostfolioUserId,
bearerToken,
body,
signal
}: {
path: string;
ghostfolioUserId: string;
bearerToken: string;
body: unknown;
signal?: AbortSignal;
}): AsyncGenerator<Record<string, unknown>> {
const baseUrl = this.getAgentBaseUrl();
if (!baseUrl) {
yield {
type: 'error',
message: 'Agent service is not configured (AGENT_SERVICE_URL).'
};
return;
}
const url = `${baseUrl.replace(/\/$/, '')}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: bearerToken.startsWith('Bearer ')
? bearerToken
: `Bearer ${bearerToken}`,
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId
};
const agentRes = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal
});
if (!agentRes.ok) {
const text = await agentRes.text();
yield {
type: 'error',
message: text || `Agent request failed (HTTP ${agentRes.status})`
};
return;
}
if (!agentRes.body) {
yield { type: 'error', message: 'Streaming response was empty' };
return;
}
const reader = agentRes.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (signal?.aborted) break;
buffer += decoder.decode(value, { stream: true });
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex >= 0) {
const rawEvent = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
const dataPayload = rawEvent
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
.join('\n');
if (dataPayload) {
try {
const parsed = JSON.parse(dataPayload) as Record<string, unknown>;
yield parsed;
} catch {
// Skip malformed events
}
}
separatorIndex = buffer.indexOf('\n\n');
}
}
} finally {
reader.releaseLock();
}
}
}

6
apps/api/src/main.ts

@ -18,6 +18,7 @@ import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app/app.module';
import { setupAgentWebSocket } from './app/endpoints/agent/agent.gateway';
import { environment } from './environments/environment';
async function bootstrap() {
@ -91,7 +92,10 @@ async function bootstrap() {
await app.listen(PORT, HOST, () => {
logLogo();
let address = app.getHttpServer().address();
const httpServer = app.getHttpServer();
setupAgentWebSocket(httpServer, app);
let address = httpServer.address();
if (typeof address === 'object') {
const addressObject = address;

1
apps/api/src/services/configuration/configuration.service.ts

@ -22,6 +22,7 @@ export class ConfigurationService {
public constructor() {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
AGENT_SERVICE_URL: str({ default: '' }),
API_KEY_ALPHA_VANTAGE: str({ default: '' }),
API_KEY_BETTER_UPTIME: str({ default: '' }),
API_KEY_COINGECKO_DEMO: str({ default: '' }),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -2,6 +2,7 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
AGENT_SERVICE_URL: string;
API_KEY_ALPHA_VANTAGE: string;
API_KEY_BETTER_UPTIME: string;
API_KEY_COINGECKO_DEMO: string;

5
apps/client/src/app/app.routes.ts

@ -36,6 +36,11 @@ export const routes: Routes = [
path: internalRoutes.api.path,
title: internalRoutes.api.title
},
{
path: internalRoutes.agent.path,
loadChildren: () =>
import('./pages/agent/agent-page.routes').then((m) => m.routes)
},
{
path: internalRoutes.auth.path,
loadChildren: () =>

1194
apps/client/src/app/pages/agent/agent-page.component.ts

File diff suppressed because it is too large

279
apps/client/src/app/pages/agent/agent-page.html

@ -0,0 +1,279 @@
<div class="agent-layout">
<div class="chat-panel">
<mat-card class="agent-shell terminal-theme">
<mat-card-header class="terminal-header">
<mat-card-title class="terminal-brand" i18n>
Agent
<span
class="connection-dot"
[ngClass]="connectionStatus"
[title]="connectionStatus"
></span>
</mat-card-title>
<mat-card-subtitle class="terminal-subtitle" i18n
>Ask portfolio questions using your current Ghostfolio
session.</mat-card-subtitle
>
</mat-card-header>
<mat-card-content class="agent-content">
<div #messagesContainer class="messages" role="log">
@if (messages.length === 0) {
<div class="empty-state" i18n>
<div class="empty-text">
Would you like me to buy stocks or crypto for you?
</div>
<button
class="strategy-flow-btn"
type="button"
(click)="startStrategyFlow()"
>
Build Investment Strategy
</button>
</div>
}
@for (entry of messages; track trackByIndex($index)) {
<div
class="message-row"
[ngClass]="{
assistant: entry.role === 'assistant',
user: entry.role === 'user'
}"
>
@if (entry.strategyStep) {
<gf-strategy-card
[disabled]="
strategyMode &&
currentStrategyStep?.stepId !== entry.strategyStep.stepId
"
[step]="entry.strategyStep"
(optionSelected)="handleStrategySelection($event)"
></gf-strategy-card>
} @else {
<div class="message-bubble">
<div class="message-text">
<markdown
class="message-markdown"
mermaid
[data]="entry.content ?? ''"
[mermaidOptions]="mermaidOptions"
></markdown>
</div>
@if (
entry.role === 'assistant' && entry.confidence !== undefined
) {
<div class="meta-row">
{{ entry.confidence * 100 | number: '1.0-0' }}% confidence
</div>
}
@if (
entry.role === 'assistant' &&
getDisplayWarnings(entry).length > 0
) {
<div class="meta-row warning">
{{ getDisplayWarnings(entry).join(' | ') }}
</div>
}
@if (
entry.role === 'assistant' &&
(entry.toolTrace?.length ?? 0) > 0
) {
<div class="meta-row tool-trace">
<span class="tool-trace-label" i18n>Tools</span>
@for (
tool of getToolStatuses(entry);
track trackByIndex($index)
) {
<span
class="tool-pill"
[ngClass]="{ ok: tool.ok, fail: !tool.ok }"
>
{{ tool.tool }} {{ tool.ok ? 'ok' : 'failed' }}
</span>
}
</div>
}
@if (entry.role === 'assistant' && entry.loopMeta) {
<div class="meta-row metrics-row">
{{ getMetricsText(entry.loopMeta) }}
</div>
}
</div>
}
</div>
}
<!-- Thinking indicator -->
@if (isSending && thinkingLabel && streamingAnswer.length === 0) {
<div class="message-row assistant">
<div class="thinking-indicator">
<span class="thinking-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<span class="thinking-label">{{ thinkingLabel }}</span>
</div>
</div>
}
<!-- Collapsible tool call rows -->
@if (activeToolCalls.length > 0) {
<div class="tool-calls-panel">
@for (tc of activeToolCalls; track trackByIndex($index)) {
<div
class="tool-call-row"
[class.expanded]="tc.expanded"
[class.fail]="!tc.ok"
[class.ok]="tc.ok"
>
<div
class="tool-call-header"
(click)="toggleToolExpanded($index)"
>
<span class="tool-call-chevron">{{
tc.expanded ? '▾' : '▸'
}}</span>
<span class="tool-call-name">{{ tc.tool }}</span>
@if (tc.ms > 0) {
<span
class="tool-call-status"
[class.fail]="!tc.ok"
[class.ok]="tc.ok"
>
{{ tc.ok ? tc.ms + 'ms' : 'FAIL' }}
</span>
} @else {
<span class="tool-call-status running">running</span>
}
</div>
@if (tc.expanded) {
<div class="tool-call-detail">
@if (tc.input && (tc.input | json) !== '{}') {
<div class="tool-detail-section">
<span class="tool-detail-label">Input</span>
<pre class="tool-detail-pre">{{
tc.input | json
}}</pre>
</div>
}
@if (tc.result) {
<div class="tool-detail-section">
<span class="tool-detail-label">Result</span>
<pre class="tool-detail-pre">{{
tc.result | json
}}</pre>
</div>
}
</div>
}
</div>
}
</div>
}
<!-- Debug console (toggleable) -->
@if (showDebugConsole && streamLogLines.length > 0) {
<div class="message-row assistant stream-console">
<div class="stream-bubble">
<div class="stream-header">[AGENT]</div>
<div class="stream-lines">
@for (line of streamLogLines; track trackByIndex($index)) {
<div class="stream-line" [ngClass]="line.cssClass">
{{ line.text }}
</div>
}
</div>
</div>
</div>
}
<!-- Streaming answer with blinking cursor -->
@if (streamingAnswer.length > 0) {
<div class="message-row assistant streaming-answer">
<div class="message-bubble">
<div class="message-text">
@if (receivedTextDelta) {
<markdown
class="message-markdown"
mermaid
[data]="streamingAnswer"
[mermaidOptions]="mermaidOptions"
></markdown>
} @else {
<span class="streaming-text">{{
streamingAnswer.slice(0, streamingAnswerVisibleLength)
}}</span>
}
@if (isSending) {
<span aria-hidden="true" class="typewriter-cursor"></span>
}
</div>
</div>
</div>
}
<div #scrollAnchor aria-hidden="true" class="scroll-anchor"></div>
</div>
<div class="composer terminal-composer">
<span aria-hidden="true" class="prompt-char">&gt;</span>
<mat-form-field appearance="outline" class="composer-input">
<input
i18n-placeholder
matInput
placeholder="Type your question..."
[(ngModel)]="draftMessage"
(keyup.enter)="sendMessage()"
/>
</mat-form-field>
@if (isSending && hasDraftMessage) {
<button
class="queue-btn terminal-queue"
i18n
type="button"
(click)="sendMessage()"
>
Queue
</button>
<button
class="queue-btn terminal-interject"
i18n
type="button"
(click)="interjectMessage()"
>
Interject
</button>
}
<button
class="debug-toggle-btn"
title="Toggle debug console"
type="button"
[class.active]="showDebugConsole"
(click)="toggleDebugConsole()"
>
<span aria-hidden="true">{{ '{' }}{{ '}' }}</span>
</button>
<button
class="send-btn terminal-send"
type="button"
[disabled]="!isSending && !hasDraftMessage"
[ngClass]="{ stop: isSending }"
(click)="isSending ? cancelMessage() : sendMessage()"
>
<span aria-hidden="true" class="send-glyph">{{
isSending ? '■' : '>'
}}</span>
</button>
</div>
@if (queueCount > 0) {
<div class="queue-meta" i18n>{{ queueCount }} queued</div>
}
</mat-card-content>
</mat-card>
</div>
</div>

15
apps/client/src/app/pages/agent/agent-page.routes.ts

@ -0,0 +1,15 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfAgentPageComponent } from './agent-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: GfAgentPageComponent,
path: '',
title: internalRoutes.agent.title
}
];

708
apps/client/src/app/pages/agent/agent-page.scss

@ -0,0 +1,708 @@
// Bloomberg terminal-style theme for the agent tab
:host {
display: block;
}
// --- Layout: chat + chart side by side ---
.agent-layout {
display: flex;
gap: 0;
height: calc(100vh - 8.5rem);
margin: 1rem auto;
max-width: 90rem;
}
.chat-panel {
flex: 1;
min-width: 0;
}
@media (max-width: 768px) {
.agent-layout {
flex-direction: column;
height: auto;
min-height: calc(100vh - 8.5rem);
}
.chat-panel {
min-height: 60vh;
}
}
.agent-shell.terminal-theme {
background: #0a0a0a;
border: 1px solid #333;
border-radius: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.terminal-theme .terminal-header {
align-items: flex-start;
background: #111;
border-bottom: 2px solid #ff6600;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 0.75rem 1rem;
.terminal-brand {
align-items: center;
color: #ff6600;
display: flex;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 1rem;
font-weight: 700;
gap: 0.5rem;
letter-spacing: 2px;
text-transform: uppercase;
}
.header-actions {
display: flex;
gap: 0.4rem;
}
.connection-dot {
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
height: 8px;
width: 8px;
&.connected {
background: #33ff99;
box-shadow: 0 0 4px rgba(51, 255, 153, 0.5);
}
&.connecting {
animation: dot-pulse 1.2s ease-in-out infinite;
background: #ffb84d;
box-shadow: 0 0 4px rgba(255, 184, 77, 0.5);
}
&.disconnected,
&.error {
background: #ff6b6b;
box-shadow: 0 0 4px rgba(255, 107, 107, 0.5);
}
}
@keyframes dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.terminal-subtitle {
color: #666;
font-size: 0.7rem;
letter-spacing: 1px;
text-transform: uppercase;
}
}
.terminal-theme .agent-content {
display: flex;
flex: 1;
flex-direction: column;
gap: 0;
min-height: 0;
padding: 0;
}
.terminal-theme .messages {
background: #0a0a0a;
border: none;
border-radius: 0;
flex: 1;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 13px;
min-height: 0;
overflow: auto;
overflow-anchor: auto;
padding: 0.75rem 1rem;
scroll-behavior: auto;
}
.terminal-theme .scroll-anchor {
height: 1px;
width: 100%;
flex-shrink: 0;
}
.terminal-theme .empty-state {
color: #666;
margin: auto;
text-align: center;
.empty-text {
font-size: 0.75rem;
letter-spacing: 1px;
text-transform: uppercase;
}
}
.terminal-theme .message-row {
display: flex;
padding: 6px 0;
border-bottom: 1px solid #1a1a1a;
&:last-child {
border-bottom: none;
}
}
.terminal-theme .message-row.user {
justify-content: flex-end;
}
.terminal-theme .message-bubble {
background: transparent;
border-radius: 0;
max-width: 85%;
padding: 0.4rem 0;
white-space: pre-wrap;
word-break: break-word;
}
.terminal-theme .message-text {
color: #d9d9d9;
font-size: 0.86rem;
line-height: 1.6;
// Structured table styling for markdown tables
table {
border-collapse: collapse;
font-size: 0.82rem;
margin: 0.75rem 0;
width: 100%;
max-width: 100%;
}
th,
td {
border: 1px solid #333;
padding: 0.45rem 0.65rem;
text-align: left;
}
th {
background: #1a1a1a;
color: #ff6600;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
td {
color: #c8c8c8;
}
// Right-align numeric columns (2nd column onward, typically metric values)
td:not(:first-child) {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr:nth-child(even) td {
background: rgba(255, 255, 255, 0.02);
}
tr:hover td {
background: rgba(255, 102, 0, 0.06);
}
// Inline Mermaid charts
.mermaid {
margin: 0.75rem 0;
max-width: 100%;
}
}
.terminal-theme .message-row.assistant .message-text {
color: #c8c8c8;
}
.terminal-theme .stream-console .stream-bubble {
background: #0d0d0d;
border: 1px solid #333;
border-radius: 0;
max-width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.78rem;
}
.terminal-theme .stream-header {
color: #ff6600;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 0.35rem;
}
.terminal-theme .stream-lines {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.terminal-theme .stream-line {
color: #888;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
}
.terminal-theme .stream-line.stream-thinking {
color: #6b8cae;
}
.terminal-theme .stream-line.stream-tool-running {
color: #ffb84d;
}
.terminal-theme .stream-line.stream-tool-ok {
color: #33ff99;
}
.terminal-theme .stream-line.stream-tool-fail,
.terminal-theme .stream-line.stream-tool-blocked {
color: #ff6b6b;
}
.terminal-theme .stream-line.stream-done {
color: #33ff99;
font-weight: 600;
}
.terminal-theme .stream-line.stream-metrics {
color: #888;
font-size: 0.72rem;
}
.terminal-theme .stream-line.stream-fail {
color: #ff6b6b;
font-weight: 500;
}
.terminal-theme .streaming-answer .message-text {
white-space: pre-wrap;
word-break: break-word;
}
.terminal-theme .streaming-text {
white-space: pre-wrap;
}
.terminal-theme .typewriter-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: #ff6600;
margin-left: 1px;
animation: cursor-blink 1s step-end infinite;
vertical-align: text-bottom;
}
@keyframes cursor-blink {
50% {
opacity: 0;
}
}
.terminal-theme .message-row.user .message-text {
color: #ff9900;
}
.terminal-theme .meta-row {
color: #5b5b5b;
font-size: 0.65rem;
margin-top: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.terminal-theme .meta-row.warning {
color: #ff3333;
}
.terminal-theme .meta-row.metrics-row {
color: #6b8cae;
font-size: 0.68rem;
}
.terminal-theme .tool-trace {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
}
.terminal-theme .tool-trace-label {
color: #999;
margin-right: 0.2rem;
}
.terminal-theme .tool-pill {
border: 1px solid #333;
border-radius: 2px;
color: #aaa;
display: inline-block;
font-size: 0.62rem;
letter-spacing: 0.4px;
padding: 0.1rem 0.3rem;
text-transform: uppercase;
}
.terminal-theme .tool-pill.ok {
border-color: #1f5f3a;
color: #33ff99;
}
.terminal-theme .tool-pill.fail {
border-color: #733;
color: #ff6b6b;
}
@media (max-width: 768px) {
.terminal-theme .tool-trace {
gap: 0.2rem;
}
}
.terminal-theme .composer.terminal-composer {
align-items: center;
display: flex;
gap: 0;
background: #111;
border: 1px solid #333;
border-top: 1px solid #333;
padding: 0;
}
.terminal-theme .prompt-char {
color: #ff6600;
font-weight: 700;
font-size: 14px;
padding: 10px 0 10px 12px;
user-select: none;
flex-shrink: 0;
}
.terminal-theme .composer-input {
flex: 1;
::ng-deep {
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
.mat-mdc-text-field-wrapper {
background: transparent;
}
.mdc-notched-outline .mdc-notched-outline__leading,
.mdc-notched-outline .mdc-notched-outline__notch,
.mdc-notched-outline .mdc-notched-outline__trailing {
border-color: transparent;
}
.mat-mdc-form-field-focus-overlay {
background: transparent;
}
input {
color: #ff9900;
caret-color: #ff6600;
}
input::placeholder {
color: #444;
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.5px;
}
.mat-mdc-floating-label {
color: #666;
}
}
}
.terminal-theme .send-btn.terminal-send {
background: #ff6600;
border: none;
color: #000;
min-width: 44px;
padding: 10px 12px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
&:hover:not(:disabled) {
background: #ff8533;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.terminal-theme .send-btn.terminal-send.stop {
background: #c74d00;
}
.terminal-theme .send-glyph {
display: inline-block;
font-size: 1rem;
font-weight: 700;
}
.terminal-theme .queue-btn {
border: 1px solid #444;
background: #111;
color: #aaa;
cursor: pointer;
font-size: 0.62rem;
letter-spacing: 0.6px;
padding: 0.45rem 0.55rem;
text-transform: uppercase;
}
.terminal-theme .queue-btn.terminal-queue:hover {
border-color: #ff8533;
color: #ff8533;
}
.terminal-theme .queue-btn.terminal-interject:hover {
border-color: #33ff99;
color: #33ff99;
}
// --- Thinking indicator ---
.terminal-theme .thinking-indicator {
align-items: center;
display: flex;
gap: 0.5rem;
padding: 0.5rem 0;
}
.terminal-theme .thinking-dots {
display: inline-flex;
gap: 4px;
.dot {
animation: dot-bounce 1.4s ease-in-out infinite;
background: #ff6600;
border-radius: 50%;
display: inline-block;
height: 6px;
width: 6px;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes dot-bounce {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
.terminal-theme .thinking-label {
color: #6b8cae;
font-size: 0.75rem;
letter-spacing: 0.5px;
text-transform: uppercase;
}
// --- Collapsible tool call rows ---
.terminal-theme .tool-calls-panel {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0.4rem 0;
}
.terminal-theme .tool-call-row {
background: #111;
border-left: 2px solid #333;
border-radius: 0;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.75rem;
&.ok {
border-left-color: #33ff99;
}
&.fail {
border-left-color: #ff6b6b;
}
}
.terminal-theme .tool-call-header {
align-items: center;
cursor: pointer;
display: flex;
gap: 0.4rem;
padding: 0.35rem 0.6rem;
user-select: none;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.terminal-theme .tool-call-chevron {
color: #666;
flex-shrink: 0;
font-size: 0.7rem;
width: 0.8rem;
}
.terminal-theme .tool-call-name {
color: #ff6600;
font-weight: 600;
}
.terminal-theme .tool-call-status {
color: #888;
margin-left: auto;
&.ok {
color: #33ff99;
}
&.fail {
color: #ff6b6b;
}
&.running {
animation: dot-pulse 1.2s ease-in-out infinite;
color: #ffb84d;
}
}
.terminal-theme .tool-call-detail {
border-top: 1px solid #222;
padding: 0.3rem 0.6rem 0.4rem 1.6rem;
}
.terminal-theme .tool-detail-section {
margin-bottom: 0.3rem;
}
.terminal-theme .tool-detail-label {
color: #666;
display: block;
font-size: 0.65rem;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.terminal-theme .tool-detail-pre {
color: #999;
font-size: 0.68rem;
line-height: 1.4;
margin: 0.15rem 0 0;
max-height: 120px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
// --- Debug console toggle ---
.terminal-theme .debug-toggle-btn {
background: transparent;
border: 1px solid #333;
border-radius: 2px;
color: #666;
cursor: pointer;
flex-shrink: 0;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.7rem;
padding: 0.35rem 0.4rem;
&:hover {
border-color: #555;
color: #aaa;
}
&.active {
border-color: #ff6600;
color: #ff6600;
}
}
.terminal-theme .queue-meta {
color: #777;
font-size: 0.62rem;
letter-spacing: 0.4px;
margin: 0.25rem 0.5rem 0.4rem;
text-transform: uppercase;
}
// --- Strategy flow button in empty state ---
.terminal-theme .empty-state {
align-items: center;
display: flex;
flex-direction: column;
gap: 1rem;
}
.terminal-theme .strategy-flow-btn {
background: transparent;
border: 1px solid #ff6600;
color: #ff6600;
cursor: pointer;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.72rem;
letter-spacing: 0.8px;
padding: 0.55rem 1rem;
text-transform: uppercase;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: #ff6600;
color: #000;
}
}
// --- Strategy card within messages ---
.terminal-theme .message-row gf-strategy-card {
max-width: 85%;
width: 100%;
}

47
apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.html

@ -0,0 +1,47 @@
<div class="chart-panel-container">
<div class="metrics-bar">
<div class="metric">
<span class="metric-label">TOTAL VALUE</span>
<span class="metric-value">${{ currentValue | number: '1.2-2' }}</span>
</div>
<div class="metric">
<span class="metric-label">RETURN</span>
<span
class="metric-value"
[class.negative]="returnPct < 0"
[class.positive]="returnPct >= 0"
>
{{ returnPct >= 0 ? '+' : '' }}{{ returnPct * 100 | number: '1.2-2' }}%
</span>
</div>
<div class="metric">
<span class="metric-label">TODAY</span>
<span
class="metric-value"
[class.negative]="todayChange < 0"
[class.positive]="todayChange >= 0"
>
{{ todayChange >= 0 ? '+' : '' }}{{ todayChange | number: '1.2-2' }}%
</span>
</div>
</div>
<div class="chart-wrapper">
@if (isLoading) {
<div class="chart-loading">Loading...</div>
}
<canvas #chartCanvas></canvas>
</div>
<div class="range-selector">
@for (r of ranges; track r.value) {
<button
class="range-pill"
[class.active]="range === r.value"
(click)="setRange(r.value)"
>
{{ r.label }}
</button>
}
</div>
</div>

107
apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.scss

@ -0,0 +1,107 @@
.chart-panel-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
padding: 0.75rem;
}
.metrics-bar {
display: flex;
gap: 1rem;
justify-content: space-between;
}
.metric {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.metric-label {
color: #666;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.6rem;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.metric-value {
color: #d9d9d9;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
&.positive {
color: #33ff99;
}
&.negative {
color: #ff6b6b;
}
}
.chart-wrapper {
flex: 1;
min-height: 0;
position: relative;
canvas {
height: 100% !important;
width: 100% !important;
}
}
.chart-loading {
align-items: center;
color: #666;
display: flex;
font-family: monospace;
font-size: 0.7rem;
height: 100%;
justify-content: center;
left: 0;
letter-spacing: 1px;
position: absolute;
text-transform: uppercase;
top: 0;
width: 100%;
}
.range-selector {
display: flex;
gap: 4px;
justify-content: center;
}
.range-pill {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 2px;
color: #888;
cursor: pointer;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.62rem;
letter-spacing: 0.5px;
padding: 0.25rem 0.5rem;
text-transform: uppercase;
&:hover {
border-color: #555;
color: #ccc;
}
&.active {
background: #ff6600;
border-color: #ff6600;
color: #000;
font-weight: 600;
}
}

237
apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.ts

@ -0,0 +1,237 @@
import { DateRange } from '@ghostfolio/common/types';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule, DecimalPipe } from '@angular/common';
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import {
CategoryScale,
Chart,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
TimeScale,
Tooltip
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
Chart.register(
CategoryScale,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
TimeScale,
Tooltip
);
interface RangeOption {
label: string;
value: DateRange;
}
@Component({
imports: [CommonModule],
providers: [DecimalPipe],
selector: 'gf-agent-chart-panel',
styleUrls: ['./agent-chart-panel.component.scss'],
templateUrl: './agent-chart-panel.component.html'
})
export class GfAgentChartPanelComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() range: DateRange = 'max';
@Output() rangeChange = new EventEmitter<DateRange>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public currentValue = 0;
public isLoading = false;
public returnPct = 0;
public todayChange = 0;
public ranges: RangeOption[] = [
{ label: '1W', value: 'wtd' },
{ label: '1M', value: 'mtd' },
{ label: '3M', value: '3m' },
{ label: '6M', value: '6m' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: 'ALL', value: 'max' }
];
private chart: Chart | null = null;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dataService: DataService,
private decimalPipe: DecimalPipe
) {}
public ngAfterViewInit(): void {
this.loadData();
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes['range'] && !changes['range'].firstChange) {
this.loadData();
}
}
public setRange(range: DateRange): void {
this.range = range;
this.rangeChange.emit(range);
this.loadData();
}
public ngOnDestroy(): void {
this.chart?.destroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadData(): void {
this.isLoading = true;
this.dataService
.fetchPortfolioPerformance({ range: this.range, withItems: true })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: (response) => {
this.isLoading = false;
const perf = response.performance;
this.currentValue =
perf?.currentNetWorth ?? perf?.currentValueInBaseCurrency ?? 0;
this.returnPct = perf?.netPerformancePercentage ?? 0;
// Approximate today's change from last two chart points
const chartData = response.chart ?? [];
if (chartData.length >= 2) {
const last = chartData[chartData.length - 1]?.netWorth ?? 0;
const prev = chartData[chartData.length - 2]?.netWorth ?? 0;
this.todayChange = prev > 0 ? ((last - prev) / prev) * 100 : 0;
} else {
this.todayChange = 0;
}
this.renderChart(chartData);
},
error: () => {
this.isLoading = false;
}
});
}
private renderChart(
data: {
date: string;
netWorth?: number;
netPerformanceInPercentage?: number;
}[]
): void {
const canvas = this.chartCanvas?.nativeElement;
if (!canvas) return;
this.chart?.destroy();
const ctx = canvas.getContext('2d');
if (!ctx) return;
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, 'rgba(255, 102, 0, 0.15)');
gradient.addColorStop(1, 'rgba(255, 102, 0, 0)');
const labels = data.map((d) => d.date);
const values = data.map((d) => d.netWorth ?? 0);
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
data: values,
borderColor: '#ff6600',
borderWidth: 1.5,
backgroundColor: gradient,
fill: true,
pointRadius: 0,
pointHitRadius: 8,
tension: 0.3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#1a1a1a',
borderColor: '#333',
borderWidth: 1,
titleColor: '#999',
bodyColor: '#ff6600',
bodyFont: { family: 'monospace', size: 12 },
titleFont: { family: 'monospace', size: 10 },
padding: 8,
displayColors: false,
callbacks: {
label: (ctx) => {
const val = ctx.parsed.y;
return `$${this.decimalPipe.transform(val, '1.2-2') ?? val}`;
}
}
}
},
scales: {
x: {
type: 'time',
grid: { display: false },
ticks: {
color: '#555',
font: { family: 'monospace', size: 9 },
maxTicksLimit: 6
},
border: { display: false }
},
y: {
position: 'right',
grid: { color: 'rgba(255,255,255,0.03)' },
ticks: {
color: '#555',
font: { family: 'monospace', size: 9 },
callback: (value) => {
const num = Number(value);
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
return String(num);
}
},
border: { display: false }
}
}
}
});
}
}

105
apps/client/src/app/pages/agent/components/sparkline/sparkline.component.ts

@ -0,0 +1,105 @@
import {
AfterViewInit,
Component,
ElementRef,
Input,
OnChanges,
OnDestroy,
SimpleChanges,
ViewChild
} from '@angular/core';
import {
Chart,
LinearScale,
LineController,
LineElement,
PointElement
} from 'chart.js';
Chart.register(LinearScale, LineController, LineElement, PointElement);
@Component({
selector: 'gf-sparkline',
template: '<canvas #canvas class="sparkline-canvas"></canvas>',
styles: [
`
:host {
display: inline-block;
height: 24px;
vertical-align: middle;
width: 80px;
}
.sparkline-canvas {
height: 100% !important;
width: 100% !important;
}
`
]
})
export class GfSparklineComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() data: number[] = [];
@Input() color = '#ff6600';
@ViewChild('canvas') canvas: ElementRef<HTMLCanvasElement>;
private chart: Chart | null = null;
public ngAfterViewInit(): void {
this.renderChart();
}
public ngOnChanges(changes: SimpleChanges): void {
if (
(changes['data'] || changes['color']) &&
!changes['data']?.firstChange
) {
this.renderChart();
}
}
public ngOnDestroy(): void {
this.chart?.destroy();
}
private renderChart(): void {
const el = this.canvas?.nativeElement;
if (!el || !this.data.length) return;
this.chart?.destroy();
const ctx = el.getContext('2d');
if (!ctx) return;
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: this.data.map((_, i) => i),
datasets: [
{
data: this.data,
borderColor: this.color,
borderWidth: 1,
pointRadius: 0,
tension: 0.3,
fill: false
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false }
},
scales: {
x: { display: false },
y: { display: false }
}
}
});
}
}

45
apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.html

@ -0,0 +1,45 @@
<div class="strategy-card">
@if (step.options) {
<div class="strategy-question">{{ step.question }}</div>
<div class="strategy-options">
@for (opt of step.options; track opt.value) {
<button
class="strategy-option"
[class.disabled]="disabled"
[disabled]="disabled"
(click)="selectOption(opt.value)"
>
<span class="option-label">{{ opt.label }}</span>
@if (opt.description) {
<span class="option-desc">{{ opt.description }}</span>
}
</button>
}
</div>
}
@if (step.recommendation) {
<div class="recommendation-card">
<div class="rec-header">{{ step.recommendation.title }}</div>
<div class="rec-description">{{ step.recommendation.description }}</div>
<div class="allocation-bars">
@for (alloc of step.recommendation.allocations; track alloc.asset) {
<div class="alloc-row">
<span class="alloc-label">{{ alloc.asset }}</span>
<div class="alloc-bar-bg">
<div
class="alloc-bar"
[style.background]="alloc.color"
[style.width.%]="alloc.percent"
></div>
</div>
<span class="alloc-pct">{{ alloc.percent }}%</span>
</div>
}
</div>
<div class="rec-risk">
Risk Level: {{ step.recommendation.riskLevel }}
</div>
</div>
}
</div>

163
apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.scss

@ -0,0 +1,163 @@
.strategy-card {
padding: 0.25rem 0;
}
.strategy-question {
color: #d9d9d9;
font-size: 0.85rem;
line-height: 1.5;
margin-bottom: 0.6rem;
}
.strategy-options {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.strategy-option {
align-items: flex-start;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 3px;
color: #d9d9d9;
cursor: pointer;
display: flex;
flex-direction: column;
font-family: inherit;
gap: 0.15rem;
min-width: 140px;
padding: 0.5rem 0.75rem;
text-align: left;
transition:
border-color 0.15s,
background 0.15s;
&:hover:not(.disabled) {
background: #222;
border-color: #ff6600;
}
&:active:not(.disabled) {
background: #ff6600;
color: #000;
}
&.disabled {
cursor: default;
opacity: 0.5;
}
}
.option-label {
font-size: 0.8rem;
font-weight: 600;
}
.option-desc {
color: #888;
font-size: 0.68rem;
line-height: 1.3;
}
// --- Recommendation card ---
.recommendation-card {
background: #111;
border: 1px solid #333;
border-left: 3px solid #ff6600;
border-radius: 2px;
padding: 0.75rem 1rem;
}
.rec-header {
color: #ff6600;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.5px;
margin-bottom: 0.4rem;
text-transform: uppercase;
}
.rec-description {
color: #c8c8c8;
font-size: 0.78rem;
line-height: 1.5;
margin-bottom: 0.75rem;
}
.allocation-bars {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.alloc-row {
align-items: center;
display: flex;
gap: 0.5rem;
}
.alloc-label {
color: #aaa;
flex-shrink: 0;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.68rem;
width: 160px;
}
.alloc-bar-bg {
background: #222;
border-radius: 1px;
flex: 1;
height: 12px;
}
.alloc-bar {
border-radius: 1px;
height: 100%;
transition: width 0.3s ease;
}
.alloc-pct {
color: #d9d9d9;
flex-shrink: 0;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
text-align: right;
width: 32px;
}
.rec-risk {
color: #666;
font-family:
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New',
monospace;
font-size: 0.65rem;
letter-spacing: 0.5px;
text-transform: uppercase;
}
@media (max-width: 768px) {
.strategy-options {
flex-direction: column;
}
.strategy-option {
min-width: auto;
}
.alloc-label {
font-size: 0.6rem;
width: 100px;
}
}

22
apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.ts

@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { StrategyStep } from '../../models/strategy-flow.types';
@Component({
imports: [CommonModule],
selector: 'gf-strategy-card',
styleUrls: ['./strategy-card.component.scss'],
templateUrl: './strategy-card.component.html'
})
export class GfStrategyCardComponent {
@Input() step: StrategyStep;
@Input() disabled = false;
@Output() optionSelected = new EventEmitter<string>();
public selectOption(value: string): void {
if (!this.disabled) {
this.optionSelected.emit(value);
}
}
}

261
apps/client/src/app/pages/agent/models/strategy-flow.data.ts

@ -0,0 +1,261 @@
import {
StrategyRecommendation,
StrategyStep,
StrategyStepId
} from './strategy-flow.types';
const STEP_ORDER: StrategyStepId[] = [
'goals',
'investmentAmount',
'timeHorizon',
'riskAppetite',
'experience'
];
const STEPS: Record<StrategyStepId, StrategyStep> = {
goals: {
stepId: 'goals',
question: "What's your primary investment goal?",
options: [
{
label: 'Retire Early',
value: 'retire_early',
description: 'Build enough wealth to stop working before 60'
},
{
label: 'Grow Wealth',
value: 'grow_wealth',
description: 'Maximize long-term portfolio growth'
},
{
label: 'Generate Income',
value: 'generate_income',
description: 'Create a steady stream of passive income'
},
{
label: 'Preserve Capital',
value: 'preserve_capital',
description: 'Protect what you have from inflation and loss'
}
]
},
investmentAmount: {
stepId: 'investmentAmount',
question: 'How much do you have to invest?',
options: [
{
label: 'Under $1K',
value: 'under_1k',
description: 'Just getting started with a small amount'
},
{
label: '$1K - $10K',
value: '1k_10k',
description: 'Building a meaningful starter portfolio'
},
{
label: '$10K - $50K',
value: '10k_50k',
description: 'Enough for a diversified portfolio'
},
{
label: '$50K - $100K',
value: '50k_100k',
description: 'Substantial capital for broad allocation'
},
{
label: '$100K+',
value: '100k_plus',
description: 'Significant assets requiring careful management'
}
]
},
timeHorizon: {
stepId: 'timeHorizon',
question: "What's your investment time horizon?",
options: [
{
label: 'Less than 1 year',
value: 'lt_1y',
description: 'Short-term — need access to funds soon'
},
{
label: '1 - 5 years',
value: '1_5y',
description: 'Medium-term — saving for a goal in a few years'
},
{
label: '5 - 15 years',
value: '5_15y',
description: 'Long-term — growing wealth over time'
},
{
label: '15+ years',
value: '15y_plus',
description: 'Very long-term — retirement or generational wealth'
}
]
},
riskAppetite: {
stepId: 'riskAppetite',
question: 'How would you feel if your portfolio dropped 20% in a month?',
options: [
{
label: 'Panic sell',
value: 'panic_sell',
description: "I'd sell immediately to stop the bleeding"
},
{
label: 'Worried but hold',
value: 'worried_hold',
description: "I'd be stressed but wouldn't sell"
},
{
label: 'Stay the course',
value: 'stay_course',
description: "Downturns are normal, I'd do nothing"
},
{
label: 'Buy the dip',
value: 'buy_dip',
description: "I'd see it as a buying opportunity"
}
]
},
experience: {
stepId: 'experience',
question: 'How experienced are you with investing?',
options: [
{
label: 'Beginner',
value: 'beginner',
description: 'New to investing, learning the basics'
},
{
label: 'Some experience',
value: 'some_experience',
description: "I've made a few trades or own some funds"
},
{
label: 'Intermediate',
value: 'intermediate',
description: 'Comfortable with stocks, ETFs, and diversification'
},
{
label: 'Expert',
value: 'expert',
description:
'Deep knowledge of markets, options, and advanced strategies'
}
]
},
recommendation: {
stepId: 'recommendation',
question: ''
}
};
export function getStep(stepId: StrategyStepId): StrategyStep {
return STEPS[stepId];
}
export function getFirstStep(): StrategyStep {
return STEPS[STEP_ORDER[0]];
}
export function getNextStepId(
currentStepId: StrategyStepId
): StrategyStepId | null {
const idx = STEP_ORDER.indexOf(currentStepId);
if (idx < 0 || idx >= STEP_ORDER.length - 1) {
return 'recommendation';
}
return STEP_ORDER[idx + 1];
}
export function computeRecommendation(
answers: Record<string, string>
): StrategyRecommendation {
let riskScore = 0;
// Goals scoring
const goalScores: Record<string, number> = {
retire_early: 2,
grow_wealth: 3,
generate_income: 1,
preserve_capital: 0
};
riskScore += goalScores[answers['goals']] ?? 1;
// Time horizon scoring
const horizonScores: Record<string, number> = {
lt_1y: 0,
'1_5y': 1,
'5_15y': 2,
'15y_plus': 3
};
riskScore += horizonScores[answers['timeHorizon']] ?? 1;
// Risk appetite scoring
const riskScores: Record<string, number> = {
panic_sell: 0,
worried_hold: 1,
stay_course: 2,
buy_dip: 3
};
riskScore += riskScores[answers['riskAppetite']] ?? 1;
// Experience scoring
const expScores: Record<string, number> = {
beginner: 0,
some_experience: 1,
intermediate: 2,
expert: 3
};
riskScore += expScores[answers['experience']] ?? 1;
// Determine profile (max score = 12)
if (riskScore <= 4) {
return {
title: 'Conservative: Dividend Income Portfolio',
description:
'Based on your preference for capital preservation and shorter time horizon, a conservative allocation focused on income-generating assets is recommended.',
allocations: [
{ asset: 'Bonds (BND/AGG)', percent: 40, color: '#6b8cae' },
{ asset: 'Dividend Stocks (VYM/SCHD)', percent: 25, color: '#33ff99' },
{ asset: 'REITs (VNQ)', percent: 15, color: '#ff6600' },
{ asset: 'Money Market/Cash', percent: 10, color: '#888' },
{ asset: 'International Bonds (BNDX)', percent: 10, color: '#ffb84d' }
],
riskLevel: 'Conservative'
};
} else if (riskScore <= 8) {
return {
title: 'Balanced: Core Satellite Strategy',
description:
'Your moderate risk tolerance and medium-to-long time horizon suit a balanced approach — broad index funds at the core with selective growth positions.',
allocations: [
{ asset: 'US Total Market (VTI)', percent: 35, color: '#33ff99' },
{ asset: 'International (VXUS)', percent: 20, color: '#6b8cae' },
{ asset: 'Bonds (BND)', percent: 20, color: '#ffb84d' },
{ asset: 'Growth ETFs (QQQ/VUG)', percent: 15, color: '#ff6600' },
{ asset: 'REITs/Alternatives', percent: 10, color: '#888' }
],
riskLevel: 'Moderate'
};
} else {
return {
title: 'Aggressive: Momentum Growth Portfolio',
description:
'Your high risk tolerance, long time horizon, and experience support an aggressive growth strategy focused on high-beta assets and sector concentration.',
allocations: [
{ asset: 'Growth ETFs (QQQ/VUG)', percent: 30, color: '#ff6600' },
{ asset: 'US Total Market (VTI)', percent: 25, color: '#33ff99' },
{ asset: 'Sector ETFs (XLK/XLF)', percent: 20, color: '#ffb84d' },
{ asset: 'International Growth', percent: 15, color: '#6b8cae' },
{ asset: 'Small Cap (VB/IWM)', percent: 10, color: '#888' }
],
riskLevel: 'Aggressive'
};
}
}

33
apps/client/src/app/pages/agent/models/strategy-flow.types.ts

@ -0,0 +1,33 @@
export type StrategyStepId =
| 'goals'
| 'investmentAmount'
| 'timeHorizon'
| 'riskAppetite'
| 'experience'
| 'recommendation';
export interface StrategyOption {
label: string;
value: string;
description?: string;
}
export interface StrategyAllocation {
asset: string;
percent: number;
color: string;
}
export interface StrategyRecommendation {
title: string;
description: string;
allocations: StrategyAllocation[];
riskLevel: string;
}
export interface StrategyStep {
stepId: StrategyStepId;
question: string;
options?: StrategyOption[];
recommendation?: StrategyRecommendation;
}

5
libs/common/src/lib/routes/routes.ts

@ -68,6 +68,11 @@ export const internalRoutes: Record<string, InternalRoute> = {
routerLink: ['/accounts'],
title: $localize`Accounts`
},
agent: {
path: 'agent',
routerLink: ['/agent'],
title: $localize`Agent`
},
api: {
excludeFromAssistant: true,
path: 'api',

Loading…
Cancel
Save