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 = `