Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"> | |
| </script> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>KAI — Your AI Assistant</title> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700&family=DM+Mono:wght@300;400;500&display=swap" | |
| rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #0d0d0f; | |
| --sidebar-bg: #111114; | |
| --surface: #18181c; | |
| --surface2: #1f1f24; | |
| --border: #2a2a30; | |
| --accent: #7c6aff; | |
| --accent2: #a78bfa; | |
| --text: #e8e8f0; | |
| --text-muted: #6b6b7a; | |
| --text-dim: #3a3a45; | |
| --user-bubble: #1e1b38; | |
| --ai-bubble: #18181c; | |
| --danger: #ff5f5f; | |
| --success: #4ade80; | |
| --green: #3fb950; | |
| --green-bg: rgba(63, 185, 80, 0.08); | |
| --red: #f85149; | |
| --red-bg: rgba(248, 81, 73, 0.08); | |
| --orange: #d2991d; | |
| --orange-bg: rgba(210, 153, 29, 0.08); | |
| --purple: #a371f7; | |
| --purple-bg: rgba(163, 113, 247, 0.08); | |
| --radius: 12px; | |
| --radius-sm: 8px; | |
| --transition: 0.2s ease; | |
| --font-mono: 'DM Mono', monospace; | |
| --font-sans: 'Syne', sans-serif; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: var(--font-sans); | |
| background: var(--bg); | |
| color: var(--text); | |
| height: 100vh; | |
| display: flex; | |
| overflow: hidden; | |
| } | |
| /* SIDEBAR */ | |
| .sidebar { | |
| width: 260px; | |
| min-width: 260px; | |
| background: var(--sidebar-bg); | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| padding: 16px 12px; | |
| gap: 8px; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| position: relative; | |
| z-index: 10; | |
| } | |
| .sidebar.collapsed { | |
| width: 0; | |
| min-width: 0; | |
| padding: 16px 0; | |
| overflow: hidden; | |
| border-right: none; | |
| } | |
| .sidebar-toggle { | |
| position: fixed; | |
| top: 16px; | |
| left: 248px; | |
| right: -12px; | |
| width: 28px; | |
| height: 29px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| z-index: 30; | |
| transition: all 0.3s ease; | |
| color: var(--text-muted); | |
| font-size: 12px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.3); | |
| } | |
| .sidebar-toggle:hover { | |
| background: var(--accent); | |
| color: white; | |
| border-color: var(--accent); | |
| } | |
| .sidebar.collapsed + .sidebar-toggle, | |
| .sidebar-toggle.collapsed { | |
| left: 10px; | |
| } | |
| .sidebar-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 8px 8px 16px; | |
| border-bottom: 1px solid var(--border); | |
| margin-bottom: 4px; | |
| min-width: 200px; | |
| } | |
| .logo { | |
| font-size: 18px; | |
| font-weight: 700; | |
| letter-spacing: 0.05em; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| white-space: nowrap; | |
| } | |
| /* Mode Switcher */ | |
| .mode-switcher { | |
| display: flex; | |
| gap: 4px; | |
| padding: 4px; | |
| background: var(--surface); | |
| border-radius: 10px; | |
| margin-bottom: 4px; | |
| min-width: 200px; | |
| } | |
| .mode-btn { | |
| flex: 1; | |
| padding: 8px 12px; | |
| border-radius: 8px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-muted); | |
| font-family: var(--font-sans); | |
| font-size: 12px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| white-space: nowrap; | |
| } | |
| .mode-btn.active { | |
| background: var(--surface2); | |
| color: var(--text); | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); | |
| } | |
| .mode-btn:hover:not(.active) { | |
| color: var(--text); | |
| background: rgba(255, 255, 255, 0.03); | |
| } | |
| .new-chat-btn { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 10px 12px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| color: var(--text); | |
| font-family: var(--font-sans); | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| width: 100%; | |
| text-align: left; | |
| min-width: 200px; | |
| } | |
| .new-chat-btn:hover { | |
| background: var(--surface2); | |
| border-color: var(--accent); | |
| color: var(--accent2); | |
| } | |
| .new-chat-btn .icon { | |
| width: 20px; | |
| height: 20px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| border-radius: 6px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 14px; | |
| flex-shrink: 0; | |
| } | |
| .conversations-label { | |
| font-size: 10px; | |
| font-weight: 600; | |
| letter-spacing: 0.1em; | |
| color: var(--text-muted); | |
| padding: 8px 8px 4px; | |
| text-transform: uppercase; | |
| min-width: 200px; | |
| white-space: nowrap; | |
| } | |
| .conversations-list { | |
| flex: 1; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| min-width: 200px; | |
| } | |
| .conversations-list::-webkit-scrollbar { | |
| width: 3px; | |
| } | |
| .conversations-list::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .conversations-list::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .conv-item { | |
| padding: 9px 12px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| color: var(--text-muted); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| transition: all 0.15s; | |
| border: 1px solid transparent; | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| } | |
| .conv-item:hover { | |
| background: var(--surface); | |
| color: var(--text); | |
| } | |
| .conv-item.active { | |
| background: var(--surface2); | |
| color: var(--text); | |
| border-color: var(--border); | |
| } | |
| .conv-item.active::before { | |
| content: ''; | |
| position: absolute; | |
| left: 0; | |
| top: 20%; | |
| height: 60%; | |
| width: 2px; | |
| background: var(--accent); | |
| border-radius: 0 2px 2px 0; | |
| } | |
| .conv-item .conv-title { | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .conv-delete-btn { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 4px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-dim); | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 14px; | |
| transition: all 0.15s; | |
| flex-shrink: 0; | |
| opacity: 0; | |
| line-height: 1; | |
| } | |
| .conv-item:hover .conv-delete-btn { | |
| opacity: 1; | |
| } | |
| .conv-delete-btn:hover { | |
| background: var(--red-bg); | |
| color: var(--red); | |
| } | |
| /* MAIN */ | |
| .main { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| position: relative; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .top-bar { | |
| padding: 16px 24px; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| background: var(--bg); | |
| min-height: 60px; | |
| } | |
| .top-bar .conv-title { | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: var(--text-muted); | |
| } | |
| .status-dot { | |
| width: 7px; | |
| height: 7px; | |
| background: var(--success); | |
| border-radius: 50%; | |
| box-shadow: 0 0 6px var(--success); | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, | |
| 100% { | |
| opacity: 1; | |
| } | |
| 50% { | |
| opacity: 0.4; | |
| } | |
| } | |
| /* CHAT MODE */ | |
| .chat-container { | |
| display: flex; | |
| flex-direction: column; | |
| flex: 1; | |
| overflow: hidden; | |
| } | |
| .messages-area { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 24px 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0; | |
| } | |
| .messages-area::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .messages-area::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .messages-area::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .welcome { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| flex: 1; | |
| gap: 16px; | |
| padding: 60px 24px; | |
| text-align: center; | |
| animation: fadeIn 0.5s ease; | |
| } | |
| .welcome-logo { | |
| font-size: 48px; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2), #c4b5fd); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| letter-spacing: -0.02em; | |
| } | |
| .welcome p { | |
| color: var(--text-muted); | |
| font-size: 15px; | |
| max-width: 340px; | |
| line-height: 1.6; | |
| } | |
| .message-row { | |
| display: flex; | |
| padding: 12px 24px; | |
| gap: 14px; | |
| animation: slideUp 0.25s ease; | |
| align-items: flex-start; | |
| justify-content: center; | |
| } | |
| @keyframes slideUp { | |
| from { | |
| opacity: 0; | |
| transform: translateY(8px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| } | |
| to { | |
| opacity: 1; | |
| } | |
| } | |
| .message-row:hover { | |
| background: rgba(255, 255, 255, 0.01); | |
| } | |
| .avatar { | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 8px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 13px; | |
| font-weight: 700; | |
| flex-shrink: 0; | |
| margin-top: 2px; | |
| } | |
| .avatar.user { | |
| background: linear-gradient(135deg, #3b2fa0, #6d5be0); | |
| color: white; | |
| } | |
| .avatar.ai { | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| color: white; | |
| } | |
| .message-content { | |
| flex: 1; | |
| max-width: 720px; | |
| } | |
| .message-sender { | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| margin-bottom: 6px; | |
| color: var(--text-muted); | |
| } | |
| .message-text { | |
| font-family: var(--font-mono); | |
| font-size: 14px; | |
| line-height: 1.7; | |
| color: var(--text); | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .message-text.user-text { | |
| font-family: var(--font-sans); | |
| font-size: 15px; | |
| } | |
| .message-text pre { | |
| background: #0d0d0f; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 12px 16px; | |
| overflow-x: auto; | |
| margin: 8px 0; | |
| } | |
| .message-text code { | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| color: var(--accent2); | |
| } | |
| .message-text pre code { | |
| color: var(--text); | |
| } | |
| .typing-indicator { | |
| display: flex; | |
| gap: 4px; | |
| align-items: center; | |
| padding: 4px 0; | |
| } | |
| .typing-indicator span { | |
| width: 6px; | |
| height: 6px; | |
| background: var(--accent); | |
| border-radius: 50%; | |
| animation: bounce 1.2s infinite; | |
| } | |
| .typing-indicator span:nth-child(2) { | |
| animation-delay: 0.2s; | |
| } | |
| .typing-indicator span:nth-child(3) { | |
| animation-delay: 0.4s; | |
| } | |
| @keyframes bounce { | |
| 0%, | |
| 60%, | |
| 100% { | |
| transform: translateY(0); | |
| opacity: 0.4; | |
| } | |
| 30% { | |
| transform: translateY(-5px); | |
| opacity: 1; | |
| } | |
| } | |
| /* CHAT INPUT */ | |
| .input-area { | |
| padding: 16px 24px 20px; | |
| background: var(--bg); | |
| border-top: 1px solid var(--border); | |
| } | |
| .input-wrapper { | |
| display: flex; | |
| align-items: flex-end; | |
| gap: 10px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| padding: 12px 14px; | |
| transition: border-color 0.2s; | |
| max-width: 860px; | |
| margin: 0 auto; | |
| } | |
| .input-wrapper:focus-within { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px rgba(124, 106, 255, 0.1); | |
| } | |
| #messageInput { | |
| flex: 1; | |
| background: transparent; | |
| border: none; | |
| outline: none; | |
| color: var(--text); | |
| font-family: var(--font-sans); | |
| font-size: 14px; | |
| resize: none; | |
| max-height: 160px; | |
| min-height: 24px; | |
| line-height: 1.5; | |
| } | |
| #messageInput::placeholder { | |
| color: var(--text-muted); | |
| } | |
| #sendBtn { | |
| width: 34px; | |
| height: 34px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| border: none; | |
| border-radius: 9px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| transition: all 0.2s; | |
| opacity: 0.5; | |
| } | |
| #sendBtn.active { | |
| opacity: 1; | |
| transform: scale(1.05); | |
| } | |
| #sendBtn:hover.active { | |
| transform: scale(1.1); | |
| } | |
| #sendBtn svg { | |
| width: 16px; | |
| height: 16px; | |
| fill: white; | |
| } | |
| .input-hint { | |
| text-align: center; | |
| font-size: 11px; | |
| color: var(--text-dim); | |
| margin-top: 10px; | |
| font-family: var(--font-mono); | |
| } | |
| .toggles { | |
| display: flex; | |
| gap: 8px; | |
| max-width: 860px; | |
| margin: 0 auto 10px; | |
| } | |
| .toggle-btn { | |
| padding: 6px 14px; | |
| border-radius: 20px; | |
| border: 1px solid var(--border); | |
| background: transparent; | |
| color: var(--text-muted); | |
| font-family: var(--font-sans); | |
| font-size: 12px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .toggle-btn.on { | |
| background: rgba(124, 106, 255, 0.15); | |
| border-color: var(--accent); | |
| color: var(--accent2); | |
| } | |
| /* CODE REVIEW MODE */ | |
| .review-container { | |
| display: none; | |
| flex: 1; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| padding: 24px; | |
| gap: 24px; | |
| } | |
| .review-container::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .review-container::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .review-container::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .review-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| transition: border-color var(--transition); | |
| } | |
| .review-card:focus-within { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px rgba(124, 106, 255, 0.1); | |
| } | |
| .card-label { | |
| display: block; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| color: var(--text-muted); | |
| margin-bottom: 8px; | |
| } | |
| .review-textarea { | |
| width: 100%; | |
| min-height: 200px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| color: var(--text); | |
| font-family: var(--font-mono); | |
| font-size: 0.9rem; | |
| padding: 16px; | |
| resize: vertical; | |
| outline: none; | |
| transition: border-color var(--transition); | |
| line-height: 1.55; | |
| tab-size: 4; | |
| } | |
| .review-textarea::placeholder { | |
| color: #484f58; | |
| } | |
| .review-textarea:focus { | |
| border-color: var(--accent); | |
| } | |
| .review-input-row { | |
| display: flex; | |
| gap: 12px; | |
| margin-top: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .review-question-input { | |
| flex: 1; | |
| min-width: 200px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| color: var(--text); | |
| font-family: var(--font-sans); | |
| font-size: 0.9rem; | |
| padding: 12px 16px; | |
| outline: none; | |
| transition: border-color var(--transition); | |
| } | |
| .review-question-input::placeholder { | |
| color: #484f58; | |
| } | |
| .review-question-input:focus { | |
| border-color: var(--accent); | |
| } | |
| .review-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 12px 28px; | |
| background: var(--accent); | |
| color: #fff; | |
| border: none; | |
| border-radius: var(--radius-sm); | |
| font-size: 0.95rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| letter-spacing: 0.01em; | |
| transition: all var(--transition); | |
| white-space: nowrap; | |
| font-family: var(--font-sans); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .review-btn:hover { | |
| background: var(--accent2); | |
| transform: translateY(-1px); | |
| box-shadow: 0 6px 20px rgba(124, 106, 255, 0.3); | |
| } | |
| .review-btn:active { | |
| transform: translateY(0); | |
| box-shadow: none; | |
| } | |
| .review-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| transform: none; | |
| box-shadow: none; | |
| pointer-events: none; | |
| } | |
| .review-btn .spinner { | |
| display: none; | |
| width: 18px; | |
| height: 18px; | |
| border: 2px solid rgba(255, 255, 255, 0.3); | |
| border-top-color: #fff; | |
| border-radius: 50%; | |
| animation: spin 0.7s linear infinite; | |
| } | |
| .review-btn.loading .spinner { | |
| display: block; | |
| } | |
| .review-btn.loading .btn-text { | |
| display: none; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| /* Review Output */ | |
| .review-output-area { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .output-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); | |
| animation: fadeSlideIn 0.35s ease forwards; | |
| opacity: 0; | |
| transform: translateY(12px); | |
| } | |
| @keyframes fadeSlideIn { | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .output-card:nth-child(1) { | |
| animation-delay: 0s; | |
| } | |
| .output-card:nth-child(2) { | |
| animation-delay: 0.06s; | |
| } | |
| .output-card:nth-child(3) { | |
| animation-delay: 0.12s; | |
| } | |
| .output-card:nth-child(4) { | |
| animation-delay: 0.18s; | |
| } | |
| .output-card:nth-child(5) { | |
| animation-delay: 0.24s; | |
| } | |
| .output-card-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 16px 20px; | |
| border-bottom: 1px solid var(--border); | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| letter-spacing: 0.01em; | |
| } | |
| .output-card-body { | |
| padding: 16px 20px; | |
| } | |
| .output-card-body pre { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| padding: 16px; | |
| overflow-x: auto; | |
| font-family: var(--font-mono); | |
| font-size: 0.85rem; | |
| line-height: 1.6; | |
| color: #d2a8ff; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| position: relative; | |
| } | |
| .output-card-body ul { | |
| list-style: none; | |
| padding: 0; | |
| } | |
| .output-card-body ul li { | |
| padding: 8px 12px; | |
| margin: 4px 0; | |
| background: var(--surface2); | |
| border-radius: 6px; | |
| font-size: 0.9rem; | |
| border-left: 3px solid var(--border); | |
| line-height: 1.5; | |
| } | |
| .output-card-body p { | |
| font-size: 0.9rem; | |
| color: var(--text-muted); | |
| line-height: 1.6; | |
| } | |
| /* Color accents per card type */ | |
| .card-bugs .output-card-header { | |
| color: var(--red); | |
| background: var(--red-bg); | |
| } | |
| .card-bugs ul li { | |
| border-left-color: var(--red); | |
| } | |
| .card-security .output-card-header { | |
| color: var(--orange); | |
| background: var(--orange-bg); | |
| } | |
| .card-security ul li { | |
| border-left-color: var(--orange); | |
| } | |
| .card-quality .output-card-header { | |
| color: var(--accent); | |
| background: rgba(124, 106, 255, 0.06); | |
| } | |
| .card-suggestions .output-card-header { | |
| color: var(--purple); | |
| background: var(--purple-bg); | |
| } | |
| .card-suggestions ul li { | |
| border-left-color: var(--purple); | |
| } | |
| .card-refactored .output-card-header { | |
| color: var(--green); | |
| background: var(--green-bg); | |
| } | |
| /* Quality Score */ | |
| .score-display { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .score-circle { | |
| position: relative; | |
| width: 80px; | |
| height: 80px; | |
| flex-shrink: 0; | |
| } | |
| .score-circle svg { | |
| transform: rotate(-90deg); | |
| } | |
| .score-circle .bg-ring { | |
| fill: none; | |
| stroke: var(--border); | |
| stroke-width: 8; | |
| } | |
| .score-circle .fg-ring { | |
| fill: none; | |
| stroke: var(--accent); | |
| stroke-width: 8; | |
| stroke-linecap: round; | |
| transition: stroke-dashoffset 0.8s ease; | |
| } | |
| .score-circle .score-text { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| color: var(--text); | |
| } | |
| .score-label { | |
| font-size: 0.85rem; | |
| color: var(--text-muted); | |
| } | |
| .score-high .fg-ring { | |
| stroke: var(--green); | |
| } | |
| .score-mid .fg-ring { | |
| stroke: var(--orange); | |
| } | |
| .score-low .fg-ring { | |
| stroke: var(--red); | |
| } | |
| /* Copy Button */ | |
| .copy-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 8px 16px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| transition: all var(--transition); | |
| font-family: var(--font-sans); | |
| margin-top: 10px; | |
| } | |
| .copy-btn:hover { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| background: rgba(124, 106, 255, 0.05); | |
| } | |
| .copy-btn.copied { | |
| border-color: var(--green); | |
| color: var(--green); | |
| background: var(--green-bg); | |
| } | |
| .copy-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| /* Error banner */ | |
| .error-banner { | |
| background: var(--red-bg); | |
| border: 1px solid rgba(248, 81, 73, 0.3); | |
| border-radius: var(--radius-sm); | |
| padding: 14px 18px; | |
| color: var(--red); | |
| font-size: 0.9rem; | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 10px; | |
| animation: fadeSlideIn 0.3s ease; | |
| } | |
| .error-banner svg { | |
| width: 20px; | |
| height: 20px; | |
| flex-shrink: 0; | |
| margin-top: 1px; | |
| } | |
| /* Empty state */ | |
| .empty-state { | |
| text-align: center; | |
| padding: 40px 20px; | |
| color: var(--text-muted); | |
| } | |
| .empty-state svg { | |
| width: 56px; | |
| height: 56px; | |
| opacity: 0.4; | |
| margin-bottom: 12px; | |
| } | |
| .empty-state p { | |
| font-size: 0.9rem; | |
| } | |
| .empty-conv { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| color: var(--text-dim); | |
| font-size: 12px; | |
| font-family: var(--font-mono); | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| /* Delete confirmation toast */ | |
| .toast { | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| color: var(--text); | |
| font-size: 14px; | |
| z-index: 1000; | |
| animation: slideUp 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); | |
| } | |
| .toast .toast-actions { | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .toast button { | |
| padding: 6px 14px; | |
| border-radius: 6px; | |
| border: 1px solid var(--border); | |
| background: var(--surface); | |
| color: var(--text); | |
| cursor: pointer; | |
| font-family: var(--font-sans); | |
| font-size: 12px; | |
| transition: all 0.2s; | |
| } | |
| .toast button.danger { | |
| background: var(--red); | |
| border-color: var(--red); | |
| color: white; | |
| } | |
| .toast button:hover { | |
| background: var(--surface2); | |
| border-color: var(--accent); | |
| } | |
| .toast button.danger:hover { | |
| background: #ff4444; | |
| } | |
| .scroll-bottom-btn { | |
| position: fixed; | |
| bottom: 150px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 40px; | |
| height: 40px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| color: var(--text-muted); | |
| font-size: 18px; | |
| z-index: 25; | |
| transition: all 0.2s; | |
| opacity: 0; | |
| pointer-events: none; | |
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| .scroll-bottom-btn.visible { | |
| opacity: 1; | |
| pointer-events: auto; | |
| } | |
| .scroll-bottom-btn:hover { | |
| background: var(--accent); | |
| color: white; | |
| border-color: var(--accent); | |
| } | |
| .mic-btn { | |
| width: 34px; | |
| height: 34px; | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| border-radius: 9px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| transition: all 0.2s; | |
| color: var(--text-muted); | |
| font-size: 16px; | |
| } | |
| .mic-btn.recording { | |
| background: var(--red); | |
| border-color: var(--red); | |
| color: white; | |
| animation: pulse 1.5s infinite; | |
| } | |
| .speak-btn { | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| cursor: pointer; | |
| padding: 4px 8px; | |
| color: var(--text-muted); | |
| font-size: 14px; | |
| margin-top: 6px; | |
| transition: all 0.2s; | |
| } | |
| .speak-btn:hover { | |
| background: var(--surface2); | |
| color: var(--accent); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <aside class="sidebar" id="sidebar"> | |
| <div class="sidebar-toggle" onclick="toggleSidebar()" title="Toggle Sidebar"> | |
| ◀ | |
| </div> | |
| <div class="sidebar-header"> | |
| <span class="logo">KAI</span> | |
| <div class="status-dot"></div> | |
| </div> | |
| <!-- Mode Switcher --> | |
| <div class="mode-switcher"> | |
| <button class="mode-btn active" id="chatModeBtn" onclick="switchMode('chat')"> | |
| 💬 Chat | |
| </button> | |
| <button class="mode-btn" id="reviewModeBtn" onclick="switchMode('review')"> | |
| 🔍 Code Review | |
| </button> | |
| </div> | |
| <button class="new-chat-btn" id="newChatBtn" onclick="startNewChat()"> | |
| <div class="icon">+</div> | |
| New conversation | |
| </button> | |
| <div class="conversations-label">Recent</div> | |
| <div class="conversations-list" id="convList"></div> | |
| </aside> | |
| <main class="main"> | |
| <div class="top-bar"> | |
| <div class="status-dot"></div> | |
| <span class="conv-title" id="convTitle">Start a new conversation</span> | |
| </div> | |
| <!-- CHAT MODE --> | |
| <div class="chat-container" id="chatContainer"> | |
| <div class="messages-area" id="messagesArea"> | |
| <div class="welcome" id="welcomeScreen"> | |
| <div class="welcome-logo">KAI</div> | |
| <p>Your personal AI assistant. Ask me anything — I remember our conversations.</p> | |
| </div> | |
| </div> | |
| <div class="input-area" id="chatInputArea"> | |
| <div class="toggles"> | |
| <button class="toggle-btn" id="webToggle" onclick="toggleFeature('web')"> | |
| 🔍 Web Search | |
| </button> | |
| <button class="toggle-btn" id="deepToggle" onclick="toggleFeature('deep')"> | |
| 🔬 Deep Research | |
| </button> | |
| </div> | |
| <div class="input-wrapper"> | |
| <textarea id="messageInput" placeholder="Message KAI..." rows="1" | |
| oninput="autoResize(this); toggleSend()" | |
| onkeydown="handleKey(event)"></textarea> | |
| <button class="mic-btn" id="micBtn" onclick="toggleRecording()" title="Voice input"> | |
| 🎤 | |
| </button> | |
| <button id="sendBtn" onclick="sendMessage()"> | |
| <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="input-hint">Enter to send · Shift+Enter for new line</div> | |
| </div> | |
| </div> | |
| <!-- CODE REVIEW MODE --> | |
| <div class="review-container" id="reviewContainer"> | |
| <div class="review-card" id="reviewInputCard"> | |
| <label class="card-label" for="codeInput">📋 Paste Your Code</label> | |
| <textarea class="review-textarea" id="codeInput" | |
| placeholder="Paste your code here... e.g. def calculate(a, b): return a + b"></textarea> | |
| <div class="review-input-row"> | |
| <input type="text" class="review-question-input" id="reviewQuestionInput" | |
| placeholder="💬 Optional: Ask a specific question about this code..." /> | |
| <button class="review-btn" id="reviewSubmitBtn" onclick="analyzeCode()"> | |
| <span class="btn-text">🔍 Analyze Code</span> | |
| <span class="spinner"></span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="review-output-area" id="reviewOutputArea"> | |
| <div class="empty-state" id="reviewEmptyState"> | |
| <svg viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <rect x="8" y="12" width="40" height="32" rx="4" stroke="currentColor" stroke-width="2.5" fill="none"/> | |
| <path d="M18 24h20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <path d="M18 30h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <circle cx="40" cy="28" r="8" stroke="currentColor" stroke-width="2" fill="none"/> | |
| <path d="M44 32l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| <p>Paste your code above and click <strong>Analyze Code</strong><br>to see the review results here.</p> | |
| </div> | |
| <div class="error-banner" id="reviewErrorBanner" style="display:none;"> | |
| <svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg> | |
| <span id="reviewErrorMessage"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <button class="scroll-bottom-btn" id="scrollBottomBtn" onclick="scrollToBottom()" title="Scroll to bottom"> | |
| ↓ | |
| </button> | |
| </main> | |
| <script> | |
| const API = 'https://kanhaiyaml-chat-bot.hf.space'; | |
| const REVIEW_API = 'https://kanhaiyaml-chat-bot.hf.space/review'; | |
| let currentConvId = null; | |
| let isTyping = false; | |
| let webSearchOn = false; | |
| let deepResearchOn = false; | |
| let currentMode = 'chat'; | |
| let sidebarCollapsed = false; | |
| let conversationToDelete = null; | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| let isRecording = false; | |
| async function toggleRecording() { | |
| if (isRecording) { | |
| await stopRecording(); | |
| } else { | |
| await startRecording(); | |
| } | |
| } | |
| async function startRecording() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| mediaRecorder = new MediaRecorder(stream); | |
| audioChunks = []; | |
| mediaRecorder.ondataavailable = (e) => { | |
| audioChunks.push(e.data); | |
| }; | |
| mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| await sendAudioToBackend(audioBlob); | |
| stream.getTracks().forEach(track => track.stop()); | |
| }; | |
| mediaRecorder.start(); | |
| isRecording = true; | |
| document.getElementById('micBtn').classList.add('recording'); | |
| document.getElementById('micBtn').innerHTML = '🔴'; | |
| } catch (err) { | |
| alert('Microphone access denied. Please allow microphone access.'); | |
| } | |
| } | |
| async function stopRecording() { | |
| if (mediaRecorder) { | |
| mediaRecorder.stop(); | |
| isRecording = false; | |
| document.getElementById('micBtn').classList.remove('recording'); | |
| document.getElementById('micBtn').innerHTML = '🎤'; | |
| } | |
| } | |
| async function sendAudioToBackend(audioBlob) { | |
| const formData = new FormData(); | |
| formData.append('audio', audioBlob, 'recording.webm'); | |
| console.log("Sending audio, size:", audioBlob.size); | |
| try { | |
| const res = await fetch(`${API}/transcribe`, { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| console.log("Response status:", res.status); | |
| if (!res.ok) { | |
| const errorText = await res.text(); | |
| console.error("Transcription error:", errorText); | |
| alert('Transcription failed: ' + errorText); | |
| return; | |
| } | |
| const data = await res.json(); | |
| console.log("Transcription result:", data); | |
| const transcribedText = data.text; | |
| if (!transcribedText || transcribedText.trim() === '') { | |
| alert('No text detected. Please try again.'); | |
| return; | |
| } | |
| document.getElementById('messageInput').value = transcribedText; | |
| toggleSend(); | |
| autoResize(document.getElementById('messageInput')); | |
| if (transcribedText.trim()) { | |
| sendMessage(); | |
| } | |
| } catch (err) { | |
| console.error('Network error:', err); | |
| alert('Failed to connect to server.'); | |
| } | |
| } | |
| // ── Sidebar Toggle ────────────────────────────────── | |
| function toggleSidebar() { | |
| sidebarCollapsed = !sidebarCollapsed; | |
| const sidebar = document.getElementById('sidebar'); | |
| const toggleBtn = sidebar.querySelector('.sidebar-toggle'); | |
| if (sidebarCollapsed) { | |
| sidebar.classList.add('collapsed'); | |
| toggleBtn.classList.add('collapsed'); | |
| toggleBtn.innerHTML = '▶'; | |
| } else { | |
| sidebar.classList.remove('collapsed'); | |
| toggleBtn.classList.remove('collapsed'); | |
| toggleBtn.innerHTML = '◀'; | |
| } | |
| } | |
| // ── Mode Switching ────────────────────────────────── | |
| function switchMode(mode) { | |
| currentMode = mode; | |
| const chatModeBtn = document.getElementById('chatModeBtn'); | |
| const reviewModeBtn = document.getElementById('reviewModeBtn'); | |
| const chatContainer = document.getElementById('chatContainer'); | |
| const reviewContainer = document.getElementById('reviewContainer'); | |
| const chatInputArea = document.getElementById('chatInputArea'); | |
| const newChatBtn = document.getElementById('newChatBtn'); | |
| if (mode === 'chat') { | |
| chatModeBtn.classList.add('active'); | |
| reviewModeBtn.classList.remove('active'); | |
| chatContainer.style.display = 'flex'; | |
| chatInputArea.style.display = 'block'; | |
| reviewContainer.style.display = 'none'; | |
| newChatBtn.style.display = 'flex'; | |
| document.getElementById('convTitle').textContent = currentConvId ? | |
| (document.querySelector(`.conv-item[data-id="${currentConvId}"]`)?.querySelector('.conv-title')?.textContent || 'New Chat') : | |
| 'Start a new conversation'; | |
| } else { | |
| reviewModeBtn.classList.add('active'); | |
| chatModeBtn.classList.remove('active'); | |
| chatContainer.style.display = 'none'; | |
| chatInputArea.style.display = 'none'; | |
| reviewContainer.style.display = 'flex'; | |
| newChatBtn.style.display = 'none'; | |
| document.getElementById('convTitle').textContent = 'Code Review'; | |
| } | |
| } | |
| function toggleFeature(type) { | |
| if (type === 'web') { | |
| webSearchOn = !webSearchOn; | |
| document.getElementById('webToggle').classList.toggle('on', webSearchOn); | |
| } else { | |
| deepResearchOn = !deepResearchOn; | |
| document.getElementById('deepToggle').classList.toggle('on', deepResearchOn); | |
| } | |
| } | |
| // ── Init ────────────────────────────────────────────── | |
| async function init() { | |
| await loadConversations(); | |
| switchMode('chat'); | |
| } | |
| // ── Load sidebar conversations ──────────────────────── | |
| async function loadConversations() { | |
| try { | |
| const res = await fetch(`${API}/conversations`); | |
| const convs = await res.json(); | |
| const list = document.getElementById('convList'); | |
| list.innerHTML = ''; | |
| if (convs.length === 0) { | |
| list.innerHTML = '<div class="empty-conv">No conversations yet</div>'; | |
| return; | |
| } | |
| convs.forEach(c => { | |
| const el = document.createElement('div'); | |
| el.className = 'conv-item' + (c.id === currentConvId ? ' active' : ''); | |
| el.dataset.id = c.id; | |
| const titleSpan = document.createElement('span'); | |
| titleSpan.className = 'conv-title'; | |
| titleSpan.textContent = c.title || 'New Chat'; | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'conv-delete-btn'; | |
| deleteBtn.title = 'Delete conversation'; | |
| deleteBtn.innerHTML = '×'; | |
| deleteBtn.onclick = (e) => { | |
| console.log("Delete button clicked"); | |
| e.stopPropagation(); | |
| confirmDeleteConversation(c.id, c.title || 'New Chat'); | |
| }; | |
| el.appendChild(titleSpan); | |
| el.appendChild(deleteBtn); | |
| el.onclick = () => { | |
| if (currentMode !== 'chat') switchMode('chat'); | |
| loadConversation(c.id, c.title); | |
| }; | |
| list.appendChild(el); | |
| }); | |
| } catch (e) { | |
| console.error('Failed to load conversations:', e); | |
| } | |
| } | |
| // ── Confirm and delete conversation ────────────────── | |
| function confirmDeleteConversation(id, title) { | |
| console.log("confirmDelete called with id:", id, "title:", title); | |
| conversationToDelete = { id, title }; | |
| // Remove existing toast if any | |
| const existingToast = document.querySelector('.toast'); | |
| if (existingToast) existingToast.remove(); | |
| const toast = document.createElement('div'); | |
| toast.className = 'toast'; | |
| toast.innerHTML = ` | |
| <span>Delete "${escapeHtml(title)}"?</span> | |
| <div class="toast-actions"> | |
| <button onclick="cancelDelete()">Cancel</button> | |
| <button class="danger" onclick="deleteConversation()">Delete</button> | |
| </div> | |
| `; | |
| document.body.appendChild(toast); | |
| // Auto-dismiss after 5 seconds | |
| setTimeout(() => { | |
| if (document.body.contains(toast)) { | |
| toast.remove(); | |
| conversationToDelete = null; | |
| } | |
| }, 5000); | |
| } | |
| function cancelDelete() { | |
| const toast = document.querySelector('.toast'); | |
| if (toast) toast.remove(); | |
| conversationToDelete = null; | |
| } | |
| async function deleteConversation() { | |
| if (!conversationToDelete) return; | |
| const { id } = conversationToDelete; | |
| const toast = document.querySelector('.toast'); | |
| if (toast) toast.remove(); | |
| try { | |
| const res = await fetch(`${API}/conversation/${id}`, { | |
| method: 'DELETE', | |
| }); | |
| if (!res.ok) { | |
| throw new Error('Failed to delete conversation'); | |
| } | |
| // If the deleted conversation was the current one, reset | |
| if (currentConvId === id) { | |
| currentConvId = null; | |
| document.getElementById('messagesArea').innerHTML = ` | |
| <div class="welcome" id="welcomeScreen"> | |
| <div class="welcome-logo">KAI</div> | |
| <p>Your personal AI assistant. Ask me anything — I remember our conversations.</p> | |
| </div> | |
| `; | |
| document.getElementById('convTitle').textContent = 'Start a new conversation'; | |
| } | |
| await loadConversations(); | |
| } catch (e) { | |
| console.error('Failed to delete conversation:', e); | |
| alert('Failed to delete conversation. Please try again.'); | |
| } | |
| conversationToDelete = null; | |
| } | |
| // ── Load a conversation's messages ─────────────────── | |
| async function loadConversation(id, title) { | |
| currentConvId = id; | |
| document.getElementById('convTitle').textContent = title || 'New Chat'; | |
| const area = document.getElementById('messagesArea'); | |
| area.innerHTML = ''; | |
| try { | |
| const res = await fetch(`${API}/conversation/${id}`); | |
| const messages = await res.json(); | |
| if (messages.length === 0) { | |
| area.innerHTML = '<div class="empty-conv">No messages yet — say something!</div>'; | |
| } else { | |
| messages.forEach(m => appendMessage(m.role, m.content)); | |
| } | |
| } catch (e) { | |
| console.error('Failed to load conversation:', e); | |
| } | |
| await loadConversations(); | |
| scrollToBottom(); | |
| } | |
| // ── Start new chat ──────────────────────────────────── | |
| async function startNewChat() { | |
| if (currentMode !== 'chat') switchMode('chat'); | |
| try { | |
| const res = await fetch(`${API}/conversation`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ title: 'New Chat' }) | |
| }); | |
| const conv = await res.json(); | |
| currentConvId = conv.id; | |
| document.getElementById('convTitle').textContent = 'New Chat'; | |
| document.getElementById('messagesArea').innerHTML = | |
| '<div class="empty-conv">New conversation started — say something!</div>'; | |
| await loadConversations(); | |
| } catch (e) { | |
| console.error('Failed to create conversation:', e); | |
| } | |
| } | |
| // ── Send message ────────────────────────────────────── | |
| async function sendMessage() { | |
| const input = document.getElementById('messageInput'); | |
| const text = input.value; | |
| if (!text || isTyping) return; | |
| if (!currentConvId) { | |
| await startNewChat(); | |
| } | |
| const welcome = document.getElementById('welcomeScreen'); | |
| if (welcome) welcome.remove(); | |
| const emptyConv = document.querySelector('.empty-conv'); | |
| if (emptyConv) emptyConv.remove(); | |
| appendMessage('user', text); | |
| input.value = ''; | |
| input.style.height = 'auto'; | |
| toggleSend(); | |
| showTyping(); | |
| isTyping = true; | |
| try { | |
| console.log("Fetching stream...", currentConvId); | |
| const res = await fetch(`${API}/chat/stream`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| conversation_id: currentConvId, | |
| message: text, | |
| web_search: webSearchOn, | |
| deep_research: deepResearchOn | |
| }) | |
| }); | |
| removeTyping(); | |
| // create empty assistant message to stream into | |
| const msgRow = createEmptyAssistantMessage(); | |
| const msgEl = msgRow.querySelector('.message-text'); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let fullText = ''; | |
| let streamDone = false; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value); | |
| console.log("CHUNK:", chunk); | |
| const lines = chunk.split('\n'); | |
| for (const line of lines) { | |
| if (line.toLowerCase().startsWith('data: ')) { | |
| const token = line.slice(6); | |
| if (token === '[DONE]') { | |
| streamDone = true; | |
| break; | |
| } | |
| fullText += token; | |
| console.log("adding token:", token, "fullText now:", fullText.length); | |
| msgEl.textContent = fullText; | |
| await new Promise(resolve => setTimeout(resolve, 0)); | |
| scrollToBottom(); | |
| } | |
| } | |
| if (streamDone) break; | |
| } | |
| msgEl.innerHTML = marked.parse(fullText); | |
| addSpeakButton(msgRow); | |
| speakText(fullText); | |
| msgEl.style.display = 'none'; | |
| msgEl.offsetHeight; | |
| msgEl.style.display = ''; | |
| scrollToBottom(); | |
| await loadConversations(); | |
| } catch (e) { | |
| removeTyping(); | |
| appendMessage('assistant', '⚠️ Something went wrong. Please try again.'); | |
| } | |
| isTyping = false; | |
| scrollToBottom(); | |
| await loadConversations(); | |
| } | |
| // ── Append message to UI ────────────────────────────── | |
| function appendMessage(role, content) { | |
| const area = document.getElementById('messagesArea'); | |
| const row = document.createElement('div'); | |
| row.className = 'message-row'; | |
| const isUser = role === 'user'; | |
| row.innerHTML = ` | |
| <div class="avatar ${isUser ? 'user' : 'ai'}">${isUser ? 'U' : 'K'}</div> | |
| <div class="message-content"> | |
| <div class="message-sender">${isUser ? 'You' : 'KAI'}</div> | |
| <div class="message-text ${isUser ? 'user-text' : ''}">${isUser ? escapeHtml(content) : marked.parse(content)}</div> | |
| </div> | |
| `; | |
| area.appendChild(row); | |
| if (!isUser) addSpeakButton(row); | |
| scrollToBottom(); | |
| } | |
| function createEmptyAssistantMessage() { | |
| const area = document.getElementById('messagesArea'); | |
| const row = document.createElement('div'); | |
| row.className = 'message-row'; | |
| row.innerHTML = ` | |
| <div class="avatar ai">K</div> | |
| <div class="message-content"> | |
| <div class="message-sender">KAI</div> | |
| <div class="message-text"></div> | |
| </div> | |
| `; | |
| area.appendChild(row); | |
| scrollToBottom(); | |
| return row; | |
| } | |
| // ── Typing indicator ────────────────────────────────── | |
| function showTyping() { | |
| const area = document.getElementById('messagesArea'); | |
| const row = document.createElement('div'); | |
| row.className = 'message-row'; | |
| row.id = 'typingRow'; | |
| row.innerHTML = ` | |
| <div class="avatar ai">K</div> | |
| <div class="message-content"> | |
| <div class="message-sender">KAI</div> | |
| <div class="typing-indicator"><span></span><span></span><span></span></div> | |
| </div> | |
| `; | |
| area.appendChild(row); | |
| scrollToBottom(); | |
| } | |
| function removeTyping() { | |
| const t = document.getElementById('typingRow'); | |
| if (t) t.remove(); | |
| } | |
| // ── Helpers ─────────────────────────────────────────── | |
| function escapeHtml(text) { | |
| return text | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| } | |
| function scrollToBottom() { | |
| const area = document.getElementById('messagesArea'); | |
| if (area) area.scrollTop = area.scrollHeight; | |
| } | |
| // Show/hide scroll-to-bottom button | |
| const messagesArea = document.getElementById('messagesArea'); | |
| messagesArea.addEventListener('scroll', function() { | |
| const btn = document.getElementById('scrollBottomBtn'); | |
| const isNearBottom = this.scrollHeight - this.scrollTop - this.clientHeight < 100; | |
| if (isNearBottom) { | |
| btn.classList.remove('visible'); | |
| } else { | |
| btn.classList.add('visible'); | |
| } | |
| }); | |
| function autoResize(el) { | |
| el.style.height = 'auto'; | |
| el.style.height = Math.min(el.scrollHeight, 160) + 'px'; | |
| } | |
| function toggleSend() { | |
| const val = document.getElementById('messageInput').value.trim(); | |
| const btn = document.getElementById('sendBtn'); | |
| btn.classList.toggle('active', val.length > 0); | |
| } | |
| function handleKey(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| } | |
| // function speakText(text) { | |
| // if ('speechSynthesis' in window) { | |
| // window.speechSynthesis.cancel(); | |
| // const utterance = new SpeechSynthesisUtterance(text); | |
| // utterance.lang = 'hi-IN' | |
| // utterance.rate = 1; | |
| // utterance.pitch = 1; | |
| // window.speechSynthesis.speak(utterance); | |
| // } | |
| // } | |
| function addSpeakButton(row) { | |
| const msgText = row.querySelector('.message-text'); | |
| const text = msgText.textContent; | |
| const speakBtn = document.createElement('button'); | |
| speakBtn.className = 'speak-btn'; | |
| speakBtn.innerHTML = '🔊'; | |
| speakBtn.title = 'Listen'; | |
| speakBtn.onclick = () => speakText(text); | |
| row.querySelector('.message-content').appendChild(speakBtn); | |
| } | |
| async function speakText(text) { | |
| try { | |
| const res = await fetch(`${API}/speak`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text: text }) | |
| }); | |
| const audioBlob = await res.blob(); | |
| // Check if blob is valid | |
| if (audioBlob.size < 100) { | |
| console.error("Audio too small, something wrong"); | |
| return; | |
| } | |
| const audioUrl = URL.createObjectURL(audioBlob); | |
| const audio = new Audio(audioUrl); | |
| // Must be triggered by user click - already is since speakText is called from onclick | |
| audio.load(); | |
| await audio.play(); | |
| } catch (err) { | |
| console.error('Failed to speak:', err); | |
| // Fallback to browser TTS | |
| if ('speechSynthesis' in window) { | |
| const utterance = new SpeechSynthesisUtterance(text); | |
| utterance.lang = 'hi-IN'; | |
| window.speechSynthesis.speak(utterance); | |
| } | |
| } | |
| } | |
| // ── CODE REVIEW LOGIC ───────────────────────────────── | |
| const codeInput = document.getElementById('codeInput'); | |
| const reviewQuestionInput = document.getElementById('reviewQuestionInput'); | |
| const reviewSubmitBtn = document.getElementById('reviewSubmitBtn'); | |
| const reviewOutputArea = document.getElementById('reviewOutputArea'); | |
| const reviewEmptyState = document.getElementById('reviewEmptyState'); | |
| const reviewErrorBanner = document.getElementById('reviewErrorBanner'); | |
| const reviewErrorMessage = document.getElementById('reviewErrorMessage'); | |
| codeInput.addEventListener('keydown', (e) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| analyzeCode(); | |
| } | |
| }); | |
| reviewQuestionInput.addEventListener('keydown', (e) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| analyzeCode(); | |
| } | |
| }); | |
| async function analyzeCode() { | |
| const code = codeInput.value.trim(); | |
| const question = reviewQuestionInput.value.trim(); | |
| if (!code) { | |
| showReviewError('Please paste some code before analyzing.'); | |
| codeInput.focus(); | |
| return; | |
| } | |
| clearReviewOutput(); | |
| hideReviewError(); | |
| setReviewLoading(true); | |
| reviewEmptyState.style.display = 'none'; | |
| try { | |
| const response = await fetch(REVIEW_API, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Accept': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| code: code, | |
| question: question || undefined, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text().catch(() => 'Unknown error'); | |
| throw new Error(`Server responded with ${response.status} ${response.statusText}. ${errorText}`); | |
| } | |
| const data = await response.json(); | |
| renderReviewOutput(data); | |
| } catch (err) { | |
| console.error('Analysis error:', err); | |
| if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) { | |
| showReviewError('⚠️ Could not connect to the backend at ' + REVIEW_API + | |
| '. Make sure your server is running and CORS is enabled.'); | |
| } else { | |
| showReviewError(err.message || 'An unexpected error occurred.'); | |
| } | |
| reviewEmptyState.style.display = 'none'; | |
| } finally { | |
| setReviewLoading(false); | |
| } | |
| } | |
| function renderReviewOutput(data) { | |
| const existingCards = reviewOutputArea.querySelectorAll('.output-card'); | |
| existingCards.forEach(card => card.remove()); | |
| const fragments = []; | |
| if (data.bugs !== undefined && data.bugs !== null) { | |
| const bugsList = Array.isArray(data.bugs) ? data.bugs : [data.bugs]; | |
| fragments.push(createReviewOutputCard({ | |
| type: 'bugs', | |
| title: '🐛 Bugs', | |
| bodyHTML: bugsList.length > 0 ? | |
| '<ul>' + bugsList.map(b => `<li>${escapeHtml(String(b))}</li>`).join('') + '</ul>' : | |
| '<p>✅ No bugs detected.</p>', | |
| })); | |
| } | |
| if (data.security_issues !== undefined && data.security_issues !== null) { | |
| const secList = Array.isArray(data.security_issues) ? data.security_issues : [data.security_issues]; | |
| fragments.push(createReviewOutputCard({ | |
| type: 'security', | |
| title: '🔒 Security Issues', | |
| bodyHTML: secList.length > 0 ? | |
| '<ul>' + secList.map(s => `<li>${escapeHtml(String(s))}</li>`).join('') + '</ul>' : | |
| '<p>✅ No security issues found.</p>', | |
| })); | |
| } | |
| if (data.quality_score !== undefined && data.quality_score !== null) { | |
| const score = parseFloat(data.quality_score); | |
| const scoreNum = isNaN(score) ? 0 : Math.max(0, Math.min(100, score)); | |
| const scoreClass = scoreNum >= 70 ? 'score-high' : scoreNum >= 40 ? 'score-mid' : 'score-low'; | |
| const circumference = 2 * Math.PI * 32; | |
| const offset = circumference - (scoreNum / 100) * circumference; | |
| fragments.push(createReviewOutputCard({ | |
| type: 'quality', | |
| title: '📊 Quality Score', | |
| bodyHTML: ` | |
| <div class="score-display"> | |
| <div class="score-circle ${scoreClass}"> | |
| <svg width="80" height="80" viewBox="0 0 80 80"> | |
| <circle class="bg-ring" cx="40" cy="40" r="32"/> | |
| <circle class="fg-ring" cx="40" cy="40" r="32" | |
| stroke-dasharray="${circumference}" | |
| stroke-dashoffset="${offset}"/> | |
| </svg> | |
| <span class="score-text">${Math.round(scoreNum)}</span> | |
| </div> | |
| <div> | |
| <span class="score-label">out of 100</span> | |
| <p style="margin-top:4px;">${scoreNum >= 70 ? 'Great quality!' : scoreNum >= 40 ? 'Needs some improvement.' : 'Significant issues found.'}</p> | |
| </div> | |
| </div>`, | |
| })); | |
| } | |
| if (data.suggestions !== undefined && data.suggestions !== null) { | |
| const sugList = Array.isArray(data.suggestions) ? data.suggestions : [data.suggestions]; | |
| fragments.push(createReviewOutputCard({ | |
| type: 'suggestions', | |
| title: '💡 Suggestions', | |
| bodyHTML: sugList.length > 0 ? | |
| '<ul>' + sugList.map(s => `<li>${escapeHtml(String(s))}</li>`).join('') + '</ul>' : | |
| '<p>No specific suggestions — code looks good!</p>', | |
| })); | |
| } | |
| if (data.refactored_code !== undefined && data.refactored_code !== null) { | |
| const refactoredCode = String(data.refactored_code); | |
| const cardEl = createReviewOutputCard({ | |
| type: 'refactored', | |
| title: '✨ Refactored Code', | |
| bodyHTML: ` | |
| <pre><code>${escapeHtml(refactoredCode)}</code></pre> | |
| <button class="copy-btn" onclick="copyRefactoredCode(this, '${escapeAttr(refactoredCode)}')"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> | |
| <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> | |
| </svg> | |
| Copy Code | |
| </button>`, | |
| }); | |
| fragments.push(cardEl); | |
| } | |
| if (fragments.length === 0) { | |
| fragments.push(createReviewOutputCard({ | |
| type: 'refactored', | |
| title: '📦 Raw Response', | |
| bodyHTML: `<pre><code>${escapeHtml(JSON.stringify(data, null, 2))}</code></pre>`, | |
| })); | |
| } | |
| fragments.forEach(frag => reviewOutputArea.appendChild(frag)); | |
| } | |
| function createReviewOutputCard({ type, title, bodyHTML }) { | |
| const card = document.createElement('div'); | |
| card.className = `output-card card-${type}`; | |
| card.innerHTML = ` | |
| <div class="output-card-header"> | |
| <span>${title}</span> | |
| </div> | |
| <div class="output-card-body"> | |
| ${bodyHTML} | |
| </div>`; | |
| return card; | |
| } | |
| function copyRefactoredCode(btn, codeText) { | |
| navigator.clipboard.writeText(codeText).then(() => { | |
| btn.classList.add('copied'); | |
| btn.innerHTML = ` | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <polyline points="20 6 9 17 4 12"/> | |
| </svg> | |
| Copied!`; | |
| setTimeout(() => { | |
| btn.classList.remove('copied'); | |
| btn.innerHTML = ` | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> | |
| <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> | |
| </svg> | |
| Copy Code`; | |
| }, 2000); | |
| }).catch(() => { | |
| const textarea = document.createElement('textarea'); | |
| textarea.value = codeText; | |
| textarea.style.position = 'fixed'; | |
| textarea.style.opacity = '0'; | |
| document.body.appendChild(textarea); | |
| textarea.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(textarea); | |
| btn.classList.add('copied'); | |
| btn.innerHTML = ` | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <polyline points="20 6 9 17 4 12"/> | |
| </svg> | |
| Copied!`; | |
| setTimeout(() => { | |
| btn.classList.remove('copied'); | |
| btn.innerHTML = ` | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> | |
| <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> | |
| </svg> | |
| Copy Code`; | |
| }, 2000); | |
| }); | |
| } | |
| function clearReviewOutput() { | |
| const existingCards = reviewOutputArea.querySelectorAll('.output-card'); | |
| existingCards.forEach(card => card.remove()); | |
| reviewEmptyState.style.display = 'none'; | |
| } | |
| function showReviewError(msg) { | |
| reviewErrorMessage.textContent = msg; | |
| reviewErrorBanner.style.display = 'flex'; | |
| } | |
| function hideReviewError() { | |
| reviewErrorBanner.style.display = 'none'; | |
| reviewErrorMessage.textContent = ''; | |
| } | |
| function setReviewLoading(isLoading) { | |
| if (isLoading) { | |
| reviewSubmitBtn.classList.add('loading'); | |
| reviewSubmitBtn.disabled = true; | |
| } else { | |
| reviewSubmitBtn.classList.remove('loading'); | |
| reviewSubmitBtn.disabled = false; | |
| } | |
| } | |
| function escapeAttr(str) { | |
| return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, | |
| '''); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> |