mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
Add an AI-powered portfolio assistant that lets users ask natural-language questions about their holdings, run allocation simulations, and receive strategy recommendations via a terminal-themed chat UI. The agent runs as a separate microservice (available as an npm package). Ghostfolio proxies authenticated requests to it. The feature is fully optional — if AGENT_SERVICE_URL is not set, all endpoints return 503 and the UI route is inactive. Architecture: - NestJS proxy module (REST + WebSocket + SSE streaming) - Angular lazy-loaded chat page with real-time tool execution visibility - Strategy questionnaire component for guided portfolio recommendations - Zero impact on existing features when disabled Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>pull/6457/head
26 changed files with 3899 additions and 1 deletions
@ -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' }); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,90 @@ |
|||
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
Inject, |
|||
Post, |
|||
Res, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Response } from 'express'; |
|||
|
|||
import { AgentService } from './agent.service'; |
|||
|
|||
@Controller('agent') |
|||
export class AgentController { |
|||
public constructor( |
|||
private readonly agentService: AgentService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
private getBearerToken(): string { |
|||
const headers = this.request.headers as unknown as Record<string, unknown>; |
|||
const auth = |
|||
headers[HEADER_KEY_TOKEN.toLowerCase()] ?? headers['authorization']; |
|||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) { |
|||
return auth; |
|||
} |
|||
return typeof auth === 'string' ? `Bearer ${auth}` : ''; |
|||
} |
|||
|
|||
@Get('auth/status') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getAuthStatus(@Res() res: Response): Promise<void> { |
|||
if (!this.agentService.isEnabled()) { |
|||
res.status(503).json({ error: 'Agent service is not configured.' }); |
|||
return; |
|||
} |
|||
const { status, data } = await this.agentService.proxyToAgent({ |
|||
method: 'GET', |
|||
path: '/api/auth/status', |
|||
ghostfolioUserId: this.request.user.id, |
|||
bearerToken: this.getBearerToken() |
|||
}); |
|||
res.status(status).json(data); |
|||
} |
|||
|
|||
@Post('chat') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async chat( |
|||
@Body() body: unknown, |
|||
@Res() res: Response |
|||
): Promise<void> { |
|||
if (!this.agentService.isEnabled()) { |
|||
res.status(503).json({ error: 'Agent service is not configured.' }); |
|||
return; |
|||
} |
|||
const { status, data } = await this.agentService.proxyToAgent({ |
|||
method: 'POST', |
|||
path: '/api/chat', |
|||
ghostfolioUserId: this.request.user.id, |
|||
bearerToken: this.getBearerToken(), |
|||
body |
|||
}); |
|||
res.status(status).json(data); |
|||
} |
|||
|
|||
@Post('chat/stream') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async chatStream( |
|||
@Body() body: unknown, |
|||
@Res() res: Response |
|||
): Promise<void> { |
|||
if (!this.agentService.isEnabled()) { |
|||
res.status(503).json({ error: 'Agent service is not configured.' }); |
|||
return; |
|||
} |
|||
await this.agentService.proxyStreamToAgent({ |
|||
path: '/api/chat/stream', |
|||
ghostfolioUserId: this.request.user.id, |
|||
bearerToken: this.getBearerToken(), |
|||
body, |
|||
res |
|||
}); |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
} |
|||
); |
|||
} |
|||
@ -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 {} |
|||
@ -0,0 +1,89 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
|
|||
import { AgentService } from './agent.service'; |
|||
|
|||
describe('AgentService', () => { |
|||
let service: AgentService; |
|||
let configurationService: Pick<ConfigurationService, 'get'>; |
|||
|
|||
beforeEach(() => { |
|||
configurationService = { |
|||
get: jest.fn() |
|||
}; |
|||
|
|||
service = new AgentService(configurationService as ConfigurationService); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
it('returns trimmed agent base URL', () => { |
|||
(configurationService.get as jest.Mock).mockReturnValue( |
|||
' http://localhost:3334/ ' |
|||
); |
|||
|
|||
expect(service.getAgentBaseUrl()).toBe('http://localhost:3334/'); |
|||
}); |
|||
|
|||
it('detects when the agent service is disabled', () => { |
|||
(configurationService.get as jest.Mock).mockReturnValue(' '); |
|||
|
|||
expect(service.isEnabled()).toBe(false); |
|||
}); |
|||
|
|||
it('returns 503 when AGENT_SERVICE_URL is missing', async () => { |
|||
(configurationService.get as jest.Mock).mockReturnValue(''); |
|||
|
|||
await expect( |
|||
service.proxyToAgent({ |
|||
method: 'GET', |
|||
path: '/api/auth/status', |
|||
ghostfolioUserId: 'user-1', |
|||
bearerToken: 'Bearer abc' |
|||
}) |
|||
).resolves.toEqual({ |
|||
status: 503, |
|||
data: { |
|||
error: 'Agent service is not configured (AGENT_SERVICE_URL).' |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
it('forwards auth and user-id headers for POST requests', async () => { |
|||
(configurationService.get as jest.Mock).mockReturnValue( |
|||
'http://localhost:3334' |
|||
); |
|||
|
|||
const json = { answer: 'ok' }; |
|||
const fetchSpy = jest.spyOn(global, 'fetch' as any).mockResolvedValue({ |
|||
headers: { |
|||
get: () => 'application/json' |
|||
}, |
|||
json: async () => json, |
|||
status: 200 |
|||
} as unknown as Response); |
|||
|
|||
const payload = { message: 'hello' }; |
|||
|
|||
await expect( |
|||
service.proxyToAgent({ |
|||
method: 'POST', |
|||
path: '/api/chat', |
|||
ghostfolioUserId: 'user-123', |
|||
bearerToken: 'token-without-prefix', |
|||
body: payload |
|||
}) |
|||
).resolves.toEqual({ status: 200, data: json }); |
|||
|
|||
expect(fetchSpy).toHaveBeenCalledWith('http://localhost:3334/api/chat', { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
Authorization: 'Bearer token-without-prefix', |
|||
'x-ghostfolio-user-id': 'user-123' |
|||
}, |
|||
body: JSON.stringify(payload) |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,235 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import type { Response } from 'express'; |
|||
import { Readable } from 'node:stream'; |
|||
|
|||
const X_GHOSTFOLIO_USER_ID = 'x-ghostfolio-user-id'; |
|||
|
|||
@Injectable() |
|||
export class AgentService { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService |
|||
) {} |
|||
|
|||
public getAgentBaseUrl(): string { |
|||
const url = this.configurationService.get('AGENT_SERVICE_URL'); |
|||
return typeof url === 'string' ? url.trim() : ''; |
|||
} |
|||
|
|||
public isEnabled(): boolean { |
|||
return this.getAgentBaseUrl().length > 0; |
|||
} |
|||
|
|||
/** |
|||
* Proxy a request to the ghostfolio-agent service with shared-auth headers. |
|||
* Forwards the user's Ghostfolio JWT and user id so the agent uses the same auth. |
|||
*/ |
|||
public async proxyToAgent({ |
|||
method, |
|||
path, |
|||
ghostfolioUserId, |
|||
bearerToken, |
|||
body |
|||
}: { |
|||
method: 'GET' | 'POST'; |
|||
path: string; |
|||
ghostfolioUserId: string; |
|||
bearerToken: string; |
|||
body?: unknown; |
|||
}): Promise<{ status: number; data: unknown }> { |
|||
const baseUrl = this.getAgentBaseUrl(); |
|||
if (!baseUrl) { |
|||
return { |
|||
status: 503, |
|||
data: { error: 'Agent service is not configured (AGENT_SERVICE_URL).' } |
|||
}; |
|||
} |
|||
|
|||
const url = `${baseUrl.replace(/\/$/, '')}${path}`; |
|||
const headers: Record<string, string> = { |
|||
'Content-Type': 'application/json', |
|||
Authorization: bearerToken.startsWith('Bearer ') |
|||
? bearerToken |
|||
: `Bearer ${bearerToken}`, |
|||
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId |
|||
}; |
|||
|
|||
const init: RequestInit = { method, headers }; |
|||
if (body !== undefined && method === 'POST') { |
|||
init.body = JSON.stringify(body); |
|||
} |
|||
|
|||
const response = await fetch(url, init); |
|||
let data: unknown; |
|||
const contentType = response.headers.get('content-type'); |
|||
if (contentType?.includes('application/json')) { |
|||
try { |
|||
data = await response.json(); |
|||
} catch { |
|||
data = { error: 'Invalid JSON response from agent' }; |
|||
} |
|||
} else { |
|||
data = { error: await response.text() }; |
|||
} |
|||
|
|||
return { status: response.status, data }; |
|||
} |
|||
|
|||
/** |
|||
* Proxy a streaming request to the agent's /api/chat/stream endpoint. |
|||
* Pipes the SSE stream from the agent to the Express response. |
|||
*/ |
|||
public async proxyStreamToAgent({ |
|||
path, |
|||
ghostfolioUserId, |
|||
bearerToken, |
|||
body, |
|||
res |
|||
}: { |
|||
path: string; |
|||
ghostfolioUserId: string; |
|||
bearerToken: string; |
|||
body: unknown; |
|||
res: Response; |
|||
}): Promise<void> { |
|||
const baseUrl = this.getAgentBaseUrl(); |
|||
if (!baseUrl) { |
|||
res.status(503).json({ |
|||
error: 'Agent service is not configured (AGENT_SERVICE_URL).' |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
const url = `${baseUrl.replace(/\/$/, '')}${path}`; |
|||
const headers: Record<string, string> = { |
|||
'Content-Type': 'application/json', |
|||
Authorization: bearerToken.startsWith('Bearer ') |
|||
? bearerToken |
|||
: `Bearer ${bearerToken}`, |
|||
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId |
|||
}; |
|||
|
|||
const agentRes = await fetch(url, { |
|||
method: 'POST', |
|||
headers, |
|||
body: JSON.stringify(body) |
|||
}); |
|||
|
|||
if (!agentRes.ok) { |
|||
const text = await agentRes.text(); |
|||
res |
|||
.status(agentRes.status) |
|||
.json({ error: text || 'Agent request failed' }); |
|||
return; |
|||
} |
|||
|
|||
res.setHeader('Content-Type', 'text/event-stream'); |
|||
res.setHeader('Cache-Control', 'no-cache'); |
|||
res.setHeader('Connection', 'keep-alive'); |
|||
res.setHeader('X-Accel-Buffering', 'no'); |
|||
res.flushHeaders?.(); |
|||
// Disable Nagle's algorithm for immediate delivery of SSE chunks
|
|||
(res as any).socket?.setNoDelay?.(true); |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
const nodeStream = Readable.fromWeb(agentRes.body as any); |
|||
nodeStream.pipe(res); |
|||
} |
|||
|
|||
/** |
|||
* Proxy a streaming request to the agent and yield parsed SSE events. |
|||
* Used by the WebSocket gateway to forward events to the client. |
|||
*/ |
|||
public async *proxyStreamToAgentEvents({ |
|||
path, |
|||
ghostfolioUserId, |
|||
bearerToken, |
|||
body, |
|||
signal |
|||
}: { |
|||
path: string; |
|||
ghostfolioUserId: string; |
|||
bearerToken: string; |
|||
body: unknown; |
|||
signal?: AbortSignal; |
|||
}): AsyncGenerator<Record<string, unknown>> { |
|||
const baseUrl = this.getAgentBaseUrl(); |
|||
if (!baseUrl) { |
|||
yield { |
|||
type: 'error', |
|||
message: 'Agent service is not configured (AGENT_SERVICE_URL).' |
|||
}; |
|||
return; |
|||
} |
|||
|
|||
const url = `${baseUrl.replace(/\/$/, '')}${path}`; |
|||
const headers: Record<string, string> = { |
|||
'Content-Type': 'application/json', |
|||
Authorization: bearerToken.startsWith('Bearer ') |
|||
? bearerToken |
|||
: `Bearer ${bearerToken}`, |
|||
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId |
|||
}; |
|||
|
|||
const agentRes = await fetch(url, { |
|||
method: 'POST', |
|||
headers, |
|||
body: JSON.stringify(body), |
|||
signal |
|||
}); |
|||
|
|||
if (!agentRes.ok) { |
|||
const text = await agentRes.text(); |
|||
yield { |
|||
type: 'error', |
|||
message: text || `Agent request failed (HTTP ${agentRes.status})` |
|||
}; |
|||
return; |
|||
} |
|||
|
|||
if (!agentRes.body) { |
|||
yield { type: 'error', message: 'Streaming response was empty' }; |
|||
return; |
|||
} |
|||
|
|||
const reader = agentRes.body.getReader(); |
|||
const decoder = new TextDecoder(); |
|||
let buffer = ''; |
|||
|
|||
try { |
|||
while (true) { |
|||
const { value, done } = await reader.read(); |
|||
if (done) break; |
|||
if (signal?.aborted) break; |
|||
|
|||
buffer += decoder.decode(value, { stream: true }); |
|||
|
|||
let separatorIndex = buffer.indexOf('\n\n'); |
|||
while (separatorIndex >= 0) { |
|||
const rawEvent = buffer.slice(0, separatorIndex); |
|||
buffer = buffer.slice(separatorIndex + 2); |
|||
|
|||
const dataPayload = rawEvent |
|||
.split('\n') |
|||
.filter((line) => line.startsWith('data:')) |
|||
.map((line) => line.slice(5).trim()) |
|||
.join('\n'); |
|||
|
|||
if (dataPayload) { |
|||
try { |
|||
const parsed = JSON.parse(dataPayload) as Record<string, unknown>; |
|||
yield parsed; |
|||
} catch { |
|||
// Skip malformed events
|
|||
} |
|||
} |
|||
|
|||
separatorIndex = buffer.indexOf('\n\n'); |
|||
} |
|||
} |
|||
} finally { |
|||
reader.releaseLock(); |
|||
} |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,279 @@ |
|||
<div class="agent-layout"> |
|||
<div class="chat-panel"> |
|||
<mat-card class="agent-shell terminal-theme"> |
|||
<mat-card-header class="terminal-header"> |
|||
<mat-card-title class="terminal-brand" i18n> |
|||
Agent |
|||
<span |
|||
class="connection-dot" |
|||
[ngClass]="connectionStatus" |
|||
[title]="connectionStatus" |
|||
></span> |
|||
</mat-card-title> |
|||
<mat-card-subtitle class="terminal-subtitle" i18n |
|||
>Ask portfolio questions using your current Ghostfolio |
|||
session.</mat-card-subtitle |
|||
> |
|||
</mat-card-header> |
|||
|
|||
<mat-card-content class="agent-content"> |
|||
<div #messagesContainer class="messages" role="log"> |
|||
@if (messages.length === 0) { |
|||
<div class="empty-state" i18n> |
|||
<div class="empty-text"> |
|||
Would you like me to buy stocks or crypto for you? |
|||
</div> |
|||
<button |
|||
class="strategy-flow-btn" |
|||
type="button" |
|||
(click)="startStrategyFlow()" |
|||
> |
|||
Build Investment Strategy |
|||
</button> |
|||
</div> |
|||
} |
|||
|
|||
@for (entry of messages; track trackByIndex($index)) { |
|||
<div |
|||
class="message-row" |
|||
[ngClass]="{ |
|||
assistant: entry.role === 'assistant', |
|||
user: entry.role === 'user' |
|||
}" |
|||
> |
|||
@if (entry.strategyStep) { |
|||
<gf-strategy-card |
|||
[disabled]=" |
|||
strategyMode && |
|||
currentStrategyStep?.stepId !== entry.strategyStep.stepId |
|||
" |
|||
[step]="entry.strategyStep" |
|||
(optionSelected)="handleStrategySelection($event)" |
|||
></gf-strategy-card> |
|||
} @else { |
|||
<div class="message-bubble"> |
|||
<div class="message-text"> |
|||
<markdown |
|||
class="message-markdown" |
|||
mermaid |
|||
[data]="entry.content ?? ''" |
|||
[mermaidOptions]="mermaidOptions" |
|||
></markdown> |
|||
</div> |
|||
@if ( |
|||
entry.role === 'assistant' && entry.confidence !== undefined |
|||
) { |
|||
<div class="meta-row"> |
|||
{{ entry.confidence * 100 | number: '1.0-0' }}% confidence |
|||
</div> |
|||
} |
|||
@if ( |
|||
entry.role === 'assistant' && |
|||
getDisplayWarnings(entry).length > 0 |
|||
) { |
|||
<div class="meta-row warning"> |
|||
{{ getDisplayWarnings(entry).join(' | ') }} |
|||
</div> |
|||
} |
|||
@if ( |
|||
entry.role === 'assistant' && |
|||
(entry.toolTrace?.length ?? 0) > 0 |
|||
) { |
|||
<div class="meta-row tool-trace"> |
|||
<span class="tool-trace-label" i18n>Tools</span> |
|||
@for ( |
|||
tool of getToolStatuses(entry); |
|||
track trackByIndex($index) |
|||
) { |
|||
<span |
|||
class="tool-pill" |
|||
[ngClass]="{ ok: tool.ok, fail: !tool.ok }" |
|||
> |
|||
{{ tool.tool }} {{ tool.ok ? 'ok' : 'failed' }} |
|||
</span> |
|||
} |
|||
</div> |
|||
} |
|||
@if (entry.role === 'assistant' && entry.loopMeta) { |
|||
<div class="meta-row metrics-row"> |
|||
{{ getMetricsText(entry.loopMeta) }} |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
|
|||
<!-- Thinking indicator --> |
|||
@if (isSending && thinkingLabel && streamingAnswer.length === 0) { |
|||
<div class="message-row assistant"> |
|||
<div class="thinking-indicator"> |
|||
<span class="thinking-dots"> |
|||
<span class="dot"></span> |
|||
<span class="dot"></span> |
|||
<span class="dot"></span> |
|||
</span> |
|||
<span class="thinking-label">{{ thinkingLabel }}</span> |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Collapsible tool call rows --> |
|||
@if (activeToolCalls.length > 0) { |
|||
<div class="tool-calls-panel"> |
|||
@for (tc of activeToolCalls; track trackByIndex($index)) { |
|||
<div |
|||
class="tool-call-row" |
|||
[class.expanded]="tc.expanded" |
|||
[class.fail]="!tc.ok" |
|||
[class.ok]="tc.ok" |
|||
> |
|||
<div |
|||
class="tool-call-header" |
|||
(click)="toggleToolExpanded($index)" |
|||
> |
|||
<span class="tool-call-chevron">{{ |
|||
tc.expanded ? '▾' : '▸' |
|||
}}</span> |
|||
<span class="tool-call-name">{{ tc.tool }}</span> |
|||
@if (tc.ms > 0) { |
|||
<span |
|||
class="tool-call-status" |
|||
[class.fail]="!tc.ok" |
|||
[class.ok]="tc.ok" |
|||
> |
|||
{{ tc.ok ? tc.ms + 'ms' : 'FAIL' }} |
|||
</span> |
|||
} @else { |
|||
<span class="tool-call-status running">running</span> |
|||
} |
|||
</div> |
|||
@if (tc.expanded) { |
|||
<div class="tool-call-detail"> |
|||
@if (tc.input && (tc.input | json) !== '{}') { |
|||
<div class="tool-detail-section"> |
|||
<span class="tool-detail-label">Input</span> |
|||
<pre class="tool-detail-pre">{{ |
|||
tc.input | json |
|||
}}</pre> |
|||
</div> |
|||
} |
|||
@if (tc.result) { |
|||
<div class="tool-detail-section"> |
|||
<span class="tool-detail-label">Result</span> |
|||
<pre class="tool-detail-pre">{{ |
|||
tc.result | json |
|||
}}</pre> |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
|
|||
<!-- Debug console (toggleable) --> |
|||
@if (showDebugConsole && streamLogLines.length > 0) { |
|||
<div class="message-row assistant stream-console"> |
|||
<div class="stream-bubble"> |
|||
<div class="stream-header">[AGENT]</div> |
|||
<div class="stream-lines"> |
|||
@for (line of streamLogLines; track trackByIndex($index)) { |
|||
<div class="stream-line" [ngClass]="line.cssClass"> |
|||
{{ line.text }} |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Streaming answer with blinking cursor --> |
|||
@if (streamingAnswer.length > 0) { |
|||
<div class="message-row assistant streaming-answer"> |
|||
<div class="message-bubble"> |
|||
<div class="message-text"> |
|||
@if (receivedTextDelta) { |
|||
<markdown |
|||
class="message-markdown" |
|||
mermaid |
|||
[data]="streamingAnswer" |
|||
[mermaidOptions]="mermaidOptions" |
|||
></markdown> |
|||
} @else { |
|||
<span class="streaming-text">{{ |
|||
streamingAnswer.slice(0, streamingAnswerVisibleLength) |
|||
}}</span> |
|||
} |
|||
@if (isSending) { |
|||
<span aria-hidden="true" class="typewriter-cursor"></span> |
|||
} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
} |
|||
<div #scrollAnchor aria-hidden="true" class="scroll-anchor"></div> |
|||
</div> |
|||
|
|||
<div class="composer terminal-composer"> |
|||
<span aria-hidden="true" class="prompt-char">></span> |
|||
<mat-form-field appearance="outline" class="composer-input"> |
|||
<input |
|||
i18n-placeholder |
|||
matInput |
|||
placeholder="Type your question..." |
|||
[(ngModel)]="draftMessage" |
|||
(keyup.enter)="sendMessage()" |
|||
/> |
|||
</mat-form-field> |
|||
|
|||
@if (isSending && hasDraftMessage) { |
|||
<button |
|||
class="queue-btn terminal-queue" |
|||
i18n |
|||
type="button" |
|||
(click)="sendMessage()" |
|||
> |
|||
Queue |
|||
</button> |
|||
<button |
|||
class="queue-btn terminal-interject" |
|||
i18n |
|||
type="button" |
|||
(click)="interjectMessage()" |
|||
> |
|||
Interject |
|||
</button> |
|||
} |
|||
|
|||
<button |
|||
class="debug-toggle-btn" |
|||
title="Toggle debug console" |
|||
type="button" |
|||
[class.active]="showDebugConsole" |
|||
(click)="toggleDebugConsole()" |
|||
> |
|||
<span aria-hidden="true">{{ '{' }}{{ '}' }}</span> |
|||
</button> |
|||
|
|||
<button |
|||
class="send-btn terminal-send" |
|||
type="button" |
|||
[disabled]="!isSending && !hasDraftMessage" |
|||
[ngClass]="{ stop: isSending }" |
|||
(click)="isSending ? cancelMessage() : sendMessage()" |
|||
> |
|||
<span aria-hidden="true" class="send-glyph">{{ |
|||
isSending ? '■' : '>' |
|||
}}</span> |
|||
</button> |
|||
</div> |
|||
|
|||
@if (queueCount > 0) { |
|||
<div class="queue-meta" i18n>{{ queueCount }} queued</div> |
|||
} |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
</div> |
|||
@ -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 |
|||
} |
|||
]; |
|||
@ -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%; |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
<div class="chart-panel-container"> |
|||
<div class="metrics-bar"> |
|||
<div class="metric"> |
|||
<span class="metric-label">TOTAL VALUE</span> |
|||
<span class="metric-value">${{ currentValue | number: '1.2-2' }}</span> |
|||
</div> |
|||
<div class="metric"> |
|||
<span class="metric-label">RETURN</span> |
|||
<span |
|||
class="metric-value" |
|||
[class.negative]="returnPct < 0" |
|||
[class.positive]="returnPct >= 0" |
|||
> |
|||
{{ returnPct >= 0 ? '+' : '' }}{{ returnPct * 100 | number: '1.2-2' }}% |
|||
</span> |
|||
</div> |
|||
<div class="metric"> |
|||
<span class="metric-label">TODAY</span> |
|||
<span |
|||
class="metric-value" |
|||
[class.negative]="todayChange < 0" |
|||
[class.positive]="todayChange >= 0" |
|||
> |
|||
{{ todayChange >= 0 ? '+' : '' }}{{ todayChange | number: '1.2-2' }}% |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="chart-wrapper"> |
|||
@if (isLoading) { |
|||
<div class="chart-loading">Loading...</div> |
|||
} |
|||
<canvas #chartCanvas></canvas> |
|||
</div> |
|||
|
|||
<div class="range-selector"> |
|||
@for (r of ranges; track r.value) { |
|||
<button |
|||
class="range-pill" |
|||
[class.active]="range === r.value" |
|||
(click)="setRange(r.value)" |
|||
> |
|||
{{ r.label }} |
|||
</button> |
|||
} |
|||
</div> |
|||
</div> |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -0,0 +1,237 @@ |
|||
import { DateRange } from '@ghostfolio/common/types'; |
|||
import { DataService } from '@ghostfolio/ui/services'; |
|||
|
|||
import { CommonModule, DecimalPipe } from '@angular/common'; |
|||
import { |
|||
AfterViewInit, |
|||
Component, |
|||
ElementRef, |
|||
EventEmitter, |
|||
Input, |
|||
OnChanges, |
|||
OnDestroy, |
|||
Output, |
|||
SimpleChanges, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { |
|||
CategoryScale, |
|||
Chart, |
|||
Filler, |
|||
LinearScale, |
|||
LineController, |
|||
LineElement, |
|||
PointElement, |
|||
TimeScale, |
|||
Tooltip |
|||
} from 'chart.js'; |
|||
import 'chartjs-adapter-date-fns'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
Chart.register( |
|||
CategoryScale, |
|||
Filler, |
|||
LinearScale, |
|||
LineController, |
|||
LineElement, |
|||
PointElement, |
|||
TimeScale, |
|||
Tooltip |
|||
); |
|||
|
|||
interface RangeOption { |
|||
label: string; |
|||
value: DateRange; |
|||
} |
|||
|
|||
@Component({ |
|||
imports: [CommonModule], |
|||
providers: [DecimalPipe], |
|||
selector: 'gf-agent-chart-panel', |
|||
styleUrls: ['./agent-chart-panel.component.scss'], |
|||
templateUrl: './agent-chart-panel.component.html' |
|||
}) |
|||
export class GfAgentChartPanelComponent |
|||
implements AfterViewInit, OnChanges, OnDestroy |
|||
{ |
|||
@Input() range: DateRange = 'max'; |
|||
@Output() rangeChange = new EventEmitter<DateRange>(); |
|||
|
|||
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>; |
|||
|
|||
public currentValue = 0; |
|||
public isLoading = false; |
|||
public returnPct = 0; |
|||
public todayChange = 0; |
|||
|
|||
public ranges: RangeOption[] = [ |
|||
{ label: '1W', value: 'wtd' }, |
|||
{ label: '1M', value: 'mtd' }, |
|||
{ label: '3M', value: '3m' }, |
|||
{ label: '6M', value: '6m' }, |
|||
{ label: 'YTD', value: 'ytd' }, |
|||
{ label: '1Y', value: '1y' }, |
|||
{ label: 'ALL', value: 'max' } |
|||
]; |
|||
|
|||
private chart: Chart | null = null; |
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private dataService: DataService, |
|||
private decimalPipe: DecimalPipe |
|||
) {} |
|||
|
|||
public ngAfterViewInit(): void { |
|||
this.loadData(); |
|||
} |
|||
|
|||
public ngOnChanges(changes: SimpleChanges): void { |
|||
if (changes['range'] && !changes['range'].firstChange) { |
|||
this.loadData(); |
|||
} |
|||
} |
|||
|
|||
public setRange(range: DateRange): void { |
|||
this.range = range; |
|||
this.rangeChange.emit(range); |
|||
this.loadData(); |
|||
} |
|||
|
|||
public ngOnDestroy(): void { |
|||
this.chart?.destroy(); |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private loadData(): void { |
|||
this.isLoading = true; |
|||
|
|||
this.dataService |
|||
.fetchPortfolioPerformance({ range: this.range, withItems: true }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: (response) => { |
|||
this.isLoading = false; |
|||
|
|||
const perf = response.performance; |
|||
this.currentValue = |
|||
perf?.currentNetWorth ?? perf?.currentValueInBaseCurrency ?? 0; |
|||
this.returnPct = perf?.netPerformancePercentage ?? 0; |
|||
|
|||
// Approximate today's change from last two chart points
|
|||
const chartData = response.chart ?? []; |
|||
if (chartData.length >= 2) { |
|||
const last = chartData[chartData.length - 1]?.netWorth ?? 0; |
|||
const prev = chartData[chartData.length - 2]?.netWorth ?? 0; |
|||
this.todayChange = prev > 0 ? ((last - prev) / prev) * 100 : 0; |
|||
} else { |
|||
this.todayChange = 0; |
|||
} |
|||
|
|||
this.renderChart(chartData); |
|||
}, |
|||
error: () => { |
|||
this.isLoading = false; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private renderChart( |
|||
data: { |
|||
date: string; |
|||
netWorth?: number; |
|||
netPerformanceInPercentage?: number; |
|||
}[] |
|||
): void { |
|||
const canvas = this.chartCanvas?.nativeElement; |
|||
if (!canvas) return; |
|||
|
|||
this.chart?.destroy(); |
|||
|
|||
const ctx = canvas.getContext('2d'); |
|||
if (!ctx) return; |
|||
|
|||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); |
|||
gradient.addColorStop(0, 'rgba(255, 102, 0, 0.15)'); |
|||
gradient.addColorStop(1, 'rgba(255, 102, 0, 0)'); |
|||
|
|||
const labels = data.map((d) => d.date); |
|||
const values = data.map((d) => d.netWorth ?? 0); |
|||
|
|||
this.chart = new Chart(ctx, { |
|||
type: 'line', |
|||
data: { |
|||
labels, |
|||
datasets: [ |
|||
{ |
|||
data: values, |
|||
borderColor: '#ff6600', |
|||
borderWidth: 1.5, |
|||
backgroundColor: gradient, |
|||
fill: true, |
|||
pointRadius: 0, |
|||
pointHitRadius: 8, |
|||
tension: 0.3 |
|||
} |
|||
] |
|||
}, |
|||
options: { |
|||
responsive: true, |
|||
maintainAspectRatio: false, |
|||
interaction: { |
|||
intersect: false, |
|||
mode: 'index' |
|||
}, |
|||
plugins: { |
|||
legend: { display: false }, |
|||
tooltip: { |
|||
backgroundColor: '#1a1a1a', |
|||
borderColor: '#333', |
|||
borderWidth: 1, |
|||
titleColor: '#999', |
|||
bodyColor: '#ff6600', |
|||
bodyFont: { family: 'monospace', size: 12 }, |
|||
titleFont: { family: 'monospace', size: 10 }, |
|||
padding: 8, |
|||
displayColors: false, |
|||
callbacks: { |
|||
label: (ctx) => { |
|||
const val = ctx.parsed.y; |
|||
return `$${this.decimalPipe.transform(val, '1.2-2') ?? val}`; |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
scales: { |
|||
x: { |
|||
type: 'time', |
|||
grid: { display: false }, |
|||
ticks: { |
|||
color: '#555', |
|||
font: { family: 'monospace', size: 9 }, |
|||
maxTicksLimit: 6 |
|||
}, |
|||
border: { display: false } |
|||
}, |
|||
y: { |
|||
position: 'right', |
|||
grid: { color: 'rgba(255,255,255,0.03)' }, |
|||
ticks: { |
|||
color: '#555', |
|||
font: { family: 'monospace', size: 9 }, |
|||
callback: (value) => { |
|||
const num = Number(value); |
|||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; |
|||
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`; |
|||
return String(num); |
|||
} |
|||
}, |
|||
border: { display: false } |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
import { |
|||
AfterViewInit, |
|||
Component, |
|||
ElementRef, |
|||
Input, |
|||
OnChanges, |
|||
OnDestroy, |
|||
SimpleChanges, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { |
|||
Chart, |
|||
LinearScale, |
|||
LineController, |
|||
LineElement, |
|||
PointElement |
|||
} from 'chart.js'; |
|||
|
|||
Chart.register(LinearScale, LineController, LineElement, PointElement); |
|||
|
|||
@Component({ |
|||
selector: 'gf-sparkline', |
|||
template: '<canvas #canvas class="sparkline-canvas"></canvas>', |
|||
styles: [ |
|||
` |
|||
:host { |
|||
display: inline-block; |
|||
height: 24px; |
|||
vertical-align: middle; |
|||
width: 80px; |
|||
} |
|||
.sparkline-canvas { |
|||
height: 100% !important; |
|||
width: 100% !important; |
|||
} |
|||
` |
|||
] |
|||
}) |
|||
export class GfSparklineComponent |
|||
implements AfterViewInit, OnChanges, OnDestroy |
|||
{ |
|||
@Input() data: number[] = []; |
|||
@Input() color = '#ff6600'; |
|||
|
|||
@ViewChild('canvas') canvas: ElementRef<HTMLCanvasElement>; |
|||
|
|||
private chart: Chart | null = null; |
|||
|
|||
public ngAfterViewInit(): void { |
|||
this.renderChart(); |
|||
} |
|||
|
|||
public ngOnChanges(changes: SimpleChanges): void { |
|||
if ( |
|||
(changes['data'] || changes['color']) && |
|||
!changes['data']?.firstChange |
|||
) { |
|||
this.renderChart(); |
|||
} |
|||
} |
|||
|
|||
public ngOnDestroy(): void { |
|||
this.chart?.destroy(); |
|||
} |
|||
|
|||
private renderChart(): void { |
|||
const el = this.canvas?.nativeElement; |
|||
if (!el || !this.data.length) return; |
|||
|
|||
this.chart?.destroy(); |
|||
|
|||
const ctx = el.getContext('2d'); |
|||
if (!ctx) return; |
|||
|
|||
this.chart = new Chart(ctx, { |
|||
type: 'line', |
|||
data: { |
|||
labels: this.data.map((_, i) => i), |
|||
datasets: [ |
|||
{ |
|||
data: this.data, |
|||
borderColor: this.color, |
|||
borderWidth: 1, |
|||
pointRadius: 0, |
|||
tension: 0.3, |
|||
fill: false |
|||
} |
|||
] |
|||
}, |
|||
options: { |
|||
responsive: true, |
|||
maintainAspectRatio: false, |
|||
animation: false, |
|||
plugins: { |
|||
legend: { display: false }, |
|||
tooltip: { enabled: false } |
|||
}, |
|||
scales: { |
|||
x: { display: false }, |
|||
y: { display: false } |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
<div class="strategy-card"> |
|||
@if (step.options) { |
|||
<div class="strategy-question">{{ step.question }}</div> |
|||
<div class="strategy-options"> |
|||
@for (opt of step.options; track opt.value) { |
|||
<button |
|||
class="strategy-option" |
|||
[class.disabled]="disabled" |
|||
[disabled]="disabled" |
|||
(click)="selectOption(opt.value)" |
|||
> |
|||
<span class="option-label">{{ opt.label }}</span> |
|||
@if (opt.description) { |
|||
<span class="option-desc">{{ opt.description }}</span> |
|||
} |
|||
</button> |
|||
} |
|||
</div> |
|||
} |
|||
|
|||
@if (step.recommendation) { |
|||
<div class="recommendation-card"> |
|||
<div class="rec-header">{{ step.recommendation.title }}</div> |
|||
<div class="rec-description">{{ step.recommendation.description }}</div> |
|||
<div class="allocation-bars"> |
|||
@for (alloc of step.recommendation.allocations; track alloc.asset) { |
|||
<div class="alloc-row"> |
|||
<span class="alloc-label">{{ alloc.asset }}</span> |
|||
<div class="alloc-bar-bg"> |
|||
<div |
|||
class="alloc-bar" |
|||
[style.background]="alloc.color" |
|||
[style.width.%]="alloc.percent" |
|||
></div> |
|||
</div> |
|||
<span class="alloc-pct">{{ alloc.percent }}%</span> |
|||
</div> |
|||
} |
|||
</div> |
|||
<div class="rec-risk"> |
|||
Risk Level: {{ step.recommendation.riskLevel }} |
|||
</div> |
|||
</div> |
|||
} |
|||
</div> |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
|
|||
import { StrategyStep } from '../../models/strategy-flow.types'; |
|||
|
|||
@Component({ |
|||
imports: [CommonModule], |
|||
selector: 'gf-strategy-card', |
|||
styleUrls: ['./strategy-card.component.scss'], |
|||
templateUrl: './strategy-card.component.html' |
|||
}) |
|||
export class GfStrategyCardComponent { |
|||
@Input() step: StrategyStep; |
|||
@Input() disabled = false; |
|||
@Output() optionSelected = new EventEmitter<string>(); |
|||
|
|||
public selectOption(value: string): void { |
|||
if (!this.disabled) { |
|||
this.optionSelected.emit(value); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,261 @@ |
|||
import { |
|||
StrategyRecommendation, |
|||
StrategyStep, |
|||
StrategyStepId |
|||
} from './strategy-flow.types'; |
|||
|
|||
const STEP_ORDER: StrategyStepId[] = [ |
|||
'goals', |
|||
'investmentAmount', |
|||
'timeHorizon', |
|||
'riskAppetite', |
|||
'experience' |
|||
]; |
|||
|
|||
const STEPS: Record<StrategyStepId, StrategyStep> = { |
|||
goals: { |
|||
stepId: 'goals', |
|||
question: "What's your primary investment goal?", |
|||
options: [ |
|||
{ |
|||
label: 'Retire Early', |
|||
value: 'retire_early', |
|||
description: 'Build enough wealth to stop working before 60' |
|||
}, |
|||
{ |
|||
label: 'Grow Wealth', |
|||
value: 'grow_wealth', |
|||
description: 'Maximize long-term portfolio growth' |
|||
}, |
|||
{ |
|||
label: 'Generate Income', |
|||
value: 'generate_income', |
|||
description: 'Create a steady stream of passive income' |
|||
}, |
|||
{ |
|||
label: 'Preserve Capital', |
|||
value: 'preserve_capital', |
|||
description: 'Protect what you have from inflation and loss' |
|||
} |
|||
] |
|||
}, |
|||
investmentAmount: { |
|||
stepId: 'investmentAmount', |
|||
question: 'How much do you have to invest?', |
|||
options: [ |
|||
{ |
|||
label: 'Under $1K', |
|||
value: 'under_1k', |
|||
description: 'Just getting started with a small amount' |
|||
}, |
|||
{ |
|||
label: '$1K - $10K', |
|||
value: '1k_10k', |
|||
description: 'Building a meaningful starter portfolio' |
|||
}, |
|||
{ |
|||
label: '$10K - $50K', |
|||
value: '10k_50k', |
|||
description: 'Enough for a diversified portfolio' |
|||
}, |
|||
{ |
|||
label: '$50K - $100K', |
|||
value: '50k_100k', |
|||
description: 'Substantial capital for broad allocation' |
|||
}, |
|||
{ |
|||
label: '$100K+', |
|||
value: '100k_plus', |
|||
description: 'Significant assets requiring careful management' |
|||
} |
|||
] |
|||
}, |
|||
timeHorizon: { |
|||
stepId: 'timeHorizon', |
|||
question: "What's your investment time horizon?", |
|||
options: [ |
|||
{ |
|||
label: 'Less than 1 year', |
|||
value: 'lt_1y', |
|||
description: 'Short-term — need access to funds soon' |
|||
}, |
|||
{ |
|||
label: '1 - 5 years', |
|||
value: '1_5y', |
|||
description: 'Medium-term — saving for a goal in a few years' |
|||
}, |
|||
{ |
|||
label: '5 - 15 years', |
|||
value: '5_15y', |
|||
description: 'Long-term — growing wealth over time' |
|||
}, |
|||
{ |
|||
label: '15+ years', |
|||
value: '15y_plus', |
|||
description: 'Very long-term — retirement or generational wealth' |
|||
} |
|||
] |
|||
}, |
|||
riskAppetite: { |
|||
stepId: 'riskAppetite', |
|||
question: 'How would you feel if your portfolio dropped 20% in a month?', |
|||
options: [ |
|||
{ |
|||
label: 'Panic sell', |
|||
value: 'panic_sell', |
|||
description: "I'd sell immediately to stop the bleeding" |
|||
}, |
|||
{ |
|||
label: 'Worried but hold', |
|||
value: 'worried_hold', |
|||
description: "I'd be stressed but wouldn't sell" |
|||
}, |
|||
{ |
|||
label: 'Stay the course', |
|||
value: 'stay_course', |
|||
description: "Downturns are normal, I'd do nothing" |
|||
}, |
|||
{ |
|||
label: 'Buy the dip', |
|||
value: 'buy_dip', |
|||
description: "I'd see it as a buying opportunity" |
|||
} |
|||
] |
|||
}, |
|||
experience: { |
|||
stepId: 'experience', |
|||
question: 'How experienced are you with investing?', |
|||
options: [ |
|||
{ |
|||
label: 'Beginner', |
|||
value: 'beginner', |
|||
description: 'New to investing, learning the basics' |
|||
}, |
|||
{ |
|||
label: 'Some experience', |
|||
value: 'some_experience', |
|||
description: "I've made a few trades or own some funds" |
|||
}, |
|||
{ |
|||
label: 'Intermediate', |
|||
value: 'intermediate', |
|||
description: 'Comfortable with stocks, ETFs, and diversification' |
|||
}, |
|||
{ |
|||
label: 'Expert', |
|||
value: 'expert', |
|||
description: |
|||
'Deep knowledge of markets, options, and advanced strategies' |
|||
} |
|||
] |
|||
}, |
|||
recommendation: { |
|||
stepId: 'recommendation', |
|||
question: '' |
|||
} |
|||
}; |
|||
|
|||
export function getStep(stepId: StrategyStepId): StrategyStep { |
|||
return STEPS[stepId]; |
|||
} |
|||
|
|||
export function getFirstStep(): StrategyStep { |
|||
return STEPS[STEP_ORDER[0]]; |
|||
} |
|||
|
|||
export function getNextStepId( |
|||
currentStepId: StrategyStepId |
|||
): StrategyStepId | null { |
|||
const idx = STEP_ORDER.indexOf(currentStepId); |
|||
if (idx < 0 || idx >= STEP_ORDER.length - 1) { |
|||
return 'recommendation'; |
|||
} |
|||
return STEP_ORDER[idx + 1]; |
|||
} |
|||
|
|||
export function computeRecommendation( |
|||
answers: Record<string, string> |
|||
): StrategyRecommendation { |
|||
let riskScore = 0; |
|||
|
|||
// Goals scoring
|
|||
const goalScores: Record<string, number> = { |
|||
retire_early: 2, |
|||
grow_wealth: 3, |
|||
generate_income: 1, |
|||
preserve_capital: 0 |
|||
}; |
|||
riskScore += goalScores[answers['goals']] ?? 1; |
|||
|
|||
// Time horizon scoring
|
|||
const horizonScores: Record<string, number> = { |
|||
lt_1y: 0, |
|||
'1_5y': 1, |
|||
'5_15y': 2, |
|||
'15y_plus': 3 |
|||
}; |
|||
riskScore += horizonScores[answers['timeHorizon']] ?? 1; |
|||
|
|||
// Risk appetite scoring
|
|||
const riskScores: Record<string, number> = { |
|||
panic_sell: 0, |
|||
worried_hold: 1, |
|||
stay_course: 2, |
|||
buy_dip: 3 |
|||
}; |
|||
riskScore += riskScores[answers['riskAppetite']] ?? 1; |
|||
|
|||
// Experience scoring
|
|||
const expScores: Record<string, number> = { |
|||
beginner: 0, |
|||
some_experience: 1, |
|||
intermediate: 2, |
|||
expert: 3 |
|||
}; |
|||
riskScore += expScores[answers['experience']] ?? 1; |
|||
|
|||
// Determine profile (max score = 12)
|
|||
if (riskScore <= 4) { |
|||
return { |
|||
title: 'Conservative: Dividend Income Portfolio', |
|||
description: |
|||
'Based on your preference for capital preservation and shorter time horizon, a conservative allocation focused on income-generating assets is recommended.', |
|||
allocations: [ |
|||
{ asset: 'Bonds (BND/AGG)', percent: 40, color: '#6b8cae' }, |
|||
{ asset: 'Dividend Stocks (VYM/SCHD)', percent: 25, color: '#33ff99' }, |
|||
{ asset: 'REITs (VNQ)', percent: 15, color: '#ff6600' }, |
|||
{ asset: 'Money Market/Cash', percent: 10, color: '#888' }, |
|||
{ asset: 'International Bonds (BNDX)', percent: 10, color: '#ffb84d' } |
|||
], |
|||
riskLevel: 'Conservative' |
|||
}; |
|||
} else if (riskScore <= 8) { |
|||
return { |
|||
title: 'Balanced: Core Satellite Strategy', |
|||
description: |
|||
'Your moderate risk tolerance and medium-to-long time horizon suit a balanced approach — broad index funds at the core with selective growth positions.', |
|||
allocations: [ |
|||
{ asset: 'US Total Market (VTI)', percent: 35, color: '#33ff99' }, |
|||
{ asset: 'International (VXUS)', percent: 20, color: '#6b8cae' }, |
|||
{ asset: 'Bonds (BND)', percent: 20, color: '#ffb84d' }, |
|||
{ asset: 'Growth ETFs (QQQ/VUG)', percent: 15, color: '#ff6600' }, |
|||
{ asset: 'REITs/Alternatives', percent: 10, color: '#888' } |
|||
], |
|||
riskLevel: 'Moderate' |
|||
}; |
|||
} else { |
|||
return { |
|||
title: 'Aggressive: Momentum Growth Portfolio', |
|||
description: |
|||
'Your high risk tolerance, long time horizon, and experience support an aggressive growth strategy focused on high-beta assets and sector concentration.', |
|||
allocations: [ |
|||
{ asset: 'Growth ETFs (QQQ/VUG)', percent: 30, color: '#ff6600' }, |
|||
{ asset: 'US Total Market (VTI)', percent: 25, color: '#33ff99' }, |
|||
{ asset: 'Sector ETFs (XLK/XLF)', percent: 20, color: '#ffb84d' }, |
|||
{ asset: 'International Growth', percent: 15, color: '#6b8cae' }, |
|||
{ asset: 'Small Cap (VB/IWM)', percent: 10, color: '#888' } |
|||
], |
|||
riskLevel: 'Aggressive' |
|||
}; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
Loading…
Reference in new issue