Browse Source

feat: add persistent chat history and clear button

- ngOnInit restores conversation from sessionStorage on mount
- saveHistory() persists every message exchange automatically
- clearHistory() resets to welcome message and wipes stored history
- Clear button added to panel header (small, subtle, matches dark theme)
- welcomeMessage getter adapts copy based on enableRealEstate flag

Made-with: Cursor
pull/6453/head
Priyanka Punukollu 1 month ago
parent
commit
3381967164
  1. 87
      agent/scripts/seed_portfolio.py
  2. 159
      apps/client/src/app/components/ai-chat/ai-chat.component.html
  3. 136
      apps/client/src/app/components/ai-chat/ai-chat.component.scss
  4. 208
      apps/client/src/app/components/ai-chat/ai-chat.component.ts
  5. 2
      apps/client/src/app/components/ai-chat/ai-markdown.pipe.ts
  6. 21
      apps/client/src/app/components/header/header.component.html
  7. 16
      apps/client/src/app/components/header/header.component.ts
  8. 20
      apps/client/src/app/services/ai-chat.service.ts
  9. 1
      apps/client/src/environments/environment.prod.ts
  10. 1
      apps/client/src/environments/environment.ts
  11. 1
      libs/ui/src/lib/environment/environment.interface.ts

87
agent/scripts/seed_portfolio.py

