From aa3d92360e6440bd68b0b48df21af5db81763583 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 10 Aug 2026 22:41:07 -0400 Subject: [PATCH] Diff a proposed script against the open one and take it a hunk at a time --- Plugin/unraid/include/ai_chat.php | 219 ++++++++++++++++++++++++++++++ Plugin/unraid/pages/scheduler.php | 33 +++++ 2 files changed, 252 insertions(+) diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index e007288..d611298 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -206,6 +206,27 @@ function vv_ai_chat_assets(): void { .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. */ +/* ── Diff view ────────────────────────────────────────────────────────────── + Replaces the code body rather than sitting beside it: on a 15" panel the point of a diff is to + be the thing you are reading, and showing both doubles the height to say the same thing twice. */ +.vv-ai-code.diffing pre { display:none; } +.vv-ai-diff { font-family:monospace; font-size:11.5px; line-height:1.5; } +.vv-ai-hunk { border-top:1px solid #222; } +.vv-ai-hunk-h { display:flex; align-items:center; gap:8px; padding:3px 8px; background:#161616; + font-size:10px; color:#666; font-family:inherit; } +.vv-ai-hunk-n { color:#4a4a4a; } +.vv-ai-dl { display:flex; padding:0 8px; white-space:pre-wrap; word-break:break-word; } +/* The marker column is what makes a diff readable without colour — which matters for the + colour-blind case and for a screen being read from across a room. */ +.vv-ai-dm { display:inline-block; width:1.2em; flex:0 0 auto; color:#3f3f3f; user-select:none; } +.vv-ai-dl.ctx { color:#6a6a6a; } +.vv-ai-dl.add { background:#12240f; color:#9fd68a; } +.vv-ai-dl.del { background:#2a1212; color:#d68a8a; } +.vv-ai-dl.add .vv-ai-dm { color:#5f9f4f; } +.vv-ai-dl.del .vv-ai-dm { color:#9f5f5f; } +.vv-ai-diff-warn { padding:6px 9px; font-size:10.5px; color:#d8b070; background:#241d10; + border-bottom:1px solid #222; font-family:inherit; } + .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; @@ -511,6 +532,168 @@ 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(); } + // ── Diff ───────────────────────────────────────────────────────────── + // Computed here from the two full texts, never asked of the model. A model asked for a unified + // diff produces a plausible-looking one with wrong line numbers and dropped context often + // enough to be useless, and it already returns the whole modified script — so the reliable + // move is to diff what it gave against what is open and show the difference ourselves. + + // Guard against a pathological pair freezing the tab. 4M cells is roughly 2000x2000 lines, + // far past any script in this repo; beyond it the block is simply offered as a block. + const DIFF_CELL_MAX = 4000000; + + function diffLines(A, B) { + const n = A.length, m = B.length; + + // Common prefix and suffix are stripped before the matrix is built. Edits cluster in the + // middle of a file, so this usually turns a 300x300 table into something trivial. + let s = 0; while (s < n && s < m && A[s] === B[s]) s++; + let e = 0; while (e < n - s && e < m - s && A[n - 1 - e] === B[m - 1 - e]) e++; + + const a = A.slice(s, n - e), b = B.slice(s, m - e); + if (a.length * b.length > DIFF_CELL_MAX) return null; + + // Longest common subsequence, filled from the end so the walk forward below is greedy and + // produces the conventional "deletions before insertions" ordering. + const R = a.length, C = b.length; + const dp = []; + for (let i = 0; i <= R; i++) dp.push(new Uint32Array(C + 1)); + for (let i = R - 1; i >= 0; i--) { + for (let j = C - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 + : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const ops = []; + for (let k = 0; k < s; k++) ops.push({ t: ' ', text: A[k] }); + let i = 0, j = 0; + while (i < R && j < C) { + if (a[i] === b[j]) { ops.push({ t: ' ', text: a[i] }); i++; j++; } + else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: '-', text: a[i] }); i++; } + else { ops.push({ t: '+', text: b[j] }); j++; } + } + while (i < R) ops.push({ t: '-', text: a[i++] }); + while (j < C) ops.push({ t: '+', text: b[j++] }); + for (let k = 0; k < e; k++) ops.push({ t: ' ', text: A[n - e + k] }); + return ops; + } + + // Changed lines, grouped with context. Runs closer together than twice the context merge into + // one hunk rather than showing the same lines as trailing context and then leading context. + const DIFF_CTX = 3; + function diffHunks(ops, ctx) { + ctx = ctx === undefined ? DIFF_CTX : ctx; + const changed = []; + ops.forEach((o, k) => { if (o.t !== ' ') changed.push(k); }); + if (!changed.length) return []; + + const hunks = []; + let from = changed[0], to = changed[0]; + for (let x = 1; x < changed.length; x++) { + if (changed[x] - to <= ctx * 2) { to = changed[x]; continue; } + hunks.push({ from, to }); + from = to = changed[x]; + } + hunks.push({ from, to }); + + return hunks.map(h => ({ + from: Math.max(0, h.from - ctx), + to: Math.min(ops.length - 1, h.to + ctx), + adds: ops.slice(h.from, h.to + 1).filter(o => o.t === '+').length, + dels: ops.slice(h.from, h.to + 1).filter(o => o.t === '-').length, + })); + } + + // Rebuilds the whole file with only the selected hunks taken. Outside a selected hunk the old + // side wins (keep '-', drop '+'); inside it the new side does. That means applying one hunk + // cannot disturb a line the operator has not agreed to change — which is the entire point of + // doing this per hunk instead of replacing the file. + function applySelected(ops, hunks, selected) { + const inSel = new Array(ops.length).fill(false); + selected.forEach(hi => { + const h = hunks[hi]; + if (h) for (let k = h.from; k <= h.to; k++) inSel[k] = true; + }); + const out = []; + ops.forEach((o, k) => { + if (o.t === ' ') out.push(o.text); + else if (o.t === '-') { if (!inSel[k]) out.push(o.text); } + else { if (inSel[k]) out.push(o.text); } + }); + return out.join('\n'); + } + + // Share of the smaller file that survives unchanged. A rewrite scores near zero and is offered + // as a block, because rendering a whole new script as one enormous all-added hunk is noise + // dressed as review. + const DIFF_MIN_SIM = 0.30; + function diffSimilarity(ops, A, B) { + const same = ops.reduce((n, o) => n + (o.t === ' ' ? 1 : 0), 0); + const base = Math.max(1, Math.min(A.length, B.length)); + return same / base; + } + + // Held per card rather than per answer. Nothing is cached across a render: every draw + // recomputes against the editor as it stands, so applying a hunk makes that hunk disappear + // from the view because it genuinely is no longer a difference. + const diffState = new WeakMap(); + + function drawDiff(card) { + const box = card.querySelector('[data-diff-body]'); + if (!box) return; + + const code = (card.querySelector('code') || {}).textContent || ''; + const cur = String((o.getCompareText && o.getCompareText()) || ''); + + if (!cur.trim()) { + box.innerHTML = `
nothing open to compare against
`; + return; + } + + const A = cur.split('\n'), B = code.split('\n'); + const ops = diffLines(A, B); + if (!ops) { + box.innerHTML = `
too large to diff — use Insert instead
`; + return; + } + + const hunks = diffHunks(ops); + if (!hunks.length) { + box.innerHTML = `
identical to what is open — nothing to apply
`; + return; + } + + const sim = diffSimilarity(ops, A, B); + diffState.set(card, { ops, hunks }); + + // Below the threshold this is a different file, not an edit of this one. Said plainly rather + // than rendered as one vast all-added hunk, which looks like review but reads as noise. + const warn = sim < DIFF_MIN_SIM + ? `
Only ${Math.round(sim * 100)}% of the open file survives — + this looks like a replacement rather than an edit. Check before applying.
` + : ''; + + box.innerHTML = warn + hunks.map((h, hi) => { + const rows = []; + for (let k = h.from; k <= h.to; k++) { + const op = ops[k]; + const cls = op.t === '+' ? 'add' : op.t === '-' ? 'del' : 'ctx'; + rows.push(`
${op.t}` + + `${esc(op.text)}
`); + } + return `
+
+ hunk ${hi + 1} of ${hunks.length} + +${h.adds} −${h.dels} + + +
+ ${rows.join('')} +
`; + }).join(''); + } + // 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. @@ -531,6 +714,12 @@ vv_ai_profiles_script(); head += `${n} line${n === 1 ? '' : 's'}`; head += ''; head += ''; + // Offered wherever there is something to compare against. Whether the open file is actually + // related to this block is decided on click, from the text as it stands then — the editor + // may have been switched or edited since the answer arrived. + if (o.getCompareText && o.onReplaceCode) { + 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) { @@ -796,6 +985,36 @@ vv_ai_profiles_script(); // 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. + // Apply one hunk. The editor is rewritten whole — the page owns splicing it in with undo — + // and then the diff is redrawn against the new content, so what was just applied vanishes + // from the list because it is no longer a difference. + const hb = e.target.closest('[data-hunk]'); + if (hb) { + const card = hb.closest('.vv-ai-code'); + const st = diffState.get(card); + if (!st) return; + const next = applySelected(st.ops, st.hunks, new Set([Number(hb.dataset.hunk)])); + if (o.onReplaceCode) o.onReplaceCode(next); + drawDiff(card); + return; + } + + const dbtn = e.target.closest('[data-code-diff]'); + if (dbtn) { + const card = dbtn.closest('.vv-ai-code'); + const open = card.classList.toggle('diffing'); + dbtn.classList.toggle('ok', open); + dbtn.textContent = open ? 'Code' : 'Diff'; + let box = card.querySelector('[data-diff-body]'); + if (!box) { + card.insertAdjacentHTML('beforeend', '
'); + box = card.querySelector('[data-diff-body]'); + } + box.hidden = !open; + if (open) drawDiff(card); + return; + } + const cbtn = e.target.closest('[data-code-copy],[data-code-insert]'); if (cbtn) { const card = cbtn.closest('.vv-ai-code'); diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 27faff1..bf059b0 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -2445,6 +2445,9 @@ if (document.getElementById('vv-sched-ai-chat')) { // Per-block Insert buttons render only where a page can receive them. onInsertCode: vvAiInsertCode, onOpenSource: vvAiOpenSource, + // Diff and per-hunk apply against whatever the editor holds at the moment of the click. + getCompareText: vvAiCompareText, + onReplaceCode: vvAiReplaceCode, }); } @@ -2530,6 +2533,36 @@ function vvAiEditorOpen() { return !!ed && ed.style.display !== 'none'; } +// What a proposed block is diffed against: the editor exactly as it stands right now, not as it +// was when the answer arrived. Read fresh on every draw, so applying one hunk and redrawing shows +// the remaining differences rather than a stale picture. +function vvAiCompareText() { + const ta = document.getElementById('vv-editor-body'); + return (ta && vvAiEditorOpen()) ? ta.value : ''; +} + +// Takes the whole rewritten file back from a hunk apply. Whole-file rather than a splice because +// the component computed the result against this exact text — handing back a range would make +// both sides responsible for the arithmetic, and only one of them can be right. +function vvAiReplaceCode(text) { + const ta = document.getElementById('vv-editor-body'); + if (!ta || !vvAiEditorOpen()) { + if (vvSchedChat) vvSchedChat.note('The editor is not open — nothing was applied.'); + return; + } + + // Bracketed immediately, so one Ctrl+Z steps back over the whole hunk rather than unpicking it + // a keystroke at a time. Cursor position is kept where it was where that still makes sense. + vvUndoCapture(true); + const caret = Math.min(ta.selectionStart, text.length); + ta.value = text; + ta.selectionStart = ta.selectionEnd = caret; + vvUndoCapture(true); + + vvSyncHlOverlay(); + vvEditorCursorMoved(); +} + // 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.