crpython commited on
Commit
d8d433a
·
1 Parent(s): a4d5c9a

Restructure as single-page about-on-top + chat-below + CTA, loading status

Browse files

Layout:
- About section now lands at top of the main column (default landing view).
- Chat section follows below; sidebar nav becomes anchor scroll with scroll-spy.
- About ends with a primary CTA button that smooth-scrolls to the chat section.
- Topbar is now sticky and sub-nav inside About sticks below it (top: 56px).
- Chat dock composer is sticky to the bottom of the viewport while in the chat section.

Loading state:
- Bot pending message shows typing dots + a status line that progresses with elapsed
time (Connecting → Waking ZeroGPU → Cold start → Still warming up).
- Subscribes to @gradio/client status events for queue/processing stage updates.

Multi-turn:
- Sends real conversation history to the backend (JSON.stringify on the convo,
excluding pending/error turns), enabling proper follow-up questions.

DOM safety:
- Refactored renderMsg to use createElement + textContent throughout (no innerHTML).
Markdown rendering uses a small tokenizer that emits text nodes / strong / code
elements directly — XSS-safe by construction.

Metadata:
- README title bumped to user-facing 'CyberSecQwen-4B · CTI Specialist' with
the 'Beating an 8B Cisco specialist at half the size' tagline as short_description.

Files changed (2) hide show
  1. README.md +13 -8
  2. index.html +354 -228
README.md CHANGED
@@ -1,18 +1,23 @@
1
  ---
2
- title: CyberSecQwen Chat
3
  emoji: "\U0001F6E1"
4
  colorFrom: blue
5
- colorTo: indigo
6
  sdk: static
7
  pinned: false
8
  license: apache-2.0
9
- short_description: Polished chat UI for CyberSecQwen-4B
10
  ---
11
 
12
- # CyberSecQwen Chat
13
 
