@ -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 , On Destroy {
@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 ) {