mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
423 lines
12 KiB
423 lines
12 KiB
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,
|
|
Component,
|
|
ElementRef,
|
|
Inject,
|
|
OnDestroy,
|
|
OnInit,
|
|
ViewChild
|
|
} from '@angular/core';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Subscription } from 'rxjs';
|
|
|
|
import { AiMarkdownPipe } from './ai-markdown.pipe';
|
|
|
|
interface ChatMessage {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
toolsUsed?: string[];
|
|
confidence?: number;
|
|
latency?: number;
|
|
feedbackGiven?: 1 | -1 | null;
|
|
isWrite?: boolean;
|
|
}
|
|
|
|
interface AgentResponse {
|
|
response: string;
|
|
confidence_score: number;
|
|
awaiting_confirmation: boolean;
|
|
pending_write: Record<string, unknown> | null;
|
|
tools_used: string[];
|
|
latency_seconds: number;
|
|
}
|
|
|
|
const HISTORY_KEY = 'portfolioAssistantHistory';
|
|
|
|
@Component({
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [CommonModule, FormsModule, HttpClientModule, AiMarkdownPipe],
|
|
selector: 'gf-ai-chat',
|
|
styleUrls: ['./ai-chat.component.scss'],
|
|
templateUrl: './ai-chat.component.html'
|
|
})
|
|
export class GfAiChatComponent implements OnInit, OnDestroy {
|
|
@ViewChild('messagesContainer') private messagesContainer: ElementRef;
|
|
|
|
public isOpen = false;
|
|
public isThinking = false;
|
|
public inputValue = '';
|
|
public messages: ChatMessage[] = [];
|
|
public successBanner = '';
|
|
public showSeedBanner = false;
|
|
public isSeeding = false;
|
|
public enableRealEstate: boolean;
|
|
|
|
// Write confirmation state
|
|
private pendingWrite: Record<string, unknown> | null = null;
|
|
public awaitingConfirmation = false;
|
|
|
|
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 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: this.welcomeMessage });
|
|
}
|
|
this.changeDetectorRef.markForCheck();
|
|
if (this.isOpen) {
|
|
setTimeout(() => this.scrollToBottom(), 50);
|
|
}
|
|
}
|
|
|
|
public closePanel(): void {
|
|
this.isOpen = false;
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Messaging
|
|
// ---------------------------------------------------------------------------
|
|
|
|
public onKeydown(event: KeyboardEvent): void {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
this.sendMessage();
|
|
}
|
|
}
|
|
|
|
public sendMessage(): void {
|
|
const query = this.inputValue.trim();
|
|
if (!query || this.isThinking) {
|
|
return;
|
|
}
|
|
this.inputValue = '';
|
|
this.doSend(query);
|
|
}
|
|
|
|
public confirmWrite(): void {
|
|
this.doSend('yes');
|
|
}
|
|
|
|
public cancelWrite(): void {
|
|
this.doSend('no');
|
|
}
|
|
|
|
private doSend(query: string): void {
|
|
this.messages.push({ role: 'user', content: query });
|
|
this.isThinking = true;
|
|
this.successBanner = '';
|
|
this.changeDetectorRef.markForCheck();
|
|
this.scrollToBottom();
|
|
|
|
const body: {
|
|
query: string;
|
|
history: { role: string; content: string }[];
|
|
pending_write?: Record<string, unknown>;
|
|
bearer_token?: string;
|
|
} = {
|
|
query,
|
|
history: this.messages
|
|
.filter((m) => m.role === 'user')
|
|
.map((m) => ({ role: 'user', content: m.content }))
|
|
};
|
|
|
|
if (this.pendingWrite) {
|
|
body.pending_write = this.pendingWrite;
|
|
}
|
|
|
|
const userToken = this.tokenStorageService.getToken();
|
|
if (userToken) {
|
|
body.bearer_token = userToken;
|
|
}
|
|
|
|
this.http.post<AgentResponse>(this.AGENT_URL, body).subscribe({
|
|
next: (data) => {
|
|
const isWriteSuccess =
|
|
data.tools_used.includes('write_transaction') &&
|
|
data.response.includes('✅');
|
|
|
|
const assistantMsg: ChatMessage = {
|
|
role: 'assistant',
|
|
content: data.response,
|
|
toolsUsed: data.tools_used,
|
|
confidence: data.confidence_score,
|
|
latency: data.latency_seconds,
|
|
feedbackGiven: null,
|
|
isWrite: isWriteSuccess
|
|
};
|
|
|
|
this.messages.push(assistantMsg);
|
|
this.awaitingConfirmation = data.awaiting_confirmation;
|
|
this.pendingWrite = data.pending_write;
|
|
|
|
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)
|
|
);
|
|
if (isEmptyPortfolio && !this.showSeedBanner) {
|
|
this.showSeedBanner = true;
|
|
}
|
|
|
|
if (isWriteSuccess) {
|
|
this.successBanner = '✅ Transaction recorded successfully';
|
|
setTimeout(() => {
|
|
this.successBanner = '';
|
|
this.changeDetectorRef.markForCheck();
|
|
}, 4000);
|
|
}
|
|
|
|
this.isThinking = false;
|
|
this.saveHistory();
|
|
this.changeDetectorRef.markForCheck();
|
|
this.scrollToBottom();
|
|
},
|
|
error: (err) => {
|
|
this.messages.push({
|
|
role: 'assistant',
|
|
content: `⚠️ Connection error: ${err.message || 'Could not reach the AI agent'}. Make sure the agent is running on port 8000.`
|
|
});
|
|
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;
|
|
this.changeDetectorRef.markForCheck();
|
|
|
|
const body: { bearer_token?: string } = {};
|
|
const userToken = this.tokenStorageService.getToken();
|
|
if (userToken) {
|
|
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.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:
|
|
'⚠️ Could not reach the seeding endpoint. Make sure the agent is running.'
|
|
});
|
|
this.saveHistory();
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 : '';
|
|
|
|
this.http
|
|
.post(this.FEEDBACK_URL, {
|
|
query: userQuery,
|
|
response: msg.content,
|
|
rating
|
|
})
|
|
.subscribe();
|
|
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Confidence helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
public confidenceLabel(score: number): string {
|
|
if (score >= 0.8) {
|
|
return 'High';
|
|
}
|
|
if (score >= 0.6) {
|
|
return 'Medium';
|
|
}
|
|
return 'Low';
|
|
}
|
|
|
|
public confidenceClass(score: number): string {
|
|
if (score >= 0.8) {
|
|
return 'confidence-high';
|
|
}
|
|
if (score >= 0.6) {
|
|
return 'confidence-medium';
|
|
}
|
|
return 'confidence-low';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scroll
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private scrollToBottom(): void {
|
|
setTimeout(() => {
|
|
if (this.messagesContainer?.nativeElement) {
|
|
const el = this.messagesContainer.nativeElement;
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}, 30);
|
|
}
|
|
}
|
|
|