Browse Source

feat(agent): fix Langfuse tracing + redesign UI with Ghostfolio theme

- Switch to static imports for @langfuse/otel and @opentelemetry/sdk-node
  so webpack includes them in the production bundle
- Redesign agent chat UI: black + teal color scheme matching main app
- Add animated ghostfolio_squash.webm video as logo in header and welcome
- Add /agent/video endpoint to serve the animation
- Update Dockerfile to copy video asset to production build
pull/6459/head
jpwilson 1 month ago
parent
commit
b1779341e0
  1. 4
      Dockerfile.railway
  2. 98
      apps/api/src/app/agent/agent-chat.html
  3. 29
      apps/api/src/app/agent/agent.controller.ts
  4. BIN
      apps/api/src/assets/ghostfolio_squash.webm
  5. 21
      apps/api/src/main.ts

4
Dockerfile.railway

@ -39,8 +39,10 @@ COPY prisma /ghostfolio/dist/apps/api/prisma/
COPY package.json /ghostfolio/dist/apps/api/ COPY package.json /ghostfolio/dist/apps/api/
RUN npm run database:generate-typings RUN npm run database:generate-typings
# Copy agent chat HTML to dist # Copy agent chat HTML and assets to dist
RUN cp /ghostfolio/apps/api/src/app/agent/agent-chat.html /ghostfolio/dist/apps/api/ 2>/dev/null || true RUN cp /ghostfolio/apps/api/src/app/agent/agent-chat.html /ghostfolio/dist/apps/api/ 2>/dev/null || true
RUN mkdir -p /ghostfolio/dist/apps/api/assets && \
cp /ghostfolio/apps/api/src/assets/ghostfolio_squash.webm /ghostfolio/dist/apps/api/assets/ 2>/dev/null || true
# Runtime image # Runtime image
FROM node:22-slim FROM node:22-slim

98
apps/api/src/app/agent/agent-chat.html

