From a9c7afc654c0976c7eb6ba15d264c8b346452737 Mon Sep 17 00:00:00 2001 From: RajatA98 Date: Sun, 1 Mar 2026 21:09:04 -0600 Subject: [PATCH] feat: add optional AI agent integration for portfolio analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 3 + apps/api/src/app/app.module.ts | 2 + .../endpoints/agent/agent.controller.spec.ts | 77 ++ .../app/endpoints/agent/agent.controller.ts | 90 ++ .../src/app/endpoints/agent/agent.gateway.ts | 150 +++ .../src/app/endpoints/agent/agent.module.ts | 20 + .../app/endpoints/agent/agent.service.spec.ts | 89 ++ .../src/app/endpoints/agent/agent.service.ts | 235 ++++ apps/api/src/main.ts | 6 +- .../configuration/configuration.service.ts | 1 + .../interfaces/environment.interface.ts | 1 + apps/client/src/app/app.routes.ts | 5 + .../app/pages/agent/agent-page.component.ts | 1194 +++++++++++++++++ .../src/app/pages/agent/agent-page.html | 279 ++++ .../src/app/pages/agent/agent-page.routes.ts | 15 + .../src/app/pages/agent/agent-page.scss | 708 ++++++++++ .../agent-chart-panel.component.html | 47 + .../agent-chart-panel.component.scss | 107 ++ .../agent-chart-panel.component.ts | 237 ++++ .../sparkline/sparkline.component.ts | 105 ++ .../strategy-card.component.html | 45 + .../strategy-card.component.scss | 163 +++ .../strategy-card/strategy-card.component.ts | 22 + .../pages/agent/models/strategy-flow.data.ts | 261 ++++ .../pages/agent/models/strategy-flow.types.ts | 33 + libs/common/src/lib/routes/routes.ts | 5 + 26 files changed, 3899 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/app/endpoints/agent/agent.controller.spec.ts create mode 100644 apps/api/src/app/endpoints/agent/agent.controller.ts create mode 100644 apps/api/src/app/endpoints/agent/agent.gateway.ts create mode 100644 apps/api/src/app/endpoints/agent/agent.module.ts create mode 100644 apps/api/src/app/endpoints/agent/agent.service.spec.ts create mode 100644 apps/api/src/app/endpoints/agent/agent.service.ts create mode 100644 apps/client/src/app/pages/agent/agent-page.component.ts create mode 100644 apps/client/src/app/pages/agent/agent-page.html create mode 100644 apps/client/src/app/pages/agent/agent-page.routes.ts create mode 100644 apps/client/src/app/pages/agent/agent-page.scss create mode 100644 apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.html create mode 100644 apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.scss create mode 100644 apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.ts create mode 100644 apps/client/src/app/pages/agent/components/sparkline/sparkline.component.ts create mode 100644 apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.html create mode 100644 apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.scss create mode 100644 apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.ts create mode 100644 apps/client/src/app/pages/agent/models/strategy-flow.data.ts create mode 100644 apps/client/src/app/pages/agent/models/strategy-flow.types.ts diff --git a/.env.example b/.env.example index e4a935626..8a01d1145 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ POSTGRES_PASSWORD= ACCESS_TOKEN_SALT= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= + +# AGENT (optional — set to enable the AI portfolio agent) +# AGENT_SERVICE_URL=http://localhost:3334 diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 89f52e1ea..fe63ad819 100644 --- a/apps/api/src/app/app.module.ts +++ b/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, diff --git a/apps/api/src/app/endpoints/agent/agent.controller.spec.ts b/apps/api/src/app/endpoints/agent/agent.controller.spec.ts new file mode 100644 index 000000000..5127df19d --- /dev/null +++ b/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' }); + }); +}); diff --git a/apps/api/src/app/endpoints/agent/agent.controller.ts b/apps/api/src/app/endpoints/agent/agent.controller.ts new file mode 100644 index 000000000..9849b42b8 --- /dev/null +++ b/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; + 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 { + 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 { + 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 { + 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 + }); + } +} diff --git a/apps/api/src/app/endpoints/agent/agent.gateway.ts b/apps/api/src/app/endpoints/agent/agent.gateway.ts new file mode 100644 index 000000000..4fd1c2fbc --- /dev/null +++ b/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(); + }); + } + ); +} diff --git a/apps/api/src/app/endpoints/agent/agent.module.ts b/apps/api/src/app/endpoints/agent/agent.module.ts new file mode 100644 index 000000000..d691e07d6 --- /dev/null +++ b/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 {} diff --git a/apps/api/src/app/endpoints/agent/agent.service.spec.ts b/apps/api/src/app/endpoints/agent/agent.service.spec.ts new file mode 100644 index 000000000..6530a9534 --- /dev/null +++ b/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; + + 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) + }); + }); +}); diff --git a/apps/api/src/app/endpoints/agent/agent.service.ts b/apps/api/src/app/endpoints/agent/agent.service.ts new file mode 100644 index 000000000..529baa72d --- /dev/null +++ b/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 = { + '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 { + 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 = { + '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> { + 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 = { + '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; + yield parsed; + } catch { + // Skip malformed events + } + } + + separatorIndex = buffer.indexOf('\n\n'); + } + } + } finally { + reader.releaseLock(); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a8de3dc5e..be2bc709b 100644 --- a/apps/api/src/main.ts +++ b/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; diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 5f9d1055d..717b02535 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/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: '' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 57c58898e..7a23b4746 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/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; diff --git a/apps/client/src/app/app.routes.ts b/apps/client/src/app/app.routes.ts index 9588cee68..04a871679 100644 --- a/apps/client/src/app/app.routes.ts +++ b/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: () => diff --git a/apps/client/src/app/pages/agent/agent-page.component.ts b/apps/client/src/app/pages/agent/agent-page.component.ts new file mode 100644 index 000000000..7ec28a96f --- /dev/null +++ b/apps/client/src/app/pages/agent/agent-page.component.ts @@ -0,0 +1,1194 @@ +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; +import { User } from '@ghostfolio/common/interfaces'; +import { NotificationService } from '@ghostfolio/ui/notifications'; +import { + AgentChatResponse, + AgentChatResponseData, + AgentToolTraceRow, + DataService +} from '@ghostfolio/ui/services'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MarkdownModule } from 'ngx-markdown'; +import { Subject, EMPTY, Subscription } from 'rxjs'; +import { catchError, finalize, takeUntil } from 'rxjs/operators'; + +import { GfStrategyCardComponent } from './components/strategy-card/strategy-card.component'; +import { + computeRecommendation, + getFirstStep, + getNextStepId, + getStep +} from './models/strategy-flow.data'; +import { StrategyStep } from './models/strategy-flow.types'; + +type AgentStreamEvent = + | { type: 'iteration_start'; iteration: number } + | { type: 'thinking'; iteration: number } + | { type: 'tool_start'; tool: string; iteration: number } + | { + type: 'tool_end'; + tool: string; + ok: boolean; + ms: number; + iteration: number; + detail?: string; + } + | { type: 'text_delta'; text: string } + | { + type: 'tool_detail'; + tool: string; + input: Record; + result?: unknown; + iteration: number; + } + | { + type: 'done'; + answer: string; + confidence: number; + warnings: string[]; + toolTrace: AgentToolTraceRow[]; + data?: AgentChatResponseData; + } + | { type: 'error'; message: string }; + +interface StreamLogLine { + text: string; + cssClass: string; +} + +interface ToolCallDisplay { + tool: string; + input: Record; + result?: unknown; + ok: boolean; + ms: number; + expanded: boolean; +} + +interface LoopMeta { + iterations: number; + totalMs: number; + tokenUsage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + terminationReason?: string; +} + +interface ChatMessage { + confidence?: number; + content: string; + data?: AgentChatResponseData; + loopMeta?: LoopMeta; + role: 'assistant' | 'user'; + strategyStep?: StrategyStep; + toolTrace?: AgentToolTraceRow[]; + warnings?: string[]; +} + +interface ToolStatusRow { + ok: boolean; + tool: string; +} + +interface PersistedChatMessage { + confidence?: number; + content: string; + data?: AgentChatResponseData; + loopMeta?: LoopMeta; + role: 'assistant' | 'user'; + toolTrace?: AgentToolTraceRow[]; + warnings?: string[]; +} + +interface NormalizedChatMessage { + confidence?: number; + content: string; + data?: AgentChatResponseData; + loopMeta?: LoopMeta; + role: 'assistant' | 'user'; + toolTrace?: AgentToolTraceRow[]; + warnings?: string[]; +} + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +const DEFAULT_HISTORY_KEY = 'agentChatHistory'; +const MAX_HISTORY_MESSAGES = 50; +const WS_RECONNECT_BASE_MS = 1000; +const WS_RECONNECT_MAX_MS = 30000; +const WS_MAX_RECONNECT_ATTEMPTS = 10; + +/** Tools that create/update orders — trigger portfolio refresh when they succeed. */ +const PORTFOLIO_MODIFYING_TOOLS = ['logPaperTrade', 'logFundMovement']; + +@Component({ + host: { class: 'page' }, + imports: [ + CommonModule, + FormsModule, + GfStrategyCardComponent, + MarkdownModule, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatInputModule + ], + selector: 'gf-agent-page', + styleUrls: ['./agent-page.scss'], + templateUrl: './agent-page.html' +}) +export class GfAgentPageComponent implements OnDestroy, OnInit { + @ViewChild('messagesContainer') messagesContainer: ElementRef; + @ViewChild('scrollAnchor') scrollAnchor: ElementRef; + + public activeToolCalls: ToolCallDisplay[] = []; + public connectionStatus: ConnectionStatus = 'disconnected'; + public currentStrategyStep: StrategyStep | null = null; + public mermaidOptions = { theme: 'dark' as const }; + public draftMessage = ''; + public isSending = false; + public messages: ChatMessage[] = []; + public queuedMessages: string[] = []; + public showDebugConsole = false; + public strategyAnswers: Record = {}; + public strategyMode = false; + public streamLogLines: StreamLogLine[] = []; + public streamingAnswer = ''; + public streamingAnswerVisibleLength = 0; + public thinkingLabel = ''; + public user: User; + + public receivedTextDelta = false; + + private activeRequestSubscription: Subscription | null = null; + private activeRequestId = 0; + private abortController: AbortController | null = null; + private intentionalClose = false; + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + private requestCounter = 0; + private unsubscribeSubject = new Subject(); + private ws: WebSocket | null = null; + private wsConnected = false; + private wsMessageHandler: ((event: AgentStreamEvent) => void) | null = null; + + public constructor( + private cdr: ChangeDetectorRef, + private dataService: DataService, + private notificationService: NotificationService, + private tokenStorageService: TokenStorageService, + private userService: UserService + ) {} + + public ngOnInit() { + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + this.messages = this.loadMessages(); + setTimeout(() => this.scrollMessagesToBottom(), 0); + + const token = this.tokenStorageService.getToken(); + if (token && !this.ws) { + this.connectWebSocket(token); + } + } + }); + + this.userService.get().pipe(takeUntil(this.unsubscribeSubject)).subscribe(); + } + + public sendMessage() { + const message = this.draftMessage.trim(); + + if (!message) { + return; + } + + if (this.isSending) { + this.enqueueMessage(message); + this.draftMessage = ''; + return; + } + + this.draftMessage = ''; + this.dispatchMessage(message); + } + + public interjectMessage() { + const message = this.draftMessage.trim(); + if (!message) { + return; + } + + this.draftMessage = ''; + this.enqueueMessage(message, true); + + if (this.isSending) { + this.activeRequestSubscription?.unsubscribe(); + this.activeRequestSubscription = null; + this.activeRequestId = 0; + this.isSending = false; + } + + this.flushQueue(); + } + + public cancelMessage(): void { + if (!this.isSending) { + return; + } + + if (this.wsConnected && this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'cancel' })); + this.wsMessageHandler = null; + } else { + this.abortController?.abort(); + } + this.abortController = null; + this.activeRequestSubscription?.unsubscribe(); + this.activeRequestSubscription = null; + this.activeRequestId = 0; + this.isSending = false; + this.flushQueue(); + } + + public get hasDraftMessage(): boolean { + return this.draftMessage.trim().length > 0; + } + + public get queueCount(): number { + return this.queuedMessages.length; + } + + public getToolStatuses(entry: ChatMessage): ToolStatusRow[] { + return (entry.toolTrace ?? []).map((item) => ({ + ok: item.ok, + tool: item.tool + })); + } + + public toggleDebugConsole(): void { + this.showDebugConsole = !this.showDebugConsole; + } + + public toggleToolExpanded(index: number): void { + this.activeToolCalls = this.activeToolCalls.map((tc, i) => + i === index ? { ...tc, expanded: !tc.expanded } : tc + ); + } + + public startStrategyFlow(): void { + this.strategyMode = true; + this.strategyAnswers = {}; + const first = getFirstStep(); + this.currentStrategyStep = first; + this.appendMessage({ + content: '', + role: 'assistant', + strategyStep: first + }); + } + + public handleStrategySelection(value: string): void { + if (!this.currentStrategyStep) return; + + const stepId = this.currentStrategyStep.stepId; + this.strategyAnswers[stepId] = value; + + // Find selected label for user message + const selectedOption = this.currentStrategyStep.options?.find( + (o) => o.value === value + ); + this.appendMessage({ + content: selectedOption?.label ?? value, + role: 'user' + }); + + const nextStepId = getNextStepId(stepId); + if (!nextStepId) { + this.finishStrategyFlow(); + return; + } + + if (nextStepId === 'recommendation') { + this.finishStrategyFlow(); + return; + } + + const nextStep = getStep(nextStepId); + this.currentStrategyStep = nextStep; + this.appendMessage({ + content: '', + role: 'assistant', + strategyStep: nextStep + }); + } + + private finishStrategyFlow(): void { + const rec = computeRecommendation(this.strategyAnswers); + const recStep: StrategyStep = { + stepId: 'recommendation', + question: '', + recommendation: rec + }; + this.currentStrategyStep = null; + this.strategyMode = false; + + this.appendMessage({ + content: '', + role: 'assistant', + strategyStep: recStep + }); + + // Send answers to agent for a personalized AI recommendation + const summary = Object.entries(this.strategyAnswers) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + this.dispatchMessage( + `[STRATEGY_FLOW] The user completed the investment strategy questionnaire with these answers: ${summary}. The system computed a "${rec.riskLevel}" profile with recommendation "${rec.title}". Please provide a personalized analysis based on their current portfolio and these preferences.` + ); + } + + public trackByIndex(index: number) { + return index; + } + + /** Warnings to hide from user (tone, or hallucination when value came from user input). */ + public getDisplayWarnings(entry: ChatMessage): string[] { + const raw = entry.warnings ?? []; + const hidden = + /unprofessional tone|ROLEPLAY|EXCESSIVE_EMOJI|slang|sarcasm|profanity|hallucination|could not be traced/i; + return raw.filter((w) => !hidden.test(w)); + } + + public getMetricsText(loopMeta: LoopMeta): string { + const iters = loopMeta.iterations; + const sec = (loopMeta.totalMs / 1000).toFixed(1); + let out = `${iters} iters · ${sec}s`; + const u = loopMeta.tokenUsage; + if (u) { + const tokens = + u.totalTokens ?? (u.inputTokens ?? 0) + (u.outputTokens ?? 0); + const cost = + ((u.inputTokens ?? 0) * 3 + (u.outputTokens ?? 0) * 15) / 1e6; + out += ` · ${tokens.toLocaleString()} tokens · $${cost.toFixed(4)} cost`; + } + return out; + } + + public ngOnDestroy() { + this.closeWebSocket(); + this.activeRequestSubscription?.unsubscribe(); + this.activeRequestSubscription = null; + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private dispatchMessage(message: string) { + const conversationHistory = this.messages.map((entry) => ({ + content: entry.content, + role: entry.role + })); + + this.appendMessage({ + content: message, + role: 'user' + }); + + this.streamingAnswer = ''; + this.streamingAnswerVisibleLength = 0; + this.activeToolCalls = []; + this.thinkingLabel = 'Thinking...'; + this.receivedTextDelta = false; + this.isSending = true; + + // Scroll so user's command is at top (Cursor-style) + setTimeout(() => this.scrollCommandToTop(), 0); + const requestId = ++this.requestCounter; + this.activeRequestId = requestId; + + const token = this.tokenStorageService.getToken(); + if (token && this.wsConnected && this.ws?.readyState === WebSocket.OPEN) { + this.dispatchMessageOverWebSocket(requestId, { + conversationHistory, + message + }); + return; + } + if (token) { + this.dispatchMessageStream( + requestId, + { conversationHistory, message }, + token + ); + return; + } + + this.activeRequestSubscription = this.dataService + .postAgentChat({ + conversationHistory, + message + }) + .pipe( + catchError((error) => { + const fallback = $localize`The agent request failed. Please try again.`; + const title = error?.error?.error || error?.message || fallback; + this.notificationService.alert({ title }); + return EMPTY; + }), + finalize(() => { + if (this.activeRequestId === requestId) { + this.isSending = false; + this.activeRequestSubscription = null; + this.activeRequestId = 0; + this.flushQueue(); + } + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe((response: AgentChatResponse) => { + this.appendMessage({ + confidence: response.confidence, + content: response.answer, + data: response.data, + loopMeta: response.loopMeta as LoopMeta | undefined, + role: 'assistant', + toolTrace: response.toolTrace, + warnings: response.warnings + }); + }); + } + + private connectWebSocket(token: string): void { + this.connectionStatus = 'connecting'; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${protocol}//${window.location.host}/api/v1/agent/ws?token=${encodeURIComponent(token)}`; + const socket = new WebSocket(url); + + socket.onopen = () => { + this.wsConnected = true; + this.connectionStatus = 'connected'; + this.reconnectAttempts = 0; + }; + + socket.onmessage = (ev: MessageEvent) => { + const handler = this.wsMessageHandler; + if (!handler) return; + try { + const event = JSON.parse(ev.data as string) as AgentStreamEvent; + handler(event); + } catch { + this.appendStreamLog('STREAM PARSE ERROR', 'stream-fail'); + } + }; + + socket.onclose = (ev: CloseEvent) => { + this.wsConnected = false; + this.ws = null; + + if (this.isSending) { + this.appendStreamLog('Connection closed', 'stream-fail'); + } + + // 4401/4403 = auth failure from gateway — don't reconnect + if (ev.code === 4401 || ev.code === 4403) { + this.connectionStatus = 'error'; + this.tokenStorageService.signOut(); + return; + } + + if (!this.intentionalClose) { + this.scheduleReconnect(); + } else { + this.connectionStatus = 'disconnected'; + } + }; + + socket.onerror = () => { + this.wsConnected = false; + this.ws = null; + }; + + this.ws = socket; + } + + private scheduleReconnect(): void { + if (this.reconnectAttempts >= WS_MAX_RECONNECT_ATTEMPTS) { + this.connectionStatus = 'error'; + return; + } + + this.connectionStatus = 'connecting'; + const delay = Math.min( + WS_RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts), + WS_RECONNECT_MAX_MS + ); + // Add jitter (0-25% of delay) + const jitter = Math.random() * delay * 0.25; + this.reconnectAttempts++; + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + const token = this.tokenStorageService.getToken(); + if (token) { + this.connectWebSocket(token); + } else { + this.connectionStatus = 'error'; + } + }, delay + jitter); + } + + private closeWebSocket(): void { + this.intentionalClose = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + this.wsConnected = false; + } + this.connectionStatus = 'disconnected'; + } + + private didPortfolioChange(toolTrace: AgentToolTraceRow[]): boolean { + return (toolTrace ?? []).some( + (t) => PORTFOLIO_MODIFYING_TOOLS.includes(t.tool) && t.ok + ); + } + + private formatIteration(iteration: number): string { + return String(iteration).padStart(2, '0'); + } + + private appendStreamLog(text: string, cssClass: string): void { + this.streamLogLines = [...this.streamLogLines, { text, cssClass }]; + this.scrollMessagesToBottom(); + } + + private dispatchMessageOverWebSocket( + requestId: number, + body: { + conversationHistory: { role: string; content: string }[]; + message: string; + } + ): void { + this.streamLogLines = []; + this.appendStreamLog('STREAMING EVENTS...', 'stream-thinking'); + + let finalAnswer: string | null = null; + let finalConfidence = 0; + let finalWarnings: string[] = []; + let finalToolTrace: AgentToolTraceRow[] = []; + let finalData: AgentChatResponseData | undefined; + let finalLoopMeta: LoopMeta | undefined; + let streamError: string | null = null; + + const handleStreamEvent = (event: AgentStreamEvent): void => { + switch (event.type) { + case 'iteration_start': + this.thinkingLabel = 'Thinking...'; + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | THINKING...`, + 'stream-thinking' + ); + this.cdr.detectChanges(); + break; + case 'thinking': + this.thinkingLabel = 'Thinking...'; + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | LLM STEP READY`, + 'stream-thinking' + ); + this.cdr.detectChanges(); + break; + case 'tool_start': + this.thinkingLabel = `Running ${event.tool}...`; + this.activeToolCalls = [ + ...this.activeToolCalls, + { tool: event.tool, input: {}, ok: true, ms: 0, expanded: false } + ]; + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [RUNNING]`, + 'stream-tool-running' + ); + this.cdr.detectChanges(); + break; + case 'tool_end': { + // Update the matching tool call with ok/ms + this.activeToolCalls = this.activeToolCalls.map((tc) => + tc.tool === event.tool && tc.ms === 0 + ? { ...tc, ok: event.ok, ms: event.ms } + : tc + ); + const blocked = event.detail?.toUpperCase().includes('BLOCKED'); + if (blocked) { + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [BLOCKED] ${event.detail ?? ''}`.trim(), + 'stream-tool-blocked' + ); + } else if (event.ok) { + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [OK ${event.ms}ms]${event.detail ? ` ${event.detail}` : ''}`, + 'stream-tool-ok' + ); + } else { + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [FAIL ${event.ms}ms]${event.detail ? ` ${event.detail}` : ''}`, + 'stream-tool-fail' + ); + } + this.cdr.detectChanges(); + break; + } + case 'text_delta': + this.receivedTextDelta = true; + this.thinkingLabel = ''; + this.streamingAnswer += event.text; + this.streamingAnswerVisibleLength = this.streamingAnswer.length; + this.cdr.detectChanges(); + this.scrollMessagesToBottom(); + break; + case 'tool_detail': + // Enrich the matching tool call with input/result + this.activeToolCalls = this.activeToolCalls.map((tc) => + tc.tool === event.tool && !tc.input?.['__enriched'] + ? { ...tc, input: event.input, result: event.result } + : tc + ); + this.cdr.detectChanges(); + break; + case 'done': { + finalAnswer = this.receivedTextDelta + ? this.streamingAnswer + : event.answer; + finalConfidence = event.confidence; + finalWarnings = event.warnings ?? []; + finalToolTrace = event.toolTrace ?? []; + finalData = event.data; + finalLoopMeta = (event as { loopMeta?: LoopMeta })?.loopMeta; + const iters = finalLoopMeta?.iterations ?? '-'; + const totalMs = finalLoopMeta?.totalMs ?? 0; + this.appendStreamLog( + `DONE — ${iters} iters · ${(totalMs / 1000).toFixed(1)}s`, + 'stream-done' + ); + const meta = finalLoopMeta; + const trace = event.toolTrace ?? []; + if (meta) { + const COST_PER_INPUT = 3.0 / 1_000_000; + const COST_PER_OUTPUT = 15.0 / 1_000_000; + const cost = + (meta.tokenUsage?.inputTokens ?? 0) * COST_PER_INPUT + + (meta.tokenUsage?.outputTokens ?? 0) * COST_PER_OUTPUT; + const tokens = + meta.tokenUsage?.totalTokens ?? + (meta.tokenUsage?.inputTokens ?? 0) + + (meta.tokenUsage?.outputTokens ?? 0); + const toolsList = trace.length + ? trace.map((t) => t.tool).join(', ') + : '—'; + const success = meta.terminationReason === 'end_turn'; + this.appendStreamLog( + `METRICS: cost $${cost.toFixed(4)} · tokens ${tokens} · tools: ${toolsList} · success: ${success}`, + 'stream-metrics' + ); + } + this.thinkingLabel = ''; + this.wsMessageHandler = null; + this.finishStreamRequest(requestId); + if (streamError) { + this.notificationService.alert({ title: streamError }); + } else if (this.receivedTextDelta) { + // Text was already streamed — finalize directly + this.streamLogLines = []; + this.streamingAnswer = ''; + this.streamingAnswerVisibleLength = 0; + this.activeToolCalls = []; + this.appendMessage({ + confidence: finalConfidence, + content: finalAnswer!, + data: finalData, + loopMeta: finalLoopMeta, + role: 'assistant', + toolTrace: finalToolTrace, + warnings: finalWarnings + }); + if (this.didPortfolioChange(finalToolTrace)) { + this.userService + .get(true) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + } + this.cdr.detectChanges(); + } else { + // Fallback: typewriter effect for non-streaming responses + this.streamingAnswer = finalAnswer!; + this.streamingAnswerVisibleLength = 0; + this.cdr.detectChanges(); + this.runTypewriterEffect(finalAnswer!, () => { + this.streamLogLines = []; + this.streamingAnswer = ''; + this.streamingAnswerVisibleLength = 0; + this.activeToolCalls = []; + this.appendMessage({ + confidence: finalConfidence, + content: finalAnswer!, + data: finalData, + loopMeta: finalLoopMeta, + role: 'assistant', + toolTrace: finalToolTrace, + warnings: finalWarnings + }); + if (this.didPortfolioChange(finalToolTrace)) { + this.userService + .get(true) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + } + this.cdr.detectChanges(); + }); + } + break; + } + case 'error': + this.appendStreamLog(`ERROR — ${event.message}`, 'stream-fail'); + streamError = event.message; + this.thinkingLabel = ''; + this.wsMessageHandler = null; + this.finishStreamRequest(requestId); + this.notificationService.alert({ title: event.message }); + this.cdr.detectChanges(); + break; + } + }; + + this.wsMessageHandler = handleStreamEvent; + this.ws?.send(JSON.stringify({ type: 'chat', ...body })); + } + + private async dispatchMessageStream( + requestId: number, + body: { + conversationHistory: { role: string; content: string }[]; + message: string; + }, + token: string + ): Promise { + this.streamLogLines = []; + this.appendStreamLog('STREAMING EVENTS...', 'stream-thinking'); + + this.abortController = new AbortController(); + + const streamRequest = (): Promise => + fetch('/api/v1/agent/chat/stream', { + method: 'POST', + signal: this.abortController?.signal, + headers: { + 'Content-Type': 'application/json', + [HEADER_KEY_TOKEN]: `Bearer ${token}` + }, + body: JSON.stringify(body) + }); + + try { + const response = await streamRequest(); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 503) { + this.appendStreamLog( + `ERROR — Agent service not configured. Set AGENT_SERVICE_URL.`, + 'stream-fail' + ); + } else { + this.appendStreamLog( + `ERROR — Request failed (HTTP ${response.status}). ${text || ''}`.trim(), + 'stream-fail' + ); + } + this.finishStreamRequest(requestId); + return; + } + + if (!response.body) { + this.appendStreamLog( + 'ERROR — Streaming response was empty.', + 'stream-fail' + ); + this.finishStreamRequest(requestId); + return; + } + + this.appendStreamLog('CONNECTED. STREAMING EVENTS...', 'stream-thinking'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finalAnswer: string | null = null; + let finalConfidence = 0; + let finalWarnings: string[] = []; + let finalToolTrace: AgentToolTraceRow[] = []; + let finalData: AgentChatResponseData | undefined; + let finalLoopMeta: LoopMeta | undefined; + let streamError: string | null = null; + + const handleStreamEvent = (event: AgentStreamEvent): void => { + switch (event.type) { + case 'iteration_start': + this.thinkingLabel = 'Thinking...'; + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | THINKING...`, + 'stream-thinking' + ); + this.cdr.detectChanges(); + break; + case 'thinking': + this.thinkingLabel = 'Thinking...'; + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | LLM STEP READY`, + 'stream-thinking' + ); + this.cdr.detectChanges(); + break; + case 'tool_start': + this.thinkingLabel = `Running ${event.tool}...`; + this.activeToolCalls = [ + ...this.activeToolCalls, + { tool: event.tool, input: {}, ok: true, ms: 0, expanded: false } + ]; + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [RUNNING]`, + 'stream-tool-running' + ); + this.cdr.detectChanges(); + break; + case 'tool_end': { + this.activeToolCalls = this.activeToolCalls.map((tc) => + tc.tool === event.tool && tc.ms === 0 + ? { ...tc, ok: event.ok, ms: event.ms } + : tc + ); + const blocked = event.detail?.toUpperCase().includes('BLOCKED'); + if (blocked) { + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [BLOCKED] ${event.detail ?? ''}`.trim(), + 'stream-tool-blocked' + ); + } else if (event.ok) { + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [OK ${event.ms}ms]${event.detail ? ` ${event.detail}` : ''}`, + 'stream-tool-ok' + ); + } else { + this.appendStreamLog( + `ITER ${this.formatIteration(event.iteration)} | TOOL ${event.tool} [FAIL ${event.ms}ms]${event.detail ? ` ${event.detail}` : ''}`, + 'stream-tool-fail' + ); + } + this.cdr.detectChanges(); + break; + } + case 'text_delta': + this.receivedTextDelta = true; + this.thinkingLabel = ''; + this.streamingAnswer += event.text; + this.streamingAnswerVisibleLength = this.streamingAnswer.length; + this.scrollMessagesToBottom(); + this.cdr.detectChanges(); + break; + case 'tool_detail': + this.activeToolCalls = this.activeToolCalls.map((tc) => + tc.tool === event.tool && !tc.input?.['__enriched'] + ? { ...tc, input: event.input, result: event.result } + : tc + ); + this.cdr.detectChanges(); + break; + case 'done': { + finalAnswer = this.receivedTextDelta + ? this.streamingAnswer + : event.answer; + finalConfidence = event.confidence; + finalWarnings = event.warnings ?? []; + finalToolTrace = event.toolTrace ?? []; + finalData = event.data; + finalLoopMeta = (event as { loopMeta?: LoopMeta })?.loopMeta; + const iters = finalLoopMeta?.iterations ?? '-'; + const totalMs = finalLoopMeta?.totalMs ?? 0; + this.appendStreamLog( + `DONE — ${iters} iters · ${(totalMs / 1000).toFixed(1)}s`, + 'stream-done' + ); + const meta = finalLoopMeta; + const trace = event.toolTrace ?? []; + if (meta) { + const COST_PER_INPUT = 3.0 / 1_000_000; + const COST_PER_OUTPUT = 15.0 / 1_000_000; + const cost = + (meta.tokenUsage?.inputTokens ?? 0) * COST_PER_INPUT + + (meta.tokenUsage?.outputTokens ?? 0) * COST_PER_OUTPUT; + const tokens = + meta.tokenUsage?.totalTokens ?? + (meta.tokenUsage?.inputTokens ?? 0) + + (meta.tokenUsage?.outputTokens ?? 0); + const toolsList = trace.length + ? trace.map((t) => t.tool).join(', ') + : '—'; + const success = meta.terminationReason === 'end_turn'; + this.appendStreamLog( + `METRICS: cost $${cost.toFixed(4)} · tokens ${tokens} · tools: ${toolsList} · success: ${success}`, + 'stream-metrics' + ); + } + this.thinkingLabel = ''; + this.cdr.detectChanges(); + break; + } + case 'error': + this.appendStreamLog(`ERROR — ${event.message}`, 'stream-fail'); + this.thinkingLabel = ''; + streamError = event.message; + this.cdr.detectChanges(); + break; + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) 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 AgentStreamEvent; + handleStreamEvent(parsed); + } catch { + this.appendStreamLog('STREAM PARSE ERROR', 'stream-fail'); + } + } + + if (streamError) break; + separatorIndex = buffer.indexOf('\n\n'); + } + + if (streamError) break; + } + + if (streamError) { + this.notificationService.alert({ + title: streamError + }); + } else if (finalAnswer !== null) { + const finalize = () => { + this.streamLogLines = []; + this.streamingAnswer = ''; + this.streamingAnswerVisibleLength = 0; + this.activeToolCalls = []; + this.appendMessage({ + confidence: finalConfidence, + content: finalAnswer, + data: finalData, + loopMeta: finalLoopMeta, + role: 'assistant', + toolTrace: finalToolTrace, + warnings: finalWarnings + }); + if (this.didPortfolioChange(finalToolTrace)) { + this.userService + .get(true) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + } + }; + + if (this.receivedTextDelta) { + // Text was already streamed — finalize directly + finalize(); + } else { + // Fallback: typewriter effect + this.streamingAnswer = finalAnswer; + this.streamingAnswerVisibleLength = 0; + this.runTypewriterEffect(finalAnswer, finalize); + } + } + } catch (error) { + if ((error as Error)?.name === 'AbortError') { + this.appendStreamLog('CANCELLED', 'stream-fail'); + } else { + const msg = error instanceof Error ? error.message : String(error); + this.appendStreamLog(`ERROR — ${msg}`, 'stream-fail'); + this.notificationService.alert({ + title: $localize`The agent request failed. ${msg}` + }); + } + } finally { + this.abortController = null; + this.finishStreamRequest(requestId); + } + } + + private finishStreamRequest(requestId: number): void { + if (this.activeRequestId === requestId) { + this.isSending = false; + this.activeRequestId = 0; + this.flushQueue(); + } + } + + private enqueueMessage(message: string, prepend = false) { + if (prepend) { + this.queuedMessages = [message, ...this.queuedMessages]; + return; + } + + this.queuedMessages = [...this.queuedMessages, message]; + } + + private flushQueue() { + if (this.isSending || this.queuedMessages.length === 0) { + return; + } + + const [nextMessage, ...remaining] = this.queuedMessages; + this.queuedMessages = remaining; + this.dispatchMessage(nextMessage); + } + + private appendMessage(message: ChatMessage) { + this.messages = [...this.messages, message].slice(-MAX_HISTORY_MESSAGES); + this.persistMessages(); + this.scrollMessagesToBottom(); + } + + private scrollCommandToTop(): void { + const container = this.messagesContainer?.nativeElement; + if (!container) return; + const userRows = container.querySelectorAll('.message-row.user'); + const lastUserRow = userRows[userRows.length - 1]; + if (lastUserRow) { + (lastUserRow as HTMLElement).scrollIntoView({ + block: 'start', + behavior: 'smooth' + }); + } + } + + private scrollMessagesToBottom(): void { + // Cursor-style: keep view anchored to the present (newest content). + const anchor = this.scrollAnchor?.nativeElement; + if (anchor) { + anchor.scrollIntoView({ block: 'end', behavior: 'auto' }); + return; + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const el = this.messagesContainer?.nativeElement; + if (el) { + el.scrollTop = el.scrollHeight; + } + }); + }); + } + + private runTypewriterEffect( + fullText: string, + onComplete: () => void, + charsPerTick = 30 + ): void { + let pos = 0; + const tick = () => { + pos = Math.min(pos + charsPerTick, fullText.length); + this.streamingAnswerVisibleLength = pos; + this.scrollMessagesToBottom(); + + if (pos < fullText.length) { + setTimeout(tick, 16); + } else { + onComplete(); + } + }; + setTimeout(tick, 50); + } + + private getStorageKey() { + return this.user?.id + ? `${DEFAULT_HISTORY_KEY}_${this.user.id}` + : DEFAULT_HISTORY_KEY; + } + + private loadMessages(): ChatMessage[] { + const rawValue = window.localStorage.getItem(this.getStorageKey()); + if (!rawValue) { + return []; + } + + try { + const parsed = JSON.parse(rawValue) as PersistedChatMessage[]; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter((entry: PersistedChatMessage) => { + return ( + typeof entry?.content === 'string' && + (entry?.role === 'assistant' || entry?.role === 'user') + ); + }) + .map( + (entry: PersistedChatMessage): NormalizedChatMessage => ({ + confidence: entry.confidence, + content: entry.content, + data: entry.data, + loopMeta: entry.loopMeta, + role: entry.role, + toolTrace: entry.toolTrace, + warnings: entry.warnings + }) + ) + .slice(-MAX_HISTORY_MESSAGES); + } catch { + return []; + } + } + + private persistMessages() { + window.localStorage.setItem( + this.getStorageKey(), + JSON.stringify(this.messages) + ); + } +} diff --git a/apps/client/src/app/pages/agent/agent-page.html b/apps/client/src/app/pages/agent/agent-page.html new file mode 100644 index 000000000..bda48fd9c --- /dev/null +++ b/apps/client/src/app/pages/agent/agent-page.html @@ -0,0 +1,279 @@ +
+
+ + + + Agent + + + Ask portfolio questions using your current Ghostfolio + session. + + + +
+ @if (messages.length === 0) { +
+
+ Would you like me to buy stocks or crypto for you? +
+ +
+ } + + @for (entry of messages; track trackByIndex($index)) { +
+ @if (entry.strategyStep) { + + } @else { +
+
+ +
+ @if ( + entry.role === 'assistant' && entry.confidence !== undefined + ) { +
+ {{ entry.confidence * 100 | number: '1.0-0' }}% confidence +
+ } + @if ( + entry.role === 'assistant' && + getDisplayWarnings(entry).length > 0 + ) { +
+ {{ getDisplayWarnings(entry).join(' | ') }} +
+ } + @if ( + entry.role === 'assistant' && + (entry.toolTrace?.length ?? 0) > 0 + ) { +
+ Tools + @for ( + tool of getToolStatuses(entry); + track trackByIndex($index) + ) { + + {{ tool.tool }} {{ tool.ok ? 'ok' : 'failed' }} + + } +
+ } + @if (entry.role === 'assistant' && entry.loopMeta) { +
+ {{ getMetricsText(entry.loopMeta) }} +
+ } +
+ } +
+ } + + + @if (isSending && thinkingLabel && streamingAnswer.length === 0) { +
+
+ + + + + + {{ thinkingLabel }} +
+
+ } + + + @if (activeToolCalls.length > 0) { +
+ @for (tc of activeToolCalls; track trackByIndex($index)) { +
+
+ {{ + tc.expanded ? '▾' : '▸' + }} + {{ tc.tool }} + @if (tc.ms > 0) { + + {{ tc.ok ? tc.ms + 'ms' : 'FAIL' }} + + } @else { + running + } +
+ @if (tc.expanded) { +
+ @if (tc.input && (tc.input | json) !== '{}') { +
+ Input +
{{
+                            tc.input | json
+                          }}
+
+ } + @if (tc.result) { +
+ Result +
{{
+                            tc.result | json
+                          }}
+
+ } +
+ } +
+ } +
+ } + + + @if (showDebugConsole && streamLogLines.length > 0) { +
+
+
[AGENT]
+
+ @for (line of streamLogLines; track trackByIndex($index)) { +
+ {{ line.text }} +
+ } +
+
+
+ } + + + @if (streamingAnswer.length > 0) { +
+
+
+ @if (receivedTextDelta) { + + } @else { + {{ + streamingAnswer.slice(0, streamingAnswerVisibleLength) + }} + } + @if (isSending) { + + } +
+
+
+ } + +
+ +
+ + + + + + @if (isSending && hasDraftMessage) { + + + } + + + + +
+ + @if (queueCount > 0) { +
{{ queueCount }} queued
+ } +
+
+
+
diff --git a/apps/client/src/app/pages/agent/agent-page.routes.ts b/apps/client/src/app/pages/agent/agent-page.routes.ts new file mode 100644 index 000000000..33f1e13b6 --- /dev/null +++ b/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 + } +]; diff --git a/apps/client/src/app/pages/agent/agent-page.scss b/apps/client/src/app/pages/agent/agent-page.scss new file mode 100644 index 000000000..70fdae49e --- /dev/null +++ b/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%; +} diff --git a/apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.html b/apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.html new file mode 100644 index 000000000..78c92dbd7 --- /dev/null +++ b/apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.html @@ -0,0 +1,47 @@ +
+
+
+ TOTAL VALUE + ${{ currentValue | number: '1.2-2' }} +
+
+ RETURN + + {{ returnPct >= 0 ? '+' : '' }}{{ returnPct * 100 | number: '1.2-2' }}% + +
+
+ TODAY + + {{ todayChange >= 0 ? '+' : '' }}{{ todayChange | number: '1.2-2' }}% + +
+
+ +
+ @if (isLoading) { +
Loading...
+ } + +
+ +
+ @for (r of ranges; track r.value) { + + } +
+
diff --git a/apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.scss b/apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.scss new file mode 100644 index 000000000..b74203143 --- /dev/null +++ b/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; + } +} diff --git a/apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.ts b/apps/client/src/app/pages/agent/components/agent-chart-panel/agent-chart-panel.component.ts new file mode 100644 index 000000000..66acc194a --- /dev/null +++ b/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(); + + @ViewChild('chartCanvas') chartCanvas: ElementRef; + + 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(); + + 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 } + } + } + } + }); + } +} diff --git a/apps/client/src/app/pages/agent/components/sparkline/sparkline.component.ts b/apps/client/src/app/pages/agent/components/sparkline/sparkline.component.ts new file mode 100644 index 000000000..3e1984d1f --- /dev/null +++ b/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: '', + 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; + + 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 } + } + } + }); + } +} diff --git a/apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.html b/apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.html new file mode 100644 index 000000000..a589d78f0 --- /dev/null +++ b/apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.html @@ -0,0 +1,45 @@ +
+ @if (step.options) { +
{{ step.question }}
+
+ @for (opt of step.options; track opt.value) { + + } +
+ } + + @if (step.recommendation) { +
+
{{ step.recommendation.title }}
+
{{ step.recommendation.description }}
+
+ @for (alloc of step.recommendation.allocations; track alloc.asset) { +
+ {{ alloc.asset }} +
+
+
+ {{ alloc.percent }}% +
+ } +
+
+ Risk Level: {{ step.recommendation.riskLevel }} +
+
+ } +
diff --git a/apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.scss b/apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.scss new file mode 100644 index 000000000..a73187d62 --- /dev/null +++ b/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; + } +} diff --git a/apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.ts b/apps/client/src/app/pages/agent/components/strategy-card/strategy-card.component.ts new file mode 100644 index 000000000..39fa6102b --- /dev/null +++ b/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(); + + public selectOption(value: string): void { + if (!this.disabled) { + this.optionSelected.emit(value); + } + } +} diff --git a/apps/client/src/app/pages/agent/models/strategy-flow.data.ts b/apps/client/src/app/pages/agent/models/strategy-flow.data.ts new file mode 100644 index 000000000..4fd9bb799 --- /dev/null +++ b/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 = { + 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 +): StrategyRecommendation { + let riskScore = 0; + + // Goals scoring + const goalScores: Record = { + retire_early: 2, + grow_wealth: 3, + generate_income: 1, + preserve_capital: 0 + }; + riskScore += goalScores[answers['goals']] ?? 1; + + // Time horizon scoring + const horizonScores: Record = { + lt_1y: 0, + '1_5y': 1, + '5_15y': 2, + '15y_plus': 3 + }; + riskScore += horizonScores[answers['timeHorizon']] ?? 1; + + // Risk appetite scoring + const riskScores: Record = { + panic_sell: 0, + worried_hold: 1, + stay_course: 2, + buy_dip: 3 + }; + riskScore += riskScores[answers['riskAppetite']] ?? 1; + + // Experience scoring + const expScores: Record = { + 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' + }; + } +} diff --git a/apps/client/src/app/pages/agent/models/strategy-flow.types.ts b/apps/client/src/app/pages/agent/models/strategy-flow.types.ts new file mode 100644 index 000000000..3bd94880a --- /dev/null +++ b/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; +} diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index 53ecd104e..00f9ca735 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -68,6 +68,11 @@ export const internalRoutes: Record = { routerLink: ['/accounts'], title: $localize`Accounts` }, + agent: { + path: 'agent', + routerLink: ['/agent'], + title: $localize`Agent` + }, api: { excludeFromAssistant: true, path: 'api',