@ -0,0 +1,87 @@
"""
seed_portfolio.py Seed a Ghostfolio account with demo holdings.
Usage:
python scripts/seed_portfolio.py
Environment variables:
GHOSTFOLIO_BASE_URL (default: http://localhost:3333)
GHOSTFOLIO_BEARER_TOKEN (required)
"""
import asyncio
import os
import sys
import httpx
GHOSTFOLIO_URL = os.getenv("GHOSTFOLIO_BASE_URL", "http://localhost:3333")
TOKEN = os.getenv("GHOSTFOLIO_BEARER_TOKEN", "")
async def seed() -> None:
if not TOKEN:
print("ERROR: GHOSTFOLIO_BEARER_TOKEN is not set.", file=sys.stderr)
sys.exit(1)
holdings = [
{
"symbol": "AAPL",
"quantity": 10,
"unitPrice": 150.00,
"date": "2024-01-15T00:00:00.000Z",
"type": "BUY",
"currency": "USD",
"dataSource": "YAHOO",
"fee": 4.95,
},
{
"symbol": "MSFT",
"quantity": 5,
"unitPrice": 300.00,
"date": "2024-03-10T00:00:00.000Z",
"type": "BUY",
"currency": "USD",
"dataSource": "YAHOO",
"fee": 4.95,
},
{
"symbol": "NVDA",
"quantity": 3,
"unitPrice": 400.00,
"date": "2024-06-01T00:00:00.000Z",
"type": "BUY",
"currency": "USD",
"dataSource": "YAHOO",
"fee": 4.95,
},
{
"symbol": "TSLA",
"quantity": 2,
"unitPrice": 200.00,
"date": "2024-09-20T00:00:00.000Z",
"type": "BUY",
"currency": "USD",
"dataSource": "YAHOO",
"fee": 4.95,
},
]
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{GHOSTFOLIO_URL}/api/v1/import",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"activities": holdings},
)
print(f"Seed result: {resp.status_code}")
if resp.status_code == 201:
print(f"SUCCESS — imported {len(holdings)} transactions.")
for h in holdings:
print(f"{h['type']} {h['quantity']} {h['symbol']} @ ${h['unitPrice']}")
else:
print(f"RESPONSE: {resp.text}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(seed())

159
apps/client/src/app/components/ai-chat/ai-chat.component.html

@ -1,17 +1,23 @@
<!-- Floating trigger button -->
<button
aria-label="Open Portfolio Assistant"
class="ai-fab"
[class.ai-fab--open]="isOpen"
(click)="togglePanel()"
aria-label="Open Portfolio Assistant"
>
<span class="ai-fab__icon">🤖</span>
<span class="ai-fab__label" [class.ai-fab__label--hidden]="isOpen">Ask AI</span>
<span class="ai-fab__label" [class.ai-fab__label--hidden]="isOpen"
>Ask AI</span
>
</button>
<!-- Slide-in panel -->
<div class="ai-panel" [class.ai-panel--visible]="isOpen" role="complementary" aria-label="Portfolio Assistant">
<div
aria-label="Portfolio Assistant"
class="ai-panel"
role="complementary"
[class.ai-panel--visible]="isOpen"
>
<!-- Panel header -->
<div class="ai-panel__header">
<div class="ai-panel__header-left">
@ -21,7 +27,19 @@
<span class="ai-panel__subtitle">Powered by Claude</span>
</div>
</div>
<button class="ai-panel__close" (click)="closePanel()" aria-label="Close"></button>
<div class="ai-panel__header-actions">
<button
aria-label="Clear chat history"
class="ai-clear-btn"
title="Clear chat history"
(click)="clearHistory()"
>
Clear
</button>
<button aria-label="Close" class="ai-panel__close" (click)="closePanel()">
</button>
</div>
</div>
<!-- Success banner -->
@ -34,8 +52,18 @@
<div class="ai-banner ai-banner--seed">
<span>Your portfolio is empty. Load demo data to try the AI?</span>
<div class="ai-banner__actions">
<button class="ai-banner__btn ai-banner__btn--primary" (click)="seedPortfolio()">🌱 Load demo data</button>
<button class="ai-banner__btn ai-banner__btn--dismiss" (click)="showSeedBanner = false">Dismiss</button>
<button
class="ai-banner__btn ai-banner__btn--primary"
(click)="seedPortfolio()"
>
🌱 Load demo data
</button>
<button
class="ai-banner__btn ai-banner__btn--dismiss"
(click)="showSeedBanner = false"
>
Dismiss
</button>
</div>
</div>
}
@ -44,26 +72,32 @@
}
<!-- Messages area -->
<div class="ai-messages" #messagesContainer>
<div #messagesContainer class="ai-messages">
@for (msg of messages; track $index) {
<div class="ai-message" [class.ai-message--user]="msg.role === 'user'" [class.ai-message--assistant]="msg.role === 'assistant'">
<div
class="ai-message"
[class.ai-message--assistant]="msg.role === 'assistant'"
[class.ai-message--user]="msg.role === 'user'"
>
<!-- Bubble -->
<div class="ai-bubble" [innerHTML]="msg.content | aiMarkdown"></div>
<!-- Assistant metadata row -->
@if (msg.role === 'assistant' && msg.confidence !== undefined) {
<div class="ai-meta">
<!-- Low confidence warning -->
@if (msg.confidence < 0.6) {
<div class="ai-meta__warning">⚠️ Low confidence — some data may be incomplete</div>
<div class="ai-meta__warning">
⚠️ Low confidence — some data may be incomplete
</div>
}
<div class="ai-meta__row">
<!-- Confidence badge -->
<span class="ai-badge" [class]="confidenceClass(msg.confidence)">
{{ confidenceLabel(msg.confidence) }} ({{ (msg.confidence * 100).toFixed(0) }}%)
{{ confidenceLabel(msg.confidence) }} ({{
(msg.confidence * 100).toFixed(0)
}}%)
</span>
<!-- Tools used chips -->
@ -72,30 +106,36 @@
}
<!-- Latency -->
<span class="ai-meta__latency">{{ msg.latency?.toFixed(1) }}s</span>
<span class="ai-meta__latency"
>{{ msg.latency?.toFixed(1) }}s</span
>
<!-- Feedback -->
<div class="ai-feedback">
<button
aria-label="Thumbs up"
class="ai-feedback__btn"
[class.ai-feedback__btn--active-up]="msg.feedbackGiven === 1"
[disabled]="msg.feedbackGiven !== null"
(click)="giveFeedback($index, 1)"
aria-label="Thumbs up"
>👍</button>
>
👍
</button>
<button
aria-label="Thumbs down"
class="ai-feedback__btn"
[class.ai-feedback__btn--active-down]="msg.feedbackGiven === -1"
[class.ai-feedback__btn--active-down]="
msg.feedbackGiven === -1
"
[disabled]="msg.feedbackGiven !== null"
(click)="giveFeedback($index, -1)"
aria-label="Thumbs down"
>👎</button>
>
👎
</button>
</div>
</div>
</div>
}
</div>
}
@ -111,12 +151,76 @@
}
</div>
<!-- Suggestion chips (visible only before first user message) -->
@if (showSuggestions && !isThinking) {
<div class="ai-suggestions">
<!-- Row 1: Portfolio chips — always shown -->
<div class="ai-suggestions__row">
<button
class="ai-suggestion-chip"
(click)="clickChip('How is my portfolio doing?')"
>
📈 My portfolio performance
</button>
<button
class="ai-suggestion-chip"
(click)="clickChip('Do I have any concentration risk?')"
>
⚠️ Any concentration risk?
</button>
<button
class="ai-suggestion-chip"
(click)="clickChip('Estimate my taxes for this year')"
>
💰 Estimate my taxes
</button>
</div>
<!-- Row 2: Real Estate chips — only when feature flag is on -->
@if (enableRealEstate) {
<div class="ai-suggestions__row ai-suggestions__row--realestate">
<button
class="ai-suggestion-chip ai-suggestion-chip--re"
(click)="clickChip('Show me Austin homes under $500k')"
>
🏠 Austin under $500k
</button>
<button
class="ai-suggestion-chip ai-suggestion-chip--re"
(click)="
clickChip('Compare Austin vs Denver for housing affordability')
"
>
📊 Austin vs Denver
</button>
<button
class="ai-suggestion-chip ai-suggestion-chip--re"
(click)="
clickChip('Give me a neighborhood snapshot for San Francisco')
"
>
🏘️ SF snapshot
</button>
</div>
}
</div>
}
<!-- Confirmation buttons (shown when awaiting yes/no) -->
@if (awaitingConfirmation && !isThinking) {
<div class="ai-confirm-bar">
<span class="ai-confirm-bar__label">Confirm this transaction?</span>
<button class="ai-confirm-bar__btn ai-confirm-bar__btn--yes" (click)="confirmWrite()">✅ Confirm</button>
<button class="ai-confirm-bar__btn ai-confirm-bar__btn--no" (click)="cancelWrite()">❌ Cancel</button>
<button
class="ai-confirm-bar__btn ai-confirm-bar__btn--yes"
(click)="confirmWrite()"
>
✅ Confirm
</button>
<button
class="ai-confirm-bar__btn ai-confirm-bar__btn--no"
(click)="cancelWrite()"
>
❌ Cancel
</button>
</div>
}
@ -126,29 +230,28 @@
<textarea
#inputField
class="ai-input"
rows="1"
placeholder="Ask about your portfolio..."
rows="1"
[disabled]="isThinking"
[(ngModel)]="inputValue"
(keydown)="onKeydown($event)"
></textarea>
<button
aria-label="Send"
class="ai-send"
[disabled]="isThinking || !inputValue.trim()"
(click)="sendMessage()"
aria-label="Send"
>
@if (isThinking) {
<span class="ai-send__spinner"></span>
} @else {
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
<svg fill="currentColor" height="18" viewBox="0 0 24 24" width="18">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
}
</button>
</div>
}
</div>
<!-- Backdrop (mobile) -->

