feat(memory): show AI-used Joplin notes
This commit is contained in:
1 parent
54ca6bf2e3
commit
8c08acec5a
5 files changed
+155
-1
No files matched your search
@@ -10706,6 +10706,7 @@ def _handle_notes_sources_list(handler):
|
||||
"inventory_scope": "already_known_runtime_only",
|
||||
"attach_supported": False,
|
||||
"automatic_recall_unchanged": True,
|
||||
"recent_ai_notes": _joplin_recent_ai_notes(limit=6),
|
||||
})
|
||||
|
||||
|
||||
@@ -10827,6 +10828,93 @@ def _joplin_get_note(note_id: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_JOPLIN_AI_RECALL_NOTE_PRIORITY = [
|
||||
("CURRENT_CONTEXT_ID", "Current Context"),
|
||||
("OPEN_ISSUES_ID", "Open Issues"),
|
||||
("AGENT_MEMORY_ID", "Agent Memory"),
|
||||
("CONVENTIONS_ID", "Conventions / Preferences"),
|
||||
("INFRA_ID", "Infrastructure"),
|
||||
("SERVICES_ID", "Services"),
|
||||
]
|
||||
|
||||
|
||||
def _joplin_prefill_script_path() -> Path | None:
|
||||
cfg = get_config()
|
||||
path_value = cfg.get("prefill_messages_script") if isinstance(cfg, dict) else None
|
||||
if not path_value:
|
||||
return None
|
||||
try:
|
||||
return Path(str(path_value)).expanduser()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _joplin_recall_note_refs(script_path: Path | None = None) -> list[dict]:
|
||||
"""Find stable Joplin note IDs referenced by the configured recall script.
|
||||
|
||||
This keeps the WebUI generic: it does not hard-code a user's note IDs, but
|
||||
can still surface the notes that the configured AI prefill/recall script is
|
||||
known to read for automatic context.
|
||||
"""
|
||||
script_path = script_path or _joplin_prefill_script_path()
|
||||
if not script_path or not script_path.exists() or not script_path.is_file():
|
||||
return []
|
||||
try:
|
||||
text = script_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
constants = {
|
||||
match.group(1): match.group(2)
|
||||
for match in re.finditer(r'(?m)^\s*([A-Z0-9_]+_ID)\s*=\s*["\']([A-Fa-f0-9]{16,64})["\']', text)
|
||||
}
|
||||
refs = []
|
||||
seen = set()
|
||||
for const_name, label in _JOPLIN_AI_RECALL_NOTE_PRIORITY:
|
||||
note_id = constants.get(const_name)
|
||||
if not note_id or note_id in seen:
|
||||
continue
|
||||
seen.add(note_id)
|
||||
refs.append({
|
||||
"id": note_id,
|
||||
"label": label,
|
||||
"constant": const_name,
|
||||
"used_by": "ai_prefill",
|
||||
"used_reason": "automatic_recall",
|
||||
})
|
||||
return refs
|
||||
|
||||
|
||||
def _joplin_recent_ai_notes(*, limit: int = 6) -> list[dict]:
|
||||
"""Return safe Joplin notes that the configured AI recall path recently uses."""
|
||||
try:
|
||||
limit = max(1, min(int(limit or 6), 20))
|
||||
except Exception:
|
||||
limit = 6
|
||||
notes = []
|
||||
for ref in _joplin_recall_note_refs()[:limit]:
|
||||
try:
|
||||
data = _joplin_api_get(f"/notes/{ref['id']}", {
|
||||
"fields": "id,title,parent_id,updated_time,user_updated_time,created_time",
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
note_id = _mcp_safe_display_text(data.get("id") or ref.get("id") or "", limit=64)
|
||||
if not note_id:
|
||||
continue
|
||||
notes.append({
|
||||
"id": note_id,
|
||||
"title": _mcp_safe_display_text(data.get("title") or ref.get("label") or "Untitled", limit=180),
|
||||
"label": _mcp_safe_display_text(ref.get("label") or "", limit=120),
|
||||
"parent_id": _mcp_safe_display_text(data.get("parent_id") or "", limit=64),
|
||||
"updated_time": data.get("user_updated_time") or data.get("updated_time"),
|
||||
"created_time": data.get("created_time"),
|
||||
"source": "joplin",
|
||||
"used_by": ref.get("used_by") or "ai_prefill",
|
||||
"used_reason": ref.get("used_reason") or "automatic_recall",
|
||||
})
|
||||
return notes
|
||||
|
||||
|
||||
def _handle_notes_search(handler, parsed):
|
||||
query = parse_qs(parsed.query or "")
|
||||
source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower()
|
||||
|
||||
@@ -1061,6 +1061,9 @@ const LOCALES = {
|
||||
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`,
|
||||
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
|
||||
external_notes_recent_ai: 'Recently used by AI',
|
||||
external_notes_auto: 'auto',
|
||||
external_notes_recent_ai_reason: 'Automatic recall',
|
||||
external_notes_search_placeholder: 'Search notes…',
|
||||
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
|
||||
source_active: 'active',
|
||||
|
||||
+11
-1
@@ -3625,6 +3625,16 @@ function _renderExternalNotesSources() {
|
||||
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
|
||||
_notesSelectedSource = (selected && selected.name) || 'joplin';
|
||||
const sourceOptions = sources.map(src => `<option value="${esc(src.name||'')}" ${src.name===_notesSelectedSource?'selected':''}>${esc(src.label||src.name||'')}</option>`).join('');
|
||||
const recentAiNotes = Array.isArray(data.recent_ai_notes) ? data.recent_ai_notes : [];
|
||||
const recentAiHtml = recentAiNotes.length
|
||||
? `<section class="notes-source-card notes-ai-recent-card">
|
||||
<div class="notes-source-card-head notes-ai-recent-head"><strong>${li('bot', 14)}${esc(t('external_notes_recent_ai'))}</strong><span class="detail-badge">${esc(t('external_notes_auto'))}</span></div>
|
||||
<div class="notes-ai-recent-list">${recentAiNotes.map(note => {
|
||||
const updated = note.updated_time ? new Date(Number(note.updated_time)).toLocaleString() : '';
|
||||
return `<button type="button" class="notes-result-card notes-ai-recent-item" onclick="previewExternalNote('${esc(note.source||'joplin')}','${esc(note.id||'')}')"><strong>${esc(note.title||note.label||'Untitled')}</strong><span>${li('clock', 14)}${esc(note.label||t('external_notes_recent_ai_reason'))}${updated ? ` · ${esc(updated)}` : ''}</span></button>`;
|
||||
}).join('')}</div>
|
||||
</section>`
|
||||
: '';
|
||||
const searchError = _notesSearchError ? `<div class="detail-form-error">${esc(_notesSearchError)}</div>` : '';
|
||||
const resultHtml = _notesSearchResults.length
|
||||
? `<div class="notes-search-results">${_notesSearchResults.map(note => `<button type="button" class="notes-result-card" onclick="previewExternalNote('${esc(note.source||_notesSelectedSource)}','${esc(note.id||'')}')"><strong>${esc(note.title||'Untitled')}</strong>${note.snippet?`<span>${esc(note.snippet)}</span>`:''}</button>`).join('')}</div>`
|
||||
@@ -3657,7 +3667,7 @@ function _renderExternalNotesSources() {
|
||||
${searchError}
|
||||
${resultHtml}
|
||||
</section>`;
|
||||
body.innerHTML = `<div class="main-view-content">${recall}${searchUi}${previewHtml}${cards}</div>`;
|
||||
body.innerHTML = `<div class="main-view-content">${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
|
||||
}
|
||||
body.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
@@ -911,6 +911,9 @@
|
||||
.notes-search-form select,.notes-search-form input{min-height:34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);padding:6px 10px;}
|
||||
.notes-search-form input{flex:1;min-width:220px;}
|
||||
.notes-search-results{display:grid;gap:8px;}
|
||||
.notes-ai-recent-list{display:grid;gap:8px;margin-top:10px;}
|
||||
.notes-ai-recent-head strong{display:inline-flex;align-items:center;gap:7px;}
|
||||
.notes-ai-recent-item span{display:inline-flex;align-items:center;gap:6px;}
|
||||
.notes-result-card{display:grid;gap:4px;width:100%;text-align:left;border:1px solid var(--border);border-radius:12px;background:var(--panel-soft);color:var(--text);padding:10px 12px;cursor:pointer;}
|
||||
.notes-result-card:hover{border-color:var(--accent);}
|
||||
.notes-result-card span{color:var(--muted);font-size:.9rem;}
|
||||
|
||||
@@ -110,3 +110,53 @@ def test_joplin_get_note_validates_id_and_truncates_body(monkeypatch):
|
||||
assert note["source"] == "joplin"
|
||||
assert len(note["body"]) < 51000
|
||||
assert "Preview truncated" in note["body"]
|
||||
|
||||
|
||||
def test_joplin_recent_ai_notes_uses_configured_prefill_script(monkeypatch, tmp_path):
|
||||
from api import routes
|
||||
|
||||
script = tmp_path / "joplin_context.py"
|
||||
script.write_text(
|
||||
'\n'.join([
|
||||
'CURRENT_CONTEXT_ID = "5ba9ab822c344115939205ca4e8eaec0"',
|
||||
'OPEN_ISSUES_ID = "623aeb6e55cb4aa39a0541f2ac09aa36"',
|
||||
'AGENT_MEMORY_ID = "0a7a232ea46b4b8bb0bbd4358f725a84"',
|
||||
'RAW_CAPTURES_ID = "cb1087795c7d4129a863ab0a5642233d"',
|
||||
]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {"prefill_messages_script": str(script)})
|
||||
|
||||
def fake_get(path, params=None):
|
||||
note_id = path.rsplit("/", 1)[-1]
|
||||
titles = {
|
||||
"5ba9ab822c344115939205ca4e8eaec0": "Current Context",
|
||||
"623aeb6e55cb4aa39a0541f2ac09aa36": "Open Issues",
|
||||
"0a7a232ea46b4b8bb0bbd4358f725a84": "Agent Memory",
|
||||
}
|
||||
assert note_id in titles
|
||||
return {"id": note_id, "title": titles[note_id], "updated_time": 123, "parent_id": "folder"}
|
||||
|
||||
monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
|
||||
|
||||
notes = routes._joplin_recent_ai_notes(limit=3)
|
||||
|
||||
assert [note["title"] for note in notes] == ["Current Context", "Open Issues", "Agent Memory"]
|
||||
assert all(note["source"] == "joplin" for note in notes)
|
||||
assert all(note["used_by"] == "ai_prefill" for note in notes)
|
||||
assert all(note["used_reason"] == "automatic_recall" for note in notes)
|
||||
|
||||
|
||||
def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes():
|
||||
from pathlib import Path
|
||||
|
||||
panels = Path("static/panels.js").read_text(encoding="utf-8")
|
||||
start = panels.index("function _renderExternalNotesSources()")
|
||||
end = panels.index("function _renderMemoryDetail", start)
|
||||
notes_block = panels[start:end]
|
||||
assert "notes-ai-recent-card" in notes_block
|
||||
assert "li('bot', 14)" in notes_block
|
||||
assert "li('clock', 14)" in notes_block
|
||||
assert "Recently used by AI" not in notes_block # i18n key, not hard-coded UI copy
|
||||
assert "🤖" not in notes_block
|
||||
assert "📚" not in notes_block
|
||||
Reference in new issue
Block a user