1731 lines
76 KiB
React
1731 lines
76 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { marked } from 'marked'
|
||
import DOMPurify from 'dompurify'
|
||
import LensView from './LensView.jsx'
|
||
import LensDiff from './Diff.jsx'
|
||
import Editor from './Editor.jsx'
|
||
import { fmtTok } from './tok'
|
||
|
||
const GB = 2 ** 30
|
||
|
||
marked.setOptions({ breaks: true, gfm: true })
|
||
|
||
// Markdown bubble content: parsed + sanitized (a local model can still emit
|
||
// arbitrary HTML — never inject it raw).
|
||
function Md({ text }) {
|
||
const html = useMemo(() => DOMPurify.sanitize(marked.parse(text || '')), [text])
|
||
return <div className="md" dangerouslySetInnerHTML={{ __html: html }} />
|
||
}
|
||
|
||
// Polluter tokens excluded by default from the Frequencies token cloud (compared
|
||
// on the form without edge spaces). Managed at the bottom of the Chat tab.
|
||
const DEFAULT_HIDDEN = [
|
||
'热门推荐', '阅读全文', '网友评论', '点击查看答案', '查看全文', '最新发布', '展开全文',
|
||
'相关问答', '続きを読む', 'Читать', 'Weiterlesen', 'スポンサーリンク',
|
||
]
|
||
const trimTok = (s) => (s || '').replace(/^\s+|\s+$/g, '')
|
||
|
||
const LENS_PRESETS = {
|
||
'Qwen/Qwen3.5-4B': {
|
||
repo_id: 'neuronpedia/jacobian-lens',
|
||
revision: 'qwen-n1000',
|
||
filename: 'qwen3.5-4b/jlens/Salesforce-wikitext/Qwen3.5-4B_jacobian_lens_n1000.pt',
|
||
},
|
||
'Qwen/Qwen3-4B': {
|
||
repo_id: 'neuronpedia/jacobian-lens',
|
||
revision: 'main',
|
||
filename: 'qwen3-4b/jlens/Salesforce-wikitext/Qwen3-4B_jacobian_lens.pt',
|
||
},
|
||
}
|
||
|
||
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 SAMPLING_DEFAULT = { temperature: 0.7, top_p: 0.95, top_k: 40, max_tokens: 512, seed: -1 }
|
||
|
||
// Human-readable name of the loaded lens (local path or Hub file) for "which lens do I have?".
|
||
function lensName(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'
|
||
}
|
||
|
||
// Note about the chat template fetched when loading a base model.
|
||
function templateNote(meta) {
|
||
const src = meta.chat_template_source
|
||
if (!src) return { suffix: '', warn: false } // the model has its own template
|
||
if (src === 'generic') {
|
||
return { suffix: ' — no chat template found on the Hub: generic User:/Assistant: template (limited results on a base model)', warn: true }
|
||
}
|
||
return { suffix: ` — chat template fetched from ${src}`, warn: false }
|
||
}
|
||
|
||
function TreeNode({ node, childs, depth, activeIds, onSelect }) {
|
||
return (
|
||
<>
|
||
<div
|
||
className={`tree-node ${activeIds.has(node.id) ? 'on-path' : ''}`}
|
||
style={{ paddingLeft: 8 + depth * 14 }}
|
||
onClick={() => onSelect(node.id)}
|
||
>
|
||
<span className={`tn-role ${node.role}`}>{node.role[0].toUpperCase()}</span>
|
||
<span className="tn-text" title={node.content || '(empty)'}>#{node.id} {node.content.slice(0, 70) || '(empty)'}</span>
|
||
{node.has_frames && <span className="tn-frames">◈</span>}
|
||
</div>
|
||
{(childs[node.id] || []).map((c) => (
|
||
<TreeNode key={c.id} node={c} childs={childs} depth={depth + 1} activeIds={activeIds} onSelect={onSelect} />
|
||
))}
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default function App() {
|
||
const [models, setModels] = useState([])
|
||
const [status, setStatus] = useState(null)
|
||
const [selected, setSelected] = useState(null)
|
||
const [dtype, setDtype] = useState('bf16')
|
||
const [quant, setQuant] = useState('')
|
||
const [device, setDevice] = useState('cuda:0')
|
||
const [notice, setNotice] = useState(null)
|
||
const [downloadRepo, setDownloadRepo] = useState('')
|
||
const [browseOpen, setBrowseOpen] = useState(false)
|
||
const [browse, setBrowse] = useState(null)
|
||
|
||
const [lensForm, setLensForm] = useState({ repo_id: 'neuronpedia/jacobian-lens', revision: '', filename: '', path: '', k: 8 })
|
||
const [reg, setReg] = useState(null)
|
||
// model being loaded right now (id requested) + lens queued to chain-load
|
||
const [loadingId, setLoadingId] = useState(null)
|
||
const queuedLensRef = useRef(null)
|
||
const [queuedLensName, setQueuedLensName] = useState(null)
|
||
const [lensOn, setLensOn] = useState(true)
|
||
const [framesCount, setFramesCount] = useState(0)
|
||
const [selectedIdx, setSelectedIdx] = useState(null)
|
||
const [diffSel, setDiffSel] = useState([])
|
||
const [tab, setTab] = useState('chat')
|
||
const [fitName, setFitName] = useState('')
|
||
|
||
const [hidden, setHidden] = useState(() => {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('jlens_hidden_tokens') || 'null')
|
||
return new Set(Array.isArray(saved) ? saved : DEFAULT_HIDDEN)
|
||
} catch { return new Set(DEFAULT_HIDDEN) }
|
||
})
|
||
const [hideInput, setHideInput] = useState('')
|
||
|
||
// chat display: markdown rendering of assistant replies (default ON)
|
||
const [chatMd, setChatMd] = useState(() => localStorage.getItem('jlens_chat_md') !== '0')
|
||
useEffect(() => { localStorage.setItem('jlens_chat_md', chatMd ? '1' : '0') }, [chatMd])
|
||
|
||
// server-side settings (Options tab)
|
||
const [settings, setSettings] = useState(null)
|
||
useEffect(() => {
|
||
jsonFetch('/api/settings').then((s) => {
|
||
setSettings(s)
|
||
setChatMd(!!s.chat_markdown)
|
||
if (s.default_quant != null) setQuant(s.default_quant)
|
||
}).catch(() => {})
|
||
}, [])
|
||
async function patchSettings(patch) {
|
||
try {
|
||
const s = await jsonFetch('/api/settings', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch),
|
||
})
|
||
setSettings(s)
|
||
if (patch.chat_markdown != null) setChatMd(!!s.chat_markdown)
|
||
if (patch.default_quant != null) setQuant(s.default_quant)
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
// manual height of the lens view (drag handle above it; null = auto 46vh)
|
||
const [lensViewH, setLensViewH] = useState(() => {
|
||
const v = +localStorage.getItem('jlens_lensview_h')
|
||
return v >= 80 ? v : null
|
||
})
|
||
useEffect(() => {
|
||
if (lensViewH) localStorage.setItem('jlens_lensview_h', String(Math.round(lensViewH)))
|
||
else localStorage.removeItem('jlens_lensview_h')
|
||
}, [lensViewH])
|
||
function startLensResize(e) {
|
||
e.preventDefault()
|
||
const el = document.querySelector('.lensview')
|
||
const startH = lensViewH ?? el?.getBoundingClientRect().height ?? 300
|
||
const startY = e.clientY
|
||
const move = (ev) => setLensViewH(
|
||
Math.max(80, Math.min(window.innerHeight * 0.85, startH + (startY - ev.clientY))),
|
||
)
|
||
const up = () => {
|
||
window.removeEventListener('mousemove', move)
|
||
window.removeEventListener('mouseup', up)
|
||
}
|
||
window.addEventListener('mousemove', move)
|
||
window.addEventListener('mouseup', up)
|
||
}
|
||
// reply being edited in place: { idx, text } | null
|
||
const [editingMsg, setEditingMsg] = useState(null)
|
||
// assistant message being CONTINUED (ref: onFrame is a stale closure)
|
||
const continuingIdRef = useRef(null)
|
||
const [continuingId, setContinuingId] = useState(null)
|
||
|
||
const [ivRules, setIvRules] = useState([])
|
||
const [ivScale, setIvScale] = useState(1)
|
||
const [ivMode, setIvMode] = useState('standard')
|
||
const [editorOpen, setEditorOpen] = useState(false)
|
||
const [editorPrefill, setEditorPrefill] = useState(null)
|
||
const [capLayers, setCapLayers] = useState('')
|
||
const [capK, setCapK] = useState('')
|
||
|
||
const [convs, setConvs] = useState([])
|
||
const [convQuery, setConvQuery] = useState('')
|
||
const [conv, setConv] = useState(null)
|
||
const [tree, setTree] = useState([])
|
||
const [showTree, setShowTree] = useState(false)
|
||
const [editParent, setEditParent] = useState(undefined)
|
||
|
||
const [fitModel, setFitModel] = useState('')
|
||
const [fitN, setFitN] = useState(100)
|
||
const [fitDataset, setFitDataset] = useState('Salesforce/wikitext-103-raw-v1')
|
||
const [fitQuant, setFitQuant] = useState('')
|
||
const [fitDevices, setFitDevices] = useState([])
|
||
const [fitDimBatch, setFitDimBatch] = useState('')
|
||
const [fitMaxSeq, setFitMaxSeq] = useState(128)
|
||
const [fitLayers, setFitLayers] = useState('')
|
||
const [fitContinue, setFitContinue] = useState('')
|
||
const [localLenses, setLocalLenses] = useState([])
|
||
const [fitAdvanced, setFitAdvanced] = useState(false)
|
||
|
||
const [system, setSystem] = useState('')
|
||
const [messages, setMessages] = useState([])
|
||
const [draft, setDraft] = useState(null)
|
||
const [input, setInput] = useState('')
|
||
const [sampling, setSampling] = useState(() => {
|
||
// restore sampling params after a refresh (temperature, etc.)
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('jlens_sampling') || 'null')
|
||
return saved && typeof saved === 'object' ? { ...SAMPLING_DEFAULT, ...saved } : SAMPLING_DEFAULT
|
||
} catch { return SAMPLING_DEFAULT }
|
||
})
|
||
|
||
const wsRef = useRef(null)
|
||
const draftRef = useRef('')
|
||
const framesRef = useRef([])
|
||
const messagesRef = useRef(null)
|
||
const lastPaintRef = useRef(0)
|
||
const convRef = useRef(null)
|
||
convRef.current = conv
|
||
|
||
const refreshModels = () =>
|
||
jsonFetch('/api/models').then((b) => setModels(b.models)).catch(() => {})
|
||
|
||
const refreshConvs = (query = convQuery) =>
|
||
jsonFetch(`/api/conversations${query ? `?query=${encodeURIComponent(query)}` : ''}`)
|
||
.then((b) => setConvs(b.conversations))
|
||
.catch(() => {})
|
||
|
||
useEffect(() => {
|
||
refreshModels()
|
||
refreshConvs()
|
||
// restore the current conversation after a page reload
|
||
const saved = Number(localStorage.getItem('jlens_conv'))
|
||
if (saved) {
|
||
jsonFetch(`/api/conversations/${saved}`).then((body) => {
|
||
setTree(body.messages)
|
||
setConv({ id: body.id, title: body.title, tags: body.tags })
|
||
if (body.messages.length) {
|
||
applyPath(Math.max(...body.messages.map((m) => m.id)), body.messages)
|
||
}
|
||
}).catch(() => localStorage.removeItem('jlens_conv'))
|
||
}
|
||
const tick = () => jsonFetch('/api/status').then(setStatus).catch(() => {})
|
||
tick()
|
||
const id = setInterval(tick, 2000)
|
||
return () => clearInterval(id)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (conv?.id) localStorage.setItem('jlens_conv', String(conv.id))
|
||
}, [conv?.id])
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('jlens_sampling', JSON.stringify(sampling))
|
||
}, [sampling])
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('jlens_hidden_tokens', JSON.stringify([...hidden]))
|
||
}, [hidden])
|
||
|
||
function hideToken(s) {
|
||
const clean = trimTok(s)
|
||
if (!clean) return
|
||
setHidden((prev) => new Set(prev).add(clean))
|
||
}
|
||
function unhideToken(s) {
|
||
setHidden((prev) => { const n = new Set(prev); n.delete(s); return n })
|
||
}
|
||
|
||
// fit devices follow the GPUs actually present: drop the missing ones,
|
||
// default to all of them when nothing (valid) is selected
|
||
useEffect(() => {
|
||
const avail = (status?.gpus || []).map((g) => `cuda:${g.index}`)
|
||
if (!avail.length) return
|
||
setFitDevices((prev) => {
|
||
const filtered = prev.filter((d) => avail.includes(d))
|
||
return filtered.length ? filtered : avail
|
||
})
|
||
}, [status?.gpus?.length])
|
||
|
||
useEffect(() => { refreshConvs() }, [convQuery])
|
||
|
||
// Auto-scroll to the bottom while a reply streams in — but only if the user
|
||
// is already near the bottom, so scrolling up to read is never hijacked.
|
||
useEffect(() => {
|
||
const el = messagesRef.current
|
||
if (!el || draft === null) return
|
||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 160) {
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
}, [draft])
|
||
|
||
// After a refresh / branch change, messages are restored without their frames
|
||
// (applyPath sets frames: undefined) → the lens view won't show. We reload the
|
||
// frames of the last message that has some (they exist server-side), to get the
|
||
// heatmap/frequencies back without having to regenerate.
|
||
const framesTriedRef = useRef(new Set())
|
||
useEffect(() => {
|
||
// draft !== null == streaming (const "streaming" declared below → avoid the TDZ)
|
||
if (draft !== null || messages.some((m) => m.frames?.length)) return
|
||
let lastIdx = -1
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].has_frames && messages[i].id != null) { lastIdx = i; break }
|
||
}
|
||
if (lastIdx < 0) return
|
||
const mid = messages[lastIdx].id
|
||
if (framesTriedRef.current.has(mid)) return
|
||
framesTriedRef.current.add(mid)
|
||
jsonFetch(`/api/messages/${mid}/frames`)
|
||
.then((body) => {
|
||
if (body.frames?.length) {
|
||
setMessages((prev) => prev.map((x) => (x.id === mid ? { ...x, frames: body.frames } : x)))
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
}, [messages, draft])
|
||
|
||
useEffect(() => {
|
||
if (tab === 'fit') jsonFetch('/api/registry/local').then((b) => setLocalLenses(b.lenses)).catch(() => {})
|
||
}, [tab, status?.fit?.state])
|
||
|
||
useEffect(() => {
|
||
if (!status) return
|
||
setIvRules(status.interventions || [])
|
||
if (status.interventions_scale != null) setIvScale(status.interventions_scale)
|
||
if (status.interventions_mode != null) setIvMode(status.interventions_mode)
|
||
}, [status])
|
||
|
||
// auto-open the token editor when a lens just got loaded
|
||
// (status?.lens, NOT lensMeta: that const is declared further down — TDZ)
|
||
const hadLensRef = useRef(false)
|
||
useEffect(() => {
|
||
const has = !!status?.lens
|
||
if (has && !hadLensRef.current) setEditorOpen(true)
|
||
hadLensRef.current = has
|
||
}, [status?.lens])
|
||
|
||
const loadedId = status?.loaded?.model_id
|
||
const lensMeta = status?.lens
|
||
const busy = status?.busy
|
||
const streaming = draft !== null
|
||
|
||
useEffect(() => {
|
||
// the registry is queryable for the model being LOADED too: pick a lens
|
||
// while the weights stream in, it chain-loads once the model is ready
|
||
const target = loadedId || loadingId
|
||
const preset = LENS_PRESETS[target]
|
||
if (preset) setLensForm((f) => ({ ...f, ...preset }))
|
||
if (!target) { setReg(null); return }
|
||
const rev = loadedId ? status?.loaded?.revision : null
|
||
jsonFetch(`/api/registry/for-model?model_id=${encodeURIComponent(target)}${rev ? `&revision=${rev}` : ''}`)
|
||
.then(setReg)
|
||
.catch(() => setReg(null))
|
||
}, [loadedId, loadingId])
|
||
|
||
function ensureWs() {
|
||
return new Promise((resolve, reject) => {
|
||
const cur = wsRef.current
|
||
if (cur && cur.readyState === WebSocket.OPEN) return resolve(cur)
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws`)
|
||
ws.onopen = () => resolve(ws)
|
||
ws.onerror = () => reject(new Error('websocket unavailable'))
|
||
ws.onmessage = (ev) => onFrame(JSON.parse(ev.data))
|
||
ws.onclose = () => { wsRef.current = null }
|
||
wsRef.current = ws
|
||
})
|
||
}
|
||
|
||
async function fetchTree(cid, { keepPath = true } = {}) {
|
||
try {
|
||
const body = await jsonFetch(`/api/conversations/${cid}`)
|
||
setTree(body.messages)
|
||
setConv({ id: body.id, title: body.title, tags: body.tags })
|
||
return body
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
return null
|
||
}
|
||
}
|
||
|
||
function onFrame(frame) {
|
||
if (frame.type === 'token') {
|
||
draftRef.current += frame.text
|
||
setDraft(draftRef.current)
|
||
} else if (frame.type === 'frame') {
|
||
framesRef.current.push(frame)
|
||
const now = performance.now()
|
||
if (now - lastPaintRef.current > 60) {
|
||
lastPaintRef.current = now
|
||
setFramesCount(framesRef.current.length)
|
||
}
|
||
} else if (frame.type === 'persisted') {
|
||
if (!convRef.current) setConv({ id: frame.conversation_id, title: '', tags: [] })
|
||
setMessages((prev) => {
|
||
const copy = [...prev]
|
||
for (let i = copy.length - 1; i >= 0; i--) {
|
||
if (copy[i].role === 'user' && copy[i].id == null) {
|
||
copy[i] = { ...copy[i], id: frame.user_message_id }
|
||
break
|
||
}
|
||
}
|
||
return copy
|
||
})
|
||
} else if (frame.type === 'done') {
|
||
const frames = framesRef.current
|
||
framesRef.current = []
|
||
setFramesCount(0)
|
||
setSelectedIdx(null)
|
||
if (frame.continued && frame.message_id != null) {
|
||
// continuation: update the extended reply in place (frame.text is the
|
||
// FULL new content) and reload its merged frames blob
|
||
continuingIdRef.current = null
|
||
setContinuingId(null)
|
||
setMessages((prev) => prev.map((m) => (m.id === frame.message_id
|
||
? {
|
||
...m, content: frame.text, stats: frame.stats,
|
||
gen_id: frame.gen_id, has_frames: m.has_frames || frames.length > 0,
|
||
frames: undefined,
|
||
}
|
||
: m)))
|
||
framesTriedRef.current.delete(frame.message_id)
|
||
jsonFetch(`/api/messages/${frame.message_id}/frames`)
|
||
.then((body) => {
|
||
if (body.frames?.length) {
|
||
setMessages((prev) => prev.map((x) => (x.id === frame.message_id
|
||
? { ...x, frames: body.frames } : x)))
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
} else {
|
||
setMessages((prev) => [...prev, {
|
||
id: frame.message_id ?? null,
|
||
role: 'assistant',
|
||
content: frame.text,
|
||
meta: frame.meta,
|
||
stats: frame.stats,
|
||
gen_id: frame.gen_id,
|
||
has_frames: frames.length > 0,
|
||
frames,
|
||
}])
|
||
}
|
||
draftRef.current = ''
|
||
setDraft(null)
|
||
if (!frame.text && !frame.continued) {
|
||
setNotice({
|
||
kind: 'err',
|
||
text: 'the model emitted end-of-turn immediately (0 tokens) — strong '
|
||
+ 'global rules can do this on short prompts: soften the factor/scale '
|
||
+ 'or rephrase',
|
||
})
|
||
}
|
||
if (frame.conversation_id) {
|
||
fetchTree(frame.conversation_id)
|
||
refreshConvs()
|
||
}
|
||
} else if (frame.type === 'error') {
|
||
setNotice({ kind: 'err', text: frame.message })
|
||
framesRef.current = []
|
||
draftRef.current = ''
|
||
setDraft(null)
|
||
continuingIdRef.current = null
|
||
setContinuingId(null)
|
||
}
|
||
}
|
||
|
||
function lastPathId() {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].id != null) return messages[i].id
|
||
}
|
||
const root = tree.find((m) => m.role === 'system')
|
||
return root ? root.id : null
|
||
}
|
||
|
||
async function sendChat({ content, parentId }) {
|
||
try {
|
||
const ws = await ensureWs()
|
||
draftRef.current = ''
|
||
framesRef.current = []
|
||
setFramesCount(0)
|
||
setDraft('')
|
||
ws.send(JSON.stringify({
|
||
type: 'chat',
|
||
conversation_id: conv?.id ?? null,
|
||
parent_id: parentId,
|
||
content,
|
||
system: conv ? undefined : (system.trim() || undefined),
|
||
sampling,
|
||
lens: lensOn && !!lensMeta,
|
||
}))
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
setDraft(null)
|
||
}
|
||
}
|
||
|
||
function onSend() {
|
||
const text = input.trim()
|
||
if (!text || streaming) return
|
||
const parentId = editParent !== undefined ? editParent : lastPathId()
|
||
setMessages((prev) => [...prev, { id: null, role: 'user', content: text }])
|
||
setInput('')
|
||
setEditParent(undefined)
|
||
sendChat({ content: text, parentId })
|
||
}
|
||
|
||
function onStop() {
|
||
wsRef.current?.send(JSON.stringify({ type: 'stop' }))
|
||
}
|
||
|
||
function onRegenerate() {
|
||
if (streaming) return
|
||
const history = [...messages]
|
||
while (history.length && history[history.length - 1].role === 'assistant') history.pop()
|
||
const lastUser = [...history].reverse().find((m) => m.role === 'user')
|
||
if (!lastUser || lastUser.id == null) return
|
||
setMessages(history)
|
||
sendChat({ content: null, parentId: lastUser.id })
|
||
}
|
||
|
||
// Extend the last assistant reply: the server generates with the turn left
|
||
// open and appends to the stored message (frames merged too).
|
||
async function onContinue() {
|
||
if (streaming) return
|
||
const last = messages[messages.length - 1]
|
||
if (!last || last.role !== 'assistant' || last.id == null) return
|
||
continuingIdRef.current = last.id
|
||
setContinuingId(last.id)
|
||
try {
|
||
const ws = await ensureWs()
|
||
draftRef.current = ''
|
||
framesRef.current = []
|
||
setFramesCount(0)
|
||
setDraft('')
|
||
ws.send(JSON.stringify({
|
||
type: 'chat',
|
||
continue_message_id: last.id,
|
||
sampling,
|
||
lens: lensOn && !!lensMeta,
|
||
}))
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
setDraft(null)
|
||
continuingIdRef.current = null
|
||
setContinuingId(null)
|
||
}
|
||
}
|
||
|
||
async function saveEditedMsg() {
|
||
if (!editingMsg) return
|
||
const m = messages[editingMsg.idx]
|
||
if (!m || m.id == null) { setEditingMsg(null); return }
|
||
try {
|
||
await jsonFetch(`/api/messages/${m.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content: editingMsg.text }),
|
||
})
|
||
setMessages((prev) => prev.map((x, i) => (i === editingMsg.idx ? { ...x, content: editingMsg.text } : x)))
|
||
setEditingMsg(null)
|
||
setNotice({ kind: 'ok', text: 'reply updated — later turns will use the edited text' })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
function onEditLast() {
|
||
if (streaming) return
|
||
const history = [...messages]
|
||
while (history.length && history[history.length - 1].role === 'assistant') history.pop()
|
||
const last = history.pop()
|
||
if (!last) return
|
||
setMessages(history)
|
||
setInput(last.content)
|
||
const parent = tree.find((m) => m.id === last.id)?.parent_id
|
||
setEditParent(parent ?? null)
|
||
}
|
||
|
||
function pathFor(nodeId, msgs) {
|
||
const byId = Object.fromEntries(msgs.map((m) => [m.id, m]))
|
||
const path = []
|
||
let cur = byId[nodeId]
|
||
while (cur) {
|
||
path.unshift(cur)
|
||
cur = cur.parent_id != null ? byId[cur.parent_id] : null
|
||
}
|
||
return path
|
||
}
|
||
|
||
function applyPath(nodeId, msgs) {
|
||
const path = pathFor(nodeId, msgs)
|
||
const sys = path.find((m) => m.role === 'system')
|
||
setSystem(sys ? sys.content : '')
|
||
setMessages(path.filter((m) => m.role !== 'system').map((m) => ({ ...m, frames: undefined })))
|
||
setSelectedIdx(null)
|
||
setEditParent(undefined)
|
||
}
|
||
|
||
async function openConv(cid) {
|
||
const body = await fetchTree(cid)
|
||
if (!body) return
|
||
const maxId = Math.max(...body.messages.map((m) => m.id))
|
||
applyPath(maxId, body.messages)
|
||
}
|
||
|
||
function newConv() {
|
||
localStorage.removeItem('jlens_conv')
|
||
setConv(null)
|
||
setTree([])
|
||
setMessages([])
|
||
setSystem('')
|
||
setSelectedIdx(null)
|
||
setEditParent(undefined)
|
||
}
|
||
|
||
async function deleteConv(cid, ev) {
|
||
ev.stopPropagation()
|
||
if (!window.confirm(`Delete conversation ${cid}?`)) return
|
||
await jsonFetch(`/api/conversations/${cid}`, { method: 'DELETE' }).catch(() => {})
|
||
if (conv?.id === cid) newConv()
|
||
refreshConvs()
|
||
}
|
||
|
||
async function patchConv(fields) {
|
||
if (!conv?.id) return
|
||
await jsonFetch(`/api/conversations/${conv.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(fields),
|
||
}).catch(() => {})
|
||
refreshConvs()
|
||
}
|
||
|
||
async function selectMessage(i) {
|
||
const m = messages[i]
|
||
if (m.has_frames && !m.frames?.length && m.id != null) {
|
||
try {
|
||
const body = await jsonFetch(`/api/messages/${m.id}/frames`)
|
||
setMessages((prev) => prev.map((x, j) => (j === i ? { ...x, frames: body.frames } : x)))
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
return
|
||
}
|
||
}
|
||
setSelectedIdx(i)
|
||
}
|
||
|
||
// Consume the lens queued during the model load (chain-load), if any.
|
||
function flushQueuedLens() {
|
||
const q = queuedLensRef.current
|
||
queuedLensRef.current = null
|
||
setQueuedLensName(null)
|
||
if (q) lensLoadBy(q)
|
||
}
|
||
|
||
async function onLoad() {
|
||
if (!selected) return
|
||
setNotice({ kind: 'ok', text: `loading ${selected}...` })
|
||
setLoadingId(selected)
|
||
try {
|
||
const meta = await jsonFetch('/api/load', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model_id: selected, dtype, quant: quant || null, device }),
|
||
})
|
||
const note = templateNote(meta)
|
||
setNotice({
|
||
kind: note.warn ? 'err' : 'ok',
|
||
text: `loaded in ${meta.load_seconds}s (${meta.n_layers} layers, d=${meta.d_model})` + note.suffix,
|
||
})
|
||
flushQueuedLens()
|
||
} catch (err) {
|
||
queuedLensRef.current = null
|
||
setQueuedLensName(null)
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
} finally {
|
||
setLoadingId(null)
|
||
}
|
||
}
|
||
|
||
async function onUnload() {
|
||
try {
|
||
const r = await jsonFetch('/api/unload', { method: 'POST' })
|
||
let text = 'nothing to unload'
|
||
if (r.unloaded) {
|
||
// VRAM actually returned = allocated before − reserved after (the rest = CUDA context)
|
||
const before = Object.values(r.vram_allocated_before || {}).reduce((a, b) => a + b, 0)
|
||
const reserved = Object.values(r.vram_reserved_after || {}).reduce((a, b) => a + b, 0)
|
||
text = `model unloaded · ${(before / GB).toFixed(1)} GB returned`
|
||
+ (reserved > 64 * 2 ** 20 ? ` (${(reserved / GB).toFixed(1)} GB still reserved)` : ' (VRAM returned to the driver)')
|
||
}
|
||
setNotice({ kind: 'ok', text })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
async function lensLoadBy(payload) {
|
||
setNotice({ kind: 'ok', text: 'loading the lens...' })
|
||
// form top-k applied to ALL load paths (registry included)
|
||
if (payload.k == null && +lensForm.k > 0) payload = { ...payload, k: +lensForm.k }
|
||
try {
|
||
const meta = await jsonFetch('/api/lens/load', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ k: +lensForm.k || 8, ...payload }),
|
||
})
|
||
const warn = (meta.warnings || []).join(' ; ')
|
||
setNotice({ kind: warn ? 'err' : 'ok', text: warn || `lens loaded (layers ${meta.tapped_layers.join(', ')})` })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
function onLensLoad() {
|
||
if (lensForm.path.trim()) {
|
||
lensClick({ path: lensForm.path.trim() })
|
||
return
|
||
}
|
||
lensClick({
|
||
repo_id: lensForm.repo_id || null,
|
||
revision: lensForm.revision || null,
|
||
filename: lensForm.filename,
|
||
})
|
||
}
|
||
|
||
// While a model is loading, clicking a lens QUEUES it (chain-load).
|
||
const modelLoading = !!loadingId && busy === 'loading'
|
||
function lensClick(payload) {
|
||
if (modelLoading) {
|
||
queuedLensRef.current = payload
|
||
setQueuedLensName(payload.path || payload.filename?.split('/').pop() || 'lens')
|
||
setNotice({ kind: 'ok', text: 'lens queued — it loads as soon as the model is ready' })
|
||
return
|
||
}
|
||
lensLoadBy(payload)
|
||
}
|
||
|
||
function renderHubLens(h) {
|
||
const nMatch = h.filename.match(/_n(\d+)/)
|
||
const stem = h.filename.split('/').pop().replace('_jacobian_lens', '').replace('.pt', '')
|
||
const isBase = h.via === 'base-model' || h.via === 'base-guess'
|
||
return (
|
||
<div key={`${h.repo_id}/${h.filename}`} className={`reg-item2 ${h.compatible === false ? 'reg-warn' : ''}`}>
|
||
<div className="reg-main">
|
||
<div className="reg-name" title={`${h.filename || stem}`}>
|
||
{stem} <span className="reg-tag hub">Hub</span>
|
||
{isBase && <span className="reg-tag base" title={h.reason}>base model</span>}
|
||
</div>
|
||
<div className="src">
|
||
{nMatch ? `${nMatch[1]} prompts` : 'n unspecified (repo default fit)'}
|
||
{h.base_model ? ` · base ${h.base_model}` : ''}
|
||
{h.cached ? ' · ✓ cached' : ' · to download'}
|
||
</div>
|
||
<div className="src">{h.repo_id} @{h.revision}</div>
|
||
{h.reason && <div className="src reg-reason">⚠ {h.reason}</div>}
|
||
</div>
|
||
<button disabled={h.compatible === false || (!!busy && !modelLoading)}
|
||
title={modelLoading ? 'queue: loads right after the model'
|
||
: h.cached ? 'load (already cached)' : 'download and load'}
|
||
onClick={() => lensClick({ repo_id: h.repo_id, filename: h.filename, revision: h.revision })}>
|
||
{modelLoading ? '⏲' : h.cached ? '▶' : '⬇'}</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
async function onLensUnload() {
|
||
await jsonFetch('/api/lens/unload', { method: 'POST' }).catch(() => {})
|
||
}
|
||
|
||
function openEditorWith(prefill) {
|
||
setEditorOpen(true)
|
||
if (prefill) setEditorPrefill(prefill)
|
||
}
|
||
|
||
// "Visualized" generation: the live stream, else the selected message if it has
|
||
// frames, else the last message with frames. Shared between the J-lens and the
|
||
// editor (auto-selecting the peak layer of an added token).
|
||
function currentGenView() {
|
||
const live = streaming && framesRef.current.length ? framesRef.current : null
|
||
let idx = selectedIdx
|
||
if (idx == null || !messages[idx]?.frames?.length) {
|
||
idx = -1
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].frames?.length) { idx = i; break }
|
||
}
|
||
}
|
||
// gen_id is only known for messages generated THIS page session; after a
|
||
// reload, fall back to the id carried by the persisted frames themselves —
|
||
// the server-side residual store survives a page refresh.
|
||
const msgGen = idx >= 0
|
||
? messages[idx].gen_id ?? messages[idx].frames[messages[idx].frames.length - 1]?.gen ?? null
|
||
: null
|
||
const genId = live ? live[live.length - 1]?.gen ?? null : msgGen
|
||
return { live, idx, genId }
|
||
}
|
||
|
||
async function applyCapture() {
|
||
const list = []
|
||
for (const part of capLayers.split(',')) {
|
||
const m = part.trim().match(/^(\d+)\s*-\s*(\d+)$/)
|
||
if (m) for (let i = +m[1]; i <= +m[2]; i++) list.push(i)
|
||
else if (part.trim()) list.push(+part.trim())
|
||
}
|
||
const layers = list.filter((x) => !isNaN(x))
|
||
try {
|
||
const body = await jsonFetch('/api/lens/layers', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
// empty layers field = keep the current layers (changing k only)
|
||
layers: layers.length ? layers : lensMeta.tapped_layers,
|
||
k: capK ? +capK : null,
|
||
}),
|
||
})
|
||
setCapLayers('')
|
||
setCapK('')
|
||
setNotice({ kind: 'ok', text: `capture: layers ${body.tapped_layers.join(', ')} · k=${body.k}` })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
async function openBrowse(path) {
|
||
try {
|
||
const body = await jsonFetch(`/api/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`)
|
||
setBrowse(body)
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
// NATIVE folder picker (server-side tkinter dialog — the server is local, and
|
||
// tkinter works on Windows/Linux/macOS alike). Returns the chosen path.
|
||
async function pickFolder() {
|
||
try {
|
||
const body = await jsonFetch('/api/pick-path', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ kind: 'dir' }),
|
||
})
|
||
return body.path || null
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function pickNative() {
|
||
const path = await pickFolder()
|
||
if (path) await registerPath(path)
|
||
}
|
||
|
||
// Browse result: the folder is ADDED to the model list (nothing is loaded
|
||
// yet) — load it from the list with the dtype/quant/device of your choice.
|
||
async function registerPath(path) {
|
||
try {
|
||
const r = await jsonFetch('/api/models/register', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path }),
|
||
})
|
||
setSelected(r.registered)
|
||
setNotice({ kind: 'ok', text: `added to the list: ${r.registered} — pick dtype/quant/device and Load` })
|
||
refreshModels()
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
async function unregisterPath(m, ev) {
|
||
ev.stopPropagation()
|
||
try {
|
||
await jsonFetch('/api/models/unregister', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: m.path }),
|
||
})
|
||
setNotice({ kind: 'ok', text: `removed from the list (files untouched): ${m.path}` })
|
||
if (selected === m.id) setSelected(null)
|
||
refreshModels()
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
async function deleteModel(m, ev) {
|
||
ev.stopPropagation()
|
||
const size = m.size_bytes ? ` (${(m.size_bytes / GB).toFixed(1)} GB)` : ''
|
||
if (!window.confirm(`Permanently delete ${m.id}${size}?\nThis erases the files from disk.`)) return
|
||
try {
|
||
const r = await jsonFetch('/api/models/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model_id: m.id }),
|
||
})
|
||
const freed = r.freed_bytes ? ` · ${(r.freed_bytes / GB).toFixed(1)} GB freed` : ''
|
||
setNotice({ kind: 'ok', text: `deleted: ${m.id}${freed}` })
|
||
if (selected === m.id) setSelected(null)
|
||
refreshModels()
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
async function convertBf16(path) {
|
||
try {
|
||
await jsonFetch('/api/convert-bf16', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path }),
|
||
})
|
||
setNotice({ kind: 'ok', text: 'bf16 conversion started (see status below)' })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
async function onDownload() {
|
||
if (!downloadRepo.trim()) return
|
||
try {
|
||
await jsonFetch('/api/download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ repo_id: downloadRepo.trim() }),
|
||
})
|
||
setNotice({ kind: 'ok', text: `download of ${downloadRepo} started` })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}
|
||
|
||
const doneDownloads = (status?.downloads || []).filter((d) => d.state === 'done').length
|
||
useEffect(() => {
|
||
if (doneDownloads > 0) refreshModels()
|
||
}, [doneDownloads])
|
||
|
||
useEffect(() => {
|
||
if (status?.convert?.state === 'done') refreshModels()
|
||
}, [status?.convert?.state])
|
||
|
||
const childs = {}
|
||
tree.forEach((m) => { (childs[m.parent_id] ??= []).push(m) })
|
||
const activeIds = new Set(messages.map((m) => m.id).filter((x) => x != null))
|
||
tree.filter((m) => m.role === 'system' && activeIds.size).forEach((m) => activeIds.add(m.id))
|
||
|
||
return (
|
||
<>
|
||
<div className="sidebar">
|
||
<h1>J-Wash</h1>
|
||
|
||
<h2>GPU</h2>
|
||
<div>
|
||
{(status?.gpus || []).map((g) => (
|
||
<div className="gpu" key={g.index}>
|
||
<div className="name">
|
||
<span>cuda:{g.index} · {g.name.replace('NVIDIA GeForce ', '')}</span>
|
||
<span>{(g.vram_used / GB).toFixed(1)} / {(g.vram_total / GB).toFixed(0)} GB</span>
|
||
</div>
|
||
<div className="bar"><div className="fill" style={{ width: `${(100 * g.vram_used) / g.vram_total}%` }} /></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="tabs">
|
||
{[['chat', 'Chat'], ['model', 'Model'], ['lens', 'Lens'], ['fit', 'Fit'], ['options', 'Options']].map(([id, label]) => (
|
||
<button key={id} className={tab === id ? 'tab-on' : ''} onClick={() => setTab(id)}>{label}</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'chat' && (<>
|
||
<h2>Conversations <button style={{ float: 'right', padding: '1px 7px' }} onClick={newConv}>+ new</button></h2>
|
||
<input type="text" placeholder="full-text search..." value={convQuery} onChange={(e) => setConvQuery(e.target.value)} />
|
||
<div className="conv-list">
|
||
{convs.map((c) => (
|
||
<div key={c.id} className={`conv-item ${conv?.id === c.id ? 'selected' : ''}`} onClick={() => openConv(c.id)}>
|
||
<div className="conv-title" title={c.title || `conversation ${c.id}`}>
|
||
{c.title || `conversation ${c.id}`}
|
||
<button className="conv-del" onClick={(e) => deleteConv(c.id, e)}>✕</button>
|
||
</div>
|
||
<div className="src">
|
||
{c.n_messages} msgs{c.tags?.length ? ` · ${c.tags.join(', ')}` : ''}
|
||
{c.snippet ? <div className="conv-snippet">{c.snippet}</div> : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<h2>Ignored tokens{hidden.size ? ` (${hidden.size})` : ''}</h2>
|
||
<input type="text" placeholder="hide a token…" value={hideInput}
|
||
onChange={(e) => setHideInput(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') { hideToken(hideInput); setHideInput('') } }} />
|
||
{hidden.size > 0 ? (
|
||
<div className="lv-hidden-row">
|
||
{[...hidden].map((h) => (
|
||
<span key={h} className="lv-hidden-chip" title="click to show again"
|
||
onClick={() => unhideToken(h)}>{fmtTok(h)} ✕</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="src">tokens excluded from the Frequencies view (right-click a token there to add one)</div>
|
||
)}
|
||
|
||
</>)}
|
||
|
||
{tab === 'model' && (<>
|
||
<h2>Models <button style={{ float: 'right', padding: '1px 7px' }} onClick={refreshModels}>↻</button></h2>
|
||
<div className="model-list">
|
||
{models.map((m) => {
|
||
const isFp32 = m.dtype === 'float32' || m.dtype === 'fp32'
|
||
const isRegistered = m.source === 'registered'
|
||
const label = isRegistered ? (m.path.split(/[\\/]/).pop() || m.id) : m.id
|
||
return (
|
||
<div
|
||
key={m.id}
|
||
className={`model-item ${selected === m.id ? 'selected' : ''} ${loadedId === m.id ? 'loaded' : ''}`}
|
||
onClick={() => setSelected(m.id)}
|
||
>
|
||
<div className="model-item-head">
|
||
<span title={m.path || m.id}>{label} {loadedId === m.id ? '●' : ''}{m.missing ? ' ⚠ folder missing' : ''}</span>
|
||
{isRegistered ? (
|
||
<button className="model-del model-unreg"
|
||
title="remove this entry from the list — the model files are NOT touched"
|
||
disabled={loadedId === m.id || !!busy}
|
||
onClick={(e) => unregisterPath(m, e)}>
|
||
<svg viewBox="0 0 14 14" width="13" height="13" aria-hidden="true">
|
||
<path d="M2 3.5h6M2 7h6M2 10.5h4M9.5 8.5l3 3m0-3l-3 3"
|
||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none" />
|
||
</svg>
|
||
</button>
|
||
) : (
|
||
<button className="model-del model-trash"
|
||
title={loadedId === m.id ? 'unload it first' : 'PERMANENTLY delete the model files from disk'}
|
||
disabled={loadedId === m.id || !!busy}
|
||
onClick={(e) => deleteModel(m, e)}>
|
||
<svg viewBox="0 0 14 14" width="13" height="13" aria-hidden="true">
|
||
<path d="M2 3.5h10M5.5 3.5V2.2c0-.4.3-.7.7-.7h1.6c.4 0 .7.3.7.7v1.3M3.2 3.5l.6 8.1c0 .5.4.9.9.9h4.6c.5 0 .9-.4.9-.9l.6-8.1M5.6 6v4M8.4 6v4"
|
||
stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" fill="none" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="src">
|
||
{m.source} · {(m.size_bytes / GB).toFixed(1)} GB
|
||
{m.n_layers != null ? ` · ${m.n_layers} layers` : ''}
|
||
{m.dtype ? ` · ${m.dtype}` : ''}
|
||
</div>
|
||
{isFp32 && (
|
||
<div className="src">
|
||
fp32 — <button className="linkbtn"
|
||
onClick={(e) => { e.stopPropagation(); convertBf16(m.path) }}
|
||
disabled={status?.convert?.state === 'running'}>convert to bf16 (÷2 space)</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="row"><label>dtype</label>
|
||
<select value={dtype} onChange={(e) => setDtype(e.target.value)}>
|
||
<option value="bf16">bf16</option>
|
||
<option value="fp16">fp16</option>
|
||
</select>
|
||
</div>
|
||
<div className="row"><label>quant</label>
|
||
<select value={quant} onChange={(e) => setQuant(e.target.value)}>
|
||
<option value="">none</option>
|
||
<option value="int8">int8</option>
|
||
<option value="nf4">nf4</option>
|
||
</select>
|
||
</div>
|
||
<div className="row"><label>device</label>
|
||
<select value={device} onChange={(e) => setDevice(e.target.value)}>
|
||
{(status?.gpus || []).map((g) => (
|
||
<option key={g.index} value={`cuda:${g.index}`}>
|
||
cuda:{g.index} · {g.name.replace('NVIDIA GeForce ', '')}
|
||
</option>
|
||
))}
|
||
{(status?.gpus?.length || 0) > 1 && <option value="auto">auto (all GPUs)</option>}
|
||
</select>
|
||
</div>
|
||
<div className="row">
|
||
<button className="primary" style={{ flex: 1 }} disabled={!selected || !!busy} onClick={onLoad}>Load</button>
|
||
<button className="danger" disabled={!loadedId || !!busy} onClick={onUnload}>Unload</button>
|
||
</div>
|
||
</>)}
|
||
|
||
{tab === 'lens' && (<>
|
||
<h2>J-Lens</h2>
|
||
{lensMeta ? (
|
||
<div className="lens-status">
|
||
<div className="status-line ok" title={lensMeta.path || `${lensMeta.repo_id || ''} ${lensMeta.filename || ''}`.trim()}>
|
||
▶ <b>{lensName(lensMeta)}</b>
|
||
</div>
|
||
<div className="status-line">
|
||
layers {lensMeta.tapped_layers[0]}–{lensMeta.tapped_layers[lensMeta.tapped_layers.length - 1]} ({lensMeta.tapped_layers.length}) · k={lensMeta.k} · n={lensMeta.n_prompts}
|
||
</div>
|
||
{(lensMeta.warnings || []).map((w, i) => <div key={i} className="status-line err">⚠ {w}</div>)}
|
||
<div className="row" style={{ marginTop: 6 }}>
|
||
<label style={{ width: 'auto' }}>
|
||
<input type="checkbox" checked={lensOn} onChange={(e) => setLensOn(e.target.checked)} /> active in chat
|
||
</label>
|
||
<button className="danger" onClick={onLensUnload}>Remove</button>
|
||
</div>
|
||
<div className="row" style={{ marginTop: 6 }}>
|
||
<label title="layers read by the lens on the next generations (fitted: see the lens)">capture</label>
|
||
<input type="text" value={capLayers}
|
||
placeholder={`${lensMeta.tapped_layers[0]}-${lensMeta.tapped_layers[lensMeta.tapped_layers.length - 1]}`}
|
||
onChange={(e) => setCapLayers(e.target.value)} />
|
||
<label title="number of tokens read per cell (top-k) — applies to the next generations" style={{ width: 'auto' }}>k</label>
|
||
<input type="number" min="1" max="32" value={capK} placeholder={lensMeta.k}
|
||
onChange={(e) => setCapK(e.target.value)} style={{ width: 52, flexShrink: 0 }} />
|
||
<button disabled={(!capLayers.trim() && !capK) || !!busy} onClick={applyCapture}>OK</button>
|
||
</div>
|
||
<div className="status-line">captured: {lensMeta.tapped_layers.join(', ')} · k={lensMeta.k} — applies to the next generations</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{modelLoading && (
|
||
<div className="status-line ok">
|
||
⏳ {loadingId} is loading — pick a lens now, it will load automatically
|
||
once the model is ready{queuedLensName ? ` · queued: ${queuedLensName}` : ''}.
|
||
</div>
|
||
)}
|
||
{reg && (reg.local.length + reg.hub.length > 0) && (
|
||
<div className="reg-list">
|
||
{reg.local.map((l) => {
|
||
const m = l.meta || {}
|
||
const sl = m.source_layers
|
||
return (
|
||
<div key={l.path} className={`reg-item2 ${l.compatible === false ? 'reg-warn' : ''}`}>
|
||
<div className="reg-main">
|
||
<div className="reg-name" title={l.name}>{l.name} <span className="reg-tag">local</span>{l.compatible === false ? ' ⚠' : ''}</div>
|
||
<div className="src">
|
||
{m.n_prompts != null ? `${m.n_prompts} prompts` : 'n unknown'}
|
||
{sl ? ` · layers ${sl[0]}–${sl[1]}` : ''}
|
||
{m.dtype ? ` · ${m.quant || m.dtype}` : ''}
|
||
{m.d_model ? ` · d=${m.d_model}` : ''}
|
||
</div>
|
||
{m.created_at && <div className="src">fit {m.created_at.slice(0, 10)}{m.fit_seconds ? ` · ${Math.round(m.fit_seconds / 60)} min` : ''}{m.corpus ? ` · ${m.corpus.split(' ')[0]}` : ''}</div>}
|
||
{l.reason && <div className="src reg-reason">⚠ {l.reason}</div>}
|
||
</div>
|
||
<button disabled={l.compatible === false || (!!busy && !modelLoading)}
|
||
onClick={() => lensClick({ path: l.path })}>{modelLoading ? '⏲' : 'Load'}</button>
|
||
</div>
|
||
)
|
||
})}
|
||
{reg.hub.map(renderHubLens)}
|
||
{reg.hub_error && <div className="status-line err">{reg.hub_error}</div>}
|
||
</div>
|
||
)}
|
||
{reg && reg.local.length + reg.hub.length === 0 && (loadedId || loadingId) && (
|
||
<div className="status-line">no known lens for this model — fit one (Fit tab), or
|
||
load a lens fitted for a compatible model below</div>
|
||
)}
|
||
{reg && (reg.other?.length || 0) > 0 && (
|
||
<details>
|
||
<summary style={{ fontSize: 12, color: 'var(--muted)', cursor: 'pointer' }}>
|
||
lenses fitted for other models ({reg.other.length})
|
||
</summary>
|
||
<div className="src" style={{ margin: '6px 0' }}>
|
||
For your own finetune or merge without a matching lens: a lens fitted
|
||
on a compatible model of the SAME architecture can work (d_model and
|
||
layer count are checked at load). Readouts drift with the distance
|
||
between the weights — treat them as approximate.
|
||
</div>
|
||
<div className="reg-list">{reg.other.map(renderHubLens)}</div>
|
||
</details>
|
||
)}
|
||
<details>
|
||
<summary style={{ fontSize: 12, color: 'var(--muted)', cursor: 'pointer' }}>manual load</summary>
|
||
<div className="src" style={{ margin: '6px 0' }}>
|
||
Load any Jacobian-lens <code>.pt</code>: either a Hugging Face repo + the
|
||
file path inside it (mirror the entries above), or the local path of a
|
||
lens you fitted (Fit tab writes <code>lenses/<name>/lens.pt</code>).
|
||
</div>
|
||
<div className="row" style={{ marginTop: 6 }}><label>repo</label>
|
||
<input type="text" value={lensForm.repo_id} placeholder="neuronpedia/jacobian-lens"
|
||
onChange={(e) => setLensForm({ ...lensForm, repo_id: e.target.value })} />
|
||
</div>
|
||
<div className="row"><label>file</label>
|
||
<input type="text" value={lensForm.filename} placeholder="gemma-3-1b-it/jlens/…/gemma-3-1b-it_jacobian_lens.pt"
|
||
onChange={(e) => setLensForm({ ...lensForm, filename: e.target.value })} />
|
||
</div>
|
||
<div className="row"><label>revision</label>
|
||
<input type="text" value={lensForm.revision} placeholder="main (default)"
|
||
onChange={(e) => setLensForm({ ...lensForm, revision: e.target.value })} />
|
||
</div>
|
||
<div className="row"><label>or path</label>
|
||
<input type="text" value={lensForm.path} placeholder="lenses/my-fit/lens.pt (local file)"
|
||
onChange={(e) => setLensForm({ ...lensForm, path: e.target.value })} />
|
||
</div>
|
||
<div className="row"><label>top-k</label>
|
||
<input type="number" min="1" max="32" value={lensForm.k} onChange={(e) => setLensForm({ ...lensForm, k: e.target.value })} />
|
||
</div>
|
||
<button className="primary"
|
||
disabled={(!loadedId && !modelLoading) || (!lensForm.filename && !lensForm.path.trim()) || (!!busy && !modelLoading)}
|
||
onClick={onLensLoad}>{modelLoading ? 'Queue lens' : 'Load lens'}</button>
|
||
</details>
|
||
</>
|
||
)}
|
||
|
||
{lensMeta && (
|
||
<>
|
||
<h2>Token editing ☢</h2>
|
||
<button className="primary" onClick={() => openEditorWith(null)}>
|
||
Open editor{ivRules.length ? ` (${ivRules.length} rule${ivRules.length > 1 ? 's' : ''})` : ''}
|
||
</button>
|
||
{ivScale !== 1 && <div className="status-line">global multiplier: ×{(+ivScale).toFixed(2)}</div>}
|
||
</>
|
||
)}
|
||
</>)}
|
||
|
||
{tab === 'fit' && (<>
|
||
<h2>Fitting</h2>
|
||
{(() => {
|
||
const fit = status?.fit
|
||
if (fit?.state === 'running' || fit?.state === 'stopping') {
|
||
return (
|
||
<div>
|
||
<div className="status-line">
|
||
{fit.name} · {fit.phase} · {fit.done}/{fit.total}
|
||
{fit.eta_seconds ? ` · ETA ${Math.max(1, Math.round(fit.eta_seconds / 60))} min` : ''}
|
||
</div>
|
||
{(fit.workers || []).map((w, i) => (
|
||
<div className="gpu" key={i}>
|
||
<div className="name"><span>{w.device} · {w.state}</span><span>{w.done}/{w.total}</span></div>
|
||
<div className="bar"><div className="fill" style={{ width: `${(100 * w.done) / Math.max(1, w.total)}%` }} /></div>
|
||
</div>
|
||
))}
|
||
<button className="danger" onClick={() => jsonFetch('/api/fit/stop', { method: 'POST' }).catch(() => {})}>
|
||
Stop (resumable)
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<>
|
||
{fit?.state === 'done' && (
|
||
<div className="status-line ok">
|
||
fit done: {fit.name} ({Math.round((fit.meta?.fit_seconds || 0) / 60)} min) — reload the model then{' '}
|
||
<button onClick={async () => {
|
||
try {
|
||
await jsonFetch('/api/lens/load', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: fit.lens_path }),
|
||
})
|
||
setNotice({ kind: 'ok', text: `lens ${fit.name} loaded` })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}}>load lens</button>
|
||
</div>
|
||
)}
|
||
{fit?.state === 'error' && <div className="status-line err">fit error: {fit.error}</div>}
|
||
{fit?.state === 'stopped' && <div className="status-line">fit stopped (checkpoints kept, restart = resume)</div>}
|
||
<div className="row"><label>continue</label>
|
||
<select value={fitContinue} onChange={(e) => {
|
||
setFitContinue(e.target.value)
|
||
const l = localLenses.find((x) => x.path === e.target.value)
|
||
if (l?.meta?.model_id) setFitModel(l.meta.model_id)
|
||
}}>
|
||
<option value="">— new lens —</option>
|
||
{localLenses.map((l) => (
|
||
<option key={l.path} value={l.path}>{l.name} (n={l.meta?.n_prompts ?? '?'})</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{fitContinue && (
|
||
<div className="status-line">resume: {fitN} prompts are added to the {localLenses.find((l) => l.path === fitContinue)?.meta?.n_prompts ?? '?'} existing ones (weighted average = fit over the union); source layers inherited</div>
|
||
)}
|
||
<div className="row"><label>model</label>
|
||
<select value={fitModel} onChange={(e) => setFitModel(e.target.value)} disabled={!!fitContinue}>
|
||
<option value="">—</option>
|
||
{models.map((m) => <option key={m.id} value={m.id}>{m.id}{m.n_layers != null ? ` (${m.n_layers} l.)` : ''}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="row"><label>name</label>
|
||
<input type="text" placeholder="(auto: model_nN)" value={fitName} onChange={(e) => setFitName(e.target.value)} />
|
||
</div>
|
||
<div className="row"><label title="number of corpus prompts (existing lenses were made with n=100 unless marked _nNNN)">prompts</label>
|
||
<input type="number" min="4" step="1" value={fitN} onChange={(e) => setFitN(e.target.value)} />
|
||
</div>
|
||
<div className="row"><label title="fit corpus. mixed = both datasets in equal parts (rounded to the nearest prompt), shuffled">dataset</label>
|
||
<select value={fitDataset} onChange={(e) => setFitDataset(e.target.value)}>
|
||
<option value="Salesforce/wikitext-103-raw-v1">Salesforce/wikitext-103-raw-v1</option>
|
||
<option value="heretic-org/Semantic-Harmless">heretic-org/Semantic-Harmless</option>
|
||
<option value="mixed">mixed (50/50)</option>
|
||
</select>
|
||
</div>
|
||
<div className="row"><label>quant</label>
|
||
<select value={fitQuant} onChange={(e) => setFitQuant(e.target.value)} disabled={!!fitContinue}>
|
||
<option value="">none (bf16)</option>
|
||
<option value="int8">int8</option>
|
||
<option value="nf4">nf4</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="row">
|
||
<button className="linkbtn" onClick={() => setFitAdvanced(!fitAdvanced)}>
|
||
{fitAdvanced ? '▾' : '▸'} advanced settings
|
||
</button>
|
||
</div>
|
||
{fitAdvanced && (
|
||
<div className="fit-adv">
|
||
<div className="row"><label title="GPUs used (several = corpus split across them)">devices</label>
|
||
<span style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||
{(status?.gpus || []).map((g) => {
|
||
const d = `cuda:${g.index}`
|
||
return (
|
||
<label key={d} style={{ width: 'auto' }} title={g.name}>
|
||
<input type="checkbox" checked={fitDevices.includes(d)}
|
||
onChange={(e) => setFitDevices(e.target.checked
|
||
? [...fitDevices, d].sort()
|
||
: fitDevices.filter((x) => x !== d))} /> {d}
|
||
</label>
|
||
)
|
||
})}
|
||
</span>
|
||
</div>
|
||
<div className="row"><label title="default: 8 on ≥16 GB GPUs, 4 below (bf16 4B). Too high = OOM">dim_batch</label>
|
||
<input type="number" min="1" step="1" placeholder="auto" value={fitDimBatch}
|
||
onChange={(e) => setFitDimBatch(e.target.value)} />
|
||
</div>
|
||
<div className="row"><label>max_seq_len</label>
|
||
<input type="number" min="8" step="8" value={fitMaxSeq} onChange={(e) => setFitMaxSeq(e.target.value)} />
|
||
</div>
|
||
<div className="row"><label title="layers to fit, e.g. 3-30 or 5,10,15; empty = library default">layers</label>
|
||
<input type="text" placeholder="(default)" value={fitLayers} disabled={!!fitContinue}
|
||
onChange={(e) => setFitLayers(e.target.value)} />
|
||
</div>
|
||
<div className="status-line">
|
||
max = second-to-last layer: the last one is the lens TARGET
|
||
(its readout is already exact, J = I, nothing to fit)
|
||
</div>
|
||
<div className="status-line">resume: restarting with the same name picks up from the checkpoints in data/fits/<name>/</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
className="primary"
|
||
disabled={(!fitModel && !fitContinue) || !fitDevices.length || !!loadedId || !!busy}
|
||
onClick={async () => {
|
||
try {
|
||
const layers = []
|
||
for (const part of fitLayers.split(',')) {
|
||
const mm = part.trim().match(/^(\d+)\s*-\s*(\d+)$/)
|
||
if (mm) for (let i = +mm[1]; i <= +mm[2]; i++) layers.push(i)
|
||
else if (part.trim() && !isNaN(+part.trim())) layers.push(+part.trim())
|
||
}
|
||
await jsonFetch('/api/fit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model_id: fitModel, n_prompts: +fitN, quant: fitQuant || null,
|
||
dataset: fitDataset,
|
||
name: fitName.trim() || null, devices: fitDevices,
|
||
dim_batch: fitDimBatch ? +fitDimBatch : null,
|
||
max_seq_len: +fitMaxSeq,
|
||
source_layers: layers.length ? layers : null,
|
||
continue_from: fitContinue || null,
|
||
}),
|
||
})
|
||
setNotice({ kind: 'ok', text: `fitting started on ${fitDevices.join(', ')}` })
|
||
} catch (err) {
|
||
setNotice({ kind: 'err', text: String(err.message || err) })
|
||
}
|
||
}}
|
||
>{fitContinue ? 'Continue fit' : 'Start fit'}</button>
|
||
{loadedId && <div className="status-line">unload the model first (VRAM required)</div>}
|
||
</>
|
||
)
|
||
})()}
|
||
</>)}
|
||
|
||
{tab === 'model' && (<>
|
||
<h2>Download</h2>
|
||
<div className="row">
|
||
<input type="text" placeholder="org/repo" value={downloadRepo} onChange={(e) => setDownloadRepo(e.target.value)} />
|
||
<button onClick={onDownload}
|
||
disabled={(status?.downloads || []).some((d) => d.repo_id === downloadRepo.trim() && d.state === 'running')}>↓</button>
|
||
</div>
|
||
{(status?.downloads || []).map((d) => {
|
||
const planTxt = d.plan
|
||
? ` (${(d.plan.size_bytes / GB).toFixed(1)} GB${d.plan.kind === 'fallback' ? ' · full repo' : ''})`
|
||
: ''
|
||
const pr = d.progress
|
||
const pct = pr && pr.total ? Math.min(100, (100 * pr.done) / pr.total) : null
|
||
return (
|
||
<div key={d.repo_id} className={`status-line ${d.state === 'error' ? 'err' : d.state === 'done' ? 'ok' : ''}`}>
|
||
{d.state === 'running' && `downloading: ${d.repo_id}${planTxt}${pct != null ? ` — ${pct.toFixed(0)} %` : '...'}`}
|
||
{d.state === 'converting' && `auto bf16 conversion: ${d.repo_id} (fp32 detected)...`}
|
||
{d.state === 'done' && `downloaded: ${d.repo_id}${d.converted ? ` → converted: ${d.converted}` : ''}`}
|
||
{d.state === 'error' && `failed ${d.repo_id}: ${d.error}`}
|
||
{(d.state === 'done' || d.state === 'error') && (
|
||
<span style={{ cursor: 'pointer', marginLeft: 6 }}
|
||
onClick={() => jsonFetch(`/api/download/${encodeURIComponent(d.repo_id)}`, { method: 'DELETE' }).catch(() => {})}> ✕</span>
|
||
)}
|
||
{d.state === 'running' && pct != null && (
|
||
<div className="dl-bar"><div className="dl-fill" style={{ width: `${pct}%` }} /></div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
{status?.convert?.state === 'running' && (
|
||
<div className="status-line">bf16 conversion in progress: {status.convert.path}...</div>
|
||
)}
|
||
{status?.convert?.state === 'done' && (
|
||
<div className="status-line ok">converted to bf16: {status.convert.result?.id}</div>
|
||
)}
|
||
{status?.convert?.state === 'error' && (
|
||
<div className="status-line err">conversion failed: {status.convert.error}</div>
|
||
)}
|
||
|
||
<h2>Browse <button style={{ float: 'right', padding: '1px 7px' }}
|
||
onClick={() => { const n = !browseOpen; setBrowseOpen(n); if (n && !browse) openBrowse(null) }}>
|
||
{browseOpen ? 'hide' : 'open'}</button></h2>
|
||
<div className="row">
|
||
<button disabled={!!busy} onClick={() => pickNative('dir')}>🗂 Browse (folder)…</button>
|
||
</div>
|
||
{browseOpen && browse && (
|
||
<div className="browser">
|
||
<div className="browser-path">
|
||
{browse.parent != null && <button className="linkbtn" onClick={() => openBrowse(browse.parent)}>⬆ parent</button>}
|
||
<button className="linkbtn" onClick={() => openBrowse(null)}>drives</button>
|
||
<span className="src"> {browse.path || '(drives)'}</span>
|
||
</div>
|
||
{browse.is_model && (
|
||
<div className="reg-item2" style={{ marginBottom: 4 }}>
|
||
<div className="reg-main"><div className="reg-name">📦 this folder is a model</div></div>
|
||
<button title="add to the model list (nothing is loaded or copied)"
|
||
onClick={() => registerPath(browse.path)}>+ Add</button>
|
||
</div>
|
||
)}
|
||
<div className="browser-list">
|
||
{browse.dirs.map((d) => (
|
||
<div key={d.path} className={`browser-row ${d.is_model ? 'is-model' : ''}`}>
|
||
<span className="browser-name" title={d.path} onClick={() => openBrowse(d.path)}>
|
||
{d.is_model ? '📦' : '📁'} {d.name}
|
||
</span>
|
||
{d.is_model && <button title="add to the model list (nothing is loaded or copied)"
|
||
onClick={() => registerPath(d.path)}>+ Add</button>}
|
||
</div>
|
||
))}
|
||
{!browse.dirs.length && <div className="src">(empty)</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>)}
|
||
|
||
{tab === 'options' && (<>
|
||
<h2>Defaults</h2>
|
||
<div className="row"><label title="quantization preselected in the Model tab">quant</label>
|
||
<select value={settings?.default_quant ?? ''}
|
||
onChange={(e) => patchSettings({ default_quant: e.target.value })}>
|
||
<option value="">none</option>
|
||
<option value="int8">int8</option>
|
||
<option value="nf4">nf4</option>
|
||
</select>
|
||
</div>
|
||
<div className="row">
|
||
<label title="editor: when a token is added, its layers are auto-selected around the most-relevant one (peak ± this radius). Wider = more robust edit, narrower = more surgical.">auto layers ±</label>
|
||
<input type="number" min="0" max="8" step="1" value={settings?.auto_layer_radius ?? 2}
|
||
onChange={(e) => patchSettings({ auto_layer_radius: Math.max(0, Math.min(8, Math.trunc(+e.target.value) || 0)) })}
|
||
style={{ width: 64, flex: 'none' }} />
|
||
</div>
|
||
<div className="row"><label>chat</label>
|
||
<label style={{ width: 'auto' }}>
|
||
<input type="checkbox" checked={settings ? !!settings.chat_markdown : chatMd}
|
||
onChange={(e) => patchSettings({ chat_markdown: e.target.checked })} /> render replies as markdown
|
||
</label>
|
||
</div>
|
||
|
||
<h2>Paths</h2>
|
||
<div className="row">
|
||
<label title="Hugging Face cache directory (downloaded models and lenses). Applied at the next server start; run.py --hf-cache overrides it.">HF cache</label>
|
||
<input type="text" value={settings?.hf_cache ?? ''} placeholder="(shared HF cache)"
|
||
onChange={(e) => setSettings((s) => ({ ...s, hf_cache: e.target.value }))}
|
||
onBlur={(e) => patchSettings({ hf_cache: e.target.value.trim() })} />
|
||
<button title="browse folders"
|
||
onClick={async () => { const p = await pickFolder(); if (p) patchSettings({ hf_cache: p }) }}>🔍</button>
|
||
</div>
|
||
<div className="row">
|
||
<label title="llama.cpp folder — needs convert_hf_to_gguf.py (and llama-quantize for quantized outputs). Setting it enables the direct GGUF export in the token editor.">llama.cpp</label>
|
||
<input type="text" value={settings?.llamacpp_dir ?? ''} placeholder="(disables GGUF export)"
|
||
onChange={(e) => setSettings((s) => ({ ...s, llamacpp_dir: e.target.value }))}
|
||
onBlur={(e) => patchSettings({ llamacpp_dir: e.target.value.trim() })} />
|
||
<button title="browse folders"
|
||
onClick={async () => { const p = await pickFolder(); if (p) patchSettings({ llamacpp_dir: p }) }}>🔍</button>
|
||
</div>
|
||
<div className="src" style={{ marginTop: 4 }}>
|
||
HF cache applies at the next server start. llama.cpp enables the direct
|
||
GGUF export (editor → Export).
|
||
</div>
|
||
</>)}
|
||
|
||
<div className="status-line">
|
||
{busy && <span>({busy}) </span>}
|
||
{/* no duplicate "busy: generating" when busy already shows the state */}
|
||
{notice && !(busy && /^busy\s*:/.test(notice.text)) && <span className={notice.kind}>{notice.text}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="main">
|
||
{conv?.id && (
|
||
<div className="conv-header">
|
||
<input
|
||
className="conv-title-input"
|
||
defaultValue={conv.title}
|
||
key={`t${conv.id}`}
|
||
onBlur={(e) => e.target.value !== conv.title && patchConv({ title: e.target.value })}
|
||
/>
|
||
<input
|
||
className="conv-tags-input"
|
||
placeholder="tags (comma-separated)"
|
||
defaultValue={conv.tags?.join(', ')}
|
||
key={`g${conv.id}`}
|
||
onBlur={(e) => patchConv({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) })}
|
||
/>
|
||
<button onClick={() => setShowTree(!showTree)}>{showTree ? 'Hide' : 'Tree'}</button>
|
||
<a href={`/api/conversations/${conv.id}/export?format=json&frames=1`} download>JSON</a>
|
||
<a href={`/api/conversations/${conv.id}/export?format=markdown`} download>MD</a>
|
||
</div>
|
||
)}
|
||
|
||
{showTree && conv?.id && (
|
||
<div className="tree-panel">
|
||
{(childs[null] || []).map((root) => (
|
||
<TreeNode
|
||
key={root.id}
|
||
node={root}
|
||
childs={childs}
|
||
depth={0}
|
||
activeIds={activeIds}
|
||
onSelect={(nodeId) => applyPath(nodeId, tree)}
|
||
/>
|
||
))}
|
||
<div className="tree-hint">click a node = resume/branch from there</div>
|
||
</div>
|
||
)}
|
||
|
||
<details className="sys">
|
||
<summary>System prompt {system.trim() ? '●' : ''} {conv?.id ? '(set at creation)' : ''}</summary>
|
||
<textarea value={system} onChange={(e) => setSystem(e.target.value)} placeholder="(none)" disabled={!!conv?.id} />
|
||
</details>
|
||
|
||
<div className="messages" ref={messagesRef}>
|
||
{messages.map((m, i) => (
|
||
<div
|
||
key={m.id ?? `tmp${i}`}
|
||
className={`msg ${m.role} ${m.has_frames || m.frames?.length ? 'has-frames' : ''} ${selectedIdx === i ? 'msg-selected' : ''}`}
|
||
onClick={() => { if (m.has_frames || m.frames?.length) selectMessage(i) }}
|
||
>
|
||
{editingMsg?.idx === i ? (
|
||
<div className="msg-edit" onClick={(e) => e.stopPropagation()}>
|
||
<textarea
|
||
value={editingMsg.text}
|
||
onChange={(e) => setEditingMsg({ idx: i, text: e.target.value })}
|
||
rows={Math.min(16, Math.max(4, editingMsg.text.split('\n').length + 1))}
|
||
/>
|
||
<div className="msg-edit-actions">
|
||
<button className="primary" onClick={saveEditedMsg}>Save</button>
|
||
<button onClick={() => setEditingMsg(null)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
) : (() => {
|
||
const text = continuingId != null && m.id === continuingId && draft !== null
|
||
? m.content + draft
|
||
: m.content
|
||
return m.role === 'assistant' && chatMd ? <Md text={text} /> : text
|
||
})()}
|
||
{m.meta && (
|
||
<div className="msgmeta">
|
||
{m.meta.model_id} · {m.meta.quant || m.meta.dtype} · {m.meta.device} · T={m.meta.sampling?.temperature ?? '?'}
|
||
{m.meta.sampling?.seed >= 0 ? ` · seed=${m.meta.sampling.seed}` : ''} · {(m.stats || m.meta.stats)?.tok_per_s} tok/s
|
||
{m.meta.interventions?.length ? ` · ☢ ${m.meta.interventions.length} intervention(s)` : ''}
|
||
{m.has_frames || m.frames?.length ? ' · lens frames ◈' : ''}
|
||
{m.role === 'assistant' && m.id != null && (
|
||
<button
|
||
className="diff-btn"
|
||
title="edit this reply (later turns will use the edited text)"
|
||
onClick={(e) => { e.stopPropagation(); setEditingMsg({ idx: i, text: m.content }) }}
|
||
>✎</button>
|
||
)}
|
||
{(m.has_frames || m.frames?.length) ? (
|
||
<button
|
||
className="diff-btn"
|
||
title="A/B lens diff — pick TWO replies to compare their lens frames side by side"
|
||
onClick={async (e) => {
|
||
e.stopPropagation()
|
||
const key = m.id ?? `tmp${i}`
|
||
if (diffSel.find((x) => x.key === key)) {
|
||
setDiffSel((prev) => prev.filter((x) => x.key !== key))
|
||
return
|
||
}
|
||
let frames = m.frames
|
||
if (!frames?.length && m.id != null) {
|
||
try {
|
||
frames = (await jsonFetch(`/api/messages/${m.id}/frames`)).frames
|
||
} catch { return }
|
||
}
|
||
if (!frames?.length) return
|
||
setDiffSel((prev) => [...prev, { key, frames }].slice(-2))
|
||
}}
|
||
>{diffSel.find((x) => x.key === (m.id ?? `tmp${i}`)) ? `◧ ${diffSel.findIndex((x) => x.key === (m.id ?? `tmp${i}`)) === 0 ? 'A' : 'B'}` : '◧'}</button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{draft !== null && continuingId == null && (
|
||
<div className="msg assistant">{chatMd && draft ? <Md text={draft} /> : (draft || '…')}</div>
|
||
)}
|
||
</div>
|
||
|
||
{diffSel.length === 2 ? (
|
||
<LensDiff
|
||
framesA={diffSel[0].frames}
|
||
framesB={diffSel[1].frames}
|
||
labelA={`#${diffSel[0].key}`}
|
||
labelB={`#${diffSel[1].key}`}
|
||
onClose={() => setDiffSel([])}
|
||
/>
|
||
) : lensMeta && lensOn || messages.some((m) => m.frames?.length) ? (() => {
|
||
const { live, idx, genId: viewGen } = currentGenView()
|
||
const viewFrames = live || (idx >= 0 ? messages[idx].frames : [])
|
||
// keep LensView MOUNTED even with no frames (it renders an empty
|
||
// shell): unmounting here would wipe the pinned tokens, e.g. while
|
||
// regenerating the first reply of a conversation
|
||
return (
|
||
<>
|
||
{viewFrames.length > 0 && (
|
||
<div className="v-resizer" title="drag to set the lens view height — double-click for auto"
|
||
onMouseDown={startLensResize} onDoubleClick={() => setLensViewH(null)} />
|
||
)}
|
||
<LensView
|
||
frames={viewFrames}
|
||
tick={`${framesCount}-${messages.length}-${idx}`}
|
||
genId={viewGen ?? null}
|
||
lensMeta={lensMeta}
|
||
hidden={hidden}
|
||
onHideToken={hideToken}
|
||
onNotice={(text, kind) => setNotice({ kind: kind || 'err', text })}
|
||
onEditToken={(id, str, layer) => openEditorWith({ id, str, layer })}
|
||
maxH={lensViewH}
|
||
editorOpen={editorOpen}
|
||
streaming={streaming}
|
||
/>
|
||
</>
|
||
)
|
||
})() : null}
|
||
|
||
<div className="composer">
|
||
<div className="controls">
|
||
<label>temp <input type="number" step="0.05" min="0" max="2" value={sampling.temperature}
|
||
onChange={(e) => setSampling({ ...sampling, temperature: +e.target.value })} /></label>
|
||
<label>top_p <input type="number" step="0.01" min="0" max="1" value={sampling.top_p}
|
||
onChange={(e) => setSampling({ ...sampling, top_p: +e.target.value })} /></label>
|
||
<label>top_k <input type="number" step="1" min="0" value={sampling.top_k}
|
||
onChange={(e) => setSampling({ ...sampling, top_k: +e.target.value })} /></label>
|
||
<label>max <input type="number" step="16" min="1" value={sampling.max_tokens}
|
||
onChange={(e) => setSampling({ ...sampling, max_tokens: +e.target.value })} /></label>
|
||
<label title="-1 = random; ≥ 0 = reproducible sampling">seed <input type="number" step="1" min="-1" value={sampling.seed}
|
||
onChange={(e) => setSampling({ ...sampling, seed: Math.trunc(+e.target.value) })} /></label>
|
||
<button onClick={onRegenerate} disabled={streaming || !messages.some((m) => m.role === 'assistant')}>Regenerate</button>
|
||
<button onClick={onContinue}
|
||
title="extend the last reply: the model picks up exactly where it stopped"
|
||
disabled={streaming || messages[messages.length - 1]?.role !== 'assistant' || messages[messages.length - 1]?.id == null}>
|
||
Continue</button>
|
||
<button onClick={onEditLast} disabled={streaming || !messages.some((m) => m.role === 'user')}>Edit last</button>
|
||
<label title="render assistant replies as markdown">
|
||
<input type="checkbox" checked={chatMd} onChange={(e) => setChatMd(e.target.checked)} /> md
|
||
</label>
|
||
{lensMeta && (
|
||
<button
|
||
className={editorOpen ? 'ed-toggle-on' : ''}
|
||
style={{ marginLeft: 'auto' }}
|
||
onClick={() => setEditorOpen(!editorOpen)}
|
||
>☢ Editor{ivRules.length ? ` (${ivRules.length})` : ''}{ivScale !== 1 ? ` ×${(+ivScale).toFixed(2)}` : ''}</button>
|
||
)}
|
||
{editParent !== undefined && <span className="fork-note">branch from #{editParent ?? 'root'}</span>}
|
||
</div>
|
||
<div className="inputrow">
|
||
<textarea
|
||
value={input}
|
||
placeholder={loadedId ? 'Message... (Enter to send, Shift+Enter for a new line)' : 'Load a model to chat'}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend() }
|
||
}}
|
||
/>
|
||
{streaming
|
||
? <button className="danger" onClick={onStop}>Stop</button>
|
||
: <button className="primary" onClick={onSend} disabled={!loadedId || !input.trim()}>Send</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Editor
|
||
open={editorOpen}
|
||
onClose={() => setEditorOpen(false)}
|
||
rules={ivRules}
|
||
scale={ivScale}
|
||
mode={ivMode}
|
||
lensMeta={lensMeta}
|
||
nLayers={status?.loaded?.n_layers}
|
||
genId={currentGenView().genId}
|
||
busy={busy}
|
||
prefill={editorPrefill}
|
||
onPrefillConsumed={() => setEditorPrefill(null)}
|
||
onRules={setIvRules}
|
||
onScale={setIvScale}
|
||
onMode={setIvMode}
|
||
onNotice={(text, kind) => setNotice({ kind: kind || 'err', text })}
|
||
rebaseSupported={status?.loaded?.rebase_supported}
|
||
autoLayerRadius={settings?.auto_layer_radius}
|
||
llamaCppSet={!!settings?.llamacpp_dir}
|
||
ggufState={status?.gguf}
|
||
/>
|
||
</>
|
||
)
|
||
}
|