136
apps/client/src/app/components/ai-chat/ai-chat.component.scss

@ -21,7 +21,9 @@
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
white-space: nowrap;
&:hover {
@ -123,6 +125,32 @@
letter-spacing: 0.03em;
}
.ai-panel__header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.ai-clear-btn {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0.4rem;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
font-size: 0.7rem;
font-weight: 500;
padding: 0.25rem 0.55rem;
letter-spacing: 0.02em;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
}
.ai-panel__close {
background: rgba(255, 255, 255, 0.15);
border: none;
@ -194,7 +222,9 @@
cursor: pointer;
transition: opacity 0.15s;
&:hover { opacity: 0.85; }
&:hover {
opacity: 0.85;
}
&--primary {
background: #2563eb;
@ -322,7 +352,9 @@
}
@keyframes typingBounce {
0%, 60%, 100% {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.4;
}
@ -427,7 +459,9 @@
padding: 0.1rem;
border-radius: 0.25rem;
opacity: 0.5;
transition: opacity 0.15s ease, background 0.15s ease;
transition:
opacity 0.15s ease,
background 0.15s ease;
&:hover:not([disabled]) {
opacity: 1;
@ -486,7 +520,9 @@
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: transform 0.15s ease, opacity 0.15s ease;
transition:
transform 0.15s ease,
opacity 0.15s ease;
&:hover {
transform: translateY(-1px);
@ -509,6 +545,92 @@
}
}
// ---------------------------------------------------------------------------
// Suggestion chips
// ---------------------------------------------------------------------------
.ai-suggestions {
padding: 0.5rem 0.75rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
flex-shrink: 0;
border-top: 1px solid rgba(0, 0, 0, 0.06);
:host-context(.theme-dark) & {
border-top-color: rgba(255, 255, 255, 0.06);
}
&__row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
&--realestate {
padding-top: 0.1rem;
}
}
}
.ai-suggestion-chip {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.65rem;
border-radius: 1.5rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s ease,
transform 0.1s ease,
opacity 0.15s ease;
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(99, 102, 241, 0.08);
color: #4f46e5;
white-space: nowrap;
:host-context(.theme-dark) & {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.35);
}
&:hover {
background: rgba(99, 102, 241, 0.16);
transform: translateY(-1px);
:host-context(.theme-dark) & {
background: rgba(99, 102, 241, 0.28);
}
}
&:active {
transform: translateY(0);
opacity: 0.85;
}
// Real estate row chips slightly warmer tint to distinguish from portfolio
&--re {
border-color: rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.07);
color: #047857;
:host-context(.theme-dark) & {
background: rgba(16, 185, 129, 0.15);
color: #6ee7b7;
border-color: rgba(16, 185, 129, 0.35);
}
&:hover {
background: rgba(16, 185, 129, 0.15);
:host-context(.theme-dark) & {
background: rgba(16, 185, 129, 0.25);
}
}
}
}
// ---------------------------------------------------------------------------
// Input area
// ---------------------------------------------------------------------------
@ -578,7 +700,9 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s ease, opacity 0.15s ease;
transition:
transform 0.15s ease,
opacity 0.15s ease;
&:hover:not([disabled]) {
transform: scale(1.05);

208
apps/client/src/app/components/ai-chat/ai-chat.component.ts

@ -1,3 +1,10 @@
import { AiChatService } from '@ghostfolio/client/services/ai-chat.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { GfEnvironment } from '@ghostfolio/ui/environment';
import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { CommonModule } from '@angular/common';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -5,14 +12,11 @@ import {
ElementRef,
Inject,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { GfEnvironment } from '@ghostfolio/ui/environment';
import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { Subscription } from 'rxjs';
import { AiMarkdownPipe } from './ai-markdown.pipe';
@ -35,6 +39,8 @@ interface AgentResponse {
latency_seconds: number;
}
const HISTORY_KEY = 'portfolioAssistantHistory';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, HttpClientModule, AiMarkdownPipe],
@ -42,7 +48,7 @@ interface AgentResponse {
styleUrls: ['./ai-chat.component.scss'],
templateUrl: './ai-chat.component.html'
})
export class GfAiChatComponent implements OnDestroy {
export class GfAiChatComponent implements OnInit, OnDestroy {
@ViewChild('messagesContainer') private messagesContainer: ElementRef;
public isOpen = false;
@ -52,6 +58,7 @@ export class GfAiChatComponent implements OnDestroy {
public successBanner = '';
public showSeedBanner = false;
public isSeeding = false;
public enableRealEstate: boolean;
// Write confirmation state
private pendingWrite: Record<string, unknown> | null = null;
@ -60,29 +67,119 @@ export class GfAiChatComponent implements OnDestroy {
private readonly AGENT_URL: string;
private readonly FEEDBACK_URL: string;
private readonly SEED_URL: string;
private aiChatSubscription: Subscription;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private http: HttpClient,
private tokenStorageService: TokenStorageService,
private aiChatService: AiChatService,
@Inject(GF_ENVIRONMENT) environment: GfEnvironment
) {
const base = (environment.agentUrl ?? '/agent').replace(/\/$/, '');
this.AGENT_URL = `${base}/chat`;
this.FEEDBACK_URL = `${base}/feedback`;
this.SEED_URL = `${base}/seed`;
this.enableRealEstate = environment.enableRealEstate ?? false;
}
public ngOnInit(): void {
const saved = sessionStorage.getItem(HISTORY_KEY);
if (saved) {
try {
this.messages = JSON.parse(saved);
} catch {
this.messages = [];
}
}
// Listen for external open-with-query events (e.g. from Real Estate nav item)
this.aiChatSubscription = this.aiChatService.openWithQuery.subscribe(
(query) => {
if (!this.isOpen) {
this.openPanel();
}
// Small delay so the panel transition completes before firing the query
setTimeout(() => {
this.doSend(query);
this.changeDetectorRef.markForCheck();
}, 150);
}
);
}
public ngOnDestroy(): void {
this.aiChatSubscription?.unsubscribe();
}
// ---------------------------------------------------------------------------
// Welcome message (changes with real-estate flag)
// ---------------------------------------------------------------------------
public get welcomeMessage(): string {
if (this.enableRealEstate) {
return (
"Hello! I'm your Portfolio Assistant, powered by Claude. " +
'Ask me about your portfolio performance, transactions, or tax estimates — ' +
'or explore housing markets and compare neighborhoods. ' +
'Use the chips below to get started.'
);
}
return (
"Hello! I'm your Portfolio Assistant. " +
'Ask me about your portfolio performance, transactions, tax estimates, ' +
'or use commands like "buy 5 shares of AAPL" to record transactions.'
);
}
// ---------------------------------------------------------------------------
// History management
// ---------------------------------------------------------------------------
private saveHistory(): void {
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(this.messages));
}
public clearHistory(): void {
this.messages = [{ role: 'assistant', content: this.welcomeMessage }];
sessionStorage.removeItem(HISTORY_KEY);
this.awaitingConfirmation = false;
this.pendingWrite = null;
this.successBanner = '';
this.showSeedBanner = false;
this.changeDetectorRef.markForCheck();
}
// ---------------------------------------------------------------------------
// Suggestion chips
// ---------------------------------------------------------------------------
public get showSuggestions(): boolean {
return this.messages.length <= 1;
}
public ngOnDestroy() {}
public clickChip(text: string): void {
this.inputValue = text;
this.sendMessage();
}
// ---------------------------------------------------------------------------
// Panel open / close
// ---------------------------------------------------------------------------
private openPanel(): void {
this.isOpen = true;
if (this.messages.length === 0) {
this.messages.push({ role: 'assistant', content: this.welcomeMessage });
}
this.changeDetectorRef.markForCheck();
setTimeout(() => this.scrollToBottom(), 50);
}
public togglePanel(): void {
this.isOpen = !this.isOpen;
if (this.isOpen && this.messages.length === 0) {
this.messages.push({
role: 'assistant',
content:
'Hello! I\'m your Portfolio Assistant. Ask me about your portfolio performance, transactions, tax estimates, or use commands like "buy 5 shares of AAPL" to record transactions.'
});
this.messages.push({ role: 'assistant', content: this.welcomeMessage });
}
this.changeDetectorRef.markForCheck();
if (this.isOpen) {
@ -95,6 +192,10 @@ export class GfAiChatComponent implements OnDestroy {
this.changeDetectorRef.markForCheck();
}
// ---------------------------------------------------------------------------
// Messaging
// ---------------------------------------------------------------------------
public onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
@ -142,9 +243,6 @@ export class GfAiChatComponent implements OnDestroy {
body.pending_write = this.pendingWrite;
}
// Send the logged-in user's token so the agent uses their own data.
// When not logged in, the field is omitted and the agent falls back to
// the shared env-var token (useful for demo/unauthenticated access).
const userToken = this.tokenStorageService.getToken();
if (userToken) {
body.bearer_token = userToken;
@ -170,8 +268,15 @@ export class GfAiChatComponent implements OnDestroy {
this.awaitingConfirmation = data.awaiting_confirmation;
this.pendingWrite = data.pending_write;
// Detect an empty portfolio and offer to seed demo data
const emptyPortfolioHints = ['0 holdings', '0 positions', 'no holdings', 'no positions', 'empty portfolio', 'no transactions', '0.00 (0.0%)'];
const emptyPortfolioHints = [
'0 holdings',
'0 positions',
'no holdings',
'no positions',
'empty portfolio',
'no transactions',
'0.00 (0.0%)'
];
const isEmptyPortfolio = emptyPortfolioHints.some((hint) =>
data.response.toLowerCase().includes(hint)
);
@ -188,6 +293,7 @@ export class GfAiChatComponent implements OnDestroy {
}
this.isThinking = false;
this.saveHistory();
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
},
@ -199,12 +305,17 @@ export class GfAiChatComponent implements OnDestroy {
this.isThinking = false;
this.awaitingConfirmation = false;
this.pendingWrite = null;
this.saveHistory();
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
}
});
}
// ---------------------------------------------------------------------------
// Seed portfolio
// ---------------------------------------------------------------------------
public seedPortfolio(): void {
this.isSeeding = true;
this.showSeedBanner = false;
@ -216,40 +327,51 @@ export class GfAiChatComponent implements OnDestroy {
body.bearer_token = userToken;
}
this.http.post<{ success: boolean; message: string }>(this.SEED_URL, body).subscribe({
next: (data) => {
this.isSeeding = false;
if (data.success) {
this.http
.post<{ success: boolean; message: string }>(this.SEED_URL, body)
.subscribe({
next: (data) => {
this.isSeeding = false;
if (data.success) {
this.messages.push({
role: 'assistant',
content: `🌱 **Demo portfolio loaded!** I've added 18 transactions across AAPL, MSFT, NVDA, GOOGL, AMZN, and VTI spanning 2021–2024. Try asking "how is my portfolio doing?" to see your analysis.`
});
} else {
this.messages.push({
role: 'assistant',
content: '⚠️ Could not load demo data. Please try again.'
});
}
this.saveHistory();
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
},
error: () => {
this.isSeeding = false;
this.messages.push({
role: 'assistant',
content: `🌱 **Demo portfolio loaded!** I've added 18 transactions across AAPL, MSFT, NVDA, GOOGL, AMZN, and VTI spanning 2021–2024. Try asking "how is my portfolio doing?" to see your analysis.`
content:
'⚠️ Could not reach the seeding endpoint. Make sure the agent is running.'
});
} else {
this.messages.push({ role: 'assistant', content: '⚠️ Could not load demo data. Please try again.' });
this.saveHistory();
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
},
error: () => {
this.isSeeding = false;
this.messages.push({ role: 'assistant', content: '⚠️ Could not reach the seeding endpoint. Make sure the agent is running.' });
this.changeDetectorRef.markForCheck();
}
});
});
}
public giveFeedback(
msgIndex: number,
rating: 1 | -1
): void {
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
public giveFeedback(msgIndex: number, rating: 1 | -1): void {
const msg = this.messages[msgIndex];
if (!msg || msg.feedbackGiven !== null) {
return;
}
msg.feedbackGiven = rating;
const userQuery =
msgIndex > 0 ? this.messages[msgIndex - 1].content : '';
const userQuery = msgIndex > 0 ? this.messages[msgIndex - 1].content : '';
this.http
.post(this.FEEDBACK_URL, {
@ -262,6 +384,10 @@ export class GfAiChatComponent implements OnDestroy {
this.changeDetectorRef.markForCheck();
}
// ---------------------------------------------------------------------------
// Confidence helpers
// ---------------------------------------------------------------------------
public confidenceLabel(score: number): string {
if (score >= 0.8) {
return 'High';
@ -282,6 +408,10 @@ export class GfAiChatComponent implements OnDestroy {
return 'confidence-low';
}
// ---------------------------------------------------------------------------
// Scroll
// ---------------------------------------------------------------------------
private scrollToBottom(): void {
setTimeout(() => {
if (this.messagesContainer?.nativeElement) {

2
apps/client/src/app/components/ai-chat/ai-markdown.pipe.ts

@ -31,7 +31,7 @@ export class AiMarkdownPipe implements PipeTransform {
// Horizontal rule ---
.replace(/^---+$/gm, '<hr>')
// Bullet lines starting with "- " or "* "
.replace(/^[*\-] (.+)$/gm, '<li>$1</li>')
.replace(/^[*-] (.+)$/gm, '<li>$1</li>')
// Wrap consecutive <li> in <ul>
.replace(/(<li>.*<\/li>(\n|$))+/g, (block) => `<ul>${block}</ul>`)
// Newlines → <br> (except inside <ul>)

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

@ -326,6 +326,15 @@
>About Ghostfolio</a
>
<hr class="d-flex d-sm-none m-0" />
@if (enableRealEstate) {
<button
class="d-flex d-sm-none"
mat-menu-item
(click)="openRealEstateChat()"
>
🏠 Real Estate
</button>
}
<button i18n mat-menu-item (click)="onSignOut()">Log out</button>
</mat-menu>
</li>
@ -409,6 +418,18 @@
>
</li>
}
@if (enableRealEstate && user) {
<li class="list-inline-item">
<button
class="d-none d-sm-block"
mat-flat-button
title="Explore housing markets with AI"
(click)="openRealEstateChat()"
>
🏠 Real Estate
</button>
</li>
}
<li class="list-inline-item">
<a
class="d-none d-sm-block no-min-width p-1"

16
apps/client/src/app/components/header/header.component.ts

@ -1,6 +1,7 @@
import { LoginWithAccessTokenDialogParams } from '@ghostfolio/client/components/login-with-access-token-dialog/interfaces/interfaces';
import { GfLoginWithAccessTokenDialogComponent } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { AiChatService } from '@ghostfolio/client/services/ai-chat.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
KEY_STAY_SIGNED_IN,
@ -14,6 +15,8 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { GfEnvironment } from '@ghostfolio/ui/environment';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -26,6 +29,7 @@ import {
CUSTOM_ELEMENTS_SCHEMA,
EventEmitter,
HostListener,
Inject,
Input,
OnChanges,
Output,
@ -102,6 +106,7 @@ export class GfHeaderComponent implements OnChanges {
@ViewChild('assistant') assistantElement: GfAssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public enableRealEstate: boolean;
public hasFilters: boolean;
public hasImpersonationId: boolean;
public hasPermissionForAuthGoogle: boolean;
@ -134,6 +139,7 @@ export class GfHeaderComponent implements OnChanges {
private unsubscribeSubject = new Subject<void>();
public constructor(
private aiChatService: AiChatService,
private dataService: DataService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
@ -142,8 +148,10 @@ export class GfHeaderComponent implements OnChanges {
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService,
private userService: UserService
private userService: UserService,
@Inject(GF_ENVIRONMENT) environment: GfEnvironment
) {
this.enableRealEstate = environment.enableRealEstate ?? false;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
@ -283,6 +291,12 @@ export class GfHeaderComponent implements OnChanges {
this.signOut.next();
}
public openRealEstateChat(): void {
this.aiChatService.openChat(
'Compare Austin vs Denver for housing affordability'
);
}
public openLoginDialog() {
const dialogRef = this.dialog.open<
GfLoginWithAccessTokenDialogComponent,

20
apps/client/src/app/services/ai-chat.service.ts

@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
/**
* Broadcast service that lets any component open the AI chat panel
* with an optional pre-filled query that auto-submits immediately.
* Used by the Real Estate header nav item (and any future trigger points).
*/
@Injectable({ providedIn: 'root' })
export class AiChatService {
private openWithQuery$ = new Subject<string>();
public get openWithQuery(): Observable<string> {
return this.openWithQuery$.asObservable();
}
public openChat(query: string): void {
this.openWithQuery$.next(query);
}
}

1
apps/client/src/environments/environment.prod.ts

@ -2,6 +2,7 @@ import type { GfEnvironment } from '@ghostfolio/ui/environment';
export const environment: GfEnvironment = {
agentUrl: 'https://ghostfolio-agent-production.up.railway.app',
enableRealEstate: true,
lastPublish: '{BUILD_TIMESTAMP}',
production: true
};

1
apps/client/src/environments/environment.ts

@ -6,6 +6,7 @@ import type { GfEnvironment } from '@ghostfolio/ui/environment';
export const environment: GfEnvironment = {
agentUrl: '/agent',
enableRealEstate: true,
lastPublish: null,
production: false
};

1
libs/ui/src/lib/environment/environment.interface.ts

@ -1,5 +1,6 @@
export interface GfEnvironment {
agentUrl?: string;
enableRealEstate?: boolean;
lastPublish: string | null;
production: boolean;
}

Loading…
Cancel
Save