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