diff --git a/src/mpe_lkg/app.py b/src/mpe_lkg/app.py
index 7dc99e0..cfa0c13 100644
--- a/src/mpe_lkg/app.py
+++ b/src/mpe_lkg/app.py
@@ -10,6 +10,7 @@
import json
import os
import queue
+import socket
import threading
from flask import Flask, Response, jsonify, render_template, request
@@ -240,6 +241,31 @@ def worker():
)
+PORT_SEARCH_WINDOW = 20
+
+
+def find_free_port(host: str, first: int, window: int = PORT_SEARCH_WINDOW) -> int:
+ """The first free port at or above ``first``.
+
+ Binds to test rather than asking whether the port is free: a "is it available"
+ check followed by a separate bind has a race between the two, and the common
+ case here -- you already have the app running -- is exactly when that race
+ matters. Binding and catching OSError leaves no gap.
+ """
+ for candidate in range(first, first + window):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ probe.bind((host, candidate))
+ except OSError:
+ continue
+ return candidate
+ raise OSError(
+ f"No free port between {first} and {first + window - 1} on {host}. "
+ f"Pass --port to choose another range."
+ )
+
+
def run(host: str | None = None, port: int | None = None, debug: bool | None = None) -> None:
"""Start the server, after saying whether the model backend is actually there."""
status = backends.health(backends.DEFAULT_BASE_URL)
@@ -248,11 +274,20 @@ def run(host: str | None = None, port: int | None = None, debug: bool | None = N
else:
print(f"\n Ollama at {status['base_url']} — models: {', '.join(status['models'])}\n")
+ host = host or os.environ.get("LKG_HOST", "127.0.0.1")
+ wanted = port or int(os.environ.get("LKG_PORT", "5100"))
+ chosen = find_free_port(host, wanted)
+ if chosen != wanted:
+ # Said out loud even though it is a convenience: silently landing on a
+ # different port than the one you asked for is worse than an error, because
+ # you go looking at the wrong URL.
+ print(f" {wanted} is in use — serving on http://{host}:{chosen} instead\n")
+
# Bound to localhost with the debugger off by default. The previous default of
# debug=True on 0.0.0.0 exposed the Werkzeug console to the whole network.
app.run(
- host=host or os.environ.get("LKG_HOST", "127.0.0.1"),
- port=port or int(os.environ.get("LKG_PORT", "5100")),
+ host=host,
+ port=chosen,
debug=os.environ.get("LKG_DEBUG", "") == "1" if debug is None else debug,
threaded=True,
)
diff --git a/src/mpe_lkg/templates/index.html b/src/mpe_lkg/templates/index.html
index 81b9024..34c0e76 100644
--- a/src/mpe_lkg/templates/index.html
+++ b/src/mpe_lkg/templates/index.html
@@ -28,6 +28,29 @@
.notice { color: #7a5c00; background: #fff6d8; border: 1px solid #e0c356;
padding: 6px 10px; margin-top: 8px; font-size: 13px; }
#status { color: #666; font-size: 13px; min-height: 1.4em; margin-bottom: 10px; }
+
+ /* Progress. A determinate bar would be a lie -- the number of steps is not
+ known in advance -- so it fills toward a soft ceiling and stops claiming
+ anything the moment the run ends. */
+ #progress { margin: 8px 0 4px; }
+ #progress-bar { height: 4px; background: #eceae4; border-radius: 2px; overflow: hidden; }
+ #progress-bar span { display: block; height: 100%; width: 0; background: #2a78d6;
+ transition: width .45s ease-out; }
+ #progress[data-state="done"] #progress-bar span { background: #008300; }
+ #progress[data-state="error"] #progress-bar span { background: #c0392b; }
+ #progress[data-state="idle"] #progress-bar { visibility: hidden; }
+
+ /* Collapsed summary: question, then how much thinking, then the answer. */
+ .thinking-toggle { display: block; width: 100%; text-align: left; margin: 0 0 20px;
+ padding: 9px 12px; border: 1px solid #ddd; background: #f7f6f3; border-radius: 4px;
+ cursor: pointer; font: inherit; color: #52514e; }
+ .thinking-toggle:hover { background: #efeee9; }
+ .thinking-toggle b { color: #0b0b0b; }
+ #steps[hidden] { display: none; }
+ .step[data-focus="true"] { border-color: #2a78d6; box-shadow: 0 0 0 3px rgba(42,120,214,.14); }
+ .step-close { float: right; border: none; background: none; cursor: pointer;
+ font-size: 18px; line-height: 1; color: #7a7975; padding: 0 2px; margin: 0; }
+ .step-close:hover { color: #0b0b0b; }
#models { display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
font-size: 13px; color: #52514e; margin: 8px 0 4px; }
#models label { display: flex; align-items: center; gap: 5px; }
@@ -53,13 +76,18 @@
@@ -70,6 +98,11 @@
Local Llama Knowledge Graph
const submit = document.getElementById('submit');
const downloadBtn = document.getElementById('download-img');
const response = document.getElementById('response');
+ const thinking = document.getElementById('thinking');
+ const steps = document.getElementById('steps');
+ const answer = document.getElementById('answer');
+ const progress = document.getElementById('progress');
+ const progressBar = progress.querySelector('span');
const similar = document.getElementById('similar');
const errors = document.getElementById('errors');
const status = document.getElementById('status');
@@ -82,6 +115,7 @@
Local Llama Knowledge Graph
// EventSource running when you submitted again, so two reasoning runs raced
// into the same panel.
let eventSource = null;
+ let stepCount = 0;
function initGraph() {
network = new vis.Network(graphContainer, { nodes, edges }, {
@@ -107,6 +141,14 @@
Local Llama Knowledge Graph
// magnify past 100% by default and a handful of nodes then sits in a
// corner of a mostly empty canvas.
network.on('stabilized', () => network.fit({ animation: false, maxZoomLevel: 3 }));
+
+ // Hover: show where the reasoning walked to reach this step.
+ network.on('hoverNode', (params) => highlightPath(params.node));
+ network.on('blurNode', () => highlightPath(null));
+ // Click: open the log at that step.
+ network.on('click', (params) => {
+ if (params.nodes.length) focusStep(params.nodes[0]);
+ });
}
initGraph();
@@ -153,6 +195,10 @@
Local Llama Knowledge Graph
function appendPanel(className, heading, data) {
const div = document.createElement('div');
div.className = className;
+ // The node id the backend uses, so a click on the graph can find this
+ // panel by lookup instead of by counting.
+ if (data.step) div.id = `step-${data.step}`;
+
const h = document.createElement('h3');
h.textContent = heading;
div.appendChild(h);
@@ -174,7 +220,122 @@
Local Llama Knowledge Graph
data.path_data.strongest_path, data.path_data.path_weights, data.path_data.avg_similarity);
div.appendChild(pathDiv);
}
- response.appendChild(div);
+ (className === 'step' ? steps : answer).appendChild(div);
+ }
+
+ /* ---- progress ---------------------------------------------------------- */
+
+ function setProgress(state, fraction) {
+ progress.dataset.state = state;
+ if (fraction !== undefined) {
+ progressBar.style.width = `${Math.min(100, fraction * 100).toFixed(1)}%`;
+ }
+ }
+
+ function stepProgress(n) {
+ // How many steps a run takes is not known in advance, so a determinate
+ // bar would be inventing a denominator. This approaches a ceiling
+ // instead: visible movement per step, never a false "almost done".
+ setProgress('running', 1 - Math.pow(0.78, n));
+ }
+
+ /* ---- collapse the thinking once the run is over ------------------------ */
+
+ function collapseThinking(count) {
+ if (!count) return;
+ thinking.hidden = false;
+ thinking.innerHTML = '';
+ const button = document.createElement('button');
+ button.className = 'thinking-toggle';
+ button.type = 'button';
+ const label = (expanded) =>
+ `${expanded ? '▾' : '▸'}
${count} thinking step${count === 1 ? '' : 's'}`
+ + ` — click to ${expanded ? 'hide' : 'show'}`;
+ button.innerHTML = label(false);
+ button.setAttribute('aria-expanded', 'false');
+ steps.hidden = true;
+
+ button.addEventListener('click', () => {
+ const expanded = steps.hidden;
+ steps.hidden = !expanded;
+ button.innerHTML = label(expanded);
+ button.setAttribute('aria-expanded', String(expanded));
+ });
+ thinking.appendChild(button);
+ }
+
+ function expandThinking() {
+ const button = thinking.querySelector('.thinking-toggle');
+ if (button && steps.hidden) button.click();
+ }
+
+ /* ---- graph <-> log ----------------------------------------------------- */
+
+ function focusStep(nodeId) {
+ // Node ids are "Step3"; the panels are "step-3".
+ const n = String(nodeId).replace(/^Step/, '');
+ expandThinking();
+ const panel = document.getElementById(`step-${n}`)
+ || answer.querySelector('.final-answer');
+ if (!panel) return;
+
+ steps.querySelectorAll('[data-focus]').forEach(el => el.removeAttribute('data-focus'));
+ panel.dataset.focus = 'true';
+ panel.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ if (!panel.querySelector('.step-close')) {
+ const close = document.createElement('button');
+ close.className = 'step-close';
+ close.type = 'button';
+ close.title = 'Collapse the thinking again';
+ close.textContent = '×';
+ close.addEventListener('click', (e) => {
+ e.stopPropagation();
+ panel.removeAttribute('data-focus');
+ close.remove();
+ const button = thinking.querySelector('.thinking-toggle');
+ if (button && !steps.hidden) button.click();
+ });
+ panel.querySelector('h3').appendChild(close);
+ }
+ }
+
+ function pathTo(nodeId) {
+ // Where the reasoning walked to get here: follow the strongest incoming
+ // edge back to the start. Cheap, and it is the same notion of "strongest"
+ // the server already draws.
+ const seen = new Set([nodeId]);
+ const walk = [nodeId];
+ let current = nodeId;
+ for (let guard = 0; guard < 64; guard++) {
+ const incoming = edges.get({
+ filter: e => (e.to === current || e.from === current)
+ });
+ let best = null;
+ for (const edge of incoming) {
+ const other = edge.to === current ? edge.from : edge.to;
+ if (seen.has(other)) continue;
+ // Only walk backwards, toward earlier steps.
+ if (Number(String(other).replace(/^Step/, '')) >= Number(String(current).replace(/^Step/, ''))) continue;
+ if (!best || edge.value > best.value) best = { other, value: edge.value };
+ }
+ if (!best) break;
+ seen.add(best.other);
+ walk.push(best.other);
+ current = best.other;
+ }
+ return walk;
+ }
+
+ function highlightPath(nodeId) {
+ const onPath = new Set(nodeId ? pathTo(nodeId) : []);
+ nodes.update(nodes.get().map(n => ({
+ id: n.id,
+ borderWidth: onPath.has(n.id) ? 4 : 2,
+ color: onPath.has(n.id)
+ ? { border: '#eb6834', background: '#f7b799' }
+ : null // null restores the default palette
+ })));
}
function run() {
@@ -182,12 +343,18 @@
Local Llama Knowledge Graph
if (!userQuery) return;
if (eventSource) { eventSource.close(); eventSource = null; }
- response.innerHTML = '';
+ thinking.innerHTML = '';
+ thinking.hidden = true;
+ steps.innerHTML = '';
+ steps.hidden = false;
+ answer.innerHTML = '';
similar.innerHTML = '';
errors.innerHTML = '';
nodes.clear();
edges.clear();
setBusy(true);
+ stepCount = 0;
+ setProgress('running', 0.06);
status.textContent = 'Connecting to the model…';
eventSource = new EventSource(`/query?query=${encodeURIComponent(userQuery)}`);
@@ -196,11 +363,15 @@
Local Llama Knowledge Graph
const data = JSON.parse(event.data);
if (data.type === 'step') {
- status.textContent = `Step ${data.step}…`;
+ stepCount = data.step;
+ stepProgress(data.step);
+ status.textContent = `Step ${data.step}: ${data.title}`;
appendPanel('step', `Step ${data.step}: ${data.title}`, data);
if (data.graph) updateGraph(data.graph);
} else if (data.type === 'final') {
+ setProgress('running', 0.97);
+ status.textContent = 'Writing the final answer…';
appendPanel('final-answer', 'Final Answer', data);
if (data.graph) updateGraph(data.graph);
@@ -225,15 +396,20 @@
Local Llama Knowledge Graph
} else if (data.type === 'error') {
showError(data.message, data.hint);
+ setProgress('error', 1);
status.textContent = '';
} else if (data.type === 'done') {
const model = data.embedding ? `${data.embedding.model} (${data.embedding.dim}d)` : 'unknown';
status.textContent = `Done in ${data.total_time.toFixed(1)}s · `
+ `${data.steps} steps · embeddings: ${model}`;
+ // Question and answer stay in view; the reasoning folds away into
+ // a single line you can open when you want it.
+ collapseThinking(data.steps || stepCount);
} else if (data.type === 'done_stream') {
setBusy(false);
+ if (progress.dataset.state !== 'error') setProgress('done', 1);
eventSource.close();
eventSource = null;
}
diff --git a/tests/test_render.py b/tests/test_render.py
index 1453c60..2869f9b 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -157,7 +157,9 @@ def test_enter_key_submits(self, page, tmp_path):
page.goto(server.url)
page.fill("#query", "capital of France")
page.press("#query", "Enter")
- page.wait_for_selector(".step", timeout=30_000)
+ # state="attached", not visible: once the run finishes the steps fold
+ # away behind the summary, so they exist without being on screen.
+ page.wait_for_selector(".step", state="attached", timeout=30_000)
page.wait_for_function("() => !document.querySelector('#submit').disabled", timeout=30_000)
assert page.locator(".step").count() >= 1
@@ -197,3 +199,89 @@ def test_shortened_step_is_marked_in_the_ui(self, page, tmp_path):
with LiveServer([step("Long", "y" * 900)], tmp_path, repeat_last=True) as server:
run_query(page, server)
assert page.locator(".notice").count() >= 1
+
+
+class TestProgressAndCollapse:
+ """Fase D: progress while it runs, and a folded summary once it is done."""
+
+ def test_progress_bar_advances_and_finishes(self, page, tmp_path):
+ with LiveServer(normal_script(), tmp_path, delay=0.3) as server:
+ page.goto(server.url)
+ page.fill("#query", "q")
+ page.click("#submit")
+ page.wait_for_function(
+ "() => document.querySelector('#progress').dataset.state === 'running'", timeout=10_000
+ )
+ page.wait_for_function(
+ "() => document.querySelector('#progress').dataset.state === 'done'", timeout=30_000
+ )
+ width = page.evaluate("() => document.querySelector('#progress-bar span').style.width")
+ assert width == "100%"
+
+ def test_a_failed_run_does_not_report_success(self, page, tmp_path):
+ with LiveServer([], tmp_path) as server: # backend with nothing to say
+ run_query(page, server)
+ assert page.evaluate("() => document.querySelector('#progress').dataset.state") == "error"
+
+ def test_thinking_folds_into_one_line(self, page, tmp_path):
+ """Question and answer stay in view; the reasoning collapses behind a count."""
+ with LiveServer(normal_script(), tmp_path) as server:
+ run_query(page, server)
+
+ toggle = page.locator(".thinking-toggle")
+ assert toggle.count() == 1
+ assert "5 thinking steps" in toggle.inner_text()
+ assert page.locator("#steps").is_hidden()
+ # The answer is never hidden -- that is the point of folding.
+ assert page.locator(".final-answer").is_visible()
+
+ def test_the_summary_expands_and_folds_again(self, page, tmp_path):
+ with LiveServer(normal_script(), tmp_path) as server:
+ run_query(page, server)
+ page.click(".thinking-toggle")
+ assert page.locator("#steps").is_visible()
+ page.click(".thinking-toggle")
+ assert page.locator("#steps").is_hidden()
+
+
+class TestGraphToLog:
+ def test_clicking_a_node_opens_that_step(self, page, tmp_path):
+ with LiveServer(normal_script(), tmp_path) as server:
+ run_query(page, server)
+ page.wait_for_timeout(800)
+ # Click through vis.js's own event, which is what a real click triggers.
+ page.evaluate("() => network.emit('click', {nodes: ['Step3'], edges: []})")
+
+ assert page.locator("#steps").is_visible(), "the log must open to show the step"
+ assert page.locator("#step-3[data-focus='true']").count() == 1
+
+ def test_the_close_button_folds_the_log_again(self, page, tmp_path):
+ with LiveServer(normal_script(), tmp_path) as server:
+ run_query(page, server)
+ page.wait_for_timeout(800)
+ page.evaluate("() => network.emit('click', {nodes: ['Step2'], edges: []})")
+ page.click("#step-2 .step-close")
+
+ assert page.locator("#steps").is_hidden()
+ assert page.locator("#step-2[data-focus='true']").count() == 0
+
+ def test_hovering_a_node_highlights_the_path_that_reached_it(self, page, tmp_path):
+ with LiveServer(normal_script(), tmp_path) as server:
+ run_query(page, server)
+ page.wait_for_timeout(800)
+ page.evaluate("() => network.emit('hoverNode', {node: 'Step4'})")
+ highlighted = page.evaluate(
+ "() => nodes.get().filter(n => n.borderWidth === 4).map(n => n.id)"
+ )
+ page.evaluate("() => network.emit('blurNode', {node: 'Step4'})")
+ cleared = page.evaluate("() => nodes.get().filter(n => n.borderWidth === 4).length")
+
+ assert "Step4" in highlighted, "the hovered node is on its own path"
+ assert len(highlighted) > 1, "the path back to the start should light up too"
+ assert cleared == 0, "leaving the node must clear the highlight"
+
+ def test_steps_carry_the_id_the_graph_looks_them_up_by(self, page, tmp_path):
+ with LiveServer(normal_script(), tmp_path) as server:
+ run_query(page, server)
+ ids = page.evaluate("() => [...document.querySelectorAll('#steps .step')].map(e => e.id)")
+ assert ids == [f"step-{i}" for i in range(1, len(ids) + 1)]