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.
290 lines
7.6 KiB
290 lines
7.6 KiB
import { animate, style, transition, trigger } from '@angular/animations';
|
|
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
|
|
import {
|
|
ChangeDetectionStrategy,
|
|
ChangeDetectorRef,
|
|
Component,
|
|
CUSTOM_ELEMENTS_SCHEMA,
|
|
DoCheck,
|
|
EventEmitter,
|
|
HostListener,
|
|
Input,
|
|
OnDestroy,
|
|
OnInit,
|
|
Output
|
|
} from '@angular/core';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
|
import { IonIcon } from '@ionic/angular/standalone';
|
|
import { formatDistanceToNowStrict } from 'date-fns';
|
|
import { addIcons } from 'ionicons';
|
|
import {
|
|
alertCircleOutline,
|
|
bulbOutline,
|
|
chevronDownOutline,
|
|
chevronUpOutline,
|
|
constructOutline,
|
|
refreshOutline,
|
|
shieldCheckmarkOutline,
|
|
sparklesOutline,
|
|
thumbsDownOutline,
|
|
thumbsUpOutline,
|
|
warningOutline
|
|
} from 'ionicons/icons';
|
|
|
|
import { ChatMessage, TOOL_DISPLAY_NAMES } from '../interfaces/interfaces';
|
|
import { GfMarkdownPipe } from '../pipes/markdown.pipe';
|
|
|
|
@Component({
|
|
animations: [
|
|
trigger('messageEnter', [
|
|
transition(':enter', [
|
|
style({ opacity: 0, transform: 'translateY(0.5rem)' }),
|
|
animate(
|
|
'200ms ease-out',
|
|
style({ opacity: 1, transform: 'translateY(0)' })
|
|
)
|
|
])
|
|
])
|
|
],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
ClipboardModule,
|
|
GfMarkdownPipe,
|
|
IonIcon,
|
|
MatButtonModule,
|
|
MatIconModule
|
|
],
|
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
selector: 'gf-agent-chat-message',
|
|
styleUrls: ['./agent-chat-message.scss'],
|
|
templateUrl: './agent-chat-message.html'
|
|
})
|
|
export class GfAgentChatMessageComponent implements DoCheck, OnDestroy, OnInit {
|
|
@Input() content: string = '';
|
|
@Input() isStreaming: boolean = false;
|
|
@Input() message: ChatMessage;
|
|
@Input() skipAnimation = false;
|
|
|
|
@Output() feedbackChanged = new EventEmitter<{
|
|
rating: 'positive' | 'negative' | null;
|
|
}>();
|
|
@Output() retryRequested = new EventEmitter<void>();
|
|
@Output() suggestionClicked = new EventEmitter<string>();
|
|
|
|
public isReasoningExpanded = false;
|
|
public isSourcesExpanded = false;
|
|
public reasoningLabel = '';
|
|
|
|
private cachedRelativeTimestamp: string | null = null;
|
|
private cachedStreamingHtml: string | undefined;
|
|
private cachedSafeHtml: SafeHtml | null = null;
|
|
private reasoningTimerInterval: ReturnType<typeof setInterval> | null = null;
|
|
private timestampRefreshInterval: ReturnType<typeof setInterval> | null =
|
|
null;
|
|
private wasReasoningStreaming = false;
|
|
|
|
public constructor(
|
|
private changeDetectorRef: ChangeDetectorRef,
|
|
private clipboard: Clipboard,
|
|
private domSanitizer: DomSanitizer
|
|
) {
|
|
addIcons({
|
|
alertCircleOutline,
|
|
bulbOutline,
|
|
chevronDownOutline,
|
|
chevronUpOutline,
|
|
constructOutline,
|
|
refreshOutline,
|
|
shieldCheckmarkOutline,
|
|
sparklesOutline,
|
|
thumbsDownOutline,
|
|
thumbsUpOutline,
|
|
warningOutline
|
|
});
|
|
}
|
|
|
|
public ngOnInit() {
|
|
this.cachedRelativeTimestamp = this.computeRelativeTimestamp();
|
|
this.timestampRefreshInterval = setInterval(() => {
|
|
const updated = this.computeRelativeTimestamp();
|
|
|
|
if (updated !== this.cachedRelativeTimestamp) {
|
|
this.cachedRelativeTimestamp = updated;
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
}, 60_000);
|
|
}
|
|
|
|
public ngDoCheck() {
|
|
const isNowStreaming = !!this.message?.isReasoningStreaming;
|
|
|
|
if (isNowStreaming && !this.wasReasoningStreaming) {
|
|
this.isReasoningExpanded = true;
|
|
this.startReasoningTimer();
|
|
} else if (!isNowStreaming && this.wasReasoningStreaming) {
|
|
this.isReasoningExpanded = false;
|
|
this.stopReasoningTimer();
|
|
this.updateReasoningLabel();
|
|
}
|
|
|
|
this.wasReasoningStreaming = isNowStreaming;
|
|
}
|
|
|
|
public ngOnDestroy() {
|
|
this.stopReasoningTimer();
|
|
|
|
if (this.timestampRefreshInterval !== null) {
|
|
clearInterval(this.timestampRefreshInterval);
|
|
this.timestampRefreshInterval = null;
|
|
}
|
|
}
|
|
|
|
@HostListener('click', ['$event'])
|
|
public onCodeCopyClick(event: MouseEvent) {
|
|
const target = event.target as HTMLElement;
|
|
const btn = target.closest('.code-copy-btn') as HTMLElement;
|
|
|
|
if (!btn) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const encodedCode = btn.getAttribute('data-code');
|
|
|
|
if (encodedCode) {
|
|
this.clipboard.copy(decodeURIComponent(encodedCode));
|
|
btn.classList.add('copied');
|
|
setTimeout(() => btn.classList.remove('copied'), 2000);
|
|
}
|
|
}
|
|
|
|
public get relativeTimestamp(): string | null {
|
|
return this.cachedRelativeTimestamp;
|
|
}
|
|
|
|
public get safeStreamingHtml(): SafeHtml | null {
|
|
const html = this.message?.streamingHtml;
|
|
|
|
if (!html) {
|
|
this.cachedStreamingHtml = undefined;
|
|
this.cachedSafeHtml = null;
|
|
|
|
return null;
|
|
}
|
|
|
|
// Only re-wrap if the string reference changed
|
|
if (html !== this.cachedStreamingHtml) {
|
|
this.cachedStreamingHtml = html;
|
|
this.cachedSafeHtml = this.domSanitizer.bypassSecurityTrustHtml(html);
|
|
}
|
|
|
|
return this.cachedSafeHtml;
|
|
}
|
|
|
|
public getToolDisplayName(toolName: string): string {
|
|
return TOOL_DISPLAY_NAMES[toolName] || toolName;
|
|
}
|
|
|
|
public getConfidenceBadgeClass(): string {
|
|
const level = this.message.confidence?.level?.toLowerCase();
|
|
|
|
if (level === 'low') {
|
|
return 'confidence-low';
|
|
} else if (level === 'medium') {
|
|
return 'confidence-medium';
|
|
}
|
|
|
|
return 'confidence-high';
|
|
}
|
|
|
|
public onToggleReasoning() {
|
|
this.isReasoningExpanded = !this.isReasoningExpanded;
|
|
}
|
|
|
|
public onToggleSources() {
|
|
this.isSourcesExpanded = !this.isSourcesExpanded;
|
|
}
|
|
|
|
public onFeedback(rating: 'positive' | 'negative') {
|
|
const newRating = this.message.feedbackRating === rating ? null : rating;
|
|
this.feedbackChanged.emit({ rating: newRating });
|
|
}
|
|
|
|
public onRetry() {
|
|
this.retryRequested.emit();
|
|
}
|
|
|
|
public onSuggestionClick(text: string) {
|
|
this.suggestionClicked.emit(text);
|
|
}
|
|
|
|
private startReasoningTimer() {
|
|
this.stopReasoningTimer();
|
|
this.reasoningLabel = $localize`Thinking...`;
|
|
this.reasoningTimerInterval = setInterval(() => {
|
|
if (!this.message?.isReasoningStreaming) {
|
|
this.stopReasoningTimer();
|
|
return;
|
|
}
|
|
|
|
const phases = this.message.reasoningPhases;
|
|
|
|
if (!phases?.length) {
|
|
return;
|
|
}
|
|
|
|
const totalMs = phases.reduce(
|
|
(sum, p) => sum + ((p.endTime ?? Date.now()) - p.startTime),
|
|
0
|
|
);
|
|
const seconds = Math.round(totalMs / 1000);
|
|
|
|
this.reasoningLabel =
|
|
seconds < 1
|
|
? $localize`Thinking...`
|
|
: $localize`Thinking for ${seconds}s...`;
|
|
this.changeDetectorRef.markForCheck();
|
|
}, 500);
|
|
}
|
|
|
|
private stopReasoningTimer() {
|
|
if (this.reasoningTimerInterval !== null) {
|
|
clearInterval(this.reasoningTimerInterval);
|
|
this.reasoningTimerInterval = null;
|
|
}
|
|
}
|
|
|
|
private computeRelativeTimestamp(): string | null {
|
|
if (!this.message?.timestamp) {
|
|
return null;
|
|
}
|
|
|
|
const diffMs = Date.now() - this.message.timestamp.getTime();
|
|
|
|
if (diffMs < 60_000) {
|
|
return $localize`just now`;
|
|
}
|
|
|
|
return formatDistanceToNowStrict(this.message.timestamp, {
|
|
addSuffix: true
|
|
});
|
|
}
|
|
|
|
private updateReasoningLabel() {
|
|
const totalMs = this.message?.totalReasoningDurationMs;
|
|
|
|
if (!totalMs) {
|
|
this.reasoningLabel = $localize`Thought`;
|
|
return;
|
|
}
|
|
|
|
const seconds = Math.round(totalMs / 1000);
|
|
|
|
this.reasoningLabel =
|
|
seconds < 1
|
|
? $localize`Thought for <1s`
|
|
: $localize`Thought for ${seconds}s`;
|
|
}
|
|
}
|
|
|