Browse Source

Add streaming responses, switch to Haiku, user feedback, and startup data gathering

- Switch LLM from Claude Sonnet to Claude Haiku 3.5 (claude-haiku-4-5-20251001),
  reducing avg eval latency from ~7s to 3.8s with 100% pass rate (55/55)
- Add streaming endpoint (POST /ai/agent/stream) using Vercel AI SDK streamText()
  with SSE, so tokens render progressively in the chat UI
- Update Angular chat component to consume SSE via fetch + ReadableStream reader
- Add user feedback (thumbs up/down) buttons on assistant messages that POST to
  new /ai/feedback endpoint and log scores to Langfuse via REST API
- Add delayed loadCurrencies() call on startup to ensure exchange rates are
  populated after data-gathering queue processes on fresh deployments
- Fix eval suite dotenv loading so LLM-as-judge can read ANTHROPIC_API_KEY

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pull/6456/head
Alan Garber 1 month ago
parent
commit
5d0ff6d58b
  1. 47
      apps/api/src/app/endpoints/ai/ai.controller.ts
  2. 291
      apps/api/src/app/endpoints/ai/ai.service.ts
  3. 615
      apps/api/src/app/endpoints/ai/eval/eval-results.json
  4. 1
      apps/api/src/app/endpoints/ai/eval/eval.ts
  5. 17
      apps/api/src/services/cron/cron.service.ts
  6. 151
      apps/client/src/app/pages/ai-chat/ai-chat-page.component.ts
  7. 62
      apps/client/src/app/pages/ai-chat/ai-chat-page.html
  8. 32
      apps/client/src/app/pages/ai-chat/ai-chat-page.scss
  9. 10
      libs/ui/src/lib/services/data.service.ts

