From 9adf760c8e8939255983336438ba3eefbb77a5e7 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 10 Aug 2026 21:12:15 -0400 Subject: [PATCH] Give every code block its own copy and insert, and number the long ones --- Plugin/unraid/include/ai_chat.php | 99 ++++++++++++++++++++++++++++++- Plugin/unraid/pages/scheduler.php | 51 ++++------------ 2 files changed, 108 insertions(+), 42 deletions(-) diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 521f31d..168580f 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -159,6 +159,25 @@ function vv_ai_chat_assets(): void { .vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px; overflow-x:auto; margin:8px 0; } .vv-ai-body pre code { background:none; padding:0; color:#9cc; } +/* ── Code cards ───────────────────────────────────────────────────────────── + Controls are always visible, never hover-revealed. The Monitor and Scheduler run full-time on + 15" panels with no pointer near them, so a control that only exists on hover does not exist. */ +.vv-ai-code { margin:8px 0; border:1px solid #222; border-radius:4px; overflow:hidden; } +.vv-ai-code pre { margin:0; border:none; border-radius:0; } +.vv-ai-code-h { display:flex; align-items:center; gap:8px; padding:3px 8px; + background:#1b1b1b; border-bottom:1px solid #222; font-size:10px; } +.vv-ai-code-lang { color:#7a9; text-transform:uppercase; letter-spacing:.4px; } +.vv-ai-code-n { color:#555; } +.vv-ai-code-sp { flex:1; } +.vv-ai-code-btn { background:#232323; border:1px solid #333; color:#bbb; font-size:10px; + padding:2px 8px; border-radius:3px; cursor:pointer; line-height:1.5; } +.vv-ai-code-btn:hover { background:#2c2c2c; color:#eee; } +.vv-ai-code-btn.ok { background:#264a26; border-color:#356b35; color:#cfe8cf; } +/* Generated content, so it stays out of textContent — Copy and Insert return the code alone. */ +.vv-ai-code.numbered code { counter-reset:vvln; } +.vv-ai-cl { counter-increment:vvln; } +.vv-ai-cl::before { content:counter(vvln); display:inline-block; width:2.6em; margin-right:.8em; + text-align:right; color:#3f3f3f; user-select:none; } .vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; } .vv-ai-cite:hover { text-decoration:underline; } @@ -434,10 +453,41 @@ vv_ai_profiles_script(); function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; } function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); } + // Long blocks get numbered, because "the error is on line 40" is unusable against a wall of + // unnumbered text. The number is CSS generated content on a per-line span, which keeps it out + // of textContent — so Copy and Insert return the code and never the gutter. + const CODE_NUMBER_FROM = 12; + + function codeCard(lang, code) { + const lines = code.split('\n'); + const n = lines.length; + const numbered = n >= CODE_NUMBER_FROM; + // Joined on the newline rather than made display:block, so
 supplies the line break and
+      // textContent comes back byte-identical to what the model wrote.
+      const body = numbered
+        ? lines.map(x => `${x}`).join('\n')
+        : code;
+
+      let head = '
'; + head += `${lang || 'text'}`; + head += `${n} line${n === 1 ? '' : 's'}`; + head += ''; + head += ''; + // Only offered where the page can actually receive it. Without a target this would be a + // button that silently does nothing, which is worse than not having one. + if (o.onInsertCode) { + head += ''; + } + head += '
'; + + return `
${head}` + + `
${body}
`; + } + // ── Minimal markdown, applied strictly after escaping ──────────────── function fmt(text) { let h = esc(text); - h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `
${c}
`); + h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => codeCard(l, c)); h = h.replace(/`([^`\n]+)`/g, '$1'); h = h.replace(/\*\*([^*\n]+)\*\*/g, '$1'); h = h.replace(/\[(\d+)\]/g, '[$1]'); @@ -586,9 +636,54 @@ vv_ai_profiles_script(); return; } const off = e.target.closest('[data-offer]'); - if (off) answerOffer(+off.dataset.offer, off.dataset.yes === '1'); + if (off) { answerOffer(+off.dataset.offer, off.dataset.yes === '1'); return; } + + // Code-card buttons. The text is read from the DOM rather than carried in a data attribute: + // a script full of quotes and backslashes interpolated into an attribute is an escaping bug + // waiting to happen, and textContent already holds exactly what the model wrote. + const cbtn = e.target.closest('[data-code-copy],[data-code-insert]'); + if (cbtn) { + const card = cbtn.closest('.vv-ai-code'); + const code = card ? (card.querySelector('code') || {}).textContent || '' : ''; + if (!code) return; + + if (cbtn.hasAttribute('data-code-insert')) { + if (o.onInsertCode) o.onInsertCode(code); + flashBtn(cbtn, 'Inserted'); + return; + } + + // navigator.clipboard needs a secure context. Unraid is routinely reached over plain http + // on the LAN, where it is simply undefined — so the textarea fallback is the path that + // actually runs here, not a legacy nicety. + const done = () => flashBtn(cbtn, 'Copied'); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(code).then(done).catch(() => copyFallback(code, done)); + } else { + copyFallback(code, done); + } + } }); + function copyFallback(text, done) { + const t = document.createElement('textarea'); + t.value = text; + // Off-screen rather than hidden: display:none and visibility:hidden are not selectable, so + // execCommand copies nothing from them. + t.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'; + document.body.appendChild(t); + t.select(); + try { document.execCommand('copy'); done(); } catch (_) {} + t.remove(); + } + + function flashBtn(btn, msg) { + const was = btn.textContent; + btn.textContent = msg; + btn.classList.add('ok'); + setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100); + } + // ── Ask / poll ─────────────────────────────────────────────────────── function send() { // Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 1da9496..946a32b 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -2437,14 +2437,13 @@ if (document.getElementById('vv-sched-ai-chat')) { vvAiLastQ = q; return false; }, - onTurn: (answer) => { vvAiFixArm(); vvAiCodeArm(answer); requestAnimationFrame(vvFitRight); }, + onTurn: () => { vvAiFixArm(); requestAnimationFrame(vvFitRight); }, // Expanding changes how much of the panel is left for the views above it. Without this the // chat grows downward past the end of the panel instead of upward into it. onResize: () => requestAnimationFrame(vvFitRight), - onOffer: (kind, yes) => { - if (kind === 'fix') vvAiFixAnswer(yes); - if (kind === 'code') vvAiCodeAnswer(yes); - }, + onOffer: (kind, yes) => { if (kind === 'fix') vvAiFixAnswer(yes); }, + // Per-block Insert buttons render only where a page can receive them. + onInsertCode: vvAiInsertCode, }); } @@ -2511,52 +2510,24 @@ let vvAiFixAsk = null; // { scope, symptom } while the offer is open and awa // It is an offer, never automatic: the editor usually holds work in progress, and a reply that // silently rewrote it would be far worse than copy/paste. -let vvAiCodePending = null; - -// Largest fenced block wins when an answer carries several — an explanation typically quotes a -// line or two before giving the whole thing, and the whole thing is what was asked for. A shebang -// beats size outright, since that is unambiguously a script rather than an excerpt. -function vvAiCodeExtract(text) { - if (!text) return ''; - const blocks = []; - const re = /```[a-zA-Z0-9_+-]*\n([\s\S]*?)```/g; - let m; - while ((m = re.exec(text)) !== null) blocks.push(m[1].replace(/\s+$/, '')); - if (!blocks.length) return ''; - const shebang = blocks.find(b => b.startsWith('#!')); - return shebang || blocks.sort((a, b) => b.length - a.length)[0]; -} - // display:'' vs 'none' is how this panel switches views — see vvShowEditor and friends. function vvAiEditorOpen() { const ed = document.getElementById('vv-editor'); return !!ed && ed.style.display !== 'none'; } -function vvAiCodeArm(answer) { - vvAiCodePending = null; - if (!vvSchedChat || !vvAiEditorOpen()) return; - - const code = vvAiCodeExtract(answer); - // A single short line is a mention, not a script. Offering on those turns every answer into a - // question and trains the operator to ignore the buttons. - if (!code || (code.length < 40 && code.indexOf('\n') === -1)) return; - - vvAiCodePending = code; - const lines = code.split('\n').length; - vvSchedChat.offer('code', 'Insert that ' + lines + '-line block into the editor at your cursor?'); -} - -function vvAiCodeAnswer(yes) { - const code = vvAiCodePending; - vvAiCodePending = null; - if (!yes || !code) return; +// Called by the chat component's per-block Insert button, with that block's exact text. This +// replaced a whole-answer offer: the offer had to guess which block was meant when an answer +// carried several, and it asked after every code answer whether or not anything was wanted. +// A button on each block needs no guess and no question. +function vvAiInsertCode(code) { + if (!code) return; const ta = document.getElementById('vv-editor-body'); // They may have switched views between the answer and the click. Inserting into a hidden // textarea would look like nothing happened and be discovered much later. if (!ta || !vvAiEditorOpen()) { - vvSchedChat.note('The editor is no longer open — nothing was inserted.'); + vvSchedChat.note('The editor is not open — nothing was inserted.'); return; }