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. 259
      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. 147
      apps/client/src/app/pages/ai-chat/ai-chat-page.component.ts
  7. 56
      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
});
}
} }

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

@ -11,20 +11,21 @@ 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() {
@ -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)

147
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,46 +71,134 @@ 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 })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
}
public onKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
}
}
public ngOnDestroy() {
this.abortController?.abort();
this.unsubscribeSubject.next();
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, message,
conversationHistory: this.conversationHistory conversationHistory: this.conversationHistory
}) }),
.pipe(takeUntil(this.unsubscribeSubject)) signal: this.abortController.signal
.subscribe({
next: (response) => {
this.messages.push({
role: 'assistant',
content: response.response
}); });
this.conversationHistory = response.conversationHistory;
this.isLoading = false; 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.changeDetectorRef.markForCheck();
this.scrollToBottom(); this.scrollToBottom();
}, } else if (eventType === 'done') {
error: () => { const metadata = JSON.parse(data);
this.messages.push({ assistantMsg.traceId = metadata.traceId;
role: 'assistant', assistantMsg.content = metadata.response;
content: this.conversationHistory = metadata.conversationHistory;
'Sorry, something went wrong. Please try again.'
});
this.isLoading = false; this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
this.scrollToBottom(); 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 = '';
}
}
} }
public onKeyDown(event: KeyboardEvent) { // If stream ended without a done event, finalize
if (event.key === 'Enter' && !event.shiftKey) { if (this.isLoading) {
event.preventDefault(); this.ngZone.run(() => {
this.sendMessage(); this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
} }
} catch (error: any) {
if (error?.name === 'AbortError') {
return;
} }
public ngOnDestroy() { this.ngZone.run(() => {
this.unsubscribeSubject.next(); assistantMsg.content = 'Sorry, something went wrong. Please try again.';
this.unsubscribeSubject.complete(); this.isLoading = false;
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
});
}
} }
private scrollToBottom() { private scrollToBottom() {

56
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>
@if (msg.role === 'assistant' && !msg.content && isLoading) {
<mat-spinner diameter="24"></mat-spinner>
} @else {
<div class="message-content">{{ msg.content }}</div> <div class="message-content">{{ msg.content }}</div>
}
@if (msg.role === 'assistant' && msg.traceId) {
<div class="feedback-buttons">
<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>
} }
@if (isLoading) {
<div class="message assistant">
<div class="message-label"><strong i18n>Assistant</strong></div>
<mat-spinner diameter="24"></mat-spinner>
</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