Cleanup init commit

This commit is contained in:
Extraltodeus
2026-07-13 22:26:50 +02:00
commit 1ff4063282
46 changed files with 12724 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>J-Wash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2019
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "jlens-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"d3": "^7.9.0",
"dompurify": "^3.4.12",
"marked": "^18.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11"
}
}
+1730
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
import { useMemo } from 'react'
import { fmtTok } from './tok'
const MAX_COLS = 200
function top1(frame, layer) {
const d = frame.layers[layer]
if (!d) return null
return { str: fmtTok(d.m_strs[0]), rank: d.m_rank[0], alt: d.m_strs.slice(0, 3).map(fmtTok).join(', ') }
}
export default function LensDiff({ framesA, framesB, labelA, labelB, onClose }) {
const layers = useMemo(() => {
if (!framesA.length || !framesB.length) return []
const a = new Set(Object.keys(framesA[0].layers))
return Object.keys(framesB[0].layers).filter((l) => a.has(l)).map(Number).sort((x, y) => x - y)
}, [framesA, framesB])
const cols = Math.min(framesA.length, framesB.length, MAX_COLS)
if (!cols || !layers.length) {
return (
<div className="lensview">
<div className="lv-controls">
diff not possible: incompatible layers or frames
<button style={{ marginLeft: 'auto' }} onClick={onClose}>close</button>
</div>
</div>
)
}
let diffCount = 0
const rows = layers.map((layer) => {
const cells = []
for (let i = 0; i < cols; i++) {
const a = top1(framesA[i], String(layer))
const b = top1(framesB[i], String(layer))
const same = a && b && a.str === b.str
if (!same) diffCount++
cells.push({ a, b, same, tokA: framesA[i].tok, tokB: framesB[i].tok })
}
return { layer, cells }
})
return (
<div className="lensview">
<div className="lv-controls">
<span>diff: <b>A</b> = {labelA} · <b>B</b> = {labelB}</span>
<span className="lv-sep" />
<span>{diffCount} divergent cells / {cols * layers.length}</span>
<button style={{ marginLeft: 'auto' }} onClick={onClose}>close</button>
</div>
<div className="lv-scroll">
<table className="diff-table">
<thead>
<tr>
<th></th>
{Array.from({ length: cols }, (_, i) => (
<th key={i} title={`A: ${framesA[i].tok} · B: ${framesB[i].tok}`}>
{fmtTok(framesA[i].tok).slice(0, 6) || '·'}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(({ layer, cells }) => (
<tr key={layer}>
<td className="diff-lay">L{layer}</td>
{cells.map((c, i) => (
<td key={i} className={c.same ? 'diff-same' : 'diff-diff'}
title={`A(${c.tokA}): ${c.a?.alt || '—'}\nB(${c.tokB}): ${c.b?.alt || '—'}`}>
<div className="diff-a">{c.a?.str.slice(0, 7) || '—'}</div>
<div className="diff-b">{c.b?.str.slice(0, 7) || '—'}</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+808
View File
@@ -0,0 +1,808 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { fmtTok } from './tok'
// Default layer slice for a new rule, as fractions of the model's layer count
// (aligned with core/ablation.py): 56 layers -> 33 to 44.
const DEFAULT_LAYERS_FRAC_LO = 3 / 5
const DEFAULT_LAYERS_FRAC_HI = 4 / 5
// default radius of the auto-selected slice around an edited token's "peak"
// layer (band = peak ± radius) — adjustable in the Options tab. More inclusive
// = more robust edit in readthrough.
const AUTO_LAYER_RADIUS_DEFAULT = 2
async function jsonFetch(url, options) {
const res = await fetch(url, options)
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.detail || res.statusText)
return body
}
const patchJson = (url, body) =>
jsonFetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
/* Layer selector: clickable cells, shift+click = range, shortcuts. */
export function LayerPicker({ all, value, onChange, compact, defaults, fitted }) {
const [anchor, setAnchor] = useState(null)
const set = useMemo(() => new Set(value), [value])
// "paint" drag: the state (on/off) is fixed by the first clicked layer, then
// applied to the hovered layers as long as the button stays held.
const dragRef = useRef(null) // { turnOn, sel } during the drag
useEffect(() => {
const up = () => { dragRef.current = null }
window.addEventListener('mouseup', up)
return () => window.removeEventListener('mouseup', up)
}, [])
if (!all.length) return null
function onCellDown(layer, ev) {
ev.preventDefault() // prevents text selection during the drag
if (ev.shiftKey && anchor != null) {
const [lo, hi] = anchor < layer ? [anchor, layer] : [layer, anchor]
const range = all.filter((l) => l >= lo && l <= hi)
const turnOn = !set.has(layer)
const next = new Set(set)
range.forEach((l) => (turnOn ? next.add(l) : next.delete(l)))
setAnchor(layer)
onChange([...next].sort((a, b) => a - b))
return
}
const turnOn = !set.has(layer)
const sel = new Set(set)
turnOn ? sel.add(layer) : sel.delete(layer)
dragRef.current = { turnOn, sel }
setAnchor(layer)
onChange([...sel].sort((a, b) => a - b))
}
function onCellEnter(layer) {
const d = dragRef.current
if (!d) return
if (d.turnOn === d.sel.has(layer)) return // already in the desired state
d.turnOn ? d.sel.add(layer) : d.sel.delete(layer)
onChange([...d.sel].sort((a, b) => a - b))
}
return (
<div className={`layerpicker ${compact ? 'lp-compact' : ''}`}>
<div className="lp-cells">
{all.map((l) => (
<span
key={l}
className={`lp-cell ${set.has(l) ? 'on' : ''} ${fitted && !fitted.has(l) ? 'lp-approx' : ''}`}
title={`layer ${l} (click-drag = paint, shift+click = range)${fitted && !fitted.has(l) ? ' — outside the lens: direct logit lens (approx.)' : ''}`}
onMouseDown={(e) => onCellDown(l, e)}
onMouseEnter={() => onCellEnter(l)}
>{l}</span>
))}
</div>
{!compact && (
<div className="lp-quick">
{defaults?.length > 0 && <button onClick={() => onChange(defaults)}>default</button>}
<button onClick={() => onChange([...all])}>all</button>
<button onClick={() => onChange([])}>none</button>
</div>
)}
</div>
)
}
/* Mini-bar: one segment per available layer, filled if the rule is active there. */
function RuleLayerBar({ all, layers, onClick }) {
const set = new Set(layers)
return (
<div
className="rulebar"
title={layers.length ? `layers ${layers.join(', ')} — click to edit` : 'no layer — inactive rule, click to edit'}
onClick={onClick}
>
{all.map((l) => <span key={l} className={`rb-seg ${set.has(l) ? 'on' : ''}`} />)}
</div>
)
}
/* Token field with live resolution (debounce) and clickable candidates. */
function TokenField({ label, value, onChange, placeholder }) {
const [cands, setCands] = useState([])
const timerRef = useRef(null)
// clears the suggestion when the field is reset by the parent (after an add,
// or an add from a view): otherwise the old candidate stays displayed
useEffect(() => {
if (!value.text.trim()) setCands([])
}, [value.text])
function lookup(text) {
clearTimeout(timerRef.current)
if (!text.trim()) { setCands([]); return }
timerRef.current = setTimeout(async () => {
try {
const body = await jsonFetch(`/api/token-lookup?q=${encodeURIComponent(text.trim())}`)
setCands(body.candidates)
const preferred = body.candidates.find((c) => c.str.startsWith(' ')) || body.candidates[0]
if (preferred) onChange({ text, id: preferred.id, str: preferred.str })
} catch { setCands([]) }
}, 280)
}
return (
<>
<div className="row"><label>{label}</label>
<input type="text" value={value.text} placeholder={placeholder}
onChange={(e) => { onChange({ text: e.target.value, id: null, str: '' }); lookup(e.target.value) }} />
</div>
{cands.length > 0 && (
<div className="ed-cands">
{cands.map((c) => (
<button key={c.id} className={value.id === c.id ? 'ed-cand-on' : ''}
onClick={() => { onChange({ text: value.text, id: c.id, str: c.str }); }}>
{fmtTok(c.str)}
</button>
))}
{value.id == null && <span className="src">no single token multi-token word?</span>}
</div>
)}
</>
)
}
// Layers as compact ranges: [20,21,22,24] → "20-22, 24".
export function formatRanges(layers) {
const s = [...layers].sort((a, b) => a - b)
const out = []
let start = null, prev = null
for (const l of s) {
if (start === null) { start = prev = l; continue }
if (l === prev + 1) { prev = l; continue }
out.push(start === prev ? `${start}` : `${start}-${prev}`)
start = prev = l
}
if (start !== null) out.push(start === prev ? `${start}` : `${start}-${prev}`)
return out.join(', ')
}
// Multi-line tooltip for a rule: full (untruncated) tokens, ids, mode, factor,
// layers — especially useful for replacement (words get cut off in the row).
function ruleTitle(r) {
const lines = [`token: "${fmtTok(r.token)}" (id ${r.token_id})`]
if (r.mode === 'replace') {
lines.push(`replacement: "${fmtTok(r.replacement)}" (id ${r.replacement_id})`)
}
lines.push(`mode: ${r.mode === 'replace' ? 'replace' : 'scale'} × ${r.factor}`)
const ls = r.layers || []
lines.push(`layers (${ls.length}): ${ls.length ? formatRanges(ls) : 'none → inactive'}`)
if (r.enabled === false) lines.push('— rule disabled —')
return lines.join('\n')
}
// Two-position toggle: steering (exploration) ↔ the pure-weights mode the
// architecture supports (read projection, or global projection on write-norm
// models like Gemma). The "exact" mode stays reachable through the API; if it
// is active, the thumb sits on the pure side and a click brings it back.
function ModeToggle({ mode, onChange, pureMode = 'readthrough' }) {
const isPure = mode !== 'standard'
return (
<div className={`mode-toggle ${isPure ? 'mt-read' : 'mt-steer'}`} role="radiogroup"
aria-label="intervention mode">
<div className="mt-thumb" />
<button type="button" className={`mt-opt ${!isPure ? 'mt-on' : ''}`}
aria-pressed={!isPure}
onClick={() => mode !== 'standard' && onChange('standard')}>
<svg viewBox="0 0 14 14" width="15" height="15" aria-hidden="true">
<path d="M2.5 1v5M2.5 10.6V13M7 1v1.4M7 7V13M11.5 1v7.4M11.5 13v-2"
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" fill="none" />
<circle cx="2.5" cy="8.3" r="1.7" fill="currentColor" />
<circle cx="7" cy="4.7" r="1.7" fill="currentColor" />
<circle cx="11.5" cy="10.7" r="1.7" fill="currentColor" />
</svg>
<span className="mt-lab">Per-layer steering</span>
<span className="mt-sub">preview only</span>
</button>
<button type="button" className={`mt-opt ${isPure ? 'mt-on' : ''}`}
aria-pressed={isPure}
onClick={() => mode !== pureMode && onChange(pureMode)}>
<svg viewBox="0 0 14 14" width="15" height="15" aria-hidden="true">
<path d="M1.2 7S3.4 3.2 7 3.2 12.8 7 12.8 7 10.6 10.8 7 10.8 1.2 7 1.2 7Z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<circle cx="7" cy="7" r="1.9" fill="currentColor" />
</svg>
<span className="mt-lab">{pureMode === 'abliteration' ? 'Global projection' : 'Read projection'}</span>
<span className="mt-sub">pure-weights · exportable</span>
</button>
</div>
)
}
const MODE_INFO = {
standard: {
label: 'Per-layer steering (preview only)',
tag: 'not exportable',
help: 'J-space hooks on the chosen layers: the most expressive way to explore, '
+ 'but the export bake only captures ~1-2 % of the effect. Switch to '
+ '"read projection" to export what you see.',
exportHelp: 'no export in this mode — switch to "read projection" for a '
+ 'faithful pure-weights bake.',
},
readthrough: {
label: 'Read projection (faithful bake)',
tag: 'pure-weights',
help: 'every read of the residual downstream of the chosen layers (q/k/v, gate/up, lm_head) '
+ 'sees the transformed residual: the preview = the exported checkpoint. Recommended for '
+ 'removals and replacements. Regenerate after a change.',
exportHelp: 'change of basis of the downstream reads + lm_head (untied if embeddings are tied). '
+ 'Formats: full checkpoint (standard safetensors), modified layers, or LoRA '
+ '(exact low-rank diff vs the original weights).',
},
exact: {
label: 'Exact compensated (soft factors)',
tag: 'pure-weights',
help: 'read projection + counter-transform of the downstream writes: reproduces a '
+ 'hook applied exactly once. ⚠ a full zap/replace makes the inverse singular '
+ '(regularized ≈ read projection) — reserve this mode for partial factors.',
exportHelp: 'downstream reads + writes transformed, lm_head untied if needed. '
+ 'Formats: full checkpoint, modified layers, or LoRA (exact low-rank diff).',
},
abliteration: {
label: 'Global projection (W_U abliteration)',
tag: 'pure-weights',
help: 'W_U projection on every residual write (embed + all layers): the pure-weights '
+ 'mode for architectures where the read projection is unavailable (write norms, '
+ 'Gemma style). Faithful for full removals/replacements; the rules\' layers are '
+ 'ignored (global projection).',
exportHelp: 'global abliteration × scale: removes/redirects the direction in embed + '
+ 'every o_proj/down_proj. Formats: full checkpoint, modified layers, or LoRA '
+ '(exact delta; embed omitted if embeddings are tied). ⚠ amplifying (factor > 1) '
+ 'stays approximate.',
},
}
export default function Editor({
open, onClose, rules, scale, mode, lensMeta, nLayers, genId, busy,
prefill, onPrefillConsumed, onRules, onScale, onMode, onNotice,
rebaseSupported = true, autoLayerRadius, llamaCppSet = false, ggufState,
}) {
// pure-weights mode this architecture can bake (cf. ModeToggle)
const pureMode = rebaseSupported === false ? 'abliteration' : 'readthrough'
const layerRadius = autoLayerRadius ?? AUTO_LAYER_RADIUS_DEFAULT
// All the model's layers; those outside the lens use the direct logit lens.
const allLayers = useMemo(() => {
if (nLayers) return Array.from({ length: nLayers }, (_, i) => i)
if (!lensMeta) return []
if (lensMeta.fitted_layers_all?.length) return lensMeta.fitted_layers_all
const [lo, hi] = lensMeta.fitted_layers || [0, -1]
return Array.from({ length: hi - lo + 1 }, (_, i) => lo + i)
}, [nLayers, lensMeta])
const fittedSet = useMemo(() => {
if (!lensMeta?.fitted_layers_all?.length) return null
return new Set(lensMeta.fitted_layers_all)
}, [lensMeta])
const defaultLayers = useMemo(() => {
const n = allLayers.length
if (!n) return []
const lo = Math.floor(n * DEFAULT_LAYERS_FRAC_LO)
const hi = Math.min(Math.floor(n * DEFAULT_LAYERS_FRAC_HI), n - 1)
return allLayers.filter((l) => l >= lo && l <= hi)
}, [allLayers])
// --- optimistic factor editing + debounced PATCH with flush ---
const [localFactors, setLocalFactors] = useState({})
const pendingRef = useRef(new Map()) // ruleId -> {timer, body}
function firePatch(id) {
const entry = pendingRef.current.get(id)
if (!entry) return Promise.resolve()
pendingRef.current.delete(id)
clearTimeout(entry.timer)
return patchJson(`/api/interventions/${id}`, entry.body)
.then((r) => {
onRules(r.rules)
setLocalFactors((prev) => { const n = { ...prev }; delete n[id]; return n })
})
.catch((err) => {
setLocalFactors((prev) => { const n = { ...prev }; delete n[id]; return n })
onNotice(String(err.message || err))
})
}
function schedulePatch(id, body) {
const prev = pendingRef.current.get(id)
if (prev) { clearTimeout(prev.timer); body = { ...prev.body, ...body } }
const timer = setTimeout(() => firePatch(id), 350)
pendingRef.current.set(id, { timer, body })
}
// --- global scale ---
const [scaleEdit, setScaleEdit] = useState(null)
const scaleTimer = useRef(null)
const scaleShown = scaleEdit ?? scale ?? 1
function setGlobalScale(v) {
setScaleEdit(v)
clearTimeout(scaleTimer.current)
scaleTimer.current = setTimeout(() => flushScale(v), 300)
}
function flushScale(v) {
clearTimeout(scaleTimer.current)
scaleTimer.current = null
return patchJson('/api/interventions', { scale: +v })
.then((r) => { onScale(r.scale); setScaleEdit(null) })
.catch((err) => { setScaleEdit(null); onNotice(String(err.message || err)) })
}
function setMode(next) {
patchJson('/api/interventions', { mode: next })
.then((r) => {
onMode(r.mode)
onNotice(`${MODE_INFO[r.mode]?.label || r.mode} — regenerate to see the effect.`, 'ok')
})
.catch((err) => onNotice(String(err.message || err)))
}
async function flushAll() {
const jobs = [...pendingRef.current.keys()].map(firePatch)
if (scaleTimer.current != null) jobs.push(flushScale(scaleEdit ?? scale ?? 1))
await Promise.all(jobs)
}
// --- multiple selection ---
const [selected, setSelected] = useState(new Set())
const [groupLayers, setGroupLayers] = useState([])
const [groupFactor, setGroupFactor] = useState('')
const selIds = [...selected].filter((id) => rules.some((r) => r.id === id))
async function applyGroup(body) {
try {
let last = null
for (const id of selIds) last = await patchJson(`/api/interventions/${id}`, body)
if (last) onRules(last.rules)
onNotice(`${selIds.length} rule(s) updated`, 'ok')
} catch (err) { onNotice(String(err.message || err)) }
}
// --- per-rule layers (inline picker) ---
const [expandedRule, setExpandedRule] = useState(null)
// --- add / edit form (editRuleId != null: the form UPDATES that rule) ---
const [addToken, setAddToken] = useState({ text: '', id: null, str: '' })
const [addRepl, setAddRepl] = useState({ text: '', id: null, str: '' })
const [addMode, setAddMode] = useState('scale')
const [addFactor, setAddFactor] = useState(0)
const [addLayers, setAddLayers] = useState([])
const [editRuleId, setEditRuleId] = useState(null)
const [flash, setFlash] = useState(false)
const addFormRef = useRef(null)
function startEditRule(r) {
setEditRuleId(r.id)
setAddToken({ text: (r.token || '').trim(), id: r.token_id, str: r.token })
setAddRepl(r.replacement_id != null
? { text: (r.replacement || '').trim(), id: r.replacement_id, str: r.replacement }
: { text: '', id: null, str: '' })
setAddMode(r.mode)
setAddFactor(r.factor)
setAddLayers(r.layers || [])
setFlash(true)
setTimeout(() => setFlash(false), 1600)
setTimeout(() => addFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 50)
}
function resetAddForm() {
setEditRuleId(null)
setAddToken({ text: '', id: null, str: '' })
setAddRepl({ text: '', id: null, str: '' })
}
// Set the default slice ONCE per model (layer count). Definitely not on every
// change of the defaultLayers reference: lensMeta is rebuilt on every
// /api/status poll, and "no layer" would refill itself.
const layersInitRef = useRef(0)
useEffect(() => {
if (!defaultLayers.length) return
if (layersInitRef.current === allLayers.length) return
layersInitRef.current = allLayers.length
setAddLayers(defaultLayers)
}, [defaultLayers])
// Auto-select the "peak" layer of the added token: as soon as a token is
// resolved and a generation with frames exists, we ask for its per-layer ranks
// (same data as the pins) and set the layers to peak ± 1. Silent if there is
// no generation, the token was never seen, or the server is busy.
useEffect(() => {
if (addToken.id == null || genId == null) return
let stale = false
jsonFetch('/api/lens/pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gen_id: genId, token_ids: [addToken.id] }),
})
.then((body) => {
if (stale) return
const d = body.pins?.[addToken.id]
if (!d?.ranks?.length) return
// most-relevant layer = the row that is lightest on average (the heatmap
// metric: 1 - log10(rank+1)/5) — consistent with peakLayerOf in the J-lens
let bestLi = 0, bestScore = -Infinity
d.ranks.forEach((layerRanks, li) => {
if (!layerRanks.length) return
const score = layerRanks.reduce(
(s, r) => s + (1 - Math.min(1, Math.log10(r + 1) / 5)), 0,
) / layerRanks.length
if (score > bestScore) { bestScore = score; bestLi = li }
})
const peak = body.layers[bestLi]
const band = allLayers.filter((l) => Math.abs(l - peak) <= layerRadius)
if (band.length) setAddLayers(band)
})
.catch(() => {})
return () => { stale = true }
}, [addToken.id, genId])
useEffect(() => {
if (!prefill) return
setAddToken({ text: (prefill.str || '').trim(), id: prefill.id, str: prefill.str })
setAddMode('scale')
setAddFactor(0)
// Pre-select the most-relevant layer (± 1 for an effective edit), otherwise
// keep the default slice already in place.
if (prefill.layer != null && allLayers.length) {
const band = allLayers.filter((l) => Math.abs(l - prefill.layer) <= layerRadius)
if (band.length) setAddLayers(band)
}
setFlash(true)
setTimeout(() => setFlash(false), 1600)
setTimeout(() => addFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 50)
onPrefillConsumed()
}, [prefill])
async function resolveId(field) {
if (field.id != null) return field.id
const body = await jsonFetch(`/api/token-lookup?q=${encodeURIComponent(field.text.trim())}`)
if (!body.candidates.length) throw new Error(`no single token for "${field.text}"`)
return (body.candidates.find((c) => c.str.startsWith(' ')) || body.candidates[0]).id
}
async function addRule() {
try {
const tokenId = await resolveId(addToken)
const replId = addMode === 'replace' ? await resolveId(addRepl) : null
if (editRuleId != null) {
// rewrite the existing rule in place (directions re-resolved server-side)
const resp = await patchJson(`/api/interventions/${editRuleId}`, {
token_id: tokenId, mode: addMode, factor: +addFactor,
replacement_id: replId, layers: addLayers,
})
onRules(resp.rules)
resetAddForm()
onNotice('rule updated — regenerate to see the effect', 'ok')
return
}
const r = await jsonFetch('/api/interventions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token_id: tokenId, mode: addMode, factor: +addFactor,
replacement_id: replId, layers: addLayers.length ? addLayers : null,
}),
})
onRules(r.rules)
resetAddForm()
onNotice('rule added — regenerate to see the effect', 'ok')
} catch (err) { onNotice(String(err.message || err)) }
}
// --- presets ---
const [presets, setPresets] = useState([])
const [presetName, setPresetName] = useState('')
const refreshPresets = () => jsonFetch('/api/presets').then((b) => setPresets(b.presets)).catch(() => {})
useEffect(() => { if (open) refreshPresets() }, [open])
async function savePreset() {
try {
await flushAll() // ensures the in-flight edits are the ones being saved
await jsonFetch(`/api/presets/${encodeURIComponent(presetName.trim())}`, { method: 'POST' })
setPresetName('')
refreshPresets()
onNotice('preset saved', 'ok')
} catch (err) { onNotice(String(err.message || err)) }
}
async function applyPreset(name) {
try {
const r = await jsonFetch(`/api/presets/${encodeURIComponent(name)}/apply`, { method: 'POST' })
onRules(r.rules)
if (r.scale != null) onScale(r.scale)
const warn = (r.warnings || []).join(' ; ')
onNotice(warn || `preset "${name}" applied`, warn ? 'err' : 'ok')
} catch (err) { onNotice(String(err.message || err)) }
}
// --- export ---
const [exportFmt, setExportFmt] = useState('full')
const [exportName, setExportName] = useState('')
const [ggufType, setGgufType] = useState('q4_k_m')
useEffect(() => {
// llama.cpp path removed from Options: don't leave an orphan gguf format
if (!llamaCppSet && exportFmt === 'gguf') setExportFmt('full')
}, [llamaCppSet, exportFmt])
async function doExport() {
onNotice('exporting...', 'ok')
try {
await flushAll()
if (exportFmt === 'gguf') {
const r = await jsonFetch('/api/edit/export-gguf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: exportName.trim(), gguf_type: ggufType }),
})
onNotice(
`GGUF conversion started (checkpoint ${r.checkpoint}) — progress shown below`,
'ok',
)
return
}
const r = await jsonFetch('/api/edit/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ format: exportFmt, name: exportName.trim() }),
})
const nParams = r.modified_params?.length ?? r.modified_params_count
const warn = (r.warnings || []).join(' ; ')
onNotice(
`exported to ${r.out_dir} (${nParams} matrices${r.untied_lm_head ? ', lm_head untied' : ''})`
+ (warn ? ` — ⚠ ${warn}` : ''),
warn ? 'err' : 'ok',
)
if (!warn) setExportName('')
} catch (err) { onNotice(String(err.message || err)) }
}
async function cleanGgufCache() {
try {
const r = await jsonFetch('/api/edit/gguf-cache/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: exportName.trim() }),
})
onNotice(`cache cleaned — ${(r.freed_bytes / 2 ** 30).toFixed(1)} GB freed`, 'ok')
} catch (err) { onNotice(String(err.message || err)) }
}
if (!open) return null
return (
<div className="editor">
<div className="ed-head">
<span> Token editor</span>
<button className="ed-close" onClick={onClose}></button>
</div>
<div className="ed-body">
<div className="ed-section">
<h3>Global multiplier</h3>
<div className="row">
<input type="range" min="0" max="3" step="0.05" value={scaleShown}
onChange={(e) => setGlobalScale(+e.target.value)} style={{ flex: 1 }} />
<input type="number" step="0.05" value={scaleShown}
onChange={(e) => setGlobalScale(e.target.value)}
style={{ width: 64, flexShrink: 0 }} />
</div>
<div className="src">
all alterations × {(+scaleShown).toFixed(2)}
{+scaleShown === 1 ? ' (neutral)' : +scaleShown === 0 ? ' (all disabled)' : ''}
</div>
<div style={{ marginTop: 10 }}>
<ModeToggle mode={mode || 'standard'} onChange={setMode} pureMode={pureMode} />
</div>
<div className="src" style={{ marginTop: 8 }}>{MODE_INFO[mode]?.help || ''}</div>
</div>
<div className="ed-section">
<h3>Active rules ({rules.length})</h3>
{rules.length === 0 && <div className="src">none add a token below or from the J-lens ()</div>}
{rules.map((r) => (
<div key={r.id} className={`ed-rule ${r.enabled === false ? 'ed-rule-off' : ''}`}>
<div className="ed-rule-main">
<input type="checkbox" checked={selected.has(r.id)}
onChange={(e) => {
const next = new Set(selected)
e.target.checked ? next.add(r.id) : next.delete(r.id)
setSelected(next)
}} />
<button className="ed-rule-toggle"
title={r.enabled === false ? 'rule disabled — click to enable (layers kept)' : 'rule active — click to disable without losing the layers'}
onClick={async () => {
try {
const resp = await patchJson(`/api/interventions/${r.id}`, { enabled: r.enabled === false })
onRules(resp.rules)
} catch (err) { onNotice(String(err.message || err)) }
}}>{r.enabled === false ? '○' : '●'}</button>
<span className="ed-rule-tok" title={ruleTitle(r)}>
«{fmtTok(r.token)}»{r.mode === 'replace' ? ` → «${fmtTok(r.replacement)}»` : ''}
</span>
<span className="src">×</span>
<input type="number" step="0.05" className="ed-rule-factor"
value={localFactors[r.id] ?? r.factor}
onChange={(e) => {
setLocalFactors((prev) => ({ ...prev, [r.id]: e.target.value }))
schedulePatch(r.id, { factor: +e.target.value })
}} />
<RuleLayerBar all={allLayers} layers={r.layers}
onClick={() => setExpandedRule(expandedRule === r.id ? null : r.id)} />
<button className="ed-rule-del" title="edit this rule (token, replacement, mode, factor, layers) in the form below"
onClick={() => startEditRule(r)}></button>
<button className="ed-rule-del" title="delete this rule" onClick={async () => {
try {
const resp = await jsonFetch(`/api/interventions/${r.id}`, { method: 'DELETE' })
onRules(resp.rules)
if (editRuleId === r.id) resetAddForm()
} catch (err) { onNotice(String(err.message || err)) }
}}></button>
</div>
{expandedRule === r.id && (
<div className="ed-rule-layers">
<LayerPicker all={allLayers} value={r.layers} defaults={defaultLayers} fitted={fittedSet}
onChange={async (layers) => {
try {
const resp = await patchJson(`/api/interventions/${r.id}`, { layers })
onRules(resp.rules)
} catch (err) { onNotice(String(err.message || err)) }
}} />
</div>
)}
</div>
))}
{rules.length > 1 && (
<div className="row" style={{ marginTop: 4 }}>
<button onClick={() => setSelected(new Set(rules.map((r) => r.id)))}>select all</button>
{selIds.length > 0 && (
<button onClick={() => setSelected(new Set())}>deselect all</button>
)}
<button onClick={async () => {
try {
const resp = await jsonFetch('/api/interventions', { method: 'DELETE' })
onRules(resp.rules)
} catch (err) { onNotice(String(err.message || err)) }
}}>remove all</button>
</div>
)}
</div>
{selIds.length > 0 && (
<div className="ed-section ed-group">
<h3>{selIds.length} rule(s) selected</h3>
<div className="src">layers to apply (none = inactive rules):</div>
<LayerPicker all={allLayers} value={groupLayers} defaults={defaultLayers} fitted={fittedSet}
onChange={setGroupLayers} />
<div className="row">
<button className="primary"
onClick={() => applyGroup({ layers: groupLayers })}>Apply layers</button>
</div>
<div className="row">
<label>factor</label>
<input type="number" step="0.05" value={groupFactor} placeholder="—"
onChange={(e) => setGroupFactor(e.target.value)} />
<button disabled={groupFactor === ''}
onClick={() => applyGroup({ factor: +groupFactor })}>Apply</button>
</div>
<button onClick={() => setSelected(new Set())}>deselect</button>
</div>
)}
<div className={`ed-section ${flash ? 'ed-flash' : ''}`} ref={addFormRef}>
<h3>{editRuleId != null
? <>Edit the rule <button style={{ marginLeft: 8, fontSize: 11 }} onClick={resetAddForm}>cancel</button></>
: 'Add a rule'}</h3>
<TokenField label="token" value={addToken} onChange={setAddToken} placeholder="word (e.g. Euro)" />
<div className="row"><label>mode</label>
<select value={addMode} onChange={(e) => { setAddMode(e.target.value); setAddFactor(e.target.value === 'replace' ? 1 : 0) }}>
<option value="scale">multiply (×0 = remove)</option>
<option value="replace">replace with</option>
</select>
</div>
{addMode === 'replace' && (
<TokenField label="with" value={addRepl} onChange={setAddRepl} placeholder="replacement token" />
)}
<div className="row"><label>factor</label>
<input type="number" step="0.05" value={addFactor} onChange={(e) => setAddFactor(e.target.value)} />
</div>
<div className="src">layers ({addLayers.length}):</div>
<LayerPicker all={allLayers} value={addLayers} defaults={defaultLayers} fitted={fittedSet}
onChange={setAddLayers} />
<button className="primary"
disabled={!addToken.text.trim() || (addMode === 'replace' && !addRepl.text.trim()) || !addLayers.length || !!busy}
onClick={addRule}>{editRuleId != null ? 'Update' : 'Add'}</button>
</div>
<div className="ed-section">
<h3>Presets</h3>
{presets.map((p) => (
<div key={p.name} className="reg-item" title={p.model_id ? `saved for ${p.model_id}` : ''}>
<span className="preset-info">
<span className="preset-name">{p.name} · {p.n_rules} rule(s)</span>
{p.model_id && <span className="src preset-model">{p.model_id.replace(/^local\//, '')}</span>}
</span>
<button disabled={!!busy} onClick={() => applyPreset(p.name)}>Apply</button>
<button onClick={async () => {
await jsonFetch(`/api/presets/${encodeURIComponent(p.name)}`, { method: 'DELETE' }).catch(() => {})
refreshPresets()
}}></button>
</div>
))}
<div className="row">
<input type="text" placeholder="preset name" value={presetName} onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && presetName.trim() && rules.length && !busy) savePreset() }} />
<button disabled={!presetName.trim() || !rules.length} onClick={savePreset}>Save</button>
</div>
</div>
<div className="ed-section">
<h3>Export the edit
<span className="exp-tag">{MODE_INFO[mode]?.tag || ''}</span>
</h3>
{mode === 'standard' ? (
<div className="src exp-disabled-note" style={{ marginBottom: 6 }}>
export disabled in "per-layer steering": no bake reproduces the
per-layer hooks faithfully. Switch the mode to
"{pureMode === 'abliteration' ? 'global projection' : 'read projection'}"
above for a faithful checkpoint.
</div>
) : null}
<div className={mode === 'standard' ? 'exp-grid exp-grid-off' : 'exp-grid'}>
<div className="row"><label>format</label>
<select value={exportFmt} onChange={(e) => setExportFmt(e.target.value)} disabled={mode === 'standard'}>
<option value="full">full checkpoint</option>
<option value="layers">modified layers (safetensors)</option>
<option value="lora">LoRA (PEFT)</option>
{llamaCppSet && <option value="gguf">GGUF (via llama.cpp)</option>}
</select>
</div>
{exportFmt === 'gguf' && (
<div className="row"><label title="bf16/f16 = plain conversion; q* = quantized with llama-quantize. The intermediate HF checkpoint is cached so other types don't re-bake.">type</label>
<select value={ggufType} onChange={(e) => setGgufType(e.target.value)}>
{['q4_k_m', 'q5_k_m', 'q6_k', 'q8_0', 'q3_k_m', 'bf16', 'f16'].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
)}
<div className="row"><label>name</label>
<input type="text" placeholder="edit name" value={exportName}
onChange={(e) => setExportName(e.target.value)} disabled={mode === 'standard'} />
</div>
<button className="primary"
disabled={mode === 'standard' || !exportName.trim()
|| (exportFmt !== 'gguf' && !rules.length) || !!busy
|| ggufState?.state === 'running'}
onClick={doExport}>Export</button>
{!llamaCppSet && (
<div className="src">tip: set the llama.cpp folder in the Options tab to
unlock a direct GGUF export.</div>
)}
{exportFmt === 'gguf' && (
<div className="row" style={{ marginTop: 2 }}>
<button disabled={!exportName.trim() || ggufState?.state === 'running'}
title="delete the cached intermediate HF checkpoint of this export (the .gguf files stay)"
onClick={cleanGgufCache}>clean cache</button>
</div>
)}
{ggufState?.state === 'running' && (
<div className="src"> GGUF {ggufState.name}: {ggufState.step}</div>
)}
{ggufState?.state === 'done' && ggufState.result && (
<div className="src ok"> GGUF ready: {ggufState.result.gguf}
{' '}({(ggufState.result.size_bytes / 2 ** 30).toFixed(1)} GB) the HF
checkpoint stays cached for other types (clean cache to reclaim).</div>
)}
{ggufState?.state === 'error' && (
<div className="src reg-reason">GGUF failed: {ggufState.error}</div>
)}
<div className="src">{MODE_INFO[mode]?.exportHelp || ''}</div>
</div>
</div>
</div>
</div>
)
}
+900
View File
@@ -0,0 +1,900 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import { fmtTok } from './tok'
import { LayerPicker, formatRanges } from './Editor.jsx'
// Mouse-following tooltip that never overflows the viewport: measured after
// render (before paint), it flips to the left of / above the cursor if needed.
function Tip({ x, y, children }) {
const ref = useRef(null)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const { width, height } = el.getBoundingClientRect()
let left = x + 12
let top = y + 14
if (left + width > window.innerWidth - 8) left = Math.max(8, x - width - 12)
if (top + height > window.innerHeight - 8) top = Math.max(8, y - height - 14)
el.style.left = `${left}px`
el.style.top = `${top}px`
})
return (
<div className="lv-tip" ref={ref} style={{ left: x + 12, top: y + 14 }}>
{children}
</div>
)
}
const trimTok = (s) => (s || '').replace(/^\s+|\s+$/g, '')
function copyText(s) {
const clean = trimTok(s)
if (!clean) return
navigator.clipboard?.writeText(clean).catch(() => {})
}
// Short name of the loaded lens, for the header badge ("which lens do I have?").
function lensLabel(meta) {
if (!meta) return ''
if (meta.path) return meta.path.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (meta.filename) {
const stem = meta.filename.split('/').pop().replace('_jacobian_lens', '').replace('.pt', '')
return meta.revision ? `${stem} @${meta.revision}` : stem
}
return meta.repo_id || 'lens'
}
const CELL_W = 58
const CELL_H = 22
const HEADER_H = 52
const LABEL_W = 44
const MAX_COLS = 400
const PIN_COLORS = ['#e8a13c', '#6cb8e0', '#7ec97e', '#c98bd4', '#d0654f']
/* ============================================================================
"PINS" VIEW SETTINGS (rank curves + heatmap of a pinned token)
⚠ After ANY change here you MUST rebuild the front-end and hard-reload the
page with Ctrl+F5:
cd ui && npm run build
(without a rebuild nothing changes: the browser serves the compiled bundle,
not this source file)
============================================================================ */
const PIN_FILL_WIDTH = true // true = the block stretches to the full available width
const PIN_PX_PER_TOKEN = 6 // minimum width of a column (position), in px
// (raise to 12-20 for wider cells: past the
// available width, horizontal scroll kicks in)
const PIN_MAX_W = 1600 // max graph width (px) — beyond it: horizontal scroll
const PIN_MIN_W = 120 // minimum graph width (px)
const PIN_CURVE_H = 90 // height of the curves graph (px)
const PIN_ROW_H_MIN = 5 // minimum height of a row (layer) in the heatmap (px)
const PIN_ROW_H_MAX = 12 // maximum height of a row (px)
const PIN_MAP_H = 180 // target heatmap height: row height ≈ PIN_MAP_H / n_layers
/* ========================================================================== */
function cellData(frame, layer, maskOn) {
const d = frame.layers[layer]
if (!d) return null
const strs = maskOn ? d.m_strs : d.strs
const ps = maskOn ? d.m_p : d.p
const ids = maskOn ? d.m_ids : d.ids
return {
str: strs[0],
rank: maskOn ? d.m_rank[0] : 0,
p: ps[0],
tid: ids[0],
top: strs.map((s, i) => ({ s, p: ps[i], r: maskOn ? d.m_rank[i] : i, tid: ids[i] })),
}
}
/* First pinned token present in the cell's top-k (concept localization). */
function pinHit(frame, layer, maskOn, pinnedIds) {
const d = frame.layers[layer]
if (!d || !pinnedIds.length) return null
const ids = maskOn ? d.m_ids : d.ids
for (let i = 0; i < ids.length; i++) {
if (pinnedIds.includes(ids[i])) return { tid: ids[i], idx: i }
}
return null
}
function rankColor(rank) {
const t = 1 - Math.min(1, Math.log10(rank + 1) / 5)
return d3.interpolateInferno(0.15 + 0.8 * t)
}
// CJK, kana, hangul, cyrillic, arabic, hebrew...: candidates for "nearest tokens"
const NONLATIN_RE = /[Ѐ-ӿ֐-ۿऀ-෿฀-໿ᄀ-ᇿ⺀-鿿ꀀ-꯿가-힯豈-﫿︰-﹏]/
function parseLayerSpec(spec) {
if (!spec.trim()) return null
const set = new Set()
for (const part of spec.split(',')) {
const m = part.trim().match(/^(\d+)\s*-\s*(\d+)$/)
if (m) for (let i = +m[1]; i <= +m[2]; i++) set.add(i)
else if (part.trim() && !isNaN(+part.trim())) set.add(+part.trim())
}
return set.size ? set : null
}
export default function LensView({ frames, tick, genId, lensMeta, onNotice, onEditToken, hidden, onHideToken, maxH, editorOpen, streaming }) {
const [maskOn, setMaskOn] = useState(true)
const [view, setView] = useState('agg')
const [filterInput, setFilterInput] = useState('')
const [dragLayers, setDragLayers] = useState(null)
const [aggLimit, setAggLimit] = useState(() => {
const v = +localStorage.getItem('jlens_agg_limit')
return v > 0 ? v : 80
})
const [autoScroll, setAutoScroll] = useState(() => localStorage.getItem('jlens_autoscroll') !== '0')
const gridScrollRef = useRef(null)
const [pinInput, setPinInput] = useState('')
const [pinCands, setPinCands] = useState([])
const pinTimerRef = useRef(null)
const [pinned, setPinned] = useState({})
const [pinData, setPinData] = useState(null)
const [tip, setTip] = useState(null)
const [aggTip, setAggTip] = useState(null)
const [pinHover, setPinHover] = useState(null) // { tid, li }: layer hovered in a pin block
const transCache = useRef(new Map()) // tid -> [{id, str, sim}] | null (request in flight)
const [, setTransTick] = useState(0)
// available width for the pin blocks (PIN_FILL_WIDTH): measured on the
// .lv-pins container. editorOpen is an explicit dependency — the 400px
// editor panel opening/closing is the main reason this width changes, and
// ResizeObserver alone proved unreliable for it — plus window resizes.
const pinsRef = useRef(null)
const [pinsW, setPinsW] = useState(0)
useEffect(() => {
const el = pinsRef.current
if (!el) return
const measure = () => setPinsW(el.clientWidth)
// synchronous first (reading clientWidth forces the reflow, and rAF /
// ResizeObserver never fire in a hidden tab), then again next frame in
// case the flex layout still settles
measure()
const raf = requestAnimationFrame(measure)
const ro = new ResizeObserver(measure)
ro.observe(el)
window.addEventListener('resize', measure)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
window.removeEventListener('resize', measure)
}
}, [pinData, editorOpen])
useEffect(() => { localStorage.setItem('jlens_agg_limit', String(aggLimit)) }, [aggLimit])
useEffect(() => { localStorage.setItem('jlens_autoscroll', autoScroll ? '1' : '0') }, [autoScroll])
// horizontal auto-scroll to the right during generation (new tokens appended
// on the right) — Heatmap view only, toggleable. Deps without gridW (declared
// below → TDZ): tick changes on every batch of frames and the effect reads
// scrollWidth live at execution time.
useEffect(() => {
if (autoScroll && view === 'grid' && gridScrollRef.current) {
const el = gridScrollRef.current
el.scrollLeft = el.scrollWidth
}
}, [tick, autoScroll, view])
// manual pinning of a token by its text, with suggestions (variants with/without
// a leading space): the pin colors the grid and draws the curves
function lookupPin(text) {
clearTimeout(pinTimerRef.current)
if (!text.trim()) { setPinCands([]); return }
pinTimerRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/token-lookup?q=${encodeURIComponent(text.trim())}`)
const body = await res.json()
setPinCands(body.candidates || [])
} catch { setPinCands([]) }
}, 250)
}
function addPin(c) {
if (!c) return
if (!pinned[c.id]) togglePin(c.id, c.str)
setPinInput('')
setPinCands([])
}
async function fetchTrans(entries) {
const missing = [...new Set(
entries.filter((e) => NONLATIN_RE.test(e.str || '')).map((e) => e.tid)
)].filter((t) => !transCache.current.has(t))
if (!missing.length) return
missing.forEach((t) => transCache.current.set(t, null))
try {
const res = await fetch('/api/token-neighbors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token_ids: missing, k: 6 }),
})
if (!res.ok) throw new Error()
const body = await res.json()
Object.entries(body.neighbors).forEach(([tid, list]) => transCache.current.set(+tid, list))
setTransTick((x) => x + 1)
} catch {
missing.forEach((t) => transCache.current.delete(t))
}
}
const transLabel = (tid) => {
const list = transCache.current.get(tid)
if (!list?.length) return null
return list.slice(0, 2).map((n) => fmtTok(n.str)).join(', ')
}
const shown = frames.length > MAX_COLS ? frames.slice(-MAX_COLS) : frames
const layers = useMemo(() => {
if (!shown.length) return []
return Object.keys(shown[shown.length - 1].layers).map(Number).sort((a, b) => a - b)
}, [tick, frames])
const filterSet = useMemo(() => parseLayerSpec(filterInput), [filterInput])
const filtered = filterSet ? layers.filter((l) => filterSet.has(l)) : layers
const filterEmpty = filterSet != null && filtered.length === 0
const layersShown = filterEmpty ? layers : filtered
// Defer applying a layer drag-selection until the mouse is released, so the
// Frequencies cloud reflows once (on mouseup) instead of on every dragged cell.
const dragLayersRef = useRef(null)
dragLayersRef.current = dragLayers
const layersRef = useRef(layers)
layersRef.current = layers
useEffect(() => {
const commit = () => {
const sel = dragLayersRef.current
if (sel == null) return
dragLayersRef.current = null
setFilterInput(sel.length >= layersRef.current.length ? '' : formatRanges(sel))
setDragLayers(null)
}
window.addEventListener('mouseup', commit)
return () => window.removeEventListener('mouseup', commit)
}, [])
const pinnedIds = useMemo(() => Object.keys(pinned).map(Number).sort((a, b) => a - b), [pinned])
const pinColor = (tid) => PIN_COLORS[pinnedIds.indexOf(tid) % PIN_COLORS.length]
// "Most-relevant" layer for a pinned token = where it reaches its best rank
// (the lowest) across all positions. Used to prefill the editor on the right
// slice rather than the default one.
function peakLayerOf(tid) {
const d = pinData?.pins?.[tid]
if (!d?.ranks?.length) return null
// "most-relevant" layer = the row that is LIGHTEST on average in the heatmap
// (rankColor metric: 1 - log10(rank+1)/5), not the single best rank at one
// position (a spike doesn't make the layer read as strong).
let bestLi = 0, bestScore = -Infinity
d.ranks.forEach((layerRanks, li) => {
if (!layerRanks.length) return
const score = layerRanks.reduce(
(s, r) => s + (1 - Math.min(1, Math.log10(r + 1) / 5)), 0,
) / layerRanks.length
if (score > bestScore) { bestScore = score; bestLi = li }
})
return pinData.layers[bestLi]
}
const agg = useMemo(() => {
if (view !== 'agg') return []
const visible = new Set(layersShown)
const map = new Map()
frames.forEach((f) => {
const seenHere = new Set()
Object.entries(f.layers).forEach(([layer, d]) => {
if (!visible.has(+layer)) return
const strs = maskOn ? d.m_strs : d.strs
const ps = maskOn ? d.m_p : d.p
const ids = maskOn ? d.m_ids : d.ids
strs.forEach((s, i) => {
const tid = ids[i]
let entry = map.get(tid)
if (!entry) {
entry = { tid, str: s, appearances: 0, maxP: 0, peakLayer: null }
map.set(tid, entry)
}
if (!seenHere.has(tid)) {
seenHere.add(tid)
entry.appearances++
}
if (ps[i] > entry.maxP) {
entry.maxP = ps[i]
entry.peakLayer = +layer
}
})
})
})
return [...map.values()]
.filter((e) => !hidden.has(trimTok(e.str)))
.sort((a, b) => b.appearances - a.appearances || b.maxP - a.maxP)
.slice(0, aggLimit)
}, [tick, frames, maskOn, view, layersShown, hidden, aggLimit])
const gridW = shown.length * CELL_W
const height = HEADER_H + layersShown.length * CELL_H
// Sequence token: two quick pin/unpin clicks race their POSTs — only the
// LAST request may write pinData, or a stale response resurrects a token
// that was just unpinned (ghost graph showing the raw token id).
const pinReqRef = useRef(0)
async function refreshPins(nextPinned) {
const ids = Object.keys(nextPinned).map(Number)
setPinned(nextPinned)
const reqId = ++pinReqRef.current
if (!ids.length || genId == null) {
setPinData(null)
return
}
try {
const res = await fetch('/api/lens/pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gen_id: genId, token_ids: ids }),
})
const body = await res.json()
if (!res.ok) throw new Error(body.detail || res.statusText)
if (reqId === pinReqRef.current) setPinData(body)
} catch (err) {
if (reqId === pinReqRef.current) {
onNotice?.(String(err.message || err))
setPinData(null)
}
}
}
// Keep the pin graphs in sync with the generation being VIEWED: refetch when
// it changes (new message generated / another message selected) — debounced
// on tick so a streaming generation triggers one fetch at the end, not one
// per frame batch.
const pinnedRef = useRef(pinned)
pinnedRef.current = pinned
useEffect(() => {
// never fetch mid-stream: slow generations space their frame batches past
// the debounce, and the server 409s /api/lens/pin while it generates —
// when `streaming` flips back to false this effect refires and fetches once
if (streaming) return
if (genId == null || !Object.keys(pinnedRef.current).length) return
const t = setTimeout(() => refreshPins(pinnedRef.current), 400)
return () => clearTimeout(t)
}, [genId, tick, streaming])
function togglePin(tid, str) {
const next = { ...pinned }
if (next[tid]) delete next[tid]
else next[tid] = str
if (genId == null) {
// in replay: no server curves, but the grid coloring stays available
setPinned(next)
setPinData(null)
return
}
refreshPins(next)
}
// Memoized grid: doesn't depend on the tooltip (smooth hover even with 400 columns).
const gridCells = useMemo(() => {
if (view !== 'grid') return null
return (
<>
{layersShown.map((layer, ri) => ri % 2 === 1 && (
<rect key={`z${layer}`} x="0" y={HEADER_H + ri * CELL_H} width={gridW} height={CELL_H}
fill="rgba(255,255,255,.025)" />
))}
{shown.map((f, ci) => (
<g key={ci} transform={`translate(${ci * CELL_W},0)`}>
<rect
x="0" y="0" width={CELL_W - 1} height={HEADER_H - 6}
fill={f.phase === 'reading' ? 'rgba(232,161,60,.10)' : 'rgba(108,184,224,.10)'}
/>
<text
x={CELL_W / 2} y={HEADER_H - 12}
className="lv-toklabel"
transform={`rotate(-38 ${CELL_W / 2} ${HEADER_H - 12})`}
>
{fmtTok(f.tok).slice(0, 9) || '·'}
</text>
</g>
))}
{layersShown.map((layer, ri) => (
<g key={layer} transform={`translate(0,${HEADER_H + ri * CELL_H})`}>
{shown.map((f, ci) => {
const c = cellData(f, String(layer), maskOn)
if (!c) return null
const opacity = Math.max(0.28, Math.min(1, Math.sqrt(c.p) * 2.4))
const hit = pinHit(f, String(layer), maskOn, pinnedIds)
return (
<g
key={ci}
transform={`translate(${ci * CELL_W},0)`}
className="lv-cell"
onMouseMove={(e) => {
setTip({ x: e.clientX, y: e.clientY, c, layer, ri, ci, f })
fetchTrans(c.top.map((t) => ({ tid: t.tid, str: t.s })))
}}
onClick={() => togglePin(c.tid, c.str)}
>
{/* hover capture over the whole cell (without the 1px gap between
visible rects). We do NOT clear the tooltip per cell: only a
hover leaving the whole grid clears it (see lv-scroll). So
moving from one cell to the next fires a single event (no
intermediate null state = no flicker) and hovering a gap keeps
the last info instead of jumping. */}
<rect x="0" y="0" width={CELL_W} height={CELL_H} fill="transparent" />
<rect
x="0" y="0" width={CELL_W - 1} height={CELL_H - 1}
fill={hit
? d3.color(pinColor(hit.tid)).copy({ opacity: 0.14 + 0.42 * (1 - hit.idx / Math.max(1, (lensMeta?.k ?? 8) - 1)) }).formatRgb()
: f.phase === 'reading' ? 'rgba(232,161,60,.06)' : 'rgba(108,184,224,.06)'}
stroke={pinned[c.tid] ? pinColor(c.tid) : 'transparent'}
/>
<text x="3" y={CELL_H - 7} className="lv-word" opacity={opacity}>
{fmtTok(c.str).slice(0, 7)}
{maskOn && c.rank > 0 && <tspan className="lv-rank" dy="-4">{c.rank}</tspan>}
</text>
</g>
)
})}
</g>
))}
</>
)
}, [tick, frames, maskOn, pinned, genId, view, layersShown, gridW])
// "Activations" view: L2 norm of the residual per layer/position, normalized
// PER LAYER (the norm grows strongly with depth: without this, the last layers
// would crush everything) between the min and the 95th PERCENTILE of the
// layer: the first token is an "attention sink" with a norm ~100× larger than
// the rest — without this clamp it would be the only thing visible and all the
// rest uniformly dark. Light color = high norm for the layer; outliers saturate
// to yellow (exact value in the tooltip).
const actHasData = useMemo(
() => view === 'act' && shown.some((f) => Object.values(f.layers).some((d) => d?.h_norm != null)),
[view, tick, frames],
)
const actCells = useMemo(() => {
if (view !== 'act') return null
const extent = {}
layersShown.forEach((layer) => {
const vals = []
shown.forEach((f) => {
const v = f.layers[String(layer)]?.h_norm
if (v != null) vals.push(v)
})
vals.sort((a, b) => a - b)
const lo = vals[0] ?? Infinity
const hi = vals.length ? vals[Math.floor(0.95 * (vals.length - 1))] : -Infinity
extent[layer] = [lo, hi]
})
return (
<>
{shown.map((f, ci) => (
<g key={ci} transform={`translate(${ci * CELL_W},0)`}>
<rect
x="0" y="0" width={CELL_W - 1} height={HEADER_H - 6}
fill={f.phase === 'reading' ? 'rgba(232,161,60,.10)' : 'rgba(108,184,224,.10)'}
/>
<text
x={CELL_W / 2} y={HEADER_H - 12}
className="lv-toklabel"
transform={`rotate(-38 ${CELL_W / 2} ${HEADER_H - 12})`}
>
{fmtTok(f.tok).slice(0, 9) || '·'}
</text>
</g>
))}
{layersShown.map((layer, ri) => (
<g key={layer} transform={`translate(0,${HEADER_H + ri * CELL_H})`}>
{shown.map((f, ci) => {
const v = f.layers[String(layer)]?.h_norm
const [lo, hi] = extent[layer]
const t = v == null || !isFinite(lo) ? null
: hi > lo ? Math.min(1, (v - lo) / (hi - lo)) : 0.5
return (
<g
key={ci}
transform={`translate(${ci * CELL_W},0)`}
onMouseMove={(e) => setTip({ x: e.clientX, y: e.clientY, layer, ri, ci, f, act: { v, lo, hi } })}
>
<rect x="0" y="0" width={CELL_W} height={CELL_H} fill="transparent" />
<rect
x="0" y="0" width={CELL_W - 1} height={CELL_H - 1}
fill={t == null ? 'rgba(255,255,255,.04)' : d3.interpolateInferno(0.08 + 0.84 * t)}
/>
</g>
)
})}
</g>
))}
</>
)
}, [tick, frames, view, layersShown, gridW])
// No frames (e.g. while regenerating the very first reply): render an empty
// shell instead of unmounting — unmounting would wipe the pinned tokens.
if (!shown.length) return <div className="lensview" />
return (
<div className="lensview" style={maxH ? { maxHeight: maxH, height: maxH } : undefined}>
<div className="lv-controls">
{/* ['act', 'Activations'] disabled (TODO) — the view==='act' render below
stays in place, just re-add the entry here to re-enable it */}
{[['agg', 'Frequencies'], ['grid', 'Heatmap']].map(([v, label]) => (
<button key={v} className={view === v ? 'lv-view-on' : ''}
onClick={() => { setView(v); setTip(null); setAggTip(null) }}>{label}</button>
))}
{lensMeta && (
<span className="lv-lensbadge" title={lensMeta.path || `${lensMeta.repo_id || ''} ${lensMeta.filename || ''}`.trim()}>
🔎 {lensLabel(lensMeta)}
</span>
)}
<span className="lv-sep" />
<label>
<input type="checkbox" checked={maskOn} onChange={(e) => setMaskOn(e.target.checked)} />
BPE/punctuation mask
</label>
{view === 'grid' && (
<label title="follow the right edge (latest tokens) during generation">
<input type="checkbox" checked={autoScroll} onChange={(e) => setAutoScroll(e.target.checked)} />
auto-scroll
</label>
)}
<span className="lv-sep" />
<label title="local display filter — doesn't affect the server capture">display</label>
<input
type="text"
placeholder={`all (e.g. ${layers[0]}-${layers[layers.length - 1]})`}
value={filterInput}
onChange={(e) => setFilterInput(e.target.value)}
style={{ width: 120 }}
/>
{filterSet != null && (
<span className={`lv-filter-badge ${filterEmpty ? 'err' : ''}`}>
{filterEmpty ? 'no layer matches' : `${layersShown.length}/${layers.length} layers`}
<span className="lv-filter-clear" onClick={() => setFilterInput('')}> </span>
</span>
)}
<label title="pin a token by its text — pick the variant with (˽) or without a space">pin</label>
<span className="lv-pinadd">
<input type="text" placeholder="token…" value={pinInput}
onChange={(e) => { setPinInput(e.target.value); lookupPin(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter' && pinCands.length) {
addPin(pinCands.find((c) => c.str.startsWith(' ')) || pinCands[0])
}
}}
style={{ width: 90 }} />
{pinCands.length > 0 && (
<span className="lv-pinadd-cands">
{pinCands.slice(0, 6).map((c) => (
<button key={c.id} title={`id ${c.id}`} onClick={() => addPin(c)}>{fmtTok(c.str)}</button>
))}
</span>
)}
</span>
<span className="lv-sep" />
{pinnedIds.map((tid) => (
<span key={tid} className="lv-pin" style={{ borderColor: pinColor(tid), color: pinColor(tid) }}
title={transLabel(tid) ? `${transLabel(tid)}` : undefined}
onMouseEnter={() => fetchTrans([{ tid, str: String(pinned[tid]) }])}>
<span onClick={() => togglePin(tid, pinned[tid])}>{fmtTok(pinned[tid])} </span>
<span
className="lv-ablate"
title="open the editor with this token prefilled (most-relevant layer pre-selected)"
onClick={() => onEditToken?.(+tid, String(pinned[tid]), peakLayerOf(+tid))}
> </span>
</span>
))}
<span className="fcount" style={{ marginLeft: 'auto' }}>
{frames.length} tokens · {layersShown.length}{layersShown.length !== layers.length ? `/${layers.length}` : ''} layers
{frames.length > MAX_COLS ? ` · last ${MAX_COLS} shown` : ''}
</span>
</div>
{/* visual selection of the displayed layers (click/click-drag, number on hover) —
synced with the "display" text field above via filterInput */}
<div className="lv-layerrow">
<span className="src">layers shown:</span>
<LayerPicker all={layers} value={dragLayers ?? layersShown} compact
onChange={setDragLayers} />
</div>
{view === 'agg' && (
<div className="lv-agg-wrap">
<div className="lv-agg-bar">
<label>show{' '}
{/* min/default multiples of the step: an unaligned value makes the
spinner's first click "snap" by 1 instead of stepping */}
<input type="number" min="10" max="2000" step="10" value={aggLimit}
onChange={(e) => setAggLimit(Math.max(10, Math.trunc(+e.target.value) || 10))}
style={{ width: 62 }} /> tokens
</label>
<span className="lv-sep" />
<span className="src">click = pin · right-click = hide</span>
</div>
<div className="lv-agg">
{agg.map((e) => (
<span
key={e.tid}
className={`lv-agg-word ${pinned[e.tid] ? 'lv-agg-pinned' : ''}`}
style={{
opacity: Math.max(0.35, Math.min(1, Math.sqrt(e.maxP) * 2.4)),
fontSize: `${Math.min(19, 11 + Math.log2(e.appearances) * 1.6)}px`,
...(pinned[e.tid] ? { color: pinColor(e.tid) } : {}),
}}
onMouseEnter={(ev) => {
setAggTip({ x: ev.clientX, y: ev.clientY, e })
fetchTrans([{ tid: e.tid, str: e.str }])
}}
onMouseLeave={() => setAggTip(null)}
onClick={() => togglePin(e.tid, e.str)}
onContextMenu={(ev) => { ev.preventDefault(); onHideToken(e.str); setAggTip(null) }}
>
{fmtTok(e.str)}<sup>{e.appearances}</sup>
</span>
))}
{agg.length === 0 && <span className="src">no token to show (all hidden or no frames)</span>}
</div>
</div>
)}
{aggTip && (() => {
const e = aggTip.e
const trans = NONLATIN_RE.test(e.str || '') ? transCache.current.get(e.tid) : null
return (
<Tip x={aggTip.x} y={aggTip.y}>
<div className="lv-tip-head">"{fmtTok(e.str)}" click = pin</div>
<div className="lv-tip-row"><span>appearances</span><span>{e.appearances}</span></div>
<div className="lv-tip-row"><span>peak</span><span>L{e.peakLayer}</span></div>
<div className="lv-tip-row"><span>max p</span><span>{(e.maxP * 100).toFixed(2)} %</span></div>
{trans?.length > 0 && (
<>
<div className="lv-tip-head" style={{ marginTop: 6 }}> nearest tokens (W_U cosine)</div>
{trans.map((n) => (
<div key={n.id} className="lv-tip-row">
<span className="lv-trans">{fmtTok(n.str)}</span>
<span>{n.sim.toFixed(2)}</span>
</div>
))}
</>
)}
</Tip>
)
})()}
{view === 'grid' && (
<div className="lv-gridwrap" ref={gridScrollRef}>
<svg width={LABEL_W} height={height} className="lv-svg lv-labels">
{layersShown.map((layer, ri) => (
<g key={layer} transform={`translate(0,${HEADER_H + ri * CELL_H})`}>
{ri % 2 === 1 && <rect x="0" y="0" width={LABEL_W} height={CELL_H} fill="rgba(255,255,255,.025)" />}
<text
x={LABEL_W - 6} y={CELL_H / 2 + 4}
className={`lv-laylabel ${tip?.layer === layer ? 'lv-laylabel-hot' : ''}`}
>L{layer}</text>
</g>
))}
</svg>
<div className="lv-scroll" onMouseLeave={() => setTip(null)}>
<svg width={gridW} height={height} className="lv-svg">
{gridCells}
{tip && tip.ri != null && (
<g pointerEvents="none">
<rect x="0" y={HEADER_H + tip.ri * CELL_H} width={gridW} height={CELL_H - 1}
fill="rgba(108,184,224,.08)" stroke="rgba(108,184,224,.35)" />
<rect x={tip.ci * CELL_W} y="0" width={CELL_W - 1} height={height}
fill="rgba(108,184,224,.06)" />
</g>
)}
</svg>
</div>
</div>
)}
{view === 'act' && (
<div className="lv-gridwrap">
<svg width={LABEL_W} height={height} className="lv-svg lv-labels">
{layersShown.map((layer, ri) => (
<g key={layer} transform={`translate(0,${HEADER_H + ri * CELL_H})`}>
{ri % 2 === 1 && <rect x="0" y="0" width={LABEL_W} height={CELL_H} fill="rgba(255,255,255,.025)" />}
<text
x={LABEL_W - 6} y={CELL_H / 2 + 4}
className={`lv-laylabel ${tip?.layer === layer ? 'lv-laylabel-hot' : ''}`}
>L{layer}</text>
</g>
))}
</svg>
<div className="lv-scroll" onMouseLeave={() => setTip(null)}>
{!actHasData && (
<div className="status-line" style={{ padding: '6px 8px' }}>
activation norms are absent from these frames regenerate (new
generations capture them)
</div>
)}
<svg width={gridW} height={height} className="lv-svg">
{actCells}
{tip && tip.ri != null && (
<g pointerEvents="none">
<rect x="0" y={HEADER_H + tip.ri * CELL_H} width={gridW} height={CELL_H - 1}
fill="none" stroke="rgba(108,184,224,.45)" />
<rect x={tip.ci * CELL_W} y="0" width={CELL_W - 1} height={height}
fill="rgba(108,184,224,.06)" />
</g>
)}
</svg>
</div>
</div>
)}
{tip && tip.act && (
<Tip x={tip.x} y={tip.y}>
<div className="lv-tip-head">
pos {tip.f.pos} · L{tip.layer} · {tip.f.phase === 'reading' ? 'reading' : 'thinking'} · tok «{fmtTok(tip.f.tok)}»
</div>
{tip.act.v == null ? (
<div className="lv-tip-row"><span>h unavailable (older generation)</span></div>
) : (
<>
<div className="lv-tip-row"><span>h (residual norm)</span><span>{tip.act.v}</span></div>
<div className="lv-tip-row"><span>layer range</span><span>{tip.act.lo} {tip.act.hi}</span></div>
</>
)}
</Tip>
)}
{tip && !tip.act && (() => {
// non-latin tokens of the cell whose "nearest tokens" we show at the tooltip's end
const nonLatin = tip.c.top.filter((t) => NONLATIN_RE.test(t.s || ''))
return (
<Tip x={tip.x} y={tip.y}>
<div className="lv-tip-head">
pos {tip.f.pos} · L{tip.layer} · {tip.f.phase === 'reading' ? 'reading' : 'thinking'} · tok «{fmtTok(tip.f.tok)}»
</div>
{tip.c.top.map((t, i) => (
<div key={i} className="lv-tip-row" style={pinned[t.tid] ? { color: pinColor(t.tid) } : undefined}>
<span>{fmtTok(t.s)}</span>
<span>r{t.r} · {(t.p * 100).toFixed(2)}%</span>
</div>
))}
{nonLatin.map((t) => {
const list = transCache.current.get(t.tid)
if (!list?.length) return null
return (
<div key={`tr${t.tid}`} className="lv-tip-trans">
<div className="lv-tip-head">"{fmtTok(t.s)}" nearest tokens (W_U cosine)</div>
{list.slice(0, 6).map((n) => (
<div key={n.id} className="lv-tip-row">
<span className="lv-trans">{fmtTok(n.str)}</span>
<span>{n.sim.toFixed(2)}</span>
</div>
))}
</div>
)
})}
</Tip>
)
})()}
{pinData && (
<div className="lv-pins" ref={pinsRef}>
{/* only tokens still pinned: a stale pinData must never resurrect an
unpinned token's graph */}
{Object.entries(pinData.pins).filter(([tid]) => pinned[tid]).map(([tid, data]) => {
const N = pinData.positions.length
const L = pinData.layers.length
// width: fills the container (PIN_FILL_WIDTH), at least
// PIN_PX_PER_TOKEN per position, bounded by PIN_MIN_W / PIN_MAX_W
const fillW = PIN_FILL_WIDTH && pinsW ? pinsW - 46 - 10 : 0
const w = Math.min(PIN_MAX_W, Math.max(PIN_MIN_W, N * PIN_PX_PER_TOKEN, fillW))
const curveH = PIN_CURVE_H
const rowH = Math.max(PIN_ROW_H_MIN, Math.min(PIN_ROW_H_MAX, Math.floor(PIN_MAP_H / L)))
const mapH = L * rowH
// column bands: position pi occupies [xL(pi), xL(pi)+cw] — the
// curve passes through the centers. A [0, N-1] → [0, w] point
// scale would push the LAST heatmap column past the svg edge
// (clipped) and misalign the hover column math.
const cw = w / N
const xL = (pi) => 40 + pi * cw
const xC = (pi) => 40 + (pi + 0.5) * cw
const y = d3.scaleLinear([0, 5.2], [2, curveH - 2])
const layerColor = (li) => d3.interpolateCool(0.15 + 0.7 * (li / Math.max(1, L - 1)))
const hov = pinHover?.tid === tid ? pinHover.li : null
const hovPi = pinHover?.tid === tid ? pinHover.pi : null
// layer labels: all if few, otherwise sampled
const labelEvery = L <= 16 ? 1 : Math.ceil(L / 12)
return (
<div key={tid} className="lv-pinblock">
<div className="lv-pinname" style={{ color: pinColor(+tid) }}>
"{fmtTok(pinned[tid] ?? tid)}"
<button className="lv-copybtn" title="copy the token to the clipboard (without spaces)"
onClick={() => copyText(String(pinned[tid] ?? ''))}></button>
<span className="lv-pinsub"> token rank per layer and position</span>
{hov != null && (
<span className="lv-pinhov">
{' '}· hover: L{pinData.layers[hov]}
{hovPi != null && pinData.tokens?.[hovPi] != null
? `${fmtTok(pinData.tokens[hovPi])}` : ''}
</span>
)}
</div>
{/* legend: one chip per layer, hover = highlights the curve and the row */}
<div className="lv-laylegend">
{pinData.layers.map((layer, li) => (
<span
key={layer}
className={`lv-laychip ${hov != null && hov !== li ? 'dim' : ''}`}
style={{ borderColor: layerColor(li), color: layerColor(li) }}
onMouseEnter={() => setPinHover({ tid, li })}
onMouseLeave={() => setPinHover(null)}
>L{layer}</span>
))}
</div>
<svg width={w + 46} height={curveH + 10}>
{[0, 1, 2, 3, 4, 5].map((d) => (
<g key={d}>
<line x1="40" x2={w + 40} y1={y(d)} y2={y(d)} className="lv-grid" />
<text x="36" y={y(d) + 3} className="lv-axis">{d === 0 ? '1' : `1e${d}`}</text>
</g>
))}
{data.ranks.map((layerRanks, li) => (
<polyline
key={li}
fill="none"
stroke={layerColor(li)}
strokeWidth={hov === li ? 2.4 : 1.2}
opacity={hov == null ? 0.75 : hov === li ? 1 : 0.12}
points={layerRanks.map((r, pi) => `${xC(pi)},${y(Math.log10(r + 1))}`).join(' ')}
onMouseEnter={() => setPinHover({ tid, li })}
onMouseLeave={() => setPinHover(null)}
style={{ cursor: 'pointer' }}
/>
))}
</svg>
<svg width={w + 46} height={mapH + 16}
onMouseMove={(e) => {
// continuous hover: each Y pixel of the heatmap falls on a row
// (layer), with no gap between rows → no more jumping
const rect = e.currentTarget.getBoundingClientRect()
const ry = e.clientY - rect.top
const li = Math.floor(ry / rowH)
// column = token position (X axis), to show the hovered token
const pi = Math.max(0, Math.min(N - 1,
Math.floor((e.clientX - rect.left - 40) / cw)))
if (li >= 0 && li < L) setPinHover({ tid, li, pi })
}}
onMouseLeave={() => setPinHover(null)}
>
{data.ranks.map((layerRanks, li) => (
<g key={li}>
{(li % labelEvery === 0 || hov === li) && (
<text x="36" y={li * rowH + rowH - 1}
className={`lv-axis ${hov === li ? 'lv-axis-hot' : ''}`}>L{pinData.layers[li]}</text>
)}
{layerRanks.map((r, pi) => (
<rect
key={pi}
x={xL(pi)} y={li * rowH}
width={Math.max(1.5, cw)} height={rowH - 1}
fill={rankColor(r)}
opacity={hov == null || hov === li ? 1 : 0.25}
/>
))}
{hov === li && (
<rect x="40" y={li * rowH} width={w} height={rowH - 1}
fill="none" stroke="var(--accent2)" strokeWidth="1" pointerEvents="none" />
)}
</g>
))}
<text x="40" y={mapH + 12} className="lv-axis" style={{ textAnchor: 'start' }}>
color = rank (light = rank 1) · X axis = token position · hover a layer above
</text>
</svg>
</div>
)
})}
</div>
)}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(<App />)
+455
View File
@@ -0,0 +1,455 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #14161a;
--panel: #1c1f26;
--panel2: #23272f;
--border: #333842;
--text: #d8dce3;
--muted: #8b93a1;
--accent: #e8a13c;
--accent2: #6cb8e0;
--danger: #d0654f;
}
body { background: var(--bg); color: var(--text); font: 14px/1.45 'Segoe UI', system-ui, sans-serif; }
#root { display: flex; height: 100vh; }
.sidebar {
width: 300px; flex-shrink: 0; background: var(--panel);
border-right: 1px solid var(--border); padding: 14px;
display: flex; flex-direction: column; gap: 12px; overflow-y: auto;
}
.sidebar h1 { font-size: 17px; color: var(--accent); }
.sidebar h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-top: 4px; }
.model-list { display: flex; flex-direction: column; gap: 4px; }
.model-item {
padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px;
cursor: pointer; font-size: 13px; word-break: break-all;
}
.model-item:hover { background: var(--panel2); }
.model-item.selected { border-color: var(--accent); background: var(--panel2); }
.model-item .src { color: var(--muted); font-size: 11px; }
.model-item.loaded { border-color: var(--accent2); }
.model-item-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 6px; }
.model-del {
padding: 0 5px; font-size: 12px; border: none; background: transparent;
flex-shrink: 0; opacity: .55; display: inline-flex; align-items: center;
}
.model-item:hover .model-del { opacity: 1; }
.model-del:disabled { opacity: .2; }
/* red = deletes the FILES from disk; blue = only forgets a registered entry */
.model-trash { color: var(--danger); }
.model-trash:hover:not(:disabled) { color: #ff7a6b; border-color: transparent; }
.model-unreg { color: var(--accent2); }
.model-unreg:hover:not(:disabled) { color: #9fd4f2; border-color: transparent; }
.row { display: flex; gap: 6px; align-items: center; }
.row label { color: var(--muted); font-size: 12px; width: 58px; flex-shrink: 0; }
select, input[type="text"], input[type="number"], textarea {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 5px; padding: 5px 7px; font: inherit; width: 100%;
}
textarea { resize: vertical; }
button {
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 12px; cursor: pointer; font: inherit;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: default; }
button.primary { background: var(--accent); color: #14161a; border-color: var(--accent); font-weight: 600; }
button.danger { border-color: var(--danger); color: var(--danger); }
.gpu { margin-bottom: 8px; }
.gpu .name { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; }
.gpu .bar { height: 8px; background: var(--panel2); border-radius: 4px; overflow: hidden; margin-top: 3px; }
.gpu .fill { height: 100%; background: var(--accent2); transition: width .5s; }
.dl-bar { height: 5px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 3px; }
.dl-fill { height: 100%; background: var(--accent); transition: width .4s; }
/* flex-shrink 0: as a flex item of the (full) sidebar it used to get squeezed
to min-height, and the download progress bar overflowed onto "Browse" */
.status-line { font-size: 12px; color: var(--muted); min-height: 16px; word-break: break-word; flex-shrink: 0; }
.status-line .ok { color: var(--accent2); }
.status-line .err { color: var(--danger); }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.messages { flex: 1; overflow-y: auto; padding: 18px 22px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 780px; padding: 10px 14px; border-radius: 10px; white-space: pre-wrap; word-break: break-word; }
.msg.user { background: var(--panel2); align-self: flex-end; }
.msg.assistant { background: var(--panel); border: 1px solid var(--border); align-self: flex-start; }
.msg .msgmeta { font-size: 11px; color: var(--muted); margin-top: 6px; }
/* markdown rendering inside assistant bubbles (the bubble keeps pre-wrap for
plain text; .md content manages its own whitespace) */
.msg .md { white-space: normal; }
.msg .md > :first-child { margin-top: 0; }
.msg .md > :last-child { margin-bottom: 0; }
.msg .md p, .msg .md ul, .msg .md ol, .msg .md pre, .msg .md blockquote,
.msg .md h1, .msg .md h2, .msg .md h3, .msg .md h4, .msg .md table { margin: 6px 0; }
.msg .md h1, .msg .md h2 { font-size: 15px; }
.msg .md h3, .msg .md h4 { font-size: 13.5px; }
.msg .md ul, .msg .md ol { padding-left: 20px; }
.msg .md code {
background: var(--panel2); padding: 1px 4px; border-radius: 4px;
font-size: 12px; font-family: Consolas, 'Fira Code', monospace;
}
.msg .md pre {
background: var(--panel2); border: 1px solid var(--border); border-radius: 6px;
padding: 8px 10px; overflow-x: auto;
}
.msg .md pre code { background: none; padding: 0; }
.msg .md table { border-collapse: collapse; }
.msg .md th, .msg .md td { border: 1px solid var(--border); padding: 3px 8px; font-size: 12.5px; }
.msg .md blockquote { border-left: 3px solid var(--border); padding-left: 10px; color: var(--muted); }
.msg .md a { color: var(--accent2); }
/* inline edit of an assistant reply */
.msg-edit textarea { width: 100%; min-width: 420px; resize: vertical; }
.msg-edit-actions { display: flex; gap: 8px; margin-top: 6px; }
.composer { border-top: 1px solid var(--border); padding: 12px 22px; display: flex; flex-direction: column; gap: 8px; }
.composer .controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.composer .controls label { font-size: 12px; color: var(--muted); }
.composer .controls input { width: 64px; }
.composer .inputrow { display: flex; gap: 8px; }
.composer .inputrow textarea { flex: 1; min-height: 44px; max-height: 160px; }
.tabs { display: flex; gap: 4px; }
.tabs button { flex: 1; padding: 5px 0; font-size: 12px; border-radius: 6px 6px 0 0; border-bottom: 2px solid transparent; }
.tabs button.tab-on { border-bottom-color: var(--accent); color: var(--accent); font-weight: 600; }
.exp-tag {
font-size: 9px; color: var(--danger); border: 1px solid var(--danger);
border-radius: 6px; padding: 0 5px; vertical-align: middle; letter-spacing: .05em;
}
/* export greyed out when the "per-layer steering" mode doesn't produce a faithful checkpoint */
.exp-grid { display: flex; flex-direction: column; gap: 7px; transition: opacity .18s; }
.exp-grid-off { opacity: .4; pointer-events: none; }
.exp-disabled-note { color: var(--danger); }
.diff-btn { padding: 0 6px; font-size: 11px; margin-left: 6px; }
.diff-table { border-collapse: collapse; font-size: 11px; }
.diff-table th { color: var(--muted); font-weight: 400; padding: 2px 4px; max-width: 60px; overflow: hidden; }
.diff-table td { padding: 1px 4px; border: 1px solid rgba(255,255,255,.04); white-space: nowrap; }
.diff-lay { color: var(--muted); }
.diff-same { opacity: .45; }
.diff-diff { outline: 1px solid var(--accent); }
.diff-a { color: var(--accent2); }
.diff-b { color: var(--accent); }
.lv-ablate { cursor: pointer; }
.lv-ablate:hover { color: var(--danger); }
.reg-list { display: flex; flex-direction: column; gap: 3px; }
.reg-item {
display: flex; justify-content: space-between; align-items: center; gap: 6px;
font-size: 12px; padding: 4px 7px; border: 1px solid var(--border); border-radius: 5px;
}
.reg-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reg-item button { padding: 2px 8px; font-size: 12px; flex-shrink: 0; }
.preset-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.preset-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preset-model { font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reg-item2 {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
}
.reg-item2.reg-warn { border-color: var(--danger); }
.reg-item2 .reg-main { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 1px; }
.reg-item2 button { padding: 3px 9px; font-size: 12px; flex-shrink: 0; align-self: center; }
.reg-name { font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reg-tag {
font-size: 9px; text-transform: uppercase; letter-spacing: .05em; padding: 0 5px;
border-radius: 7px; background: var(--panel2); color: var(--muted); font-weight: 600;
}
.reg-tag.hub { background: rgba(108,184,224,.18); color: var(--accent2); }
.reg-tag.base { background: rgba(140,220,160,.16); color: #7fce8f; }
.reg-reason { color: var(--danger) !important; white-space: normal; }
.linkbtn {
background: none; border: none; padding: 0; color: var(--accent2);
font-size: 11px; cursor: pointer; text-decoration: underline;
}
.linkbtn:hover { color: var(--accent); }
.linkbtn:disabled { color: var(--muted); cursor: default; text-decoration: none; }
.browser { border: 1px solid var(--border); border-radius: 6px; padding: 6px; }
.browser-path { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 5px; }
.browser-path .src { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.browser-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
.browser-row {
display: flex; justify-content: space-between; align-items: center; gap: 6px;
padding: 2px 4px; border-radius: 4px; font-size: 12px;
}
.browser-row:hover { background: var(--panel2); }
.browser-row.is-model { color: var(--accent); }
.browser-name { cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.browser-row button { padding: 1px 8px; font-size: 11px; flex-shrink: 0; }
.fit-adv { border-left: 2px solid var(--border); padding-left: 8px; display: flex; flex-direction: column; gap: 6px; }
.conv-list { display: flex; flex-direction: column; gap: 4px; max-height: 220px; overflow-y: auto; }
.conv-item { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 12px; }
.conv-item:hover { background: var(--panel2); }
.conv-item.selected { border-color: var(--accent2); }
.conv-title { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
.conv-del { padding: 0 5px; font-size: 10px; border: none; color: var(--muted); }
.conv-del:hover { color: var(--danger); }
.conv-snippet { color: var(--muted); font-size: 11px; margin-top: 2px; }
.conv-header {
display: flex; gap: 8px; align-items: center; padding: 8px 22px;
border-bottom: 1px solid var(--border); background: var(--panel);
}
.conv-title-input { flex: 1; font-weight: 600; }
.conv-tags-input { width: 220px; }
.conv-header a { color: var(--accent2); font-size: 12px; text-decoration: none; border: 1px solid var(--border); border-radius: 5px; padding: 4px 9px; }
.conv-header a:hover { border-color: var(--accent2); }
.tree-panel {
border-bottom: 1px solid var(--border); background: var(--panel);
padding: 8px 14px; max-height: 220px; overflow-y: auto; font-size: 12px;
}
.tree-node { display: flex; gap: 7px; align-items: baseline; padding: 2px 4px; cursor: pointer; border-radius: 4px; white-space: nowrap; overflow: hidden; }
.tree-node:hover { background: var(--panel2); }
.tree-node.on-path { background: rgba(108,184,224,.10); }
.tn-role { width: 14px; text-align: center; border-radius: 3px; font-size: 10px; font-weight: 700; flex-shrink: 0; }
.tn-role.user { color: var(--accent); }
.tn-role.assistant { color: var(--accent2); }
.tn-role.system { color: var(--muted); }
.tn-text { overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.tn-frames { color: var(--accent); }
.tree-hint { color: var(--muted); font-size: 11px; margin-top: 5px; }
.fork-note { color: var(--accent); font-size: 12px; }
.msg.has-frames { cursor: pointer; }
.msg.msg-selected { outline: 1px solid var(--accent2); }
.lensview {
border-top: 1px solid var(--border); background: var(--panel);
max-height: 46vh; overflow-y: auto; padding: 6px 12px; flex-shrink: 0;
}
/* drag handle above the lens view: sets its height manually */
.v-resizer {
height: 6px; cursor: ns-resize; flex-shrink: 0;
background: linear-gradient(var(--border), transparent);
}
.v-resizer:hover { background: var(--accent2); opacity: .5; }
.lv-controls { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); padding: 4px 0 8px; flex-wrap: wrap; }
.lv-controls input[type="text"] { padding: 3px 6px; font-size: 12px; }
.lv-controls button { padding: 3px 10px; font-size: 12px; }
.lv-sep { width: 1px; height: 16px; background: var(--border); }
.lv-pin { background: rgba(232,161,60,.15); color: var(--accent); border: 1px solid var(--accent); border-radius: 9px; padding: 1px 8px; cursor: pointer; font-size: 12px; }
/* single scroll container (H + V): the bars stay at the edges so they're always
visible, even when the grid overflows in height. The label column is pinned to
the left (sticky) and scrolls vertically with the grid. */
.lv-gridwrap { display: flex; align-items: flex-start; overflow: auto; max-height: 34vh; }
.lv-labels { position: sticky; left: 0; z-index: 2; background: var(--panel); flex-shrink: 0; }
.lv-scroll { flex-shrink: 0; }
.lv-laylabel-hot { fill: var(--accent2); font-weight: 700; }
.lv-filter-badge { color: var(--accent2); font-size: 11px; white-space: nowrap; }
.lv-filter-badge.err { color: var(--danger); }
.lv-filter-clear { cursor: pointer; }
.lv-filter-clear:hover { color: var(--danger); }
.lv-agg { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: baseline; padding: 6px 2px; }
.lv-agg-word { cursor: pointer; }
.lv-agg-word:hover { color: var(--accent2); }
.lv-agg-word.lv-agg-pinned { color: var(--accent); text-decoration: underline; }
.lv-agg-word sup { font-size: 9px; color: var(--muted); }
.lv-agg-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--muted); padding: 2px 2px 6px; }
.lv-agg-bar input[type="number"], .lv-agg-bar input[type="text"] { padding: 3px 6px; font-size: 12px; }
.lv-hidden-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 0 2px 8px; }
.lv-hidden-chip {
font-size: 11px; color: var(--muted); border: 1px solid var(--border);
border-radius: 5px; padding: 1px 6px; cursor: pointer; max-width: 160px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lv-hidden-chip:hover { border-color: var(--accent2); color: var(--accent2); }
.lv-view-on { border-color: var(--accent) !important; color: var(--accent); }
.lv-pinadd { position: relative; display: inline-flex; }
.lv-pinadd input { padding: 3px 6px; font-size: 12px; }
.lv-pinadd-cands {
position: absolute; top: 100%; left: 0; margin-top: 3px; z-index: 12;
display: flex; gap: 3px; flex-wrap: wrap; max-width: 240px;
background: #0f1114; border: 1px solid var(--border); border-radius: 6px;
padding: 4px; box-shadow: 0 4px 14px rgba(0,0,0,.5);
}
.lv-pinadd-cands button { padding: 2px 7px; font-size: 12px; }
.lv-layerrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 2px 6px; }
.lv-layerrow .layerpicker { flex: 1; min-width: 0; }
.lv-lensbadge {
font-size: 11px; color: var(--accent2); border: 1px solid var(--border);
border-radius: 6px; padding: 1px 8px; max-width: 240px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.lv-svg { display: block; }
.lv-toklabel { fill: var(--muted); font-size: 10px; text-anchor: start; }
.lv-laylabel { fill: var(--muted); font-size: 10px; text-anchor: end; }
.lv-word { fill: var(--text); font-size: 11px; }
.lv-rank { fill: var(--muted); font-size: 7.5px; }
.lv-cell { cursor: pointer; }
.lv-cell:hover rect { stroke: var(--accent2); }
.lv-tip {
position: fixed; z-index: 10; background: #0f1114; border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-size: 12px; pointer-events: none;
max-width: 260px; box-shadow: 0 4px 18px rgba(0,0,0,.5);
}
.lv-tip-head { color: var(--muted); font-size: 11px; margin-bottom: 5px; }
.lv-trans { color: var(--accent2); font-size: 11px; font-style: italic; }
.lv-tip-row { display: flex; justify-content: space-between; gap: 14px; }
.lv-tip-row span:last-child { color: var(--muted); }
.lv-tip-trans { border-top: 1px solid var(--border); margin-top: 6px; padding-top: 5px; }
.lv-tip-trans .lv-tip-head { margin-bottom: 3px; }
.lv-pins { border-top: 1px solid var(--border); margin-top: 8px; padding-top: 8px; }
.lv-pinblock { margin-bottom: 12px; overflow-x: auto; }
/* graph on top, heatmap below — never side by side (svgs are inline by default) */
.lv-pinblock svg { display: block; }
.lv-pinname { font-size: 12px; color: var(--accent); margin-bottom: 4px; }
.lv-pinsub { color: inherit; }
.lv-copybtn {
padding: 0 5px; font-size: 12px; margin: 0 2px; line-height: 1.4;
border: 1px solid var(--border); border-radius: 4px; color: var(--muted);
background: var(--panel2); cursor: pointer; vertical-align: baseline;
}
.lv-copybtn:hover { color: var(--accent2); border-color: var(--accent2); }
.lv-pinhov { color: var(--accent2); font-weight: 600; }
.lv-grid { stroke: rgba(255,255,255,.06); }
.lv-axis { fill: var(--muted); font-size: 9px; text-anchor: end; }
.lv-axis-hot { fill: var(--accent2); font-weight: 700; }
.lv-laylegend { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 5px; }
.lv-laychip {
font-size: 10px; padding: 0 5px; border: 1px solid; border-radius: 8px;
cursor: pointer; user-select: none; line-height: 1.5;
}
.lv-laychip.dim { opacity: .3; }
.lens-live {
border-top: 1px solid var(--border); background: var(--panel);
padding: 8px 22px; max-height: 240px; overflow-y: auto;
}
.lens-head { display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.phase { padding: 1px 8px; border-radius: 8px; font-weight: 600; }
.phase.reading { background: rgba(232, 161, 60, .18); color: var(--accent); }
.phase.thinking { background: rgba(108, 184, 224, .18); color: var(--accent2); }
.lens-rows { display: flex; flex-direction: column; gap: 2px; }
.lens-row { display: flex; gap: 10px; align-items: baseline; white-space: nowrap; overflow: hidden; }
.lens-row .lnum { color: var(--muted); font-size: 11px; width: 34px; flex-shrink: 0; }
.lword { font-size: 13px; }
.lword sup { font-size: 9px; color: var(--muted); }
.sys { padding: 8px 22px; border-bottom: 1px solid var(--border); background: var(--panel); }
.sys summary { cursor: pointer; font-size: 12px; color: var(--muted); }
.sys textarea { margin-top: 6px; min-height: 40px; }
/* ---- Token editor ---- */
.editor {
width: 400px; flex-shrink: 0; background: var(--panel);
border-left: 1px solid var(--border);
display: flex; flex-direction: column; min-height: 0;
}
.ed-head {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px; border-bottom: 1px solid var(--border);
color: var(--accent); font-weight: 600;
}
.ed-close { padding: 2px 9px; }
.ed-body { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 16px; }
.ed-section { display: flex; flex-direction: column; gap: 7px; }
.ed-section h3 {
font-size: 12px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); font-weight: 600;
}
.editor .src { color: var(--muted); font-size: 11px; }
.ed-flash { animation: ed-flash 1.5s ease-out; border-radius: 8px; }
@keyframes ed-flash {
0% { background: rgba(232,161,60,.25); box-shadow: 0 0 0 2px var(--accent); }
100% { background: transparent; box-shadow: none; }
}
.ed-toggle-on { border-color: var(--accent) !important; color: var(--accent); }
/* Mode toggle: steering (exploration) ↔ read projection (exportable).
Sliding thumb, two halves tiling the track (4px insets, seam at the center). */
.mode-toggle {
position: relative; display: grid; grid-template-columns: 1fr 1fr;
background: var(--panel2); border: 1px solid var(--border); border-radius: 10px;
padding: 4px; isolation: isolate; user-select: none;
}
.mt-thumb {
position: absolute; z-index: 0; top: 4px; bottom: 4px; left: 4px;
width: calc(50% - 4px); border-radius: 7px;
transition: transform .24s cubic-bezier(.34,1.2,.44,1), background .2s, box-shadow .2s;
}
.mt-steer .mt-thumb {
transform: translateX(0);
background: linear-gradient(135deg, rgba(108,184,224,.92), rgba(108,184,224,.6));
box-shadow: 0 2px 12px rgba(108,184,224,.4);
}
.mt-read .mt-thumb {
transform: translateX(100%);
background: linear-gradient(135deg, rgba(126,201,126,.94), rgba(126,201,126,.62));
box-shadow: 0 2px 12px rgba(126,201,126,.4);
}
.mt-opt {
position: relative; z-index: 1; background: none; border: none; border-radius: 7px;
display: flex; flex-direction: column; align-items: center; gap: 3px;
padding: 9px 6px 8px; cursor: pointer; color: var(--muted);
transition: color .18s; text-align: center; line-height: 1.15;
}
.mt-opt:hover:not(.mt-on) { color: var(--text); }
.mt-opt svg { opacity: .92; margin-bottom: 1px; }
.mt-lab { font-size: 12px; font-weight: 600; }
.mt-sub { font-size: 9.5px; letter-spacing: .02em; opacity: .82; }
.mt-on { color: #14161a; }
.mt-on .mt-sub { opacity: .74; }
.ed-rule { border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; }
.ed-rule-off { opacity: .5; }
.ed-rule-toggle {
padding: 0 5px; font-size: 12px; border: none; background: transparent;
color: var(--accent2); flex-shrink: 0; line-height: 1;
}
.ed-rule-off .ed-rule-toggle { color: var(--muted); }
.ed-rule-toggle:hover { color: var(--accent); border-color: transparent; }
.ed-rule-main { display: flex; align-items: center; gap: 6px; }
.ed-rule-tok {
font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1; min-width: 96px;
}
.ed-rule-factor { width: 62px !important; flex-shrink: 0; padding: 3px 5px !important; font-size: 12px !important; }
.ed-rule-del { padding: 1px 7px; font-size: 11px; border: none; color: var(--muted); flex-shrink: 0; }
.ed-rule-del:hover { color: var(--danger); }
.ed-rule-layers { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; }
.rulebar {
display: flex; gap: 1px; cursor: pointer; flex-shrink: 1;
min-width: 42px; max-width: 118px; overflow: hidden;
padding: 3px 2px; border-radius: 4px; align-items: center;
}
.rulebar:hover { background: var(--panel2); }
.rb-seg { width: 2px; min-width: 1px; height: 12px; background: var(--panel2); border-radius: 1px; }
.rb-seg.on { background: var(--accent); }
.layerpicker { display: flex; flex-direction: column; gap: 5px; }
.lp-cells { display: flex; flex-wrap: wrap; gap: 3px; }
.lp-cell {
min-width: 22px; text-align: center; font-size: 11px; padding: 2px 3px;
border: 1px solid var(--border); border-radius: 4px; cursor: pointer;
color: var(--muted); user-select: none;
}
.lp-cell:hover { border-color: var(--accent2); }
.lp-cell.on { background: rgba(232,161,60,.2); border-color: var(--accent); color: var(--accent); }
.lp-cell.lp-approx { border-style: dashed; opacity: .75; }
.lp-quick { display: flex; gap: 5px; }
.lp-quick button { padding: 2px 9px; font-size: 11px; }
.ed-cands { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; font-size: 11px; }
.ed-cands button { padding: 2px 8px; font-size: 12px; }
.ed-cands .ed-cand-on { border-color: var(--accent); color: var(--accent); background: rgba(232,161,60,.12); }
.ed-group { border: 1px solid var(--accent2); border-radius: 8px; padding: 9px; }
.ed-group h3 { color: var(--accent2); }
+7
View File
@@ -0,0 +1,7 @@
// Token display: leading/trailing spaces made visible with ˽ (like the original
// JLens) — ' Paris' → '˽Paris', 'foo ' → 'foo˽'. Without this it's impossible to
// tell ' Euro' from 'Euro' in the UI.
export function fmtTok(s) {
if (s == null) return ''
return String(s).replace(/^ +| +$/g, (m) => '˽'.repeat(m.length))
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Dev proxy target: the backend port (run.py --port), overridable so a
// non-default instance can be hot-reload developed too.
const backend = `127.0.0.1:${process.env.JWASH_PORT || 8381}`
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': `http://${backend}`,
'/ws': { target: `ws://${backend}`, ws: true },
},
},
})