mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
556 lines
16 KiB
556 lines
16 KiB
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Ghostfolio AI Agent</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: #0f1117;
|
|
color: #e2e8f0;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
header {
|
|
padding: 16px 24px;
|
|
background: #161b27;
|
|
border-bottom: 1px solid #1e2535;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
header .logo {
|
|
width: 36px;
|
|
height: 36px;
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 18px;
|
|
}
|
|
|
|
header h1 { font-size: 17px; font-weight: 600; color: #f1f5f9; }
|
|
header p { font-size: 12px; color: #64748b; }
|
|
|
|
.status-dot {
|
|
margin-left: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
color: #64748b;
|
|
}
|
|
|
|
.dot {
|
|
width: 8px; height: 8px;
|
|
border-radius: 50%;
|
|
background: #22c55e;
|
|
box-shadow: 0 0 6px #22c55e;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
.dot.offline { background: #ef4444; box-shadow: 0 0 6px #ef4444; animation: none; }
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
}
|
|
|
|
.chat-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.message {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-width: 720px;
|
|
}
|
|
|
|
.message.user { align-self: flex-end; align-items: flex-end; }
|
|
.message.agent { align-self: flex-start; align-items: flex-start; }
|
|
|
|
.bubble {
|
|
padding: 12px 16px;
|
|
border-radius: 14px;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.message.user .bubble {
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
color: #fff;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
|
|
.message.agent .bubble {
|
|
background: #1e2535;
|
|
color: #e2e8f0;
|
|
border-bottom-left-radius: 4px;
|
|
border: 1px solid #2a3347;
|
|
}
|
|
|
|
.meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.tag {
|
|
font-size: 11px;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
border: 1px solid #2a3347;
|
|
color: #94a3b8;
|
|
background: #161b27;
|
|
}
|
|
|
|
.tag.tool { border-color: #6366f1; color: #a5b4fc; }
|
|
.tag.pass { border-color: #22c55e; color: #86efac; }
|
|
.tag.flag { border-color: #f59e0b; color: #fcd34d; }
|
|
.tag.fail { border-color: #ef4444; color: #fca5a5; }
|
|
.tag.time { border-color: #334155; }
|
|
|
|
.typing {
|
|
display: flex;
|
|
gap: 5px;
|
|
padding: 14px 18px;
|
|
background: #1e2535;
|
|
border-radius: 14px;
|
|
border-bottom-left-radius: 4px;
|
|
border: 1px solid #2a3347;
|
|
width: fit-content;
|
|
}
|
|
|
|
.typing span {
|
|
width: 7px; height: 7px;
|
|
background: #6366f1;
|
|
border-radius: 50%;
|
|
animation: bounce 1.2s infinite;
|
|
}
|
|
.typing span:nth-child(2) { animation-delay: 0.2s; }
|
|
.typing span:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
@keyframes bounce {
|
|
0%, 80%, 100% { transform: translateY(0); }
|
|
40% { transform: translateY(-6px); }
|
|
}
|
|
|
|
.input-area {
|
|
padding: 16px 24px;
|
|
background: #161b27;
|
|
border-top: 1px solid #1e2535;
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.quick-btns {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
padding: 0 24px 12px;
|
|
background: #161b27;
|
|
}
|
|
|
|
.quick-btn {
|
|
font-size: 12px;
|
|
padding: 5px 12px;
|
|
border-radius: 999px;
|
|
border: 1px solid #2a3347;
|
|
background: #1e2535;
|
|
color: #94a3b8;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.quick-btn:hover {
|
|
border-color: #6366f1;
|
|
color: #a5b4fc;
|
|
background: #1e2540;
|
|
}
|
|
|
|
textarea {
|
|
flex: 1;
|
|
background: #1e2535;
|
|
border: 1px solid #2a3347;
|
|
border-radius: 12px;
|
|
color: #e2e8f0;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
padding: 12px 16px;
|
|
resize: none;
|
|
min-height: 48px;
|
|
max-height: 160px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
textarea:focus { border-color: #6366f1; }
|
|
textarea::placeholder { color: #475569; }
|
|
|
|
button.send {
|
|
width: 48px; height: 48px;
|
|
border-radius: 12px;
|
|
border: none;
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
color: #fff;
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
button.send:hover { opacity: 0.85; }
|
|
button.send:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
.empty-state {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
color: #475569;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-state .icon { font-size: 48px; }
|
|
.empty-state h2 { font-size: 18px; color: #94a3b8; }
|
|
.empty-state p { font-size: 13px; max-width: 340px; line-height: 1.6; }
|
|
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: #2a3347; border-radius: 3px; }
|
|
|
|
.confirmation-banner {
|
|
background: #1c1f2e;
|
|
border: 1px solid #f59e0b55;
|
|
border-radius: 10px;
|
|
padding: 10px 14px;
|
|
font-size: 12px;
|
|
color: #fcd34d;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
/* ── Debug panel ── */
|
|
.debug-panel {
|
|
margin-top: 6px;
|
|
width: 100%;
|
|
}
|
|
|
|
.debug-panel summary {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
list-style: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
color: #6366f1;
|
|
padding: 3px 0;
|
|
}
|
|
|
|
.debug-panel summary::-webkit-details-marker { display: none; }
|
|
|
|
.debug-panel summary .debug-tools {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
|
|
.debug-panel summary .tool-chip {
|
|
background: #1e2540;
|
|
border: 1px solid #6366f1;
|
|
color: #a5b4fc;
|
|
border-radius: 999px;
|
|
padding: 1px 7px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.debug-panel summary .no-tools {
|
|
background: #1e2535;
|
|
border: 1px solid #334155;
|
|
color: #64748b;
|
|
border-radius: 999px;
|
|
padding: 1px 7px;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.debug-panel summary .debug-meta {
|
|
margin-left: auto;
|
|
color: #475569;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.debug-body {
|
|
font-family: "SF Mono", "Fira Code", monospace;
|
|
font-size: 11px;
|
|
padding: 10px 12px;
|
|
background: #0d1117;
|
|
color: #e2e8f0;
|
|
border-radius: 6px;
|
|
margin-top: 4px;
|
|
border: 1px solid #1e2535;
|
|
overflow-x: auto;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.debug-body .db-row { display: flex; gap: 8px; }
|
|
.debug-body .db-key { color: #6366f1; min-width: 110px; }
|
|
.debug-body .db-val { color: #94a3b8; }
|
|
.debug-body .db-val.pass { color: #22c55e; }
|
|
.debug-body .db-val.flag { color: #f59e0b; }
|
|
.debug-body .db-val.fail { color: #ef4444; }
|
|
.debug-body .db-val.high { color: #22c55e; }
|
|
.debug-body .db-val.med { color: #f59e0b; }
|
|
.debug-body .db-val.low { color: #ef4444; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="logo">📈</div>
|
|
<div>
|
|
<h1>Ghostfolio AI Agent</h1>
|
|
<p>LangGraph · Claude Sonnet 4 · LangSmith traced</p>
|
|
</div>
|
|
<div class="status-dot">
|
|
<div class="dot" id="dot"></div>
|
|
<span id="status-label">Connecting…</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="chat-area" id="chat">
|
|
<div class="empty-state" id="empty">
|
|
<div class="icon">💼</div>
|
|
<h2>Ask about your portfolio</h2>
|
|
<p>Query performance, transactions, tax estimates, compliance checks, and market data — all grounded in your real Ghostfolio data.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="quick-btns">
|
|
<button class="quick-btn" onclick="sendQuick('How is my portfolio doing?')">📊 Portfolio overview</button>
|
|
<button class="quick-btn" onclick="sendQuick('Show me my recent transactions')">🔄 Recent transactions</button>
|
|
<button class="quick-btn" onclick="sendQuick('What is my estimated tax liability?')">🧾 Tax estimate</button>
|
|
<button class="quick-btn" onclick="sendQuick('Am I over-concentrated in any position?')">⚖️ Compliance check</button>
|
|
<button class="quick-btn" onclick="sendQuick('What is the current price of AAPL?')">💹 Market data</button>
|
|
<button class="quick-btn" onclick="sendQuick('What is my YTD return?')">📅 YTD return</button>
|
|
</div>
|
|
|
|
<div class="input-area">
|
|
<textarea id="input" placeholder="Ask anything about your portfolio…" rows="1"></textarea>
|
|
<button class="send" id="send-btn" onclick="send()">➤</button>
|
|
</div>
|
|
|
|
<script>
|
|
const BASE = 'http://localhost:8000';
|
|
const chat = document.getElementById('chat');
|
|
const input = document.getElementById('input');
|
|
const sendBtn = document.getElementById('send-btn');
|
|
const empty = document.getElementById('empty');
|
|
const dot = document.getElementById('dot');
|
|
const statusLabel = document.getElementById('status-label');
|
|
let history = [];
|
|
let typingEl = null;
|
|
|
|
// Health check on load
|
|
async function checkHealth() {
|
|
try {
|
|
const r = await fetch(`${BASE}/health`);
|
|
const d = await r.json();
|
|
if (d.status === 'ok') {
|
|
dot.classList.remove('offline');
|
|
statusLabel.textContent = d.ghostfolio_reachable ? 'Online · Ghostfolio connected' : 'Online · Ghostfolio unreachable';
|
|
} else {
|
|
throw new Error();
|
|
}
|
|
} catch {
|
|
dot.classList.add('offline');
|
|
statusLabel.textContent = 'Agent offline';
|
|
}
|
|
}
|
|
checkHealth();
|
|
|
|
// Auto-resize textarea
|
|
input.addEventListener('input', () => {
|
|
input.style.height = 'auto';
|
|
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
|
|
});
|
|
|
|
// Enter to send (Shift+Enter for newline)
|
|
input.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
|
});
|
|
|
|
function sendQuick(text) {
|
|
input.value = text;
|
|
send();
|
|
}
|
|
|
|
function addMessage(role, text, meta = null) {
|
|
empty.style.display = 'none';
|
|
const wrap = document.createElement('div');
|
|
wrap.className = `message ${role}`;
|
|
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'bubble';
|
|
bubble.textContent = text;
|
|
wrap.appendChild(bubble);
|
|
|
|
if (meta) {
|
|
const metaDiv = document.createElement('div');
|
|
metaDiv.className = 'meta';
|
|
|
|
if (meta.tools_used?.length) {
|
|
meta.tools_used.forEach(t => {
|
|
const tag = document.createElement('span');
|
|
tag.className = 'tag tool';
|
|
tag.textContent = '🔧 ' + t;
|
|
metaDiv.appendChild(tag);
|
|
});
|
|
}
|
|
|
|
if (meta.verification_outcome) {
|
|
const tag = document.createElement('span');
|
|
tag.className = 'tag ' + (meta.verification_outcome === 'pass' ? 'pass' : meta.verification_outcome === 'flag' ? 'flag' : 'fail');
|
|
tag.textContent = meta.verification_outcome === 'pass' ? '✓ verified' : '⚠ ' + meta.verification_outcome;
|
|
metaDiv.appendChild(tag);
|
|
}
|
|
|
|
if (meta.confidence_score != null) {
|
|
const tag = document.createElement('span');
|
|
tag.className = 'tag';
|
|
tag.textContent = `confidence ${Math.round(meta.confidence_score * 100)}%`;
|
|
metaDiv.appendChild(tag);
|
|
}
|
|
|
|
if (meta.latency_seconds != null) {
|
|
const tag = document.createElement('span');
|
|
tag.className = 'tag time';
|
|
tag.textContent = `${meta.latency_seconds}s`;
|
|
metaDiv.appendChild(tag);
|
|
}
|
|
|
|
wrap.appendChild(metaDiv);
|
|
|
|
if (meta.awaiting_confirmation) {
|
|
const banner = document.createElement('div');
|
|
banner.className = 'confirmation-banner';
|
|
banner.textContent = '⚠️ Investment decision detected — no buy/sell advice will be given.';
|
|
wrap.appendChild(banner);
|
|
}
|
|
|
|
// ── Debug panel (Byron requirement: graders must SEE tool calls) ──
|
|
const debugEl = document.createElement('div');
|
|
debugEl.innerHTML = renderDebugPanel(meta);
|
|
wrap.appendChild(debugEl);
|
|
}
|
|
|
|
chat.appendChild(wrap);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
}
|
|
|
|
function renderDebugPanel(meta) {
|
|
const tools = meta.tools_used || [];
|
|
const confidence = meta.confidence_score != null ? meta.confidence_score : null;
|
|
const latency = meta.latency_seconds != null ? meta.latency_seconds : null;
|
|
const outcome = meta.verification_outcome || null;
|
|
|
|
// Tool chips
|
|
const toolHtml = tools.length
|
|
? tools.map(t => `<span class="tool-chip">🔧 ${t}</span>`).join('')
|
|
: '<span class="no-tools">no tools called</span>';
|
|
|
|
// Confidence colour
|
|
const confClass = confidence == null ? '' : confidence >= 0.8 ? 'high' : confidence >= 0.5 ? 'med' : 'low';
|
|
const confDisplay = confidence != null ? `${Math.round(confidence * 100)}%` : '—';
|
|
|
|
// Outcome colour
|
|
const outcomeClass = outcome === 'pass' ? 'pass' : outcome === 'flag' ? 'flag' : outcome ? 'fail' : '';
|
|
|
|
// Summary meta string
|
|
const summaryMeta = [
|
|
confidence != null ? `${Math.round(confidence * 100)}% confidence` : null,
|
|
latency != null ? `${latency}s` : null,
|
|
].filter(Boolean).join(' · ');
|
|
|
|
return `
|
|
<details class="debug-panel">
|
|
<summary>
|
|
<span style="font-size:12px; margin-right:2px;">🔧</span>
|
|
<span class="debug-tools">${toolHtml}</span>
|
|
<span class="debug-meta">${summaryMeta}</span>
|
|
</summary>
|
|
<div class="debug-body">
|
|
<div class="db-row"><span class="db-key">tools_called</span><span class="db-val">${tools.length ? tools.join(', ') : 'none'}</span></div>
|
|
<div class="db-row"><span class="db-key">verification</span><span class="db-val ${outcomeClass}">${outcome || '—'}</span></div>
|
|
<div class="db-row"><span class="db-key">confidence</span><span class="db-val ${confClass}">${confDisplay}</span></div>
|
|
<div class="db-row"><span class="db-key">latency</span><span class="db-val">${latency != null ? latency + 's' : '—'}</span></div>
|
|
</div>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
function showTyping() {
|
|
typingEl = document.createElement('div');
|
|
typingEl.className = 'message agent';
|
|
typingEl.innerHTML = `<div class="typing"><span></span><span></span><span></span></div>`;
|
|
chat.appendChild(typingEl);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
}
|
|
|
|
function removeTyping() {
|
|
if (typingEl) { typingEl.remove(); typingEl = null; }
|
|
}
|
|
|
|
async function send() {
|
|
const query = input.value.trim();
|
|
if (!query || sendBtn.disabled) return;
|
|
|
|
addMessage('user', query);
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
sendBtn.disabled = true;
|
|
showTyping();
|
|
|
|
try {
|
|
const res = await fetch(`${BASE}/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, history }),
|
|
});
|
|
const data = await res.json();
|
|
removeTyping();
|
|
addMessage('agent', data.response, data);
|
|
history.push({ role: 'user', content: query });
|
|
history.push({ role: 'assistant', content: data.response });
|
|
} catch (err) {
|
|
removeTyping();
|
|
addMessage('agent', '❌ Could not reach the agent at localhost:8000. Make sure the server is running.');
|
|
} finally {
|
|
sendBtn.disabled = false;
|
|
input.focus();
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|