@ -8,31 +8,37 @@
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e; background: #0c0c0c;
color: #e0e0e0; color: #e0e0e0;
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
header { header {
background: #16213e; background: #111111;
padding: 16px 24px; padding: 12px 24px;
border-bottom: 1px solid #2a2a4a; border-bottom: 1px solid #2a2a2a;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.logo-video {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
}
header a.back { header a.back {
color: #a78bfa; color: #36cfb4;
text-decoration: none; text-decoration: none;
font-size: 14px; font-size: 14px;
padding: 6px 14px; padding: 6px 14px;
border-radius: 6px; border-radius: 6px;
border: 1px solid #2a2a4a; border: 1px solid #2a2a2a;
transition: background 0.2s; transition: background 0.2s;
} }
header a.back:hover { background: #222244; } header a.back:hover { background: #1a1a1a; }
header h1 { font-size: 18px; font-weight: 600; } header h1 { font-size: 18px; font-weight: 600; color: #ffffff; }
header span { font-size: 12px; color: #888; } header span { font-size: 12px; color: #888; }
.header-actions { .header-actions {
margin-left: auto; margin-left: auto;
@ -43,16 +49,16 @@
.header-actions button { .header-actions button {
padding: 6px 14px; padding: 6px 14px;
font-size: 12px; font-size: 12px;
background: #222244; background: #1a1a1a;
border: 1px solid #2a2a4a; border: 1px solid #2a2a2a;
color: #e0e0e0; color: #e0e0e0;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
} }
.header-actions button:hover { background: #2a2a5a; } .header-actions button:hover { background: #252525; }
.status { .status {
font-size: 12px; font-size: 12px;
color: #4ade80; color: #36cfb4;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@ -61,7 +67,7 @@
content: ''; content: '';
width: 8px; width: 8px;
height: 8px; height: 8px;
background: #4ade80; background: #36cfb4;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
} }
@ -70,8 +76,8 @@
.main-content { display: flex; flex: 1; overflow: hidden; } .main-content { display: flex; flex: 1; overflow: hidden; }
#sidebar { #sidebar {
width: 260px; width: 260px;
background: #16213e; background: #111111;
border-right: 1px solid #2a2a4a; border-right: 1px solid #2a2a2a;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
@ -79,20 +85,22 @@
#sidebar h3 { #sidebar h3 {
padding: 12px 16px; padding: 12px 16px;
font-size: 13px; font-size: 13px;
color: #888; color: #36cfb4;
border-bottom: 1px solid #2a2a4a; border-bottom: 1px solid #2a2a2a;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.conv-item { .conv-item {
padding: 10px 16px; padding: 10px 16px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #1a1a2e; border-bottom: 1px solid #1a1a1a;
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.conv-item:hover { background: #222244; } .conv-item:hover { background: #1a1a1a; }
.conv-item.active { background: #0f3460; } .conv-item.active { background: #1a2a2a; border-left: 3px solid #36cfb4; }
.conv-item .conv-date { font-size: 10px; color: #666; margin-top: 2px; } .conv-item .conv-date { font-size: 10px; color: #666; margin-top: 2px; }
.conv-item .conv-delete { .conv-item .conv-delete {
float: right; float: right;
@ -121,12 +129,14 @@
} }
.message.user { .message.user {
align-self: flex-end; align-self: flex-end;
background: #0f3460; background: #1a3a35;
border: 1px solid #2a5a4a;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
.message.assistant { .message.assistant {
align-self: flex-start; align-self: flex-start;
background: #222244; background: #1a1a1a;
border: 1px solid #2a2a2a;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
.message.assistant .tool-calls { .message.assistant .tool-calls {
@ -137,12 +147,12 @@
color: #888; color: #888;
} }
.message.assistant .tool-calls span { .message.assistant .tool-calls span {
background: #1a1a3e; background: #0a1a18;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
margin-right: 4px; margin-right: 4px;
font-family: monospace; font-family: monospace;
color: #a78bfa; color: #36cfb4;
} }
.verification { .verification {
margin-top: 6px; margin-top: 6px;
@ -150,7 +160,7 @@
border-top: 1px solid #333; border-top: 1px solid #333;
font-size: 11px; font-size: 11px;
} }
.verification.pass { color: #4ade80; } .verification.pass { color: #36cfb4; }
.verification.fail { color: #f87171; } .verification.fail { color: #f87171; }
.message.error { .message.error {
align-self: center; align-self: center;
@ -160,7 +170,7 @@
} }
.typing { .typing {
align-self: flex-start; align-self: flex-start;
color: #888; color: #36cfb4;
font-size: 13px; font-size: 13px;
padding: 8px 16px; padding: 8px 16px;
} }
@ -175,8 +185,8 @@
} }
#input-area { #input-area {
padding: 16px 24px; padding: 16px 24px;
background: #16213e; background: #111111;
border-top: 1px solid #2a2a4a; border-top: 1px solid #2a2a2a;
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
@ -184,26 +194,27 @@
flex: 1; flex: 1;
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #2a2a4a; border: 1px solid #2a2a2a;
background: #1a1a2e; background: #0c0c0c;
color: #e0e0e0; color: #e0e0e0;
font-size: 14px; font-size: 14px;
outline: none; outline: none;
font-family: inherit; font-family: inherit;
} }
#input:focus { border-color: #4a4a8a; } #input:focus { border-color: #36cfb4; }
#input::placeholder { color: #555; } #input::placeholder { color: #555; }
button { button {
padding: 12px 24px; padding: 12px 24px;
border-radius: 8px; border-radius: 8px;
border: none; border: none;
background: #0f3460; background: #1a3a35;
color: #e0e0e0; color: #36cfb4;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
font-weight: 500;
} }
button:hover { background: #1a4a80; } button:hover { background: #245a4a; }
button:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { opacity: 0.5; cursor: not-allowed; }
.welcome { .welcome {
text-align: center; text-align: center;
@ -212,7 +223,13 @@
font-size: 14px; font-size: 14px;
line-height: 2; line-height: 2;
} }
.welcome h2 { color: #888; font-size: 16px; margin-bottom: 8px; } .welcome video {
width: 120px;
height: 120px;
border-radius: 16px;
margin-bottom: 12px;
}
.welcome h2 { color: #36cfb4; font-size: 18px; margin-bottom: 8px; }
.suggestions { .suggestions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -223,15 +240,16 @@
.suggestions button { .suggestions button {
padding: 8px 14px; padding: 8px 14px;
font-size: 12px; font-size: 12px;
background: #222244; background: #1a1a1a;
border: 1px solid #2a2a4a; border: 1px solid #2a2a2a;
color: #e0e0e0;
} }
.suggestions button:hover { background: #2a2a5a; } .suggestions button:hover { background: #1a2a2a; border-color: #36cfb4; color: #36cfb4; }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<a class="back" id="back-link" href="/en/">&#8592; Ghostfolio</a> <video class="logo-video" autoplay loop muted playsinline src="/api/v1/agent/video"></video>
<h1>Agent</h1> <h1>Agent</h1>
<span>AI Financial Assistant</span> <span>AI Financial Assistant</span>
<div class="header-actions"> <div class="header-actions">
@ -248,6 +266,7 @@
<div id="chat-area"> <div id="chat-area">
<div id="chat"> <div id="chat">
<div class="welcome"> <div class="welcome">
<video autoplay loop muted playsinline src="/api/v1/agent/video"></video>
<h2>Ask me about your portfolio</h2> <h2>Ask me about your portfolio</h2>
<div>I can analyze your holdings, look up market data, review transactions, assess risk, and estimate taxes.</div> <div>I can analyze your holdings, look up market data, review transactions, assess risk, and estimate taxes.</div>
<div class="suggestions"> <div class="suggestions">
@ -374,6 +393,7 @@
messages = []; messages = [];
chat.innerHTML = ` chat.innerHTML = `
<div class="welcome"> <div class="welcome">
<video autoplay loop muted playsinline src="/api/v1/agent/video"></video>
<h2>Ask me about your portfolio</h2> <h2>Ask me about your portfolio</h2>
<div>I can analyze your holdings, look up market data, review transactions, assess risk, and estimate taxes.</div> <div>I can analyze your holdings, look up market data, review transactions, assess risk, and estimate taxes.</div>
<div class="suggestions"> <div class="suggestions">

29
apps/api/src/app/agent/agent.controller.ts

@ -64,6 +64,35 @@ export class AgentController {
return res.status(404).send('Chat UI not found'); return res.status(404).send('Chat UI not found');
} }
@Get('video')
public serveVideo(@Res() res: Response) {
const fs = require('node:fs');
const path = require('node:path');
const paths = [
path.join(
process.cwd(),
'apps',
'api',
'src',
'assets',
'ghostfolio_squash.webm'
),
path.join(__dirname, '..', 'assets', 'ghostfolio_squash.webm'),
path.join(process.cwd(), 'assets', 'ghostfolio_squash.webm')
];
for (const p of paths) {
if (fs.existsSync(p)) {
res.setHeader('Content-Type', 'video/webm');
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.sendFile(p);
}
}
return res.status(404).send('Video not found');
}
@Get('conversations') @Get('conversations')
@HasPermission(permissions.createOrder) @HasPermission(permissions.createOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

BIN
apps/api/src/assets/ghostfolio_squash.webm

Binary file not shown.

21
apps/api/src/main.ts

@ -19,21 +19,16 @@ import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { LangfuseSpanProcessor } from '@langfuse/otel';
// Initialize Langfuse OpenTelemetry tracing (if configured) // Initialize Langfuse OpenTelemetry tracing (must happen before NestJS bootstrap)
if (process.env.LANGFUSE_SECRET_KEY) { if (process.env.LANGFUSE_SECRET_KEY) {
try { const sdk = new NodeSDK({
const { NodeSDK } = require('@opentelemetry/sdk-node'); spanProcessors: [new LangfuseSpanProcessor()]
const { LangfuseSpanProcessor } = require('@langfuse/otel'); });
sdk.start();
const sdk = new NodeSDK({ console.log('Langfuse tracing initialized');
spanProcessors: [new LangfuseSpanProcessor()]
});
sdk.start();
console.log('Langfuse tracing initialized');
} catch (error) {
console.warn('Langfuse tracing not available:', error.message);
}
} }
async function bootstrap() { async function bootstrap() {

Loading…
Cancel
Save