FinAI / index.html
junaid17's picture
Upload 13 files
ca67025 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FinAI — Financial Intelligence</title>
<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">
<style>
:root {
--bg: #050505;
--bg-elevated: #0a0a0a;
--bg-card: #0f0f0f;
--bg-hover: #161616;
--bg-active: #1a1a1a;
--border: #1f1f1f;
--border-light: #2a2a2a;
--border-hover: #333333;
--text: #f5f5f5;
--text-secondary: #a0a0a0;
--text-muted: #666666;
--text-dim: #444444;
--accent: #e8e8e8;
--accent-dim: #999999;
--radius-sm: 16px;
--radius: 20px;
--radius-lg: 24px;
--radius-pill: 999px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeInScale { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
@keyframes slideInRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes slideInLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
@keyframes breathe { 0%,100% { transform: scale(1); opacity: 0.6; } 50% { transform: scale(1.05); opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes typingDot { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
@keyframes progressFill { from { width: 0%; } to { width: 100%; } }
@keyframes cursorBlink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
@keyframes ripple { 0% { transform: scale(0); opacity: 0.5; } 100% { transform: scale(4); opacity: 0; } }
@keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
.app { display: flex; height: 100vh; width: 100vw; }
/* ===== SIDEBAR ===== */
.sidebar {
width: 300px;
background: var(--bg-elevated);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 20px;
gap: 20px;
overflow-y: auto;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
position: relative; z-index: 100;
}
.sidebar-logo {
display: flex; align-items: center; gap: 12px;
padding: 8px 4px;
}
.logo-ring {
width: 36px; height: 36px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1.5px solid var(--border-light);
display: flex; align-items: center; justify-content: center;
font-size: 16px;
position: relative;
overflow: hidden;
}
.logo-ring::after {
content: ''; position: absolute; inset: -2px;
border-radius: inherit;
background: conic-gradient(from 0deg, transparent, var(--text-dim), transparent);
opacity: 0.3;
animation: spin 8s linear infinite;
z-index: -1;
}
.logo-text {
font-size: 18px; font-weight: 700;
letter-spacing: -0.4px;
color: var(--text);
}
.new-chat {
display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 12px;
background: var(--bg-card);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px; font-weight: 500;
font-family: inherit; cursor: pointer;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.new-chat:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
.new-chat:active { transform: scale(0.98); }
.section-label {
font-size: 10px; font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-dim);
padding: 0 6px;
margin-bottom: 6px;
}
.recent-list { display: flex; flex-direction: column; gap: 2px; }
.recent-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.recent-item:hover { background: var(--bg-hover); }
.recent-item.active { background: var(--bg-active); }
.recent-item.active::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 14px; background: var(--text-secondary); border-radius: 2px;
}
.recent-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--border-light); flex-shrink: 0;
}
.recent-item.active .recent-dot { background: var(--text-secondary); }
.recent-title {
font-size: 13px; font-weight: 500;
color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.recent-time { font-size: 11px; color: var(--text-dim); margin-top: 1px; }
/* Upload Area */
.upload-wrap { display: flex; flex-direction: column; gap: 10px; }
.upload-zone {
border: 1.5px dashed var(--border-light);
border-radius: var(--radius-sm);
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: var(--bg-card);
position: relative;
overflow: hidden;
}
.upload-zone:hover {
border-color: var(--border-hover);
background: var(--bg-hover);
}
.upload-zone.dragover {
border-color: var(--text-muted);
background: rgba(255,255,255,0.02);
}
.upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-zone-icon { font-size: 28px; margin-bottom: 8px; display: block; filter: grayscale(1); opacity: 0.7; }
.upload-zone-text { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.upload-zone-hint { font-size: 11px; color: var(--text-dim); margin-top: 3px; }
/* Upload Progress */
.upload-progress {
display: none;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
animation: fadeInScale 0.3s ease;
}
.upload-progress.show { display: flex; }
.progress-track {
height: 3px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--text-dim), var(--text-secondary), var(--text-dim));
background-size: 200% 100%;
border-radius: 3px;
width: 0%;
transition: width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
animation: shimmer 2s linear infinite;
}
.progress-info {
display: flex; justify-content: space-between; align-items: center;
font-size: 11px;
}
.progress-step {
color: var(--text-secondary);
font-weight: 500;
display: flex; align-items: center; gap: 6px;
}
.progress-step .spinner {
width: 12px; height: 12px;
border: 1.5px solid transparent;
border-top-color: var(--text-secondary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.progress-pct { color: var(--text-dim); font-family: 'JetBrains Mono', monospace; font-size: 10px; }
.upload-done {
display: none;
padding: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
animation: fadeInScale 0.3s ease;
}
.upload-done.show { display: flex; align-items: center; gap: 10px; }
.upload-done-icon {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--bg-hover);
display: flex; align-items: center; justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.upload-done-text { font-size: 12px; color: var(--text-secondary); font-weight: 500; }
.upload-done-meta { font-size: 10px; color: var(--text-dim); margin-top: 2px; }
.upload-btn {
padding: 10px;
background: var(--bg-card);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px; font-weight: 500;
font-family: inherit; cursor: pointer;
transition: all 0.25s ease;
width: 100%;
}
.upload-btn:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}
.upload-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.reset-btn {
padding: 10px;
background: transparent;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px; font-weight: 500;
font-family: inherit; cursor: pointer;
transition: all 0.2s;
width: 100%;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.reset-btn:hover {
border-color: rgba(239,68,68,0.3);
color: #ef4444;
background: rgba(239,68,68,0.03);
}
/* Session */
.session-box {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--border);
display: flex; flex-direction: column; gap: 10px;
}
.session-label {
display: flex; justify-content: space-between; align-items: center;
font-size: 10px; color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1.5px;
}
.session-id {
font-family: 'JetBrains Mono', monospace;
font-size: 10px; color: var(--text-dim);
word-break: break-all;
padding: 8px 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.copy-mini {
background: none; border: none;
color: var(--text-dim); cursor: pointer;
font-size: 10px; padding: 2px 6px;
border-radius: 4px; transition: all 0.2s;
}
.copy-mini:hover { color: var(--text-secondary); background: var(--bg-hover); }
.version { font-size: 10px; color: var(--text-dim); text-align: center; }
/* ===== MAIN ===== */
.main {
flex: 1;
display: flex; flex-direction: column;
position: relative; overflow: hidden;
background: var(--bg);
}
/* Empty State */
.empty {
flex: 1;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 24px;
padding: 40px 24px;
animation: fadeInScale 0.6s ease;
}
.empty-orb {
width: 72px; height: 72px;
border-radius: var(--radius-lg);
background: var(--bg-card);
border: 1.5px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 28px;
position: relative;
animation: breathe 4s ease-in-out infinite;
}
.empty-orb::before {
content: ''; position: absolute; inset: -4px;
border-radius: inherit;
background: conic-gradient(from 0deg, transparent, var(--border-light), transparent);
opacity: 0.2;
animation: spin 12s linear infinite;
z-index: -1;
}
.empty-title {
font-size: 32px; font-weight: 700;
letter-spacing: -1px;
color: var(--text);
}
.empty-desc {
font-size: 14px; color: var(--text-muted);
text-align: center; max-width: 400px; line-height: 1.7;
}
.empty-chips {
display: flex; flex-wrap: wrap;
justify-content: center; gap: 8px;
max-width: 600px; margin-top: 4px;
}
.chip {
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
font-size: 12px; color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
font-weight: 500;
}
.chip:hover {
border-color: var(--border-hover);
color: var(--text);
background: var(--bg-hover);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 32px 24px 16px;
display: flex; flex-direction: column;
gap: 28px;
scroll-behavior: smooth;
}
.msg {
display: flex; gap: 14px;
max-width: 780px;
width: 100%;
align-self: center;
animation: fadeIn 0.35s ease-out;
}
.msg-avatar {
width: 32px; height: 32px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
font-size: 13px;
position: relative;
}
.msg-avatar.user {
background: var(--bg-card);
border: 1.5px solid var(--border-light);
color: var(--text-secondary);
}
.msg-avatar.assistant {
background: var(--bg-card);
border: 1.5px solid var(--border-light);
color: var(--text);
}
.msg-avatar::after {
content: ''; position: absolute; bottom: -2px; right: -2px;
width: 8px; height: 8px; border-radius: 50%;
background: var(--bg); border: 2px solid var(--bg);
}
.msg-avatar.user::after { background: var(--text-dim); }
.msg-avatar.assistant::after { background: var(--text-secondary); }
.msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.msg-head {
display: flex; align-items: center; gap: 8px;
font-size: 12px;
}
.msg-name { font-weight: 600; color: var(--text); }
.msg-time { color: var(--text-dim); font-weight: 400; font-size: 11px; }
.msg-text {
font-size: 14.5px; line-height: 1.75;
color: var(--text-secondary);
word-wrap: break-word;
padding: 2px 0;
}
.msg-text strong { color: var(--text); font-weight: 600; }
.msg-text em { color: var(--text); font-style: italic; }
.msg-text code {
background: var(--bg-elevated);
padding: 2px 6px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px; color: var(--text);
border: 1px solid var(--border);
}
.msg-text p { margin: 0 0 10px 0; }
.msg-text p:last-child { margin-bottom: 0; }
/* Typing */
.typing {
display: flex; gap: 4px; align-items: center;
padding: 6px 0;
}
.typing span {
width: 5px; height: 5px;
background: var(--text-dim);
border-radius: 50%;
animation: typingDot 1.4s ease-in-out infinite;
}
.typing span:nth-child(2) { animation-delay: 0.2s; }
.typing span:nth-child(3) { animation-delay: 0.4s; }
/* Cursor */
.stream-cursor {
display: inline-block;
width: 2px; height: 1.1em;
background: var(--text-secondary);
margin-left: 2px;
vertical-align: text-bottom;
animation: cursorBlink 1s step-end infinite;
}
/* RAG Card */
.rag {
margin-top: 10px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
animation: fadeInScale 0.35s ease;
}
.rag-toggle {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.rag-toggle:hover { background: var(--bg-hover); }
.rag-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: var(--radius-pill);
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.8px;
}
.rag-badge.rag-active {
background: var(--bg-hover);
color: var(--text-secondary);
border: 1px solid var(--border-light);
}
.rag-badge.rag-direct {
background: var(--bg-hover);
color: var(--text-dim);
border: 1px solid var(--border);
}
.rag-label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.rag-arrow { margin-left: auto; font-size: 10px; color: var(--text-dim); transition: transform 0.25s ease; }
.rag-toggle.open .rag-arrow { transform: rotate(180deg); }
.rag-body {
display: none;
padding: 0 14px 12px;
flex-direction: column; gap: 6px;
}
.rag-body.open { display: flex; }
.source {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
background: var(--bg);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
transition: all 0.2s ease;
}
.source:hover {
border-color: var(--border-hover);
background: var(--bg-card);
transform: translateX(3px);
}
.source-icon {
width: 28px; height: 28px;
background: var(--bg-elevated);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 12px; flex-shrink: 0;
border: 1px solid var(--border);
}
.source-info { flex: 1; min-width: 0; }
.source-name {
font-size: 12px; font-weight: 500;
color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.source-meta { font-size: 10px; color: var(--text-dim); margin-top: 1px; }
.source-score {
font-family: 'JetBrains Mono', monospace;
font-size: 10px; color: var(--text-secondary);
background: var(--bg-elevated);
padding: 3px 8px; border-radius: 6px;
border: 1px solid var(--border);
}
.rag-footer {
padding-top: 8px; margin-top: 4px;
border-top: 1px solid var(--border);
font-size: 10px; color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
display: flex; justify-content: space-between;
}
/* Performance */
.perf {
display: flex; align-items: center; gap: 14px;
padding: 4px 0;
font-size: 11px;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
}
.perf-item { display: flex; align-items: center; gap: 5px; }
.perf-dot {
width: 4px; height: 4px; border-radius: 50%;
background: var(--text-dim);
}
/* Input */
.input-area {
padding: 16px 24px 28px;
display: flex; flex-direction: column;
align-items: center; gap: 10px;
position: relative;
}
.input-box {
display: flex; align-items: flex-end; gap: 10px;
width: 100%; max-width: 720px;
background: var(--bg-elevated);
border: 1.5px solid var(--border);
border-radius: var(--radius);
padding: 6px 6px 6px 18px;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.input-box:focus-within {
border-color: var(--border-hover);
box-shadow: 0 0 0 4px rgba(255,255,255,0.03), 0 8px 32px rgba(0,0,0,0.4);
}
.input-field {
flex: 1;
background: transparent; border: none;
color: var(--text); font-family: inherit;
font-size: 14.5px; padding: 10px 0;
resize: none; outline: none;
max-height: 160px; min-height: 24px;
line-height: 1.6;
}
.input-field::placeholder { color: var(--text-dim); }
.send-btn {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--bg-card);
border: 1.5px solid var(--border);
color: var(--text-secondary);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
flex-shrink: 0; margin-bottom: 2px;
}
.send-btn:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
color: var(--text);
transform: scale(1.08);
}
.send-btn:active { transform: scale(0.95); }
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none; }
.send-btn svg { width: 15px; height: 15px; }
.input-hint {
font-size: 11px; color: var(--text-dim);
text-align: center;
}
/* Status */
.status-bar {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 6px 24px;
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--text-dim);
pointer-events: none;
}
.status-pulse {
width: 5px; height: 5px;
background: var(--text-dim);
border-radius: 50%;
}
.status-pulse.active {
background: var(--text-secondary);
animation: pulse 2s ease-in-out infinite;
box-shadow: 0 0 6px rgba(160,160,160,0.3);
}
/* Toast */
.toasts {
position: fixed; top: 16px; right: 16px;
z-index: 1000;
display: flex; flex-direction: column; gap: 8px;
}
.toast {
padding: 12px 18px; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 500;
display: flex; align-items: center; gap: 10px;
animation: slideInRight 0.35s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
backdrop-filter: blur(16px);
max-width: 380px;
border: 1px solid var(--border);
}
.toast.ok { background: var(--bg-card); color: var(--text); }
.toast.err { background: var(--bg-card); color: #ef4444; }
.toast.info { background: var(--bg-card); color: var(--text-secondary); }
/* Mobile */
.mobile-toggle {
display: none;
position: absolute; left: 16px; top: 14px;
width: 36px; height: 36px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border);
background: var(--bg-elevated);
color: var(--text-secondary);
align-items: center; justify-content: center;
cursor: pointer; font-size: 16px; z-index: 60;
}
.sidebar-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
z-index: 99; backdrop-filter: blur(4px);
}
@media (max-width: 768px) {
.sidebar { position: fixed; left: 0; top: 0; height: 100vh; transform: translateX(-100%); width: 280px; }
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.show { display: block; }
.mobile-toggle { display: flex; }
.messages { padding: 56px 16px 16px; }
.msg { max-width: 100%; }
.input-area { padding: 12px 16px 20px; }
.empty-title { font-size: 26px; }
}
/* Chat header */
.chat-header {
padding: 14px 24px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
position: relative;
background: rgba(5,5,5,0.8);
backdrop-filter: blur(16px);
}
.chat-header-text {
font-size: 13px; font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.spinner {
width: 14px; height: 14px;
border: 1.5px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
</style>
<base target="_blank">
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<div class="logo-ring">💹</div>
<div class="logo-text">FinAI</div>
</div>
<button class="new-chat" id="newChatBtn">
<span style="font-size:16px;">+</span> New Chat
</button>
<div>
<div class="section-label">Recent Chats</div>
<div class="recent-list" id="recentList"></div>
</div>
<div class="upload-wrap">
<div class="section-label">Upload Document</div>
<div class="upload-zone" id="uploadZone">
<input type="file" id="fileInput" accept=".pdf" />
<span class="upload-zone-icon">📄</span>
<div class="upload-zone-text" id="uzText">Drop PDF or click to browse</div>
<div class="upload-zone-hint" id="uzHint">Financial reports, statements, tax docs</div>
</div>
<div class="upload-progress" id="uploadProgress">
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-info">
<span class="progress-step" id="progressStep"><div class="spinner"></div> Uploading PDF...</span>
<span class="progress-pct" id="progressPct">0%</span>
</div>
</div>
<div class="upload-done" id="uploadDone">
<div class="upload-done-icon"></div>
<div>
<div class="upload-done-text" id="udText">Document uploaded</div>
<div class="upload-done-meta" id="udMeta">Ready for queries</div>
</div>
</div>
<button class="upload-btn" id="uploadBtn">Upload Document</button>
</div>
<div>
<div class="section-label">Danger Zone</div>
<button class="reset-btn" id="resetBtn">
<span>🗑</span> Delete All Documents
</button>
</div>
<div class="session-box">
<div class="session-label">
<span>Session ID</span>
<button class="copy-mini" id="copyThread">Copy</button>
</div>
<div class="session-id" id="threadDisplay"></div>
<div class="version">FinAI v1.0 · Qwen + RAG</div>
</div>
</aside>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Main -->
<main class="main" id="main">
<button class="mobile-toggle" id="mobileToggle"></button>
<header class="chat-header" id="chatHeader" style="display:none;">
<div class="chat-header-text">Conversation</div>
</header>
<div class="messages" id="messages">
<div class="empty" id="emptyState">
<div class="empty-orb">💹</div>
<div class="empty-title">FinAI</div>
<div class="empty-desc">
Your intelligent financial assistant. Upload a document and ask about dividends,
taxes, retirement accounts, business valuation, or market analysis.
</div>
<div class="empty-chips" id="emptyChips">
<div class="chip" data-q="What are the risks of dividend-yielding stocks?">Dividend risks</div>
<div class="chip" data-q="15-year vs 30-year mortgage paid in 15 — what's the difference?">Mortgage comparison</div>
<div class="chip" data-q="How do taxes work for a new LLC business owner?">LLC taxes</div>
<div class="chip" data-q="Can I have both a 401(k) and a SEP IRA?">Retirement accounts</div>
<div class="chip" data-q="How to value a small business before investing?">Business valuation</div>
<div class="chip" data-q="Why do companies have fiscal years different from calendar years?">Fiscal years</div>
<div class="chip" data-q="What's wrong with taking money from your own business?">Owner withdrawals</div>
<div class="chip" data-q="Is there a candlestick pattern that guarantees profit?">Trading patterns</div>
</div>
</div>
</div>
<div class="input-area">
<div class="input-box">
<textarea class="input-field" id="inputField" rows="1" placeholder="Ask a finance question..."></textarea>
<button class="send-btn" id="sendBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22,2 15,22 11,13 2,9 22,2"></polygon>
</svg>
</button>
</div>
<div class="input-hint">Enter to send · Shift+Enter for new line</div>
</div>
<div class="status-bar">
<span class="status-pulse" id="statusDot"></span>
<span id="statusText">Ready</span>
</div>
</main>
</div>
<div class="toasts" id="toasts"></div>
<script>
// ================= CONFIG =================
const API_BASE = "http://127.0.0.1:8000";
// ================= STATE =================
let threadId = localStorage.getItem("finai_tid") || generateId();
let messages = JSON.parse(localStorage.getItem("finai_msgs") || "[]");
let isStreaming = false;
let currentFile = null;
let streamStart = 0;
let tokCount = 0;
localStorage.setItem("finai_tid", threadId);
// ================= DOM =================
const sidebar = document.getElementById("sidebar");
const sidebarOverlay = document.getElementById("sidebarOverlay");
const mobileToggle = document.getElementById("mobileToggle");
const messagesEl = document.getElementById("messages");
const emptyState = document.getElementById("emptyState");
const inputField = document.getElementById("inputField");
const sendBtn = document.getElementById("sendBtn");
const uploadZone = document.getElementById("uploadZone");
const fileInput = document.getElementById("fileInput");
const uploadBtn = document.getElementById("uploadBtn");
const uploadProgress = document.getElementById("uploadProgress");
const progressFill = document.getElementById("progressFill");
const progressStep = document.getElementById("progressStep");
const progressPct = document.getElementById("progressPct");
const uploadDone = document.getElementById("uploadDone");
const udText = document.getElementById("udText");
const udMeta = document.getElementById("udMeta");
const uzText = document.getElementById("uzText");
const uzHint = document.getElementById("uzHint");
const resetBtn = document.getElementById("resetBtn");
const newChatBtn = document.getElementById("newChatBtn");
const threadDisplay = document.getElementById("threadDisplay");
const copyThread = document.getElementById("copyThread");
const toasts = document.getElementById("toasts");
const chatHeader = document.getElementById("chatHeader");
const statusDot = document.getElementById("statusDot");
const statusText = document.getElementById("statusText");
const recentList = document.getElementById("recentList");
threadDisplay.textContent = threadId;
// ================= INIT =================
function generateId() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16);
});
}
function saveMsgs() {
localStorage.setItem("finai_msgs", JSON.stringify(messages));
updateRecent();
}
function updateRecent() {
let chats = JSON.parse(localStorage.getItem("finai_chats") || "[]");
if (messages.length > 0) {
const first = messages.find(m => m.role === "user");
const title = first ? first.content.slice(0, 32) + (first.content.length > 32 ? "..." : "") : "New Chat";
const idx = chats.findIndex(c => c.id === threadId);
const entry = { id: threadId, title, time: Date.now() };
if (idx >= 0) chats[idx] = entry; else chats.unshift(entry);
chats = chats.slice(0, 15);
localStorage.setItem("finai_chats", JSON.stringify(chats));
}
recentList.innerHTML = "";
chats.forEach(c => {
const el = document.createElement("div");
el.className = `recent-item${c.id === threadId ? " active" : ""}`;
const d = new Date(c.time);
const ts = d.toLocaleDateString() === new Date().toLocaleDateString()
? d.toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"})
: d.toLocaleDateString([], {month:"short", day:"numeric"});
el.innerHTML = `<div class="recent-dot"></div><div><div class="recent-title">${esc(c.title)}</div><div class="recent-time">${ts}</div></div>`;
el.addEventListener("click", () => loadChat(c.id));
recentList.appendChild(el);
});
}
function loadChat(id) {
const data = localStorage.getItem("finai_msgs_" + id);
if (!data) { toast("Chat not found", "err"); return; }
threadId = id;
messages = JSON.parse(data);
localStorage.setItem("finai_tid", threadId);
threadDisplay.textContent = threadId;
renderMsgs();
updateRecent();
sidebar.classList.remove("open");
sidebarOverlay.classList.remove("show");
}
function renderMsgs() {
messagesEl.innerHTML = "";
if (messages.length === 0) {
messagesEl.appendChild(emptyState);
chatHeader.style.display = "none";
return;
}
chatHeader.style.display = "flex";
messages.forEach(m => appendMsg(m.role, m.content, m.meta, m.perf, false));
scrollBottom();
}
// ================= HELPERS =================
function toast(msg, type="info") {
const el = document.createElement("div");
el.className = `toast ${type}`;
const icon = type === "ok" ? "✓" : type === "err" ? "✕" : "•";
el.innerHTML = `<span style="font-weight:700;font-size:14px;">${icon}</span> ${msg}`;
toasts.appendChild(el);
setTimeout(() => { el.style.opacity="0"; el.style.transform="translateX(20px)"; setTimeout(()=>el.remove(),300); }, 4000);
}
function setStatus(text, active=false) {
statusText.textContent = text;
statusDot.classList.toggle("active", active);
}
function scrollBottom() { messagesEl.scrollTop = messagesEl.scrollHeight; }
function esc(t) { const d=document.createElement("div"); d.textContent=t; return d.innerHTML; }
function fmtMd(t) {
if (!t) return "";
return t
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\n/g, "<p></p>");
}
// ================= MESSAGES =================
function appendMsg(role, content, meta=null, perf=null, animate=true) {
if (messages.length === 0 && emptyState.parentNode) {
emptyState.remove();
chatHeader.style.display = "flex";
}
const msg = document.createElement("div");
msg.className = `msg${animate?" fade-in":""}`;
const av = document.createElement("div");
av.className = `msg-avatar ${role}`;
av.textContent = role === "user" ? "👤" : "🤖";
const body = document.createElement("div");
body.className = "msg-body";
const head = document.createElement("div");
head.className = "msg-head";
const tm = new Date().toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"});
head.innerHTML = `<span class="msg-name">${role==="user"?"You":"FinAI"}</span><span class="msg-time">${tm}</span>`;
body.appendChild(head);
const bubble = document.createElement("div");
bubble.className = "msg-text";
bubble.innerHTML = fmtMd(content);
body.appendChild(bubble);
if (perf && role === "assistant") {
const p = document.createElement("div");
p.className = "perf";
p.innerHTML = `
<div class="perf-item"><span class="perf-dot"></span>${perf.tokens} tokens</div>
<div class="perf-item">${perf.tps.toFixed(1)} tok/s</div>
<div class="perf-item">${perf.latency.toFixed(2)}s</div>
`;
body.appendChild(p);
}
if (meta && role === "assistant") {
body.appendChild(makeRag(meta));
}
msg.appendChild(av);
msg.appendChild(body);
messagesEl.appendChild(msg);
scrollBottom();
return msg;
}
function makeRag(meta) {
const card = document.createElement("div");
card.className = "rag";
const used = meta.used_rag;
const sources = meta.sources || [];
const toggle = document.createElement("div");
toggle.className = "rag-toggle";
const bCls = used ? "rag-active" : "rag-direct";
const bTxt = used ? `📄 RAG · ${sources.length} source${sources.length!==1?"s":""}` : "🧠 Direct";
toggle.innerHTML = `<span class="rag-badge ${bCls}">${bTxt}</span><span class="rag-arrow">▼</span>`;
const body = document.createElement("div");
body.className = "rag-body";
if (used && sources.length) {
sources.forEach((s,i) => {
const row = document.createElement("div");
row.className = "source";
row.style.animationDelay = `${i*0.06}s`;
row.innerHTML = `
<div class="source-icon">📄</div>
<div class="source-info">
<div class="source-name">${esc(s.document||"Unknown")}</div>
<div class="source-meta">Page ${s.page||"?"}${s.chunk_index!==undefined?` · Chunk ${s.chunk_index}`:""}</div>
</div>
<div class="source-score">${(s.score||0.95).toFixed(3)}</div>
`;
body.appendChild(row);
});
} else if (!used) {
const info = document.createElement("div");
info.style.cssText = "font-size:11px;color:var(--text-dim);padding:4px 0;";
info.textContent = "Generated from fine-tuned Qwen model knowledge without document retrieval.";
body.appendChild(info);
}
const foot = document.createElement("div");
foot.className = "rag-footer";
foot.innerHTML = `<span>Thread: ${meta.thread_id||threadId}</span><span>${new Date().toLocaleTimeString()}</span>`;
body.appendChild(foot);
toggle.addEventListener("click", () => {
const open = body.classList.contains("open");
body.classList.toggle("open");
toggle.classList.toggle("open");
toggle.querySelector(".rag-arrow").textContent = open ? "▼" : "▲";
});
card.appendChild(toggle);
card.appendChild(body);
return card;
}
// ================= CHAT =================
async function sendMsg() {
const text = inputField.value.trim();
if (!text || isStreaming) return;
inputField.value = "";
inputField.style.height = "auto";
isStreaming = true;
sendBtn.disabled = true;
streamStart = performance.now();
tokCount = 0;
setStatus("Generating...", true);
messages.push({role:"user", content:text});
saveMsgs();
appendMsg("user", text);
const asst = {role:"assistant", content:"", meta:null, perf:null};
messages.push(asst);
const msgDiv = appendMsg("assistant", "", null, null);
const bubble = msgDiv.querySelector(".msg-text");
bubble.innerHTML = `<div class="typing"><span></span><span></span><span></span></div>`;
try {
const res = await fetch(`${API_BASE}/chat/stream`, {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({message:text, thread_id:threadId})
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", full = "", meta = null;
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.trim()) continue;
const clean = line.replace(/^data: /,"").trim();
if (!clean) continue;
try {
const ev = JSON.parse(clean);
if (ev.type === "token") {
if (full === "") bubble.innerHTML = "";
full += ev.content;
tokCount++;
const elapsed = (performance.now()-streamStart)/1000;
const tps = elapsed>0 ? tokCount/elapsed : 0;
setStatus(`${tokCount} tokens · ${tps.toFixed(1)} tok/s`, true);
bubble.innerHTML = fmtMd(full) + '<span class="stream-cursor"></span>';
scrollBottom();
} else if (ev.type === "metadata") {
meta = ev;
} else if (ev.type === "done") {
break;
} else if (ev.type === "error") {
throw new Error(ev.message);
}
} catch(e) {}
}
}
const total = (performance.now()-streamStart)/1000;
const tps = total>0 ? tokCount/total : 0;
bubble.innerHTML = fmtMd(full);
asst.content = full;
asst.meta = meta;
asst.perf = {tokens:tokCount, tps, latency:total};
saveMsgs();
localStorage.setItem("finai_msgs_"+threadId, JSON.stringify(messages));
if (meta) {
const rag = makeRag(meta);
const body = msgDiv.querySelector(".msg-body");
const perfEl = body.querySelector(".perf");
body.insertBefore(rag, perfEl?.nextSibling || null);
}
setStatus("Ready", false);
} catch(err) {
bubble.innerHTML = `<span style="color:#ef4444">Error: ${esc(err.message)}</span>`;
asst.content = `Error: ${err.message}`;
saveMsgs();
toast(err.message, "err");
setStatus("Error", false);
} finally {
isStreaming = false;
sendBtn.disabled = false;
inputField.focus();
}
}
// ================= UPLOAD =================
uploadZone.addEventListener("click", () => fileInput.click());
uploadZone.addEventListener("dragover", e => { e.preventDefault(); uploadZone.classList.add("dragover"); });
uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
uploadZone.addEventListener("drop", e => {
e.preventDefault();
uploadZone.classList.remove("dragover");
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener("change", () => { if (fileInput.files.length) handleFile(fileInput.files[0]); });
function handleFile(file) {
if (file.type !== "application/pdf" && !file.name.endsWith(".pdf")) {
toast("Only PDF files are supported", "err");
return;
}
currentFile = file;
uzText.textContent = file.name;
uzHint.textContent = `${(file.size/1024).toFixed(1)} KB · Click Upload to index`;
uploadZone.style.borderColor = "var(--border-hover)";
uploadDone.classList.remove("show");
}
uploadBtn.addEventListener("click", async () => {
if (!currentFile) { toast("Select a PDF file first", "info"); return; }
uploadProgress.classList.add("show");
uploadDone.classList.remove("show");
uploadBtn.disabled = true;
const steps = [
{text:"Uploading PDF...", pct:"15%"},
{text:"Extracting text...", pct:"30%"},
{text:"Splitting into chunks...", pct:"50%"},
{text:"Creating embeddings...", pct:"70%"},
{text:"Storing in vector DB...", pct:"90%"},
{text:"Finalizing...", pct:"98%"}
];
let idx = 0;
const interval = setInterval(() => {
if (idx < steps.length) {
progressStep.innerHTML = `<div class="spinner"></div> ${steps[idx].text}`;
progressPct.textContent = steps[idx].pct;
progressFill.style.width = steps[idx].pct;
idx++;
}
}, 500);
const formData = new FormData();
formData.append("file", currentFile);
try {
const res = await fetch(`${API_BASE}/upload`, {method:"POST", body:formData});
const data = await res.json();
clearInterval(interval);
if (res.ok) {
progressFill.style.width = "100%";
progressStep.innerHTML = `<div class="spinner"></div> Done!`;
progressPct.textContent = "100%";
setTimeout(() => {
uploadProgress.classList.remove("show");
udText.textContent = `Uploaded: ${data.filename || currentFile.name}`;
udMeta.textContent = `${data.chunks || "?"} chunks indexed · Ready for queries`;
uploadDone.classList.add("show");
toast(`"${currentFile.name}" indexed successfully`, "ok");
currentFile = null;
uzText.textContent = "Drop PDF or click to browse";
uzHint.textContent = "Financial reports, statements, tax docs";
uploadZone.style.borderColor = "";
fileInput.value = "";
uploadBtn.disabled = false;
}, 600);
} else {
throw new Error(data.detail || data.message || "Upload failed");
}
} catch(err) {
clearInterval(interval);
uploadProgress.classList.remove("show");
progressFill.style.width = "0%";
toast(err.message, "err");
uploadBtn.disabled = false;
}
});
// ================= RESET =================
resetBtn.addEventListener("click", async () => {
if (!confirm("Delete all uploaded documents? This cannot be undone.")) return;
try {
const res = await fetch(`${API_BASE}/reset`, {method:"DELETE"});
if (res.ok) { toast("All documents deleted", "ok"); uploadDone.classList.remove("show"); }
else throw new Error("Failed");
} catch(err) { toast(err.message, "err"); }
});
// ================= NEW CHAT =================
newChatBtn.addEventListener("click", () => {
if (messages.length > 0) {
localStorage.setItem("finai_msgs_"+threadId, JSON.stringify(messages));
}
messages = [];
threadId = generateId();
localStorage.setItem("finai_tid", threadId);
localStorage.removeItem("finai_msgs");
threadDisplay.textContent = threadId;
messagesEl.innerHTML = "";
messagesEl.appendChild(emptyState);
chatHeader.style.display = "none";
updateRecent();
toast("New chat started", "info");
});
// ================= COPY =================
copyThread.addEventListener("click", () => {
navigator.clipboard.writeText(threadId).then(() => {
copyThread.textContent = "Copied!";
setTimeout(() => copyThread.textContent = "Copy", 1200);
});
});
// ================= INPUT =================
inputField.addEventListener("input", () => {
inputField.style.height = "auto";
inputField.style.height = Math.min(inputField.scrollHeight, 160) + "px";
});
inputField.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
sendBtn.addEventListener("click", sendMsg);
// ================= CHIPS =================
document.getElementById("emptyChips").addEventListener("click", e => {
const chip = e.target.closest(".chip");
if (chip) {
inputField.value = chip.dataset.q;
inputField.focus();
inputField.style.height = "auto";
inputField.style.height = Math.min(inputField.scrollHeight, 160) + "px";
}
});
// ================= MOBILE =================
mobileToggle.addEventListener("click", () => {
sidebar.classList.toggle("open");
sidebarOverlay.classList.toggle("show");
});
sidebarOverlay.addEventListener("click", () => {
sidebar.classList.remove("open");
sidebarOverlay.classList.remove("show");
});
// ================= BOOT =================
updateRecent();
if (messages.length > 0) renderMsgs();
</script>
</body>
</html>