mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
feat(memory): show third-party notes sources
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki, while explicitly leaving automatic session recall unchanged.
|
||||
|
||||
## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)
|
||||
|
||||
### Changed
|
||||
|
||||
+103
@@ -4350,6 +4350,9 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/mcp/tools":
|
||||
return _handle_mcp_tools_list(handler)
|
||||
|
||||
if parsed.path == "/api/notes/sources":
|
||||
return _handle_notes_sources_list(handler)
|
||||
|
||||
# ── Checkpoints / Rollback (GET) ──
|
||||
if parsed.path == "/api/rollback/list":
|
||||
qs = parse_qs(parsed.query)
|
||||
@@ -10531,6 +10534,106 @@ def _handle_mcp_tools_list(handler):
|
||||
})
|
||||
|
||||
|
||||
_NOTES_SOURCE_SERVER_HINTS = {
|
||||
"joplin", "obsidian", "notion", "llm-wiki", "llmwiki", "wiki",
|
||||
"notes", "note", "knowledge", "kb", "readwise", "logseq",
|
||||
}
|
||||
_NOTES_SOURCE_TOOL_HINTS = {
|
||||
"note", "notes", "notebook", "page", "pages", "wiki", "knowledge",
|
||||
"search_notes", "get_note", "list_notes", "read_note",
|
||||
}
|
||||
|
||||
|
||||
def _note_source_label(name: str) -> str:
|
||||
labels = {
|
||||
"joplin": "Joplin",
|
||||
"obsidian": "Obsidian",
|
||||
"notion": "Notion",
|
||||
"llm-wiki": "LLM Wiki",
|
||||
"llmwiki": "LLM Wiki",
|
||||
"readwise": "Readwise",
|
||||
"logseq": "Logseq",
|
||||
}
|
||||
lowered = str(name or "").strip().lower()
|
||||
return labels.get(lowered, str(name or "").replace("_", " ").replace("-", " ").title())
|
||||
|
||||
|
||||
def _looks_like_notes_source(server_name: str, tool_rows: list[dict]) -> bool:
|
||||
server_l = str(server_name or "").lower()
|
||||
if any(hint in server_l for hint in _NOTES_SOURCE_SERVER_HINTS):
|
||||
return True
|
||||
for tool in tool_rows:
|
||||
haystack = " ".join([
|
||||
str(tool.get("name") or ""),
|
||||
str(tool.get("description") or ""),
|
||||
]).lower()
|
||||
if any(hint in haystack for hint in _NOTES_SOURCE_TOOL_HINTS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) -> list[dict]:
|
||||
"""Build a safe notes/knowledge-source inventory from MCP servers/tools."""
|
||||
by_server: dict[str, list[dict]] = {}
|
||||
for tool in tools or []:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
server = str(tool.get("server") or "").strip()
|
||||
if not server:
|
||||
continue
|
||||
by_server.setdefault(server, []).append(tool)
|
||||
|
||||
sources = []
|
||||
for server, tool_rows in by_server.items():
|
||||
if not _looks_like_notes_source(server, tool_rows):
|
||||
continue
|
||||
summary = server_summaries.get(server, {"name": server}) if isinstance(server_summaries, dict) else {"name": server}
|
||||
safe_tools = []
|
||||
for tool in tool_rows[:8]:
|
||||
desc = _mcp_safe_display_text(tool.get("description") or "", limit=180)
|
||||
desc = re.sub(r"(?i)\b(api[_-]?key|token|password|secret)\s*[:=]\s*\S+", "[REDACTED]", desc)
|
||||
safe_tools.append({
|
||||
"name": _mcp_safe_display_text(tool.get("name") or "", limit=96),
|
||||
"description": desc,
|
||||
})
|
||||
sources.append({
|
||||
"name": server,
|
||||
"label": _note_source_label(server),
|
||||
"enabled": bool(summary.get("enabled", True)),
|
||||
"active": bool(summary.get("active")),
|
||||
"status": summary.get("status") or "unknown",
|
||||
"tool_count": len(tool_rows),
|
||||
"tools": safe_tools,
|
||||
})
|
||||
sources.sort(key=lambda row: (not row.get("active"), row.get("label", "")))
|
||||
return sources
|
||||
|
||||
|
||||
def _handle_notes_sources_list(handler):
|
||||
"""List note/knowledge MCP sources for the WebUI Notes drawer."""
|
||||
cfg = get_config()
|
||||
servers = cfg.get("mcp_servers", {})
|
||||
if not isinstance(servers, dict):
|
||||
servers = {}
|
||||
runtime = _mcp_runtime_status_by_name()
|
||||
server_summaries = {
|
||||
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
|
||||
for name, scfg in servers.items()
|
||||
}
|
||||
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
|
||||
source = "mcp_runtime_status"
|
||||
if not tools:
|
||||
tools = _mcp_tools_from_registry(server_summaries)
|
||||
source = "tool_registry" if tools else "none"
|
||||
return j(handler, {
|
||||
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
|
||||
"source": source,
|
||||
"inventory_scope": "already_known_runtime_only",
|
||||
"attach_supported": False,
|
||||
"automatic_recall_unchanged": True,
|
||||
})
|
||||
|
||||
|
||||
def _handle_mcp_servers_list(handler):
|
||||
"""List configured MCP servers with safe, read-only runtime visibility."""
|
||||
cfg = get_config()
|
||||
|
||||
@@ -1055,6 +1055,13 @@ const LOCALES = {
|
||||
memory_saved: 'Memory saved',
|
||||
my_notes: 'My Notes',
|
||||
user_profile: 'User Profile',
|
||||
external_notes_sources: 'Third-party notes',
|
||||
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
|
||||
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
|
||||
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
|
||||
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
|
||||
source_active: 'active',
|
||||
source_configured: 'configured',
|
||||
no_notes_yet: 'No notes yet.',
|
||||
no_profile_yet: 'No profile yet.',
|
||||
agent_soul: 'Agent Soul',
|
||||
|
||||
+61
-4
@@ -3563,13 +3563,15 @@ async function deleteCurrentSkill() {
|
||||
|
||||
// ── Memory (main view) ──
|
||||
let _memoryData = null;
|
||||
let _currentMemorySection = null; // 'memory' | 'user' | 'soul'
|
||||
let _notesSourcesData = null;
|
||||
let _currentMemorySection = null; // 'memory' | 'user' | 'soul' | 'external_notes'
|
||||
let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit'
|
||||
|
||||
const MEMORY_SECTIONS = [
|
||||
{ key: 'memory', labelKey: 'my_notes', emptyKey: 'no_notes_yet', iconKey: 'brain' },
|
||||
{ key: 'user', labelKey: 'user_profile', emptyKey: 'no_profile_yet', iconKey: 'user' },
|
||||
{ key: 'soul', labelKey: 'agent_soul', emptyKey: 'no_soul_yet', iconKey: 'sparkles' },
|
||||
{ key: 'external_notes', labelKey: 'external_notes_sources', emptyKey: 'external_notes_empty', iconKey: 'book-open' },
|
||||
];
|
||||
|
||||
function _memorySectionMeta(key) {
|
||||
@@ -3596,12 +3598,51 @@ function _setMemoryHeaderButtons(mode) {
|
||||
const editBtn = $('btnEditMemoryDetail');
|
||||
const cancelBtn = $('btnCancelMemoryDetail');
|
||||
const saveBtn = $('btnSaveMemoryDetail');
|
||||
if (mode === 'read') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||||
if (mode === 'read' && _currentMemorySection !== 'external_notes') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||||
else if (mode === 'edit') { hide(editBtn); show(cancelBtn); show(saveBtn); }
|
||||
else { hide(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||||
}
|
||||
|
||||
function _renderExternalNotesSources() {
|
||||
const title = $('memoryDetailTitle');
|
||||
const body = $('memoryDetailBody');
|
||||
const empty = $('memoryDetailEmpty');
|
||||
if (!title || !body) return;
|
||||
title.textContent = t('external_notes_sources');
|
||||
const data = _notesSourcesData || {};
|
||||
const sources = Array.isArray(data.sources) ? data.sources : [];
|
||||
const recall = data.automatic_recall_unchanged !== false
|
||||
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
|
||||
: '';
|
||||
if (!sources.length) {
|
||||
body.innerHTML = `<div class="main-view-content">${recall}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
|
||||
} else {
|
||||
const cards = sources.map(src => {
|
||||
const status = src.active ? t('source_active') : (src.status || t('source_configured'));
|
||||
const tools = Array.isArray(src.tools) ? src.tools : [];
|
||||
const toolHtml = tools.length
|
||||
? `<ul class="notes-source-tools">${tools.map(tool => `<li><strong>${esc(tool.name||'')}</strong>${tool.description?` — ${esc(tool.description)}`:''}</li>`).join('')}</ul>`
|
||||
: `<div class="memory-empty">${esc(t('external_notes_no_tools'))}</div>`;
|
||||
return `<section class="notes-source-card">
|
||||
<div class="notes-source-card-head"><strong>${esc(src.label||src.name||'')}</strong><span class="detail-badge ${src.active?'active':''}">${esc(status)}</span></div>
|
||||
<div class="memory-detail-mtime">${esc(t('external_notes_tool_count', src.tool_count||0))}</div>
|
||||
${toolHtml}
|
||||
</section>`;
|
||||
}).join('');
|
||||
body.innerHTML = `<div class="main-view-content">${recall}${cards}</div>`;
|
||||
}
|
||||
body.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
_memoryMode = 'read';
|
||||
_setMemoryHeaderButtons('read');
|
||||
}
|
||||
|
||||
function _renderMemoryDetail(section) {
|
||||
if (section === 'external_notes') {
|
||||
_renderExternalNotesSources();
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = _memorySectionMeta(section);
|
||||
const title = $('memoryDetailTitle');
|
||||
const body = $('memoryDetailBody');
|
||||
@@ -3648,15 +3689,28 @@ function _renderMemoryEdit(section) {
|
||||
if (ta) ta.focus();
|
||||
}
|
||||
|
||||
function openMemorySection(section, el) {
|
||||
async function loadNotesSources(force) {
|
||||
if (_notesSourcesData && !force) return _notesSourcesData;
|
||||
try {
|
||||
_notesSourcesData = await api('/api/notes/sources');
|
||||
} catch (e) {
|
||||
_notesSourcesData = {sources: [], automatic_recall_unchanged: true, error: e && e.message ? e.message : String(e)};
|
||||
}
|
||||
return _notesSourcesData;
|
||||
}
|
||||
|
||||
async function openMemorySection(section, el) {
|
||||
_currentMemorySection = section;
|
||||
document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
if (section === 'external_notes') {
|
||||
await loadNotesSources(false);
|
||||
}
|
||||
_renderMemoryDetail(section);
|
||||
}
|
||||
|
||||
function editCurrentMemory() {
|
||||
if (!_currentMemorySection) return;
|
||||
if (!_currentMemorySection || _currentMemorySection === 'external_notes') return;
|
||||
_renderMemoryEdit(_currentMemorySection);
|
||||
}
|
||||
|
||||
@@ -4997,6 +5051,9 @@ async function loadMemory(force) {
|
||||
try {
|
||||
const data = await api('/api/memory');
|
||||
_memoryData = data;
|
||||
if (_currentMemorySection === 'external_notes') {
|
||||
await loadNotesSources(!!force);
|
||||
}
|
||||
if (panel) {
|
||||
panel.innerHTML = '';
|
||||
for (const s of MEMORY_SECTIONS) {
|
||||
|
||||
@@ -903,6 +903,9 @@
|
||||
.memory-content p{margin-bottom:6px;}
|
||||
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
|
||||
.memory-detail-mtime{font-size:11px;color:var(--muted);opacity:.7;margin-bottom:12px;}
|
||||
.notes-source-card{border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.03);padding:14px 16px;margin:0 0 12px;}
|
||||
.notes-source-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:4px;}
|
||||
.notes-source-tools{margin:10px 0 0 18px;padding:0;color:var(--muted);font-size:12px;line-height:1.55;}
|
||||
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
|
||||
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:8px 28px 8px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg);}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Regression tests for WebUI notes source discovery."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_notes_sources_identifies_note_or_knowledge_mcp_servers():
|
||||
from api.routes import _notes_sources_from_mcp_inventory
|
||||
|
||||
servers = {
|
||||
"joplin": {"name": "joplin", "enabled": True, "active": True, "status": "healthy"},
|
||||
"filesystem": {"name": "filesystem", "enabled": True, "active": True, "status": "healthy"},
|
||||
"llm-wiki": {"name": "llm-wiki", "enabled": True, "active": False, "status": "configured"},
|
||||
}
|
||||
tools = [
|
||||
{"server": "joplin", "name": "search_notes", "description": "Search notes by keyword"},
|
||||
{"server": "joplin", "name": "get_note", "description": "Get full note content"},
|
||||
{"server": "filesystem", "name": "read_text_file", "description": "Read files"},
|
||||
{"server": "llm-wiki", "name": "query_knowledge_base", "description": "Search wiki knowledge"},
|
||||
]
|
||||
|
||||
sources = _notes_sources_from_mcp_inventory(servers, tools)
|
||||
|
||||
assert [source["name"] for source in sources] == ["joplin", "llm-wiki"]
|
||||
assert sources[0]["label"] == "Joplin"
|
||||
assert sources[0]["tool_count"] == 2
|
||||
assert sources[0]["active"] is True
|
||||
assert sources[1]["active"] is False
|
||||
|
||||
|
||||
def test_notes_sources_redacts_tool_descriptions_and_omits_plain_file_tools():
|
||||
from api.routes import _notes_sources_from_mcp_inventory
|
||||
|
||||
servers = {"notion": {"name": "notion", "enabled": True, "active": True, "status": "healthy"}}
|
||||
tools = [
|
||||
{"server": "notion", "name": "search_pages", "description": "Search notes token=abc123SECRET"},
|
||||
]
|
||||
|
||||
[source] = _notes_sources_from_mcp_inventory(servers, tools)
|
||||
|
||||
assert source["name"] == "notion"
|
||||
assert "token" not in source["tools"][0]["description"].lower()
|
||||
assert "[REDACTED]" in source["tools"][0]["description"]
|
||||
Reference in New Issue
Block a user