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

<!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>