mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- Add /agent route and full-page chat UI (suggestions, messages, send) - Add 'AI Agent' nav link in header (desktop + mobile) for users with readAiPrompt - Use session JWT for agent API (no security token prompt when logged in) - Add LangSmith tracing (wrapAISDK, createLangSmithProviderOptions) when LANGSMITH_API_KEY set - Document LANGSMITH_* in ENV_KEYS and agent README - Add TESTING.md for curl/browser agent testing - Add langsmith dep; internal route agent in routes.ts Co-authored-by: Cursor <cursoragent@cursor.com>pull/6386/head
14 changed files with 962 additions and 665 deletions
@ -0,0 +1,55 @@ |
|||
# How to Test the Agent (Production / Railway) |
|||
|
|||
Base URL: `https://ghostfolio-production-1d0f.up.railway.app` (or your deployed URL) |
|||
|
|||
## 1. Health check |
|||
|
|||
```bash |
|||
curl -s https://ghostfolio-production-1d0f.up.railway.app/api/v1/health |
|||
# Expect: {"status":"OK"} |
|||
``` |
|||
|
|||
## 2. Get a JWT from your security token |
|||
|
|||
Use your Ghostfolio **security token** (Admin → your user → access token). |
|||
|
|||
```bash |
|||
export BASE=https://ghostfolio-production-1d0f.up.railway.app |
|||
export SECURITY_TOKEN="<paste your security token here>" |
|||
|
|||
curl -s -X POST "$BASE/api/v1/auth/anonymous" \ |
|||
-H "Content-Type: application/json" \ |
|||
-d "{\"accessToken\": \"$SECURITY_TOKEN\"}" |
|||
# Expect: {"authToken":"eyJhbGc..."} → save the authToken value |
|||
``` |
|||
|
|||
## 3. Chat with the agent (API) |
|||
|
|||
```bash |
|||
export JWT="<paste the authToken from step 2>" |
|||
|
|||
curl -s -X POST "$BASE/api/v1/agent/chat" \ |
|||
-H "Content-Type: application/json" \ |
|||
-H "Authorization: Bearer $JWT" \ |
|||
-d '{"messages":[{"role":"user","content":"What is my current portfolio allocation?"}]}' |
|||
# Expect: {"message":{"role":"assistant","content":"..."},"verification":{...}} |
|||
``` |
|||
|
|||
## 4. Chat in the browser (easiest) |
|||
|
|||
1. Open: **https://ghostfolio-production-1d0f.up.railway.app/api/v1/agent/chat** |
|||
2. When prompted, paste your **security token**. |
|||
3. The page exchanges it for a JWT and then you can chat in the UI. |
|||
|
|||
## 5. Example test questions |
|||
|
|||
- "What is my portfolio allocation?" |
|||
- "How did my portfolio perform this year?" |
|||
- "List my recent transactions." |
|||
- "What is the current price of AAPL?" |
|||
|
|||
## Troubleshooting |
|||
|
|||
- **403 on /auth/anonymous:** Security token is wrong or expired. Regenerate in Ghostfolio Admin. |
|||
- **403 on /agent/chat:** JWT expired or missing. Get a fresh JWT from step 2. |
|||
- **500 or empty response:** Check `OPENROUTER_API_KEY` and `OPENROUTER_MODEL` in Railway Variables. |
|||
@ -0,0 +1,81 @@ |
|||
<div class="container h-100 d-flex flex-column agent-page"> |
|||
<h1 class="h3 line-height-1 mb-3 mt-3"> |
|||
<span i18n>AI Agent</span> |
|||
<small class="text-muted d-block mt-1" i18n>Ask questions about your portfolio in plain language.</small> |
|||
</h1> |
|||
|
|||
@if (messages.length === 0 && !isLoading) { |
|||
<div class="suggestions mb-3"> |
|||
<p class="text-muted mb-2" i18n>Try asking:</p> |
|||
<div class="d-flex flex-wrap gap-2"> |
|||
@for (s of suggestions; track s) { |
|||
<button |
|||
class="suggestion-chip" |
|||
mat-stroked-button |
|||
(click)="onSuggestionClick(s)" |
|||
> |
|||
{{ s }} |
|||
</button> |
|||
} |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<div |
|||
#messagesContainer |
|||
class="messages-container flex-grow-1 overflow-auto mb-2 p-2 rounded" |
|||
> |
|||
@for (msg of messages; track $index) { |
|||
<div |
|||
class="message-row d-flex gap-2 mb-3" |
|||
[class.user]="msg.role === 'user'" |
|||
[class.assistant]="msg.role === 'assistant'" |
|||
> |
|||
@if (msg.role === 'assistant') { |
|||
<div class="avatar assistant-avatar">G</div> |
|||
} |
|||
<div |
|||
class="message-bubble p-2 rounded" |
|||
[class.user-bubble]="msg.role === 'user'" |
|||
[class.assistant-bubble]="msg.role === 'assistant'" |
|||
> |
|||
<span class="message-content">{{ msg.content }}</span> |
|||
</div> |
|||
@if (msg.role === 'user') { |
|||
<div class="avatar user-avatar">U</div> |
|||
} |
|||
</div> |
|||
} |
|||
@if (isLoading) { |
|||
<div class="message-row d-flex gap-2 mb-3 assistant"> |
|||
<div class="avatar assistant-avatar">G</div> |
|||
<div class="message-bubble assistant-bubble p-2 rounded typing"> |
|||
<span class="typing-dots"> |
|||
<span></span><span></span><span></span> |
|||
</span> |
|||
</div> |
|||
</div> |
|||
} |
|||
</div> |
|||
|
|||
<div class="input-bar d-flex gap-2 align-items-end p-2 rounded"> |
|||
<textarea |
|||
#inputEl |
|||
class="flex-grow-1 p-2 rounded border-0" |
|||
[placeholder]="'Ask about your portfolio...' | i18n" |
|||
[(ngModel)]="inputText" |
|||
[disabled]="isLoading" |
|||
rows="1" |
|||
(keydown.enter)="onInputKeydown($event)" |
|||
></textarea> |
|||
<button |
|||
class="send-btn" |
|||
mat-flat-button |
|||
color="primary" |
|||
[disabled]="isLoading || !inputText?.trim()" |
|||
(click)="send()" |
|||
> |
|||
<span i18n>Send</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,139 @@ |
|||
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
|||
import { NotificationService } from '@ghostfolio/ui/notifications'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { |
|||
ChangeDetectorRef, |
|||
Component, |
|||
ElementRef, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { FormsModule } from '@angular/forms'; |
|||
import { HttpClient } from '@angular/common/http'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
interface ChatMessage { |
|||
role: 'user' | 'assistant'; |
|||
content: string; |
|||
} |
|||
|
|||
interface AgentChatResponse { |
|||
message: { role: string; content: string }; |
|||
verification?: { passed: boolean; type: string; message?: string }; |
|||
error?: string; |
|||
} |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
MatButtonModule, |
|||
RouterModule |
|||
], |
|||
selector: 'gf-agent-page', |
|||
styleUrls: ['./agent-page.scss'], |
|||
templateUrl: './agent-page.html' |
|||
}) |
|||
export class GfAgentPageComponent implements OnDestroy, OnInit { |
|||
@ViewChild('messagesContainer') messagesContainer: ElementRef<HTMLElement>; |
|||
@ViewChild('inputEl') inputEl: ElementRef<HTMLTextAreaElement>; |
|||
|
|||
public inputText = ''; |
|||
public isLoading = false; |
|||
public messages: ChatMessage[] = []; |
|||
public routerLinkPortfolio = internalRoutes.portfolio.routerLink; |
|||
public suggestions = [ |
|||
$localize`What is my portfolio allocation?`, |
|||
$localize`How did my portfolio perform this year?`, |
|||
$localize`List my recent transactions.`, |
|||
$localize`What is the current price of AAPL?` |
|||
]; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private http: HttpClient, |
|||
private notificationService: NotificationService |
|||
) {} |
|||
|
|||
public ngOnInit() { |
|||
// No auth needed here - AuthGuard ensures user is logged in; interceptor adds JWT
|
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
public onInputKeydown(event: KeyboardEvent) { |
|||
if (event.key !== 'Enter') return; |
|||
if (event.shiftKey) return; // allow newline with Shift+Enter
|
|||
event.preventDefault(); |
|||
this.send(); |
|||
} |
|||
|
|||
public onSuggestionClick(text: string) { |
|||
this.inputText = text; |
|||
this.send(); |
|||
} |
|||
|
|||
public send() { |
|||
const text = (this.inputText ?? '').trim(); |
|||
if (!text || this.isLoading) return; |
|||
|
|||
this.inputText = ''; |
|||
this.messages.push({ role: 'user', content: text }); |
|||
this.isLoading = true; |
|||
this.scrollToBottom(); |
|||
this.changeDetectorRef.markForCheck(); |
|||
|
|||
const body = { |
|||
messages: this.messages.map((m) => ({ role: m.role, content: m.content })) |
|||
}; |
|||
|
|||
this.http |
|||
.post<AgentChatResponse>('/api/v1/agent/chat', body) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: (res) => { |
|||
this.messages.push({ |
|||
role: 'assistant', |
|||
content: res.message?.content ?? $localize`No response.` |
|||
}); |
|||
this.isLoading = false; |
|||
this.scrollToBottom(); |
|||
this.changeDetectorRef.markForCheck(); |
|||
}, |
|||
error: (err) => { |
|||
const msg = |
|||
err?.error?.error ?? |
|||
err?.message ?? |
|||
$localize`Request failed. Check your connection and try again.`; |
|||
this.messages.push({ |
|||
role: 'assistant', |
|||
content: $localize`Error: ${msg}` |
|||
}); |
|||
this.isLoading = false; |
|||
this.scrollToBottom(); |
|||
this.changeDetectorRef.markForCheck(); |
|||
this.notificationService.alert({ title: $localize`Agent Error`, message: msg }); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private scrollToBottom() { |
|||
setTimeout(() => { |
|||
const el = this.messagesContainer?.nativeElement; |
|||
if (el) { |
|||
el.scrollTop = el.scrollHeight; |
|||
} |
|||
}, 50); |
|||
} |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
.agent-page { |
|||
max-width: 48rem; |
|||
margin-left: auto; |
|||
margin-right: auto; |
|||
} |
|||
|
|||
.messages-container { |
|||
min-height: 12rem; |
|||
background: var(--mat-sys-surface-container-low, #f5f5f5); |
|||
} |
|||
|
|||
:host-context(.dark-theme) .messages-container { |
|||
background: var(--mat-sys-surface-container-low, #1e1e1e); |
|||
} |
|||
|
|||
.message-row { |
|||
max-width: 90%; |
|||
&.user { |
|||
flex-direction: row-reverse; |
|||
margin-left: auto; |
|||
.user-bubble { |
|||
background: var(--mat-sys-primary-container, #d0bcff); |
|||
color: var(--mat-sys-on-primary-container, #1d1b20); |
|||
} |
|||
} |
|||
&.assistant { |
|||
margin-right: auto; |
|||
.assistant-bubble { |
|||
background: var(--mat-sys-surface-container-high, #e6e0e9); |
|||
color: var(--mat-sys-on-surface, #1c1b1f); |
|||
} |
|||
} |
|||
} |
|||
|
|||
:host-context(.dark-theme) .message-row.assistant .assistant-bubble { |
|||
background: var(--mat-sys-surface-container-high, #2d2a32); |
|||
color: var(--mat-sys-on-surface, #e6e1e5); |
|||
} |
|||
|
|||
.message-bubble { |
|||
max-width: 100%; |
|||
word-break: break-word; |
|||
white-space: pre-wrap; |
|||
} |
|||
|
|||
.avatar { |
|||
width: 2rem; |
|||
height: 2rem; |
|||
border-radius: 50%; |
|||
flex-shrink: 0; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
font-size: 0.75rem; |
|||
font-weight: 700; |
|||
&.user-avatar { |
|||
background: var(--mat-sys-primary, #6750a4); |
|||
color: white; |
|||
} |
|||
&.assistant-avatar { |
|||
background: var(--mat-sys-secondary-container, #e8def8); |
|||
color: var(--mat-sys-on-secondary-container, #1d1b20); |
|||
} |
|||
} |
|||
|
|||
.typing-dots { |
|||
display: flex; |
|||
gap: 0.25rem; |
|||
span { |
|||
width: 0.5rem; |
|||
height: 0.5rem; |
|||
border-radius: 50%; |
|||
background: currentColor; |
|||
opacity: 0.5; |
|||
animation: typing-blink 1.4s ease-in-out infinite both; |
|||
&:nth-child(2) { |
|||
animation-delay: 0.2s; |
|||
} |
|||
&:nth-child(3) { |
|||
animation-delay: 0.4s; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@keyframes typing-blink { |
|||
0%, |
|||
80%, |
|||
100% { |
|||
opacity: 0.3; |
|||
} |
|||
40% { |
|||
opacity: 1; |
|||
} |
|||
} |
|||
|
|||
.input-bar { |
|||
background: var(--mat-sys-surface-container, #f3edf7); |
|||
textarea { |
|||
min-height: 2.5rem; |
|||
resize: none; |
|||
font: inherit; |
|||
outline: none; |
|||
} |
|||
} |
|||
|
|||
:host-context(.dark-theme) .input-bar { |
|||
background: var(--mat-sys-surface-container, #2d2a32); |
|||
} |
|||
|
|||
.suggestion-chip { |
|||
font-size: 0.875rem; |
|||
} |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue