cybersecqwen-chat / index.html
athena129's picture
Chat UX overhaul: sticky auto-scroll, copy/regenerate/continue actions, finish-marker handling, one-shot retry, localStorage persistence
0fe5335 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>CyberSecQwen-4B</title>
<!--
CyberSecQwen-4B · single-file frontend
──────────────────────────────────────
Two views:
#/ Chat — vertically centered hero → docks composer after first message
#/about About — long-form scrollable page with sticky sub-nav
Backend hook: askModel(prompt) — swap its body for fetch('/infer', …).
Vanilla JS only. No build step.
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<!-- HF OAuth: client-side sign-in via @huggingface/hub. The access token
is passed to @gradio/client's Client.connect() so ZeroGPU attributes
each call to the visitor's HF account (free 3.5 min/day, Pro 25 min/day)
instead of the 2 min/day anonymous IP quota. -->
<script async src="https://unpkg.com/es-module-shims@1.7.0/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"@huggingface/hub": "https://cdn.jsdelivr.net/npm/@huggingface/hub@0.21.0/+esm"
}
}
</script>
<style>
/* ── DESIGN TOKENS ─────────────────────────────────────── */
:root{
--bg: #08080a;
--surface: #0e0e11;
--surface-2: #16161a;
--border: #232326;
--border-soft: #1a1a1d;
--fg: #fafafa;
--fg-2: #e4e4e7;
--fg-3: #a1a1aa;
--fg-4: #71717a;
--fg-5: #52525b;
--accent: #22d3ee;
--accent-soft: rgba(34,211,238,0.08);
--accent-border: rgba(34,211,238,0.28);
--accent-glow: rgba(34,211,238,0.18);
--font-sans: 'Geist', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--font-serif: 'Instrument Serif', ui-serif, Georgia, serif;
--ease-out: cubic-bezier(.22,.61,.36,1);
}
*,*::before,*::after{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
color:var(--fg-2);
background: var(--bg);
font-family:var(--font-sans);
font-size:14px;
line-height:1.5;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
overflow:hidden;
}
::selection{background:var(--accent-soft);color:#fff}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
/* Atmospheric background — two cyan radial meshes + grain */
.bg-mesh{
position:fixed;inset:0;pointer-events:none;z-index:0;
background:
radial-gradient(900px 540px at 92% -8%, rgba(34,211,238,.16), transparent 60%),
radial-gradient(620px 420px at 6% 108%, rgba(34,211,238,.10), transparent 60%);
}
.bg-grain{
position:fixed;inset:0;pointer-events:none;z-index:1;
opacity:.035;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
/* ── APP SHELL ─────────────────────────────────────────── */
.app{
position:relative;z-index:2;
display:grid;
grid-template-columns: 240px 1fr;
height:100vh;
}
/* Sidebar */
.sidebar{
border-right:1px solid var(--border-soft);
background: rgba(14,14,17,.6);
backdrop-filter: blur(8px);
display:flex;flex-direction:column;
padding:18px 14px;
min-height:0;
}
.brand{display:flex;align-items:center;gap:10px;padding:4px 6px 18px}
.brand-mark{
width:32px;height:32px;border-radius:8px;
border:1px solid var(--accent-border);
display:grid;place-items:center;
background:var(--accent-soft);
overflow:hidden;
}
.brand-mark img{
width:22px;height:22px;
/* recolor the multicolor Qwen logo to cyan to match strict palette */
filter: brightness(0) saturate(100%) invert(82%) sepia(34%) saturate(2576%) hue-rotate(140deg) brightness(101%) contrast(96%);
display:block;
}
.brand-name{font-weight:600;letter-spacing:-.005em;color:var(--fg);font-size:14px}
.brand-tag{font-family:var(--font-mono);font-size:10.5px;color:var(--fg-4);letter-spacing:.02em}
.nav{display:flex;flex-direction:column;gap:2px;margin-top:6px}
.nav a{
display:flex;align-items:center;gap:10px;
padding:8px 10px;border-radius:8px;color:var(--fg-3);font-size:13.5px;
transition:background .15s, color .15s;
}
.nav a:hover{color:var(--fg-2)}
.nav a.active{background:var(--surface-2);color:var(--fg)}
.nav a svg{width:15px;height:15px;flex:none;opacity:.85}
.side-foot{
margin-top:auto;display:flex;flex-direction:column;gap:4px;
padding-top:14px;border-top:1px solid var(--border-soft);
}
.side-foot a{
padding:6px 10px;border-radius:6px;color:var(--fg-4);font-size:12.5px;
display:flex;align-items:center;gap:8px;
}
.side-foot a:hover{color:var(--fg-2);background:var(--surface)}
.side-foot a svg{width:13px;height:13px;opacity:.8}
.status-pill{
margin-top:6px;align-self:flex-start;
padding:3px 9px;border:1px solid var(--border);border-radius:999px;
font-family:var(--font-mono);font-size:10.5px;color:var(--fg-4);
display:inline-flex;align-items:center;gap:6px;
}
.status-dot{width:5px;height:5px;border-radius:50%;background:var(--accent);box-shadow:0 0 6px var(--accent)}
/* Main — scroll container for the stacked sections (about → chat). */
.main{display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto;scroll-behavior:smooth;position:relative}
.topbar{
height:56px;flex:none;
padding:0 28px;border-bottom:1px solid var(--border-soft);
display:flex;align-items:center;justify-content:space-between;
background:rgba(8,8,10,.72);backdrop-filter:blur(10px);
position:sticky;top:0;z-index:10;
}
.topbar h1.section-title{
margin:0;font-size:13.5px;font-weight:500;color:var(--fg-2);letter-spacing:.005em;
}
.top-status{
font-family:var(--font-mono);font-size:11px;color:var(--fg-4);
padding:4px 10px;border:1px solid var(--border);border-radius:999px;
display:inline-flex;align-items:center;gap:7px;
}
.top-actions{display:flex;align-items:center;gap:10px}
.top-link{
font-family:var(--font-mono);font-size:11px;color:var(--accent);
padding:4px 10px;border:1px solid var(--accent);border-radius:999px;
text-decoration:none;letter-spacing:.005em;
transition:background .15s ease;
}
.top-link:hover{background:rgba(34,211,238,.08)}
/* Tab pages — only one visible at a time. Hash-routed via setTab(). */
.page-section{display:none;position:relative}
.page-section.is-active{display:block}
.page-section.chat-section.is-active{display:flex;flex-direction:column;min-height:calc(100vh - 56px)}
.page-section.chat-section.is-active > .chat-active{flex:1;display:flex;flex-direction:column}
.hidden{display:none !important}
/* Call-to-action block at end of About */
.cta-block{
margin:64px auto 0;max-width:560px;
padding:36px 32px;
border:1px solid var(--accent-border);border-radius:16px;
background:radial-gradient(120% 100% at 50% 0%, var(--accent-soft), transparent 70%);
text-align:center;
}
.cta-block h3{
margin:0 0 8px;
font-family:var(--font-serif);font-weight:400;
font-size:30px;line-height:1.15;letter-spacing:-.01em;color:var(--fg);
}
.cta-block h3 em{font-style:italic;color:var(--accent)}
.cta-block p{margin:0 0 22px;color:var(--fg-4);font-size:14.5px;max-width:42ch;margin-left:auto;margin-right:auto}
.cta-primary{
display:inline-flex;align-items:center;gap:9px;
padding:11px 22px;border-radius:10px;
background:var(--accent);color:#06181c;font-weight:600;font-size:14px;
transition:transform .2s var(--ease-out), filter .15s, box-shadow .2s var(--ease-out);
box-shadow: 0 0 0 1px var(--accent-border), 0 0 28px var(--accent-glow);
}
.cta-primary:hover{transform:translateY(-1px);filter:brightness(1.05)}
.cta-primary svg{transition:transform .2s var(--ease-out)}
.cta-primary:hover svg{transform:translateY(2px)}
/* Loading status line under typing indicator */
.status-text{
margin-top:6px;
font-family:var(--font-mono);font-size:11px;color:var(--fg-5);
letter-spacing:.02em;
}
.status-text.warn{color:var(--accent)}
/* ── HF SIGN-IN GATE (chat tab when no token) ─────────── */
.signin-gate{
display:flex;flex-direction:column;align-items:center;justify-content:center;
padding:96px 28px 80px;gap:18px;
}
.signin-gate.hidden{display:none}
.signin-card{
max-width:520px;width:100%;
background:linear-gradient(180deg, var(--surface) 0%, rgba(34,211,238,0.03) 100%);
border:1px solid var(--border);border-radius:18px;
padding:28px 28px 24px;
text-align:center;
box-shadow: 0 10px 40px rgba(0,0,0,0.35);
}
.signin-card h3{
margin:0 0 8px;
font-family:var(--font-serif);font-weight:400;
font-size:30px;line-height:1.1;letter-spacing:-.01em;color:var(--fg);
}
.signin-card h3 em{font-style:italic;color:var(--accent);font-family:var(--font-serif)}
.signin-card .lede{margin:0 0 20px;color:var(--fg-4);font-size:14px;line-height:1.55}
.signin-btn{
display:inline-flex;align-items:center;gap:10px;
padding:12px 22px;border-radius:11px;
background:#fafafa;color:#08080a;
font-weight:600;font-size:14px;letter-spacing:.01em;
border:1px solid rgba(255,255,255,0.05);
transition:transform .15s var(--ease-out), filter .15s var(--ease-out);
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
}
.signin-btn:hover{transform:translateY(-1px);filter:brightness(1.03)}
.signin-btn:active{transform:translateY(0)}
.signin-btn .hf-emoji{font-size:18px;line-height:1}
.signin-meta{
margin-top:14px;color:var(--fg-5);font-size:12px;font-family:var(--font-mono);
letter-spacing:.02em;
}
.signin-meta a{color:var(--accent);border-bottom:1px dotted var(--accent-border)}
/* Sidebar user pill (replaces status pill when signed in) */
.user-pill{
display:flex;align-items:center;gap:8px;
padding:7px 9px;
background:var(--accent-soft);border:1px solid var(--accent-border);
border-radius:9px;
color:var(--fg-2);font-size:12px;
font-family:var(--font-mono);
}
.user-pill .who{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--accent)}
.user-pill button{color:var(--fg-4);font-size:11px;letter-spacing:.05em;text-transform:uppercase}
.user-pill button:hover{color:var(--fg-2)}
/* ── CHAT SECTION ──────────────────────────────────────── */
.chat-empty{
display:flex;flex-direction:column;align-items:center;justify-content:center;
padding:64px 28px 80px;
gap:24px;
}
.hero-icon{
color:var(--accent);
width:36px;height:36px;
display:grid;place-items:center;
animation: fadeUp .6s var(--ease-out) both;
}
.hero-h1{
margin:0;
font-family:var(--font-serif);font-weight:400;
font-size:clamp(40px, 6vw, 56px);
line-height:1.05;letter-spacing:-.01em;color:var(--fg);
text-align:center;
animation: fadeUp .6s var(--ease-out) both;animation-delay:.08s;
}
.hero-h1 em{font-style:italic;color:var(--accent);font-family:var(--font-serif)}
.hero-sub{
margin:0;font-size:15px;color:var(--fg-4);max-width:54ch;text-align:center;
animation: fadeUp .6s var(--ease-out) both;animation-delay:.16s;
}
.composer{
width:100%;max-width:640px;
background:var(--surface);
border:1px solid var(--border);border-radius:14px;
padding:14px 14px 10px;
display:flex;flex-direction:column;gap:10px;
transition:border-color .2s var(--ease-out), box-shadow .2s var(--ease-out);
animation: fadeUp .6s var(--ease-out) both;animation-delay:.24s;
}
.composer:focus-within{
border-color:var(--accent-border);
box-shadow: 0 0 0 4px var(--accent-soft), 0 0 32px var(--accent-glow);
}
.composer textarea{
width:100%;min-height:24px;max-height:240px;
background:transparent;color:var(--fg);
border:0;outline:none;resize:none;
font:inherit;font-size:15px;line-height:1.5;
}
.composer textarea::placeholder{color:var(--fg-5)}
.composer-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
.send-hint{font-family:var(--font-mono);font-size:10.5px;color:var(--fg-5)}
.send-btn{
display:inline-flex;align-items:center;gap:7px;
padding:8px 14px;border-radius:8px;
background:var(--accent);color:#06181c;font-weight:600;font-size:13px;
transition:transform .2s var(--ease-out), filter .15s, box-shadow .2s var(--ease-out);
box-shadow: 0 0 0 1px var(--accent-border), 0 0 24px var(--accent-glow);
}
.send-btn:hover:not(:disabled){transform:translateY(-1px);filter:brightness(1.05)}
.send-btn:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}
.chips{
display:flex;flex-wrap:wrap;gap:8px;justify-content:center;
max-width:640px;
animation: fadeUp .6s var(--ease-out) both;animation-delay:.32s;
}
.chip{
padding:6px 12px;border:1px solid var(--border);border-radius:999px;
font-size:12.5px;color:var(--fg-3);
display:inline-flex;align-items:center;gap:7px;
transition:transform .2s var(--ease-out), border-color .15s, color .15s;
}
.chip .kind{font-family:var(--font-mono);font-size:10.5px;color:var(--accent)}
.chip:hover{transform:translateY(-1px);border-color:var(--accent-border);color:var(--fg-2)}
/* Active conversation layout — stacked, dock is sticky-bottom of viewport */
.chat-active{
display:flex;flex-direction:column;
width:100%;
}
.thread{
padding: 28px 28px 32px;
display:flex;flex-direction:column;align-items:center;
}
.thread-inner{width:100%;max-width:720px;display:flex;flex-direction:column;gap:18px}
.msg{display:flex;flex-direction:column;gap:6px;max-width:100%}
.msg .who{
font-family:var(--font-mono);font-size:10.5px;color:var(--fg-5);
letter-spacing:.04em;text-transform:uppercase;
}
.msg.user .bubble{
align-self:flex-end;max-width:80%;
background:var(--surface-2);border:1px solid var(--border);
color:var(--fg);
padding:10px 14px;
border-radius:12px 12px 4px 12px;
white-space:pre-wrap;
font-size:14.5px;
}
.msg.user .who{align-self:flex-end}
.msg.bot .body{
color:var(--fg-2);font-size:14.5px;line-height:1.65;
white-space:pre-wrap;
}
.msg.bot .body strong{color:var(--fg)}
.msg.bot .body code{
font-family:var(--font-mono);font-size:13px;
background:var(--surface);border:1px solid var(--border-soft);
padding:1px 6px;border-radius:5px;color:var(--fg-2);
}
/* Per-message action row (copy / regenerate / continue) under bot bubbles. */
.msg-actions{
display:flex;gap:6px;margin-top:6px;align-items:center;flex-wrap:wrap;
opacity:.55;transition:opacity .15s var(--ease-out);
}
.msg-actions:hover, .msg.bot:hover .msg-actions, .msg-actions:focus-within{opacity:1}
.msg-action{
display:inline-flex;align-items:center;gap:5px;
padding:4px 8px;border-radius:6px;
border:1px solid var(--border-soft);background:transparent;
color:var(--fg-4);font-size:11.5px;font-family:var(--font-mono);
letter-spacing:.02em;
transition:color .15s, border-color .15s, background .15s;
cursor:pointer;
}
.msg-action:hover{color:var(--fg-2);border-color:var(--border);background:var(--surface)}
.msg-action svg{width:11px;height:11px;opacity:.85}
.msg-action.is-flash{color:var(--accent);border-color:var(--accent-border)}
/* Continue button — accent-styled to draw the eye, since truncation is the only
case where the user actually has to click to keep going. */
.msg-action.continue{
color:var(--accent);border:1px solid var(--accent-border);
background:var(--accent-soft);
padding:5px 10px;font-weight:500;
}
.msg-action.continue:hover{background:rgba(34,211,238,0.14);color:var(--fg)}
.truncation-note{
margin-top:8px;font-family:var(--font-mono);font-size:10.5px;
color:var(--fg-5);letter-spacing:.02em;
}
.truncation-note .dot{color:var(--accent)}
.typing{display:inline-flex;gap:5px;padding:8px 0}
.typing i{
width:5px;height:5px;border-radius:50%;background:var(--fg-5);
animation:blink 1.2s infinite ease-in-out;
}
.typing i:nth-child(2){animation-delay:.15s}
.typing i:nth-child(3){animation-delay:.30s}
@keyframes blink{0%,80%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
/* Docked composer — sticky to viewport bottom while scrolling chat */
.dock{
flex:none;
padding:14px 28px 22px;
background:linear-gradient(to bottom, transparent, rgba(8,8,10,.85) 22%, var(--bg) 60%);
display:flex;justify-content:center;
position:sticky;bottom:0;z-index:4;
pointer-events:none;
}
.dock > .dock-inner{
pointer-events:auto;width:100%;max-width:720px;
display:flex;align-items:flex-end;gap:10px;
}
.dock .composer{margin:0;flex:1;animation:none}
.new-chat{
flex:none;height:46px;padding:0 14px;
border:1px solid var(--border);border-radius:10px;
color:var(--fg-3);font-size:13px;
display:inline-flex;align-items:center;gap:7px;
transition:border-color .15s, color .15s;
}
.new-chat:hover{color:var(--fg);border-color:var(--accent-border)}
/* ── ABOUT VIEW ────────────────────────────────────────── */
.about{padding:0}
.about-inner{max-width:880px;margin:0 auto;padding:48px 32px 120px}
.subnav{
position:sticky;top:56px;z-index:5;
background:rgba(8,8,10,.78);backdrop-filter:blur(10px);
border-bottom:1px solid var(--border-soft);
padding:12px 32px;
display:flex;justify-content:center;
}
.subnav-inner{display:flex;gap:6px;flex-wrap:wrap;max-width:880px;width:100%}
.subnav a{
font-family:var(--font-mono);font-size:11.5px;color:var(--fg-4);
padding:5px 10px;border-radius:6px;letter-spacing:.02em;
transition:color .15s, background .15s;
}
.subnav a:hover{color:var(--fg-2);background:var(--surface)}
.subnav a.is-current{color:var(--accent);background:var(--accent-soft)}
.about h2{
font-family:var(--font-serif);font-weight:400;
font-size:36px;line-height:1.1;letter-spacing:-.01em;color:var(--fg);
margin:0 0 14px;
}
.about h3{
font-size:13px;font-weight:600;color:var(--fg-4);
text-transform:uppercase;letter-spacing:.12em;
margin:0 0 18px;
}
.about p{color:var(--fg-3);font-size:15px;line-height:1.7;margin:0 0 14px;max-width:64ch}
.about p strong{color:var(--fg-2)}
.about section{padding:48px 0;border-top:1px solid var(--border-soft)}
.about section:first-of-type{border-top:0;padding-top:24px}
.tagline{
font-family:var(--font-serif);font-style:italic;font-weight:400;
font-size:28px;line-height:1.25;color:var(--fg);
border-left:1px solid var(--accent-border);
padding:6px 0 6px 18px;margin:18px 0 0;max-width:42ch;
}
.tagline em{font-style:italic;color:var(--accent)}
/* Research walkthrough video card */
.video-card{
border:1px solid var(--border);border-radius:14px;
background:var(--surface);
padding:6px 6px 14px;
margin:18px 0 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.32);
overflow:hidden;
}
.video-card video{
width:100%;height:auto;display:block;
border-radius:9px;
background:#000;
}
.video-card .vc-meta{
display:flex;align-items:center;justify-content:space-between;gap:12px;
padding:10px 10px 4px;
font-family:var(--font-mono);font-size:11.5px;color:var(--fg-4);
letter-spacing:.02em;
}
.video-card .vc-meta .vc-tag{
color:var(--accent);
background:var(--accent-soft);
border:1px solid var(--accent-border);
padding:3px 8px;border-radius:6px;
font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;
}
.stat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:22px 0 28px}
.stat{
border:1px solid var(--border);border-radius:12px;
background:var(--surface);padding:18px;
display:flex;flex-direction:column;gap:6px;
}
.stat .lbl{font-family:var(--font-mono);font-size:10.5px;color:var(--fg-5);text-transform:uppercase;letter-spacing:.08em}
.stat .num{
font-family:var(--font-serif);font-size:38px;line-height:1;color:var(--fg);
font-feature-settings:"tnum";letter-spacing:-.01em;
}
.stat.accent .num{color:var(--accent)}
.stat .sub{font-family:var(--font-mono);font-size:11.5px;color:var(--fg-4)}
table.cmp{
width:100%;border-collapse:collapse;font-size:13px;
border:1px solid var(--border);border-radius:12px;overflow:hidden;
background:var(--surface);
}
table.cmp th, table.cmp td{
padding:10px 14px;border-bottom:1px solid var(--border-soft);text-align:left;
font-variant-numeric:tabular-nums;
}
table.cmp th{
font-family:var(--font-mono);font-size:10.5px;letter-spacing:.06em;
text-transform:uppercase;color:var(--fg-5);font-weight:500;
background:var(--surface-2);
}
table.cmp td{color:var(--fg-3)}
table.cmp td.model{color:var(--fg-2);font-weight:500}
table.cmp tr.us td{background:var(--accent-soft);color:var(--fg)}
table.cmp tr.us td.model{color:var(--accent);font-weight:600}
table.cmp tr:last-child td{border-bottom:0}
.lift-callout{
margin-top:22px;padding:16px 18px;border:1px solid var(--accent-border);
border-radius:12px;background:var(--accent-soft);
display:flex;align-items:center;gap:18px;flex-wrap:wrap;
}
.lift-callout .lift{
font-family:var(--font-serif);font-size:32px;line-height:1;color:var(--accent);
}
.lift-callout .desc{color:var(--fg-2);font-size:14px}
.lift-callout .desc small{display:block;color:var(--fg-4);font-family:var(--font-mono);font-size:11px;margin-top:4px}
.card-row{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0 26px}
.card{
border:1px solid var(--border);border-radius:12px;background:var(--surface);
padding:18px;display:flex;flex-direction:column;gap:8px;
}
.card .pill{
font-family:var(--font-mono);font-size:10.5px;color:var(--accent);
text-transform:uppercase;letter-spacing:.08em;
}
.card h4{margin:0;color:var(--fg);font-size:15px;font-weight:600;letter-spacing:-.005em}
.card p{margin:0;color:var(--fg-3);font-size:13.5px;line-height:1.6}
.spec{
display:grid;grid-template-columns:auto 1fr;gap:0;
border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface);
margin:14px 0;
}
.spec dt, .spec dd{padding:9px 14px;border-bottom:1px solid var(--border-soft)}
.spec dt{
font-family:var(--font-mono);font-size:11.5px;color:var(--fg-4);
background:var(--surface-2);border-right:1px solid var(--border-soft);
text-transform:uppercase;letter-spacing:.04em;
}
.spec dd{margin:0;color:var(--fg-2);font-size:13.5px;font-family:var(--font-mono)}
.spec dt:nth-last-of-type(1), .spec dd:nth-last-of-type(1){border-bottom:0}
pre.code{
margin:14px 0;background:var(--surface);border:1px solid var(--border-soft);
border-radius:10px;padding:14px 16px;
font-family:var(--font-mono);font-size:12.5px;color:var(--fg-2);
overflow:auto;line-height:1.55;
}
pre.code .c{color:var(--fg-5)}
pre.code .k{color:var(--accent)}
ul.bullets{padding-left:18px;color:var(--fg-3);font-size:14.5px;line-height:1.7;margin:0 0 14px}
ul.bullets li{margin:4px 0}
ul.bullets li strong{color:var(--fg-2)}
.twocol{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:18px 0}
/* Scrollbar */
*::-webkit-scrollbar{width:10px;height:10px}
*::-webkit-scrollbar-track{background:transparent}
*::-webkit-scrollbar-thumb{background:#1d1d20;border-radius:99px;border:2px solid transparent;background-clip:content-box}
*::-webkit-scrollbar-thumb:hover{background:#2a2a2e;background-clip:content-box;border:2px solid transparent}
@keyframes fadeUp{
from{opacity:0;transform:translateY(8px)}
to {opacity:1;transform:translateY(0)}
}
/* Responsive */
@media (max-width: 860px){
.app{grid-template-columns: 1fr}
.sidebar{display:none}
.stat-grid, .card-row, .twocol{grid-template-columns:1fr}
}
</style>
</head>
<body>
<div class="bg-mesh" aria-hidden="true"></div>
<div class="bg-grain" aria-hidden="true"></div>
<div class="app">
<!-- ── SIDEBAR ─────────────────────────────────────────── -->
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"><img src="data:image/png;base64,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" alt="Qwen" /></div>
<div>
<div class="brand-name">CyberSecQwen</div>
<div class="brand-tag">4B · CTI</div>
</div>
</div>
<nav class="nav" id="nav">
<a href="#about" data-section="about" class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v5h1"/></svg>
About
</a>
<a href="#chat" data-section="chat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Try the Chat
</a>
</nav>
<div class="side-foot">
<a href="https://huggingface.co/athena129/CyberSecQwen-4B" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
Model card
</a>
<a href="https://github.com/GPT-64590/CyberSecQwen-4B" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-4 1.5-4-2-6-2"/><path d="M15 22v-4a3 3 0 0 0-1-2.3c3 0 6-2 6-5.5a4 4 0 0 0-1-2.8 4 4 0 0 0 0-2.8s-1 0-3 1.3a10 10 0 0 0-5 0C7.9 2.6 7 2.6 7 2.6a4 4 0 0 0 0 2.8 4 4 0 0 0-1 2.8C6 11.7 9 13.7 12 13.7a3 3 0 0 0-1 2.3v4"/></svg>
GitHub
</a>
<a href="https://lablab.ai/ai-hackathons/amd-developer/athena19/cybersecqwen-4b-cti-specialist-fine-tuned-on-amd" target="_blank" rel="noopener" title="AMD Developer Hackathon submission on lablab.ai">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg>
Hackathon entry
</a>
<span class="status-pill" id="statusPill"><span class="status-dot"></span> Trained on MI300X</span>
<div class="user-pill" id="userPill" style="display:none">
<span class="who" id="userPillWho">@user</span>
<button id="signoutBtn" type="button" title="Sign out">Out</button>
</div>
</div>
</aside>
<!-- ── MAIN ────────────────────────────────────────────── -->
<main class="main" id="main">
<header class="topbar">
<h1 class="section-title" id="topTitle">CyberSecQwen-4B</h1>
<div class="top-actions">
<a class="top-link" href="https://huggingface.co/athena129/CyberSecQwen-4B" target="_blank" rel="noopener" title="Open the Hugging Face model card">Model card ↗</a>
<span class="top-status">4B · CTI specialist · temp 0.3</span>
</div>
</header>
<!-- ── ABOUT (default tab) ──────────────────────────── -->
<section class="page-section about is-active" id="about">
<div class="about">
<div class="subnav">
<div class="subnav-inner" id="subnav">
<a href="#walkthrough">Walkthrough</a>
<a href="#performance">Performance</a>
<a href="#recipe">Recipe</a>
<a href="#hardware">Hardware</a>
<a href="#limitations">Limitations</a>
<a href="#companion">Companion</a>
<a href="#citation">Citation</a>
</div>
</div>
<div class="about-inner">
<section id="overview">
<h3>Overview</h3>
<h2>CyberSecQwen-4B</h2>
<p>A Qwen3-4B base fine-tuned for cyber threat intelligence — CWE classification, CVE-to-CWE mapping, and code-pattern reasoning. Trained on AMD MI300X with a recipe designed to preserve instruction-tuned behavior while shifting the model's distribution onto the CTI domain.</p>
<p class="tagline">Beating an 8B Cisco specialist <em>at half the size.</em></p>
</section>
<section id="walkthrough">
<h3>Walkthrough</h3>
<h2>Research, in 5 minutes</h2>
<p>A walkthrough of the training methodology, AMD MI300X workflow, and the benchmark results — context for the numbers below.</p>
<div class="video-card">
<video controls preload="metadata" playsinline>
<source src="research.mp4" type="video/mp4" />
Your browser doesn't support inline video — <a href="research.mp4">download the file</a>.
</video>
<div class="vc-meta">
<span><span class="vc-tag">Video</span> AMD MI300X · Qwen3-4B fine-tune</span>
<span>5:33 · 720p</span>
</div>
</div>
</section>
<section id="performance">
<h3>Performance</h3>
<h2>CTI-Bench, n=5, temp 0.3</h2>
<p>Evaluated on the public CTI-Bench multi-trial protocol. Scores below are mean ± 1σ over five runs.</p>
<div class="stat-grid">
<div class="stat">
<span class="lbl">CTI-RCM</span>
<span class="num">0.6664</span>
<span class="sub">± 0.0023 · CWE root-cause mapping</span>
</div>
<div class="stat accent">
<span class="lbl">CTI-MCQ</span>
<span class="num">0.5868</span>
<span class="sub">± 0.0029 · +8.7 pp vs Cisco-Instruct-8B</span>
</div>
<div class="stat">
<span class="lbl">Param ratio</span>
<span class="num"></span>
<span class="sub">smaller than Cisco-Foundation-Sec-8B</span>
</div>
</div>
<table class="cmp">
<thead>
<tr><th>Model</th><th>Params</th><th>CTI-RCM</th><th>CTI-MCQ</th></tr>
</thead>
<tbody>
<tr><td class="model">Foundation-Sec-8B (base, 5-shot)</td><td>8B</td><td>0.7450</td><td>0.6552</td></tr>
<tr><td class="model">Cisco-Foundation-Sec-Instruct-8B</td><td>8B</td><td>0.6850</td><td>0.4996</td></tr>
<tr><td class="model">CyberPal-2.0-20B</td><td>20B</td><td>0.7280</td><td>0.7384</td></tr>
<tr class="us"><td class="model">CyberSecQwen-4B</td><td>4B</td><td>0.6664</td><td>0.5868</td></tr>
<tr><td class="model">Gemma4Defense-2B (companion)</td><td>2B</td><td>0.6754</td><td>0.6042</td></tr>
<tr><td class="model">Qwen3-4B-Instruct-2507 (raw)</td><td>4B</td><td>0.5190</td><td>0.4732</td></tr>
<tr><td class="model">Qwen3-4B-Base (5-shot)</td><td>4B</td><td>0.5170</td><td>0.6672</td></tr>
<tr><td class="model">Gemma-4-E2B-it (raw)</td><td>2B</td><td>0.5800</td><td>0.5780</td></tr>
</tbody>
</table>
<div class="lift-callout">
<span class="lift">+15.1 pp</span>
<div class="desc">RCM lift over our Qwen3-4B base
<small>and +12.0 pp on MCQ — same architecture, recipe alone</small>
</div>
</div>
</section>
<section id="recipe">
<h3>Recipe</h3>
<h2>What changed, in three ideas.</h2>
<div class="card-row">
<div class="card">
<span class="pill">01 · Decontamination</span>
<h4>Strict eval-set scrub</h4>
<p>Training corpus is filtered against CTI-Bench prompts and near-duplicates before any update sees a gradient. No leakage, no shortcutting.</p>
</div>
<div class="card">
<span class="pill">02 · IT-base preservation</span>
<h4>Keep the instruction-tuned voice</h4>
<p>Loss is balanced so the model adopts CTI domain knowledge without losing Qwen3's general instruction-following. Tone, formatting, and refusal behavior remain intact.</p>
</div>
<div class="card">
<span class="pill">03 · Recipe portability</span>
<h4>Same recipe, different base</h4>
<p>The same procedure applied to Gemma-4-E2B-it produces Gemma4Defense-2B with comparable lift — evidence the gains come from the recipe, not the base.</p>
</div>
</div>
<h3>Corpus · ~14,776 supervised records</h3>
<ul class="bullets">
<li><strong>rcm-2021 (decontaminated)</strong> — ~6,776 CVE → CWE classification examples from MITRE/NVD 2021 cohort, with all CTI-Bench overlap items removed.</li>
<li><strong>cve_cti synthetic Q&amp;A</strong> — ~8,000 defensive-analyst-style Q&amp;A pairs grounded in CVE descriptions.</li>
</ul>
<p>An earlier internal CPT corpus had <strong>72.3% test-set overlap</strong> with CTI-Bench. The released model trains exclusively on the 2021 cohort with overlap removed — released numbers are post-fix.</p>
<h3>Hyperparameters</h3>
<dl class="spec">
<dt>Base</dt> <dd>Qwen3-4B-Instruct-2507</dd>
<dt>Adapter</dt> <dd>LoRA r=64, α=64, dropout=0.05</dd>
<dt>Targets</dt> <dd>q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj</dd>
<dt>Optimizer</dt> <dd>AdamW · cosine schedule · warmup_ratio=0.05 · weight_decay=0.01</dd>
<dt>LR · peak</dt> <dd>5e-5</dd>
<dt>Batch · effective</dt><dd>2 per device · grad_accum 8 · effective batch 16</dd>
<dt>Schedule</dt> <dd>10 epochs · max_seq_len 4096</dd>
<dt>Precision</dt> <dd>bfloat16 throughout</dd>
<dt>Attention</dt> <dd>flash_attention_2 (FA2)</dd>
<dt>Wall · MI300X</dt> <dd>173 min · 1,290 steps · 7.85 s/step</dd>
<dt>Eval protocol</dt> <dd>Foundation-Sec arXiv:2504.21039 §B.3-B.4 · n=5 · temp 0.3</dd>
</dl>
</section>
<section id="hardware">
<h3>Hardware</h3>
<h2>Trained on AMD MI300X.</h2>
<p>The recipe was developed on a single-node MI300X stack. Optimizations are aimed at ROCm; the recipe ports cleanly to other datacenter-class GPUs (40&nbsp;GB+ VRAM) with the FA2 caveat noted below.</p>
<h3>Stack</h3>
<dl class="spec">
<dt>Accelerator</dt> <dd>AMD Instinct MI300X · 192 GB HBM3 (gfx942)</dd>
<dt>Runtime</dt> <dd>ROCm 7.0</dd>
<dt>Docker image</dt> <dd>vllm/vllm-openai-rocm:latest</dd>
<dt>PyTorch</dt> <dd>2.6.0 (ROCm)</dd>
<dt>flash-attn</dt> <dd>2.8.3 (preinstalled in vLLM ROCm image)</dd>
<dt>vLLM</dt> <dd>0.10.1</dd>
</dl>
<h3>FA2 viability per model family</h3>
<p style="font-size:13.5px;color:var(--fg-4);margin:0 0 10px;">FA2 via the ROCm/flash-attention Composable-Kernels backend is supported on MI300X but bounded at <strong style="color:var(--fg-2)">head_dim ≤ 256</strong> by the LDS shared-memory budget.</p>
<table class="cmp">
<thead><tr><th>Family</th><th>head_dim</th><th>FA2</th><th>Note</th></tr></thead>
<tbody>
<tr class="us"><td class="model">Qwen3 (this work)</td><td>128</td><td>✓ enabled</td><td>fits LDS budget — ~1.6× faster than sdpa</td></tr>
<tr><td class="model">Llama-3 / Mistral</td><td>128</td><td>✓ works</td><td>same head_dim class</td></tr>
<tr><td class="model">Gemma-2</td><td>256</td><td>✓ boundary</td><td>at LDS limit, viable</td></tr>
<tr><td class="model">Gemma-4 (global layers)</td><td>512</td><td>✗ disabled</td><td>exceeds LDS — fallback to sdpa</td></tr>
</tbody>
</table>
<h3>Optimizations</h3>
<ul class="bullets">
<li><strong>FA2 enabled in training</strong> — Qwen3-4B head_dim=128 fits the gfx942 LDS budget; ~7.85 s/step at LoRA r=64 / max_seq_len=4096 — <strong>~1.6× faster</strong> than the same recipe on Gemma-4 (sdpa fallback).</li>
<li><strong>TRITON_ATTN backend for vLLM inference</strong> — recommended on MI300X for Qwen3-class models.</li>
<li><strong>bf16 throughout</strong> — native MI300X precision; no mixed-precision dance.</li>
<li><strong>AITER kernels for matmul</strong><code>VLLM_ROCM_USE_AITER=1</code> + <code>TORCH_BLAS_PREFER_HIPBLASLT=1</code>. Note: this works for Qwen3 dense — does NOT work for gpt-oss MoE (AITER=0 required there).</li>
<li><strong>Prefix caching</strong> — vLLM <code>--enable-prefix-caching</code> for shared system-prompt batches.</li>
<li><strong>HF Transfer for push/pull</strong><code>HF_HUB_ENABLE_HF_TRANSFER=1</code> saturates ~240 MB/s; 8 GB merged model uploads in ~36 s.</li>
</ul>
<h3>ROCm environment (verified-working)</h3>
<pre class="code"><span class="c"># env exported inside the vLLM ROCm Docker container</span>
<span class="k">export</span> VLLM_ROCM_USE_AITER=1
<span class="k">export</span> TORCH_BLAS_PREFER_HIPBLASLT=1
<span class="k">export</span> HF_HUB_DISABLE_XET=1
<span class="k">export</span> PYTORCH_ROCM_ARCH=<span class="c">'gfx90a;gfx942;gfx950'</span>
<span class="k">export</span> AITER_ROCM_ARCH=<span class="c">'gfx942;gfx950'</span>
<span class="k">export</span> HIP_FORCE_DEV_KERNARG=1</pre>
<p><strong>Portability:</strong> the recipe runs on other 40&nbsp;GB+ datacenter GPUs with the AMD-specific env vars dropped (no-ops elsewhere). FA2 via <code>pip install flash-attn --no-build-isolation</code>. Same VRAM minimums: 24&nbsp;GB+ for training, 12&nbsp;GB+ for inference.</p>
</section>
<section id="limitations">
<h3>Limitations</h3>
<h2>What it's good at, and where it isn't.</h2>
<h3>In-distribution · verified strong</h3>
<div class="card-row">
<div class="card">
<span class="pill">Strong</span>
<h4>CWE classification</h4>
<p>Mapping a described weakness or code pattern to a CWE id with a short rationale. Calibrated on CTI-Bench RCM.</p>
</div>
<div class="card">
<span class="pill">Strong</span>
<h4>CVE-to-CWE on trained CVEs</h4>
<p>Returns the underlying CWE for CVEs whose descriptions are in-distribution. Confidence drops for CVEs disclosed after the corpus cutoff.</p>
</div>
<div class="card">
<span class="pill">Strong</span>
<h4>Code-pattern reasoning</h4>
<p>Identifying the weakness class behind a small snippet (string-format SQL, unchecked path joins, inline HTML rendering, etc.).</p>
</div>
</div>
<h3>Out-of-distribution · known failure modes</h3>
<div class="card-row">
<div class="card">
<span class="pill">Weak</span>
<h4>MITRE ATT&amp;CK technique IDs</h4>
<p>Wrong T-numbers (returned T1543/003 — Windows Service — for scheduled-task persistence, vs the correct T1053.005). Wrong technique names (called LSASS dumping "Extract Web Credentials"; correct is T1003.001 OS Credential Dumping: LSASS Memory).</p>
</div>
<div class="card">
<span class="pill">Weak</span>
<h4>CVE implementation specifics</h4>
<p>Fabricates implementation details. Cited a non-existent <code>pgp</code> binary path when asked about CVE-2024-3400 (PAN-OS GlobalProtect — actual root cause is session-ID handling). Top-level CWE call usually correct; deep mechanics often invented.</p>
</div>
<div class="card">
<span class="pill">Weak</span>
<h4>Tool categorization</h4>
<p>Misclassifies offensive tooling. Listed Mimikatz as a "ransomware loader" — Mimikatz is a credential-dumping utility, not a ransomware loader.</p>
</div>
</div>
<h3>Validated empirically · we tested 14, kept 7</h3>
<p>We ran 14 candidate demo prompts through the deployed model and graded each output for factual accuracy. <strong>7 passed</strong>, <strong>7 were cut</strong>: Log4Shell vs Spring4Shell exploit-primitive comparison; CVE-2024-3400 explanation; LSASS ATT&amp;CK mapping; schtasks ATT&amp;CK mapping; Dockerfile <code>curl|bash</code> review; ransomware detection signals; Python dynamic-code-execution CWE. Most cuts were OOD hallucinations: invented technique numbers, fabricated CVE specifics, mislabeled mitigations.</p>
<h3>Out-of-scope use</h3>
<ul class="bullets">
<li>Generating exploit code, weaponized PoC, or attacker tradecraft.</li>
<li>Critical security decisions without qualified human review.</li>
<li>Legal, medical, or other regulated-advice contexts.</li>
<li>Tasks outside cybersecurity (general chat, code generation, summarization).</li>
<li>Violation of laws (CFAA, GDPR, etc.).</li>
</ul>
<h3>Recommendations</h3>
<ul class="bullets">
<li><strong>Pair with retrieval.</strong> Ground CVE / advisory queries against an authoritative source before quoting specifics.</li>
<li><strong>Sample at low temperature.</strong> CWE selection benefits from determinism (temp 0.2–0.3).</li>
<li><strong>Treat output as a triage hint,</strong> not a verdict — keep an analyst in the loop.</li>
</ul>
</section>
<section id="companion">
<h3>Companion</h3>
<h2>Gemma4Defense-2B</h2>
<p>The same recipe applied to Gemma-4-E2B-it produces a 2B companion that scores RCM <strong>0.6754 ± 0.0035</strong> and MCQ <strong>0.6042 ± 0.0090</strong> — slightly higher MCQ than CyberSecQwen-4B at half the parameters again. Use the 2B when memory is tight; use the 4B when you want stronger general instruction-following and longer-form rationales.</p>
</section>
<section id="citation">
<h3>Citation</h3>
<h2>Cite &amp; license.</h2>
<pre class="code"><span class="c">% bibtex</span>
@misc{cybersecqwen2026,
title = {CyberSecQwen-4B: A Compact CTI Specialist Fine-Tuned from
Qwen3-4B-Instruct-2507 on AMD MI300X},
author = {Mulia, Samuel},
year = {2026},
publisher = {Hugging Face},
url = {https://huggingface.co/athena129/CyberSecQwen-4B}
}</pre>
<p><strong>License:</strong> Apache 2.0, end-to-end — weights, training code, and the synthetic CVE/CTI Q&amp;A corpus. The decontaminated 2021 CVE→CWE mappings derive from public MITRE/NVD records. Evaluation protocol: <a href="https://arxiv.org/abs/2504.21039" target="_blank" style="color:var(--accent)">Foundation-Sec-8B (arXiv:2504.21039)</a>. Benchmark: <a href="https://github.com/xashru/cti-bench" target="_blank" style="color:var(--accent)">CTI-Bench</a>.</p>
</section>
<!-- CTA — links into the chat section below -->
<div class="cta-block">
<h3>Ready to <em>try it?</em></h3>
<p>Test CyberSecQwen-4B on your own prompts below. ZeroGPU; cold start ~10–20 s, then warm.</p>
<a href="#chat" class="cta-primary" id="ctaTry">
Try the chat
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</a>
</div>
</div><!-- /about-inner -->
</div><!-- /about wrapper -->
</section><!-- /#about -->
<!-- ── CHAT (below) ─────────────────────────────────── -->
<section class="page-section chat-section" id="chat">
<!-- Sign-in gate: shown when visitor has no HF OAuth token. -->
<div class="signin-gate hidden" id="signinGate">
<div class="signin-card">
<h3>Sign in to <em>start chatting.</em></h3>
<p class="lede">
The model runs on Hugging Face ZeroGPU — a free, shared GPU pool.
Sign in with your HF account so each call uses your own quota
<span style="color:var(--fg-3)">(free tier: 3.5 min/day · Pro: 25 min/day)</span>
instead of the 2 min/day anonymous IP cap.
</p>
<button class="signin-btn" id="signinBtn" type="button">
<span class="hf-emoji">🤗</span>
<span>Sign in with Hugging Face</span>
</button>
<div class="signin-meta">
No data stored — only your access token, kept locally.
<br/>Don't have an account? <a href="https://huggingface.co/join" target="_blank" rel="noopener">Create one (free)</a>.
</div>
</div>
</div>
<div class="chat-empty" id="chatEmpty">
<div class="hero-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg>
</div>
<h2 class="hero-h1">Map a vulnerability<br/><em>to a CWE.</em></h2>
<p class="hero-sub">Paste a snippet, log line, or CVE. The model returns a Common Weakness Enumeration with a one-paragraph rationale.</p>
<form class="composer" id="composerEmpty">
<textarea rows="1" id="taEmpty" placeholder="Describe the issue, paste code, or drop a CVE id…"></textarea>
<div class="composer-row">
<span class="send-hint">↵ to send · ⇧↵ for newline</span>
<button class="send-btn" type="submit" id="sendEmpty" disabled>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l14-7-7 14-2-5-5-2z"/></svg>
Send
</button>
</div>
</form>
<div class="chips" id="chips">
<button class="chip" data-prompt="What CWE applies to using strcpy without bounds checking? Give a one-paragraph rationale.">
<span class="kind">CWE</span> Buffer overflow
</button>
<button class="chip" data-prompt="What CWE underlies the MOVEit Transfer SQL injection chain (CVE-2023-34362)?">
<span class="kind">CVE</span> MOVEit
</button>
<button class="chip" data-prompt="What CWE applies to: query = 'SELECT * FROM users WHERE id=' + user_id">
<span class="kind">CWE</span> SQL injection
</button>
<button class="chip" data-prompt="Log line: GET /static/../../../etc/passwd HTTP/1.1 200 — what weakness, and how to fix?">
<span class="kind">LOG</span> path traversal
</button>
</div>
</div>
<div class="chat-active hidden" id="chatActive">
<div class="thread"><div class="thread-inner" id="threadInner"></div></div>
<div class="dock">
<div class="dock-inner">
<form class="composer" id="composerDock">
<textarea rows="1" id="taDock" placeholder="Continue the conversation…"></textarea>
<div class="composer-row">
<span class="send-hint">↵ to send · ⇧↵ for newline</span>
<button class="send-btn" type="submit" id="sendDock" disabled>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l14-7-7 14-2-5-5-2z"/></svg>
Send
</button>
</div>
</form>
<button class="new-chat" id="newChat" type="button" title="Start a new chat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
New chat
</button>
</div>
</div>
</div>
</section><!-- /#chat -->
</main>
</div>
<script type="module">
/* ─── HF OAUTH (client-side) ──────────────────────────────────
Visitors call our ZeroGPU backend via @gradio/client. Without a
token, ZeroGPU treats every call as anonymous (2 min/day per IP)
and the demo runs out almost immediately. Solution: sign the user
in with their own HF account via the OAuth app provisioned by
`hf_oauth: true` in this Space's README, then forward the access
token to Client.connect() so quota attributes to them.
*/
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "@huggingface/hub";
let oauthResult = null;
try {
const stored = localStorage.getItem("hf_oauth");
if (stored) oauthResult = JSON.parse(stored);
} catch { oauthResult = null; }
// Handle the redirect-back from HF if it just happened.
const fromRedirect = await oauthHandleRedirectIfPresent();
if (fromRedirect) {
oauthResult = fromRedirect;
localStorage.setItem("hf_oauth", JSON.stringify(oauthResult));
// Clean the OAuth query params out of the URL.
const cleanHash = location.hash || "#chat";
history.replaceState(null, "", location.pathname + cleanHash);
}
function getOAuthToken(){
return oauthResult?.accessToken || null;
}
function getUsername(){
return oauthResult?.userInfo?.preferredUsername || oauthResult?.userInfo?.name || "";
}
async function startSignIn(){
// OAUTH_SCOPES is injected by HF when hf_oauth: true is set.
const scopes = (window.huggingface?.variables?.OAUTH_SCOPES) || "openid profile";
window.location.href = (await oauthLoginUrl({ scopes })) + "&prompt=consent";
}
function signOut(){
oauthResult = null;
localStorage.removeItem("hf_oauth");
// Drop the cached gradio client so the next call re-connects without the token.
_client = null;
applyAuthUI();
}
/* ─── BACKEND HOOK ────────────────────────────────────────────
Calls `athena129/cybersecqwen-demo` via @gradio/client. Backend
signature: chat(message: str, history_json: str) -> str (streaming).
When signed in, we pass `hf_token: oauthResult.accessToken` so
ZeroGPU bills the visitor's account, not the anonymous IP pool.
*/
const BACKEND_SPACE = "athena129/cybersecqwen-demo";
let _client = null;
let _clientToken = null; // remember which token built the cached client
async function getClient(){
const tok = getOAuthToken();
if (_client && _clientToken === tok) return _client;
const { Client } = await import("https://esm.sh/@gradio/client@2.2.0");
const opts = { events: ["data", "status"] };
if (tok) opts.hf_token = tok;
_client = await Client.connect(BACKEND_SPACE, opts);
_clientToken = tok;
return _client;
}
// Backend appends "\n<!--finish:length-->" or "\n<!--finish:stop-->" to the
// final cumulative yield. Strip + capture the reason so the UI can offer a
// Continue affordance on length-truncation. Match end-of-string only: a partial
// trailing prefix like "\n<!--finish:" is harmless during streaming.
const FINISH_MARKER_RE = /\n?<!--finish:(\w+)-->\s*$/;
function stripFinishMarker(text){
if (!text) return { text: "", finishReason: null };
const m = text.match(FINISH_MARKER_RE);
if (m) return { text: text.slice(0, text.length - m[0].length), finishReason: m[1] };
return { text, finishReason: null };
}
async function askModel(prompt, history, onChunk, onStatus){
const client = await getClient();
const job = client.submit("/chat", [prompt, JSON.stringify(history || [])]);
let lastContent = "";
let lastFinishReason = null;
let errorPayload = null;
// Inactivity watchdog: if no event arrives for 90 s, abort the iteration
// and surface a typed timeout error. Without this, a dropped SSE stream
// (network blip, missing process_completed) leaves the for-await hanging
// forever — UI gets stuck on typing dots, next sends pile onto stale state.
const INACTIVITY_MS = 90_000;
let watchdog = null;
let timedOut = false;
const arm = () => {
if (watchdog) clearTimeout(watchdog);
watchdog = setTimeout(() => {
timedOut = true;
try { job.cancel?.(); } catch {}
}, INACTIVITY_MS);
};
arm();
try {
for await (const msg of job){
arm();
if (timedOut) break;
if (msg.type === "data" && msg.data && Array.isArray(msg.data)){
const raw = msg.data[0];
if (typeof raw === "string" && raw !== lastContent){
const { text: cleaned, finishReason } = stripFinishMarker(raw);
if (finishReason) lastFinishReason = finishReason;
if (cleaned !== lastContent){
lastContent = cleaned;
if (onChunk) onChunk(lastContent);
}
}
} else if (msg.type === "status"){
if (msg.stage === "error"){
errorPayload = { title: msg.title || "", message: msg.message || "" };
}
if (onStatus) onStatus(msg);
// @gradio/client@2.2.0 emits {type:"status", stage:"complete"} as the
// final event after the last data chunk. Older 1.x didn't reliably
// close the AsyncIterator on completion — the explicit break + cancel
// ensures the for-await exits, _inflight resets, and the next turn's
// Send button can re-enable on user input.
if (msg.stage === "complete" || msg.stage === "error") {
try { job.cancel?.(); } catch {}
break;
}
}
}
} finally {
if (watchdog) clearTimeout(watchdog);
}
if (timedOut){
const err = new Error("The streaming connection went quiet for 90 s — likely a backend hang, dropped SSE, or ZeroGPU duration cap reached.");
err.title = "Stream timeout";
err.partial = lastContent;
throw err;
}
if (errorPayload){
const err = new Error(errorPayload.message || errorPayload.title || "Backend returned an error.");
err.title = errorPayload.title;
err.detail = errorPayload.message;
err.partial = lastContent;
throw err;
}
return {
answer: lastContent || "The model returned no output. Try again.",
finishReason: lastFinishReason,
};
}
/* Map a backend error to a useful user-facing message. */
function explainError(e){
const msg = (e && (e.message || String(e))) || "";
const title = (e && e.title) || "";
const lower = (title + " " + msg).toLowerCase();
if (lower.includes("zerogpu quota") || lower.includes("quota exceeded")){
if (!getOAuthToken()){
return (
"Anonymous quota exhausted (2 minutes per day per IP). " +
"Sign in with Hugging Face for your own quota — free tier 3.5 min/day, Pro 25 min/day."
);
}
return (
"Your HF quota is exhausted for today. Pro accounts get 25 min/day; " +
"free accounts get 3.5 min/day. The quota resets 24 h after first use.\n\n" + msg
);
}
if (lower.includes("cold") || lower.includes("starting") || lower.includes("loading")){
return "ZeroGPU is cold-starting (~10–20 seconds after idle). Try again in a moment.";
}
return "The backend isn't reachable right now. " + msg;
}
/* ─── AUTH UI CONTROLLER ─────────────────────────────────────
Toggles sign-in gate on chat tab + sidebar user pill based on
whether we have a cached OAuth result. Called once at boot, and
again on sign-in / sign-out. */
function applyAuthUI(){
const signed = !!getOAuthToken();
const gate = document.getElementById('signinGate');
const empty = document.getElementById('chatEmpty');
const active = document.getElementById('chatActive');
const statusPill = document.getElementById('statusPill');
const userPill = document.getElementById('userPill');
const userPillWho = document.getElementById('userPillWho');
if (signed){
gate.classList.add('hidden');
// Restore chat-empty unless a conversation has already started.
if (convo.length === 0){
empty.classList.remove('hidden');
active.classList.add('hidden');
}
if (statusPill) statusPill.style.display = 'none';
if (userPill){
userPill.style.display = 'flex';
userPillWho.textContent = '@' + (getUsername() || 'user');
}
} else {
// Hide chat surfaces; show gate.
empty.classList.add('hidden');
active.classList.add('hidden');
gate.classList.remove('hidden');
if (statusPill) statusPill.style.display = '';
if (userPill) userPill.style.display = 'none';
}
}
/* ─── DOM REFS ────────────────────────────────────────────── */
const main = document.getElementById('main');
const navLinks = document.querySelectorAll('#nav a');
const aboutSection = document.getElementById('about');
const chatSection = document.getElementById('chat');
const chatEmpty = document.getElementById('chatEmpty');
const chatActive = document.getElementById('chatActive');
const threadInner = document.getElementById('threadInner');
let convo = []; // [{role:'user'|'bot', text, pending?, statusText?, error?, finishReason?}]
/* ─── CONVO PERSISTENCE ────────────────────────────────────
Keep the conversation across page reloads (and across HF Space rebuilds
on the static-frontend host). Stored under a versioned key so a future
schema change can bump the version and cleanly drop stale state.
*/
const CONVO_STORAGE_KEY = 'cybersecqwen_convo_v1';
function saveConvo(){
try {
// Don't persist live `pending` flags — those are mid-stream UI state and
// would render as stuck typing-dots after a reload.
const stable = convo.map(m => {
if (m.pending) return null;
const { pending, statusText, ...keep } = m;
void pending; void statusText;
return keep;
}).filter(Boolean);
// Orphan-user trim: if the final saved entry is a user turn (its bot reply
// was pending and got filtered out), drop it. Reloading mid-stream
// shouldn't leave a hanging user prompt with no follow-up.
while (stable.length && stable[stable.length - 1].role === 'user'){
stable.pop();
}
localStorage.setItem(CONVO_STORAGE_KEY, JSON.stringify(stable));
} catch { /* quota exceeded or private mode — silently ignore */ }
}
function loadConvo(){
try {
const raw = localStorage.getItem(CONVO_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
// Final safety: trim any trailing pending/error entries (shouldn't be
// saved, but a mid-write reload could land here).
const out = [...parsed];
while (out.length && (out[out.length - 1].pending || out[out.length - 1].error)){
out.pop();
// If the now-last entry is a user turn with no bot reply, drop it too.
if (out.length && out[out.length - 1].role === 'user') out.pop();
}
return out;
} catch { return []; }
}
function clearConvoStorage(){
try { localStorage.removeItem(CONVO_STORAGE_KEY); } catch {}
}
/* ─── DOM HELPERS (no innerHTML, all textContent / appendChild) */
function el(tag, opts){
const e = document.createElement(tag);
if (opts){
if (opts.cls) e.className = opts.cls;
if (opts.text != null) e.textContent = opts.text;
}
return e;
}
/* Safe markdown — escape implicit, only **bold** and `code` are recognized.
IMPORTANT: streaming partial yields can land mid-`**` or mid-`` ` `` pair
(e.g. cumulative text "... **The" before the closing `**` arrives). The
unmatched-opener cases below are critical: without them the plain-run
inner loop breaks immediately on the same `**`, the slice is empty, `i`
doesn't advance, and the outer while loops forever — freezing the tab
with "Page Unresponsive" once a streaming chunk happens to end mid-pair. */
function renderMdToFragment(s){
const frag = document.createDocumentFragment();
let i = 0;
while (i < s.length){
const startI = i; // safety: guarantee forward progress every iteration
// **bold**
if (s[i] === '*' && s[i+1] === '*'){
const end = s.indexOf('**', i + 2);
if (end !== -1){
frag.appendChild(el('strong', { text: s.slice(i + 2, end) }));
i = end + 2;
continue;
}
// Unmatched opening (mid-stream partial) — render as plain text and advance.
frag.appendChild(document.createTextNode('**'));
i += 2;
continue;
}
// `code`
if (s[i] === '`'){
const end = s.indexOf('`', i + 1);
if (end !== -1){
frag.appendChild(el('code', { text: s.slice(i + 1, end) }));
i = end + 1;
continue;
}
// Unmatched opening — render as plain text and advance.
frag.appendChild(document.createTextNode('`'));
i += 1;
continue;
}
// Plain run — find next special char
let next = i;
while (next < s.length){
if (s[next] === '`') break;
if (s[next] === '*' && s[next + 1] === '*') break;
next++;
}
frag.appendChild(document.createTextNode(s.slice(i, next)));
i = next;
// Defense in depth: never let the outer loop spin without progress.
if (i === startI) i++;
}
return frag;
}
/* ─── COMPOSER BINDING ────────────────────────────────────── */
// Module-level flag: true while a sendMessage call is mid-stream. Used by all
// composers to disable submit so a second message can't race a stale job.
let _inflight = false;
const _composerUpdaters = []; // each composer registers its disabled-state updater
function refreshComposers(){ _composerUpdaters.forEach(fn => fn()); }
function bindComposer(form, ta, btn, onSend){
const update = () => { btn.disabled = _inflight || ta.value.trim().length === 0; };
_composerUpdaters.push(update);
ta.addEventListener('input', () => {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 240) + 'px';
update();
});
ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); if (!btn.disabled) form.requestSubmit(); }
});
form.addEventListener('submit', e => { e.preventDefault(); const v = ta.value.trim(); if (!v || _inflight) return; onSend(v); });
update();
}
bindComposer(
document.getElementById('composerEmpty'),
document.getElementById('taEmpty'),
document.getElementById('sendEmpty'),
sendMessage,
);
bindComposer(
document.getElementById('composerDock'),
document.getElementById('taDock'),
document.getElementById('sendDock'),
sendMessage,
);
document.querySelectorAll('#chips .chip').forEach(c => {
c.addEventListener('click', () => {
const ta = document.getElementById('taEmpty');
ta.value = c.dataset.prompt;
ta.dispatchEvent(new Event('input'));
ta.focus();
});
});
document.getElementById('newChat').addEventListener('click', () => {
convo = [];
clearConvoStorage();
showEmptyState();
chatSection.scrollIntoView({ behavior:'smooth', block:'start' });
});
/* ─── VIEW STATE TOGGLES ──────────────────────────────────── */
function showEmptyState(){
chatActive.classList.add('hidden');
chatEmpty.classList.remove('hidden');
while (threadInner.firstChild) threadInner.removeChild(threadInner.firstChild);
setTimeout(() => document.getElementById('taEmpty')?.focus(), 30);
}
function showActiveState(){
chatEmpty.classList.add('hidden');
chatActive.classList.remove('hidden');
}
/* ─── MESSAGE RENDERING (DOM API only) ────────────────────── */
// Tiny SVG factory — keeps icon markup local to buildMsg without innerHTML.
function svgIcon(d, opts){
const NS = 'http://www.w3.org/2000/svg';
const s = document.createElementNS(NS, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('fill', 'none');
s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', (opts && opts.sw) || '1.8');
s.setAttribute('stroke-linecap', 'round');
s.setAttribute('stroke-linejoin', 'round');
const p = document.createElementNS(NS, 'path');
p.setAttribute('d', d);
s.appendChild(p);
return s;
}
function makeActionBtn(action, label, iconPath, extraCls){
const b = el('button', { cls: 'msg-action' + (extraCls ? ' ' + extraCls : '') });
b.type = 'button';
b.dataset.action = action;
b.appendChild(svgIcon(iconPath));
b.appendChild(el('span', { text: label }));
return b;
}
function buildMsg(m){
const wrap = el('div', { cls: 'msg ' + (m.role === 'user' ? 'user' : 'bot') });
wrap.appendChild(el('span', { cls: 'who', text: m.role === 'user' ? 'You' : 'CyberSecQwen' }));
if (m.role === 'user'){
wrap.appendChild(el('div', { cls: 'bubble', text: m.text }));
return wrap;
}
// bot
const body = el('div', { cls: 'body' });
if (m.pending){
const typing = el('span', { cls: 'typing' });
typing.appendChild(el('i'));
typing.appendChild(el('i'));
typing.appendChild(el('i'));
body.appendChild(typing);
body.appendChild(el('div', { cls: 'status-text', text: m.statusText || 'Connecting…' }));
} else if (m.error){
body.appendChild(el('div', { cls: 'status-text warn', text: m.text }));
} else {
body.appendChild(renderMdToFragment(m.text));
// Actions row — copy/regenerate on every completed bot turn; continue
// only on length-truncation. Regenerate is wired only to the last bot
// turn (idx === convo.length - 1) to avoid splitting timelines mid-thread.
const actions = el('div', { cls: 'msg-actions' });
actions.appendChild(makeActionBtn(
'copy', 'Copy',
'M9 9h10v12H9zM5 5h10v2M5 5v10h2',
));
const isLastBot = (m === convo[convo.length - 1]);
if (isLastBot){
actions.appendChild(makeActionBtn(
'regenerate', 'Regenerate',
'M3 12a9 9 0 0 1 15.5-6.3L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.5 6.3L3 16M3 21v-5h5',
));
}
if (m.finishReason === 'length' && isLastBot){
actions.appendChild(makeActionBtn(
'continue', 'Continue',
'M5 12h14M13 6l6 6-6 6',
'continue',
));
const note = el('div', { cls: 'truncation-note' });
note.appendChild(el('span', { cls: 'dot', text: '● ' }));
note.appendChild(document.createTextNode('Response was capped at the token limit. Click Continue to extend.'));
body.appendChild(note);
}
body.appendChild(actions);
}
wrap.appendChild(body);
return wrap;
}
/* ─── STICKY-BOTTOM SCROLL ─────────────────────────────────
Auto-scroll the main viewport on stream chunks ONLY when the user is
already near the bottom. If they've scrolled up to read earlier content,
respect their position — don't yank them back. Threshold is forgiving
(96 px) so a pixel-perfect pin isn't required.
*/
const PIN_THRESHOLD_PX = 96;
function isPinnedToBottom(){
return (main.scrollHeight - main.scrollTop - main.clientHeight) <= PIN_THRESHOLD_PX;
}
function scrollToBottom(){
main.scrollTo({ top: main.scrollHeight });
}
function replaceLastMsg(){
const wasPinned = isPinnedToBottom();
const last = threadInner.lastElementChild;
if (last) last.replaceWith(buildMsg(convo[convo.length - 1]));
if (wasPinned) scrollToBottom();
}
function appendToThread(m){
const wasPinned = isPinnedToBottom();
threadInner.appendChild(buildMsg(m));
// New message appends always pull viewport down — user's intent is to see
// their own send + the incoming reply. After streaming starts, the
// sticky-bottom check in replaceLastMsg respects further user scroll.
scrollToBottom();
void wasPinned; // explicit no-op: capture for symmetry / future use
}
/* ─── SEND ─────────────────────────────────────────────────── */
// Quota / cold-start errors aren't transient — retrying immediately just
// burns more visitor quota for the same failure. Network/timeout/empty-
// response errors might recover on a fresh connection. Heuristic for
// "worth retrying once silently".
function isTransientError(e){
const lower = ((e && (e.title || '')) + ' ' + (e && (e.message || ''))).toLowerCase();
if (lower.includes('quota')) return false;
if (lower.includes('cold') || lower.includes('starting') || lower.includes('loading')) return false;
if (lower.includes('sign in')) return false;
return true;
}
async function sendMessage(text){
if (_inflight) return; // hard guard against double-submit races
_inflight = true;
refreshComposers();
const wasEmpty = convo.length === 0;
if (wasEmpty){ showActiveState(); }
convo.push({ role:'user', text });
convo.push({ role:'bot', text:'', pending:true, statusText:'Connecting to backend…' });
appendToThread(convo[convo.length - 2]);
appendToThread(convo[convo.length - 1]);
saveConvo();
// Reset both composers immediately for responsiveness
for (const id of ['taEmpty', 'taDock']){
const ta = document.getElementById(id);
if (ta){ ta.value = ''; ta.style.height='auto'; ta.dispatchEvent(new Event('input')); }
}
// Build history from prior turns (exclude the just-added user+pending pair)
const hist = convo.slice(0, -2)
.filter(m => !m.pending && !m.error)
.map(m => ({ role: m.role === 'bot' ? 'assistant' : 'user', content: m.text }));
// Progressive timer-based status copy if no/few status updates arrive
const t0 = Date.now();
let statusTimer = setInterval(() => {
const last = convo[convo.length - 1];
if (!last.pending) return;
const elapsed = (Date.now() - t0) / 1000;
let copy = last.statusText;
if (elapsed > 5 && elapsed <= 12) copy = 'Waking ZeroGPU (cold start ~10–20 s)…';
else if (elapsed > 12 && elapsed <= 25) copy = 'Cold start in progress — almost there…';
else if (elapsed > 25) copy = 'Still warming up — ZeroGPU shared pool can be slow.';
if (copy !== last.statusText){
last.statusText = copy;
replaceLastMsg();
}
}, 1500);
const onChunk = (partial) => {
if (statusTimer){ clearInterval(statusTimer); statusTimer = null; }
convo[convo.length - 1] = { role:'bot', text: partial };
replaceLastMsg();
};
const onStatus = (s) => {
const last = convo[convo.length - 1];
if (!last.pending) return;
let copy = null;
if (s.stage === 'pending') copy = 'Queued…';
else if (s.stage === 'processing') copy = 'Generating…';
else if (s.stage === 'iterating') copy = 'Generating…';
if (copy && copy !== last.statusText){
last.statusText = copy;
replaceLastMsg();
}
};
// One-shot retry: if the first attempt fails with no chunks received and the
// error is transient (timeout, dropped SSE, empty response — NOT quota or
// cold-start), silently rebuild the gradio client and retry once. Caps wait
// at one extra round-trip; user-visible latency goes from "instant fail" to
// "~7-15s of seamless retry that usually succeeds".
const runOnce = async (isRetry) => {
if (isRetry){
_client = null;
_clientToken = null;
// Reset the pending bot-turn copy so the typing dots make sense again.
convo[convo.length - 1] = { role:'bot', text:'', pending:true, statusText:'Reconnecting…' };
replaceLastMsg();
}
return await askModel(text, hist, onChunk, onStatus);
};
let result = null;
let firstError = null;
try{
result = await runOnce(false);
} catch (e){
firstError = e;
const noPartial = !(e && typeof e.partial === 'string' && e.partial.length > 0);
if (noPartial && isTransientError(e)){
try {
result = await runOnce(true);
firstError = null;
} catch (e2){
firstError = e2;
}
}
}
if (result){
convo[convo.length - 1] = {
role:'bot',
text: result.answer,
finishReason: result.finishReason || null,
};
} else {
const e = firstError;
const errMsg = explainError(e);
const partial = (e && typeof e.partial === 'string') ? e.partial : '';
convo[convo.length - 1] = {
role:'bot',
error: true,
text: partial ? `${partial}\n\n— ${errMsg}` : errMsg,
};
// Drop the cached gradio Client so the next send rebuilds a fresh
// SSE/queue connection. If the previous one got into a bad state
// (timeout, dropped stream, server error), reusing it would chain
// the failure into every subsequent message.
_client = null;
_clientToken = null;
}
if (statusTimer){ clearInterval(statusTimer); statusTimer = null; }
_inflight = false;
refreshComposers();
replaceLastMsg();
saveConvo();
setTimeout(() => document.getElementById('taDock')?.focus(), 30);
}
/* ─── REGENERATE / CONTINUE ────────────────────────────────
Both wired only to the LAST bot turn. Regenerate replays the previous
user message; Continue appends a synthetic "Continue" user turn so the
model picks up from the truncated response in its own history.
*/
async function regenerateLast(){
if (_inflight) return;
if (convo.length < 2) return;
// Expect [..., user, bot] tail; pop the bot and re-run the user turn.
const last = convo[convo.length - 1];
if (last.role !== 'bot' || last.pending) return;
const userTurn = convo[convo.length - 2];
if (!userTurn || userTurn.role !== 'user') return;
// Drop the bot turn (and its DOM node) before sendMessage re-pushes
// user+pending. SendMessage rebuilds history from convo.slice(0, -2)
// so we also need to drop the user turn it'll re-add — equivalent to
// popping both and letting sendMessage append them fresh.
convo.pop(); // bot
convo.pop(); // user
// Remove the trailing two DOM nodes (user bubble + bot bubble) to match.
if (threadInner.lastElementChild) threadInner.removeChild(threadInner.lastElementChild);
if (threadInner.lastElementChild) threadInner.removeChild(threadInner.lastElementChild);
saveConvo();
await sendMessage(userTurn.text);
}
async function continueTruncated(){
if (_inflight) return;
if (convo.length === 0) return;
const last = convo[convo.length - 1];
if (last.role !== 'bot' || last.pending || last.finishReason !== 'length') return;
// sendMessage's history slice is convo.slice(0,-2) — i.e. it drops the
// last two before sending. We want the truncated bot turn to be IN the
// history the backend sees. So push a "Continue" user turn and let
// sendMessage's flow handle the rest; the truncated bot turn ends up
// at index N-2 (now in history) and the new user "Continue" + pending
// bot are the last two.
await sendMessage('Continue.');
}
async function copyText(text){
try {
await navigator.clipboard.writeText(text || '');
return true;
} catch {
// Older browsers / non-secure contexts: fallback via temp textarea.
try {
const ta = document.createElement('textarea');
ta.value = text || '';
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
return true;
} catch { return false; }
}
}
// Event delegation on threadInner — single listener handles all action clicks
// regardless of how many bot turns are rendered or replaced mid-stream.
threadInner.addEventListener('click', async (e) => {
const btn = e.target.closest('.msg-action');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'regenerate'){
await regenerateLast();
} else if (action === 'continue'){
await continueTruncated();
} else if (action === 'copy'){
// Map the click back to a convo index via DOM position, then copy the
// raw model text from convo[idx].text. Doing it from DOM innerText would
// pick up the action-button labels and the truncation note.
const msgEl = btn.closest('.msg');
const idx = msgEl ? Array.prototype.indexOf.call(threadInner.children, msgEl) : -1;
const m = idx >= 0 ? convo[idx] : null;
const text = (m && m.text) || '';
if (await copyText(text)){
btn.classList.add('is-flash');
const span = btn.querySelector('span');
const orig = span ? span.textContent : 'Copy';
if (span) span.textContent = 'Copied';
setTimeout(() => {
btn.classList.remove('is-flash');
if (span) span.textContent = orig;
}, 900);
}
}
});
/* ─── TAB SWITCHING (sidebar nav + CTA) ───────────────────── */
function setTab(name){
if (name !== 'about' && name !== 'chat') name = 'about';
aboutSection.classList.toggle('is-active', name === 'about');
chatSection.classList.toggle('is-active', name === 'chat');
navLinks.forEach(a => a.classList.toggle('active', a.dataset.section === name));
// Reset main scroll so the tab opens at its top
main.scrollTo({ top: 0 });
// Update hash without firing hashchange
const target = '#' + name;
if (location.hash !== target){
history.replaceState(null, '', target);
}
// Refresh auth UI when entering chat — the gate vs composer toggle
// depends on whether the visitor is signed in.
if (name === 'chat'){
applyAuthUI();
if (getOAuthToken() && convo.length === 0){
setTimeout(() => document.getElementById('taEmpty')?.focus(), 30);
}
}
}
navLinks.forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
setTab(a.dataset.section);
});
});
document.getElementById('ctaTry').addEventListener('click', e => {
e.preventDefault();
setTab('chat');
});
window.addEventListener('hashchange', () => {
setTab(location.hash === '#chat' ? 'chat' : 'about');
});
/* ─── ABOUT SUB-NAV: smooth-scroll + scroll-spy within the about tab ─── */
const subnavLinks = document.querySelectorAll('#subnav a');
const aboutSubsections = document.querySelectorAll('#about .about-inner > section');
subnavLinks.forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const id = a.getAttribute('href').slice(1);
const target = document.getElementById(id);
if (target) main.scrollTo({ top: target.offsetTop - 110, behavior:'smooth' });
});
});
function setCurrentSubnav(){
if (!aboutSection.classList.contains('is-active')) return;
const top = main.scrollTop + 140;
let current = aboutSubsections[0]?.id;
aboutSubsections.forEach(s => { if (s.offsetTop <= top) current = s.id; });
subnavLinks.forEach(a => a.classList.toggle('is-current', a.getAttribute('href') === '#' + current));
}
main.addEventListener('scroll', setCurrentSubnav, { passive:true });
/* ─── AUTH BUTTON BINDINGS ────────────────────────────────── */
document.getElementById('signinBtn')?.addEventListener('click', startSignIn);
document.getElementById('signoutBtn')?.addEventListener('click', signOut);
/* ─── RESTORE CONVO FROM PREVIOUS SESSION ─────────────────
If the visitor had an in-progress conversation before reload, rehydrate
the thread. Only render if the current view enters chat mode — about-tab
visitors don't need to see chat DOM until they navigate.
*/
function rehydrateConvoIfAny(){
const restored = loadConvo();
if (!restored.length) return;
convo = restored;
// Render every persisted message into the thread, then ensure chat-active
// is shown. applyAuthUI handles the empty-vs-active toggle for signed-in
// users; for signed-out users the gate stays up and the thread sits hidden
// until they sign in.
while (threadInner.firstChild) threadInner.removeChild(threadInner.firstChild);
for (const m of convo) threadInner.appendChild(buildMsg(m));
if (getOAuthToken()){
chatEmpty.classList.add('hidden');
chatActive.classList.remove('hidden');
}
// Snap to bottom of thread when rehydrated.
setTimeout(scrollToBottom, 0);
}
/* ─── BOOT — read initial hash, default to about ──────────── */
rehydrateConvoIfAny();
applyAuthUI();
setTab(location.hash === '#chat' ? 'chat' : 'about');
setCurrentSubnav();
</script>
</body>
</html>