47
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -13,10 +13,12 @@ import {
Param, Param,
Post, Post,
Query, Query,
Res,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
@ -73,4 +75,49 @@ export class AiController {
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Post('agent/stream')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async agentChatStream(
@Body() body: { message: string; conversationHistory?: any[] },
@Res() res: Response
) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
await this.aiService.agentChatStream({
message: body.message,
conversationHistory: body.conversationHistory,
impersonationId: undefined,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id,
onChunk: (text) => {
res.write(`event: text\ndata: ${JSON.stringify(text)}\n\n`);
},
onDone: (metadata) => {
res.write(`event: done\ndata: ${JSON.stringify(metadata)}\n\n`);
res.end();
},
onError: (error) => {
res.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`);
res.end();
}
});
}
@Post('feedback')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async submitFeedback(
@Body() body: { traceId: string; value: number }
) {
return this.aiService.submitFeedback({
traceId: body.traceId,
value: body.value,
userId: this.request.user.id
});
}
} }

291
apps/api/src/app/endpoints/ai/ai.service.ts

@ -11,40 +11,41 @@ import {
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types'; import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { createAnthropic } from '@ai-sdk/anthropic'; import { createAnthropic } from '@ai-sdk/anthropic';
import { Injectable, Logger } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText, CoreMessage } from 'ai'; import { generateText, streamText, CoreMessage } from 'ai';
import { randomUUID } from 'crypto';
import type { ColumnDescriptor } from 'tablemark'; import type { ColumnDescriptor } from 'tablemark';
import { getPortfolioHoldingsTool } from './tools/portfolio-holdings.tool';
import { getPortfolioPerformanceTool } from './tools/portfolio-performance.tool';
import { getAccountSummaryTool } from './tools/account-summary.tool'; import { getAccountSummaryTool } from './tools/account-summary.tool';
import { getDividendSummaryTool } from './tools/dividend-summary.tool'; import { getDividendSummaryTool } from './tools/dividend-summary.tool';
import { getTransactionHistoryTool } from './tools/transaction-history.tool';
import { getLookupMarketDataTool } from './tools/market-data.tool';
import { getExchangeRateTool } from './tools/exchange-rate.tool'; import { getExchangeRateTool } from './tools/exchange-rate.tool';
import { getLookupMarketDataTool } from './tools/market-data.tool';
import { getPortfolioHoldingsTool } from './tools/portfolio-holdings.tool';
import { getPortfolioPerformanceTool } from './tools/portfolio-performance.tool';
import { getPortfolioReportTool } from './tools/portfolio-report.tool'; import { getPortfolioReportTool } from './tools/portfolio-report.tool';
import { getTransactionHistoryTool } from './tools/transaction-history.tool';
import { runVerificationChecks } from './verification'; import { runVerificationChecks } from './verification';
function getAgentSystemPrompt() { function getAgentSystemPrompt() {
return [ return [
`Today's date is ${new Date().toISOString().split('T')[0]}.`, `Today's date is ${new Date().toISOString().split('T')[0]}.`,
'', '',
'You are a helpful financial assistant for Ghostfolio, a personal wealth management application.', 'You are a helpful financial assistant for Ghostfolio, a personal wealth management application.',
'You help users understand their portfolio, holdings, performance, and financial data.', 'You help users understand their portfolio, holdings, performance, and financial data.',
'', '',
'IMPORTANT RULES:', 'IMPORTANT RULES:',
'1. Only provide information based on actual data from the tools available to you. NEVER make up or hallucinate financial data.', '1. Only provide information based on actual data from the tools available to you. NEVER make up or hallucinate financial data.',
'2. When citing specific numbers (prices, percentages, values), they MUST come directly from tool results.', '2. When citing specific numbers (prices, percentages, values), they MUST come directly from tool results.',
'3. If you cannot find the requested information, say so clearly rather than guessing.', '3. If you cannot find the requested information, say so clearly rather than guessing.',
'4. You are a READ-ONLY assistant. You cannot execute trades, modify portfolios, or make changes to accounts.', '4. You are a READ-ONLY assistant. You cannot execute trades, modify portfolios, or make changes to accounts.',
'5. If asked to perform actions like buying, selling, or transferring assets, politely decline and explain you can only provide information.', '5. If asked to perform actions like buying, selling, or transferring assets, politely decline and explain you can only provide information.',
'6. Include appropriate financial disclaimers when providing analytical or forward-looking commentary.', '6. Include appropriate financial disclaimers when providing analytical or forward-looking commentary.',
'7. When the user asks about performance for a specific time period, pass the appropriate dateRange parameter: "ytd" for this year, "1y" for past year, "5y" for 5 years, "mtd" for this month, "wtd" for this week, "1d" for today. Use "max" for all-time or when no specific period is mentioned.', '7. When the user asks about performance for a specific time period, pass the appropriate dateRange parameter: "ytd" for this year, "1y" for past year, "5y" for 5 years, "mtd" for this month, "wtd" for this week, "1d" for today. Use "max" for all-time or when no specific period is mentioned.',
'', '',
'DISCLAIMER: This is an AI assistant providing informational responses based on portfolio data.', 'DISCLAIMER: This is an AI assistant providing informational responses based on portfolio data.',
'This is not financial advice. Always consult with a qualified financial advisor before making investment decisions.' 'This is not financial advice. Always consult with a qualified financial advisor before making investment decisions.'
].join('\n'); ].join('\n');
} }
@ -197,36 +198,32 @@ export class AiService {
return [ return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, `You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
holdingsTableString, holdingsTableString,
"Structure your answer with these sections:", 'Structure your answer with these sections:',
"Overview: Briefly summarize the portfolio composition and allocation rationale.", 'Overview: Briefly summarize the portfolio composition and allocation rationale.',
"Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.", 'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',
"Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.", 'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.',
"Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.", 'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.',
"Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).", 'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).',
"Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.", 'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.',
"Conclusion: Provide a concise summary highlighting key insights.", 'Conclusion: Provide a concise summary highlighting key insights.',
`Provide your answer in the following language: ${languageCode}.` `Provide your answer in the following language: ${languageCode}.`
].join("\n"); ].join('\n');
} }
public async agentChat({ private buildAgentConfig({
conversationHistory, userId,
message,
impersonationId, impersonationId,
userCurrency, userCurrency
userId
}: { }: {
conversationHistory?: CoreMessage[]; userId: string;
message: string;
impersonationId?: string; impersonationId?: string;
userCurrency: string; userCurrency: string;
userId: string;
}) { }) {
const anthropicApiKey = process.env.ANTHROPIC_API_KEY; const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
if (!anthropicApiKey) { if (!anthropicApiKey) {
throw new Error( throw new Error(
"ANTHROPIC_API_KEY is not configured. Please set the environment variable." 'ANTHROPIC_API_KEY is not configured. Please set the environment variable.'
); );
} }
@ -272,23 +269,47 @@ export class AiService {
}) })
}; };
return { anthropic, tools };
}
public async agentChat({
conversationHistory,
message,
impersonationId,
userCurrency,
userId
}: {
conversationHistory?: CoreMessage[];
message: string;
impersonationId?: string;
userCurrency: string;
userId: string;
}) {
const { anthropic, tools } = this.buildAgentConfig({
userId,
impersonationId,
userCurrency
});
const messages: CoreMessage[] = [ const messages: CoreMessage[] = [
...(conversationHistory ?? []), ...(conversationHistory ?? []),
{ role: "user" as const, content: message } { role: 'user' as const, content: message }
]; ];
const traceId = randomUUID();
try { try {
const result = await generateText({ const result = await generateText({
model: anthropic("claude-sonnet-4-20250514"), model: anthropic('claude-haiku-4-5-20251001'),
system: getAgentSystemPrompt(), system: getAgentSystemPrompt(),
tools, tools,
toolChoice: "auto", toolChoice: 'auto',
messages, messages,
maxSteps: 5, maxSteps: 10,
experimental_telemetry: { experimental_telemetry: {
isEnabled: true, isEnabled: true,
functionId: "ghostfolio-ai-agent", functionId: 'ghostfolio-ai-agent',
metadata: { userId } metadata: { userId, traceId }
} }
}); });
@ -299,12 +320,13 @@ export class AiService {
args: tc.args args: tc.args
})); }));
const toolResults = result.steps const toolResults = result.steps.flatMap(
.flatMap((step) => step.toolResults ?? []); (step) => step.toolResults ?? []
);
const updatedHistory: CoreMessage[] = [ const updatedHistory: CoreMessage[] = [
...messages, ...messages,
{ role: "assistant" as const, content: result.text } { role: 'assistant' as const, content: result.text }
]; ];
// Run verification checks (disclaimer, hallucination detection, scope validation) // Run verification checks (disclaimer, hallucination detection, scope validation)
@ -318,15 +340,16 @@ export class AiService {
response: responseText, response: responseText,
toolCalls, toolCalls,
verificationChecks: checks, verificationChecks: checks,
conversationHistory: updatedHistory conversationHistory: updatedHistory,
traceId
}; };
} catch (error) { } catch (error) {
this.logger.error("Agent chat error:", error); this.logger.error('Agent chat error:', error);
if (error?.message?.includes("API key")) { if (error?.message?.includes('API key')) {
return { return {
response: response:
"The AI service is not properly configured. Please check your API key settings.", 'The AI service is not properly configured. Please check your API key settings.',
toolCalls: [], toolCalls: [],
conversationHistory: messages conversationHistory: messages
}; };
@ -334,10 +357,170 @@ export class AiService {
return { return {
response: response:
"I encountered an issue processing your request. Please try again later.", 'I encountered an issue processing your request. Please try again later.',
toolCalls: [], toolCalls: [],
conversationHistory: messages conversationHistory: messages
}; };
} }
} }
public async agentChatStream({
conversationHistory,
message,
impersonationId,
userCurrency,
userId,
onChunk,
onDone,
onError
}: {
conversationHistory?: CoreMessage[];
message: string;
impersonationId?: string;
userCurrency: string;
userId: string;
onChunk: (text: string) => void;
onDone: (metadata: {
response: string;
toolCalls: any[];
verificationChecks: any[];
conversationHistory: CoreMessage[];
traceId: string;
}) => void;
onError: (error: string) => void;
}) {
const messages: CoreMessage[] = [
...(conversationHistory ?? []),
{ role: 'user' as const, content: message }
];
const traceId = randomUUID();
try {
const { anthropic, tools } = this.buildAgentConfig({
userId,
impersonationId,
userCurrency
});
const result = streamText({
model: anthropic('claude-haiku-4-5-20251001'),
system: getAgentSystemPrompt(),
tools,
toolChoice: 'auto',
messages,
maxSteps: 10,
experimental_telemetry: {
isEnabled: true,
functionId: 'ghostfolio-ai-agent-stream',
metadata: { userId, traceId }
}
});
let fullText = '';
for await (const chunk of result.textStream) {
fullText += chunk;
onChunk(chunk);
}
const stepsResult = await result.steps;
const toolCalls = stepsResult
.flatMap((step) => step.toolCalls ?? [])
.map((tc) => ({
toolName: tc.toolName,
args: tc.args
}));
const toolResults = stepsResult.flatMap((step) => step.toolResults ?? []);
const { responseText, checks } = runVerificationChecks({
responseText: fullText,
toolResults,
toolCalls
});
// If verification added extra text (e.g. disclaimer), send the difference
if (responseText.length > fullText.length) {
onChunk(responseText.slice(fullText.length));
}
const updatedHistory: CoreMessage[] = [
...messages,
{ role: 'assistant' as const, content: responseText }
];
onDone({
response: responseText,
toolCalls,
verificationChecks: checks,
conversationHistory: updatedHistory,
traceId
});
} catch (error) {
this.logger.error('Agent stream error:', error);
onError(
error?.message?.includes('API key')
? 'The AI service is not properly configured.'
: 'I encountered an issue processing your request.'
);
}
}
public async submitFeedback({
traceId,
value,
userId
}: {
traceId: string;
value: number;
userId: string;
}) {
const langfuseSecretKey = process.env.LANGFUSE_SECRET_KEY;
const langfusePublicKey = process.env.LANGFUSE_PUBLIC_KEY;
const langfuseBaseUrl =
process.env.LANGFUSE_BASEURL || 'https://cloud.langfuse.com';
if (!langfuseSecretKey || !langfusePublicKey) {
this.logger.warn('Langfuse keys not configured — feedback not recorded');
return { success: false, reason: 'Langfuse not configured' };
}
try {
const credentials = Buffer.from(
`${langfusePublicKey}:${langfuseSecretKey}`
).toString('base64');
const res = await fetch(`${langfuseBaseUrl}/api/public/scores`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${credentials}`
},
body: JSON.stringify({
traceId,
name: 'user-feedback',
value,
comment: value === 1 ? 'thumbs up' : 'thumbs down',
source: 'API',
metadata: { userId }
})
});
if (!res.ok) {
const errorBody = await res.text();
this.logger.warn(
`Langfuse score API error: ${res.status} ${errorBody}`
);
return { success: false, reason: `Langfuse API error: ${res.status}` };
}
this.logger.log(`Feedback recorded: traceId=${traceId} value=${value}`);
return { success: true };
} catch (error) {
this.logger.error('Failed to submit feedback to Langfuse:', error);
return { success: false, reason: 'Failed to contact Langfuse' };
}
}
} }

615
apps/api/src/app/endpoints/ai/eval/eval-results.json

File diff suppressed because it is too large

1
apps/api/src/app/endpoints/ai/eval/eval.ts

@ -22,6 +22,7 @@
* CATEGORY=<name> run only one category (happy_path, edge_case, adversarial, multi_step) * CATEGORY=<name> run only one category (happy_path, edge_case, adversarial, multi_step)
*/ */
import "dotenv/config";
import * as http from "http"; import * as http from "http";
import * as fs from "fs"; import * as fs from "fs";

17
apps/api/src/services/cron/cron.service.ts

@ -35,6 +35,23 @@ export class CronService implements OnApplicationBootstrap {
this.logger.log('Triggering initial data gathering on startup...'); this.logger.log('Triggering initial data gathering on startup...');
await this.dataGatheringService.gather7Days(); await this.dataGatheringService.gather7Days();
} }
// Reload exchange rates after data gathering queue has had time
// to process high-priority currency pair jobs (~1 job per 4s).
// This ensures rates are populated on fresh Railway deployments.
setTimeout(
async () => {
try {
await this.exchangeRateDataService.loadCurrencies();
this.logger.log(
'Exchange rates reloaded after startup data gathering'
);
} catch (error) {
this.logger.warn('Failed to reload exchange rates on startup', error);
}
},
5 * 60 * 1000
);
} }
@Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE) @Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE)

151
apps/client/src/app/pages/ai-chat/ai-chat-page.component.ts

@ -1,3 +1,4 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -6,11 +7,13 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
NgZone,
OnDestroy, OnDestroy,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -18,6 +21,8 @@ import { Subject, takeUntil } from 'rxjs';
interface ChatMessage { interface ChatMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
traceId?: string;
feedback?: 1 | -1;
} }
@Component({ @Component({
@ -27,6 +32,7 @@ interface ChatMessage {
CommonModule, CommonModule,
FormsModule, FormsModule,
MatButtonModule, MatButtonModule,
MatIconModule,
MatInputModule, MatInputModule,
MatProgressSpinnerModule MatProgressSpinnerModule
], ],
@ -43,11 +49,14 @@ export class GfAiChatPageComponent implements OnDestroy {
public isLoading = false; public isLoading = false;
private conversationHistory: any[] = []; private conversationHistory: any[] = [];
private abortController: AbortController | null = null;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
private ngZone: NgZone,
private tokenStorageService: TokenStorageService
) {} ) {}
public sendMessage() { public sendMessage() {
@ -62,34 +71,25 @@ export class GfAiChatPageComponent implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
this.scrollToBottom(); this.scrollToBottom();
const assistantMsg: ChatMessage = { role: 'assistant', content: '' };
this.messages.push(assistantMsg);
this.changeDetectorRef.markForCheck();
this.streamResponse(message, assistantMsg);
}
public submitFeedback(msg: ChatMessage, value: 1 | -1) {
if (!msg.traceId || msg.feedback) {
return;
}
msg.feedback = value;
this.changeDetectorRef.markForCheck();
this.dataService this.dataService
.postAgentChat({ .postAgentFeedback({ traceId: msg.traceId, value })
message,
conversationHistory: this.conversationHistory
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe();
next: (response) => {
this.messages.push({
role: 'assistant',
content: response.response
});
this.conversationHistory = response.conversationHistory;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
},
error: () => {
this.messages.push({
role: 'assistant',
content:
'Sorry, something went wrong. Please try again.'
});
this.isLoading = false;
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
}
});
} }
public onKeyDown(event: KeyboardEvent) { public onKeyDown(event: KeyboardEvent) {
@ -100,10 +100,107 @@ export class GfAiChatPageComponent implements OnDestroy {
} }
public ngOnDestroy() { public ngOnDestroy() {
this.abortController?.abort();
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private async streamResponse(message: string, assistantMsg: ChatMessage) {
this.abortController = new AbortController();
const token = this.tokenStorageService.getToken();
try {
const res = await fetch('/api/v1/ai/agent/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({
message,
conversationHistory: this.conversationHistory
}),
signal: this.abortController.signal
});
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// Parse SSE events from buffer
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let eventType = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
const data = line.slice(6);
this.ngZone.run(() => {
if (eventType === 'text') {
assistantMsg.content += JSON.parse(data);
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
} else if (eventType === 'done') {
const metadata = JSON.parse(data);
assistantMsg.traceId = metadata.traceId;
assistantMsg.content = metadata.response;
this.conversationHistory = metadata.conversationHistory;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
} else if (eventType === 'error') {
const errorData = JSON.parse(data);
assistantMsg.content =
errorData.error ||
'Sorry, something went wrong. Please try again.';
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}
});
eventType = '';
}
}
}
// If stream ended without a done event, finalize
if (this.isLoading) {
this.ngZone.run(() => {
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
} catch (error: any) {
if (error?.name === 'AbortError') {
return;
}
this.ngZone.run(() => {
assistantMsg.content = 'Sorry, something went wrong. Please try again.';
this.isLoading = false;
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
});
}
}
private scrollToBottom() { private scrollToBottom() {
setTimeout(() => { setTimeout(() => {
if (this.messagesContainer) { if (this.messagesContainer) {

62
apps/client/src/app/pages/ai-chat/ai-chat-page.html

@ -5,12 +5,19 @@
<div #messagesContainer class="messages-container"> <div #messagesContainer class="messages-container">
@if (messages.length === 0 && !isLoading) { @if (messages.length === 0 && !isLoading) {
<div class="empty-state"> <div class="empty-state">
<p i18n>Ask me anything about your portfolio, holdings, performance, or market data.</p> <p i18n>
Ask me anything about your portfolio, holdings, performance, or
market data.
</p>
</div> </div>
} }
@for (msg of messages; track $index) { @for (msg of messages; track $index) {
<div class="message" [class.user]="msg.role === 'user'" [class.assistant]="msg.role === 'assistant'"> <div
class="message"
[class.assistant]="msg.role === 'assistant'"
[class.user]="msg.role === 'user'"
>
<div class="message-label"> <div class="message-label">
@if (msg.role === 'user') { @if (msg.role === 'user') {
<strong i18n>You</strong> <strong i18n>You</strong>
@ -18,36 +25,59 @@
<strong i18n>Assistant</strong> <strong i18n>Assistant</strong>
} }
</div> </div>
<div class="message-content">{{ msg.content }}</div> @if (msg.role === 'assistant' && !msg.content && isLoading) {
</div> <mat-spinner diameter="24"></mat-spinner>
} } @else {
<div class="message-content">{{ msg.content }}</div>
@if (isLoading) { }
<div class="message assistant"> @if (msg.role === 'assistant' && msg.traceId) {
<div class="message-label"><strong i18n>Assistant</strong></div> <div class="feedback-buttons">
<mat-spinner diameter="24"></mat-spinner> <button
class="feedback-btn"
mat-icon-button
title="Helpful"
[class.selected]="msg.feedback === 1"
[disabled]="!!msg.feedback"
(click)="submitFeedback(msg, 1)"
>
<mat-icon>thumb_up</mat-icon>
</button>
<button
class="feedback-btn"
mat-icon-button
title="Not helpful"
[class.selected]="msg.feedback === -1"
[disabled]="!!msg.feedback"
(click)="submitFeedback(msg, -1)"
>
<mat-icon>thumb_down</mat-icon>
</button>
</div>
}
</div> </div>
} }
</div> </div>
<div class="input-area"> <div class="input-area">
<mat-form-field class="w-100" appearance="outline"> <mat-form-field appearance="outline" class="w-100">
<input <input
i18n-placeholder
matInput matInput
placeholder="Ask about your portfolio..." placeholder="Ask about your portfolio..."
i18n-placeholder [disabled]="isLoading"
[(ngModel)]="userInput" [(ngModel)]="userInput"
(keydown)="onKeyDown($event)" (keydown)="onKeyDown($event)"
[disabled]="isLoading"
/> />
</mat-form-field> </mat-form-field>
<button <button
mat-flat-button
color="primary" color="primary"
i18n
mat-flat-button
[disabled]="!userInput.trim() || isLoading" [disabled]="!userInput.trim() || isLoading"
(click)="sendMessage()" (click)="sendMessage()"
i18n >
>Send</button> Send
</button>
</div> </div>
</div> </div>
</div> </div>

32
apps/client/src/app/pages/ai-chat/ai-chat-page.scss

@ -71,6 +71,38 @@
line-height: 1.5; line-height: 1.5;
} }
.feedback-buttons {
display: flex;
gap: 0.25rem;
margin-top: 0.25rem;
.feedback-btn {
width: 28px;
height: 28px;
line-height: 28px;
opacity: 0.5;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
opacity: 1;
}
&.selected {
opacity: 1;
color: rgb(var(--palette-primary-500));
}
&:disabled:not(.selected) {
opacity: 0.3;
}
}
}
.input-area { .input-area {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

10
libs/ui/src/lib/services/data.service.ts

@ -740,17 +740,19 @@ export class DataService {
return this.http.get<WatchlistResponse>('/api/v1/watchlist'); return this.http.get<WatchlistResponse>('/api/v1/watchlist');
} }
public postAgentChat(body: { public postAgentChat(body: { message: string; conversationHistory?: any[] }) {
message: string;
conversationHistory?: any[];
}) {
return this.http.post<{ return this.http.post<{
response: string; response: string;
toolCalls: Array<{ toolName: string; args: any }>; toolCalls: Array<{ toolName: string; args: any }>;
conversationHistory: any[]; conversationHistory: any[];
traceId?: string;
}>('/api/v1/ai/agent', body); }>('/api/v1/ai/agent', body);
} }
public postAgentFeedback(body: { traceId: string; value: number }) {
return this.http.post<{ success: boolean }>('/api/v1/ai/feedback', body);
}
public loginAnonymous(accessToken: string) { public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', { return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken accessToken

Loading…
Cancel
Save