feat: add LLM Wiki status panel
This commit is contained in:
184
api/routes.py
184
api/routes.py
@@ -1631,6 +1631,186 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
|
||||
# ── Insights endpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
_LLM_WIKI_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki"
|
||||
_LLM_WIKI_PAGE_DIRS = ("entities", "concepts", "comparisons", "queries")
|
||||
|
||||
|
||||
def _llm_wiki_active_hermes_home() -> Path:
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
return Path(get_active_hermes_home()).expanduser()
|
||||
except Exception:
|
||||
return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
||||
|
||||
|
||||
def _llm_wiki_env_file_path(hermes_home: Path) -> str | None:
|
||||
env_path = hermes_home / ".env"
|
||||
if not env_path.exists() or not env_path.is_file():
|
||||
return None
|
||||
try:
|
||||
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
if key.strip() != "WIKI_PATH":
|
||||
continue
|
||||
value = value.strip().strip('"').strip("'")
|
||||
return value or None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _llm_wiki_get_config_path_value(config: dict, dotted_key: str) -> str | None:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
if dotted_key in config and config.get(dotted_key):
|
||||
return str(config.get(dotted_key))
|
||||
cur = config
|
||||
for part in dotted_key.split("."):
|
||||
if not isinstance(cur, dict) or part not in cur:
|
||||
return None
|
||||
cur = cur[part]
|
||||
return str(cur) if cur else None
|
||||
|
||||
|
||||
def _llm_wiki_config_path() -> str | None:
|
||||
try:
|
||||
from api.config import get_config as _get_cfg
|
||||
cfg = _get_cfg()
|
||||
except Exception:
|
||||
return None
|
||||
return (
|
||||
_llm_wiki_get_config_path_value(cfg, "skills.config.wiki.path")
|
||||
or _llm_wiki_get_config_path_value(cfg, "wiki.path")
|
||||
)
|
||||
|
||||
|
||||
def _llm_wiki_resolve_path() -> tuple[Path, str, bool]:
|
||||
hermes_home = _llm_wiki_active_hermes_home()
|
||||
raw = os.getenv("WIKI_PATH") or _llm_wiki_env_file_path(hermes_home)
|
||||
source = "WIKI_PATH" if raw else "default"
|
||||
configured = bool(raw)
|
||||
if not raw:
|
||||
raw = _llm_wiki_config_path()
|
||||
if raw:
|
||||
source = "skills.config.wiki.path"
|
||||
configured = True
|
||||
if not raw:
|
||||
raw = "~/wiki"
|
||||
return Path(os.path.expandvars(raw)).expanduser(), source, configured
|
||||
|
||||
|
||||
def _llm_wiki_safe_iso(ts: float | None) -> str | None:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _llm_wiki_count_files(root: Path) -> int:
|
||||
if not root.exists() or not root.is_dir():
|
||||
return 0
|
||||
count = 0
|
||||
for item in root.rglob("*"):
|
||||
try:
|
||||
if item.is_file() and not any(part.startswith(".") for part in item.relative_to(root).parts):
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
return count
|
||||
|
||||
|
||||
def _llm_wiki_page_files(wiki_path: Path) -> list[Path]:
|
||||
pages: list[Path] = []
|
||||
for dirname in _LLM_WIKI_PAGE_DIRS:
|
||||
section = wiki_path / dirname
|
||||
if not section.exists() or not section.is_dir():
|
||||
continue
|
||||
for item in section.rglob("*.md"):
|
||||
try:
|
||||
rel = item.relative_to(section)
|
||||
if item.is_file() and not any(part.startswith(".") for part in rel.parts):
|
||||
pages.append(item)
|
||||
except Exception:
|
||||
continue
|
||||
return pages
|
||||
|
||||
|
||||
def _build_llm_wiki_status() -> dict:
|
||||
"""Return private-safe LLM Wiki status metadata without reading page bodies."""
|
||||
try:
|
||||
wiki_path, path_source, path_configured = _llm_wiki_resolve_path()
|
||||
base = {
|
||||
"available": False,
|
||||
"enabled": False,
|
||||
"status": "missing",
|
||||
"entry_count": 0,
|
||||
"page_count": 0,
|
||||
"raw_source_count": 0,
|
||||
"last_updated": None,
|
||||
"last_writer": None,
|
||||
"path_configured": path_configured,
|
||||
"path_source": path_source,
|
||||
"toggle_available": False,
|
||||
"toggle_reason": "Hermes Agent exposes WIKI_PATH/wiki.path for location, but no stable on/off config flag is currently available.",
|
||||
"docs_url": _LLM_WIKI_DOCS_URL,
|
||||
}
|
||||
if not wiki_path.exists():
|
||||
return base
|
||||
if not wiki_path.is_dir():
|
||||
base["status"] = "not_directory"
|
||||
return base
|
||||
|
||||
page_files = _llm_wiki_page_files(wiki_path)
|
||||
status_files = [p for p in (wiki_path / "SCHEMA.md", wiki_path / "index.md", wiki_path / "log.md") if p.exists() and p.is_file()]
|
||||
status_files.extend(page_files)
|
||||
latest = None
|
||||
for item in status_files:
|
||||
try:
|
||||
mtime = item.stat().st_mtime
|
||||
except Exception:
|
||||
continue
|
||||
latest = mtime if latest is None else max(latest, mtime)
|
||||
|
||||
base.update({
|
||||
"available": True,
|
||||
"enabled": True,
|
||||
"status": "ready" if page_files else "empty",
|
||||
"entry_count": len(page_files),
|
||||
"page_count": len(page_files),
|
||||
"raw_source_count": _llm_wiki_count_files(wiki_path / "raw"),
|
||||
"last_updated": _llm_wiki_safe_iso(latest),
|
||||
})
|
||||
return base
|
||||
except Exception as exc:
|
||||
return {
|
||||
"available": False,
|
||||
"enabled": False,
|
||||
"status": "error",
|
||||
"entry_count": 0,
|
||||
"page_count": 0,
|
||||
"raw_source_count": 0,
|
||||
"last_updated": None,
|
||||
"last_writer": None,
|
||||
"path_configured": False,
|
||||
"path_source": "unknown",
|
||||
"toggle_available": False,
|
||||
"toggle_reason": "Unable to inspect LLM Wiki status safely.",
|
||||
"docs_url": _LLM_WIKI_DOCS_URL,
|
||||
"error": type(exc).__name__,
|
||||
}
|
||||
|
||||
|
||||
def _handle_llm_wiki_status(handler, parsed) -> bool:
|
||||
j(handler, _build_llm_wiki_status())
|
||||
return True
|
||||
|
||||
|
||||
def _handle_insights(handler, parsed) -> bool:
|
||||
"""Return usage analytics from local WebUI session data."""
|
||||
import collections
|
||||
@@ -2143,7 +2323,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
handler.end_headers()
|
||||
return True
|
||||
|
||||
# ── Insights ──
|
||||
# ── Insights / knowledge status ──
|
||||
if parsed.path == "/api/insights":
|
||||
return _handle_insights(handler, parsed)
|
||||
|
||||
@@ -2151,6 +2331,8 @@ def handle_get(handler, parsed) -> bool:
|
||||
from api.kanban_bridge import handle_kanban_get
|
||||
|
||||
return handle_kanban_get(handler, parsed)
|
||||
if parsed.path == "/api/wiki/status":
|
||||
return _handle_llm_wiki_status(handler, parsed)
|
||||
|
||||
if parsed.path == "/health":
|
||||
return _handle_health(handler, parsed)
|
||||
|
||||
BIN
docs/pr-media/1257/llm-wiki-status.png
Normal file
BIN
docs/pr-media/1257/llm-wiki-status.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -716,7 +716,9 @@
|
||||
<div class="main-view-title" data-i18n="insights_title">Usage Analytics</div>
|
||||
</div>
|
||||
<div class="main-view-content" id="insightsContent" style="padding:16px;overflow-y:auto">
|
||||
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
|
||||
<div class="insights-card wiki-status-card" id="llmWikiStatusCard">
|
||||
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainSettings" class="main-view">
|
||||
|
||||
@@ -1995,8 +1995,11 @@ async function loadInsights(animate) {
|
||||
}
|
||||
const period = ($('insightsPeriod') || {}).value || '30';
|
||||
try {
|
||||
const data = await api(`/api/insights?days=${period}`);
|
||||
_renderInsights(data, box);
|
||||
const [data, wikiStatus] = await Promise.all([
|
||||
api(`/api/insights?days=${period}`),
|
||||
api('/api/wiki/status').catch(err => ({status:'error', error: err.message || String(err)})),
|
||||
]);
|
||||
_renderInsights(data, box, wikiStatus);
|
||||
} catch(e) {
|
||||
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
|
||||
} finally {
|
||||
@@ -2007,7 +2010,56 @@ async function loadInsights(animate) {
|
||||
}
|
||||
}
|
||||
|
||||
function _renderInsights(d, box) {
|
||||
function _formatLlmWikiTimestamp(value) {
|
||||
if (!value) return 'Never';
|
||||
try { return new Date(value).toLocaleString(); }
|
||||
catch (_) { return String(value); }
|
||||
}
|
||||
|
||||
function _renderLlmWikiStatus(d) {
|
||||
const status = d || {status:'error'};
|
||||
const isReady = status.available && status.status === 'ready';
|
||||
const isEmpty = status.available && status.status === 'empty';
|
||||
const isError = status.status === 'error';
|
||||
const badgeClass = isReady ? 'ok' : isError ? 'err' : isEmpty ? 'warn' : 'muted';
|
||||
const badgeText = isReady ? 'Available' : isError ? 'Error' : isEmpty ? 'Empty' : 'Unavailable';
|
||||
const docsUrl = status.docs_url || 'https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki';
|
||||
const toggleNote = status.toggle_available
|
||||
? 'Toggle available from configured Hermes Agent setting.'
|
||||
: (status.toggle_reason || 'No stable LLM Wiki on/off config flag was detected, so this panel is read-only.');
|
||||
const statusNote = isReady
|
||||
? 'LLM Wiki is configured and page metadata is visible without exposing wiki content.'
|
||||
: isEmpty
|
||||
? 'LLM Wiki exists but has no entity, concept, comparison, or query pages yet.'
|
||||
: isError
|
||||
? `Unable to inspect LLM Wiki status${status.error ? ': ' + status.error : ''}.`
|
||||
: 'No LLM Wiki directory was found. Set WIKI_PATH or skills.config.wiki.path to enable status visibility.';
|
||||
return `
|
||||
<div class="insights-card wiki-status-card" id="llmWikiStatusCard">
|
||||
<div class="wiki-status-head">
|
||||
<div>
|
||||
<div class="insights-card-title">LLM Wiki</div>
|
||||
<div class="wiki-status-sub">Knowledge-base observability</div>
|
||||
</div>
|
||||
<span class="wiki-status-badge ${badgeClass}">${esc(badgeText)}</span>
|
||||
</div>
|
||||
<div class="wiki-status-note">${esc(statusNote)}</div>
|
||||
<div class="wiki-status-grid">
|
||||
<div><span>Enabled</span><strong>${status.enabled ? 'Yes' : 'No'}</strong></div>
|
||||
<div><span>Entries</span><strong>${Number(status.entry_count || 0).toLocaleString()}</strong></div>
|
||||
<div><span>Pages</span><strong>${Number(status.page_count || 0).toLocaleString()}</strong></div>
|
||||
<div><span>raw/ files</span><strong>${Number(status.raw_source_count || 0).toLocaleString()}</strong></div>
|
||||
<div><span>Last updated</span><strong>${esc(_formatLlmWikiTimestamp(status.last_updated))}</strong></div>
|
||||
<div><span>Last writer</span><strong>${esc(status.last_writer || 'Not available')}</strong></div>
|
||||
</div>
|
||||
<div class="wiki-status-footer">
|
||||
<span>${esc(toggleNote)}</span>
|
||||
<a href="${esc(docsUrl)}" target="_blank" rel="noopener noreferrer">Docs</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderInsights(d, box, wikiStatus) {
|
||||
const fmtNum = n => Number(n || 0).toLocaleString();
|
||||
const fmtCost = c => {
|
||||
const value = Number(c || 0);
|
||||
@@ -2106,6 +2158,7 @@ function _renderInsights(d, box) {
|
||||
</div>`;
|
||||
|
||||
box.innerHTML = `
|
||||
${_renderLlmWikiStatus(wikiStatus)}
|
||||
<div class="insights-grid">
|
||||
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
|
||||
</div>
|
||||
|
||||
@@ -3142,6 +3142,21 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.insights-stat-label{font-size:11px;color:var(--muted);margin-top:4px;}
|
||||
.insights-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}
|
||||
.insights-card{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:16px;}
|
||||
.wiki-status-card{margin-bottom:16px;}
|
||||
.wiki-status-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;}
|
||||
.wiki-status-sub{font-size:11px;color:var(--muted);margin-top:-4px;}
|
||||
.wiki-status-badge{display:inline-flex;align-items:center;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface);}
|
||||
.wiki-status-badge.ok{color:var(--accent-text);background:var(--accent-bg);border-color:var(--accent-bg-strong);}
|
||||
.wiki-status-badge.warn{color:#e8a030;background:rgba(232,160,48,.12);border-color:rgba(232,160,48,.28);}
|
||||
.wiki-status-badge.err{color:var(--error,#e05);background:color-mix(in srgb,var(--error,#e05) 10%,transparent);border-color:color-mix(in srgb,var(--error,#e05) 30%,transparent);}
|
||||
.wiki-status-note{font-size:12px;color:var(--muted);line-height:1.55;margin-bottom:12px;}
|
||||
.wiki-status-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-bottom:12px;}
|
||||
.wiki-status-grid div{display:flex;flex-direction:column;gap:3px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-width:0;}
|
||||
.wiki-status-grid span{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;}
|
||||
.wiki-status-grid strong{font-size:13px;color:var(--text);font-weight:650;overflow-wrap:anywhere;}
|
||||
.wiki-status-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:10px;}
|
||||
.wiki-status-footer a{color:var(--accent);text-decoration:none;font-weight:600;white-space:nowrap;}
|
||||
.wiki-status-footer a:hover{text-decoration:underline;}
|
||||
.insights-card-title{font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;}
|
||||
.insights-table{width:100%;font-size:12px;}
|
||||
.insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;}
|
||||
|
||||
100
tests/test_issue1257_llm_wiki_status.py
Normal file
100
tests/test_issue1257_llm_wiki_status.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _write(path: Path, text: str = "# Synthetic\n") -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_llm_wiki_status_reads_synthetic_fixture_without_exposing_content(tmp_path, monkeypatch):
|
||||
"""The wiki status API should summarize counts/mtime without leaking page text."""
|
||||
import api.routes as routes
|
||||
|
||||
wiki = tmp_path / "wiki"
|
||||
_write(wiki / "SCHEMA.md", "# Schema\n")
|
||||
_write(wiki / "index.md", "# Index\n")
|
||||
_write(wiki / "log.md", "# Log\n## [2026-05-04] update | Secret project name\n- Details stay private\n")
|
||||
_write(
|
||||
wiki / "entities" / "private-agent.md",
|
||||
"---\ntitle: Private Agent\nupdated: 2026-05-04\n---\nSensitive body text must not ship.\n",
|
||||
)
|
||||
_write(wiki / "concepts" / "safe-summary.md", "---\ntitle: Safe Summary\n---\nMore private text\n")
|
||||
_write(wiki / "raw" / "articles" / "source.md", "Raw source body should not count as wiki page\n")
|
||||
|
||||
monkeypatch.setenv("WIKI_PATH", str(wiki))
|
||||
|
||||
status = routes._build_llm_wiki_status()
|
||||
|
||||
assert status["available"] is True
|
||||
assert status["enabled"] is True
|
||||
assert status["entry_count"] == 2
|
||||
assert status["page_count"] == 2
|
||||
assert status["raw_source_count"] == 1
|
||||
assert status["last_updated"] is not None
|
||||
assert status["last_writer"] is None
|
||||
assert status["toggle_available"] is False
|
||||
assert status["docs_url"].endswith("/research-llm-wiki")
|
||||
serialized = repr(status)
|
||||
assert "Sensitive body text" not in serialized
|
||||
assert "Secret project name" not in serialized
|
||||
assert str(wiki) not in serialized
|
||||
|
||||
|
||||
def test_llm_wiki_status_reports_unavailable_when_path_missing(tmp_path, monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
missing = tmp_path / "does-not-exist"
|
||||
monkeypatch.setenv("WIKI_PATH", str(missing))
|
||||
|
||||
status = routes._build_llm_wiki_status()
|
||||
|
||||
assert status["available"] is False
|
||||
assert status["enabled"] is False
|
||||
assert status["entry_count"] == 0
|
||||
assert status["page_count"] == 0
|
||||
assert status["raw_source_count"] == 0
|
||||
assert status["last_updated"] is None
|
||||
assert status["status"] == "missing"
|
||||
|
||||
|
||||
def test_api_wiki_status_route_is_registered(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
wiki = tmp_path / "wiki"
|
||||
_write(wiki / "entities" / "one.md")
|
||||
monkeypatch.setenv("WIKI_PATH", str(wiki))
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_j(handler, payload, status=200, extra_headers=None):
|
||||
captured["status"] = status
|
||||
captured["payload"] = payload
|
||||
|
||||
with patch("api.routes.j", side_effect=fake_j):
|
||||
handled = routes.handle_get(SimpleNamespace(), urlparse("/api/wiki/status"))
|
||||
|
||||
assert handled is True
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"]["entry_count"] == 1
|
||||
|
||||
|
||||
def test_insights_panel_fetches_and_renders_llm_wiki_status_card():
|
||||
panels_src = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
index_src = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
style_src = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "api('/api/wiki/status')" in panels_src
|
||||
assert "function _renderLlmWikiStatus" in panels_src
|
||||
assert "llmWikiStatusCard" in index_src
|
||||
assert "wiki-status-card" in style_src
|
||||
assert "raw/" in panels_src
|
||||
assert "recent_entries" not in panels_src
|
||||
Reference in New Issue
Block a user