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.
236 lines
6.2 KiB
236 lines
6.2 KiB
import {
|
|
ChangeDetectionStrategy,
|
|
ChangeDetectorRef,
|
|
Component,
|
|
ElementRef,
|
|
Inject,
|
|
OnDestroy,
|
|
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 { 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;
|
|
}
|
|
|
|
@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 OnDestroy {
|
|
@ViewChild('messagesContainer') private messagesContainer: ElementRef;
|
|
|
|
public isOpen = false;
|
|
public isThinking = false;
|
|
public inputValue = '';
|
|
public messages: ChatMessage[] = [];
|
|
public successBanner = '';
|
|
|
|
// Write confirmation state
|
|
private pendingWrite: Record<string, unknown> | null = null;
|
|
public awaitingConfirmation = false;
|
|
|
|
private readonly AGENT_URL: string;
|
|
private readonly FEEDBACK_URL: string;
|
|
|
|
public constructor(
|
|
private changeDetectorRef: ChangeDetectorRef,
|
|
private http: HttpClient,
|
|
@Inject(GF_ENVIRONMENT) environment: GfEnvironment
|
|
) {
|
|
const base = (environment.agentUrl ?? '/agent').replace(/\/$/, '');
|
|
this.AGENT_URL = `${base}/chat`;
|
|
this.FEEDBACK_URL = `${base}/feedback`;
|
|
}
|
|
|
|
public ngOnDestroy() {}
|
|
|
|
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.changeDetectorRef.markForCheck();
|
|
if (this.isOpen) {
|
|
setTimeout(() => this.scrollToBottom(), 50);
|
|
}
|
|
}
|
|
|
|
public closePanel(): void {
|
|
this.isOpen = false;
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
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>;
|
|
} = {
|
|
query,
|
|
history: this.messages
|
|
.filter((m) => m.role === 'user')
|
|
.map((m) => ({ role: 'user', content: m.content }))
|
|
};
|
|
|
|
if (this.pendingWrite) {
|
|
body.pending_write = this.pendingWrite;
|
|
}
|
|
|
|
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;
|
|
|
|
if (isWriteSuccess) {
|
|
this.successBanner = '✅ Transaction recorded successfully';
|
|
setTimeout(() => {
|
|
this.successBanner = '';
|
|
this.changeDetectorRef.markForCheck();
|
|
}, 4000);
|
|
}
|
|
|
|
this.isThinking = false;
|
|
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.changeDetectorRef.markForCheck();
|
|
this.scrollToBottom();
|
|
}
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
private scrollToBottom(): void {
|
|
setTimeout(() => {
|
|
if (this.messagesContainer?.nativeElement) {
|
|
const el = this.messagesContainer.nativeElement;
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}, 30);
|
|
}
|
|
}
|
|
|