| <!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 {
|
| 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-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 {
|
| 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-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 {
|
| flex: 1;
|
| display: flex; flex-direction: column;
|
| position: relative; overflow: hidden;
|
| background: var(--bg);
|
| }
|
|
|
|
|
| .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 {
|
| 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 {
|
| 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; }
|
|
|
|
|
| .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 {
|
| 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;
|
| }
|
|
|
|
|
| .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-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-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);
|
| }
|
|
|
|
|
| .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-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 {
|
| 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">
|
|
|
| <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 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>
|
|
|
| const API_BASE = "http://127.0.0.1:8000";
|
|
|
|
|
| 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);
|
|
|
|
|
| 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;
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
| 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>");
|
| }
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
| 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();
|
| }
|
| }
|
|
|
|
|
| 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;
|
| }
|
| });
|
|
|
|
|
| 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"); }
|
| });
|
|
|
|
|
| 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");
|
| });
|
|
|
|
|
| copyThread.addEventListener("click", () => {
|
| navigator.clipboard.writeText(threadId).then(() => {
|
| copyThread.textContent = "Copied!";
|
| setTimeout(() => copyThread.textContent = "Copy", 1200);
|
| });
|
| });
|
|
|
|
|
| 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);
|
|
|
|
|
| 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";
|
| }
|
| });
|
|
|
|
|
| mobileToggle.addEventListener("click", () => {
|
| sidebar.classList.toggle("open");
|
| sidebarOverlay.classList.toggle("show");
|
| });
|
|
|
| sidebarOverlay.addEventListener("click", () => {
|
| sidebar.classList.remove("open");
|
| sidebarOverlay.classList.remove("show");
|
| });
|
|
|
|
|
| updateRecent();
|
| if (messages.length > 0) renderMsgs();
|
| </script>
|
| </body>
|
| </html> |