mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
Add new /chat route with full-page chat interface featuring: - Conversation list sidebar with delete functionality - Newest-first message ordering - Auto-scroll to latest message - Starter prompt buttons - Response details with confidence/citations/verification - Feedback system (helpful/needs work) LocalStorage persistence: - Conversations sorted by updatedAt - Max 50 conversations, 200 messages each - Active conversation tracking Frontend fixes: - Messages display newest on top (reverse chronological) - Auto-scroll to top when new messages arrive - Template ref for scroll control Related issue: Fix chat page UI/UX for natural conversation flowpull/6395/head
7 changed files with 1806 additions and 0 deletions
@ -0,0 +1,238 @@ |
|||
<div class="container"> |
|||
<h1 class="h3 mb-3 text-center" i18n>AI Chat</h1> |
|||
|
|||
<div class="row"> |
|||
<div class="col-lg-4 mb-3 mb-lg-0"> |
|||
<mat-card appearance="outlined" class="h-100"> |
|||
<mat-card-content> |
|||
<button |
|||
class="w-100" |
|||
color="primary" |
|||
mat-flat-button |
|||
type="button" |
|||
(click)="onNewChat()" |
|||
> |
|||
<span class="align-items-center d-inline-flex"> |
|||
<mat-icon aria-hidden="true" class="mr-1">add</mat-icon> |
|||
<span i18n>New chat</span> |
|||
</span> |
|||
</button> |
|||
|
|||
<div class="conversation-list mt-3"> |
|||
@for ( |
|||
conversation of conversations; |
|||
track trackConversationById($index, conversation) |
|||
) { |
|||
<div class="align-items-center conversation-row d-flex mb-2"> |
|||
<button |
|||
class="conversation-select flex-grow-1 text-left" |
|||
mat-stroked-button |
|||
type="button" |
|||
[class.active]="currentConversation?.id === conversation.id" |
|||
(click)="onSelectConversation(conversation.id)" |
|||
> |
|||
<div class="conversation-title text-truncate">{{ conversation.title }}</div> |
|||
<div class="conversation-meta text-muted"> |
|||
{{ conversation.updatedAt | date: 'short' }} |
|||
</div> |
|||
</button> |
|||
|
|||
<button |
|||
aria-label="Delete conversation" |
|||
class="conversation-delete ml-1" |
|||
i18n-aria-label |
|||
mat-icon-button |
|||
type="button" |
|||
(click)="onDeleteConversation($event, conversation.id)" |
|||
> |
|||
<mat-icon aria-hidden="true">delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
} |
|||
</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
|
|||
<div class="col-lg-8"> |
|||
<mat-card appearance="outlined"> |
|||
<mat-card-content> |
|||
@if (!hasPermissionToReadAiPrompt) { |
|||
<div class="alert alert-warning mb-0" role="alert" i18n> |
|||
You need AI prompt permission to use this assistant. |
|||
</div> |
|||
} @else { |
|||
<div class="d-flex flex-wrap mb-3 prompt-list"> |
|||
@for (prompt of starterPrompts; track prompt) { |
|||
<button |
|||
class="mr-2 mb-2" |
|||
mat-stroked-button |
|||
type="button" |
|||
(click)="onSelectStarterPrompt(prompt)" |
|||
> |
|||
{{ prompt }} |
|||
</button> |
|||
} |
|||
</div> |
|||
|
|||
<mat-form-field class="w-100"> |
|||
<mat-label i18n>Ask about your portfolio</mat-label> |
|||
<textarea |
|||
aria-label="Ask about your portfolio" |
|||
i18n-aria-label |
|||
matInput |
|||
rows="3" |
|||
[(ngModel)]="query" |
|||
[disabled]="isSubmitting" |
|||
(keydown.enter)="onSubmitFromKeyboard($event)" |
|||
></textarea> |
|||
</mat-form-field> |
|||
|
|||
<div class="align-items-center d-flex mb-3"> |
|||
<button |
|||
color="primary" |
|||
mat-flat-button |
|||
type="button" |
|||
[disabled]="isSubmitting || !query?.trim()" |
|||
(click)="onSubmit()" |
|||
> |
|||
<ng-container i18n>Send</ng-container> |
|||
</button> |
|||
@if (isSubmitting) { |
|||
<mat-spinner class="ml-3" color="accent" [diameter]="20" /> |
|||
} |
|||
</div> |
|||
|
|||
@if (errorMessage) { |
|||
<div class="alert alert-danger mb-3" role="alert"> |
|||
{{ errorMessage }} |
|||
</div> |
|||
} |
|||
|
|||
<div #chatLogContainer aria-live="polite" aria-relevant="additions text" class="chat-log" role="log"> |
|||
@for (message of visibleMessages; track message.id) { |
|||
<div |
|||
class="chat-message mb-3 p-3 rounded" |
|||
[class.assistant]="message.role === 'assistant'" |
|||
[class.user]="message.role === 'user'" |
|||
> |
|||
<div class="chat-message-header mb-1 text-muted"> |
|||
<span class="role-label text-uppercase">{{ getRoleLabel(message.role) }}</span> |
|||
<span class="ml-2 timestamp">{{ |
|||
message.createdAt | date: 'shortTime' |
|||
}}</span> |
|||
|
|||
@if (message.role === 'assistant' && message.response) { |
|||
<button |
|||
aria-label="Show response details" |
|||
class="chat-details-trigger ml-2" |
|||
i18n-aria-label |
|||
mat-stroked-button |
|||
type="button" |
|||
[matMenuTriggerFor]="responseDetailsMenu" |
|||
(click)="onOpenResponseDetails(message.response)" |
|||
> |
|||
<mat-icon aria-hidden="true">info</mat-icon> |
|||
<span i18n>Info</span> |
|||
</button> |
|||
} |
|||
</div> |
|||
<div class="chat-message-content">{{ message.content }}</div> |
|||
|
|||
@if (message.feedback) { |
|||
<div class="align-items-center d-flex feedback-controls mt-2"> |
|||
<button |
|||
class="mr-2" |
|||
mat-stroked-button |
|||
type="button" |
|||
[disabled]=" |
|||
message.feedback.isSubmitting || !!message.feedback.rating |
|||
" |
|||
(click)="onRateResponse({ messageId: message.id, rating: 'up' })" |
|||
> |
|||
<ng-container i18n>Helpful</ng-container> |
|||
</button> |
|||
<button |
|||
mat-stroked-button |
|||
type="button" |
|||
[disabled]=" |
|||
message.feedback.isSubmitting || !!message.feedback.rating |
|||
" |
|||
(click)="onRateResponse({ messageId: message.id, rating: 'down' })" |
|||
> |
|||
<ng-container i18n>Needs work</ng-container> |
|||
</button> |
|||
|
|||
@if (message.feedback.isSubmitting) { |
|||
<span class="ml-2 text-muted" i18n>Saving feedback...</span> |
|||
} @else if (message.feedback.feedbackId) { |
|||
<span class="ml-2 text-muted" i18n>Feedback saved</span> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
|
|||
<mat-menu #responseDetailsMenu="matMenu" class="no-max-width" xPosition="before"> |
|||
<div class="response-details-panel p-3" (click)="$event.stopPropagation()"> |
|||
@if (activeResponseDetails; as details) { |
|||
<div class="response-details-section"> |
|||
<strong i18n>Confidence</strong>: |
|||
{{ details.confidence.score * 100 | number: '1.0-0' }}% |
|||
({{ details.confidence.band }}) |
|||
</div> |
|||
|
|||
@if (details.citations.length > 0) { |
|||
<div class="response-details-section"> |
|||
<strong i18n>Citations</strong> |
|||
<ul class="mb-0 pl-3 response-details-list"> |
|||
@for (citation of details.citations; track $index) { |
|||
<li> |
|||
<span class="font-weight-bold">{{ citation.source }}</span> |
|||
- |
|||
{{ citation.snippet }} |
|||
</li> |
|||
} |
|||
</ul> |
|||
</div> |
|||
} |
|||
|
|||
@if (details.verification.length > 0) { |
|||
<div class="response-details-section"> |
|||
<strong i18n>Verification</strong> |
|||
<ul class="mb-0 pl-3 response-details-list"> |
|||
@for (check of details.verification; track $index) { |
|||
<li> |
|||
<span class="text-capitalize">{{ check.status }}</span> |
|||
- |
|||
{{ check.check }}: |
|||
{{ check.details }} |
|||
</li> |
|||
} |
|||
</ul> |
|||
</div> |
|||
} |
|||
|
|||
@if (details.observability) { |
|||
<div class="response-details-section"> |
|||
<strong i18n>Observability</strong>: |
|||
<span class="ml-1" |
|||
>{{ details.observability.latencyInMs }}ms, ~{{ |
|||
details.observability.tokenEstimate.total |
|||
}} |
|||
tokens</span |
|||
> |
|||
</div> |
|||
} |
|||
} @else { |
|||
<span class="text-muted" i18n>No response details available.</span> |
|||
} |
|||
</div> |
|||
</mat-menu> |
|||
} |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,161 @@ |
|||
:host { |
|||
--ai-chat-assistant-background: rgba(var(--dark-primary-text), 0.03); |
|||
--ai-chat-border-color: rgba(var(--dark-primary-text), 0.14); |
|||
--ai-chat-message-text: rgb(var(--dark-primary-text)); |
|||
--ai-chat-muted-text: rgba(var(--dark-primary-text), 0.7); |
|||
--ai-chat-selection-background: rgba(var(--palette-primary-500), 0.45); |
|||
--ai-chat-selection-text: rgb(var(--dark-primary-text)); |
|||
--ai-chat-user-background: rgba(var(--palette-primary-500), 0.1); |
|||
--ai-chat-user-border: rgba(var(--palette-primary-500), 0.3); |
|||
display: block; |
|||
} |
|||
|
|||
:host-context(.theme-dark) { |
|||
--ai-chat-assistant-background: rgba(var(--light-primary-text), 0.06); |
|||
--ai-chat-border-color: rgba(var(--light-primary-text), 0.2); |
|||
--ai-chat-message-text: rgb(var(--light-primary-text)); |
|||
--ai-chat-muted-text: rgba(var(--light-primary-text), 0.72); |
|||
--ai-chat-selection-background: rgba(var(--palette-primary-300), 0.4); |
|||
--ai-chat-selection-text: rgb(var(--light-primary-text)); |
|||
--ai-chat-user-background: rgba(var(--palette-primary-500), 0.18); |
|||
--ai-chat-user-border: rgba(var(--palette-primary-300), 0.45); |
|||
} |
|||
|
|||
.chat-log { |
|||
max-height: 36rem; |
|||
overflow-y: auto; |
|||
padding-right: 0.25rem; |
|||
} |
|||
|
|||
.chat-message { |
|||
border: 1px solid var(--ai-chat-border-color); |
|||
color: var(--ai-chat-message-text); |
|||
} |
|||
|
|||
.chat-message.assistant { |
|||
background: var(--ai-chat-assistant-background); |
|||
} |
|||
|
|||
.chat-message.user { |
|||
background: var(--ai-chat-user-background); |
|||
border-color: var(--ai-chat-user-border); |
|||
} |
|||
|
|||
.chat-message-content { |
|||
color: var(--ai-chat-message-text); |
|||
margin-top: 0.25rem; |
|||
white-space: pre-wrap; |
|||
word-break: break-word; |
|||
} |
|||
|
|||
.chat-message-content::selection, |
|||
.chat-message-header::selection, |
|||
.response-details-panel::selection, |
|||
.response-details-panel li::selection, |
|||
.response-details-panel strong::selection, |
|||
textarea::selection { |
|||
background: var(--ai-chat-selection-background); |
|||
color: var(--ai-chat-selection-text); |
|||
} |
|||
|
|||
.chat-message-header { |
|||
align-items: center; |
|||
color: var(--ai-chat-muted-text) !important; |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
.chat-details-trigger { |
|||
align-items: center; |
|||
color: var(--ai-chat-muted-text); |
|||
display: inline-flex; |
|||
gap: 0.2rem; |
|||
height: 1.75rem; |
|||
line-height: 1; |
|||
min-width: 0; |
|||
padding: 0 0.4rem; |
|||
} |
|||
|
|||
.chat-details-trigger mat-icon { |
|||
font-size: 0.95rem; |
|||
height: 0.95rem; |
|||
width: 0.95rem; |
|||
} |
|||
|
|||
.conversation-list { |
|||
max-height: 42rem; |
|||
overflow-y: auto; |
|||
padding-right: 0.25rem; |
|||
} |
|||
|
|||
.conversation-select { |
|||
border-color: var(--ai-chat-border-color); |
|||
min-height: 3.5rem; |
|||
} |
|||
|
|||
.conversation-select.active { |
|||
background: rgba(var(--palette-primary-500), 0.12); |
|||
border-color: var(--ai-chat-user-border); |
|||
} |
|||
|
|||
.conversation-title { |
|||
color: var(--ai-chat-message-text); |
|||
font-size: 0.95rem; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.conversation-meta { |
|||
color: var(--ai-chat-muted-text) !important; |
|||
font-size: 0.75rem; |
|||
} |
|||
|
|||
.conversation-delete { |
|||
color: var(--ai-chat-muted-text); |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.prompt-list { |
|||
gap: 0.25rem; |
|||
} |
|||
|
|||
.role-label { |
|||
letter-spacing: 0.03em; |
|||
} |
|||
|
|||
.feedback-controls { |
|||
gap: 0.25rem; |
|||
} |
|||
|
|||
.response-details-panel { |
|||
color: var(--ai-chat-message-text); |
|||
max-height: min(24rem, calc(100vh - 8rem)); |
|||
max-width: min(26rem, calc(100vw - 2rem)); |
|||
min-width: min(18rem, calc(100vw - 2rem)); |
|||
overflow-y: auto; |
|||
white-space: normal; |
|||
} |
|||
|
|||
.response-details-section { |
|||
color: var(--ai-chat-muted-text); |
|||
font-size: 0.85rem; |
|||
} |
|||
|
|||
.response-details-section + .response-details-section { |
|||
border-top: 1px solid var(--ai-chat-border-color); |
|||
margin-top: 0.75rem; |
|||
padding-top: 0.75rem; |
|||
} |
|||
|
|||
.response-details-list { |
|||
margin-top: 0.25rem; |
|||
} |
|||
|
|||
.response-details-list li + li { |
|||
margin-top: 0.25rem; |
|||
} |
|||
|
|||
@media (max-width: 991.98px) { |
|||
.conversation-list { |
|||
max-height: 16rem; |
|||
} |
|||
} |
|||
@ -0,0 +1,304 @@ |
|||
import { |
|||
AiChatConversation, |
|||
AiChatConversationsService, |
|||
AiChatMessage |
|||
} from '@ghostfolio/client/services/ai-chat-conversations.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { AiAgentChatResponse } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { DataService } from '@ghostfolio/ui/services'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { Component, OnDestroy, OnInit, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; |
|||
import { FormsModule } from '@angular/forms'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { MatIconModule } from '@angular/material/icon'; |
|||
import { MatInputModule } from '@angular/material/input'; |
|||
import { MatMenuModule } from '@angular/material/menu'; |
|||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; |
|||
import { Subject } from 'rxjs'; |
|||
import { finalize, takeUntil } from 'rxjs/operators'; |
|||
|
|||
@Component({ |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
MatButtonModule, |
|||
MatCardModule, |
|||
MatFormFieldModule, |
|||
MatIconModule, |
|||
MatInputModule, |
|||
MatMenuModule, |
|||
MatProgressSpinnerModule |
|||
], |
|||
selector: 'gf-chat-page', |
|||
styleUrls: ['./chat-page.component.scss'], |
|||
templateUrl: './chat-page.component.html' |
|||
}) |
|||
export class GfChatPageComponent implements AfterViewInit, OnDestroy, OnInit { |
|||
@ViewChild('chatLogContainer', { static: false }) |
|||
chatLogContainer: ElementRef<HTMLElement>; |
|||
public readonly assistantRoleLabel = $localize`Assistant`; |
|||
public activeResponseDetails: AiAgentChatResponse | undefined; |
|||
public conversations: AiChatConversation[] = []; |
|||
public currentConversation: AiChatConversation | undefined; |
|||
public errorMessage: string; |
|||
public hasPermissionToReadAiPrompt = false; |
|||
public isSubmitting = false; |
|||
public query = ''; |
|||
public readonly starterPrompts = [ |
|||
$localize`Give me a portfolio risk summary.`, |
|||
$localize`What are my top concentration risks right now?`, |
|||
$localize`Show me the latest market prices for my top holdings.` |
|||
]; |
|||
public readonly userRoleLabel = $localize`You`; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private readonly aiChatConversationsService: AiChatConversationsService, |
|||
private readonly dataService: DataService, |
|||
private readonly userService: UserService |
|||
) {} |
|||
|
|||
public ngOnInit() { |
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
this.hasPermissionToReadAiPrompt = hasPermission( |
|||
state?.user?.permissions, |
|||
permissions.readAiPrompt |
|||
); |
|||
}); |
|||
|
|||
this.aiChatConversationsService |
|||
.getConversations() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((conversations) => { |
|||
this.conversations = conversations; |
|||
}); |
|||
|
|||
this.aiChatConversationsService |
|||
.getCurrentConversation() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((conversation) => { |
|||
this.currentConversation = conversation; |
|||
this.activeResponseDetails = undefined; |
|||
this.scrollToTop(); |
|||
}); |
|||
|
|||
if (this.aiChatConversationsService.getConversationsSnapshot().length === 0) { |
|||
this.aiChatConversationsService.createConversation(); |
|||
} |
|||
} |
|||
|
|||
public ngAfterViewInit() { |
|||
this.scrollToTop(); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private scrollToTop() { |
|||
if (this.chatLogContainer) { |
|||
this.chatLogContainer.nativeElement.scrollTop = 0; |
|||
} |
|||
} |
|||
|
|||
public get visibleMessages() { |
|||
const messages = this.currentConversation?.messages ?? []; |
|||
return [...messages].reverse(); |
|||
} |
|||
|
|||
public getRoleLabel(role: AiChatMessage['role']) { |
|||
return role === 'assistant' ? this.assistantRoleLabel : this.userRoleLabel; |
|||
} |
|||
|
|||
public onDeleteConversation(event: Event, conversationId: string) { |
|||
event.stopPropagation(); |
|||
|
|||
this.aiChatConversationsService.deleteConversation(conversationId); |
|||
|
|||
if (this.aiChatConversationsService.getConversationsSnapshot().length === 0) { |
|||
this.aiChatConversationsService.createConversation(); |
|||
} |
|||
} |
|||
|
|||
public onNewChat() { |
|||
this.errorMessage = undefined; |
|||
this.query = ''; |
|||
this.aiChatConversationsService.createConversation(); |
|||
} |
|||
|
|||
public onOpenResponseDetails(response?: AiAgentChatResponse) { |
|||
this.activeResponseDetails = response; |
|||
} |
|||
|
|||
public onRateResponse({ |
|||
messageId, |
|||
rating |
|||
}: { |
|||
messageId: number; |
|||
rating: 'down' | 'up'; |
|||
}) { |
|||
const conversation = this.currentConversation; |
|||
|
|||
if (!conversation) { |
|||
return; |
|||
} |
|||
|
|||
const message = conversation.messages.find(({ id }) => { |
|||
return id === messageId; |
|||
}); |
|||
|
|||
if (!message?.response?.memory?.sessionId) { |
|||
return; |
|||
} |
|||
|
|||
if (message.feedback?.isSubmitting || message.feedback?.rating) { |
|||
return; |
|||
} |
|||
|
|||
this.aiChatConversationsService.updateMessage({ |
|||
conversationId: conversation.id, |
|||
messageId, |
|||
updater: (currentMessage) => { |
|||
return { |
|||
...currentMessage, |
|||
feedback: { |
|||
...currentMessage.feedback, |
|||
isSubmitting: true |
|||
} |
|||
}; |
|||
} |
|||
}); |
|||
|
|||
this.dataService |
|||
.postAiChatFeedback({ |
|||
rating, |
|||
sessionId: message.response.memory.sessionId |
|||
}) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: ({ feedbackId }) => { |
|||
this.aiChatConversationsService.updateMessage({ |
|||
conversationId: conversation.id, |
|||
messageId, |
|||
updater: (currentMessage) => { |
|||
return { |
|||
...currentMessage, |
|||
feedback: { |
|||
feedbackId, |
|||
isSubmitting: false, |
|||
rating |
|||
} |
|||
}; |
|||
} |
|||
}); |
|||
}, |
|||
error: () => { |
|||
this.aiChatConversationsService.updateMessage({ |
|||
conversationId: conversation.id, |
|||
messageId, |
|||
updater: (currentMessage) => { |
|||
return { |
|||
...currentMessage, |
|||
feedback: { |
|||
...currentMessage.feedback, |
|||
isSubmitting: false |
|||
} |
|||
}; |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onSelectConversation(conversationId: string) { |
|||
this.errorMessage = undefined; |
|||
this.query = ''; |
|||
this.aiChatConversationsService.selectConversation(conversationId); |
|||
} |
|||
|
|||
public onSelectStarterPrompt(prompt: string) { |
|||
this.query = prompt; |
|||
} |
|||
|
|||
public onSubmit() { |
|||
const normalizedQuery = this.query?.trim(); |
|||
|
|||
if ( |
|||
!this.hasPermissionToReadAiPrompt || |
|||
this.isSubmitting || |
|||
!normalizedQuery |
|||
) { |
|||
return; |
|||
} |
|||
|
|||
const conversation = |
|||
this.currentConversation ?? this.aiChatConversationsService.createConversation(); |
|||
|
|||
this.aiChatConversationsService.appendUserMessage({ |
|||
content: normalizedQuery, |
|||
conversationId: conversation.id |
|||
}); |
|||
|
|||
this.errorMessage = undefined; |
|||
this.isSubmitting = true; |
|||
this.query = ''; |
|||
|
|||
this.dataService |
|||
.postAiChat({ |
|||
query: normalizedQuery, |
|||
sessionId: conversation.sessionId |
|||
}) |
|||
.pipe( |
|||
finalize(() => { |
|||
this.isSubmitting = false; |
|||
}), |
|||
takeUntil(this.unsubscribeSubject) |
|||
) |
|||
.subscribe({ |
|||
next: (response) => { |
|||
this.aiChatConversationsService.setConversationSessionId({ |
|||
conversationId: conversation.id, |
|||
sessionId: response.memory.sessionId |
|||
}); |
|||
this.aiChatConversationsService.appendAssistantMessage({ |
|||
content: response.answer, |
|||
conversationId: conversation.id, |
|||
feedback: { |
|||
isSubmitting: false |
|||
}, |
|||
response |
|||
}); |
|||
}, |
|||
error: () => { |
|||
this.errorMessage = $localize`AI request failed. Check your model quota and permissions.`; |
|||
|
|||
this.aiChatConversationsService.appendAssistantMessage({ |
|||
content: $localize`Request failed. Please retry.`, |
|||
conversationId: conversation.id |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onSubmitFromKeyboard(event: KeyboardEvent) { |
|||
if (!event.shiftKey) { |
|||
this.onSubmit(); |
|||
event.preventDefault(); |
|||
} |
|||
} |
|||
|
|||
public trackConversationById( |
|||
_index: number, |
|||
conversation: AiChatConversation |
|||
) { |
|||
return conversation.id; |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
|||
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
|||
|
|||
import { Routes } from '@angular/router'; |
|||
|
|||
import { GfChatPageComponent } from './chat-page.component'; |
|||
|
|||
export const routes: Routes = [ |
|||
{ |
|||
canActivate: [AuthGuard], |
|||
component: GfChatPageComponent, |
|||
path: '', |
|||
title: internalRoutes.chat.title |
|||
} |
|||
]; |
|||
@ -0,0 +1,96 @@ |
|||
import { TestBed } from '@angular/core/testing'; |
|||
|
|||
import { AiChatConversationsService } from './ai-chat-conversations.service'; |
|||
|
|||
describe('AiChatConversationsService', () => { |
|||
let service: AiChatConversationsService; |
|||
|
|||
beforeEach(() => { |
|||
localStorage.clear(); |
|||
|
|||
TestBed.configureTestingModule({}); |
|||
service = TestBed.inject(AiChatConversationsService); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
localStorage.clear(); |
|||
}); |
|||
|
|||
it('creates and selects a new conversation', () => { |
|||
const createdConversation = service.createConversation(); |
|||
|
|||
expect(service.getConversationsSnapshot()).toHaveLength(1); |
|||
expect(service.getCurrentConversationSnapshot()?.id).toBe(createdConversation.id); |
|||
expect(createdConversation.title).toBe('New Chat'); |
|||
}); |
|||
|
|||
it('derives title from first user message and falls back for generic prompts', () => { |
|||
const detailedConversation = service.createConversation(); |
|||
service.appendUserMessage({ |
|||
content: 'Help me rebalance my holdings for lower concentration risk.', |
|||
conversationId: detailedConversation.id |
|||
}); |
|||
|
|||
const updatedDetailedConversation = service.getCurrentConversationSnapshot(); |
|||
|
|||
expect(updatedDetailedConversation?.title).toBe( |
|||
'Help me rebalance my holdings for lower concentr...' |
|||
); |
|||
|
|||
const genericConversation = service.createConversation(); |
|||
service.appendUserMessage({ |
|||
content: 'hi', |
|||
conversationId: genericConversation.id |
|||
}); |
|||
|
|||
expect(service.getCurrentConversationSnapshot()?.title).toBe('New Chat'); |
|||
}); |
|||
|
|||
it('starts new chats with fresh context and keeps per-conversation session memory', () => { |
|||
const firstConversation = service.createConversation(); |
|||
service.setConversationSessionId({ |
|||
conversationId: firstConversation.id, |
|||
sessionId: 'session-1' |
|||
}); |
|||
|
|||
const secondConversation = service.createConversation(); |
|||
|
|||
expect(service.getCurrentConversationSnapshot()?.id).toBe(secondConversation.id); |
|||
expect(service.getCurrentConversationSnapshot()?.sessionId).toBeUndefined(); |
|||
|
|||
service.selectConversation(firstConversation.id); |
|||
|
|||
expect(service.getCurrentConversationSnapshot()?.sessionId).toBe('session-1'); |
|||
}); |
|||
|
|||
it('restores conversations and active selection from local storage', () => { |
|||
const firstConversation = service.createConversation(); |
|||
service.appendUserMessage({ |
|||
content: 'first chat message', |
|||
conversationId: firstConversation.id |
|||
}); |
|||
service.setConversationSessionId({ |
|||
conversationId: firstConversation.id, |
|||
sessionId: 'session-first' |
|||
}); |
|||
|
|||
const secondConversation = service.createConversation(); |
|||
service.appendUserMessage({ |
|||
content: 'second chat message', |
|||
conversationId: secondConversation.id |
|||
}); |
|||
|
|||
const restoredService = new AiChatConversationsService(); |
|||
|
|||
expect(restoredService.getConversationsSnapshot()).toHaveLength(2); |
|||
expect(restoredService.getCurrentConversationSnapshot()?.id).toBe( |
|||
secondConversation.id |
|||
); |
|||
|
|||
restoredService.selectConversation(firstConversation.id); |
|||
|
|||
expect(restoredService.getCurrentConversationSnapshot()?.sessionId).toBe( |
|||
'session-first' |
|||
); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,590 @@ |
|||
import { AiAgentChatResponse } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable } from '@angular/core'; |
|||
import { BehaviorSubject, combineLatest } from 'rxjs'; |
|||
import { map } from 'rxjs/operators'; |
|||
|
|||
export interface AiChatFeedbackState { |
|||
feedbackId?: string; |
|||
isSubmitting: boolean; |
|||
rating?: 'down' | 'up'; |
|||
} |
|||
|
|||
export interface AiChatMessage { |
|||
content: string; |
|||
createdAt: Date; |
|||
feedback?: AiChatFeedbackState; |
|||
id: number; |
|||
response?: AiAgentChatResponse; |
|||
role: 'assistant' | 'user'; |
|||
} |
|||
|
|||
export interface AiChatConversation { |
|||
createdAt: Date; |
|||
id: string; |
|||
messages: AiChatMessage[]; |
|||
nextMessageId: number; |
|||
sessionId?: string; |
|||
title: string; |
|||
updatedAt: Date; |
|||
} |
|||
|
|||
type StoredAiChatMessage = Omit<AiChatMessage, 'createdAt'> & { |
|||
createdAt: string; |
|||
}; |
|||
|
|||
type StoredAiChatConversation = Omit< |
|||
AiChatConversation, |
|||
'createdAt' | 'messages' | 'updatedAt' |
|||
> & { |
|||
createdAt: string; |
|||
messages: StoredAiChatMessage[]; |
|||
updatedAt: string; |
|||
}; |
|||
|
|||
@Injectable({ |
|||
providedIn: 'root' |
|||
}) |
|||
export class AiChatConversationsService { |
|||
private readonly STORAGE_KEY_ACTIVE_CONVERSATION_ID = |
|||
'gf_ai_chat_active_conversation_id_v1'; |
|||
private readonly STORAGE_KEY_CONVERSATIONS = 'gf_ai_chat_conversations_v1'; |
|||
private readonly DEFAULT_CONVERSATION_TITLE = 'New Chat'; |
|||
private readonly GENERIC_FIRST_MESSAGE_PATTERN = |
|||
/^(hi|hello|hey|yo|hola|new chat|start)$/i; |
|||
private readonly MAX_STORED_CONVERSATIONS = 50; |
|||
private readonly MAX_STORED_MESSAGES = 200; |
|||
|
|||
private activeConversationIdSubject = new BehaviorSubject<string | undefined>( |
|||
undefined |
|||
); |
|||
private conversationsSubject = new BehaviorSubject<AiChatConversation[]>([]); |
|||
|
|||
public constructor() { |
|||
this.restoreState(); |
|||
} |
|||
|
|||
public appendAssistantMessage({ |
|||
content, |
|||
conversationId, |
|||
feedback, |
|||
response |
|||
}: { |
|||
content: string; |
|||
conversationId: string; |
|||
feedback?: AiChatFeedbackState; |
|||
response?: AiAgentChatResponse; |
|||
}) { |
|||
return this.appendMessage({ |
|||
content, |
|||
conversationId, |
|||
feedback, |
|||
response, |
|||
role: 'assistant' |
|||
}); |
|||
} |
|||
|
|||
public appendUserMessage({ |
|||
content, |
|||
conversationId |
|||
}: { |
|||
content: string; |
|||
conversationId: string; |
|||
}) { |
|||
return this.appendMessage({ |
|||
content, |
|||
conversationId, |
|||
role: 'user' |
|||
}); |
|||
} |
|||
|
|||
public createConversation({ |
|||
select = true, |
|||
title |
|||
}: { |
|||
select?: boolean; |
|||
title?: string; |
|||
} = {}): AiChatConversation { |
|||
const now = new Date(); |
|||
const conversation: AiChatConversation = { |
|||
createdAt: now, |
|||
id: this.getConversationId(), |
|||
messages: [], |
|||
nextMessageId: 0, |
|||
title: title?.trim() || this.DEFAULT_CONVERSATION_TITLE, |
|||
updatedAt: now |
|||
}; |
|||
|
|||
const conversations = this.conversationsSubject.getValue(); |
|||
this.setState({ |
|||
activeConversationId: |
|||
select || !this.activeConversationIdSubject.getValue() |
|||
? conversation.id |
|||
: this.activeConversationIdSubject.getValue(), |
|||
conversations: [conversation, ...conversations] |
|||
}); |
|||
|
|||
return conversation; |
|||
} |
|||
|
|||
public deleteConversation(id: string) { |
|||
const conversations = this.conversationsSubject |
|||
.getValue() |
|||
.filter((conversation) => { |
|||
return conversation.id !== id; |
|||
}); |
|||
|
|||
const activeConversationId = this.activeConversationIdSubject.getValue(); |
|||
|
|||
this.setState({ |
|||
activeConversationId: |
|||
activeConversationId === id ? conversations[0]?.id : activeConversationId, |
|||
conversations |
|||
}); |
|||
} |
|||
|
|||
public getActiveConversationId() { |
|||
return this.activeConversationIdSubject.asObservable(); |
|||
} |
|||
|
|||
public getConversations() { |
|||
return this.conversationsSubject.asObservable(); |
|||
} |
|||
|
|||
public getConversationsSnapshot() { |
|||
return this.conversationsSubject.getValue(); |
|||
} |
|||
|
|||
public getCurrentConversation() { |
|||
return combineLatest([ |
|||
this.conversationsSubject, |
|||
this.activeConversationIdSubject |
|||
]).pipe( |
|||
map(([conversations, activeConversationId]) => { |
|||
return conversations.find(({ id }) => { |
|||
return id === activeConversationId; |
|||
}); |
|||
}) |
|||
); |
|||
} |
|||
|
|||
public getCurrentConversationSnapshot() { |
|||
const activeConversationId = this.activeConversationIdSubject.getValue(); |
|||
|
|||
return this.conversationsSubject.getValue().find(({ id }) => { |
|||
return id === activeConversationId; |
|||
}); |
|||
} |
|||
|
|||
public renameConversation({ id, title }: { id: string; title: string }) { |
|||
return this.updateConversation(id, (conversation) => { |
|||
return { |
|||
...conversation, |
|||
title: title.trim() || this.DEFAULT_CONVERSATION_TITLE, |
|||
updatedAt: new Date() |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public selectConversation(id: string) { |
|||
const hasConversation = this.conversationsSubject.getValue().some((conversation) => { |
|||
return conversation.id === id; |
|||
}); |
|||
|
|||
if (!hasConversation) { |
|||
return false; |
|||
} |
|||
|
|||
this.setState({ |
|||
activeConversationId: id, |
|||
conversations: this.conversationsSubject.getValue() |
|||
}); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
public setConversationSessionId({ |
|||
conversationId, |
|||
sessionId |
|||
}: { |
|||
conversationId: string; |
|||
sessionId: string; |
|||
}) { |
|||
return this.updateConversation(conversationId, (conversation) => { |
|||
return { |
|||
...conversation, |
|||
sessionId, |
|||
updatedAt: new Date() |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public updateMessage({ |
|||
conversationId, |
|||
messageId, |
|||
updater |
|||
}: { |
|||
conversationId: string; |
|||
messageId: number; |
|||
updater: (message: AiChatMessage) => AiChatMessage; |
|||
}) { |
|||
return this.updateConversation(conversationId, (conversation) => { |
|||
const messageIndex = conversation.messages.findIndex(({ id }) => { |
|||
return id === messageId; |
|||
}); |
|||
|
|||
if (messageIndex < 0) { |
|||
return conversation; |
|||
} |
|||
|
|||
const updatedMessages = conversation.messages.map((message, index) => { |
|||
return index === messageIndex ? updater(message) : message; |
|||
}); |
|||
|
|||
return { |
|||
...conversation, |
|||
messages: updatedMessages |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
private appendMessage({ |
|||
content, |
|||
conversationId, |
|||
feedback, |
|||
response, |
|||
role |
|||
}: { |
|||
content: string; |
|||
conversationId: string; |
|||
feedback?: AiChatFeedbackState; |
|||
response?: AiAgentChatResponse; |
|||
role: AiChatMessage['role']; |
|||
}) { |
|||
let appendedMessage: AiChatMessage | undefined; |
|||
|
|||
this.updateConversation(conversationId, (conversation) => { |
|||
const now = new Date(); |
|||
appendedMessage = { |
|||
content, |
|||
createdAt: now, |
|||
feedback, |
|||
id: conversation.nextMessageId, |
|||
response, |
|||
role |
|||
}; |
|||
|
|||
const hasExistingUserMessage = conversation.messages.some((message) => { |
|||
return message.role === 'user'; |
|||
}); |
|||
|
|||
return { |
|||
...conversation, |
|||
messages: [...conversation.messages, appendedMessage].slice( |
|||
-this.MAX_STORED_MESSAGES |
|||
), |
|||
nextMessageId: conversation.nextMessageId + 1, |
|||
title: |
|||
role === 'user' && !hasExistingUserMessage |
|||
? this.getConversationTitleFromFirstMessage(content) |
|||
: conversation.title, |
|||
updatedAt: now |
|||
}; |
|||
}); |
|||
|
|||
return appendedMessage; |
|||
} |
|||
|
|||
private getConversationId() { |
|||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { |
|||
return crypto.randomUUID(); |
|||
} |
|||
|
|||
return `conversation-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; |
|||
} |
|||
|
|||
private getConversationTitleFromFirstMessage(content: string) { |
|||
const normalized = this.stripMarkdown(content) |
|||
.replace(/\s+/g, ' ') |
|||
.trim(); |
|||
|
|||
if ( |
|||
normalized.length < 10 || |
|||
this.GENERIC_FIRST_MESSAGE_PATTERN.test(normalized) || |
|||
this.isEmojiOnly(normalized) |
|||
) { |
|||
return this.DEFAULT_CONVERSATION_TITLE; |
|||
} |
|||
|
|||
if (normalized.length > 48) { |
|||
return `${normalized.slice(0, 48).trimEnd()}...`; |
|||
} |
|||
|
|||
return normalized; |
|||
} |
|||
|
|||
private getStorage() { |
|||
try { |
|||
return globalThis.localStorage; |
|||
} catch { |
|||
return undefined; |
|||
} |
|||
} |
|||
|
|||
private isEmojiOnly(content: string) { |
|||
return /^\p{Emoji}+$/u.test(content.replace(/\s+/g, '')); |
|||
} |
|||
|
|||
private persistState() { |
|||
const storage = this.getStorage(); |
|||
|
|||
if (!storage) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
storage.setItem( |
|||
this.STORAGE_KEY_CONVERSATIONS, |
|||
JSON.stringify( |
|||
this.conversationsSubject.getValue().map((conversation) => { |
|||
return this.toStoredConversation(conversation); |
|||
}) |
|||
) |
|||
); |
|||
|
|||
const activeConversationId = this.activeConversationIdSubject.getValue(); |
|||
|
|||
if (activeConversationId) { |
|||
storage.setItem(this.STORAGE_KEY_ACTIVE_CONVERSATION_ID, activeConversationId); |
|||
} else { |
|||
storage.removeItem(this.STORAGE_KEY_ACTIVE_CONVERSATION_ID); |
|||
} |
|||
} catch { |
|||
// Keep chat usable when browser storage is unavailable or full.
|
|||
} |
|||
} |
|||
|
|||
private restoreState() { |
|||
const storage = this.getStorage(); |
|||
|
|||
if (!storage) { |
|||
return; |
|||
} |
|||
|
|||
const rawConversations = storage.getItem(this.STORAGE_KEY_CONVERSATIONS); |
|||
|
|||
if (!rawConversations) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
const parsed = JSON.parse(rawConversations) as unknown; |
|||
|
|||
if (!Array.isArray(parsed)) { |
|||
return; |
|||
} |
|||
|
|||
const conversations = parsed |
|||
.map((conversation) => { |
|||
return this.toConversation(conversation); |
|||
}) |
|||
.filter((conversation): conversation is AiChatConversation => { |
|||
return Boolean(conversation); |
|||
}); |
|||
|
|||
const sortedConversations = this.sortConversations(conversations).slice( |
|||
0, |
|||
this.MAX_STORED_CONVERSATIONS |
|||
); |
|||
|
|||
const activeConversationId = storage.getItem( |
|||
this.STORAGE_KEY_ACTIVE_CONVERSATION_ID |
|||
); |
|||
const hasActiveConversation = sortedConversations.some((conversation) => { |
|||
return conversation.id === activeConversationId; |
|||
}); |
|||
|
|||
this.conversationsSubject.next(sortedConversations); |
|||
this.activeConversationIdSubject.next( |
|||
hasActiveConversation ? activeConversationId : sortedConversations[0]?.id |
|||
); |
|||
} catch { |
|||
storage.removeItem(this.STORAGE_KEY_ACTIVE_CONVERSATION_ID); |
|||
storage.removeItem(this.STORAGE_KEY_CONVERSATIONS); |
|||
} |
|||
} |
|||
|
|||
private setState({ |
|||
activeConversationId, |
|||
conversations |
|||
}: { |
|||
activeConversationId?: string; |
|||
conversations: AiChatConversation[]; |
|||
}) { |
|||
const sortedConversations = this.sortConversations(conversations).slice( |
|||
0, |
|||
this.MAX_STORED_CONVERSATIONS |
|||
); |
|||
|
|||
const hasActiveConversation = sortedConversations.some((conversation) => { |
|||
return conversation.id === activeConversationId; |
|||
}); |
|||
|
|||
this.conversationsSubject.next(sortedConversations); |
|||
this.activeConversationIdSubject.next( |
|||
hasActiveConversation ? activeConversationId : sortedConversations[0]?.id |
|||
); |
|||
this.persistState(); |
|||
} |
|||
|
|||
private sortConversations(conversations: AiChatConversation[]) { |
|||
return [...conversations].sort((a, b) => { |
|||
return b.updatedAt.getTime() - a.updatedAt.getTime(); |
|||
}); |
|||
} |
|||
|
|||
private stripMarkdown(content: string) { |
|||
return content |
|||
.replace(/```[\s\S]*?```/g, ' ') |
|||
.replace(/`([^`]*)`/g, '$1') |
|||
.replace(/(\[|\]|_|#|>|~|\*)/g, ' ') |
|||
.trim(); |
|||
} |
|||
|
|||
private toConversation( |
|||
conversation: unknown |
|||
): AiChatConversation | undefined { |
|||
if (!conversation || typeof conversation !== 'object') { |
|||
return undefined; |
|||
} |
|||
|
|||
const storedConversation = conversation as Partial<StoredAiChatConversation>; |
|||
|
|||
if ( |
|||
typeof storedConversation.id !== 'string' || |
|||
typeof storedConversation.title !== 'string' || |
|||
typeof storedConversation.createdAt !== 'string' || |
|||
typeof storedConversation.updatedAt !== 'string' || |
|||
!Array.isArray(storedConversation.messages) |
|||
) { |
|||
return undefined; |
|||
} |
|||
|
|||
const createdAt = new Date(storedConversation.createdAt); |
|||
const updatedAt = new Date(storedConversation.updatedAt); |
|||
|
|||
if ( |
|||
Number.isNaN(createdAt.getTime()) || |
|||
Number.isNaN(updatedAt.getTime()) || |
|||
(storedConversation.sessionId && |
|||
typeof storedConversation.sessionId !== 'string') |
|||
) { |
|||
return undefined; |
|||
} |
|||
|
|||
const messages = storedConversation.messages |
|||
.map((message) => { |
|||
return this.toMessage(message); |
|||
}) |
|||
.filter((message): message is AiChatMessage => { |
|||
return Boolean(message); |
|||
}) |
|||
.slice(-this.MAX_STORED_MESSAGES); |
|||
|
|||
const nextMessageId = |
|||
Math.max( |
|||
typeof storedConversation.nextMessageId === 'number' |
|||
? storedConversation.nextMessageId |
|||
: -1, |
|||
messages.reduce((maxId, message) => { |
|||
return Math.max(maxId, message.id); |
|||
}, -1) + 1 |
|||
) || 0; |
|||
|
|||
return { |
|||
createdAt, |
|||
id: storedConversation.id, |
|||
messages, |
|||
nextMessageId, |
|||
sessionId: storedConversation.sessionId?.trim() || undefined, |
|||
title: storedConversation.title.trim() || this.DEFAULT_CONVERSATION_TITLE, |
|||
updatedAt |
|||
}; |
|||
} |
|||
|
|||
private toMessage(message: unknown): AiChatMessage | undefined { |
|||
if (!message || typeof message !== 'object') { |
|||
return undefined; |
|||
} |
|||
|
|||
const storedMessage = message as Partial<StoredAiChatMessage>; |
|||
|
|||
if ( |
|||
typeof storedMessage.content !== 'string' || |
|||
typeof storedMessage.id !== 'number' || |
|||
typeof storedMessage.createdAt !== 'string' || |
|||
(storedMessage.role !== 'assistant' && storedMessage.role !== 'user') |
|||
) { |
|||
return undefined; |
|||
} |
|||
|
|||
const createdAt = new Date(storedMessage.createdAt); |
|||
|
|||
if (Number.isNaN(createdAt.getTime())) { |
|||
return undefined; |
|||
} |
|||
|
|||
return { |
|||
content: storedMessage.content, |
|||
createdAt, |
|||
feedback: storedMessage.feedback, |
|||
id: storedMessage.id, |
|||
response: storedMessage.response, |
|||
role: storedMessage.role |
|||
}; |
|||
} |
|||
|
|||
private toStoredConversation( |
|||
conversation: AiChatConversation |
|||
): StoredAiChatConversation { |
|||
return { |
|||
...conversation, |
|||
createdAt: conversation.createdAt.toISOString(), |
|||
messages: conversation.messages.map((message) => { |
|||
return { |
|||
...message, |
|||
createdAt: message.createdAt.toISOString() |
|||
}; |
|||
}), |
|||
updatedAt: conversation.updatedAt.toISOString() |
|||
}; |
|||
} |
|||
|
|||
private updateConversation( |
|||
id: string, |
|||
updater: (conversation: AiChatConversation) => AiChatConversation |
|||
) { |
|||
let hasUpdatedConversation = false; |
|||
|
|||
const conversations = this.conversationsSubject.getValue().map((conversation) => { |
|||
if (conversation.id !== id) { |
|||
return conversation; |
|||
} |
|||
|
|||
hasUpdatedConversation = true; |
|||
|
|||
return updater(conversation); |
|||
}); |
|||
|
|||
if (!hasUpdatedConversation) { |
|||
return false; |
|||
} |
|||
|
|||
this.setState({ |
|||
activeConversationId: this.activeConversationIdSubject.getValue(), |
|||
conversations |
|||
}); |
|||
|
|||
return true; |
|||
} |
|||
} |
|||
@ -0,0 +1,402 @@ |
|||
# Chat Page UI/UX Fixes - Implementation Plan |
|||
|
|||
## Problem Statement |
|||
|
|||
The newly implemented dedicated chat page (`/chat`) has two critical UX issues that make it feel unnatural and hard to use: |
|||
|
|||
### Issue 1: Message Ordering |
|||
**Current behavior:** Messages appear with oldest on top, newest on bottom (standard chat log order). |
|||
**Expected behavior:** Newest messages should appear on top, oldest on bottom (reverse chronological). |
|||
**Impact:** Users have to scroll to see the latest response, which is the opposite of what they expect in a conversation view. |
|||
|
|||
### Issue 2: Robotic System Prompts |
|||
**Current behavior:** Generic queries like "hi", "hello", or "remember my name is Max" trigger canned, robotic responses like: |
|||
``` |
|||
I am Ghostfolio AI. I can help with portfolio analysis, concentration risk, market prices, diversification options, and stress scenarios. |
|||
Try one of these: |
|||
- "Show my top holdings" |
|||
- "What is my concentration risk?" |
|||
- "Help me diversify with actionable options" |
|||
``` |
|||
|
|||
**Expected behavior:** Natural, conversational responses that acknowledge the user's input in a friendly way. For example: |
|||
- User: "remember my name is Max" → Assistant: "Got it, Max! I'll remember that. What would you like to know about your portfolio?" |
|||
- User: "hi" → Assistant: "Hello! I'm here to help with your portfolio. What's on your mind today?" |
|||
|
|||
**Impact:** The current responses feel impersonal and automated, breaking the conversational flow and making users feel like they're interacting with a script rather than an assistant. |
|||
|
|||
## Root Cause Analysis |
|||
|
|||
### Message Ordering |
|||
|
|||
**Location:** `apps/client/src/app/pages/chat/chat-page.component.ts:99-101` |
|||
|
|||
```typescript |
|||
public get visibleMessages() { |
|||
return [...(this.currentConversation?.messages ?? [])].reverse(); |
|||
} |
|||
``` |
|||
|
|||
The code already reverses messages, but this is being applied to the message array **before** it's displayed. The issue is that `.reverse()` reverses the array in place and returns the same array reference, which can cause issues with Angular's change detection. Additionally, the CSS or layout may be positioning messages incorrectly. |
|||
|
|||
**Verification needed:** |
|||
1. Confirm the actual order of messages in the DOM |
|||
2. Check if CSS is affecting visual order vs DOM order |
|||
3. Verify Angular's trackBy function is working correctly with reversed arrays |
|||
|
|||
### Robotic System Prompts |
|||
|
|||
**Location:** `apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts:336-342, 466` |
|||
|
|||
The `createNoToolDirectResponse()` function returns canned responses for queries that don't require tools. This is triggered when: |
|||
1. User sends a greeting or generic message |
|||
2. No tools are planned (`plannedTools.length === 0`) |
|||
3. Policy route is `'direct'` with `blockReason: 'no_tool_query'` |
|||
|
|||
The responses are intentionally generic and informative, but they don't feel conversational or acknowledge the user's specific input. |
|||
|
|||
## Solution Architecture |
|||
|
|||
### Phase 1: Fix Message Ordering (Quick Win) |
|||
|
|||
#### 1.1 Update Message Display Logic |
|||
|
|||
**File:** `apps/client/src/app/pages/chat/chat-page.component.ts` |
|||
|
|||
Change: |
|||
```typescript |
|||
public get visibleMessages() { |
|||
return [...(this.currentConversation?.messages ?? [])].reverse(); |
|||
} |
|||
``` |
|||
|
|||
To: |
|||
```typescript |
|||
public get visibleMessages() { |
|||
// Create a copy and reverse for newest-first display |
|||
const messages = this.currentConversation?.messages ?? []; |
|||
return [...messages].reverse(); |
|||
} |
|||
``` |
|||
|
|||
**Verification:** |
|||
1. Test with 1, 5, 10+ messages |
|||
2. Verify new messages appear at top immediately after submission |
|||
3. Confirm scroll position behavior (should stay at top or auto-scroll to newest) |
|||
4. Check that trackBy function still works correctly |
|||
|
|||
#### 1.2 Add Auto-Scroll to Newest Message |
|||
|
|||
**File:** `apps/client/src/app/pages/chat/chat-page.component.ts` |
|||
|
|||
```typescript |
|||
import { ElementRef, ViewChild, AfterViewInit } from '@angular/core'; |
|||
|
|||
export class GfChatPageComponent implements OnDestroy, OnInit, AfterViewInit { |
|||
@ViewChild('chatLogContainer', { static: false }) |
|||
chatLogContainer: ElementRef<HTMLElement>; |
|||
|
|||
// ... existing code ... |
|||
|
|||
ngAfterViewInit() { |
|||
// Scroll to top (newest message) when messages change |
|||
this.visibleMessages; // Trigger change detection |
|||
} |
|||
|
|||
private scrollToTop() { |
|||
if (this.chatLogContainer) { |
|||
this.chatLogContainer.nativeElement.scrollTop = 0; |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**Template update:** `apps/client/src/app/pages/chat/chat-page.component.html` |
|||
|
|||
```html |
|||
<div aria-live="polite" #chatLogContainer class="chat-log" role="log"> |
|||
<!-- messages --> |
|||
</div> |
|||
``` |
|||
|
|||
### Phase 2: Natural Language Responses for Non-Tool Queries |
|||
|
|||
#### 2.1 Update Backend Response Generation |
|||
|
|||
**File:** `apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts` |
|||
|
|||
Replace the `createNoToolDirectResponse()` function to generate more natural, contextual responses: |
|||
|
|||
```typescript |
|||
export function createNoToolDirectResponse(query: string): string { |
|||
const normalizedQuery = query.toLowerCase().trim(); |
|||
|
|||
// Greeting patterns |
|||
const greetingPatterns = [ |
|||
/^(hi|hello|hey|hiya|greetings)/i, |
|||
/^(good (morning|afternoon|evening))/i, |
|||
/^(how are you|how's it going|what's up)/i |
|||
]; |
|||
|
|||
// Name introduction patterns |
|||
const nameIntroductionPatterns = [ |
|||
/(?:my name is|i'm|i am|call me)\s+(\w+)/i, |
|||
/remember (?:that )?my name is\s+(\w+)/i |
|||
]; |
|||
|
|||
// Check for greeting |
|||
if (greetingPatterns.some(pattern => pattern.test(normalizedQuery))) { |
|||
const greetings = [ |
|||
"Hello! I'm here to help with your portfolio analysis. What would you like to know?", |
|||
"Hi! I can help you understand your portfolio better. What's on your mind?", |
|||
"Hey there! Ready to dive into your portfolio? Just ask!" |
|||
]; |
|||
return greetings[Math.floor(Math.random() * greetings.length)]; |
|||
} |
|||
|
|||
// Check for name introduction |
|||
const nameMatch = nameIntroductionPatterns.find(pattern => |
|||
pattern.test(normalizedQuery) |
|||
); |
|||
if (nameMatch) { |
|||
const match = normalizedQuery.match(nameMatch); |
|||
const name = match?.[1]; |
|||
if (name) { |
|||
return `Nice to meet you, ${name.charAt(0).toUpperCase() + name.slice(1)}! I've got that saved. What would you like to know about your portfolio today?`; |
|||
} |
|||
} |
|||
|
|||
// Default helpful response (more conversational) |
|||
const defaults = [ |
|||
"I'm here to help with your portfolio! You can ask me things like 'Show my top holdings' or 'What's my concentration risk?'", |
|||
"Sure! I can analyze your portfolio, check concentration risks, look up market prices, and more. What would you like to explore?", |
|||
"I'd be happy to help! Try asking about your holdings, risk analysis, or market data for your investments." |
|||
]; |
|||
|
|||
return defaults[Math.floor(Math.random() * defaults.length)]; |
|||
} |
|||
``` |
|||
|
|||
#### 2.2 Add Context Awareness for Follow-up Queries |
|||
|
|||
For users who say "thanks" or "ok" after a previous interaction, acknowledge it conversationally: |
|||
|
|||
```typescript |
|||
// Add to createNoToolDirectResponse |
|||
const acknowledgmentPatterns = [ |
|||
/^(thanks|thank you|thx|ty|ok|okay|great|awesome)/i |
|||
]; |
|||
|
|||
if (acknowledgmentPatterns.some(pattern => pattern.test(normalizedQuery))) { |
|||
const acknowledgments = [ |
|||
"You're welcome! Let me know if you need anything else.", |
|||
"Happy to help! What else would you like to know?", |
|||
"Anytime! Feel free to ask if you have more questions." |
|||
]; |
|||
return acknowledgments[Math.floor(Math.random() * acknowledgments.length)]; |
|||
} |
|||
``` |
|||
|
|||
#### 2.3 Update Memory to Track User Name |
|||
|
|||
When a user introduces themselves, store this in user preferences so it can be used in future responses: |
|||
|
|||
**File:** `apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.ts` |
|||
|
|||
Extend the `AiAgentUserPreferenceState` interface: |
|||
|
|||
```typescript |
|||
export interface AiAgentUserPreferenceState { |
|||
name?: string; // Add this |
|||
responseStyle?: 'concise' | 'detailed'; |
|||
updatedAt?: string; |
|||
} |
|||
``` |
|||
|
|||
Update `resolvePreferenceUpdate()` to extract and store user names: |
|||
|
|||
```typescript |
|||
export function resolvePreferenceUpdate({ |
|||
query, |
|||
userPreferences |
|||
}: { |
|||
query: string; |
|||
userPreferences?: AiAgentUserPreferenceState; |
|||
}): { |
|||
acknowledgement?: string; |
|||
userPreferences: AiAgentUserPreferenceState; |
|||
} { |
|||
const normalizedQuery = query.toLowerCase().trim(); |
|||
const nameMatch = normalizedQuery.match(/(?:my name is|i'm|i am|call me)\s+(\w+)/i); |
|||
|
|||
let name = userPreferences?.name; |
|||
if (nameMatch && nameMatch[1]) { |
|||
name = nameMatch[1]; |
|||
} |
|||
|
|||
return { |
|||
userPreferences: { |
|||
...userPreferences, |
|||
name, |
|||
responseStyle: userPreferences?.responseStyle, |
|||
updatedAt: new Date().toISOString() |
|||
} |
|||
}; |
|||
} |
|||
``` |
|||
|
|||
Then personalize responses when we know the user's name: |
|||
|
|||
```typescript |
|||
// In createNoToolDirectResponse or buildAnswer |
|||
if (userPreferences?.name) { |
|||
return `Hi ${userPreferences.name}! ${restOfResponse}`; |
|||
} |
|||
``` |
|||
|
|||
## Implementation Steps |
|||
|
|||
### Step 1: Message Ordering Fix (Frontend) |
|||
1. Update `chat-page.component.ts` to properly reverse message array |
|||
2. Add `AfterViewInit` hook and scroll-to-top logic |
|||
3. Update template with `#chatLogContainer` reference |
|||
4. Test with various message counts |
|||
5. Verify accessibility (aria-live still works correctly) |
|||
|
|||
### Step 2: Natural Language Responses (Backend) |
|||
1. Refactor `createNoToolDirectResponse()` in `ai-agent.policy.utils.ts` |
|||
2. Add greeting, acknowledgment, and name-introduction pattern matching |
|||
3. Add randomness to responses for variety |
|||
4. Write unit tests for new response patterns |
|||
5. Test with user queries from evals dataset |
|||
|
|||
### Step 3: User Name Memory (Backend) |
|||
1. Extend `AiAgentUserPreferenceState` interface with `name` field |
|||
2. Update `resolvePreferenceUpdate()` to extract names |
|||
3. Update `setUserPreferences()` to store names in Redis |
|||
4. Personalize responses when name is available |
|||
5. Add tests for name extraction and storage |
|||
|
|||
### Step 4: Integration Testing |
|||
1. End-to-end test: full conversation flow with greetings |
|||
2. Verify message ordering works correctly |
|||
3. Test name memory across multiple queries |
|||
4. Test with existing evals dataset (ensure no regressions) |
|||
5. Manual QA: test natural language feels conversational |
|||
|
|||
## Success Criteria |
|||
|
|||
### Message Ordering |
|||
- [ ] Newest messages appear at top of chat log |
|||
- [ ] New messages immediately appear at top after submission |
|||
- [ ] Scroll position stays at top (or auto-scrolls to newest) |
|||
- [ ] Works correctly with 1, 5, 10, 50+ messages |
|||
- [ ] Angular change detection works efficiently |
|||
- [ ] Accessibility (screen readers) still function correctly |
|||
|
|||
### Natural Language Responses |
|||
- [ ] Greetings ("hi", "hello") return friendly, varied responses |
|||
- [ ] Name introduction ("my name is Max") acknowledges the name |
|||
- [ ] Acknowledgments ("thanks", "ok") return polite follow-ups |
|||
- [ ] Default non-tool queries are more conversational |
|||
- [ ] User name is remembered across session |
|||
- [ ] Responses use name when available ("Hi Max!") |
|||
- [ ] No regressions in existing functionality |
|||
|
|||
### Code Quality |
|||
- [ ] All changes pass existing tests |
|||
- [ ] New unit tests for response patterns |
|||
- [ ] No TypeScript errors |
|||
- [ ] Code follows existing patterns |
|||
- [ ] Documentation updated if needed |
|||
|
|||
## Risks & Mitigations |
|||
|
|||
### Risk 1: Breaking Change in Message Display |
|||
**Risk:** Reversing message order could confuse existing users or break other components. |
|||
**Mitigation:** |
|||
- Test thoroughly in staging before deploying |
|||
- Consider adding a user preference for message order if feedback is negative |
|||
- Monitor for bug reports after deploy |
|||
|
|||
### Risk 2: Overly Casual Tone |
|||
**Risk:** Making responses too casual could reduce perceived professionalism. |
|||
**Mitigation:** |
|||
- Keep responses friendly but not slangy |
|||
- Avoid emojis (per project guidelines) |
|||
- Maintain focus on portfolio/finance context |
|||
- A/B test if unsure |
|||
|
|||
### Risk 3: Name Extraction False Positives |
|||
**Risk:** Pattern matching could incorrectly extract names from non-name sentences. |
|||
**Mitigation:** |
|||
- Use specific patterns (require "my name is", "call me", etc.) |
|||
- Only capitalize first letter of extracted name |
|||
- Don't persist name without confidence |
|||
- Add tests for edge cases |
|||
|
|||
### Risk 4: Performance Impact |
|||
**Risk:** Adding pattern matching for every query could slow response times. |
|||
**Mitigation:** |
|||
- Patterns are simple regex (should be fast) |
|||
- Only runs for non-tool queries (minority of cases) |
|||
- Profile before and after if concerned |
|||
- Could cache compiled regex patterns if needed |
|||
|
|||
## Testing Strategy |
|||
|
|||
### Unit Tests |
|||
1. Test `visibleMessages` getter returns correct order |
|||
2. Test `createNoToolDirectResponse()` with various inputs |
|||
3. Test name extraction patterns |
|||
4. Test user preferences update logic |
|||
|
|||
### Integration Tests |
|||
1. Test full chat flow with greeting → name → follow-up |
|||
2. Test message ordering with many messages |
|||
3. Test scrolling behavior |
|||
4. Test that tool-based queries still work correctly |
|||
|
|||
### Manual QA Checklist |
|||
- [ ] Send "hi" → get friendly greeting |
|||
- [ ] Send "my name is Max" → response acknowledges Max |
|||
- [ ] Send another query → response uses "Max" |
|||
- [ ] Send 5 messages → newest appears at top |
|||
- [ ] Refresh page → order preserved |
|||
- [ ] Ask portfolio question → still works |
|||
- [ ] Send "thanks" → get polite acknowledgment |
|||
|
|||
## Rollout Plan |
|||
|
|||
### Phase 1: Frontend Message Ordering (Low Risk) |
|||
- Deploy to staging |
|||
- QA team tests |
|||
- Deploy to production |
|||
- Monitor for 24 hours |
|||
|
|||
### Phase 2: Natural Language Backend (Medium Risk) |
|||
- Deploy to staging |
|||
- QA team tests with various queries |
|||
- Run evals dataset to check for regressions |
|||
- Deploy to production with feature flag if needed |
|||
- Monitor user feedback |
|||
|
|||
### Phase 3: Name Memory (Low Risk) |
|||
- Deploy after Phase 2 is stable |
|||
- Test name persistence across sessions |
|||
- Deploy to production |
|||
|
|||
## Documentation Updates |
|||
|
|||
- [ ] Update AI service documentation with new response patterns |
|||
- [ ] Add examples of natural language responses to docs |
|||
- [ ] Document user preferences schema changes |
|||
- [ ] Update any API documentation if relevant |
|||
|
|||
## Future Enhancements (Out of Scope) |
|||
|
|||
- More sophisticated NLP for intent detection |
|||
- Sentiment analysis to adjust response tone |
|||
- Multi-language support for greetings |
|||
- User customization of response style |
|||
- Quick suggestions based on conversation context |
|||
Loading…
Reference in new issue