14
- Static frontend that calls the [`athena129/cybersecqwen-demo`](https://huggingface.co/spaces/athena129/cybersecqwen-demo) Gradio Space (ZeroGPU backend) via `@gradio/client`.
15
 
16
- - Single-file HTML, vanilla JS, no build step
17
- - Connects to the deployed Gradio backend's `/chat` endpoint
18
- - Streaming token-by-token via Gradio's submit job iterator
 
 
 
 
 
 
1
  ---
2
+ title: CyberSecQwen-4B · CTI Specialist
3
  emoji: "\U0001F6E1"
4
  colorFrom: blue
5
+ colorTo: gray
6
  sdk: static
7
  pinned: false
8
  license: apache-2.0
9
+ short_description: Beating an 8B Cisco specialist at half the size
10
  ---
11
 
12
+ # CyberSecQwen-4B
13
 
14
+ A 4B-parameter Qwen3 fine-tune specialized for cyber threat intelligence — CWE classification, CVE-to-CWE mapping, code-pattern reasoning. Trained on a single AMD Instinct MI300X.
15
 
16
+ | Benchmark | Score (n=5, temp 0.3) | vs. Foundation-Sec-Instruct-8B |
17
+ |---|---|---|
18
+ | CTI-RCM | 0.6664 ± 0.0023 | -1.9 pp at half the size |
19
+ | CTI-MCQ | 0.5868 ± 0.0029 | **+8.7 pp at half the size** |
20
+
21
+ This Space is the polished chat UI. It calls the ZeroGPU backend [`athena129/cybersecqwen-demo`](https://huggingface.co/spaces/athena129/cybersecqwen-demo) via `@gradio/client`. Cold start ~10–20 s, then warm.
22
+
23
+ → Model card: [`athena129/CyberSecQwen-4B`](https://huggingface.co/athena129/CyberSecQwen-4B) · Source: [GitHub](https://github.com/GPT-64590/CyberSecQwen-4B)
index.html CHANGED
@@ -139,13 +139,14 @@
139
  }
140
  .status-dot{width:5px;height:5px;border-radius:50%;background:var(--accent);box-shadow:0 0 6px var(--accent)}
141
 
142
- /* Main */
143
- .main{display:flex;flex-direction:column;min-width:0;min-height:0}
144
  .topbar{
145
  height:56px;flex:none;
146
  padding:0 28px;border-bottom:1px solid var(--border-soft);
147
  display:flex;align-items:center;justify-content:space-between;
148
- background:rgba(8,8,10,.5);backdrop-filter:blur(8px);
 
149
  }
150
  .topbar h1.section-title{
151
  margin:0;font-size:13.5px;font-weight:500;color:var(--fg-2);letter-spacing:.005em;
@@ -156,15 +157,49 @@
156
  display:inline-flex;align-items:center;gap:7px;
157
  }
158
 
159
- .view{flex:1;min-height:0;overflow:auto;position:relative}
160
- .view.no-scroll{overflow:hidden;display:flex;flex-direction:column}
161
- .view.no-scroll > .chat-active{flex:1;min-height:0}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
- /* ── CHAT VIEW ─────────────────────────────────────────── */
164
  .chat-empty{
165
- min-height:100%;
166
  display:flex;flex-direction:column;align-items:center;justify-content:center;
167
- padding:7vh 28px 12vh;
168
  gap:24px;
169
  }
170
  .hero-icon{
@@ -233,16 +268,14 @@
233
  .chip .kind{font-family:var(--font-mono);font-size:10.5px;color:var(--accent)}
234
  .chip:hover{transform:translateY(-1px);border-color:var(--accent-border);color:var(--fg-2)}
235
 
236
- /* Active conversation layout */
237
  .chat-active{
238
  display:flex;flex-direction:column;
239
- width:100%;min-height:0;
240
  }
241
  .thread{
242
- flex:1;min-height:0;overflow:auto;
243
  padding: 28px 28px 32px;
244
  display:flex;flex-direction:column;align-items:center;
245
- scroll-behavior:smooth;
246
  }
247
  .thread-inner{width:100%;max-width:720px;display:flex;flex-direction:column;gap:18px}
248
 
@@ -281,14 +314,13 @@
281
  .typing i:nth-child(3){animation-delay:.30s}
282
  @keyframes blink{0%,80%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
283
 
284
- /* Docked composer */
285
  .dock{
286
  flex:none;
287
  padding:14px 28px 22px;
288
  background:linear-gradient(to bottom, transparent, rgba(8,8,10,.85) 22%, var(--bg) 60%);
289
  display:flex;justify-content:center;
290
- margin-top:-32px;
291
- position:relative;z-index:2;
292
  pointer-events:none;
293
  }
294
  .dock > .dock-inner{
@@ -309,7 +341,7 @@
309
  .about{padding:0}
310
  .about-inner{max-width:880px;margin:0 auto;padding:48px 32px 120px}
311
  .subnav{
312
- position:sticky;top:0;z-index:5;
313
  background:rgba(8,8,10,.78);backdrop-filter:blur(10px);
314
  border-bottom:1px solid var(--border-soft);
315
  padding:12px 32px;
@@ -468,14 +500,14 @@
468
  </div>
469
 
470
  <nav class="nav" id="nav">
471
- <a href="#/" data-route="/" class="active">
472
- <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>
473
- Chat
474
- </a>
475
- <a href="#/about" data-route="/about">
476
  <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>
477
  About
478
  </a>
 
 
 
 
479
  </nav>
480
 
481
  <div class="side-foot">
@@ -492,80 +524,15 @@
492
  </aside>
493
 
494
  <!-- ── MAIN ────────────────────────────────────────────── -->
495
- <main class="main">
496
  <header class="topbar">
497
- <h1 class="section-title" id="topTitle">Chat</h1>
498
- <span class="top-status">cybersecqwen-4b · temp 0.3</span>
499
  </header>
500
 
501
- <div class="view" id="view"><!-- routed content --></div>
502
- </main>
503
- </div>
504
-
505
- <!-- ── CHAT VIEW (template) ─────────────────────────────── -->
506
- <template id="tpl-chat-empty">
507
- <div class="chat-empty">
508
- <div class="hero-icon" aria-hidden="true">
509
- <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>
510
- </div>
511
- <h2 class="hero-h1">Map a vulnerability<br/><em>to a CWE.</em></h2>
512
- <p class="hero-sub">Paste a snippet, log line, or CVE. The model returns a Common Weakness Enumeration with a one-paragraph rationale.</p>
513
-
514
- <form class="composer" id="composer-empty">
515
- <textarea rows="1" id="ta-empty" placeholder="Describe the issue, paste code, or drop a CVE id…"></textarea>
516
- <div class="composer-row">
517
- <span class="send-hint">↵ to send · ⇧↵ for newline</span>
518
- <button class="send-btn" type="submit" id="send-empty" disabled>
519
- <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>
520
- Send
521
- </button>
522
- </div>
523
- </form>
524
-
525
- <div class="chips" id="chips">
526
- <button class="chip" data-prompt="Explain CWE-120 (classic buffer overflow) with a minimal C example and the standard mitigation.">
527
- <span class="kind">CWE</span> Buffer overflow
528
- </button>
529
- <button class="chip" data-prompt="What CWE underlies the MOVEit Transfer SQL injection chain (CVE-2023-34362)?">
530
- <span class="kind">CVE</span> MOVEit
531
- </button>
532
- <button class="chip" data-prompt="Audit this nginx config snippet for security weaknesses:&#10;server { listen 80; root /var/www; autoindex on; location /api/ { proxy_pass http://127.0.0.1:8000; } }">
533
- <span class="kind">CFG</span> nginx audit
534
- </button>
535
- <button class="chip" data-prompt="Log line: GET /static/../../../etc/passwd HTTP/1.1 200 — what weakness, and how to fix?">
536
- <span class="kind">LOG</span> path traversal
537
- </button>
538
- </div>
539
- </div>
540
- </template>
541
-
542
- <template id="tpl-chat-active">
543
- <div class="chat-active">
544
- <div class="thread" id="thread"><div class="thread-inner" id="thread-inner"></div></div>
545
- <div class="dock">
546
- <div class="dock-inner">
547
- <form class="composer" id="composer-dock">
548
- <textarea rows="1" id="ta-dock" placeholder="Continue the conversation…"></textarea>
549
- <div class="composer-row">
550
- <span class="send-hint">↵ to send · ⇧↵ for newline</span>
551
- <button class="send-btn" type="submit" id="send-dock" disabled>
552
- <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>
553
- Send
554
- </button>
555
- </div>
556
- </form>
557
- <button class="new-chat" id="newChat" type="button" title="Start a new chat">
558
- <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>
559
- New chat
560
- </button>
561
- </div>
562
- </div>
563
- </div>
564
- </template>
565
-
566
- <!-- ── ABOUT VIEW (template) ────────────────────────────── -->
567
- <template id="tpl-about">
568
- <div class="about">
569
  <div class="subnav">
570
  <div class="subnav-inner" id="subnav">
571
  <a href="#performance">Performance</a>
@@ -810,29 +777,102 @@
810
  <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>
811
  </section>
812
 
813
- </div>
814
- </div>
815
- </template>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
 
817
  <script>
818
  /* ─── BACKEND HOOK ────────────────────────────────────────────
819
  Calls the deployed Gradio Space `athena129/cybersecqwen-demo`
820
- via @gradio/client. The backend exposes /chat with signature
821
- chat(message: str, history_json: str) -> str (streaming). We
822
- pass "[]" for single-turn and surface cumulative output via onChunk.
 
823
  */
824
  const BACKEND_SPACE = "athena129/cybersecqwen-demo";
825
  let _client = null;
826
  async function getClient(){
827
  if (_client) return _client;
828
  const { Client } = await import("https://esm.sh/@gradio/client@1.10.0");
829
- _client = await Client.connect(BACKEND_SPACE);
830
  return _client;
831
  }
832
 
833
- async function askModel(prompt, onChunk){
834
  const client = await getClient();
835
- const job = client.submit("/chat", [prompt, "[]"]);
836
  let lastContent = "";
837
  for await (const msg of job){
838
  if (msg.type === "data" && msg.data && Array.isArray(msg.data)){
@@ -841,38 +881,71 @@ async function askModel(prompt, onChunk){
841
  lastContent = text;
842
  if (onChunk) onChunk(lastContent);
843
  }
 
 
844
  }
845
  }
846
  return { answer: lastContent || "The model returned no output. Try again." };
847
  }
848
 
849
- /* ─── ROUTING ────────────────────────────────────────────── */
850
- const view = document.getElementById('view');
851
- const topTitle = document.getElementById('topTitle');
852
  const navLinks = document.querySelectorAll('#nav a');
853
-
854
- let convo = []; // [{role:'user'|'bot', text, pending?}]
855
-
856
- function route(){
857
- const hash = location.hash || '#/';
858
- navLinks.forEach(a => a.classList.toggle('active', a.dataset.route === (hash.replace(/^#/,'') || '/')));
859
- if (hash.startsWith('#/about')){
860
- topTitle.textContent = 'About';
861
- renderAbout();
862
- } else {
863
- topTitle.textContent = 'Chat';
864
- renderChat();
 
 
865
  }
 
866
  }
867
- window.addEventListener('hashchange', route);
868
 
869
- /* ─── CHAT ────────────────────────────────────────────────── */
870
- function renderChat(){
871
- view.innerHTML = '';
872
- view.classList.toggle('no-scroll', convo.length > 0);
873
- if (convo.length === 0) renderEmptyChat(); else renderActiveChat();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  }
875
 
 
876
  function bindComposer(form, ta, btn, onSend){
877
  const update = () => { btn.disabled = ta.value.trim().length === 0; };
878
  ta.addEventListener('input', () => {
@@ -887,148 +960,201 @@ function bindComposer(form, ta, btn, onSend){
887
  update();
888
  }
889
 
890
- function renderEmptyChat(){
891
- view.appendChild(document.getElementById('tpl-chat-empty').content.cloneNode(true));
892
- const form = document.getElementById('composer-empty');
893
- const ta = document.getElementById('ta-empty');
894
- const btn = document.getElementById('send-empty');
895
- bindComposer(form, ta, btn, sendMessage);
896
- // focus
897
- setTimeout(() => ta.focus(), 30);
898
-
899
- document.querySelectorAll('#chips .chip').forEach(c => {
900
- c.addEventListener('click', () => {
901
- ta.value = c.dataset.prompt;
902
- ta.dispatchEvent(new Event('input'));
903
- ta.focus();
904
- });
 
 
 
 
905
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  }
907
 
908
- function renderActiveChat(){
909
- view.innerHTML = '';
910
- view.classList.add('no-scroll');
911
- view.appendChild(document.getElementById('tpl-chat-active').content.cloneNode(true));
912
- const inner = document.getElementById('thread-inner');
913
- inner.innerHTML = '';
914
- convo.forEach(m => inner.appendChild(renderMsg(m)));
915
- scrollThread();
916
-
917
- const form = document.getElementById('composer-dock');
918
- const ta = document.getElementById('ta-dock');
919
- const btn = document.getElementById('send-dock');
920
- bindComposer(form, ta, btn, sendMessage);
921
- setTimeout(() => ta.focus(), 30);
922
-
923
- document.getElementById('newChat').addEventListener('click', () => {
924
- convo = [];
925
- renderChat();
926
- });
927
  }
928
 
929
- function renderMsg(m){
930
- const wrap = document.createElement('div');
931
- wrap.className = 'msg ' + (m.role === 'user' ? 'user' : 'bot');
 
 
932
  if (m.role === 'user'){
933
- wrap.innerHTML = `<span class="who">You</span><div class="bubble"></div>`;
934
- wrap.querySelector('.bubble').textContent = m.text;
 
 
 
 
 
 
 
 
 
 
 
 
935
  } else {
936
- wrap.innerHTML = `<span class="who">CyberSecQwen</span><div class="body"></div>`;
937
- const body = wrap.querySelector('.body');
938
- if (m.pending){
939
- body.innerHTML = '<span class="typing"><i></i><i></i><i></i></span>';
940
- } else {
941
- body.innerHTML = renderMd(m.text);
942
- }
943
  }
 
944
  return wrap;
945
  }
946
 
947
- function renderMd(s){
948
- return s
949
- .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
950
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
951
- .replace(/`([^`]+)`/g, '<code>$1</code>');
952
  }
953
 
954
- function scrollThread(){
955
- const t = document.getElementById('thread');
956
- if (t) t.scrollTop = t.scrollHeight;
957
  }
958
 
 
959
  async function sendMessage(text){
960
  const wasEmpty = convo.length === 0;
 
 
961
  convo.push({ role:'user', text });
962
- convo.push({ role:'bot', text:'', pending:true });
 
 
 
 
 
 
 
 
963
 
964
- // Always re-render the active chat so the empty-state DOM
965
- // (hero, chips, empty composer) is fully removed on first send.
966
- renderActiveChat();
967
- scrollThread();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968
 
969
- // Stream tokens into the last bot message as they arrive.
970
  const onChunk = (partial) => {
 
971
  convo[convo.length - 1] = { role:'bot', text: partial };
972
- const inner = document.getElementById('thread-inner');
973
- if (!inner) return;
974
- const last = inner.lastElementChild;
975
- if (last) last.replaceWith(renderMsg(convo[convo.length - 1]));
976
- scrollThread();
 
 
 
 
 
 
 
 
 
977
  };
978
 
979
  try{
980
- const { answer } = await askModel(text, onChunk);
981
  convo[convo.length - 1] = { role:'bot', text: answer };
982
  } catch (e){
983
  convo[convo.length - 1] = {
984
  role:'bot',
985
- text: 'The model is unavailable right now. ZeroGPU may be cold-starting (first call after idle takes ~30 s) — try again in a moment.\n\n`' + (e?.message || String(e)) + '`',
 
986
  };
 
 
987
  }
988
-
989
- // Final re-render of the bot message in place.
990
- const inner = document.getElementById('thread-inner');
991
- if (inner){
992
- const last = inner.lastElementChild;
993
- if (last) last.replaceWith(renderMsg(convo[convo.length - 1]));
994
- scrollThread();
995
- }
996
- // refocus composer
997
- const ta = document.getElementById('ta-dock');
998
- if (ta){ ta.value = ''; ta.style.height = 'auto'; ta.dispatchEvent(new Event('input')); ta.focus(); }
999
  }
1000
 
1001
- /* ─── ABOUT ────────────────────────────────────────────────── */
1002
- function renderAbout(){
1003
- view.innerHTML = '';
1004
- view.appendChild(document.getElementById('tpl-about').content.cloneNode(true));
1005
-
1006
- // Sub-nav scroll-spy + smooth-scroll within the view (the scroll container is .view)
1007
- const subnav = view.querySelector('#subnav');
1008
- const links = subnav.querySelectorAll('a');
1009
- const sections = view.querySelectorAll('.about section');
1010
-
1011
- links.forEach(a => {
1012
- a.addEventListener('click', e => {
1013
- e.preventDefault();
1014
- const id = a.getAttribute('href').slice(1);
1015
- const target = view.querySelector('#' + id);
1016
- if (target) view.scrollTo({ top: target.offsetTop - 60, behavior:'smooth' });
1017
- });
1018
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1019
 
1020
- const setCurrent = () => {
1021
- const top = view.scrollTop + 80;
1022
- let current = sections[0]?.id;
1023
- sections.forEach(s => { if (s.offsetTop <= top) current = s.id; });
1024
- links.forEach(a => a.classList.toggle('is-current', a.getAttribute('href') === '#' + current));
1025
- };
1026
- view.addEventListener('scroll', setCurrent, { passive:true });
1027
- setCurrent();
1028
  }
 
 
1029
 
1030
- /* ─── BOOT ─────────────────────────────────────────────────── */
1031
- route();
 
 
 
1032
  </script>
1033
  </body>
1034
  </html>
 
139
  }
140
  .status-dot{width:5px;height:5px;border-radius:50%;background:var(--accent);box-shadow:0 0 6px var(--accent)}
141
 
142
+ /* Main — scroll container for the stacked sections (about → chat). */
143
+ .main{display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto;scroll-behavior:smooth;position:relative}
144
  .topbar{
145
  height:56px;flex:none;
146
  padding:0 28px;border-bottom:1px solid var(--border-soft);
147
  display:flex;align-items:center;justify-content:space-between;
148
+ background:rgba(8,8,10,.72);backdrop-filter:blur(10px);
149
+ position:sticky;top:0;z-index:10;
150
  }
151
  .topbar h1.section-title{
152
  margin:0;font-size:13.5px;font-weight:500;color:var(--fg-2);letter-spacing:.005em;
 
157
  display:inline-flex;align-items:center;gap:7px;
158
  }
159
 
160
+ /* Stacked page sections */
161
+ .page-section{position:relative;scroll-margin-top:56px}
162
+ .page-section + .page-section{border-top:1px solid var(--border-soft)}
163
+ .hidden{display:none !important}
164
+
165
+ /* Call-to-action block at end of About */
166
+ .cta-block{
167
+ margin:64px auto 0;max-width:560px;
168
+ padding:36px 32px;
169
+ border:1px solid var(--accent-border);border-radius:16px;
170
+ background:radial-gradient(120% 100% at 50% 0%, var(--accent-soft), transparent 70%);
171
+ text-align:center;
172
+ }
173
+ .cta-block h3{
174
+ margin:0 0 8px;
175
+ font-family:var(--font-serif);font-weight:400;
176
+ font-size:30px;line-height:1.15;letter-spacing:-.01em;color:var(--fg);
177
+ }
178
+ .cta-block h3 em{font-style:italic;color:var(--accent)}
179
+ .cta-block p{margin:0 0 22px;color:var(--fg-4);font-size:14.5px;max-width:42ch;margin-left:auto;margin-right:auto}
180
+ .cta-primary{
181
+ display:inline-flex;align-items:center;gap:9px;
182
+ padding:11px 22px;border-radius:10px;
183
+ background:var(--accent);color:#06181c;font-weight:600;font-size:14px;
184
+ transition:transform .2s var(--ease-out), filter .15s, box-shadow .2s var(--ease-out);
185
+ box-shadow: 0 0 0 1px var(--accent-border), 0 0 28px var(--accent-glow);
186
+ }
187
+ .cta-primary:hover{transform:translateY(-1px);filter:brightness(1.05)}
188
+ .cta-primary svg{transition:transform .2s var(--ease-out)}
189
+ .cta-primary:hover svg{transform:translateY(2px)}
190
+
191
+ /* Loading status line under typing indicator */
192
+ .status-text{
193
+ margin-top:6px;
194
+ font-family:var(--font-mono);font-size:11px;color:var(--fg-5);
195
+ letter-spacing:.02em;
196
+ }
197
+ .status-text.warn{color:var(--accent)}
198
 
199
+ /* ── CHAT SECTION ──────────────────────────────────────── */
200
  .chat-empty{
 
201
  display:flex;flex-direction:column;align-items:center;justify-content:center;
202
+ padding:64px 28px 80px;
203
  gap:24px;
204
  }
205
  .hero-icon{
 
268
  .chip .kind{font-family:var(--font-mono);font-size:10.5px;color:var(--accent)}
269
  .chip:hover{transform:translateY(-1px);border-color:var(--accent-border);color:var(--fg-2)}
270
 
271
+ /* Active conversation layout — stacked, dock is sticky-bottom of viewport */
272
  .chat-active{
273
  display:flex;flex-direction:column;
274
+ width:100%;
275
  }
276
  .thread{
 
277
  padding: 28px 28px 32px;
278
  display:flex;flex-direction:column;align-items:center;
 
279
  }
280
  .thread-inner{width:100%;max-width:720px;display:flex;flex-direction:column;gap:18px}
281
 
 
314
  .typing i:nth-child(3){animation-delay:.30s}
315
  @keyframes blink{0%,80%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
316
 
317
+ /* Docked composer — sticky to viewport bottom while scrolling chat */
318
  .dock{
319
  flex:none;
320
  padding:14px 28px 22px;
321
  background:linear-gradient(to bottom, transparent, rgba(8,8,10,.85) 22%, var(--bg) 60%);
322
  display:flex;justify-content:center;
323
+ position:sticky;bottom:0;z-index:4;
 
324
  pointer-events:none;
325
  }
326
  .dock > .dock-inner{
 
341
  .about{padding:0}
342
  .about-inner{max-width:880px;margin:0 auto;padding:48px 32px 120px}
343
  .subnav{
344
+ position:sticky;top:56px;z-index:5;
345
  background:rgba(8,8,10,.78);backdrop-filter:blur(10px);
346
  border-bottom:1px solid var(--border-soft);
347
  padding:12px 32px;
 
500
  </div>
501
 
502
  <nav class="nav" id="nav">
503
+ <a href="#about" data-section="about" class="active">
 
 
 
 
504
  <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>
505
  About
506
  </a>
507
+ <a href="#chat" data-section="chat">
508
+ <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>
509
+ Try the Chat
510
+ </a>
511
  </nav>
512
 
513
  <div class="side-foot">
 
524
  </aside>
525
 
526
  <!-- ── MAIN ────────────────────────────────────────────── -->
527
+ <main class="main" id="main">
528
  <header class="topbar">
529
+ <h1 class="section-title" id="topTitle">CyberSecQwen-4B</h1>
530
+ <span class="top-status">4B · CTI specialist · temp 0.3</span>
531
  </header>
532
 
533
+ <!-- ── ABOUT (top) ───────────────────────────────────── -->
534
+ <section class="page-section about" id="about">
535
+ <div class="about">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  <div class="subnav">
537
  <div class="subnav-inner" id="subnav">
538
  <a href="#performance">Performance</a>
 
777
  <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>
778
  </section>
779
 
780
+ <!-- CTA — links into the chat section below -->
781
+ <div class="cta-block">
782
+ <h3>Ready to <em>try it?</em></h3>
783
+ <p>Test CyberSecQwen-4B on your own prompts below. ZeroGPU; cold start ~10–20 s, then warm.</p>
784
+ <a href="#chat" class="cta-primary" id="ctaTry">
785
+ Try the chat
786
+ <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>
787
+ </a>
788
+ </div>
789
+
790
+ </div><!-- /about-inner -->
791
+ </div><!-- /about wrapper -->
792
+ </section><!-- /#about -->
793
+
794
+ <!-- ── CHAT (below) ─────────────────────────────────── -->
795
+ <section class="page-section chat-section" id="chat">
796
+ <div class="chat-empty" id="chatEmpty">
797
+ <div class="hero-icon" aria-hidden="true">
798
+ <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>
799
+ </div>
800
+ <h2 class="hero-h1">Map a vulnerability<br/><em>to a CWE.</em></h2>
801
+ <p class="hero-sub">Paste a snippet, log line, or CVE. The model returns a Common Weakness Enumeration with a one-paragraph rationale.</p>
802
+
803
+ <form class="composer" id="composerEmpty">
804
+ <textarea rows="1" id="taEmpty" placeholder="Describe the issue, paste code, or drop a CVE id…"></textarea>
805
+ <div class="composer-row">
806
+ <span class="send-hint">↵ to send · ⇧↵ for newline</span>
807
+ <button class="send-btn" type="submit" id="sendEmpty" disabled>
808
+ <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>
809
+ Send
810
+ </button>
811
+ </div>
812
+ </form>
813
+
814
+ <div class="chips" id="chips">
815
+ <button class="chip" data-prompt="What CWE applies to using strcpy without bounds checking? Give a one-paragraph rationale.">
816
+ <span class="kind">CWE</span> Buffer overflow
817
+ </button>
818
+ <button class="chip" data-prompt="What CWE underlies the MOVEit Transfer SQL injection chain (CVE-2023-34362)?">
819
+ <span class="kind">CVE</span> MOVEit
820
+ </button>
821
+ <button class="chip" data-prompt="What CWE applies to: query = 'SELECT * FROM users WHERE id=' + user_id">
822
+ <span class="kind">CWE</span> SQL injection
823
+ </button>
824
+ <button class="chip" data-prompt="Log line: GET /static/../../../etc/passwd HTTP/1.1 200 — what weakness, and how to fix?">
825
+ <span class="kind">LOG</span> path traversal
826
+ </button>
827
+ </div>
828
+ </div>
829
+
830
+ <div class="chat-active hidden" id="chatActive">
831
+ <div class="thread"><div class="thread-inner" id="threadInner"></div></div>
832
+ <div class="dock">
833
+ <div class="dock-inner">
834
+ <form class="composer" id="composerDock">
835
+ <textarea rows="1" id="taDock" placeholder="Continue the conversation…"></textarea>
836
+ <div class="composer-row">
837
+ <span class="send-hint">↵ to send · ⇧↵ for newline</span>
838
+ <button class="send-btn" type="submit" id="sendDock" disabled>
839
+ <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>
840
+ Send
841
+ </button>
842
+ </div>
843
+ </form>
844
+ <button class="new-chat" id="newChat" type="button" title="Start a new chat">
845
+ <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>
846
+ New chat
847
+ </button>
848
+ </div>
849
+ </div>
850
+ </div>
851
+ </section><!-- /#chat -->
852
+
853
+ </main>
854
+ </div>
855
 
856
  <script>
857
  /* ─── BACKEND HOOK ────────────────────────────────────────────
858
  Calls the deployed Gradio Space `athena129/cybersecqwen-demo`
859
+ via @gradio/client. The backend exposes /chat with signature:
860
+ chat(message: str, history_json: str) -> str (streaming)
861
+ We send JSON.stringify(history) for multi-turn and subscribe to
862
+ status events so the loading UI can show queue/cold-start progress.
863
  */
864
  const BACKEND_SPACE = "athena129/cybersecqwen-demo";
865
  let _client = null;
866
  async function getClient(){
867
  if (_client) return _client;
868
  const { Client } = await import("https://esm.sh/@gradio/client@1.10.0");
869
+ _client = await Client.connect(BACKEND_SPACE, { events: ["data", "status"] });
870
  return _client;
871
  }
872
 
873
+ async function askModel(prompt, history, onChunk, onStatus){
874
  const client = await getClient();
875
+ const job = client.submit("/chat", [prompt, JSON.stringify(history || [])]);
876
  let lastContent = "";
877
  for await (const msg of job){
878
  if (msg.type === "data" && msg.data && Array.isArray(msg.data)){
 
881
  lastContent = text;
882
  if (onChunk) onChunk(lastContent);
883
  }
884
+ } else if (msg.type === "status" && onStatus){
885
+ onStatus(msg);
886
  }
887
  }
888
  return { answer: lastContent || "The model returned no output. Try again." };
889
  }
890
 
891
+ /* ─── DOM REFS ────────────────────────────────────────────── */
892
+ const main = document.getElementById('main');
 
893
  const navLinks = document.querySelectorAll('#nav a');
894
+ const aboutSection = document.getElementById('about');
895
+ const chatSection = document.getElementById('chat');
896
+ const chatEmpty = document.getElementById('chatEmpty');
897
+ const chatActive = document.getElementById('chatActive');
898
+ const threadInner = document.getElementById('threadInner');
899
+
900
+ let convo = []; // [{role:'user'|'bot', text, pending?, statusText?, error?}]
901
+
902
+ /* ─── DOM HELPERS (no innerHTML, all textContent / appendChild) */
903
+ function el(tag, opts){
904
+ const e = document.createElement(tag);
905
+ if (opts){
906
+ if (opts.cls) e.className = opts.cls;
907
+ if (opts.text != null) e.textContent = opts.text;
908
  }
909
+ return e;
910
  }
 
911
 
912
+ /* Safe markdown escape implicit, only **bold** and `code` are recognized. */
913
+ function renderMdToFragment(s){
914
+ const frag = document.createDocumentFragment();
915
+ let i = 0;
916
+ while (i < s.length){
917
+ // **bold**
918
+ if (s[i] === '*' && s[i+1] === '*'){
919
+ const end = s.indexOf('**', i + 2);
920
+ if (end !== -1){
921
+ frag.appendChild(el('strong', { text: s.slice(i + 2, end) }));
922
+ i = end + 2;
923
+ continue;
924
+ }
925
+ }
926
+ // `code`
927
+ if (s[i] === '`'){
928
+ const end = s.indexOf('`', i + 1);
929
+ if (end !== -1){
930
+ frag.appendChild(el('code', { text: s.slice(i + 1, end) }));
931
+ i = end + 1;
932
+ continue;
933
+ }
934
+ }
935
+ // Plain run — find next special char
936
+ let next = i;
937
+ while (next < s.length){
938
+ if (s[next] === '`') break;
939
+ if (s[next] === '*' && s[next + 1] === '*') break;
940
+ next++;
941
+ }
942
+ frag.appendChild(document.createTextNode(s.slice(i, next)));
943
+ i = next;
944
+ }
945
+ return frag;
946
  }
947
 
948
+ /* ─── COMPOSER BINDING ────────────────────────────────────── */
949
  function bindComposer(form, ta, btn, onSend){
950
  const update = () => { btn.disabled = ta.value.trim().length === 0; };
951
  ta.addEventListener('input', () => {
 
960
  update();
961
  }
962
 
963
+ bindComposer(
964
+ document.getElementById('composerEmpty'),
965
+ document.getElementById('taEmpty'),
966
+ document.getElementById('sendEmpty'),
967
+ sendMessage,
968
+ );
969
+ bindComposer(
970
+ document.getElementById('composerDock'),
971
+ document.getElementById('taDock'),
972
+ document.getElementById('sendDock'),
973
+ sendMessage,
974
+ );
975
+
976
+ document.querySelectorAll('#chips .chip').forEach(c => {
977
+ c.addEventListener('click', () => {
978
+ const ta = document.getElementById('taEmpty');
979
+ ta.value = c.dataset.prompt;
980
+ ta.dispatchEvent(new Event('input'));
981
+ ta.focus();
982
  });
983
+ });
984
+
985
+ document.getElementById('newChat').addEventListener('click', () => {
986
+ convo = [];
987
+ showEmptyState();
988
+ chatSection.scrollIntoView({ behavior:'smooth', block:'start' });
989
+ });
990
+
991
+ /* ─── VIEW STATE TOGGLES ──────────────────────────────────── */
992
+ function showEmptyState(){
993
+ chatActive.classList.add('hidden');
994
+ chatEmpty.classList.remove('hidden');
995
+ while (threadInner.firstChild) threadInner.removeChild(threadInner.firstChild);
996
+ setTimeout(() => document.getElementById('taEmpty')?.focus(), 30);
997
  }
998
 
999
+ function showActiveState(){
1000
+ chatEmpty.classList.add('hidden');
1001
+ chatActive.classList.remove('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
  }
1003
 
1004
+ /* ─── MESSAGE RENDERING (DOM API only) ────────────────────── */
1005
+ function buildMsg(m){
1006
+ const wrap = el('div', { cls: 'msg ' + (m.role === 'user' ? 'user' : 'bot') });
1007
+ wrap.appendChild(el('span', { cls: 'who', text: m.role === 'user' ? 'You' : 'CyberSecQwen' }));
1008
+
1009
  if (m.role === 'user'){
1010
+ wrap.appendChild(el('div', { cls: 'bubble', text: m.text }));
1011
+ return wrap;
1012
+ }
1013
+ // bot
1014
+ const body = el('div', { cls: 'body' });
1015
+ if (m.pending){
1016
+ const typing = el('span', { cls: 'typing' });
1017
+ typing.appendChild(el('i'));
1018
+ typing.appendChild(el('i'));
1019
+ typing.appendChild(el('i'));
1020
+ body.appendChild(typing);
1021
+ body.appendChild(el('div', { cls: 'status-text', text: m.statusText || 'Connecting…' }));
1022
+ } else if (m.error){
1023
+ body.appendChild(el('div', { cls: 'status-text warn', text: m.text }));
1024
  } else {
1025
+ body.appendChild(renderMdToFragment(m.text));
 
 
 
 
 
 
1026
  }
1027
+ wrap.appendChild(body);
1028
  return wrap;
1029
  }
1030
 
1031
+ function replaceLastMsg(){
1032
+ const last = threadInner.lastElementChild;
1033
+ if (last) last.replaceWith(buildMsg(convo[convo.length - 1]));
 
 
1034
  }
1035
 
1036
+ function appendToThread(m){
1037
+ threadInner.appendChild(buildMsg(m));
 
1038
  }
1039
 
1040
+ /* ─── SEND ─────────────────────────────────────────────────── */
1041
  async function sendMessage(text){
1042
  const wasEmpty = convo.length === 0;
1043
+ if (wasEmpty){ showActiveState(); }
1044
+
1045
  convo.push({ role:'user', text });
1046
+ convo.push({ role:'bot', text:'', pending:true, statusText:'Connecting to backend…' });
1047
+ appendToThread(convo[convo.length - 2]);
1048
+ appendToThread(convo[convo.length - 1]);
1049
+
1050
+ // Reset both composers immediately for responsiveness
1051
+ for (const id of ['taEmpty', 'taDock']){
1052
+ const ta = document.getElementById(id);
1053
+ if (ta){ ta.value = ''; ta.style.height='auto'; ta.dispatchEvent(new Event('input')); }
1054
+ }
1055
 
1056
+ // Build history from prior turns (exclude the just-added user+pending pair)
1057
+ const hist = convo.slice(0, -2)
1058
+ .filter(m => !m.pending && !m.error)
1059
+ .map(m => ({ role: m.role === 'bot' ? 'assistant' : 'user', content: m.text }));
1060
+
1061
+ // Progressive timer-based status copy if no/few status updates arrive
1062
+ const t0 = Date.now();
1063
+ let statusTimer = setInterval(() => {
1064
+ const last = convo[convo.length - 1];
1065
+ if (!last.pending) return;
1066
+ const elapsed = (Date.now() - t0) / 1000;
1067
+ let copy = last.statusText;
1068
+ if (elapsed > 5 && elapsed <= 12) copy = 'Waking ZeroGPU (cold start ~10–20 s)…';
1069
+ else if (elapsed > 12 && elapsed <= 25) copy = 'Cold start in progress — almost there…';
1070
+ else if (elapsed > 25) copy = 'Still warming up — ZeroGPU shared pool can be slow.';
1071
+ if (copy !== last.statusText){
1072
+ last.statusText = copy;
1073
+ replaceLastMsg();
1074
+ }
1075
+ }, 1500);
1076
 
 
1077
  const onChunk = (partial) => {
1078
+ if (statusTimer){ clearInterval(statusTimer); statusTimer = null; }
1079
  convo[convo.length - 1] = { role:'bot', text: partial };
1080
+ replaceLastMsg();
1081
+ };
1082
+
1083
+ const onStatus = (s) => {
1084
+ const last = convo[convo.length - 1];
1085
+ if (!last.pending) return;
1086
+ let copy = null;
1087
+ if (s.stage === 'pending') copy = 'Queued…';
1088
+ else if (s.stage === 'processing') copy = 'Generating…';
1089
+ else if (s.stage === 'iterating') copy = 'Generating…';
1090
+ if (copy && copy !== last.statusText){
1091
+ last.statusText = copy;
1092
+ replaceLastMsg();
1093
+ }
1094
  };
1095
 
1096
  try{
1097
+ const { answer } = await askModel(text, hist, onChunk, onStatus);
1098
  convo[convo.length - 1] = { role:'bot', text: answer };
1099
  } catch (e){
1100
  convo[convo.length - 1] = {
1101
  role:'bot',
1102
+ error: true,
1103
+ text: 'The backend is unavailable right now. ZeroGPU cold start can take ~10–20 s on the first call after idle — try again in a moment. (' + (e?.message || String(e)) + ')',
1104
  };
1105
+ } finally {
1106
+ if (statusTimer){ clearInterval(statusTimer); statusTimer = null; }
1107
  }
1108
+ replaceLastMsg();
1109
+ setTimeout(() => document.getElementById('taDock')?.focus(), 30);
 
 
 
 
 
 
 
 
 
1110
  }
1111
 
1112
+ /* ─── SIDEBAR NAV: smooth-scroll + scroll-spy ─────────────── */
1113
+ navLinks.forEach(a => {
1114
+ a.addEventListener('click', e => {
1115
+ e.preventDefault();
1116
+ const id = a.dataset.section;
1117
+ const target = document.getElementById(id);
1118
+ if (target) main.scrollTo({ top: target.offsetTop, behavior:'smooth' });
 
 
 
 
 
 
 
 
 
 
1119
  });
1120
+ });
1121
+
1122
+ const sectionsTop = [aboutSection, chatSection];
1123
+ function setActiveNav(){
1124
+ const top = main.scrollTop + 80;
1125
+ let currentId = sectionsTop[0].id;
1126
+ sectionsTop.forEach(s => { if (s.offsetTop <= top) currentId = s.id; });
1127
+ navLinks.forEach(a => a.classList.toggle('active', a.dataset.section === currentId));
1128
+ }
1129
+ main.addEventListener('scroll', setActiveNav, { passive:true });
1130
+
1131
+ /* ─── ABOUT SUB-NAV: scroll-spy within the page ───────────── */
1132
+ const subnavLinks = document.querySelectorAll('#subnav a');
1133
+ const aboutSubsections = document.querySelectorAll('#about .about-inner > section');
1134
+
1135
+ subnavLinks.forEach(a => {
1136
+ a.addEventListener('click', e => {
1137
+ e.preventDefault();
1138
+ const id = a.getAttribute('href').slice(1);
1139
+ const target = document.getElementById(id);
1140
+ if (target) main.scrollTo({ top: target.offsetTop - 110, behavior:'smooth' });
1141
+ });
1142
+ });
1143
 
1144
+ function setCurrentSubnav(){
1145
+ const top = main.scrollTop + 140;
1146
+ let current = aboutSubsections[0]?.id;
1147
+ aboutSubsections.forEach(s => { if (s.offsetTop <= top) current = s.id; });
1148
+ subnavLinks.forEach(a => a.classList.toggle('is-current', a.getAttribute('href') === '#' + current));
 
 
 
1149
  }
1150
+ main.addEventListener('scroll', setCurrentSubnav, { passive:true });
1151
+ setCurrentSubnav();
1152
 
1153
+ /* ─── BOOT — handle initial hash, default to top (about) ──── */
1154
+ if (location.hash === '#chat'){
1155
+ setTimeout(() => chatSection.scrollIntoView({ block:'start' }), 0);
1156
+ }
1157
+ setActiveNav();
1158
  </script>
1159
  </body>
1160
  </html>