@polycode-projects/seonix 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -25
- package/bin/cli.mjs +78 -18
- package/package.json +5 -3
- package/roslyn/Program.cs +79 -11
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +370 -5
- package/src/ask.mjs +1523 -83
- package/src/browser.mjs +99 -19
- package/src/chat.mjs +785 -0
- package/src/codegraph.mjs +213 -5
- package/src/cs_treesitter.mjs +57 -34
- package/src/embed.mjs +191 -0
- package/src/extract.mjs +220 -39
- package/src/java_treesitter.mjs +82 -55
- package/src/jsts_tsc.mjs +51 -4
- package/src/nlp-bundle.mjs +120 -0
- package/src/prose-nlp.mjs +52 -0
- package/src/prose.mjs +42 -0
- package/src/schema-docs.mjs +29 -0
- package/src/server.mjs +27 -4
- package/src/sessions.mjs +206 -0
- package/src/temporal.mjs +70 -0
- package/src/timeline.mjs +160 -0
- package/src/viz.mjs +273 -83
package/src/viz.mjs
CHANGED
|
@@ -25,6 +25,8 @@ import { parseEntities, resolveSymbol, relationKind, siteOf } from "./codegraph.
|
|
|
25
25
|
import { loadConfig } from "./config.mjs";
|
|
26
26
|
import * as source from "./source.mjs";
|
|
27
27
|
import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents, renderBrowserHtml } from "./browser.mjs";
|
|
28
|
+
import { extractTimeline, renderTimelineHtml } from "./timeline.mjs";
|
|
29
|
+
import { winkBrowserBundle } from "./nlp-bundle.mjs";
|
|
28
30
|
|
|
29
31
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
32
|
const execFileP = promisify(execFile);
|
|
@@ -134,7 +136,7 @@ export function buildSubgraph(graph, { focusId, depth = 2, hubDegree = 40, maxNo
|
|
|
134
136
|
|
|
135
137
|
/** The viewer's runtime payload — everything repo-specific lives here, nothing in
|
|
136
138
|
* the page. Pure; schema is part of the viewer contract (tests pin it). */
|
|
137
|
-
export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false } = {}) {
|
|
139
|
+
export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false, nav = null } = {}) {
|
|
138
140
|
return {
|
|
139
141
|
focusId: subgraph.focusId,
|
|
140
142
|
focusLabel: focusLabel || subgraph.focusId,
|
|
@@ -142,7 +144,8 @@ export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef =
|
|
|
142
144
|
maxDepth: subgraph.nodes.reduce((m, n) => Math.max(m, n.depth), 0),
|
|
143
145
|
repoUrl: repoUrl.replace(/\/+$/, ""),
|
|
144
146
|
repoRef,
|
|
145
|
-
siteNav: !!siteNav,
|
|
147
|
+
siteNav: !!siteNav, // legacy flag — stale deployed data keeps its absolute links
|
|
148
|
+
...(nav ? { nav } : {}), // {name: href} — the viewer rebuilds #sitenav from this
|
|
146
149
|
nodes: subgraph.nodes,
|
|
147
150
|
edges: subgraph.edges,
|
|
148
151
|
};
|
|
@@ -201,12 +204,24 @@ const inlineJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
|
201
204
|
* silently produce incomplete answers, which this project's honesty contract
|
|
202
205
|
* doesn't allow (see the file-level comment's "ONE viewer, three data channels" —
|
|
203
206
|
* this is a fourth, for the same one-viewer-many-modes reason).
|
|
207
|
+
*
|
|
208
|
+
* The OPTIONAL wink-nlp lemma tier (ask-nlp.mjs's browser twin, from nlp-bundle.mjs)
|
|
209
|
+
* reaches the chat two ways: `nlpInline` inlines the ~4 MB bundle as its own <script>
|
|
210
|
+
* so it registers `window.__seonixNlp` at page load (portable `viz --nlp`); `nlpPath`
|
|
211
|
+
* points the page at a same-origin sibling asset the chat LAZY-loads on first ask
|
|
212
|
+
* (the SITE build). Neither is present in the default local single-file — that viewer
|
|
213
|
+
* stays lemma-off and fetches nothing, exactly as before, preserving its
|
|
214
|
+
* no-external-fetch guarantee. ask() picks up whatever `window.__seonixNlp` is set,
|
|
215
|
+
* or degrades to the adapter-less tiers when it's absent.
|
|
204
216
|
*/
|
|
205
|
-
export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null, dataPath = null, askData = null, askDataPath = null } = {}) {
|
|
217
|
+
export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null, dataPath = null, askData = null, askDataPath = null, nlpInline = null, nlpPath = null } = {}) {
|
|
206
218
|
const embedded = embedData ? `<script type="application/json" id="seonix-data">${inlineJson(embedData)}</script>\n` : "";
|
|
207
219
|
const askEmbedded = askData ? `<script type="application/json" id="seonix-ask-data">${inlineJson(askData)}</script>\n` : "";
|
|
208
|
-
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}) };
|
|
220
|
+
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}), ...(nlpPath ? { nlp: nlpPath } : {}) };
|
|
209
221
|
const cfg = Object.keys(cfgObj).length ? `<script type="application/json" id="seonix-cfg">${inlineJson(cfgObj)}</script>\n` : "";
|
|
222
|
+
// Inline bundle: escape any literal </script inside the wink model data so the
|
|
223
|
+
// block can't be closed early (harmless inside JS strings, impossible outside them).
|
|
224
|
+
const nlpEmbedded = nlpInline ? `<script>${String(nlpInline).replace(/<\/script/gi, "<\\/script")}</script>\n` : "";
|
|
210
225
|
return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map</title>
|
|
211
226
|
<!-- generated by: seonix viz (see packages/seonix/src/viz.mjs) — one viewer, data loaded at runtime -->
|
|
212
227
|
<style>
|
|
@@ -219,18 +234,25 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
219
234
|
#bar label,.lbl{color:#a9b1d6} #bar b{color:#7aa2f7}
|
|
220
235
|
#bar button{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;line-height:1.2}
|
|
221
236
|
#bar button:hover{border-color:#7aa2f7;color:#7aa2f7}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
.
|
|
225
|
-
#hub{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
|
|
237
|
+
#bar button:disabled{opacity:.4;cursor:default}
|
|
238
|
+
#bar button:disabled:hover{border-color:#2a2e42;color:#c0caf5}
|
|
239
|
+
#depthval{min-width:1.2em;text-align:center;display:inline-block;cursor:help}
|
|
240
|
+
#hub,#beam{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
|
|
226
241
|
#legend{padding:4px 12px 6px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px;flex:0 0 auto}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
242
|
+
/* Graph on top (full width, generous height); the chat + node-detail ride a
|
|
243
|
+
full-width row BENEATH it (chat grows, detail a compact fixed column). On a
|
|
244
|
+
narrow screen #below stacks so the body never scrolls sideways. */
|
|
245
|
+
#main{flex:1 1 auto;display:flex;flex-direction:column;min-height:0}
|
|
246
|
+
#cy{flex:1 1 auto;position:relative;min-height:260px}
|
|
247
|
+
#below{flex:0 0 auto;display:flex;background:#16161e;border-top:1px solid #2a2e42;max-height:46vh;box-sizing:border-box}
|
|
248
|
+
#ask{flex:1 1 auto;min-width:0;padding:14px 16px;overflow:auto;box-sizing:border-box;display:flex;flex-direction:column}
|
|
249
|
+
#detail{flex:0 0 300px;padding:14px;overflow:auto;border-left:1px solid #2a2e42;box-sizing:border-box}
|
|
250
|
+
@media(max-width:720px){#below{flex-direction:column;max-height:58vh;overflow:auto}#detail{flex:0 0 auto;border-left:0;border-top:1px solid #2a2e42}}
|
|
230
251
|
.lg{white-space:nowrap;cursor:pointer;user-select:none;color:#a9b1d6}.lg i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:middle}.lg input{vertical-align:middle;margin:0 3px 0 0}
|
|
231
252
|
.lg .cnt{color:#565f89;margin-left:3px;font-size:11px;font-variant-numeric:tabular-nums}
|
|
232
253
|
select{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;font:inherit}
|
|
233
|
-
#
|
|
254
|
+
#detail h3{margin:2px 0 8px;color:#c0caf5;font-size:15px;word-break:break-all}
|
|
255
|
+
#ask h4{margin:0 0 8px;color:#a9b1d6;font-size:11px;text-transform:uppercase;letter-spacing:.04em}
|
|
234
256
|
.badge{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border:1px solid #2a2e42;border-radius:10px;font-size:11px;color:#a9b1d6}
|
|
235
257
|
.badge i{width:8px;height:8px;border-radius:50%;display:inline-block}
|
|
236
258
|
#detail dl{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;margin:10px 0;font-size:12px;color:#c0caf5}
|
|
@@ -241,32 +263,34 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
241
263
|
.hint{color:#565f89;font-size:11px;margin-top:10px}
|
|
242
264
|
.empty{color:#a9b1d6;font-size:12px;line-height:1.7}
|
|
243
265
|
code{color:#9aa5ce;word-break:break-all}
|
|
244
|
-
#ask{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid #2a2e42}
|
|
245
266
|
.askrow{display:flex;gap:6px}
|
|
246
|
-
#askq{flex:1;min-width:0;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:
|
|
267
|
+
#askq{flex:1;min-width:0;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:7px 10px;font:inherit;font-size:13px}
|
|
247
268
|
#askq:focus{outline:none;border-color:#7aa2f7}
|
|
248
|
-
.askresult{margin-top:
|
|
269
|
+
.askresult{margin-top:10px;flex:1 1 auto;font-size:12.5px;line-height:1.6;color:#c0caf5;overflow:auto}
|
|
249
270
|
.askresult.miss{color:#a9b1d6;font-style:italic}
|
|
250
271
|
.askresult .askq-echo{color:#565f89;margin-bottom:4px;font-style:normal}
|
|
251
272
|
</style></head><body>
|
|
252
273
|
<div id="bar">
|
|
253
274
|
<span class="grp"><b>seon</b> focus: <b id="focuslabel">…</b></span>
|
|
254
|
-
<span class="grp"><span class="lbl">depth</span><
|
|
255
|
-
<span class="grp"><label title="hide nodes with more connections than this (hubs swamp the layout)"><input type="checkbox" id="hubon" checked> hide hubs deg> <input id="hub" type="number" min="2" max="200" value="16"></label
|
|
275
|
+
<span class="grp"><span class="lbl">depth</span><button id="depthdown" title="shallower">−</button><b id="depthval"></b><button id="depthup" title="deeper">+</button></span>
|
|
276
|
+
<span class="grp"><label title="hide nodes with more connections than this (hubs swamp the layout)"><input type="checkbox" id="hubon" checked> hide hubs deg> <input id="hub" type="number" min="2" max="200" value="16"></label>
|
|
277
|
+
<label title="beam-prune from the focus: at each hop keep only the top-N neighbours by degree (margin+cap prune) — a degree-scored analogue of the ask engine's beam search, NOT the same scorer"><input type="checkbox" id="beamon"> beam <input id="beam" type="number" min="1" max="32" value="8"></label></span>
|
|
256
278
|
<span class="grp"><label>labels <select id="verb"><option value="smart">smart</option><option value="name">all names</option><option value="site">name+site</option><option value="none">none</option></select></label>
|
|
257
279
|
<label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label></span>
|
|
258
280
|
<span class="grp" id="view">
|
|
259
|
-
<button id="
|
|
281
|
+
<button id="fit" title="fit the whole graph in view (Esc)">fit</button>
|
|
260
282
|
<button id="zoomout" title="zoom out">−</button>
|
|
261
283
|
<button id="zoomin" title="zoom in">+</button>
|
|
284
|
+
<button id="reset" title="restore the boot view: default filters, all types shown, original focus">reset</button>
|
|
262
285
|
</span>
|
|
263
286
|
<span class="grp nav" id="sitenav" hidden><a href="/">home</a><a href="/code-browser.html">browser</a><a href="/timeline.html">timeline</a></span>
|
|
264
287
|
</div>
|
|
265
288
|
<div id="legend"></div>
|
|
266
289
|
<div id="main">
|
|
267
290
|
<div id="cy"></div>
|
|
268
|
-
<div id="
|
|
291
|
+
<div id="below">
|
|
269
292
|
<div id="ask">
|
|
293
|
+
<h4>Ask the graph</h4>
|
|
270
294
|
<div class="askrow"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "what calls this"'><button class="btn" id="asksubmit">ask</button></div>
|
|
271
295
|
<div id="askresult" class="askresult"></div>
|
|
272
296
|
</div>
|
|
@@ -275,7 +299,7 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
275
299
|
</div>
|
|
276
300
|
${embedded}${askEmbedded}${cfg}<script>${cytoscape}</script>
|
|
277
301
|
<script>${askEngine}</script>
|
|
278
|
-
<script>
|
|
302
|
+
${nlpEmbedded}<script>
|
|
279
303
|
// Type palette (see the CVD note in viz.mjs) — viewer styling, identical for every repo.
|
|
280
304
|
const COLORS={Module:'#3987e5',Class:'#c98500',Function:'#008300',Method:'#d55181',Attribute:'#9085e9',GlobalVariable:'#d95926',Commit:'#199e70'};
|
|
281
305
|
const $=id=>document.getElementById(id);
|
|
@@ -318,22 +342,53 @@ function loadAskData(){
|
|
|
318
342
|
})();
|
|
319
343
|
return _askGraphPromise;
|
|
320
344
|
}
|
|
345
|
+
// The OPTIONAL wink-nlp lemma adapter (window.__seonixNlp — see nlp-bundle.mjs).
|
|
346
|
+
// An inlined bundle registers it at page load; the site build ships it as a
|
|
347
|
+
// same-origin sibling this injects LAZILY on the first ask (never at boot), so the
|
|
348
|
+
// ~4 MB model is only paid for when the chat is actually used. With neither present
|
|
349
|
+
// (the default local single-file) this resolves to undefined and the chat runs
|
|
350
|
+
// adapter-less — ask() then falls back to its curated + fuzzy tiers, exactly as
|
|
351
|
+
// before, and NOTHING is fetched, keeping the no-external-fetch guarantee intact.
|
|
352
|
+
let _nlpPromise=null;
|
|
353
|
+
function loadNlp(){
|
|
354
|
+
if(window.__seonixNlp) return Promise.resolve(window.__seonixNlp);
|
|
355
|
+
if(_nlpPromise) return _nlpPromise;
|
|
356
|
+
const cfgEl=document.getElementById('seonix-cfg');
|
|
357
|
+
const url=cfgEl?JSON.parse(cfgEl.textContent).nlp:null;
|
|
358
|
+
if(!url) return Promise.resolve(undefined);
|
|
359
|
+
_nlpPromise=new Promise(res=>{
|
|
360
|
+
const s=document.createElement('script');
|
|
361
|
+
s.src=url;
|
|
362
|
+
s.onload=()=>res(window.__seonixNlp);
|
|
363
|
+
s.onerror=()=>res(undefined); // a failed asset degrades to lemma-off, never a broken chat
|
|
364
|
+
document.head.appendChild(s);
|
|
365
|
+
});
|
|
366
|
+
return _nlpPromise;
|
|
367
|
+
}
|
|
321
368
|
function init(G){
|
|
322
369
|
document.title='seon code-map — '+G.focusLabel;
|
|
323
370
|
$('focuslabel').textContent=G.focusLabel;
|
|
324
|
-
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
371
|
+
// nav rides the data payload ({name: href}, hrefs relative to THIS page);
|
|
372
|
+
// G.siteNav is the legacy flag — stale deployed data keeps its absolute links.
|
|
373
|
+
if(G.nav){$('sitenav').innerHTML=Object.entries(G.nav).map(([k,h])=>'<a href="'+esc(h)+'">'+esc(k)+'</a>').join('');$('sitenav').hidden=false;}
|
|
374
|
+
else if(G.siteNav)$('sitenav').hidden=false;
|
|
375
|
+
// Legend doubles as a node-type filter: a checkbox chip per type GROUP —
|
|
376
|
+
// Function/Method share a chip (legend-only merge; node fills stay distinct),
|
|
377
|
+
// with a live visible/loaded count summed across the group.
|
|
378
|
+
const GROUPS=[['Module'],['Class'],['Function','Method'],['Attribute'],['GlobalVariable'],['Commit']];
|
|
379
|
+
$('legend').innerHTML=GROUPS
|
|
380
|
+
.map(g=>'<label class="lg" title="show/hide '+g.join('/')+' nodes"><input type="checkbox" class="typechk" data-cls="'+g.join(',')+'" checked>'+g.map(k=>'<i style="background:'+COLORS[k]+'"></i>').join('')+g.join('/')+'<span class="cnt" data-cnt="'+g.join(',')+'"></span></label>')
|
|
330
381
|
.join(' ');
|
|
331
|
-
// depth is a
|
|
332
|
-
const maxDepth=Math.max(
|
|
333
|
-
|
|
334
|
-
|
|
382
|
+
// depth is a − [N] + stepper clamped to the DATA's max depth (no dead buttons), default 2
|
|
383
|
+
const maxDepth=Math.max(1,G.maxDepth);
|
|
384
|
+
let depthVal=Math.min(2,maxDepth);
|
|
385
|
+
$('depthval').title='captured to depth '+maxDepth+' — regenerate with --depth for more';
|
|
386
|
+
function syncDepthUi(){$('depthval').textContent=depthVal;$('depthdown').disabled=depthVal<=1;$('depthup').disabled=depthVal>=maxDepth;}
|
|
387
|
+
syncDepthUi();
|
|
388
|
+
const INITIAL_FOCUS=G.focusId;
|
|
335
389
|
const cy=cytoscape({container:document.getElementById('cy'),
|
|
336
|
-
|
|
390
|
+
// depth0 caches the boot depth per node BEFORE any recentre mutates data('depth') — the reset button restores from it
|
|
391
|
+
elements:[...G.nodes.map(n=>({data:{...n,depth0:n.depth,color:COLORS[n.cls]||'#8a8f98'}})),...G.edges.map((e,i)=>({data:{id:'e'+i,...e}}))],
|
|
337
392
|
style:[
|
|
338
393
|
// black text outline keeps white labels readable over node fills and the dark canvas;
|
|
339
394
|
// node size encodes degree (bounded) so hubs read as hubs at a glance.
|
|
@@ -355,26 +410,53 @@ function init(G){
|
|
|
355
410
|
let selId=null;
|
|
356
411
|
function enabledTypes(){
|
|
357
412
|
const on=new Set();
|
|
358
|
-
document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)
|
|
413
|
+
document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)c.dataset.cls.split(',').forEach(t=>on.add(t));});
|
|
359
414
|
return on;
|
|
360
415
|
}
|
|
361
|
-
function curDepth(){return
|
|
416
|
+
function curDepth(){return depthVal;}
|
|
362
417
|
function curHub(){return $('hubon').checked ? +$('hub').value : Infinity;}
|
|
418
|
+
const NODE=new Map(G.nodes.map(n=>[n.id,n]));
|
|
419
|
+
// Beam mode: BFS from the focus over the loaded adjacency, pruning each node's
|
|
420
|
+
// candidate neighbours (those passing hub+type filters) to the top-width by
|
|
421
|
+
// DEGREE with margin deg >= best*0.5 — mirrors BEAM_MARGIN_FRAC=0.5 in
|
|
422
|
+
// codegraph.mjs, but degree-scored: an analogue of the ask engine's beam
|
|
423
|
+
// search, not the same scorer.
|
|
424
|
+
function beamSet(width,h,types){
|
|
425
|
+
const pass=id=>{const n=NODE.get(id);return !!n&&(id===G.focusId||n.degree<=h)&&types.has(n.cls);};
|
|
426
|
+
const seen=new Set([G.focusId]);
|
|
427
|
+
let frontier=[G.focusId];
|
|
428
|
+
while(frontier.length){
|
|
429
|
+
const next=[];
|
|
430
|
+
for(const id of frontier){
|
|
431
|
+
const cand=(ADJ[id]||[]).filter(nb=>!seen.has(nb)&&pass(nb))
|
|
432
|
+
.sort((a,b)=>NODE.get(b).degree-NODE.get(a).degree);
|
|
433
|
+
const best=cand.length?NODE.get(cand[0]).degree:0;
|
|
434
|
+
for(const nb of cand.slice(0,width)){
|
|
435
|
+
if(NODE.get(nb).degree>=best*0.5){seen.add(nb);next.push(nb);}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
frontier=next;
|
|
439
|
+
}
|
|
440
|
+
return seen;
|
|
441
|
+
}
|
|
363
442
|
function applyFilters(){
|
|
364
443
|
const d=curDepth(), h=curHub(), types=enabledTypes();
|
|
444
|
+
const beam=$('beamon').checked?beamSet(Math.max(1,Math.min(32,+$('beam').value||8)),h,types):null;
|
|
365
445
|
const vis={}, tot={};
|
|
366
446
|
cy.batch(()=>cy.nodes().forEach(n=>{
|
|
367
447
|
const cls=n.data('cls');
|
|
368
448
|
tot[cls]=(tot[cls]||0)+1;
|
|
369
|
-
const v =
|
|
449
|
+
const v = beam ? beam.has(n.id())
|
|
450
|
+
: n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h) && types.has(cls);
|
|
370
451
|
if(v) vis[cls]=(vis[cls]||0)+1;
|
|
371
452
|
n.style('display', v?'element':'none');
|
|
372
453
|
}));
|
|
373
|
-
// live per-
|
|
454
|
+
// live per-group counts: visible/loaded summed across the group's classes
|
|
374
455
|
document.querySelectorAll('.cnt').forEach(s=>{
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
s.
|
|
456
|
+
const ks=s.dataset.cnt.split(',');
|
|
457
|
+
const v=ks.reduce((a,k)=>a+(vis[k]||0),0), t=ks.reduce((a,k)=>a+(tot[k]||0),0);
|
|
458
|
+
s.textContent=v+'/'+t;
|
|
459
|
+
s.title=v+' visible of '+t+' loaded '+ks.join('/')+' node(s) at depth '+d+(isFinite(h)?', hide deg>'+h:'');
|
|
378
460
|
});
|
|
379
461
|
applyLabels();
|
|
380
462
|
}
|
|
@@ -397,18 +479,39 @@ function init(G){
|
|
|
397
479
|
}
|
|
398
480
|
cy.on('mouseover','node',e=>{if($('verb').value==='smart')e.target.style('label',labelText(e.target,false));});
|
|
399
481
|
cy.on('mouseout','node',e=>{if($('verb').value==='smart')applyLabels();});
|
|
400
|
-
|
|
401
|
-
|
|
482
|
+
const stepDepth=dir=>{const d=Math.min(maxDepth,Math.max(1,depthVal+dir));if(d===depthVal)return;depthVal=d;syncDepthUi();applyFilters();};
|
|
483
|
+
$('depthdown').addEventListener('click',()=>stepDepth(-1));
|
|
484
|
+
$('depthup').addEventListener('click',()=>stepDepth(1));
|
|
402
485
|
$('hubon').addEventListener('change',applyFilters);
|
|
403
486
|
$('hub').addEventListener('input',applyFilters);
|
|
487
|
+
$('beamon').addEventListener('change',applyFilters);
|
|
488
|
+
$('beam').addEventListener('input',applyFilters);
|
|
404
489
|
document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',applyFilters));
|
|
405
490
|
$('verb').addEventListener('change',applyLabels);
|
|
406
491
|
$('layout').addEventListener('change',()=>cy.layout({name:$('layout').value,animate:false}).run());
|
|
407
492
|
const ZF=1.2;
|
|
408
493
|
const zoomBy=(f)=>cy.zoom({level:cy.zoom()*f,renderedPosition:{x:cy.width()/2,y:cy.height()/2}});
|
|
409
|
-
$('
|
|
494
|
+
$('fit').addEventListener('click',()=>{cy.fit(undefined,30);});
|
|
410
495
|
$('zoomin').addEventListener('click',()=>zoomBy(ZF));
|
|
411
496
|
$('zoomout').addEventListener('click',()=>zoomBy(1/ZF));
|
|
497
|
+
// real reset: back to the boot view — defaults, all types on, depths from
|
|
498
|
+
// depth0 (the pre-recentre capture), focus ring on the boot focus.
|
|
499
|
+
$('reset').addEventListener('click',()=>{
|
|
500
|
+
if($('layout').value!=='cose'){$('layout').value='cose';cy.layout({name:'cose',animate:false}).run();}
|
|
501
|
+
$('verb').value='smart';
|
|
502
|
+
$('hubon').checked=true;$('hub').value=16;
|
|
503
|
+
$('beamon').checked=false;$('beam').value=8;
|
|
504
|
+
depthVal=Math.min(2,maxDepth);syncDepthUi();
|
|
505
|
+
document.querySelectorAll('.typechk').forEach(c=>{c.checked=true;});
|
|
506
|
+
cy.getElementById(G.focusId).removeClass('focus');
|
|
507
|
+
G.focusId=INITIAL_FOCUS;
|
|
508
|
+
cy.batch(()=>cy.nodes().forEach(n=>n.data('depth',n.data('depth0'))));
|
|
509
|
+
cy.getElementById(INITIAL_FOCUS).addClass('focus');
|
|
510
|
+
$('focuslabel').textContent=G.focusLabel;
|
|
511
|
+
applyFilters();
|
|
512
|
+
select(null);
|
|
513
|
+
cy.fit(undefined,30);
|
|
514
|
+
});
|
|
412
515
|
// Deep link into the source host (GitLab URL layout) when the data carries a
|
|
413
516
|
// repoUrl: Commit nodes -> /-/commit/<sha>, anything with a file site ->
|
|
414
517
|
// /-/blob/<ref>/<path>#L<lines>, bare Modules -> the file blob.
|
|
@@ -504,8 +607,10 @@ function init(G){
|
|
|
504
607
|
return;
|
|
505
608
|
}
|
|
506
609
|
out.textContent='thinking…';
|
|
507
|
-
|
|
508
|
-
|
|
610
|
+
// Load the graph and (if configured) the wink lemma adapter together, then run
|
|
611
|
+
// the mechanical ask. An undefined nlp makes ask() degrade to its adapter-less tiers.
|
|
612
|
+
Promise.all([loadAskData(),loadNlp()]).then(([askGraph,nlp])=>{
|
|
613
|
+
const {content,seonix_ask}=ask(askGraph,query,{contextId:selId,nlp});
|
|
509
614
|
const total=(seonix_ask.matches||[]).length;
|
|
510
615
|
const shown=total?highlightAsk(seonix_ask.matches.map(m=>m.id)):0;
|
|
511
616
|
out.classList.toggle('miss',!!seonix_ask.miss);
|
|
@@ -587,11 +692,15 @@ function resolveFocus(graph, focusArg) {
|
|
|
587
692
|
* code browser rides along at `/code-browser.html` (alias `/browser`) with a live
|
|
588
693
|
* temporal payload — the local equivalent of the site's browser section.
|
|
589
694
|
*/
|
|
590
|
-
export async function startVizServer({ values, cytoscape, askEngine = "", log = () => {} }) {
|
|
695
|
+
export async function startVizServer({ values, cytoscape, askEngine = "", nlpBundle = null, log = () => {} }) {
|
|
591
696
|
const port = Math.max(0, parseInt(values.port, 10) || 0);
|
|
592
697
|
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
593
|
-
|
|
698
|
+
// With --nlp the local live view gets the same lemma tier as the site: the bundle
|
|
699
|
+
// is served in-process at /seonix-nlp.js and the viewer lazy-loads it on first ask.
|
|
700
|
+
const viewer = renderViewerHtml({ cytoscape, askEngine, nlpPath: nlpBundle ? "/seonix-nlp.js" : null });
|
|
594
701
|
const browserPage = await renderBrowserHtml({ cytoscape });
|
|
702
|
+
// nav = the routes this server itself serves; injected into every payload/page
|
|
703
|
+
const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
|
|
595
704
|
const buildData = async () => {
|
|
596
705
|
const { graph } = await loadGraph(values);
|
|
597
706
|
const focus = resolveFocus(graph, values.focus);
|
|
@@ -602,7 +711,7 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
602
711
|
hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
|
|
603
712
|
maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
|
|
604
713
|
});
|
|
605
|
-
return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref });
|
|
714
|
+
return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref, nav });
|
|
606
715
|
};
|
|
607
716
|
// The chat panel's own channel — the FULL raw graph (not the depth-limited display
|
|
608
717
|
// sub-graph above), rebuilt fresh per request so a re-index shows up without a
|
|
@@ -620,7 +729,17 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
620
729
|
gitCommitParents(process.cwd()), // P3 ghost-branch merges
|
|
621
730
|
]);
|
|
622
731
|
const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
|
|
623
|
-
return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()) });
|
|
732
|
+
return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()), nav });
|
|
733
|
+
};
|
|
734
|
+
const buildTimelinePage = async () => {
|
|
735
|
+
const { raw } = await loadGraph(values);
|
|
736
|
+
return renderTimelineHtml({
|
|
737
|
+
commits: extractTimeline(raw),
|
|
738
|
+
repoUrl,
|
|
739
|
+
repoRef: values.ref,
|
|
740
|
+
generatedAt: raw.generated_at || "",
|
|
741
|
+
nav,
|
|
742
|
+
});
|
|
624
743
|
};
|
|
625
744
|
await buildData(); // fail fast (missing graph, bad focus) before binding the port
|
|
626
745
|
const server = createServer(async (req, res) => {
|
|
@@ -631,6 +750,16 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
631
750
|
} else if (path === "/browser" || path === "/code-browser.html") {
|
|
632
751
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
633
752
|
res.end(browserPage);
|
|
753
|
+
} else if (path === "/timeline" || path === "/timeline.html") {
|
|
754
|
+
// rendered from a fresh graph load, same re-index-shows-on-refresh invariant
|
|
755
|
+
try {
|
|
756
|
+
const page = await buildTimelinePage();
|
|
757
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
758
|
+
res.end(page);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
761
|
+
res.end(`failed to render the timeline: ${String(err.message || err)}`);
|
|
762
|
+
}
|
|
634
763
|
} else if (path === "/seonix-graph-data.json") {
|
|
635
764
|
try {
|
|
636
765
|
res.writeHead(200, { "content-type": "application/json" });
|
|
@@ -666,6 +795,9 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
666
795
|
res.writeHead(500, { "content-type": "application/json" });
|
|
667
796
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
668
797
|
}
|
|
798
|
+
} else if (path === "/seonix-nlp.js" && nlpBundle) {
|
|
799
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
800
|
+
res.end(nlpBundle);
|
|
669
801
|
} else {
|
|
670
802
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
671
803
|
res.end("not found");
|
|
@@ -693,17 +825,28 @@ export async function runVizCli(argv) {
|
|
|
693
825
|
"site-nav": { type: "boolean", default: false },
|
|
694
826
|
serve: { type: "boolean", default: false },
|
|
695
827
|
port: { type: "string", default: "0" },
|
|
696
|
-
// Chronograph (code browser) artifacts —
|
|
828
|
+
// Chronograph (code browser) + timeline artifacts — generated next to --out
|
|
829
|
+
// by default; --graph-only suppresses both. See src/browser.mjs, src/timeline.mjs.
|
|
697
830
|
"browser-out": { type: "string" },
|
|
698
831
|
"browser-data-out": { type: "string" },
|
|
832
|
+
"timeline-out": { type: "string" },
|
|
833
|
+
"graph-only": { type: "boolean", default: false },
|
|
699
834
|
scope: { type: "string", default: "product" },
|
|
835
|
+
// Include the OPTIONAL wink-nlp lemma tier in the chat (see nlp-bundle.mjs).
|
|
836
|
+
// Site mode (--data-out): written as a same-origin sibling the page lazy-loads.
|
|
837
|
+
// Portable/serve mode: inlined (single-file) / served in-process. Off by default,
|
|
838
|
+
// so the local single-file stays lean and lemma-off exactly as before.
|
|
839
|
+
nlp: { type: "boolean", default: false },
|
|
700
840
|
},
|
|
701
841
|
allowPositionals: false,
|
|
702
842
|
});
|
|
703
843
|
const cytoscape = await cytoscapeSource();
|
|
704
844
|
const askEngine = await askSource();
|
|
845
|
+
// Build the wink lemma bundle once, only when --nlp asked for it (~4 MB; skip the
|
|
846
|
+
// work entirely otherwise). Wired below as a sibling asset (site) or inline (portable).
|
|
847
|
+
const nlpBundle = values.nlp ? await winkBrowserBundle() : null;
|
|
705
848
|
if (values.serve) {
|
|
706
|
-
await startVizServer({ values, cytoscape, askEngine, log: (s) => process.stderr.write(s) });
|
|
849
|
+
await startVizServer({ values, cytoscape, askEngine, nlpBundle, log: (s) => process.stderr.write(s) });
|
|
707
850
|
return; // keeps serving until Ctrl-C
|
|
708
851
|
}
|
|
709
852
|
const { config, graph, raw } = await loadGraph(values);
|
|
@@ -721,13 +864,30 @@ export async function runVizCli(argv) {
|
|
|
721
864
|
hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
|
|
722
865
|
maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
|
|
723
866
|
});
|
|
867
|
+
const out = resolve(values.out);
|
|
868
|
+
// Siblings by default: the code browser and the commit timeline land next to
|
|
869
|
+
// --out unless --graph-only. Nav hrefs are computed from the ACTUAL output
|
|
870
|
+
// paths (filenames vary: seon-graph.html locally, seonix-graph.html on site);
|
|
871
|
+
// --site-nav additionally adds the site's absolute home entry.
|
|
872
|
+
const browserOut = values["graph-only"] ? null : resolve(values["browser-out"] || join(dirname(out), "code-browser.html"));
|
|
873
|
+
const timelineOut = values["graph-only"] ? null : resolve(values["timeline-out"] || join(dirname(out), "timeline.html"));
|
|
874
|
+
const relHref = (from, to) => {
|
|
875
|
+
const r = relative(dirname(from), to);
|
|
876
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
877
|
+
};
|
|
878
|
+
const navFor = (from) => ({
|
|
879
|
+
...(values["site-nav"] ? { home: "/" } : {}),
|
|
880
|
+
graph: relHref(from, out),
|
|
881
|
+
browser: relHref(from, browserOut),
|
|
882
|
+
timeline: relHref(from, timelineOut),
|
|
883
|
+
});
|
|
724
884
|
const data = buildViewerData(subgraph, {
|
|
725
885
|
focusLabel: focus.label,
|
|
726
886
|
repoUrl: values["repo-url"],
|
|
727
887
|
repoRef: values.ref,
|
|
728
888
|
siteNav: values["site-nav"],
|
|
889
|
+
nav: values["graph-only"] ? null : navFor(out),
|
|
729
890
|
});
|
|
730
|
-
const out = resolve(values.out);
|
|
731
891
|
if (values["data-out"]) {
|
|
732
892
|
// site mode: viewer page + sibling data file (data updates don't touch the page).
|
|
733
893
|
// The chat panel's full-graph channel rides the same sibling-file convention,
|
|
@@ -736,55 +896,85 @@ export async function runVizCli(argv) {
|
|
|
736
896
|
const rel = relative(dirname(out), dataOut) || "seonix-graph-data.json";
|
|
737
897
|
const askDataOut = resolve(dirname(out), "seonix-ask-data.json");
|
|
738
898
|
const askRel = relative(dirname(out), askDataOut) || "seonix-ask-data.json";
|
|
899
|
+
// Site build: the wink lemma bundle rides the same sibling-asset convention as
|
|
900
|
+
// the data files — a same-origin ./seonix-nlp.js the page lazy-loads on first ask.
|
|
901
|
+
let nlpPath = null;
|
|
902
|
+
if (nlpBundle) {
|
|
903
|
+
const nlpOut = resolve(dirname(out), "seonix-nlp.js");
|
|
904
|
+
await writeFile(nlpOut, nlpBundle);
|
|
905
|
+
nlpPath = "./seonix-nlp.js";
|
|
906
|
+
}
|
|
739
907
|
await Promise.all([writeFile(dataOut, JSON.stringify(data)), writeFile(askDataOut, JSON.stringify(raw))]);
|
|
740
908
|
await writeFile(out, renderViewerHtml({
|
|
741
909
|
cytoscape, askEngine,
|
|
742
910
|
dataPath: rel === "seonix-graph-data.json" ? null : rel,
|
|
743
911
|
askDataPath: askRel === "seonix-ask-data.json" ? null : askRel,
|
|
912
|
+
nlpPath,
|
|
744
913
|
}));
|
|
745
914
|
process.stderr.write(
|
|
746
915
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
747
|
-
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}
|
|
916
|
+
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}` +
|
|
917
|
+
`${nlpPath ? ` + ${resolve(dirname(out), "seonix-nlp.js")} (wink lemma tier)` : ""}\n`,
|
|
748
918
|
);
|
|
749
919
|
} else {
|
|
750
|
-
// portable mode: one self-contained file, both channels embedded
|
|
751
|
-
|
|
920
|
+
// portable mode: one self-contained file, both channels embedded (+ inline wink
|
|
921
|
+
// bundle when --nlp; otherwise lemma-off, no external fetch, exactly as before)
|
|
922
|
+
await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw, nlpInline: nlpBundle }));
|
|
752
923
|
process.stderr.write(
|
|
753
924
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
754
925
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out}\n`,
|
|
755
926
|
);
|
|
756
927
|
}
|
|
757
928
|
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
929
|
+
// Sibling artifacts (code browser + commit timeline) — best-effort: a missing
|
|
930
|
+
// git history (or any other failure here) warns but never kills the graph
|
|
931
|
+
// artifact already written above.
|
|
932
|
+
if (browserOut || timelineOut) {
|
|
933
|
+
try {
|
|
934
|
+
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
935
|
+
if (browserOut) {
|
|
936
|
+
const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
|
|
937
|
+
const [order, parentsBySha] = await Promise.all([
|
|
938
|
+
gitCommitOrder(process.cwd(), commitIds),
|
|
939
|
+
gitCommitParents(process.cwd()), // P3 ghost-branch merges — static data, no polling here
|
|
940
|
+
]);
|
|
941
|
+
const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
|
|
942
|
+
const browserData = buildBrowserData(tg, {
|
|
943
|
+
repoUrl,
|
|
944
|
+
repoRef: values.ref,
|
|
945
|
+
siteNav: values["site-nav"],
|
|
946
|
+
nav: navFor(browserOut),
|
|
947
|
+
});
|
|
948
|
+
if (values["browser-data-out"]) {
|
|
949
|
+
const bDataOut = resolve(values["browser-data-out"]);
|
|
950
|
+
const rel = relative(dirname(browserOut), bDataOut) || "code-browser-data.json";
|
|
951
|
+
await writeFile(bDataOut, JSON.stringify(browserData));
|
|
952
|
+
await writeFile(browserOut, await renderBrowserHtml({ cytoscape, dataPath: rel === "code-browser-data.json" ? null : rel }));
|
|
953
|
+
process.stderr.write(
|
|
954
|
+
`seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
|
|
955
|
+
`(scope ${values.scope}) → ${browserOut} + ${bDataOut}\n`,
|
|
956
|
+
);
|
|
957
|
+
} else {
|
|
958
|
+
await writeFile(browserOut, await renderBrowserHtml({ cytoscape, embedData: browserData }));
|
|
959
|
+
process.stderr.write(
|
|
960
|
+
`seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
|
|
961
|
+
`(scope ${values.scope}) → ${browserOut}\n`,
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (timelineOut) {
|
|
966
|
+
const commits = extractTimeline(raw);
|
|
967
|
+
await writeFile(timelineOut, renderTimelineHtml({
|
|
968
|
+
commits,
|
|
969
|
+
repoUrl,
|
|
970
|
+
repoRef: values.ref,
|
|
971
|
+
generatedAt: raw.generated_at || "",
|
|
972
|
+
nav: navFor(timelineOut),
|
|
973
|
+
}));
|
|
974
|
+
process.stderr.write(`seonix viz: timeline ${commits.length} commits → ${timelineOut}\n`);
|
|
975
|
+
}
|
|
976
|
+
} catch (err) {
|
|
977
|
+
process.stderr.write(`seonix viz: warning — sibling pages (browser/timeline) failed: ${String(err.message || err)}; the graph artifact is intact\n`);
|
|
788
978
|
}
|
|
789
979
|
}
|
|
790
980
|
}
|