feat: add LLM Wiki status panel

This commit is contained in:
Michael Lam
2026-05-04 16:54:08 -07:00
committed by test
parent e23ba59df2
commit 2684d6fa98
6 changed files with 357 additions and 5 deletions

View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;}

View 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