Spaces:
Running
Running
| <html lang="en" data-density="comfortable"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <meta name="theme-color" content="#08101a" /> | |
| <link rel="icon" type="image/png" href="/templates/logo.png" /> | |
| <title>{{ app_title }}</title> | |
| <style> | |
| /* ββ Design Tokens ββ */ | |
| :root { | |
| --bg: #08101a; | |
| --panel: rgba(15, 22, 34, .94); | |
| --panel2: rgba(20, 29, 44, .96); | |
| --border: rgba(148, 163, 184, .14); | |
| --border2: rgba(148, 163, 184, .2); | |
| --text: #eef3f9; | |
| --muted: #a4b3c7; | |
| --accent: #7ca6ff; | |
| --accent2: #4fd1c5; | |
| --good: #2dd4bf; | |
| --bad: #f87171; | |
| --warn: #fbbf24; | |
| --shadow: 0 18px 44px rgba(0,0,0,.34); | |
| --shadow-soft: 0 8px 26px rgba(0,0,0,.2); | |
| --radius-xs: 4px; | |
| --radius-sm: 8px; | |
| --radius-md: 12px; | |
| --radius-lg: 16px; | |
| --radius-xl: 22px; | |
| --radius-pill: 999px; | |
| --space-1: 4px; --space-2: 8px; --space-3: 12px; | |
| --space-4: 16px; --space-6: 24px; --space-8: 32px; | |
| --ease-out: cubic-bezier(.22,.61,.36,1); | |
| --ease-in-out: cubic-bezier(.4,0,.2,1); | |
| --ease-spring: cubic-bezier(.34,1.56,.64,1); | |
| --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| --font: "Segoe UI Variable Text", "Segoe UI", Aptos, system-ui, -apple-system, sans-serif; | |
| --bubble-padding: 10px 14px; | |
| --turn-gap: 6px; | |
| --font-size-base: 14px; | |
| } | |
| /* ββ Light Mode ββ */ | |
| @media (prefers-color-scheme: light) { | |
| :root { | |
| --bg: #f8f9fc; --panel: rgba(255,255,255,.96); | |
| --panel2: rgba(245,247,252,.98); | |
| --border: rgba(30,40,80,.1); --border2: rgba(30,40,80,.16); | |
| --text: #1a1d26; --muted: #5a6374; | |
| --accent: #3b6fd4; --accent2: #0d9488; | |
| --good: #0d9488; --bad: #dc2626; --warn: #d97706; | |
| --shadow: 0 18px 44px rgba(0,0,0,.1); | |
| --shadow-soft: 0 8px 26px rgba(0,0,0,.07); | |
| } | |
| html, body { background: radial-gradient(ellipse at top center, #e8edf8 0%, var(--bg) 50%); } | |
| body::before { opacity: .06; } | |
| #topbar { background: rgba(248,249,252,.88); } | |
| .compose { background: rgba(248,249,252,.92); } | |
| } | |
| /* ββ Density Variants ββ */ | |
| [data-density="compact"] { --bubble-padding: 6px 10px; --turn-gap: 3px; --font-size-base: 13px; } | |
| [data-density="spacious"] { --bubble-padding: 14px 18px; --turn-gap: 10px; --font-size-base: 15px; } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { animation-duration: 0.01ms ; transition-duration: 0.01ms ; } | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { | |
| width: 100%; height: var(--app-height, 100%); | |
| background: radial-gradient(ellipse at top center, #161c2b 0%, var(--bg) 50%); | |
| color: var(--text); font-family: var(--font); | |
| overflow: hidden; -webkit-font-smoothing: antialiased; overscroll-behavior: none; | |
| } | |
| body::before { | |
| content: ""; position: fixed; inset: 0; | |
| background-image: | |
| linear-gradient(rgba(124,166,255,.025) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(124,166,255,.025) 1px, transparent 1px); | |
| background-size: 56px 56px; pointer-events: none; opacity: .25; will-change: transform; | |
| } | |
| #app { | |
| position: relative; z-index: 1; height: var(--app-height, 100%); | |
| display: flex; flex-direction: column; min-height: 0; | |
| } | |
| .skip-link { | |
| position: absolute; top: -100px; left: var(--space-4); | |
| background: var(--panel); color: var(--text); | |
| padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); | |
| z-index: 300; font-size: 13px; font-weight: 600; | |
| border: 1px solid var(--border2); text-decoration: none; | |
| transition: top 150ms var(--ease-out); | |
| } | |
| .skip-link:focus { top: var(--space-2); } | |
| /* ββ Topbar ββ */ | |
| #topbar { | |
| height: 56px; padding: 0 var(--space-4); | |
| display: flex; align-items: center; justify-content: space-between; | |
| border-bottom: 1px solid var(--border); | |
| backdrop-filter: blur(20px) saturate(180%); | |
| background: rgba(11,14,20,.78); flex-shrink: 0; position: relative; z-index: 10; | |
| } | |
| @media (max-height: 500px) { #topbar { height: 42px; } } | |
| .brand { display: flex; align-items: center; gap: 10px; min-width: 0; } | |
| .logo { | |
| width: 30px; height: 30px; border-radius: 10px; display: block; object-fit: cover; | |
| box-shadow: 0 6px 18px rgba(108,131,255,.25); border: 1px solid rgba(255,255,255,.08); flex: 0 0 auto; | |
| } | |
| .brand-title { font-weight: 700; letter-spacing: -.03em; font-size: 15px; } | |
| .brand-sub { color: var(--muted); font-size: 11px; font-family: var(--mono); margin-left: 2px; } | |
| .top-actions { display: flex; align-items: center; gap: var(--space-1); position: relative; } | |
| .top-btn { | |
| border: 1px solid var(--border2); background: rgba(255,255,255,.03); color: var(--muted); | |
| border-radius: var(--radius-md); padding: 6px 12px; font: inherit; font-size: 12px; | |
| cursor: pointer; transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out), transform 180ms var(--ease-out); | |
| display: flex; align-items: center; gap: 5px; position: relative; | |
| } | |
| .top-btn:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.06); } | |
| .top-btn svg { width: 14px; height: 14px; } | |
| .top-btn[aria-label]::after { | |
| content: attr(aria-label); position: absolute; top: calc(100% + 6px); right: 0; | |
| background: var(--panel2); border: 1px solid var(--border2); border-radius: var(--radius-sm); | |
| padding: 4px 8px; font-size: 11px; color: var(--text); white-space: nowrap; | |
| opacity: 0; pointer-events: none; transition: opacity 150ms var(--ease-out); z-index: 50; | |
| } | |
| .top-btn[aria-label]:hover::after { opacity: 1; } | |
| /* ββ Status bar ββ */ | |
| #statusbar { | |
| height: 0; overflow: hidden; transition: height 220ms var(--ease-out), opacity 220ms var(--ease-out); | |
| opacity: 0; border-bottom: 1px solid transparent; background: rgba(11,14,20,.6); | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 12px; font-family: var(--mono); color: var(--muted); flex-shrink: 0; | |
| } | |
| #statusbar.visible { height: 32px; opacity: 1; border-bottom-color: var(--border); } | |
| #statusbar .status-dot { | |
| width: 6px; height: 6px; border-radius: 50%; margin-right: var(--space-2); | |
| display: inline-block; background: var(--accent); animation: pulse-dot 1.2s ease infinite; | |
| } | |
| @keyframes pulse-dot { 0%, 100% { opacity: .4; transform: scale(.85); } 50% { opacity: 1; transform: scale(1.1); } } | |
| #scrollProgress { | |
| position: fixed ; top: 0; left: 0; height: 3px; width: 0%; | |
| background: linear-gradient(90deg, var(--accent), var(--accent2)); | |
| transition: width 100ms ease; z-index: 9999; pointer-events: none; transform-origin: left center; | |
| } | |
| /* ββ Chat area ββ */ | |
| #chat { | |
| flex: 1; min-height: 0; overflow-y: auto; padding: 20px 14px 24px; | |
| scroll-behavior: auto; overscroll-behavior-y: contain; | |
| -webkit-overflow-scrolling: touch; overflow-anchor: auto; | |
| scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.1) transparent; | |
| } | |
| #chat::-webkit-scrollbar { width: 5px; } | |
| #chat::-webkit-scrollbar-track { background: transparent; } | |
| #chat::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: var(--radius-pill); } | |
| .wrap { max-width: 760px; margin: 0 auto; } | |
| /* ββ Welcome ββ */ | |
| .welcome { | |
| margin: 6vh auto 0; max-width: 480px; text-align: center; | |
| padding: var(--space-6) 20px; border: 1px solid var(--border); | |
| border-radius: var(--radius-xl); background: rgba(255,255,255,.025); | |
| box-shadow: var(--shadow); animation: fadeUp 400ms var(--ease-out) both; | |
| } | |
| @media (max-height: 500px) { .welcome { margin-top: 1vh; padding: var(--space-3); } } | |
| .welcome h1 { font-size: 22px; font-weight: 700; letter-spacing: -.03em; line-height: 1.3; } | |
| .welcome p { color: var(--muted); line-height: 1.6; margin-top: var(--space-2); font-size: 13px; } | |
| .welcome-suggestions { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: center; margin-top: var(--space-4); } | |
| .suggestion-chip { | |
| border: 1px solid var(--border2); background: rgba(255,255,255,.03); | |
| border-radius: var(--radius-pill); padding: 6px 14px; font: inherit; font-size: 12px; | |
| color: var(--muted); cursor: pointer; | |
| transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out); | |
| text-align: left; | |
| } | |
| .suggestion-chip:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.05); } | |
| @keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } | |
| /* ββ Skeleton ββ */ | |
| .skeleton-wrap { padding: 20px 0; } | |
| .skeleton { | |
| background: linear-gradient(90deg, rgba(255,255,255,.03) 0%, rgba(255,255,255,.07) 50%, rgba(255,255,255,.03) 100%); | |
| background-size: 200% 100%; animation: skeleton-shimmer 1.5s ease infinite; border-radius: var(--radius-md); | |
| } | |
| @keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } | |
| .skeleton-line { height: 14px; margin-bottom: 8px; } | |
| .skeleton-line.short { width: 40%; } .skeleton-line.medium { width: 70%; } .skeleton-line.long { width: 95%; } | |
| .skeleton-bubble { height: 80px; border-radius: var(--radius-lg); margin-bottom: 12px; } | |
| /* ββ Turns ββ */ | |
| .turn { display: flex; gap: 10px; margin-bottom: var(--turn-gap); align-items: flex-start; } | |
| .turn.new-turn { animation: fadeUp 280ms var(--ease-out) both; } | |
| .turn.user { justify-content: flex-end; } | |
| .avatar { | |
| width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; | |
| font-size: 12px; flex: 0 0 auto; transition: transform 200ms var(--ease-spring); | |
| } | |
| .avatar:hover { transform: scale(1.1); } | |
| .avatar.user { background: linear-gradient(135deg, #1f2b63, #2d1d58); border: 1px solid rgba(108,131,255,.2); } | |
| .avatar.assistant { background: linear-gradient(135deg, #163d34, #183c54); border: 1px solid rgba(45,212,191,.2); } | |
| .bubble { | |
| max-width: min(620px, calc(100vw - 100px)); border: 1px solid var(--border); | |
| border-radius: var(--radius-lg); padding: var(--bubble-padding); | |
| line-height: 1.6; font-size: var(--font-size-base); | |
| white-space: pre-wrap; word-break: break-word; background: rgba(255,255,255,.03); | |
| } | |
| .turn.assistant .bubble { border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg); } | |
| .turn.user .bubble { | |
| background: linear-gradient(135deg, rgba(108,131,255,.15), rgba(161,110,255,.12)); | |
| border-color: rgba(108,131,255,.2); | |
| border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg); | |
| } | |
| .turn-meta { | |
| margin-top: var(--space-1); font-size: 10px; color: var(--muted); | |
| font-family: var(--mono); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; | |
| } | |
| .chip { | |
| border: 1px solid var(--border); border-radius: var(--radius-pill); | |
| padding: 2px 7px; font-size: 11px; letter-spacing: .03em; | |
| display: inline-flex; align-items: center; gap: 3px; | |
| } | |
| .chip.good { color: var(--good); border-color: rgba(45,212,191,.25); } | |
| .chip.muted { color: var(--muted); } | |
| .chip.warn { color: var(--warn); border-color: rgba(251,191,36,.25); } | |
| .chip.matched { color: var(--accent); border-color: rgba(108,131,255,.25); } | |
| /* ββ Best answer ββ */ | |
| .best-answer-bubble { | |
| border: 1px solid rgba(45,212,191,.15); | |
| border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg); | |
| padding: var(--bubble-padding); background: rgba(45,212,191,.04); | |
| line-height: 1.6; font-size: var(--font-size-base); | |
| white-space: pre-wrap; word-break: break-word; outline: none; | |
| } | |
| .best-answer-meta { margin-top: var(--space-1); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } | |
| /* ββ Shared Markdown Elements ββ */ | |
| .bubble p, .best-answer-bubble p, .other-answer-text p { margin: 3px 0; white-space: normal; } | |
| .bubble h1, .bubble h2, .bubble h3, .bubble h4, .bubble h5, .bubble h6, | |
| .best-answer-bubble h1, .best-answer-bubble h2, .best-answer-bubble h3, | |
| .best-answer-bubble h4, .best-answer-bubble h5, .best-answer-bubble h6, | |
| .other-answer-text h1, .other-answer-text h2, .other-answer-text h3 { | |
| line-height: 1.3; margin: 8px 0 3px; white-space: normal; | |
| } | |
| .bubble h1, .best-answer-bubble h1 { font-size: 1.35em; font-weight: 800; } | |
| .bubble h2, .best-answer-bubble h2 { font-size: 1.2em; font-weight: 700; } | |
| .bubble h3, .best-answer-bubble h3 { font-size: 1.05em; font-weight: 700; color: var(--accent); } | |
| .bubble h4, .best-answer-bubble h4 { font-size: .95em; font-weight: 700; } | |
| .bubble h5, .best-answer-bubble h5, .bubble h6, .best-answer-bubble h6 { font-size: .88em; font-weight: 700; } | |
| .bubble ul, .bubble ol, .best-answer-bubble ul, .best-answer-bubble ol, | |
| .other-answer-text ul, .other-answer-text ol { margin: 3px 0 3px 18px; padding: 0; white-space: normal; } | |
| .bubble li, .best-answer-bubble li, .other-answer-text li { margin: 1px 0; white-space: normal; } | |
| .task-item { list-style: none; margin-left: -18px; } | |
| .task-item input[type="checkbox"] { accent-color: var(--accent2); margin-right: 6px; pointer-events: none; cursor: default; } | |
| .bubble code, .best-answer-bubble code, .other-answer-text code { | |
| font-family: var(--mono); font-size: .87em; | |
| background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); | |
| border-radius: var(--radius-xs); padding: 1px 5px; | |
| } | |
| .code-block-wrapper { position: relative; margin: 6px 0; } | |
| .bubble pre, .best-answer-bubble pre, .other-answer-text pre { | |
| background: rgba(0,0,0,.38); border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); padding: 10px 12px; | |
| overflow-x: auto; white-space: pre; font-family: var(--mono); | |
| font-size: .84em; line-height: 1.5; margin: 0; | |
| } | |
| .bubble pre code, .best-answer-bubble pre code, .other-answer-text pre code { | |
| background: none; border: none; padding: 0; font-size: inherit; | |
| } | |
| .code-lang-label { | |
| position: absolute; top: 5px; left: 6px; | |
| background: rgba(255,255,255,.06); | |
| border-radius: 0 0 var(--radius-xs) var(--radius-xs); | |
| padding: 2px 8px; font-size: 10px; color: var(--muted); | |
| font-family: var(--mono); text-transform: uppercase; letter-spacing: .05em; pointer-events: none; | |
| } | |
| .copy-code-btn { | |
| position: absolute; top: 6px; right: 6px; | |
| border: 1px solid var(--border2); background: rgba(0,0,0,.3); color: var(--muted); | |
| border-radius: var(--radius-xs); padding: 2px 7px; | |
| font: inherit; font-size: 10px; font-family: var(--mono); cursor: pointer; | |
| opacity: 0; transition: opacity 150ms var(--ease-out), color 150ms var(--ease-out), border-color 150ms var(--ease-out); | |
| } | |
| .code-block-wrapper:hover .copy-code-btn { opacity: 1; } | |
| .copy-code-btn:hover { color: var(--text); border-color: rgba(108,131,255,.4); } | |
| .copy-code-btn.copied { color: var(--good); border-color: rgba(45,212,191,.4); opacity: 1; } | |
| .bubble blockquote, .best-answer-bubble blockquote, .other-answer-text blockquote { | |
| border-left: 3px solid var(--accent); background: rgba(124,166,255,.04); | |
| border-radius: 0 var(--radius-sm) var(--radius-sm) 0; | |
| margin: 6px 0; padding: 6px 12px; color: var(--muted); white-space: normal; font-style: italic; | |
| } | |
| .bubble hr, .best-answer-bubble hr { border: none; border-top: 1px solid var(--border2); margin: 8px 0; } | |
| .bubble a, .best-answer-bubble a, .other-answer-text a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } | |
| .bubble a[href^="http"]::after, .best-answer-bubble a[href^="http"]::after { content: " β"; font-size: .75em; opacity: .5; } | |
| .bubble table, .best-answer-bubble table, .other-answer-text table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; white-space: normal; } | |
| .bubble th, .bubble td, .best-answer-bubble th, .best-answer-bubble td, | |
| .other-answer-text th, .other-answer-text td { border: 1px solid var(--border2); padding: 6px 10px; text-align: left; } | |
| .bubble th, .best-answer-bubble th { background: rgba(255,255,255,.05); font-weight: 600; } | |
| sup { font-size: .75em; vertical-align: super; line-height: 0; } | |
| sub { font-size: .75em; vertical-align: sub; line-height: 0; } | |
| .md-img { | |
| display: block; max-width: 100%; min-width: 60px; max-height: 480px; | |
| width: auto; height: auto; border-radius: var(--radius-md); border: 1px solid var(--border); | |
| margin: 6px 0; object-fit: contain; cursor: zoom-in; transition: opacity 150ms ease; | |
| } | |
| .md-img:hover { opacity: .9; } | |
| p.md-gap { min-height: 0.35em; margin: 0 ; padding: 0; } | |
| .quality-dots { display: inline-flex; gap: 2px; align-items: center; margin-left: 4px; } | |
| .quality-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--border2); } | |
| .quality-dot.filled { background: var(--accent2); } | |
| /* ββ Thinking Block ββ */ | |
| .thinking-dropdown { margin: 6px 0; border-radius: var(--radius-md); overflow: hidden; } | |
| .thinking-summary { | |
| display: flex; align-items: center; gap: 8px; padding: 8px 12px; | |
| background: rgba(255,255,255,.04); border: 1px solid var(--border); | |
| border-radius: var(--radius-md); cursor: pointer; user-select: none; | |
| font-size: 12px; font-family: var(--mono); color: var(--muted); | |
| transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out); | |
| list-style: none; | |
| } | |
| .thinking-summary:hover { background: rgba(255,255,255,.06); border-color: var(--border2); } | |
| .thinking-summary::-webkit-details-marker { display: none; } | |
| .thinking-summary .thinking-arrow { | |
| display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px; | |
| } | |
| details.thinking-dropdown[open] .thinking-arrow { transform: rotate(90deg); } | |
| .thinking-summary .thinking-spinner { | |
| width: 12px; height: 12px; border: 2px solid var(--border2); | |
| border-top-color: var(--accent); border-radius: 50%; | |
| animation: spin-thinking .8s linear infinite; | |
| } | |
| @keyframes spin-thinking { to { transform: rotate(360deg); } } | |
| .thinking-content { | |
| padding: 10px 14px; font-size: 13px; line-height: 1.6; | |
| color: var(--muted); border: 1px solid var(--border); border-top: none; | |
| border-radius: 0 0 var(--radius-md) var(--radius-md); | |
| background: rgba(255,255,255,.02); white-space: pre-wrap; word-break: break-word; | |
| max-height: 400px; overflow-y: auto; | |
| } | |
| .thinking-content p { color: var(--muted); } | |
| /* ββ Vote ββ */ | |
| .vote-row { display: flex; gap: var(--space-1); align-items: center; margin-top: 6px; flex-wrap: wrap; } | |
| .vote-btn { | |
| border: 1px solid var(--border2); background: rgba(255,255,255,.02); color: var(--muted); | |
| border-radius: var(--radius-sm); padding: 4px 9px; font: inherit; font-size: 11px; | |
| cursor: pointer; display: inline-flex; align-items: center; gap: 3px; | |
| position: relative; overflow: hidden; | |
| transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-spring); | |
| } | |
| .vote-btn:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.07); } | |
| .vote-btn.voted-up { border-color: rgba(45,212,191,.5); color: var(--good); background: rgba(45,212,191,.08); } | |
| .vote-btn.voted-down { border-color: rgba(248,113,113,.5); color: var(--bad); background: rgba(248,113,113,.08); } | |
| .vote-btn:active { transform: scale(.92); } | |
| .vote-count { display: inline-block; overflow: hidden; height: 1.2em; position: relative; } | |
| .vote-count-inner { display: block; transition: transform 300ms var(--ease-spring); } | |
| .action-btn { | |
| border: 1px solid var(--border2); background: rgba(255,255,255,.02); color: var(--muted); | |
| border-radius: var(--radius-sm); padding: 4px 9px; font: inherit; font-size: 11px; | |
| cursor: pointer; display: inline-flex; align-items: center; gap: 4px; | |
| transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out); | |
| } | |
| .action-btn:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.05); } | |
| /* ββ Write answer ββ */ | |
| .write-answer-btn { | |
| border: 1px solid rgba(45,212,191,.3); background: rgba(45,212,191,.08); color: var(--good); | |
| border-radius: var(--radius-md); padding: var(--space-2) 14px; | |
| font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; | |
| display: inline-flex; align-items: center; gap: 6px; margin-top: var(--space-2); | |
| transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out), transform 180ms var(--ease-spring); | |
| } | |
| .write-answer-btn:hover { background: rgba(45,212,191,.14); border-color: rgba(45,212,191,.5); } | |
| .write-answer-btn svg { width: 14px; height: 14px; } | |
| .write-panel { | |
| max-height: 0; overflow: hidden; opacity: 0; margin-top: 0; | |
| transition: max-height 300ms var(--ease-out), opacity 250ms var(--ease-out), margin 200ms var(--ease-out); | |
| } | |
| .write-panel.open { max-height: 400px; opacity: 1; margin-top: 10px; } | |
| .write-tabs { display: flex; gap: 2px; margin-bottom: 6px; } | |
| .write-tab { | |
| border: 1px solid var(--border); border-bottom: none; background: transparent; color: var(--muted); | |
| border-radius: var(--radius-sm) var(--radius-sm) 0 0; padding: 4px 12px; | |
| font: inherit; font-size: 11px; cursor: pointer; transition: color 150ms, background 150ms, border-color 150ms; | |
| } | |
| .write-tab.active { border-color: var(--accent); color: var(--accent); background: rgba(108,131,255,.08); } | |
| .write-textarea { | |
| width: 100%; min-height: 80px; max-height: 160px; resize: vertical; | |
| border: 1px solid var(--border2); border-radius: 0 var(--radius-md) var(--radius-md) var(--radius-md); | |
| background: var(--panel); color: var(--text); font: inherit; font-size: 13px; line-height: 1.55; | |
| padding: 10px 12px; outline: none; | |
| transition: border-color 200ms var(--ease-out), height 100ms var(--ease-out); | |
| } | |
| .write-textarea:focus { border-color: rgba(45,212,191,.4); box-shadow: 0 0 0 3px rgba(45,212,191,.07); } | |
| .write-textarea::placeholder { color: #5a6178; } | |
| .write-preview { | |
| min-height: 80px; max-height: 160px; overflow-y: auto; | |
| border: 1px solid var(--border2); border-radius: 0 var(--radius-md) var(--radius-md) var(--radius-md); | |
| background: var(--panel); padding: 10px 12px; font-size: 13px; line-height: 1.6; display: none; | |
| } | |
| .write-preview.active { display: block; } | |
| .char-count { font-size: 10px; font-family: var(--mono); color: var(--muted); text-align: right; margin-top: 2px; } | |
| .char-count.near-limit { color: var(--warn); } | |
| .char-count.over-limit { color: var(--bad); } | |
| .write-actions { display: flex; gap: 6px; margin-top: 6px; align-items: center; } | |
| .write-submit { | |
| border: 1px solid rgba(45,212,191,.4); background: rgba(45,212,191,.12); color: var(--good); | |
| border-radius: var(--radius-sm); padding: 6px 14px; font: inherit; font-size: 12px; font-weight: 600; | |
| cursor: pointer; transition: border-color 160ms, color 160ms, background 160ms; | |
| } | |
| .write-submit:hover { background: rgba(45,212,191,.2); border-color: rgba(45,212,191,.6); } | |
| .write-submit:disabled { opacity: .4; cursor: not-allowed; } | |
| .write-cancel { | |
| border: 1px solid var(--border); background: transparent; color: var(--muted); | |
| border-radius: var(--radius-sm); padding: 6px 12px; font: inherit; font-size: 12px; | |
| cursor: pointer; transition: border-color 160ms, color 160ms; | |
| } | |
| .write-cancel:hover { border-color: var(--border2); color: var(--text); } | |
| /* ββ Other answers ββ */ | |
| .other-answers-toggle { | |
| margin-top: var(--space-2); border: 1px solid var(--border); background: rgba(255,255,255,.02); | |
| color: var(--muted); border-radius: var(--radius-md); padding: 6px 12px; | |
| font: inherit; font-size: 11px; cursor: pointer; | |
| display: inline-flex; align-items: center; gap: 5px; | |
| transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out); | |
| } | |
| .other-answers-toggle:hover { border-color: rgba(108,131,255,.3); color: var(--text); } | |
| .other-answers-toggle .arrow, .related-toggle .arrow { | |
| display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px; | |
| } | |
| .other-answers-toggle.open .arrow, .related-toggle.open .arrow { transform: rotate(90deg); } | |
| .other-answers-panel, .related-panel, .versions-panel { | |
| max-height: 0; overflow: hidden; opacity: 0; | |
| transition: max-height 300ms var(--ease-out), opacity 200ms var(--ease-out); | |
| } | |
| .other-answers-panel { margin-top: var(--space-1); } | |
| .other-answers-panel.open { max-height: 3000px; opacity: 1; } | |
| .related-panel.open { max-height: 3000px; opacity: 1; margin-top: 6px; } | |
| .versions-panel { | |
| border-left: 2px solid var(--border2); padding-left: 10px; margin-top: var(--space-1); | |
| transition: max-height 280ms var(--ease-out), opacity 180ms var(--ease-out); | |
| } | |
| .versions-panel.open { max-height: 1500px; opacity: 1; } | |
| .other-answer-card { | |
| border: 1px solid var(--border); border-radius: var(--radius-md); | |
| padding: 10px 12px; margin-top: 6px; background: rgba(255,255,255,.02); | |
| animation: fadeUp 200ms var(--ease-out) both; position: relative; | |
| } | |
| .other-answer-card.related { | |
| background: linear-gradient(180deg, rgba(108,131,255,.05), rgba(255,255,255,.02)); | |
| border-color: rgba(108,131,255,.16); | |
| } | |
| .other-answer-head { | |
| display: flex; gap: 6px; flex-wrap: wrap; align-items: center; | |
| color: var(--muted); font-family: var(--mono); font-size: 10px; margin-bottom: 6px; | |
| } | |
| .other-answer-text { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; } | |
| /* ββ Preview lines ββ */ | |
| .preview-block { display: flex; gap: 8px; align-items: flex-start; margin-top: 6px; } | |
| .preview-label { | |
| flex: 0 0 auto; font-family: var(--mono); font-size: 9px; color: var(--muted); | |
| background: rgba(255,255,255,.04); border: 1px solid var(--border); | |
| border-radius: var(--radius-xs); padding: 2px 6px; line-height: 1.3; letter-spacing: .06em; | |
| } | |
| .preview-text { | |
| flex: 1; min-width: 0; font-size: 12.5px; line-height: 1.5; color: var(--text); | |
| display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; | |
| -webkit-box-orient: vertical; overflow: hidden; white-space: normal; word-break: break-word; | |
| } | |
| .preview-text.muted-preview { color: var(--muted); } | |
| .preview-actions { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: wrap; } | |
| .preview-actions .vote-row { margin-top: 0; } | |
| .ask-btn { | |
| border: 1px solid rgba(108,131,255,.35); background: rgba(108,131,255,.1); color: var(--accent); | |
| border-radius: var(--radius-sm); padding: 4px 11px; font: inherit; font-size: 11px; font-weight: 600; | |
| cursor: pointer; display: inline-flex; align-items: center; gap: 4px; | |
| transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-spring); | |
| } | |
| .ask-btn:hover { background: rgba(108,131,255,.2); border-color: rgba(108,131,255,.55); } | |
| .ask-btn:active { transform: scale(.95); } | |
| .ask-btn svg { width: 11px; height: 11px; } | |
| /* ββ Versions ββ */ | |
| .versions-toggle { | |
| margin-top: var(--space-1); color: var(--muted); font-size: 10px; | |
| cursor: pointer; font-family: var(--mono); | |
| display: inline-flex; align-items: center; gap: 4px; | |
| border: none; background: none; padding: 0; transition: color 150ms var(--ease-out); | |
| } | |
| .versions-toggle:hover { color: var(--text); } | |
| .version-card { | |
| border: 1px solid var(--border); background: rgba(255,255,255,.02); | |
| border-radius: var(--radius-md); padding: 8px 10px; margin-top: var(--space-1); | |
| animation: fadeUp 180ms var(--ease-out) both; | |
| } | |
| .version-head { | |
| font-size: 10px; color: var(--muted); font-family: var(--mono); | |
| display: flex; gap: 5px; flex-wrap: wrap; align-items: center; margin-bottom: var(--space-1); | |
| } | |
| /* ββ Propose version ββ */ | |
| .propose-panel { | |
| max-height: 0; overflow: hidden; opacity: 0; margin-top: 6px; | |
| transition: max-height 280ms var(--ease-out), opacity 200ms var(--ease-out); | |
| } | |
| .propose-panel.open { max-height: 400px; opacity: 1; } | |
| .propose-textarea { | |
| width: 100%; min-height: 60px; max-height: 140px; resize: vertical; | |
| border: 1px solid var(--border2); border-radius: var(--radius-md); | |
| background: var(--panel); color: var(--text); font: inherit; font-size: 13px; line-height: 1.55; | |
| padding: 8px 10px; outline: none; | |
| transition: border-color 200ms var(--ease-out), height 100ms var(--ease-out); | |
| } | |
| .propose-textarea:focus { border-color: rgba(108,131,255,.4); box-shadow: 0 0 0 3px rgba(108,131,255,.07); } | |
| .propose-textarea::placeholder { color: #5a6178; } | |
| .propose-actions { display: flex; gap: 6px; margin-top: 6px; } | |
| .propose-submit { | |
| border: 1px solid rgba(108,131,255,.3); background: rgba(108,131,255,.1); color: var(--accent); | |
| border-radius: var(--radius-sm); padding: 5px 12px; font: inherit; font-size: 11px; | |
| cursor: pointer; transition: border-color 160ms, color 160ms, background 160ms; | |
| } | |
| .propose-submit:hover { background: rgba(108,131,255,.18); border-color: rgba(108,131,255,.5); } | |
| .propose-submit:disabled { opacity: .5; cursor: not-allowed; } | |
| .propose-cancel { | |
| border: 1px solid var(--border); background: transparent; color: var(--muted); | |
| border-radius: var(--radius-sm); padding: 5px 10px; font: inherit; font-size: 11px; cursor: pointer; | |
| } | |
| /* ββ Typing indicator ββ */ | |
| .typing-indicator { | |
| display: flex; gap: 10px; margin-bottom: 6px; align-items: flex-start; | |
| animation: fadeUp 250ms var(--ease-out) both; | |
| } | |
| .typing-dots { | |
| display: flex; gap: 4px; align-items: center; padding: 12px 16px; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg); | |
| background: rgba(255,255,255,.03); | |
| } | |
| .typing-dots span { | |
| width: 6px; height: 6px; border-radius: 50%; background: var(--muted); | |
| animation: typingBounce 1.1s ease infinite; | |
| } | |
| .typing-dots span:nth-child(2) { animation-delay: .15s; } | |
| .typing-dots span:nth-child(3) { animation-delay: .3s; } | |
| @keyframes typingBounce { 0%, 60%, 100% { transform: translateY(0); opacity: .35; } 30% { transform: translateY(-5px); opacity: 1; } } | |
| .answer-reveal { | |
| opacity: 0; transform: translateY(4px); filter: blur(1px); | |
| transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out), filter 180ms var(--ease-out); | |
| } | |
| .answer-reveal.revealed { opacity: 1; transform: translateY(0); filter: blur(0); } | |
| @keyframes glow-in { 0% { box-shadow: 0 0 0 rgba(45,212,191,0); } 40% { box-shadow: 0 0 18px rgba(45,212,191,.3); } 100% { box-shadow: none; } } | |
| .answer-new-glow { animation: glow-in 700ms var(--ease-out) both; } | |
| /* ββ Related ββ */ | |
| .related-stack { margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,.06); } | |
| .related-stack .chip { margin-bottom: 6px; } | |
| .related-toggle { | |
| margin-top: 2px; border: 1px solid var(--border); background: rgba(255,255,255,.02); | |
| color: var(--muted); border-radius: var(--radius-md); padding: 6px 12px; | |
| font: inherit; font-size: 11px; cursor: pointer; | |
| display: inline-flex; align-items: center; gap: 5px; | |
| transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out); | |
| } | |
| .related-toggle:hover { border-color: rgba(108,131,255,.3); color: var(--text); } | |
| .related-panel { margin-top: 0; transition: max-height 280ms var(--ease-out), opacity 180ms var(--ease-out), margin-top 180ms var(--ease-out); } | |
| .related-score { color: var(--accent); border-color: rgba(108,131,255,.22); } | |
| .related-note { color: var(--muted); font-size: 11px; line-height: 1.5; margin-top: 6px; } | |
| /* ββ Composer ββ */ | |
| .compose { | |
| border-top: 1px solid var(--border); background: rgba(11,14,20,.85); | |
| backdrop-filter: blur(16px) saturate(160%); | |
| padding: 10px 14px calc(14px + env(safe-area-inset-bottom)); flex-shrink: 0; | |
| } | |
| @media (max-height: 500px) { .compose { padding: 6px 10px; } } | |
| .compose-inner { | |
| max-width: 760px; margin: 0 auto; | |
| border: 1px solid var(--border2); border-radius: var(--radius-lg); | |
| padding: 8px 10px 6px; background: var(--panel); box-shadow: var(--shadow-soft); | |
| transition: border-color 200ms var(--ease-out), box-shadow 200ms var(--ease-out); position: relative; | |
| } | |
| .compose-inner:focus-within { | |
| border-color: rgba(108,131,255,.4); | |
| box-shadow: 0 0 0 3px rgba(108,131,255,.12), var(--shadow-soft); | |
| } | |
| .autocomplete-dropdown { | |
| position: absolute; bottom: calc(100% + 6px); left: 0; right: 0; | |
| background: var(--panel2); border: 1px solid var(--border2); | |
| border-radius: var(--radius-md); z-index: 20; overflow: hidden; | |
| box-shadow: var(--shadow); display: none; | |
| } | |
| .autocomplete-dropdown.open { display: block; } | |
| .autocomplete-item { | |
| padding: 8px 12px; cursor: pointer; | |
| display: flex; justify-content: space-between; align-items: center; gap: 8px; | |
| transition: background 120ms; border-bottom: 1px solid var(--border); | |
| } | |
| .autocomplete-item:last-child { border-bottom: none; } | |
| .autocomplete-item:hover { background: rgba(108,131,255,.07); } | |
| .autocomplete-match { font-size: 13px; color: var(--text); } | |
| .autocomplete-meta { font-size: 10px; color: var(--muted); font-family: var(--mono); white-space: nowrap; } | |
| #prompt { | |
| width: 100%; min-height: 40px; max-height: 180px; resize: none; border: none; outline: none; | |
| background: transparent; color: var(--text); font: inherit; font-size: var(--font-size-base); | |
| line-height: 1.55; padding: 2px 2px 4px; transition: height 100ms var(--ease-out); | |
| } | |
| #prompt::placeholder { color: #5a6178; } | |
| .compose-row { | |
| display: flex; align-items: center; justify-content: space-between; | |
| gap: var(--space-2); border-top: 1px solid var(--border); padding-top: 6px; | |
| } | |
| .hint { color: var(--muted); font-size: 10px; font-family: var(--mono); } | |
| .send-btn { | |
| border: none; border-radius: 10px; padding: 7px 16px; cursor: pointer; | |
| font: inherit; font-size: 13px; font-weight: 600; color: white; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| box-shadow: 0 4px 14px rgba(108,131,255,.2); | |
| transition: transform 140ms var(--ease-spring), box-shadow 140ms var(--ease-out), filter 140ms var(--ease-out); | |
| } | |
| .send-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(108,131,255,.3); } | |
| .send-btn:active { transform: scale(.96); } | |
| .send-btn:disabled { opacity: .4; cursor: not-allowed; transform: none; box-shadow: none; } | |
| /* ββ Settings panel ββ */ | |
| .settings-backdrop { | |
| position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 99; | |
| opacity: 0; pointer-events: none; transition: opacity 250ms var(--ease-in-out); | |
| } | |
| .settings-backdrop.visible { opacity: 1; pointer-events: auto; } | |
| #settingsPanel { | |
| position: fixed; top: 56px; right: 0; width: 280px; | |
| background: var(--panel); border: 1px solid var(--border2); | |
| border-radius: 0 0 0 var(--radius-lg); box-shadow: var(--shadow); z-index: 100; | |
| transform: translateX(100%); transition: transform 250ms var(--ease-in-out); | |
| padding: 14px 16px; backdrop-filter: blur(24px) saturate(200%); | |
| } | |
| #settingsPanel.open { transform: translateX(0); } | |
| .settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } | |
| .settings-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } | |
| .settings-close { | |
| border: none; background: none; color: var(--muted); cursor: pointer; | |
| font-size: 16px; line-height: 1; padding: 2px 4px; border-radius: var(--radius-xs); transition: color 150ms; | |
| } | |
| .settings-close:hover { color: var(--text); } | |
| .setting-row { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 6px 0; border-bottom: 1px solid var(--border); | |
| } | |
| .setting-row:last-child { border-bottom: none; } | |
| .setting-label { font-size: 12px; color: var(--text); } | |
| .setting-desc { font-size: 10px; color: var(--muted); margin-top: 1px; } | |
| .anim-segment { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; } | |
| .anim-option { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 6px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border); | |
| cursor: pointer; font-size: 12px; color: var(--muted); | |
| transition: border-color 150ms, background 150ms; | |
| } | |
| .anim-option.active { border-color: rgba(108,131,255,.4); background: rgba(108,131,255,.08); color: var(--accent); } | |
| .anim-preview { width: 24px; height: 8px; border-radius: 4px; background: var(--border2); overflow: hidden; position: relative; } | |
| .anim-option.active .anim-preview::after { | |
| content: ""; position: absolute; inset: 0; | |
| background: linear-gradient(90deg, var(--accent), var(--accent2)); | |
| animation: anim-preview-slide 1s ease infinite alternate; | |
| } | |
| @keyframes anim-preview-slide { from { transform: translateX(-100%); } to { transform: translateX(0); } } | |
| .density-segment { display: flex; gap: 4px; margin-top: 6px; width: 100%; } | |
| .density-option { | |
| flex: 1; padding: 6px; text-align: center; border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); cursor: pointer; font-size: 11px; color: var(--muted); | |
| transition: border-color 150ms, background 150ms, color 150ms; | |
| } | |
| .density-option.active { border-color: rgba(108,131,255,.4); background: rgba(108,131,255,.08); color: var(--accent); } | |
| /* ββ Toast ββ */ | |
| #toast { | |
| position: fixed; left: 50%; bottom: 80px; | |
| transform: translateX(-50%) translateY(12px); opacity: 0; pointer-events: none; | |
| transition: opacity 200ms var(--ease-in-out), transform 200ms var(--ease-in-out); z-index: 50; | |
| background: rgba(17,21,29,.95); border: 1px solid var(--border2); border-radius: var(--radius-md); | |
| padding: 8px 14px; color: var(--text); font-family: var(--mono); font-size: 11px; | |
| box-shadow: var(--shadow); white-space: nowrap; backdrop-filter: blur(10px); | |
| display: flex; align-items: center; gap: 8px; | |
| } | |
| #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; } | |
| #toast.good { border-color: rgba(45,212,191,.4); color: var(--good); } | |
| #toast.bad { border-color: rgba(248,113,113,.4); color: var(--bad); } | |
| .toast-retry { | |
| border: 1px solid currentColor; border-radius: var(--radius-xs); | |
| background: transparent; color: inherit; padding: 2px 8px; | |
| font: inherit; font-size: 10px; cursor: pointer; white-space: nowrap; | |
| } | |
| .no-answer-bubble { border-style: dashed ; color: var(--muted); } | |
| /* ββ Jump to latest ββ */ | |
| #jumpLatest { | |
| position: fixed; left: 50%; bottom: 132px; | |
| transform: translateX(-50%) translateY(8px); z-index: 55; | |
| border: 1px solid rgba(124,166,255,.3); background: rgba(14,20,31,.94); | |
| color: var(--text); border-radius: var(--radius-pill); | |
| padding: 8px 14px; font: inherit; font-size: 12px; | |
| box-shadow: var(--shadow-soft); backdrop-filter: blur(10px); | |
| opacity: 0; pointer-events: none; cursor: pointer; | |
| transition: opacity 160ms var(--ease-out), transform 160ms var(--ease-out); | |
| } | |
| #jumpLatest.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); } | |
| /* ββ Lightbox ββ */ | |
| #lightbox { | |
| position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 400; | |
| display: none; place-items: center; cursor: zoom-out; animation: fadeIn 150ms ease; | |
| } | |
| #lightbox.open { display: grid; } | |
| #lightbox img { max-width: 95vw; max-height: 95vh; border-radius: var(--radius-sm); box-shadow: 0 20px 60px rgba(0,0,0,.5); cursor: default; } | |
| #lightboxClose { | |
| position: absolute; top: 16px; right: 16px; | |
| border: 1px solid rgba(255,255,255,.2); background: rgba(0,0,0,.5); color: white; | |
| border-radius: var(--radius-pill); width: 36px; height: 36px; | |
| display: grid; place-items: center; cursor: pointer; font-size: 18px; line-height: 1; | |
| } | |
| @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } | |
| /* ββ Command palette ββ */ | |
| #cmdBackdrop { | |
| position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 500; | |
| display: none; place-items: flex-start center; padding-top: 18vh; | |
| } | |
| #cmdBackdrop.open { display: grid; } | |
| #cmdPalette { | |
| width: min(480px, 90vw); background: var(--panel2); border: 1px solid var(--border2); | |
| border-radius: var(--radius-xl); box-shadow: var(--shadow); overflow: hidden; | |
| animation: fadeUp 150ms var(--ease-out) both; | |
| } | |
| #cmdInput { | |
| width: 100%; border: none; border-bottom: 1px solid var(--border); | |
| background: transparent; color: var(--text); font: inherit; font-size: 14px; | |
| padding: 14px 16px; outline: none; | |
| } | |
| #cmdInput::placeholder { color: var(--muted); } | |
| .cmd-list { max-height: 280px; overflow-y: auto; padding: 4px 0; } | |
| .cmd-item { | |
| padding: 10px 16px; cursor: pointer; display: flex; align-items: center; gap: 10px; | |
| font-size: 13px; color: var(--text); transition: background 100ms; | |
| } | |
| .cmd-item:hover, .cmd-item.focused { background: rgba(108,131,255,.1); } | |
| .cmd-item-icon { color: var(--muted); font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; } | |
| .cmd-item-label { flex: 1; } | |
| .cmd-item-shortcut { font-family: var(--mono); font-size: 10px; color: var(--muted); } | |
| .cmd-empty { padding: 16px; text-align: center; color: var(--muted); font-size: 13px; } | |
| /* ββ Confirm modal ββ */ | |
| #confirmBackdrop { | |
| position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 600; | |
| display: none; place-items: center; | |
| } | |
| #confirmBackdrop.open { display: grid; } | |
| #confirmModal { | |
| width: min(360px, 90vw); background: var(--panel2); border: 1px solid var(--border2); | |
| border-radius: var(--radius-xl); padding: 24px; box-shadow: var(--shadow); | |
| animation: fadeUp 150ms var(--ease-out) both; | |
| } | |
| .confirm-title { font-size: 15px; font-weight: 700; margin-bottom: 8px; } | |
| .confirm-msg { font-size: 13px; color: var(--muted); line-height: 1.5; margin-bottom: 20px; } | |
| .confirm-actions { display: flex; gap: 8px; justify-content: flex-end; } | |
| .confirm-ok { | |
| border: none; border-radius: var(--radius-sm); padding: 7px 18px; | |
| font: inherit; font-size: 13px; font-weight: 600; | |
| background: var(--bad); color: white; cursor: pointer; transition: filter 150ms; | |
| } | |
| .confirm-ok:hover { filter: brightness(1.1); } | |
| .confirm-cancel { | |
| border: 1px solid var(--border2); background: transparent; color: var(--muted); | |
| border-radius: var(--radius-sm); padding: 7px 14px; font: inherit; font-size: 13px; cursor: pointer; | |
| } | |
| .question-note { | |
| font-size: 11px; color: var(--muted); font-family: var(--mono); | |
| margin-top: 4px; display: flex; align-items: center; gap: 4px; | |
| } | |
| /* ββ Focus styles ββ */ | |
| #jumpLatest:focus-visible, .top-btn:focus-visible, .vote-btn:focus-visible, | |
| .action-btn:focus-visible, .ask-btn:focus-visible, .write-answer-btn:focus-visible, | |
| .other-answers-toggle:focus-visible, .related-toggle:focus-visible, .send-btn:focus-visible, | |
| .write-submit:focus-visible, .write-cancel:focus-visible, .propose-submit:focus-visible, | |
| .propose-cancel:focus-visible, .anim-option:focus-visible, .density-option:focus-visible, | |
| .suggestion-chip:focus-visible, .cmd-item:focus-visible, .confirm-ok:focus-visible, | |
| .confirm-cancel:focus-visible, .copy-code-btn:focus-visible { | |
| outline: 2px solid rgba(124,166,255,.7); outline-offset: 2px; | |
| } | |
| /* ββ Responsive ββ */ | |
| @media (max-width: 600px) { | |
| #topbar { padding: 0 10px; } | |
| .brand-sub { display: none; } | |
| .bubble { max-width: calc(100vw - 80px); } | |
| .welcome { margin-top: 3vh; padding: 18px 14px; } | |
| .welcome h1 { font-size: 18px; } | |
| #settingsPanel { width: 100%; border-radius: 0 0 var(--radius-lg) var(--radius-lg); } | |
| #jumpLatest { bottom: 120px; max-width: calc(100vw - 24px); white-space: nowrap; } | |
| } | |
| @media (pointer: coarse) { | |
| .vote-btn, .action-btn, .ask-btn, .versions-toggle, .other-answers-toggle, | |
| .related-toggle, .write-answer-btn { min-height: 44px; padding: 8px 14px; } | |
| .vote-btn { min-height: 44px; } | |
| .top-btn { min-height: 40px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <a href="#prompt" class="skip-link">Skip to chat input</a> | |
| <div id="app"> | |
| <header id="topbar" role="banner"> | |
| <div class="brand"> | |
| <img class="logo" src="/templates/logo.png" alt="Human Intelligence logo" /> | |
| <div> | |
| <div class="brand-title">Human Intelligence</div> | |
| <div class="brand-sub">community answers</div> | |
| </div> | |
| </div> | |
| <nav class="top-actions" aria-label="Chat actions"> | |
| <button class="top-btn" id="newChatBtn" aria-label="Start a new chat"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> | |
| New chat | |
| </button> | |
| <button class="top-btn" id="settingsBtn" aria-label="Open appearance settings" aria-expanded="false" aria-controls="settingsPanel"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> | |
| </button> | |
| </nav> | |
| </header> | |
| <div id="statusbar" role="status" aria-live="assertive"> | |
| <span class="status-dot" aria-hidden="true"></span> | |
| <span id="statusText">Thinkingβ¦</span> | |
| </div> | |
| <button id="jumpLatest" type="button" aria-label="Jump to latest content">New content below Β· Jump β</button> | |
| <div class="settings-backdrop" id="settingsBackdrop" aria-hidden="true"></div> | |
| <div id="settingsPanel" role="dialog" aria-label="Appearance settings" aria-modal="false"> | |
| <div class="settings-header"> | |
| <div class="settings-title">Appearance</div> | |
| <button class="settings-close" id="settingsClose" aria-label="Close settings">Γ</button> | |
| </div> | |
| <div class="setting-row"> | |
| <div> | |
| <div class="setting-label">Response animation</div> | |
| <div class="setting-desc">How answers are revealed</div> | |
| </div> | |
| </div> | |
| <div class="anim-segment" id="animSegment" role="radiogroup" aria-label="Animation style"> | |
| <div class="anim-option" data-anim="none" role="radio" aria-checked="true" tabindex="0"><span>Instant</span><div class="anim-preview"></div></div> | |
| <div class="anim-option" data-anim="ai" role="radio" aria-checked="false" tabindex="0"><span>Quick fade</span><div class="anim-preview"></div></div> | |
| <div class="anim-option" data-anim="human" role="radio" aria-checked="false" tabindex="0"><span>Gentle fade</span><div class="anim-preview"></div></div> | |
| <div class="anim-option" data-anim="diffusion" role="radio" aria-checked="false" tabindex="0"><span>Soft reveal</span><div class="anim-preview"></div></div> | |
| <div class="anim-option" data-anim="diffusion-v2" role="radio" aria-checked="false" tabindex="0"><span>Slow reveal</span><div class="anim-preview"></div></div> | |
| </div> | |
| <div class="setting-row" style="margin-top:12px;"> | |
| <div> | |
| <div class="setting-label">Content density</div> | |
| <div class="setting-desc">Amount of spacing in chat</div> | |
| </div> | |
| </div> | |
| <div class="density-segment" id="densitySegment" role="radiogroup" aria-label="Content density"> | |
| <div class="density-option" data-density="compact" role="radio" aria-checked="false" tabindex="0">Compact</div> | |
| <div class="density-option active" data-density="comfortable" role="radio" aria-checked="true" tabindex="0">Default</div> | |
| <div class="density-option" data-density="spacious" role="radio" aria-checked="false" tabindex="0">Spacious</div> | |
| </div> | |
| </div> | |
| <main id="chat" role="main"> | |
| <div id="scrollProgress" aria-hidden="true"></div> | |
| <div class="wrap"> | |
| <div class="welcome" id="welcome"> | |
| <h1 id="welcomeTitle">Ask a question. Get answers from real people.</h1> | |
| <p>Type a question below. If a matching answer exists, it appears instantly. Otherwise, anyone can write the first answer.</p> | |
| <p>Please do not share personal or sensitive information.</p> | |
| <div class="welcome-suggestions" id="welcomeSuggestions" aria-label="Example questions"> | |
| <button class="suggestion-chip" data-q="How does the internet work?">π‘ How does the internet work?</button> | |
| <button class="suggestion-chip" data-q="What is the best way to learn programming?">π₯ Best way to learn programming?</button> | |
| <button class="suggestion-chip" data-q="How do I improve my sleep?">π How do I improve my sleep?</button> | |
| </div> | |
| </div> | |
| <div id="transcript" aria-live="polite" aria-atomic="false"></div> | |
| </div> | |
| </main> | |
| <div class="compose"> | |
| <form id="composeForm" class="compose-inner" onsubmit="return false;" autocomplete="off"> | |
| <div class="autocomplete-dropdown" id="autocompleteDropdown" role="listbox" aria-label="Similar questions"></div> | |
| <textarea id="prompt" rows="1" placeholder="Ask a questionβ¦" aria-label="Your question" autocomplete="off" spellcheck="true"></textarea> | |
| <div class="compose-row"> | |
| <div class="hint" id="hint" aria-live="polite">Enter to ask Β· Shift+Enter newline Β· Ctrl+K commands</div> | |
| <button class="send-btn" id="sendBtn" type="submit">Ask</button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <div id="toast" role="alert" aria-live="assertive"></div> | |
| <div id="lightbox" aria-modal="true" aria-label="Image viewer" role="dialog"> | |
| <button id="lightboxClose" aria-label="Close image viewer">Γ</button> | |
| <img id="lightboxImg" src="" alt="" /> | |
| </div> | |
| <div id="cmdBackdrop" role="dialog" aria-modal="true" aria-label="Command palette"> | |
| <div id="cmdPalette"> | |
| <input id="cmdInput" type="text" placeholder="Type a command or searchβ¦" aria-label="Command search" autocomplete="off" /> | |
| <div class="cmd-list" id="cmdList" role="listbox"></div> | |
| </div> | |
| </div> | |
| <div id="confirmBackdrop" role="dialog" aria-modal="true" aria-labelledby="confirmTitle"> | |
| <div id="confirmModal"> | |
| <div class="confirm-title" id="confirmTitle">Are you sure?</div> | |
| <div class="confirm-msg" id="confirmMsg"></div> | |
| <div class="confirm-actions"> | |
| <button class="confirm-cancel" id="confirmCancel">Cancel</button> | |
| <button class="confirm-ok" id="confirmOk">Confirm</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script></script> | |
| <script> | |
| (() => { | |
| 'use strict'; | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| STATE | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| const S = { | |
| clientId: null, conversation: null, currentQuestion: '', | |
| relatedAnswers: [], loading: false, atBottom: true, | |
| animMode: localStorage.getItem('hi_anim') || 'none', | |
| density: localStorage.getItem('hi_density') || 'comfortable', | |
| lastAction: null, originalTitle: document.title, | |
| }; | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| UTILITIES | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| const $ = id => document.getElementById(id); | |
| const qs = (sel, ctx = document) => ctx.querySelector(sel); | |
| const qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel)); | |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | |
| function updateAppHeight() { | |
| const h = window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight; | |
| document.documentElement.style.setProperty('--app-height', `${Math.round(h)}px`); | |
| } | |
| function getClientId() { | |
| let id = localStorage.getItem('hi_client_id'); | |
| if (!id) { | |
| id = (crypto.randomUUID?.() || Date.now().toString(36) + Math.random().toString(36).slice(2)) | |
| .replace(/-/g, '').slice(0, 16); | |
| localStorage.setItem('hi_client_id', id); | |
| } | |
| return id; | |
| } | |
| function debounce(fn, ms) { | |
| let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; | |
| } | |
| function debounceClick(fn, ms = 350) { | |
| let blocked = false; | |
| return async (...a) => { | |
| if (blocked) return; | |
| blocked = true; | |
| try { await fn(...a); } finally { setTimeout(() => { blocked = false; }, ms); } | |
| }; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| TOAST / STATUS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function toast(msg, kind = '', retryFn = null) { | |
| const t = $('toast'); | |
| t.innerHTML = ''; | |
| const span = document.createElement('span'); | |
| span.textContent = msg; | |
| t.appendChild(span); | |
| if (retryFn) { | |
| const btn = document.createElement('button'); | |
| btn.className = 'toast-retry'; btn.textContent = 'Retry'; | |
| btn.onclick = () => { hideToast(); retryFn(); }; | |
| t.appendChild(btn); | |
| } | |
| t.className = 'show' + (kind ? ' ' + kind : ''); | |
| clearTimeout(t._t); | |
| t._t = setTimeout(hideToast, retryFn ? 6000 : 2500); | |
| } | |
| function hideToast() { $('toast').className = ''; } | |
| let statusTimers = []; | |
| function showStatus(text) { $('statusText').textContent = text; $('statusbar').classList.add('visible'); } | |
| function hideStatus() { statusTimers.forEach(clearTimeout); statusTimers = []; $('statusbar').classList.remove('visible'); } | |
| function showStatusWithEscalation() { | |
| showStatus('Searching for answersβ¦'); | |
| statusTimers.push(setTimeout(() => showStatus('Still searchingβ¦'), 8000)); | |
| statusTimers.push(setTimeout(() => showStatus('Taking longer than usualβ¦'), 20000)); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| ESCAPE / TEXT | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function esc(s) { | |
| return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| function nl2br(s) { return esc(s).replace(/\n/g, '<br>'); } | |
| function previewText(s) { | |
| return String(s||'').replace(/```[\s\S]*?```/g,' [code] ') | |
| .replace(/!\[[^\]]*\]\([^)]*\)/g,' [image] ').replace(/\[([^\]]+)\]\([^)]*\)/g,'$1') | |
| .replace(/^[#>\-*+]\s+/gm,'').replace(/[*_~`]+/g,'').replace(/\s+/g,' ').trim(); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| INLINE MARKDOWN | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function renderInlineMarkdown(s) { | |
| const tokens = []; | |
| let raw = String(s||'').replace( | |
| /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, | |
| (m, imgAlt, imgSrc, linkText, linkHref) => { | |
| const idx = tokens.length; | |
| tokens.push(imgSrc !== undefined | |
| ? `<img class="md-img" src="${imgSrc}" alt="${esc(imgAlt)}" loading="lazy">` | |
| : `<a href="${linkHref}" target="_blank" rel="noopener noreferrer">${esc(linkText)}</a>`); | |
| return `\x00${idx}\x00`; | |
| } | |
| ); | |
| raw = raw.replace(/`([^`]+)`/g, (_, c) => { | |
| const idx = tokens.length; tokens.push(`<code>${esc(c)}</code>`); return `\x00${idx}\x00`; | |
| }); | |
| let out = esc(raw); | |
| out = out.replace(/\*\*\*([^*]+)\*\*\*/g,'<strong><em>$1</em></strong>'); | |
| out = out.replace(/___([^_]+)___/g,'<strong><em>$1</em></strong>'); | |
| out = out.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>'); | |
| out = out.replace(/__([^_]+)__/g,'<strong>$1</strong>'); | |
| out = out.replace(/\*([^*\s][^*]*[^*\s]|\S)\*/g,'<em>$1</em>'); | |
| out = out.replace(/_([^_\s][^_]*[^_\s]|\S)_/g,'<em>$1</em>'); | |
| out = out.replace(/~~([^~]+)~~/g,'<s>$1</s>'); | |
| out = out.replace(/\^([^^]+)\^/g,'<sup>$1</sup>'); | |
| out = out.replace(/~([^~]+)~/g,'<sub>$1</sub>'); | |
| out = out.replace(/\x00(\d+)\x00/g, (_, i) => tokens[Number(i)]); | |
| return out; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| BLOCK MARKDOWN RENDERER | |
| (with thinking block extraction) | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| /** | |
| * Extract <|thinking|>β¦</|thinking|> blocks from raw text. | |
| * Returns { segments: [ {type:'text'|'thinking', content:string} ] } | |
| */ | |
| function extractThinkingBlocks(md) { | |
| const segments = []; | |
| const openTag = '<|thinking|>'; | |
| const closeTag = '</|thinking|>'; | |
| let cursor = 0; | |
| while (cursor < md.length) { | |
| const openIdx = md.indexOf(openTag, cursor); | |
| if (openIdx === -1) { | |
| segments.push({ type: 'text', content: md.slice(cursor) }); | |
| break; | |
| } | |
| if (openIdx > cursor) { | |
| segments.push({ type: 'text', content: md.slice(cursor, openIdx) }); | |
| } | |
| const closeIdx = md.indexOf(closeTag, openIdx + openTag.length); | |
| if (closeIdx === -1) { | |
| // Unclosed thinking block β treat rest as thinking | |
| segments.push({ type: 'thinking', content: md.slice(openIdx + openTag.length) }); | |
| cursor = md.length; | |
| break; | |
| } | |
| segments.push({ type: 'thinking', content: md.slice(openIdx + openTag.length, closeIdx) }); | |
| cursor = closeIdx + closeTag.length; | |
| } | |
| return segments; | |
| } | |
| function renderMarkdown(md) { | |
| const lines = String(md||'').replace(/\r\n/g,'\n').split('\n'); | |
| const out = []; | |
| let inCode = false, codeLang = '', codeBuf = []; | |
| let lastWasBlank = false; | |
| const listStack = []; | |
| function closeListsTo(target) { | |
| while (listStack.length && listStack[listStack.length-1].indent > target) | |
| out.push(`</${listStack.pop().type}>`); | |
| } | |
| function closeLists() { while (listStack.length) out.push(`</${listStack.pop().type}>`); } | |
| let inQuote = false; | |
| function closeQuote() { if (inQuote) { out.push('</blockquote>'); inQuote = false; } } | |
| let inTable = false, tableHeaders = [], tableAligns = []; | |
| function closeTable() { | |
| if (inTable) { out.push('</tbody></table>'); inTable = false; tableHeaders = []; tableAligns = []; } | |
| } | |
| for (let li = 0; li < lines.length; li++) { | |
| const raw = lines[li].trimEnd(); | |
| if (inCode) { | |
| if (/^```/.test(raw)) { | |
| const highlighted = esc(codeBuf.join('\n')); | |
| const langLabel = codeLang ? `<span class="code-lang-label">${esc(codeLang)}</span>` : ''; | |
| out.push(`<div class="code-block-wrapper">${langLabel}<button class="copy-code-btn" data-copy-code="${encodeURIComponent(codeBuf.join('\n'))}">Copy</button><pre><code>${highlighted}</code></pre></div>`); | |
| codeBuf = []; codeLang = ''; inCode = false; | |
| } else codeBuf.push(raw); | |
| continue; | |
| } | |
| if (/^```/.test(raw)) { | |
| closeLists(); closeQuote(); closeTable(); | |
| codeLang = raw.replace(/^```/,'').trim().toLowerCase(); inCode = true; continue; | |
| } | |
| if (!raw.trim()) { | |
| closeLists(); closeQuote(); closeTable(); | |
| if (!lastWasBlank) out.push('<p class="md-gap"></p>'); | |
| lastWasBlank = true; continue; | |
| } | |
| lastWasBlank = false; | |
| if (/^#{1,6}\s/.test(raw)) { | |
| closeLists(); closeQuote(); closeTable(); | |
| const lvl = Math.min(6, raw.match(/^#+/)[0].length); | |
| out.push(`<h${lvl}>${renderInlineMarkdown(raw.replace(/^#+\s+/,''))}</h${lvl}>`); continue; | |
| } | |
| if (/^(-{3,}|\*{3,}|_{3,})$/.test(raw.trim())) { | |
| closeLists(); closeQuote(); closeTable(); out.push('<hr>'); continue; | |
| } | |
| if (/^> ?/.test(raw)) { | |
| closeLists(); closeTable(); | |
| if (!inQuote) { out.push('<blockquote>'); inQuote = true; } | |
| out.push(`<p>${renderInlineMarkdown(raw.replace(/^> ?/,''))}</p>`); continue; | |
| } | |
| closeQuote(); | |
| if (/^\|.+\|$/.test(raw)) { | |
| const nextLine = lines[li+1] ? lines[li+1].trimEnd() : ''; | |
| if (/^\|[\s|:\-]+\|$/.test(nextLine)) { | |
| closeLists(); | |
| tableHeaders = raw.split('|').slice(1,-1).map(c=>c.trim()); | |
| const sepCells = nextLine.split('|').slice(1,-1).map(c=>c.trim()); | |
| tableAligns = sepCells.map(c => /^:-+:$/.test(c)?'center': /^-+:$/.test(c)?'right':'left'); | |
| out.push('<table><thead><tr>'); | |
| tableHeaders.forEach((h,i)=>out.push(`<th style="text-align:${tableAligns[i]}">${renderInlineMarkdown(h)}</th>`)); | |
| out.push('</tr></thead><tbody>'); inTable = true; li++; continue; | |
| } else if (inTable) { | |
| const cells = raw.split('|').slice(1,-1).map(c=>c.trim()); | |
| out.push('<tr>'); | |
| cells.forEach((c,i)=>out.push(`<td style="text-align:${tableAligns[i]||'left'}">${renderInlineMarkdown(c)}</td>`)); | |
| out.push('</tr>'); continue; | |
| } | |
| } | |
| if (inTable && !/^\|/.test(raw)) closeTable(); | |
| const ulMatch = raw.match(/^(\s*)[-*]\s+(.*)/); | |
| const olMatch = raw.match(/^(\s*)\d+\.\s+(.*)/); | |
| if (ulMatch || olMatch) { | |
| closeQuote(); closeTable(); | |
| const indent = (ulMatch||olMatch)[1].length; | |
| const type = ulMatch ? 'ul' : 'ol'; | |
| const text = ulMatch ? ulMatch[2] : olMatch[2]; | |
| if (!listStack.length || indent > listStack[listStack.length-1].indent) { | |
| out.push(`<${type}>`); listStack.push({type,indent}); | |
| } else if (indent < listStack[listStack.length-1].indent) { | |
| closeListsTo(indent); | |
| if (!listStack.length || listStack[listStack.length-1].indent !== indent) { | |
| out.push(`<${type}>`); listStack.push({type,indent}); | |
| } | |
| } | |
| const taskMatch = text.match(/^\[([ xX])\]\s+(.*)/); | |
| if (taskMatch) { | |
| const checked = taskMatch[1] !== ' '; | |
| out.push(`<li class="task-item"><input type="checkbox" ${checked?'checked':''} disabled aria-checked="${checked}">${renderInlineMarkdown(taskMatch[2])}</li>`); | |
| } else out.push(`<li>${renderInlineMarkdown(text)}</li>`); | |
| continue; | |
| } | |
| closeLists(); closeTable(); | |
| out.push(`<p>${renderInlineMarkdown(raw)}</p>`); | |
| } | |
| closeLists(); closeQuote(); closeTable(); | |
| if (inCode) out.push(`<div class="code-block-wrapper"><pre><code>${codeBuf.join('\n')}</code></pre></div>`); | |
| return out.join(''); | |
| } | |
| /** | |
| * Render markdown that may contain thinking blocks. | |
| * Returns HTML with <details> dropdowns for each thinking block. | |
| * `thinkingDuration` is the number of seconds to display (null = still thinking). | |
| */ | |
| function renderMarkdownWithThinking(md, thinkingDuration = null) { | |
| const segments = extractThinkingBlocks(md); | |
| return segments.map(seg => { | |
| if (seg.type === 'thinking') { | |
| const durLabel = thinkingDuration != null | |
| ? `thinking for ${thinkingDuration}s` | |
| : `thinkingβ¦`; | |
| const spinnerOrArrow = thinkingDuration != null | |
| ? `<span class="thinking-arrow" aria-hidden="true">βΆ</span>` | |
| : `<span class="thinking-spinner" aria-hidden="true"></span>`; | |
| const thinkingHtml = renderMarkdown(seg.content.trim()); | |
| return `<details class="thinking-dropdown"> | |
| <summary class="thinking-summary">${spinnerOrArrow} <span>${esc(durLabel)}</span></summary> | |
| <div class="thinking-content">${thinkingHtml}</div> | |
| </details>`; | |
| } | |
| return renderMarkdown(seg.content); | |
| }).join(''); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| TIME HELPERS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function relativeTime(iso) { | |
| if (!iso) return ''; | |
| try { | |
| const diff = Date.now() - new Date(iso).getTime(); | |
| const mins = Math.floor(diff/60000); | |
| if (mins<1) return 'just now'; | |
| if (mins<60) return `${mins}m ago`; | |
| const hrs = Math.floor(mins/60); | |
| if (hrs<24) return `${hrs}h ago`; | |
| const days = Math.floor(hrs/24); | |
| if (days<7) return `${days}d ago`; | |
| return new Date(iso).toLocaleString([],{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); | |
| } catch { return iso; } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| QUALITY SCORE | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function answerQuality(text) { | |
| let s = 0; | |
| if (text.length>200) s++; if (/```/.test(text)) s++; | |
| if (/https?:\/\//.test(text)) s++; if (/\n[-*] /.test(text)) s++; | |
| return Math.min(s,4); | |
| } | |
| function renderQualityDots(text) { | |
| const q = answerQuality(text); | |
| return `<span class="quality-dots" aria-label="Answer quality">${ | |
| Array.from({length:4},(_,i)=>`<span class="quality-dot ${i<q?'filled':''}" aria-hidden="true"></span>`).join('') | |
| }</span>`; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| SCROLL | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function isNearBottom() { const c=$('chat'); return c.scrollHeight-c.scrollTop-c.clientHeight<72; } | |
| function setJumpLatest(v) { $('jumpLatest')?.classList.toggle('show',!!v); } | |
| let scrollRAF = null; | |
| function scrollBottom(force=false) { | |
| if (scrollRAF) return; | |
| scrollRAF = requestAnimationFrame(() => { | |
| scrollRAF = null; const c=$('chat'); if (!c) return; | |
| if (force||S.atBottom||isNearBottom()) { | |
| c.scrollTop = c.scrollHeight; setJumpLatest(false); S.atBottom = true; | |
| } else setJumpLatest(true); | |
| }); | |
| } | |
| function updateScrollProgress() { | |
| const c=$('chat'), bar=$('scrollProgress'); if (!c||!bar) return; | |
| const pct = c.scrollHeight<=c.clientHeight ? 0 : (c.scrollTop/(c.scrollHeight-c.clientHeight))*100; | |
| bar.style.width = pct.toFixed(1)+'%'; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| DOM HELPERS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function appendHTML(target, html) { | |
| if (!html) return; | |
| const tpl = document.createElement('template'); | |
| tpl.innerHTML = html.trim(); | |
| target.appendChild(tpl.content); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| ANIMATE TEXT (chunked autoregressive) | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function animateText(el, text) { | |
| if (!el) return; | |
| const mode = S.animMode; | |
| const delays = { none:0, ai:18, human:30, diffusion:50, 'diffusion-v2':70 }; | |
| const delay = delays[mode] ?? 0; | |
| const segments = extractThinkingBlocks(text); | |
| if (mode === 'none') { | |
| el.innerHTML = renderMarkdownWithThinking(text, null); | |
| finalizeThinkingBlocks(el); | |
| bindCodeCopyButtons(el); | |
| return; | |
| } | |
| el.innerHTML = ''; | |
| for (const seg of segments) { | |
| if (seg.type === 'thinking') { | |
| await animateThinkingBlock(el, seg.content, delay); | |
| } else { | |
| const textContainer = document.createElement('div'); | |
| el.appendChild(textContainer); | |
| await animateMarkdownChunked(textContainer, seg.content, delay); | |
| } | |
| } | |
| finalizeThinkingBlocks(el); | |
| bindCodeCopyButtons(el); | |
| } | |
| /** | |
| * Animate a thinking block: show a "thinkingβ¦" dropdown that updates | |
| * in real-time, then when complete, display final duration. | |
| */ | |
| async function animateThinkingBlock(parentEl, thinkingText, delay) { | |
| const details = document.createElement('details'); | |
| details.className = 'thinking-dropdown'; | |
| const summary = document.createElement('summary'); | |
| summary.className = 'thinking-summary'; | |
| summary.innerHTML = `<span class="thinking-spinner" aria-hidden="true"></span> <span class="thinking-label">thinkingβ¦</span>`; | |
| const contentDiv = document.createElement('div'); | |
| contentDiv.className = 'thinking-content'; | |
| details.appendChild(summary); | |
| details.appendChild(contentDiv); | |
| parentEl.appendChild(details); | |
| scrollBottom(); | |
| const startTime = performance.now(); | |
| // Stream the thinking content in chunks | |
| await animateMarkdownChunked(contentDiv, thinkingText.trim(), delay); | |
| const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); | |
| // Replace spinner with arrow + final time | |
| summary.innerHTML = `<span class="thinking-arrow" aria-hidden="true">βΆ</span> <span>thinking for ${elapsed}s</span>`; | |
| } | |
| /** | |
| * Animate markdown by splitting it into small line-based chunks | |
| * and rendering progressively, so it feels autoregressive. | |
| */ | |
| async function animateMarkdownChunked(el, mdText, delay) { | |
| if (!mdText.trim()) return; | |
| const lines = mdText.replace(/\r\n/g,'\n').split('\n'); | |
| let buffer = ''; | |
| // We'll accumulate lines and re-render periodically | |
| // Chunk size: 1-3 lines at a time for natural feel | |
| const chunkSize = delay > 40 ? 1 : delay > 15 ? 2 : 3; | |
| for (let i = 0; i < lines.length; i += chunkSize) { | |
| const chunk = lines.slice(i, i + chunkSize).join('\n'); | |
| buffer += (buffer ? '\n' : '') + chunk; | |
| // Render current buffer | |
| const html = renderMarkdown(buffer); | |
| el.innerHTML = html; | |
| bindCodeCopyButtons(el); | |
| scrollBottom(); | |
| if (delay > 0 && i + chunkSize < lines.length) { | |
| await sleep(delay); | |
| } | |
| } | |
| } | |
| /** | |
| * After all animation is done, finalize any thinking blocks | |
| * that might still show spinners (for non-animated renders). | |
| */ | |
| function finalizeThinkingBlocks(el) { | |
| // For statically rendered thinking blocks, we just need to make sure | |
| // the duration is set. This is handled in renderMarkdownWithThinking | |
| // for instant mode. For animated mode, animateThinkingBlock handles it. | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| TYPING INDICATOR | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function showTyping() { | |
| removeTyping(); | |
| $('transcript').insertAdjacentHTML('beforeend', ` | |
| <div class="typing-indicator" id="typingInd" aria-label="Loading response" role="status"> | |
| <div class="avatar assistant" aria-hidden="true">β¦</div> | |
| <div class="typing-dots" aria-hidden="true"><span></span><span></span><span></span></div> | |
| </div>`); | |
| scrollBottom(); | |
| } | |
| function removeTyping() { $('typingInd')?.remove(); } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| LIGHTBOX | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function openLightbox(src, alt) { | |
| $('lightboxImg').src = src; $('lightboxImg').alt = alt||''; | |
| $('lightbox').classList.add('open'); $('lightboxClose').focus(); | |
| document.addEventListener('keydown', closeLightboxOnKey); | |
| } | |
| function closeLightbox() { | |
| $('lightbox').classList.remove('open'); $('lightboxImg').src = ''; | |
| document.removeEventListener('keydown', closeLightboxOnKey); | |
| } | |
| function closeLightboxOnKey(e) { if (e.key==='Escape') closeLightbox(); } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| CODE COPY BUTTONS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function bindCodeCopyButtons(ctx = document) { | |
| qsa('[data-copy-code]', ctx).forEach(btn => { | |
| if (btn._bound) return; btn._bound = true; | |
| btn.addEventListener('click', async () => { | |
| try { | |
| await navigator.clipboard.writeText(decodeURIComponent(btn.getAttribute('data-copy-code'))); | |
| btn.textContent='Copied!'; btn.classList.add('copied'); | |
| setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied');},2000); | |
| } catch { toast('Could not copy','bad'); } | |
| }); | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| ANSWER HELPERS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function activeVersion(answer) { | |
| const v = answer?.versions||[]; | |
| if (!v.length) return null; | |
| return v.find(x=>x.id===answer.active_version) | |
| || [...v].sort((a,b)=>(Number(b.votes||0)-Number(a.votes||0)) || String(b.created_at||'').localeCompare(String(a.created_at||'')))[0]; | |
| } | |
| function answerScore(a) { const v=activeVersion(a); return v?Number(v.votes||0):0; } | |
| function sortedAnswers(conv) { | |
| return [...(conv?.answers||[])].sort((a,b)=>{ | |
| const d=answerScore(b)-answerScore(a); | |
| return d!==0?d:String(b.created_at||'').localeCompare(String(a.created_at||'')); | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| RENDER HELPERS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| const COPY_SVG = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`; | |
| const ASK_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>`; | |
| const PENCIL_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`; | |
| function renderVoteRow(answerId, ver) { | |
| const myVote = ver.votes_by_client?.[S.clientId]; | |
| const vu = myVote===1, vd = myVote===-1; | |
| const cnt = Number(ver.votes||0); | |
| return `<div class="vote-row"> | |
| <button class="vote-btn ${vu?'voted-up':''}" data-vote="${answerId}|${ver.id}|1" | |
| aria-label="Upvote. ${cnt} vote${cnt!==1?'s':''}. ${vu?'You voted.':'Not voted.'}" aria-pressed="${vu}"> | |
| β² <span class="vote-count"><span class="vote-count-inner">${cnt}</span></span> | |
| </button> | |
| <button class="vote-btn ${vd?'voted-down':''}" data-vote="${answerId}|${ver.id}|-1" | |
| aria-label="Downvote.${vd?' You voted.':''}" aria-pressed="${vd}">βΌ</button> | |
| <button class="action-btn" data-copy-answer="${answerId}" data-answer-id="${ver.id}">${COPY_SVG} Copy</button> | |
| </div>`; | |
| } | |
| function renderVersions(answer) { | |
| const act = activeVersion(answer); | |
| const others = (answer.versions||[]).filter(v=>v.id!==act?.id); | |
| if (!others.length) return ''; | |
| return ` | |
| <button class="versions-toggle" type="button" data-toggle-versions="${answer.id}" aria-controls="vp-${answer.id}" aria-expanded="false"> | |
| <span class="arrow" aria-hidden="true">βΆ</span> ${others.length} version${others.length>1?'s':''} | |
| </button> | |
| <div class="versions-panel" id="vp-${answer.id}" role="region" aria-label="Other versions"> | |
| ${others.map(v=>` | |
| <div class="version-card"> | |
| <div class="version-head"> | |
| <span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span> | |
| <span>${relativeTime(v.created_at)}</span><span aria-hidden="true">Β·</span> | |
| <span>${Number(v.votes||0)} vote${Number(v.votes||0)!==1?'s':''}</span> | |
| </div> | |
| <div class="preview-block"> | |
| <span class="preview-label">A</span> | |
| <div class="preview-text">${esc(previewText(v.text||''))}</div> | |
| </div> | |
| <div class="preview-actions"> | |
| ${renderVoteRow(answer.id, v)} | |
| <button class="ask-btn" type="button" data-ask-current="1" aria-label="Ask this question again">${ASK_SVG} Ask</button> | |
| </div> | |
| </div>`).join('')} | |
| </div>`; | |
| } | |
| function renderPropose(answerId) { | |
| return ` | |
| <button class="action-btn" type="button" data-propose="${answerId}" aria-controls="pp-${answerId}" aria-expanded="false" title="Suggest an improved version">β Propose version</button> | |
| <div class="propose-panel" id="pp-${answerId}" role="region" aria-label="Propose version"> | |
| <textarea class="propose-textarea" placeholder="Write a better versionβ¦" rows="3" aria-label="Proposed version text"></textarea> | |
| <div class="char-count"><span class="cc-cur">0</span> / 5000</div> | |
| <div class="propose-actions"> | |
| <button class="propose-submit" data-submit-proposal="${answerId}">Submit</button> | |
| <button class="propose-cancel" data-cancel-propose="${answerId}">Cancel</button> | |
| </div> | |
| </div>`; | |
| } | |
| function renderWriteAnswer(convId) { | |
| return ` | |
| <button class="write-answer-btn" type="button" id="writeAnswerBtn" aria-controls="writePanel" aria-expanded="false">${PENCIL_SVG} Write an answer</button> | |
| <div class="write-panel" id="writePanel" role="region" aria-label="Write your answer"> | |
| <div class="write-tabs" role="tablist"> | |
| <button class="write-tab active" role="tab" id="writeTabEdit" aria-selected="true" aria-controls="writeEditorPane">Write</button> | |
| <button class="write-tab" role="tab" id="writeTabPreview" aria-selected="false" aria-controls="writePreviewPane">Preview</button> | |
| </div> | |
| <div id="writeEditorPane" role="tabpanel" aria-labelledby="writeTabEdit"> | |
| <textarea class="write-textarea" id="writeTextarea" placeholder="Write your answer here⦠Markdown is supported." rows="4" aria-label="Your answer" maxlength="5000"></textarea> | |
| </div> | |
| <div id="writePreviewPane" role="tabpanel" aria-labelledby="writeTabPreview" class="write-preview"></div> | |
| <div class="char-count" id="writeCharCount"><span id="writeCharCur">0</span> / 5000</div> | |
| <div class="write-actions"> | |
| <button class="write-submit" id="writeSubmit">Submit answer</button> | |
| <button class="write-cancel" id="writeCancel">Cancel</button> | |
| </div> | |
| </div>`; | |
| } | |
| function renderAnswerBlock(answer, idx, isBest) { | |
| const v = activeVersion(answer); if (!v) return ''; | |
| const rawText = v.text||''; | |
| const label = isBest ? `<span class="chip good">β best answer</span>` : `<span class="chip muted">answer ${idx+1}</span>`; | |
| const bubbleId = isBest ? 'id="bestAnswerText"' : ''; | |
| const bubbleClass = isBest ? 'best-answer-bubble' : 'bubble'; | |
| const glowClass = isBest ? 'answer-new-glow' : ''; | |
| return ` | |
| <div ${bubbleId} class="${bubbleClass} ${glowClass}" tabindex="-1">${isBest ? '' : renderMarkdownWithThinking(rawText)}</div> | |
| <div class="turn-meta" style="margin-top:var(--space-1);"> | |
| ${label} ${renderQualityDots(rawText)} | |
| <span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span> | |
| <span>${relativeTime(v.created_at)}</span> | |
| </div> | |
| ${renderVoteRow(answer.id, v)} | |
| <div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1);"> | |
| ${renderVersions(answer)} ${renderPropose(answer.id)} | |
| </div>`; | |
| } | |
| function renderOtherAnswers(answers) { | |
| if (answers.length<=1) return ''; | |
| const others = answers.slice(1); | |
| return ` | |
| <button class="other-answers-toggle" type="button" id="otherAnswersToggle" aria-controls="otherAnswersPanel" aria-expanded="false"> | |
| <span class="arrow" aria-hidden="true">βΆ</span> ${others.length} other answer${others.length>1?'s':''} | |
| </button> | |
| <div class="other-answers-panel" id="otherAnswersPanel" role="region" aria-label="Other answers"> | |
| ${others.map((a,i)=>{ | |
| const v=activeVersion(a); if(!v) return ''; | |
| return `<div class="other-answer-card"> | |
| <div class="other-answer-head"> | |
| <span class="chip muted">answer ${i+2}</span> | |
| <span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span> | |
| <span>${relativeTime(v.created_at)}</span> ${renderQualityDots(v.text||'')} | |
| </div> | |
| <div class="other-answer-text">${renderMarkdownWithThinking(v.text||'')}</div> | |
| ${renderVoteRow(a.id, v)} | |
| <div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1);"> | |
| ${renderVersions(a)} ${renderPropose(a.id)} | |
| </div> | |
| </div>`; | |
| }).join('')} | |
| </div>`; | |
| } | |
| function renderRelated(rel) { | |
| if (!rel?.length) return ''; | |
| return ` | |
| <div class="related-stack"> | |
| <div class="chip muted">from similar questions</div> | |
| <button class="related-toggle" type="button" id="relatedToggle" aria-controls="relatedPanel" aria-expanded="false"> | |
| <span class="arrow" aria-hidden="true">βΆ</span> ${rel.length} related answer${rel.length>1?'s':''} | |
| </button> | |
| <div class="related-panel" id="relatedPanel" role="region" aria-label="Related answers"> | |
| ${rel.map(r=>` | |
| <div class="other-answer-card related"> | |
| <div class="other-answer-head"> | |
| <span class="chip matched">related</span> | |
| <span class="chip related-score">score ${Number(r.score||0).toFixed(2)}</span> | |
| </div> | |
| <div class="preview-block"><span class="preview-label">Q</span> | |
| <div class="preview-text">${esc(previewText(r.question||''))}</div></div> | |
| <div class="preview-block"><span class="preview-label">A</span> | |
| <div class="preview-text muted-preview">${esc(previewText(r.answer||''))}</div></div> | |
| <div class="preview-actions"> | |
| <button class="ask-btn" type="button" data-ask-question="${esc(r.question||'')}" aria-label="Ask this question">${ASK_SVG} Ask</button> | |
| </div> | |
| </div>`).join('')} | |
| <p class="related-note">Previews of answers from semantically similar questions. Click Ask to start a fresh conversation.</p> | |
| </div> | |
| </div>`; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| MAIN RENDER | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function renderConversation(questionText, doAnimate) { | |
| const tr=$('transcript'), wl=$('welcome'); | |
| const frag = document.createDocumentFragment(); | |
| if (!S.conversation) { | |
| wl.style.display=''; tr.replaceChildren(); | |
| setJumpLatest(false); document.title=S.originalTitle; | |
| updateWelcomeState(); return; | |
| } | |
| wl.style.display='none'; | |
| const q = questionText||S.conversation.question||''; | |
| document.title = q.slice(0,60)+' β '+S.originalTitle; | |
| if (S.conversation.id) history.replaceState({cid:S.conversation.id},'',`/q/${S.conversation.id}`); | |
| const isNew = !S.conversation.created_at || (Date.now()-new Date(S.conversation.created_at).getTime())<10000; | |
| const questionNote = isNew | |
| ? `<div class="question-note">π You're the first to ask this!</div>` | |
| : `<div class="question-note">Asked ${relativeTime(S.conversation.created_at)}</div>`; | |
| appendHTML(frag, ` | |
| <div class="turn user new-turn"><div> | |
| <div class="bubble">${nl2br(q)}</div> | |
| <div class="turn-meta"><span class="chip muted">question</span><span>${relativeTime(S.conversation.created_at)}</span></div> | |
| ${questionNote} | |
| </div><div class="avatar user" aria-hidden="true">U</div></div>`); | |
| const answers = sortedAnswers(S.conversation); | |
| if (!answers.length) { | |
| appendHTML(frag, ` | |
| <div class="turn assistant new-turn"><div class="avatar assistant" aria-hidden="true">β¦</div><div> | |
| <div class="bubble no-answer-bubble" role="status">β³ No answer yet. Be the first to write one.</div> | |
| <div class="turn-meta"><span class="chip warn">β³ awaiting answer</span></div> | |
| ${renderWriteAnswer(S.conversation.id)} | |
| <div id="relatedMount"></div> | |
| </div></div>`); | |
| } else { | |
| appendHTML(frag, ` | |
| <div class="turn assistant new-turn"><div class="avatar assistant" aria-hidden="true">β¦</div> | |
| <div style="min-width:0;flex:1;"> | |
| ${renderAnswerBlock(answers[0], 0, true)} | |
| ${renderWriteAnswer(S.conversation.id)} | |
| ${renderOtherAnswers(answers)} | |
| <div id="relatedMount"></div> | |
| </div> | |
| </div>`); | |
| } | |
| tr.replaceChildren(frag); | |
| if (answers.length) { | |
| const bestV = activeVersion(answers[0]); | |
| if (bestV) { | |
| const el = $('bestAnswerText'); | |
| if (doAnimate) { | |
| await animateText(el, bestV.text||''); | |
| } else if (el) { | |
| el.innerHTML = renderMarkdownWithThinking(bestV.text||''); | |
| bindCodeCopyButtons(el); | |
| } | |
| } | |
| } | |
| const rm = $('relatedMount'); | |
| if (rm && S.relatedAnswers.length) rm.innerHTML = renderRelated(S.relatedAnswers); | |
| bindHandlers(); scrollBottom(); restoreDraft(); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| DRAFTS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function draftKey() { return S.conversation ? `hi_draft_${S.conversation.id}` : null; } | |
| function saveDraft(text) { const k=draftKey(); if(!k) return; text ? localStorage.setItem(k,text) : localStorage.removeItem(k); } | |
| function clearDraft() { const k=draftKey(); if(k) localStorage.removeItem(k); } | |
| function restoreDraft() { | |
| const k=draftKey(); if(!k) return; | |
| const saved = localStorage.getItem(k); | |
| const ta = $('writeTextarea'); | |
| if (saved && ta) { | |
| ta.value = saved; updateCharCount(ta,'writeCharCur',5000); | |
| const panel=$('writePanel'), btn=$('writeAnswerBtn'); | |
| if (panel&&btn) { panel.classList.add('open'); btn.setAttribute('aria-expanded','true'); } | |
| } | |
| } | |
| function updateCharCount(ta, spanId, max) { | |
| const span=$(spanId); if(!span) return; | |
| const len=ta.value.length; span.textContent=len; | |
| const row=span.closest('.char-count'); | |
| if(row){ row.classList.toggle('near-limit',len>max*.85&&len<=max); row.classList.toggle('over-limit',len>max); } | |
| } | |
| function updateWelcomeState() { | |
| const title=$('welcomeTitle'); if(!title) return; | |
| title.textContent = localStorage.getItem('hi_last_cid') | |
| ? 'Welcome back. Ask something new.' | |
| : 'Ask a question. Get answers from real people.'; | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| EVENT DELEGATION | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function bindHandlers() { | |
| const tr=$('transcript'); | |
| if (tr._delegated) return; | |
| tr._delegated = true; | |
| tr.addEventListener('click', async e => { | |
| const voteBtn = e.target.closest('[data-vote]'); | |
| if (voteBtn) { await handleVote(voteBtn); return; } | |
| const askQ = e.target.closest('[data-ask-question]'); | |
| if (askQ) { handleAskFromCard(askQ.getAttribute('data-ask-question')); return; } | |
| if (e.target.closest('[data-ask-current]')) { handleAskFromCard(S.currentQuestion); return; } | |
| const copyAns = e.target.closest('[data-copy-answer]'); | |
| if (copyAns) { await handleCopyAnswer(copyAns); return; } | |
| const toggleVer = e.target.closest('[data-toggle-versions]'); | |
| if (toggleVer) { handleToggleVersions(toggleVer); return; } | |
| if (e.target.closest('#otherAnswersToggle')) { handleTogglePanel('otherAnswersToggle','otherAnswersPanel'); return; } | |
| if (e.target.closest('#relatedToggle')) { handleTogglePanel('relatedToggle','relatedPanel'); return; } | |
| const proposeBtn = e.target.closest('[data-propose]'); | |
| if (proposeBtn) { handleProposeToggle(proposeBtn); return; } | |
| const cancelProp = e.target.closest('[data-cancel-propose]'); | |
| if (cancelProp) { handleProposeCancel(cancelProp); return; } | |
| const submitProp = e.target.closest('[data-submit-proposal]'); | |
| if (submitProp) { await handleSubmitProposal(submitProp); return; } | |
| if (e.target.closest('#writeAnswerBtn')) { handleWriteToggle(); return; } | |
| if (e.target.closest('#writeCancel')) { handleWriteCancel(); return; } | |
| if (e.target.closest('#writeSubmit')) { await handleWriteSubmit(); return; } | |
| if (e.target.closest('#writeTabEdit')) { handleWriteTab('edit'); return; } | |
| if (e.target.closest('#writeTabPreview')) { handleWriteTab('preview'); return; } | |
| const img = e.target.closest('.md-img'); | |
| if (img) { openLightbox(img.src, img.alt); return; } | |
| }); | |
| tr.addEventListener('input', e => { | |
| const ta = e.target; | |
| if (ta.tagName!=='TEXTAREA') return; | |
| autoGrow(ta); | |
| if (ta.id==='writeTextarea') { | |
| updateCharCount(ta,'writeCharCur',5000); | |
| saveDraft(ta.value); | |
| const preview=$('writePreviewPane'); | |
| if (preview?.classList.contains('write-preview') && !preview.style.display?.includes('none')) | |
| preview.innerHTML = renderMarkdown(ta.value); | |
| } else { | |
| const panel = ta.closest('.propose-panel'); | |
| if (panel) { | |
| const ccSpan=qs('.cc-cur',panel); | |
| if(ccSpan){ ccSpan.textContent=ta.value.length; | |
| const ccRow=qs('.char-count',panel); | |
| if(ccRow){ ccRow.classList.toggle('near-limit',ta.value.length>4250); ccRow.classList.toggle('over-limit',ta.value.length>5000); } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| /* ββ Individual Handlers ββ */ | |
| const handleVote = debounceClick(async btn => { | |
| if (!S.conversation) return; | |
| const [aid,vid,d] = btn.getAttribute('data-vote').split('|'); | |
| const delta = Number(d); | |
| const answer = (S.conversation.answers||[]).find(a=>a.id===aid); | |
| const ver = answer?.versions?.find(v=>v.id===vid); | |
| const myVote = ver?.votes_by_client?.[S.clientId]; | |
| const effectiveDelta = myVote===delta ? 0 : delta; | |
| const countEl = qs('.vote-count-inner',btn.parentElement); | |
| const prevCount = countEl ? Number(countEl.textContent) : 0; | |
| if (countEl) { | |
| const newCount = prevCount + (effectiveDelta===0 ? -delta : delta); | |
| countEl.style.transform = `translateY(${delta>0?'-100%':'100%'})`; | |
| requestAnimationFrame(()=>{ countEl.textContent=newCount; countEl.style.transform=''; }); | |
| } | |
| btn.classList.toggle('voted-up', effectiveDelta===1); | |
| btn.classList.toggle('voted-down', effectiveDelta===-1); | |
| if ('vibrate' in navigator) navigator.vibrate(10); | |
| S.lastAction = ()=>handleVote(btn); | |
| const res = await callAPI('vote',{ | |
| conversation_id:S.conversation.id, answer_id:aid, version_id:vid, delta:effectiveDelta | |
| }); | |
| if (res.ok) { | |
| S.conversation=res.conversation; save(); | |
| updateVoteBtn(btn,aid,vid,res.conversation); | |
| } else { | |
| if(countEl) countEl.textContent=prevCount; | |
| btn.classList.toggle('voted-up', myVote===1); | |
| btn.classList.toggle('voted-down', myVote===-1); | |
| toast(res.error||'Vote failed','bad',S.lastAction); | |
| } | |
| }); | |
| function updateVoteBtn(btn,aid,vid,conv) { | |
| const answer=(conv.answers||[]).find(a=>a.id===aid); | |
| const ver=answer?.versions?.find(v=>v.id===vid); | |
| if(!ver) return; | |
| const myVote=ver.votes_by_client?.[S.clientId]; | |
| const cnt=Number(ver.votes||0); | |
| const countEl=qs('.vote-count-inner',btn.parentElement); | |
| if(countEl) countEl.textContent=cnt; | |
| const vu=myVote===1, vd=myVote===-1; | |
| btn.classList.toggle('voted-up',vu); btn.classList.toggle('voted-down',vd); | |
| if(btn.getAttribute('data-vote').endsWith('|1')){ | |
| btn.setAttribute('aria-label',`Upvote. ${cnt} vote${cnt!==1?'s':''}. ${vu?'You voted.':'Not voted.'}`); | |
| btn.setAttribute('aria-pressed',String(vu)); | |
| } else btn.setAttribute('aria-pressed',String(vd)); | |
| } | |
| async function handleCopyAnswer(btn) { | |
| const answer=(S.conversation?.answers||[]).find(a=>a.id===btn.getAttribute('data-copy-answer')); | |
| const ver=activeVersion(answer); if(!ver) return; | |
| try { await navigator.clipboard.writeText(ver.text||''); toast('Answer copied','good'); } | |
| catch { toast('Could not copy','bad'); } | |
| } | |
| function handleAskFromCard(q) { | |
| const text=String(q||'').trim(); | |
| if(!text||S.loading) return; | |
| const p=$('prompt'); if(p){p.value=text;autoGrow(p);} submitPrompt(); | |
| } | |
| function handleToggleVersions(btn) { | |
| const id=btn.getAttribute('data-toggle-versions'), p=$('vp-'+id); if(!p) return; | |
| const open=p.classList.toggle('open'); | |
| const arrow=qs('.arrow',btn); if(arrow) arrow.style.transform=open?'rotate(90deg)':''; | |
| btn.setAttribute('aria-expanded',String(open)); | |
| } | |
| function handleTogglePanel(toggleId, panelId) { | |
| const toggle=$(toggleId), panel=$(panelId); if(!toggle||!panel) return; | |
| const open=panel.classList.toggle('open'); | |
| toggle.classList.toggle('open',open); toggle.setAttribute('aria-expanded',String(open)); | |
| } | |
| function handleProposeToggle(btn) { | |
| const id=btn.getAttribute('data-propose'), p=$('pp-'+id); if(!p) return; | |
| const open=p.classList.toggle('open'); btn.setAttribute('aria-expanded',String(open)); | |
| if(open){const ta=qs('textarea',p); if(ta) setTimeout(()=>ta.focus(),80);} | |
| } | |
| function handleProposeCancel(btn) { | |
| const id=btn.getAttribute('data-cancel-propose'), p=$('pp-'+id); | |
| if(p) p.classList.remove('open'); | |
| const trigger=qs(`[data-propose="${id}"]`); | |
| if(trigger){trigger.setAttribute('aria-expanded','false');trigger.focus();} | |
| } | |
| const handleSubmitProposal = debounceClick(async btn => { | |
| const aid=btn.getAttribute('data-submit-proposal'); | |
| const box=$('pp-'+aid), ta=box?qs('textarea',box):null; | |
| const text=ta?ta.value.trim():''; | |
| if(!text){toast('Empty proposal','bad');return;} if(!S.conversation) return; | |
| btn.disabled=true; const orig=btn.textContent; btn.textContent='Savingβ¦'; | |
| showStatus('Saving proposalβ¦'); | |
| S.lastAction=()=>handleSubmitProposal(btn); | |
| const res=await callAPI('propose',{conversation_id:S.conversation.id,answer_id:aid,text}); | |
| hideStatus(); btn.disabled=false; btn.textContent=orig; | |
| if(res.ok){S.conversation=res.conversation;save();renderConversation(S.currentQuestion,false);toast('Version proposed','good');} | |
| else toast(res.error||'Error','bad',S.lastAction); | |
| }); | |
| function handleWriteToggle() { | |
| const p=$('writePanel'),btn=$('writeAnswerBtn'); if(!p||!btn) return; | |
| const open=p.classList.toggle('open'); btn.setAttribute('aria-expanded',String(open)); | |
| if(open){const ta=$('writeTextarea');if(ta) setTimeout(()=>ta.focus(),100);} | |
| } | |
| function handleWriteCancel() { | |
| const p=$('writePanel'); if(p) p.classList.remove('open'); | |
| const btn=$('writeAnswerBtn'); if(btn){btn.setAttribute('aria-expanded','false');btn.focus();} | |
| } | |
| function handleWriteTab(mode) { | |
| const editTab=$('writeTabEdit'),previewTab=$('writeTabPreview'); | |
| const editorPane=$('writeEditorPane'),previewPane=$('writePreviewPane'); | |
| if(!editTab||!previewTab||!editorPane||!previewPane) return; | |
| if(mode==='edit'){ | |
| editTab.classList.add('active'); editTab.setAttribute('aria-selected','true'); | |
| previewTab.classList.remove('active'); previewTab.setAttribute('aria-selected','false'); | |
| editorPane.style.display=''; previewPane.style.display='none'; | |
| previewPane.classList.remove('active'); $('writeTextarea')?.focus(); | |
| } else { | |
| previewTab.classList.add('active'); previewTab.setAttribute('aria-selected','true'); | |
| editTab.classList.remove('active'); editTab.setAttribute('aria-selected','false'); | |
| editorPane.style.display='none'; previewPane.style.display=''; | |
| previewPane.classList.add('active'); | |
| const ta=$('writeTextarea'); | |
| previewPane.innerHTML = ta ? renderMarkdown(ta.value) : '<p style="color:var(--muted)">Nothing to preview.</p>'; | |
| bindCodeCopyButtons(previewPane); | |
| } | |
| } | |
| const handleWriteSubmit = debounceClick(async () => { | |
| const ta=$('writeTextarea'), text=ta?ta.value.trim():''; | |
| if(!text){toast('Empty answer','bad');return;} if(!S.conversation) return; | |
| const ws=$('writeSubmit'); | |
| if(ws){ws.disabled=true;ws.textContent='Savingβ¦';} showStatus('Saving answerβ¦'); | |
| S.lastAction=handleWriteSubmit; | |
| const res=await callAPI('answer',{conversation_id:S.conversation.id,text,question:S.currentQuestion}); | |
| hideStatus(); if(ws){ws.disabled=false;ws.textContent='Submit answer';} | |
| if(res.ok){ | |
| S.conversation=res.conversation; clearDraft(); save(); | |
| await renderConversation(S.currentQuestion,false); toast('Answer saved','good'); | |
| setTimeout(()=>{const el=$('bestAnswerText');if(el){el.setAttribute('tabindex','-1');el.focus();}},200); | |
| } else toast(res.error||'Error','bad',S.lastAction); | |
| }); | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| AUTOCOMPLETE | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| const debouncedAutocomplete = debounce(async q => { | |
| if(!q||q.length<4){closeAutocomplete();return;} | |
| const res = await callAPI('search',{query:q,limit:5}); | |
| if(!res.ok||!res.results?.length){closeAutocomplete();return;} | |
| showAutocomplete(res.results); | |
| }, 300); | |
| function showAutocomplete(results) { | |
| const dd=$('autocompleteDropdown'); if(!dd) return; | |
| dd.innerHTML = results.map((r,i)=> | |
| `<div class="autocomplete-item" role="option" tabindex="-1" data-ac-q="${esc(r.question)}" id="ac-item-${i}"> | |
| <span class="autocomplete-match">${esc(r.question)}</span> | |
| <span class="autocomplete-meta">${Number(r.answer_count||0)} answer${Number(r.answer_count||0)!==1?'s':''}</span> | |
| </div>`).join(''); | |
| dd.classList.add('open'); | |
| qsa('.autocomplete-item',dd).forEach(item=>{ | |
| item.addEventListener('click',()=>{ | |
| $('prompt').value=item.getAttribute('data-ac-q'); | |
| closeAutocomplete(); submitPrompt(); | |
| }); | |
| }); | |
| } | |
| function closeAutocomplete() { $('autocompleteDropdown')?.classList.remove('open'); } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| API | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function save() { if(S.conversation) localStorage.setItem('hi_last_cid',S.conversation.id); } | |
| const inflightRequests = new Set(); | |
| async function callAPI(action, payload={}) { | |
| const key = action+':'+JSON.stringify(payload); | |
| if ((action==='search'||action==='get_conversation') && inflightRequests.has(key)) | |
| return {ok:false,error:'Request in progress'}; | |
| inflightRequests.add(key); | |
| try { | |
| const resp = await fetch('/api',{ | |
| method:'POST', | |
| headers:{'Content-Type':'application/json','X-Client-Id':S.clientId}, | |
| body:JSON.stringify({action,client_id:S.clientId,...payload}), | |
| }); | |
| const data = await resp.json().catch(()=>null); | |
| if(!resp.ok) return data&&typeof data==='object' ? data : {ok:false,error:`Request failed (${resp.status})`}; | |
| return data||{ok:false,error:'Empty response from server'}; | |
| } catch(err) { return {ok:false,error:err?.message||'Network error'}; } | |
| finally { inflightRequests.delete(key); } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| ASK / SUBMIT | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function askQuestion(q) { | |
| showStatusWithEscalation(); showTyping(); | |
| S.loading=true; $('sendBtn').disabled=true; closeAutocomplete(); | |
| S.lastAction=()=>askQuestion(q); | |
| const res = await callAPI('ask',{question:q}); | |
| removeTyping(); hideStatus(); S.loading=false; $('sendBtn').disabled=false; | |
| if(!res.ok){toast(res.error||'Error','bad',S.lastAction);return;} | |
| S.conversation=res.conversation; S.currentQuestion=q; | |
| S.relatedAnswers=Array.isArray(res.related)?res.related:[]; | |
| save(); toast(res.matched?'β Existing answer found':'β New question created','good'); | |
| await renderConversation(q,true); | |
| } | |
| async function submitPrompt() { | |
| const p=$('prompt'), text=p.value.trim(); | |
| if(!text||S.loading) return; | |
| p.value=''; autoGrow(p); await askQuestion(text); | |
| } | |
| function autoGrow(el) { | |
| el.style.height='auto'; | |
| let h=Math.min(el.scrollHeight,180); if(h<40) h=40; | |
| requestAnimationFrame(()=>{el.style.height=h+'px';}); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| LOAD SAVED | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function loadSaved() { | |
| const id=localStorage.getItem('hi_last_cid'); if(!id) return; | |
| const tr=$('transcript'), wl=$('welcome'); | |
| wl.style.display='none'; | |
| tr.innerHTML=`<div class="skeleton-wrap"> | |
| <div class="skeleton skeleton-bubble"></div> | |
| <div class="skeleton skeleton-line long"></div> | |
| <div class="skeleton skeleton-line medium"></div> | |
| <div class="skeleton skeleton-line short"></div></div>`; | |
| showStatus('Loading conversationβ¦'); | |
| const res=await callAPI('get_conversation',{conversation_id:id}); | |
| hideStatus(); | |
| if(res.ok&&res.conversation){ | |
| S.conversation=res.conversation; S.currentQuestion=res.conversation.question||''; | |
| S.relatedAnswers=[]; renderConversation(S.currentQuestion,false); | |
| } else { tr.innerHTML=''; wl.style.display=''; updateWelcomeState(); localStorage.removeItem('hi_last_cid'); } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| NEW CHAT | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function newChat() { | |
| if(qsa('textarea').some(t=>t.value.trim())){ | |
| if(!await confirmModal('Start a new chat?','You have unsaved content. It will be lost.')) return; | |
| } | |
| S.conversation=null; S.currentQuestion=''; S.relatedAnswers=[]; S.atBottom=true; | |
| localStorage.removeItem('hi_last_cid'); | |
| $('transcript').innerHTML=''; $('welcome').style.display=''; | |
| updateWelcomeState(); setJumpLatest(false); $('prompt').value=''; | |
| autoGrow($('prompt')); history.replaceState({},'','/'); | |
| document.title=S.originalTitle; $('prompt').focus(); | |
| } | |
| function confirmModal(title, msg) { | |
| return new Promise(resolve => { | |
| $('confirmTitle').textContent=title; $('confirmMsg').textContent=msg; | |
| $('confirmBackdrop').classList.add('open'); $('confirmOk').focus(); | |
| function cleanup(r){$('confirmBackdrop').classList.remove('open');$('confirmOk').onclick=null;$('confirmCancel').onclick=null;resolve(r);} | |
| $('confirmOk').onclick=()=>cleanup(true); $('confirmCancel').onclick=()=>cleanup(false); | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| SETTINGS | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function initSettings() { | |
| const panel=$('settingsPanel'), btn=$('settingsBtn'), backdrop=$('settingsBackdrop'); | |
| function setOpen(open, focusEl=null) { | |
| panel.classList.toggle('open',open); backdrop.classList.toggle('visible',open); | |
| btn.setAttribute('aria-expanded',String(open)); panel.inert=!open; | |
| if(open){qs('.anim-option',panel)?.focus();} else if(focusEl) focusEl.focus(); | |
| } | |
| btn.onclick=()=>setOpen(!panel.classList.contains('open')); | |
| $('settingsClose').onclick=()=>setOpen(false,btn); | |
| backdrop.onclick=()=>setOpen(false,btn); | |
| document.addEventListener('keydown',e=>{if(e.key==='Escape'&&panel.classList.contains('open'))setOpen(false,btn);}); | |
| // Animation options | |
| const animOpts=qsa('.anim-option',$('animSegment')); | |
| function syncAnim(){ | |
| animOpts.forEach(o=>{const a=S.animMode===o.getAttribute('data-anim');o.classList.toggle('active',a);o.setAttribute('aria-checked',String(a));}); | |
| } | |
| animOpts.forEach(o=>{ | |
| o.addEventListener('click',()=>{S.animMode=o.getAttribute('data-anim');localStorage.setItem('hi_anim',S.animMode);syncAnim();}); | |
| o.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();o.click();}}); | |
| }); | |
| // Density options | |
| const densityOpts=qsa('.density-option',$('densitySegment')); | |
| function syncDensity(){ | |
| densityOpts.forEach(o=>{const a=S.density===o.getAttribute('data-density');o.classList.toggle('active',a);o.setAttribute('aria-checked',String(a));}); | |
| document.documentElement.setAttribute('data-density',S.density); | |
| } | |
| densityOpts.forEach(o=>{ | |
| o.addEventListener('click',()=>{S.density=o.getAttribute('data-density');localStorage.setItem('hi_density',S.density);syncDensity();}); | |
| o.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();o.click();}}); | |
| }); | |
| $('jumpLatest').onclick=()=>scrollBottom(true); | |
| $('lightbox').onclick=e=>{if(e.target===$('lightbox'))closeLightbox();}; | |
| $('lightboxClose').onclick=closeLightbox; | |
| panel.inert=true; syncAnim(); syncDensity(); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| COMMAND PALETTE | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| const COMMANDS = [ | |
| {icon:'β¦',label:'New chat',shortcut:'Ctrl+N',action:newChat}, | |
| {icon:'π',label:'Copy best answer',action:async()=>{ | |
| const el=$('bestAnswerText');if(!el){toast('No answer to copy','bad');return;} | |
| try{await navigator.clipboard.writeText(el.innerText);toast('Copied','good');}catch{toast('Could not copy','bad');} | |
| }}, | |
| {icon:'β',label:'Write an answer',action:()=>$('writeAnswerBtn')?.click()}, | |
| {icon:'π',label:'Copy page URL',action:async()=>{ | |
| try{await navigator.clipboard.writeText(location.href);toast('URL copied','good');}catch{toast('Could not copy','bad');} | |
| }}, | |
| {icon:'β',label:'Open settings',action:()=>$('settingsBtn').click()}, | |
| {icon:'β',label:'Jump to latest',action:()=>scrollBottom(true)}, | |
| ]; | |
| let cmdFocusIdx=-1; | |
| function openCommandPalette(){ | |
| $('cmdBackdrop').classList.add('open'); $('cmdInput').value=''; | |
| $('cmdInput').focus(); cmdFocusIdx=-1; renderCmdList(''); | |
| } | |
| function closeCommandPalette(){$('cmdBackdrop').classList.remove('open');$('prompt').focus();} | |
| function renderCmdList(query){ | |
| const list=$('cmdList'), q=query.toLowerCase(); | |
| const filtered=q?COMMANDS.filter(c=>c.label.toLowerCase().includes(q)):COMMANDS; | |
| if(!filtered.length){list.innerHTML='<div class="cmd-empty">No commands found.</div>';return;} | |
| list.innerHTML=filtered.map((c,i)=> | |
| `<div class="cmd-item" role="option" data-cmd-idx="${i}" tabindex="-1"> | |
| <span class="cmd-item-icon" aria-hidden="true">${c.icon}</span> | |
| <span class="cmd-item-label">${esc(c.label)}</span> | |
| ${c.shortcut?`<span class="cmd-item-shortcut">${esc(c.shortcut)}</span>`:''} | |
| </div>`).join(''); | |
| list._filtered=filtered; | |
| qsa('.cmd-item',list).forEach((item,i)=>{ | |
| item.addEventListener('click',()=>{closeCommandPalette();filtered[i].action();}); | |
| }); | |
| } | |
| function initCommandPalette(){ | |
| const backdrop=$('cmdBackdrop'), input=$('cmdInput'), list=$('cmdList'); | |
| backdrop.addEventListener('click',e=>{if(e.target===backdrop)closeCommandPalette();}); | |
| input.addEventListener('input',()=>{cmdFocusIdx=-1;renderCmdList(input.value);}); | |
| input.addEventListener('keydown',e=>{ | |
| const items=qsa('.cmd-item',list); | |
| if(e.key==='ArrowDown'){e.preventDefault();cmdFocusIdx=Math.min(cmdFocusIdx+1,items.length-1);items[cmdFocusIdx]?.focus();} | |
| else if(e.key==='ArrowUp'){e.preventDefault();cmdFocusIdx=Math.max(cmdFocusIdx-1,-1);cmdFocusIdx<0?input.focus():items[cmdFocusIdx]?.focus();} | |
| else if(e.key==='Escape') closeCommandPalette(); | |
| else if(e.key==='Enter'&&items.length) items[0]?.click(); | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| GLOBAL KEYBOARD | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function initKeyboard(){ | |
| document.addEventListener('keydown',e=>{ | |
| const ctrl=e.ctrlKey||e.metaKey; | |
| if(ctrl&&e.key==='k'){e.preventDefault();openCommandPalette();return;} | |
| if(ctrl&&e.key==='n'){e.preventDefault();newChat();return;} | |
| if(ctrl&&e.key==='Enter'&&e.target.tagName==='TEXTAREA'){ | |
| e.preventDefault(); | |
| if(e.target.id==='writeTextarea') handleWriteSubmit(); | |
| else{const p=e.target.closest('.propose-panel');if(p)qs('.propose-submit',p)?.click();} | |
| return; | |
| } | |
| }); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| SUGGESTION CHIPS + PULL TO REFRESH | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function initSuggestionChips(){ | |
| qsa('.suggestion-chip').forEach(chip=>{ | |
| chip.addEventListener('click',()=>{ | |
| const q=chip.getAttribute('data-q'); if(!q) return; | |
| $('prompt').value=q; autoGrow($('prompt')); submitPrompt(); | |
| }); | |
| }); | |
| } | |
| function initPullToRefresh(){ | |
| const chat=$('chat'); let pullStart=0; | |
| chat.addEventListener('touchstart',e=>{pullStart=chat.scrollTop===0?e.touches[0].clientY:0;},{passive:true}); | |
| chat.addEventListener('touchend',e=>{ | |
| if(!pullStart) return; | |
| if(e.changedTouches[0].clientY-pullStart>80&&S.conversation){ | |
| pullStart=0; showStatus('Refreshingβ¦'); | |
| callAPI('get_conversation',{conversation_id:S.conversation.id}).then(res=>{ | |
| hideStatus(); | |
| if(res.ok&&res.conversation){S.conversation=res.conversation;renderConversation(S.currentQuestion,false);toast('Refreshed','good');} | |
| }); | |
| } | |
| pullStart=0; | |
| },{passive:true}); | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββ | |
| INIT | |
| βββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function init(){ | |
| updateAppHeight(); | |
| window.addEventListener('resize',updateAppHeight); | |
| window.addEventListener('orientationchange',updateAppHeight); | |
| if(window.visualViewport){ | |
| window.visualViewport.addEventListener('resize',updateAppHeight); | |
| window.visualViewport.addEventListener('scroll',updateAppHeight); | |
| } | |
| if(window.matchMedia('(prefers-reduced-motion: reduce)').matches) S.animMode='none'; | |
| S.clientId=getClientId(); | |
| S.density=localStorage.getItem('hi_density')||'comfortable'; | |
| document.documentElement.setAttribute('data-density',S.density); | |
| const chat=$('chat'); | |
| chat.addEventListener('scroll',()=>{ | |
| S.atBottom=isNearBottom(); if(S.atBottom) setJumpLatest(false); updateScrollProgress(); | |
| },{passive:true}); | |
| $('composeForm').addEventListener('submit',e=>{e.preventDefault();submitPrompt();}); | |
| $('sendBtn').addEventListener('click',e=>{e.preventDefault();submitPrompt();}); | |
| $('newChatBtn').addEventListener('click',newChat); | |
| const prompt=$('prompt'); | |
| prompt.addEventListener('input',e=>{autoGrow(e.target);debouncedAutocomplete(e.target.value.trim());}); | |
| prompt.addEventListener('keydown',e=>{ | |
| if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();submitPrompt();} | |
| if(e.key==='Escape') closeAutocomplete(); | |
| if(e.key==='ArrowDown'){const f=qs('.autocomplete-item',$('autocompleteDropdown'));if(f){e.preventDefault();f.focus();}} | |
| }); | |
| document.addEventListener('click',e=>{if(!e.target.closest('.compose-inner'))closeAutocomplete();}); | |
| initSettings(); initCommandPalette(); initKeyboard(); | |
| initSuggestionChips(); initPullToRefresh(); | |
| const d=window.__HI_INIT__||{}; | |
| if(d.client_id) S.clientId=d.client_id; | |
| updateWelcomeState(); await loadSaved(); prompt.focus(); | |
| } | |
| init(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |