Cleanup init commit
This commit is contained in:
+1730
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />)
|
||||
@@ -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); }
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user