FinRAG / app /static /index.html
Harshavard21's picture
Add πŸ“ˆ emoji favicon to UI
2bf7b60
Raw History Blame Contribute Delete
50.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FinRAG β€” Indian Markets Intelligence</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>πŸ“ˆ</text></svg>">
<meta name="description" content="AI-powered financial Q&A over BSE annual reports of major Indian companies">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
/* ═══════════════════════════════════════════
RESET & TOKENS
═══════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0f17;
--bg-sidebar: #0a0c13;
--bg-input: #131620;
--bg-msg: #111420;
--bg-hover: rgba(255,255,255,0.04);
--bg-active: rgba(59,130,246,0.1);
--border: rgba(255,255,255,0.07);
--border-blue: rgba(59,130,246,0.3);
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #334155;
--accent: #3b82f6;
--accent2: #06b6d4;
--accent3: #8b5cf6;
--green: #10b981;
--red: #ef4444;
--amber: #f59e0b;
--radius: 10px;
--sidebar-w: 260px;
--font: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
html, body {
height: 100%; width: 100%;
font-family: var(--font);
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* ═══════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════ */
#app { display: flex; height: 100vh; width: 100vw; overflow: hidden; }
/* ── Sidebar ── */
#sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.22s cubic-bezier(0.4,0,0.2,1),
min-width 0.22s cubic-bezier(0.4,0,0.2,1);
position: relative;
z-index: 10;
}
#sidebar.collapsed { width: 0; min-width: 0; }
/* ── Main ── */
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* ═══════════════════════════════════════════
SIDEBAR INTERNALS
═══════════════════════════════════════════ */
.sb-top {
padding: 1rem 0.85rem 0.6rem;
flex-shrink: 0;
}
.brand {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.2rem 0 0.8rem;
}
.brand-icon { font-size: 1.3rem; }
.brand-name {
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.brand-sub { font-size: 0.6rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; }
.new-chat-btn {
width: 100%; padding: 0.55rem 0.8rem;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text); font-family: var(--font); font-size: 0.83rem; font-weight: 500;
cursor: pointer; display: flex; align-items: center; gap: 0.5rem;
transition: background 0.15s, border-color 0.15s;
}
.new-chat-btn:hover {
background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.15);
}
.new-chat-btn .plus { font-size: 1.1rem; font-weight: 300; color: var(--accent2); }
.sb-divider { height: 1px; background: var(--border); margin: 0.5rem 0; }
.sb-scroll {
flex: 1; overflow-y: auto; overflow-x: hidden;
padding: 0 0.5rem;
}
.sb-scroll::-webkit-scrollbar { width: 3px; }
.sb-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; }
.group-label {
font-size: 0.65rem; font-weight: 600; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.08em;
padding: 0.7rem 0.4rem 0.25rem;
}
.hist-item {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.42rem 0.6rem; border-radius: 7px;
font-size: 0.8rem; color: var(--text-muted);
cursor: pointer; overflow: hidden; white-space: nowrap;
text-overflow: ellipsis;
transition: background 0.12s, color 0.12s;
user-select: none;
}
.hist-item:hover { background: var(--bg-hover); color: var(--text); }
.hist-item.active { background: var(--bg-active); color: #93c5fd; }
.hist-item .icon { font-size: 0.75rem; flex-shrink: 0; }
.hist-item .title { overflow: hidden; text-overflow: ellipsis; flex: 1; }
.hist-item .del {
flex-shrink: 0; font-size: 0.85rem;
color: var(--text-dim); opacity: 0.35;
padding: 0.1rem 0.3rem; border-radius: 4px;
transition: opacity 0.15s, color 0.15s, background 0.15s;
cursor: pointer;
}
.hist-item:hover .del { opacity: 1; color: var(--red); }
.hist-item .del:hover { background: rgba(239,68,68,0.12); }
.sb-footer {
padding: 0.6rem 0.85rem;
border-top: 1px solid var(--border);
flex-shrink: 0;
font-size: 0.65rem; color: var(--text-dim); line-height: 1.7;
letter-spacing: 0.02em;
}
/* ═══════════════════════════════════════════
TOPBAR
═══════════════════════════════════════════ */
#topbar {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 1.2rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: rgba(13,15,23,0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.toggle-btn {
background: none; border: none; cursor: pointer;
color: var(--text-muted); padding: 0.3rem; border-radius: 6px;
font-size: 1rem; transition: background 0.12s, color 0.12s;
flex-shrink: 0;
}
.toggle-btn:hover { background: var(--bg-hover); color: var(--text); }
/* Mode tabs */
.mode-tabs { display: flex; gap: 0.2rem; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 0.2rem; }
.mode-tab {
padding: 0.3rem 0.9rem; border-radius: 6px;
font-size: 0.78rem; font-weight: 500; color: var(--text-muted);
cursor: pointer; border: none; background: none; font-family: var(--font);
transition: all 0.15s; white-space: nowrap; letter-spacing: 0.01em;
}
.mode-tab.active { background: rgba(59,130,246,0.18); color: #93c5fd; }
.mode-tab:hover:not(.active) { color: var(--text); }
/* Context selects */
.ctx-row { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.ctx-select {
background: rgba(255,255,255,0.05); border: 1px solid var(--border);
border-radius: 7px; color: var(--text); font-family: var(--font);
font-size: 0.78rem; padding: 0.3rem 0.6rem; cursor: pointer;
outline: none; transition: border-color 0.15s;
}
.ctx-select:focus { border-color: var(--border-blue); }
.ctx-select option { background: #1e2538; }
/* Multi-company select for compare */
.multi-company-bar {
display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap;
}
.co-pill {
display: flex; align-items: center; gap: 0.25rem;
background: rgba(59,130,246,0.12); border: 1px solid var(--border-blue);
border-radius: 20px; padding: 0.18rem 0.55rem;
font-size: 0.72rem; color: #93c5fd; font-family: var(--mono);
}
.co-pill .rm { cursor: pointer; opacity: 0.6; transition: opacity 0.1s; }
.co-pill .rm:hover { opacity: 1; }
.add-company-btn {
background: rgba(255,255,255,0.04); border: 1px dashed var(--border);
border-radius: 20px; padding: 0.18rem 0.7rem;
font-size: 0.72rem; color: var(--text-muted); cursor: pointer;
font-family: var(--font); transition: all 0.15s;
}
.add-company-btn:hover { border-color: var(--border-blue); color: var(--accent2); }
.extract-btn {
padding: 0.35rem 1rem; border-radius: 7px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border: none; color: #fff; font-family: var(--font);
font-size: 0.78rem; font-weight: 600; cursor: pointer;
transition: opacity 0.15s; white-space: nowrap;
}
.extract-btn:hover { opacity: 0.85; }
.extract-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ═══════════════════════════════════════════
CHAT THREAD
═══════════════════════════════════════════ */
#thread {
flex: 1; overflow-y: auto; overflow-x: hidden;
padding: 1.5rem 0 2rem;
scroll-behavior: smooth;
}
#thread::-webkit-scrollbar { width: 4px; }
#thread::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.07); border-radius: 2px; }
/* welcome */
#welcome {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 70vh;
padding: 2rem; text-align: center; gap: 1rem;
animation: fadeUp 0.4s ease;
}
.wlc-logo { font-size: 3.2rem; margin-bottom: 0.3rem; }
.wlc-title {
font-size: 2.2rem; font-weight: 800; letter-spacing: -0.04em;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 50%, var(--accent3) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
line-height: 1.1;
}
.wlc-sub { font-size: 0.95rem; color: var(--text-muted); max-width: 420px; line-height: 1.65; }
.sug-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; max-width: 580px; width: 100%; margin-top: 0.5rem; }
.sug-card {
background: rgba(255,255,255,0.03); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.85rem 1rem;
text-align: left; cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.12s;
user-select: none;
}
.sug-card:hover {
background: rgba(59,130,246,0.07); border-color: var(--border-blue);
transform: translateY(-1px);
}
.sug-icon { font-size: 1rem; margin-bottom: 0.3rem; display: block; }
.sug-text { font-size: 0.8rem; color: var(--text-muted); line-height: 1.4; }
/* Message wrapper */
.msg-wrap {
max-width: 780px; margin: 0 auto; padding: 0 1.5rem;
animation: fadeUp 0.25s ease;
}
.msg-wrap + .msg-wrap { margin-top: 0.15rem; }
/* User message */
.msg-user {
display: flex; justify-content: flex-end; margin: 0.8rem 0 0.3rem;
}
.msg-user .bubble {
background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(6,182,212,0.12));
border: 1px solid rgba(59,130,246,0.2);
border-radius: 14px 14px 3px 14px;
padding: 0.7rem 1rem; max-width: 75%;
font-size: 0.9rem; line-height: 1.6; color: #dbeafe;
}
/* Assistant message */
.msg-asst { display: flex; gap: 0.75rem; margin: 0.5rem 0; }
.asst-avatar {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
background: linear-gradient(135deg, var(--accent), var(--accent2));
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem; margin-top: 0.15rem;
}
.asst-body { flex: 1; min-width: 0; }
.asst-name { font-size: 0.72rem; color: var(--text-dim); margin-bottom: 0.3rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.asst-content { font-size: 0.9rem; line-height: 1.75; color: #cbd5e1; }
.asst-content p { margin-bottom: 0.6rem; }
.asst-content p:last-child { margin-bottom: 0; }
.asst-content strong { color: #e2e8f0; font-weight: 600; }
.asst-content em { color: var(--accent2); }
.asst-content ul, .asst-content ol { padding-left: 1.4rem; margin: 0.4rem 0; }
.asst-content li { margin: 0.2rem 0; }
.asst-content table { width: 100%; border-collapse: collapse; margin: 0.6rem 0; font-size: 0.83rem; }
.asst-content th { background: rgba(59,130,246,0.1); border: 1px solid var(--border-blue); padding: 0.4rem 0.7rem; text-align: left; color: #93c5fd; }
.asst-content td { border: 1px solid var(--border); padding: 0.35rem 0.7rem; color: var(--text-muted); }
.asst-content tr:nth-child(even) td { background: rgba(255,255,255,0.02); }
.asst-content code { background: rgba(255,255,255,0.08); border-radius: 4px; padding: 0.1em 0.35em; font-family: var(--mono); font-size: 0.85em; color: var(--accent2); }
.asst-content pre { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 0.8rem 1rem; overflow-x: auto; margin: 0.5rem 0; }
.asst-content h3, .asst-content h4 { color: #e2e8f0; margin: 0.8rem 0 0.3rem; font-weight: 600; }
/* Typing cursor */
.cursor {
display: inline-block; width: 2px; height: 1em;
background: var(--accent2); margin-left: 2px;
vertical-align: middle; border-radius: 1px;
animation: blink 0.9s infinite;
}
@keyframes blink { 0%,50% { opacity: 1; } 51%,100% { opacity: 0; } }
/* Sources */
.sources-bar {
display: flex; flex-wrap: wrap; gap: 0.3rem;
margin-top: 0.7rem; padding-top: 0.6rem;
border-top: 1px solid var(--border);
}
.src-chip {
background: rgba(59,130,246,0.08); border: 1px solid rgba(59,130,246,0.3);
border-radius: 5px; padding: 0.15rem 0.5rem;
font-size: 0.67rem; color: #93c5fd; font-family: var(--mono);
transition: all 0.12s; text-decoration: none; cursor: pointer;
}
.src-chip:hover { background: rgba(59,130,246,0.15); border-color: rgba(59,130,246,0.5); }
/* KPI Grid */
.kpi-section { margin: 0.8rem 0; }
.kpi-section-title { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.5rem; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.55rem; margin-bottom: 0.55rem; }
.kpi-card {
background: rgba(255,255,255,0.03); border: 1px solid var(--border);
border-radius: 9px; padding: 0.8rem 0.9rem; text-align: center;
transition: border-color 0.15s;
}
.kpi-card.has-val { border-color: rgba(59,130,246,0.2); }
.kpi-card .val {
font-size: 1.15rem; font-weight: 700; font-family: var(--mono);
color: var(--accent2); line-height: 1.2; margin-bottom: 0.25rem;
}
.kpi-card .val.na { color: var(--text-dim); font-size: 0.85rem; font-family: var(--font); }
.kpi-card .val.text-green { color: #10b981; }
.kpi-card .val.text-red { color: #ef4444; }
.kpi-card .lbl { font-size: 0.63rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
/* chart wrapper */
.chart-wrap { margin: 0.5rem 0; border-radius: var(--radius); overflow: hidden; }
/* Spinner */
.spin-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; font-size: 0.8rem; color: var(--text-dim); }
.spinner {
width: 14px; height: 14px; border: 2px solid rgba(59,130,246,0.15);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.7s linear infinite; flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ═══════════════════════════════════════════
INPUT AREA
═══════════════════════════════════════════ */
#input-area {
flex-shrink: 0;
padding: 0.8rem 1.5rem 1.2rem;
background: linear-gradient(to top, var(--bg) 80%, transparent);
}
.input-wrap {
max-width: 780px; margin: 0 auto;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
display: flex; flex-direction: column;
}
.input-wrap:focus-within {
border-color: var(--border-blue);
box-shadow: 0 0 0 3px rgba(59,130,246,0.08);
}
.input-row {
display: flex; align-items: flex-end; gap: 0.5rem;
padding: 0.55rem 0.65rem 0.55rem 0.65rem;
}
#query-input {
flex: 1; resize: none; border: none; outline: none;
background: transparent; color: var(--text);
font-family: var(--font); font-size: 0.92rem; line-height: 1.55;
min-height: 24px; max-height: 180px;
overflow-y: auto; padding: 0;
}
#query-input::placeholder { color: var(--text-dim); }
#query-input::-webkit-scrollbar { width: 3px; }
.input-left { display: flex; align-items: flex-end; gap: 0.3rem; }
.input-right { display: flex; align-items: flex-end; }
.at-btn {
width: 30px; height: 30px; border-radius: 7px; border: none;
background: rgba(255,255,255,0.05); color: var(--text-muted);
font-size: 0.85rem; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.12s, color 0.12s; position: relative; flex-shrink: 0;
}
.at-btn:hover { background: rgba(255,255,255,0.1); color: var(--text); }
.send-btn {
width: 32px; height: 32px; border-radius: 8px; border: none;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff; font-size: 1rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.15s, transform 0.12s;
flex-shrink: 0;
}
.send-btn:hover { opacity: 0.85; transform: scale(1.04); }
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none; }
/* Company picker popup */
#company-picker {
position: absolute; bottom: calc(100% + 8px); left: 0;
background: #1a1f33; border: 1px solid var(--border-blue);
border-radius: 10px; width: 280px; max-height: 260px;
overflow-y: auto; z-index: 100; display: none;
box-shadow: 0 16px 40px rgba(0,0,0,0.5);
}
#company-picker.open { display: block; animation: popUp 0.15s ease; }
.picker-search {
padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: #1a1f33;
}
.picker-search input {
width: 100%; background: none; border: none; outline: none;
color: var(--text); font-family: var(--font); font-size: 0.82rem;
}
.picker-search input::placeholder { color: var(--text-dim); }
.picker-item {
padding: 0.45rem 0.8rem; font-size: 0.8rem; color: var(--text-muted);
cursor: pointer; display: flex; justify-content: space-between; align-items: center;
transition: background 0.1s;
}
.picker-item:hover { background: var(--bg-hover); color: var(--text); }
.picker-item .ticker { font-size: 0.67rem; font-family: var(--mono); color: var(--text-dim); }
/* no hint row β€” removed */
/* ═══════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════ */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes popUp {
from { opacity: 0; transform: translateY(6px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
</style>
</head>
<body>
<div id="app">
<!-- ═══ SIDEBAR ═══ -->
<aside id="sidebar">
<div class="sb-top">
<div class="brand">
<span class="brand-icon">πŸ“ˆ</span>
<div>
<div class="brand-name">FinRAG</div>
<div class="brand-sub">Indian Markets Intelligence</div>
</div>
</div>
<button class="new-chat-btn" onclick="newChat()">
<span class="plus">+</span> New Chat
</button>
</div>
<div class="sb-divider"></div>
<div class="sb-scroll" id="history-list">
<!-- populated by JS -->
</div>
<div class="sb-footer">
Hybrid RAG Β· Financial Intelligence
</div>
</aside>
<!-- ═══ MAIN ═══ -->
<div id="main">
<!-- Topbar -->
<div id="topbar">
<button class="toggle-btn" onclick="toggleSidebar()" title="Toggle sidebar">☰</button>
<div class="mode-tabs">
<button class="mode-tab active" data-mode="chat" onclick="setMode('chat')">Chat</button>
<button class="mode-tab" data-mode="compare" onclick="setMode('compare')">Compare</button>
<button class="mode-tab" data-mode="dashboard" onclick="setMode('dashboard')">Dashboard</button>
</div>
<!-- Context (changes per mode) -->
<div class="ctx-row" id="ctx-row">
<!-- rendered by renderCtx() -->
</div>
</div>
<!-- Chat Thread -->
<div id="thread">
<div id="welcome">
<div class="wlc-logo"></div>
<div class="wlc-title">FinRAG</div>
<div class="wlc-sub">Ask anything about major Indian companies.<br>Answers grounded in official BSE annual reports.</div>
<div class="sug-grid" id="suggestions"></div>
</div>
</div>
<!-- Input Area -->
<div id="input-area">
<div style="max-width:780px;margin:0 auto;position:relative;">
<div id="company-picker" class="closed">
<div class="picker-search">
<input type="text" id="picker-search" placeholder="Search company…" oninput="filterPicker(this.value)">
</div>
<div id="picker-list"></div>
</div>
<div class="input-wrap">
<div class="input-row">
<div class="input-left">
<button class="at-btn" onclick="togglePicker()" title="Select company">@</button>
</div>
<textarea id="query-input" placeholder="Ask anything… or type @Company to filter" rows="1"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<div class="input-right">
<button class="send-btn" id="send-btn" onclick="submitQuery()">↑</button>
</div>
</div>
</div>
</div>
</div>
</div><!-- end main -->
</div><!-- end app -->
<script>
/* ═══════════════════════════════════════════════════════
STATE
═══════════════════════════════════════════════════════ */
const state = {
mode: 'chat',
company: 'TCS',
fy: 'FY2025',
compareCompanies: ['TCS', 'INFOSYS'],
conversationId: null,
isStreaming: false,
companies: [],
tickers: {},
};
/* ═══════════════════════════════════════════════════════
INIT
═══════════════════════════════════════════════════════ */
async function init() {
// Load companies
const res = await fetch('/api/companies');
const data = await res.json();
state.companies = data.companies;
state.tickers = data.tickers;
state.company = state.companies[0];
renderCtx();
renderSuggestions();
buildPicker();
loadHistory();
}
/* ═══════════════════════════════════════════════════════
SIDEBAR TOGGLE
═══════════════════════════════════════════════════════ */
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
}
/* ═══════════════════════════════════════════════════════
MODE
═══════════════════════════════════════════════════════ */
function setMode(mode) {
state.mode = mode;
document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === mode));
renderCtx();
}
function renderCtx() {
const row = document.getElementById('ctx-row');
const cos = state.companies;
const fyOptions = ['FY2025', 'FY2024', 'All'];
if (state.mode === 'compare') {
let html = `<div class="multi-company-bar" id="compare-bar">`;
state.compareCompanies.forEach(c => {
html += `<span class="co-pill">${c} <span class="rm" onclick="removeCompare('${c}')">Γ—</span></span>`;
});
html += `<button class="add-company-btn" onclick="openComparePicker()">+ Add</button></div>`;
html += fySelect();
row.innerHTML = html;
} else if (state.mode === 'dashboard') {
row.innerHTML = companySelect() + fySelect() +
`<button class="extract-btn" id="extract-btn" onclick="extractDashboard()">Extract KPIs β†’</button>`;
} else {
row.innerHTML = companySelect() + fySelect();
}
}
function companySelect() {
let s = `<select class="ctx-select" id="company-sel" onchange="state.company=this.value">`;
state.companies.forEach(c => {
s += `<option value="${c}" ${c === state.company ? 'selected' : ''}>${c}</option>`;
});
return s + '</select>';
}
function fySelect() {
return `<select class="ctx-select" id="fy-sel" onchange="state.fy=this.value">
<option value="FY2025">FY2025</option>
<option value="FY2024">FY2024</option>
<option value="All">All Years</option>
</select>`;
}
/* ═══════════════════════════════════════════════════════
COMPARE COMPANY MANAGEMENT
═══════════════════════════════════════════════════════ */
function removeCompare(c) {
state.compareCompanies = state.compareCompanies.filter(x => x !== c);
renderCtx();
}
function openComparePicker() {
const picker = document.getElementById('company-picker');
picker.classList.add('open');
document.getElementById('picker-search').focus();
picker._mode = 'compare';
}
/* ═══════════════════════════════════════════════════════
COMPANY PICKER
═══════════════════════════════════════════════════════ */
function buildPicker() {
renderPickerItems(state.companies);
}
function renderPickerItems(items) {
const list = document.getElementById('picker-list');
list.innerHTML = items.map(c =>
`<div class="picker-item" onclick="selectFromPicker('${c}')">
<span>${c}</span>
<span class="ticker">${state.tickers[c] || ''}</span>
</div>`
).join('');
}
function filterPicker(q) {
const filtered = state.companies.filter(c => c.toLowerCase().includes(q.toLowerCase()));
renderPickerItems(filtered);
}
function togglePicker() {
const picker = document.getElementById('company-picker');
picker.classList.toggle('open');
picker._mode = 'chat';
if (picker.classList.contains('open')) {
document.getElementById('picker-search').value = '';
document.getElementById('picker-search').focus();
renderPickerItems(state.companies);
}
}
function selectFromPicker(company) {
const picker = document.getElementById('company-picker');
if (picker._mode === 'compare') {
if (!state.compareCompanies.includes(company)) {
state.compareCompanies.push(company);
}
renderCtx();
} else {
state.company = company;
const sel = document.getElementById('company-sel');
if (sel) sel.value = company;
// Also insert @company into input
const inp = document.getElementById('query-input');
if (!inp.value.includes(company)) {
inp.value = `@${company} ` + inp.value;
}
inp.focus();
}
picker.classList.remove('open');
}
// Close picker on outside click
document.addEventListener('click', e => {
const picker = document.getElementById('company-picker');
const atBtn = document.querySelector('.at-btn');
if (!picker.contains(e.target) && !atBtn.contains(e.target)) {
picker.classList.remove('open');
}
});
/* ═══════════════════════════════════════════════════════
SUGGESTIONS
═══════════════════════════════════════════════════════ */
const SUGGESTIONS = [
{ icon: 'πŸ’°', text: "What was TCS's revenue and net profit for FY2025?" },
{ icon: '🏦', text: "What is HDFC Bank's gross NPA ratio?" },
{ icon: 'βš–οΈ', text: "Compare Infosys vs HCL Technologies on revenue growth" },
{ icon: 'πŸ“Š', text: "Show KPI dashboard for Reliance Industries" },
];
function renderSuggestions() {
const grid = document.getElementById('suggestions');
grid.innerHTML = SUGGESTIONS.map(s =>
`<div class="sug-card" onclick="submitSuggestion('${s.text.replace(/'/g,"\\'")}')">
<span class="sug-icon">${s.icon}</span>
<div class="sug-text">${s.text}</div>
</div>`
).join('');
}
function submitSuggestion(text) {
const inp = document.getElementById('query-input');
inp.value = text;
submitQuery();
}
/* ═══════════════════════════════════════════════════════
TEXTAREA AUTO-RESIZE
═══════════════════════════════════════════════════════ */
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 180) + 'px';
}
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitQuery();
}
}
/* ═══════════════════════════════════════════════════════
CONVERSATION MANAGEMENT
═══════════════════════════════════════════════════════ */
async function newChat() {
const res = await fetch('/api/conversations', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: state.mode }),
});
const data = await res.json();
state.conversationId = data.id;
// Clear thread, show welcome
document.getElementById('thread').innerHTML = `
<div id="welcome">
<div class="wlc-logo">πŸ“ˆ</div>
<div class="wlc-title">FinRAG</div>
<div class="wlc-sub">Ask anything about 17 major Indian companies.<br>Answers grounded in official BSE annual reports.</div>
<div class="sug-grid" id="suggestions"></div>
</div>`;
renderSuggestions();
loadHistory();
}
async function loadHistory() {
const res = await fetch('/api/conversations');
const data = await res.json();
renderHistory(data.grouped || {});
}
function renderHistory(grouped) {
const list = document.getElementById('history-list');
let html = '';
const icons = { chat: 'πŸ’¬', compare: 'βš–οΈ', dashboard: 'πŸ“Š' };
for (const [group, convs] of Object.entries(grouped)) {
html += `<div class="group-label">${group}</div>`;
convs.forEach(c => {
const active = c.id === state.conversationId ? 'active' : '';
const icon = icons[c.mode] || 'πŸ’¬';
html += `<div class="hist-item ${active}" onclick="loadConversation('${c.id}', '${c.mode}')">
<span class="icon">${icon}</span>
<span class="title">${escHtml(c.title)}</span>
<span class="del" onclick="event.stopPropagation();deleteConv('${c.id}')">πŸ—‘</span>
</div>`;
});
}
list.innerHTML = html || '<div style="padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--text-dim);">No history yet</div>';
}
async function loadConversation(cid, mode) {
state.conversationId = cid;
if (mode) setMode(mode);
const res = await fetch(`/api/conversations/${cid}/messages`);
const data = await res.json();
const thread = document.getElementById('thread');
thread.innerHTML = '';
for (const msg of data.messages) {
if (msg.role === 'user') {
appendUserMsg(msg.content);
} else {
const meta = msg.metadata || {};
const el = appendAssistantMsg(msg.content, false);
if (meta.sources?.length) appendSources(el, meta.sources);
if (meta.kpi_metrics) appendKPI(el, meta.kpi_metrics, meta.company || '');
}
}
thread.scrollTop = thread.scrollHeight;
loadHistory();
}
async function deleteConv(cid) {
await fetch(`/api/conversations/${cid}`, { method: 'DELETE' });
if (state.conversationId === cid) {
state.conversationId = null;
newChat();
} else {
loadHistory();
}
}
/* ═══════════════════════════════════════════════════════
SAVE MESSAGE
═══════════════════════════════════════════════════════ */
async function saveMessage(role, content, metadata = null) {
if (!state.conversationId) {
const res = await fetch('/api/conversations', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: state.mode }),
});
const data = await res.json();
state.conversationId = data.id;
}
await fetch(`/api/conversations/${state.conversationId}/messages`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role, content, metadata }),
});
}
/* ═══════════════════════════════════════════════════════
SUBMIT QUERY
═══════════════════════════════════════════════════════ */
async function submitQuery() {
const inp = document.getElementById('query-input');
const query = inp.value.trim();
if (!query || state.isStreaming) return;
inp.value = '';
inp.style.height = 'auto';
// Hide welcome
const welcome = document.getElementById('welcome');
if (welcome) welcome.remove();
// Get current selectors FIRST
const companySel = document.getElementById('company-sel');
if (companySel) state.company = companySel.value;
const fySel = document.getElementById('fy-sel');
if (fySel) state.fy = fySel.value;
const ql = query.toLowerCase();
// Auto-detect compare/dashboard intent FIRST
if (/compare|vs |versus|vs\./i.test(ql) && state.mode !== 'compare') setMode('compare');
else if (/dashboard|kpi|extract metric/i.test(ql) && state.mode !== 'dashboard') setMode('dashboard');
if (state.mode === 'compare') {
const mentioned = state.companies.filter(c => ql.includes(c.toLowerCase()) || (state.tickers[c] && ql.includes(state.tickers[c].toLowerCase())));
if (mentioned.length > 0) {
state.compareCompanies = [];
mentioned.forEach(c => {
if (!state.compareCompanies.includes(c)) state.compareCompanies.push(c);
});
renderCtx();
}
} else {
// Single company detection for chat/dashboard
const atMatch = query.match(/@(\w+)/);
if (atMatch) {
const mentioned = state.companies.find(c => c.toLowerCase() === atMatch[1].toLowerCase());
if (mentioned) state.company = mentioned;
} else {
for (const c of state.companies) {
if (ql.includes(c.toLowerCase()) || (state.tickers[c] && ql.includes(state.tickers[c].toLowerCase()))) {
state.company = c;
break;
}
}
}
// Sync dropdown UI
if (companySel && companySel.value !== state.company) {
companySel.value = state.company;
}
}
appendUserMsg(query);
await saveMessage('user', query);
loadHistory();
if (state.mode === 'dashboard') {
await runDashboard();
} else if (state.mode === 'compare') {
await runCompare(query);
} else {
await runChat(query);
}
loadHistory();
}
/* ═══════════════════════════════════════════════════════
CHAT
═══════════════════════════════════════════════════════ */
async function runChat(query) {
setBusy(true);
const spinEl = appendSpinner('Searching documents…');
let fullText = '';
let sources = [];
const asst = createAssistantEl();
try {
const res = await fetch('/api/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, company: state.company, fiscal_year: state.fy }),
});
spinEl.remove();
const thread = document.getElementById('thread');
thread.appendChild(asst.wrap);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const evt = JSON.parse(line.slice(6));
if (evt.type === 'chunk') {
fullText += evt.text;
asst.content.innerHTML = marked.parse(fullText) + '<span class="cursor"></span>';
scrollThread();
} else if (evt.type === 'sources') {
sources = evt.sources;
} else if (evt.type === 'done') {
asst.content.innerHTML = marked.parse(fullText);
if (sources.length) appendSources(asst.content, sources);
}
}
}
} catch (e) {
spinEl.remove();
appendAssistantMsg(`Error: ${e.message}`, false);
}
await saveMessage('assistant', fullText, { sources });
setBusy(false);
}
/* ═══════════════════════════════════════════════════════
COMPARE
═══════════════════════════════════════════════════════ */
async function runCompare(query) {
setBusy(true);
const companies = state.compareCompanies.length >= 1 ? state.compareCompanies : [state.company];
const spinEl = appendSpinner(`Comparing ${companies.join(' vs ')}…`);
let fullText = '';
let sources = [];
const asst = createAssistantEl();
try {
const res = await fetch('/api/compare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, companies, fiscal_year: state.fy }),
});
spinEl.remove();
document.getElementById('thread').appendChild(asst.wrap);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const evt = JSON.parse(line.slice(6));
if (evt.type === 'chunk') {
fullText += evt.text;
asst.content.innerHTML = marked.parse(fullText) + '<span class="cursor"></span>';
scrollThread();
} else if (evt.type === 'sources') {
sources = evt.sources;
} else if (evt.type === 'done') {
asst.content.innerHTML = marked.parse(fullText);
if (sources.length) appendSources(asst.content, sources);
}
}
}
} catch (e) {
spinEl.remove();
appendAssistantMsg(`Error: ${e.message}`, false);
}
await saveMessage('assistant', fullText, { sources });
setBusy(false);
}
/* ═══════════════════════════════════════════════════════
DASHBOARD
═══════════════════════════════════════════════════════ */
async function extractDashboard() {
const companySel = document.getElementById('company-sel');
if (companySel) state.company = companySel.value;
const fySel = document.getElementById('fy-sel');
if (fySel) state.fy = fySel.value;
const welcome = document.getElementById('welcome');
if (welcome) welcome.remove();
const query = `Show KPI dashboard for ${state.company} ${state.fy}`;
appendUserMsg(query);
await saveMessage('user', query);
await runDashboard();
loadHistory();
}
async function runDashboard() {
setBusy(true);
const companySel = document.getElementById('company-sel');
if (companySel) state.company = companySel.value;
const spinEl = appendSpinner(`Extracting KPIs for ${state.company}…`);
const asst = createAssistantEl();
try {
const res = await fetch('/api/dashboard', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company: state.company, fiscal_year: state.fy }),
});
const data = await res.json();
const metrics = data.metrics || {};
const fyLabel = data.fiscal_year || state.fy;
const ticker = state.tickers[state.company] || '';
spinEl.remove();
const thread = document.getElementById('thread');
thread.appendChild(asst.wrap);
asst.content.innerHTML = `<strong>${state.company}</strong> (${ticker}) β€” KPI Summary <span style="color:var(--accent2)">${fyLabel}</span>`;
appendKPI(asst.content, metrics, state.company);
await saveMessage('assistant',
`${state.company} KPI Dashboard β€” ${fyLabel}`,
{ kpi_metrics: metrics, company: state.company, fy: fyLabel }
);
} catch (e) {
spinEl.remove();
appendAssistantMsg(`Error: ${e.message}`, false);
}
setBusy(false);
scrollThread();
}
/* ═══════════════════════════════════════════════════════
DOM HELPERS
═══════════════════════════════════════════════════════ */
function appendUserMsg(text) {
const thread = document.getElementById('thread');
const wrap = document.createElement('div');
wrap.className = 'msg-wrap';
wrap.innerHTML = `<div class="msg-user"><div class="bubble">${escHtml(text)}</div></div>`;
thread.appendChild(wrap);
scrollThread();
}
function createAssistantEl() {
const wrap = document.createElement('div');
wrap.className = 'msg-wrap';
const content = document.createElement('div');
content.className = 'asst-content';
wrap.innerHTML = `
<div class="msg-asst">
<div class="asst-avatar">πŸ“ˆ</div>
<div class="asst-body">
<div class="asst-name">FinRAG</div>
</div>
</div>`;
wrap.querySelector('.asst-body').appendChild(content);
return { wrap, content };
}
function appendAssistantMsg(markdown, streaming = false) {
const thread = document.getElementById('thread');
const { wrap, content } = createAssistantEl();
content.innerHTML = marked.parse(markdown);
thread.appendChild(wrap);
scrollThread();
return content;
}
function appendSpinner(text) {
const thread = document.getElementById('thread');
const el = document.createElement('div');
el.style.cssText = 'max-width:780px;margin:0.5rem auto;padding:0 1.5rem;';
el.innerHTML = `<div class="spin-row"><div class="spinner"></div><span>${escHtml(text)}</span></div>`;
thread.appendChild(el);
scrollThread();
return el;
}
function appendSources(container, sources) {
if (!sources?.length) return;
const div = document.createElement('div');
div.className = 'sources-bar';
sources.forEach(s => {
const chip = document.createElement('a');
chip.className = 'src-chip' + (s.is_table ? ' tbl' : '');
chip.textContent = `${s.company || ''} ${s.fiscal_year || ''} Pg.${s.page}${s.is_table ? ' [TABLE]' : ''}`.trim();
chip.title = s.file || '';
if (s.file) {
chip.href = `/raw_pdfs/${encodeURIComponent(s.file)}#page=${s.page}`;
chip.target = '_blank';
}
div.appendChild(chip);
});
container.appendChild(div);
}
function appendKPI(container, metrics, company) {
const fmt = (v, type) => {
if (v === null || v === undefined) return null;
if (type === 'crore') return v >= 100000 ? `β‚Ή${(v/100000).toFixed(1)}L Cr` : `β‚Ή${Math.round(v).toLocaleString('en-IN')} Cr`;
if (type === 'pct') return `${v.toFixed(2)}%`;
if (type === 'inr') return `β‚Ή${v.toFixed(2)}`;
return String(v);
};
const rows = [
{ title: 'Revenue & Profitability', items: [
['Revenue', metrics.revenue_crore, fmt(metrics.revenue_crore, 'crore')],
['Net Profit', metrics.net_profit_crore, fmt(metrics.net_profit_crore, 'crore')],
['Net Margin', metrics.net_profit_margin_pct, fmt(metrics.net_profit_margin_pct, 'pct')],
['EBITDA Margin',metrics.ebitda_margin_pct, fmt(metrics.ebitda_margin_pct, 'pct')],
]},
{ title: 'Balance Sheet', items: [
['Total Assets', metrics.total_assets_crore, fmt(metrics.total_assets_crore, 'crore')],
['Equity', metrics.equity_crore, fmt(metrics.equity_crore, 'crore')],
['EPS', metrics.eps_inr, fmt(metrics.eps_inr, 'inr')],
['ROE', metrics.roe_pct, fmt(metrics.roe_pct, 'pct')],
]},
];
const hasBanking = metrics.npa_gross_pct || metrics.npa_net_pct || metrics.nim_pct;
if (hasBanking) rows.push({ title: 'Banking Metrics', items: [
['Gross NPA', metrics.npa_gross_pct, fmt(metrics.npa_gross_pct, 'pct')],
['Net NPA', metrics.npa_net_pct, fmt(metrics.npa_net_pct, 'pct')],
['NIM', metrics.nim_pct, fmt(metrics.nim_pct, 'pct')],
['EBITDA', metrics.ebitda_crore, fmt(metrics.ebitda_crore, 'crore')],
]});
const section = document.createElement('div');
section.className = 'kpi-section';
const colorMetrics = ['Net Margin', 'EBITDA Margin', 'EPS', 'ROE', 'NIM'];
const inverseColorMetrics = ['Gross NPA', 'Net NPA'];
rows.forEach(row => {
const validItems = row.items.filter(([lbl, raw, val]) => val !== null);
if (validItems.length === 0) return; // Skip section if all metrics are N/A
section.innerHTML += `<div class="kpi-section-title">${row.title}</div>`;
const grid = document.createElement('div');
grid.className = 'kpi-grid';
validItems.forEach(([lbl, raw, val]) => {
let colorClass = '';
if (colorMetrics.includes(lbl) && raw !== null) {
colorClass = raw > 0 ? ' text-green' : (raw < 0 ? ' text-red' : '');
} else if (inverseColorMetrics.includes(lbl) && raw !== null) {
colorClass = ' text-red'; // NPA is bad
}
const card = document.createElement('div');
card.className = 'kpi-card has-val';
card.innerHTML = `<div class="val${colorClass}">${val}</div><div class="lbl">${lbl}</div>`;
grid.appendChild(card);
});
section.appendChild(grid);
});
container.appendChild(section);
// Charts
const renderChart = (id, title, dataMap) => {
const filtered = Object.entries(dataMap).filter(([,v]) => v != null);
if (filtered.length < 1) return;
const chartWrap = document.createElement('div');
chartWrap.className = 'chart-wrap';
chartWrap.innerHTML = `<div id="${id}"></div>`;
container.appendChild(chartWrap);
setTimeout(() => {
Plotly.newPlot(id,
[{ type: 'bar', orientation: 'h',
y: filtered.map(([k]) => k),
x: filtered.map(([,v]) => v),
marker: { color: ['#3b82f6','#06b6d4','#8b5cf6'] },
text: filtered.map(([,v]) => `β‚Ή${(v).toLocaleString('en-IN')} Cr`),
textposition: 'outside', textfont: { color: '#e2e8f0', size: 11 },
}],
{ paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(255,255,255,0.02)',
font: { color: '#94a3b8', family: 'Inter' },
xaxis: { gridcolor: 'rgba(255,255,255,0.05)', tickfont: { color: '#64748b' } },
yaxis: { tickfont: { color: '#e2e8f0', size: 12 }, automargin: true },
margin: { t: 35, b: 10, l: 10, r: 80 }, height: filtered.length === 1 ? 90 : 160,
title: { text: title, font: { color: '#64748b', size: 11 } },
},
{ displayModeBar: false, responsive: true }
);
}, 100);
};
const stamp = Date.now();
renderChart('chart-pl-' + stamp, `${company} β€” P&L Overview`, {
'Revenue': metrics.revenue_crore,
'EBITDA': metrics.ebitda_crore,
'Net Profit': metrics.net_profit_crore,
});
renderChart('chart-bs-' + stamp, `${company} β€” Balance Sheet`, {
'Total Assets': metrics.total_assets_crore,
'Equity': metrics.equity_crore,
});
}
/* ═══════════════════════════════════════════════════════
UTILS
═══════════════════════════════════════════════════════ */
function scrollThread() {
const t = document.getElementById('thread');
t.scrollTop = t.scrollHeight;
}
function setBusy(busy) {
state.isStreaming = busy;
document.getElementById('send-btn').disabled = busy;
const eb = document.getElementById('extract-btn');
if (eb) eb.disabled = busy;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* Configure marked */
marked.setOptions({ breaks: true, gfm: true });
init();
</script>
</body>
</html>