feat(dashboard): integrate link suggestions and predictive links into UI
- FastAPI: parse_engram now includes link_suggestions and predictive_links from metadata - FastAPI: add POST /api/links/accept to create links from suggestions - Dashboard: new renderCardsWithSuggestions() displays suggestions in each card - Dashboard: acceptLink() function handles click-to-link - Dashboard: loadCards() calls renderCardsWithSuggestions() - Systemd: remove DirectoryNotEmpty from ingest path unit (already present) Refs: #25 #27
This commit is contained in:
@@ -15,34 +15,35 @@ from pathlib import Path
|
|||||||
|
|
||||||
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
|
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
|
||||||
WORKSPACE = Path("/root/.openclaw/workspace")
|
WORKSPACE = Path("/root/.openclaw/workspace")
|
||||||
HANDLER = WORKSPACE / "context-buffer" / "handler.py"
|
CURRENT_DIR = WORKSPACE / "context-buffer" / "current"
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
# Hole alle Topics mit status done/completed via handler
|
# Lese context-buffer index.json direkt
|
||||||
|
index_path = WORKSPACE / "context-buffer" / "index.json"
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
with open(index_path) as f:
|
||||||
["python3", str(HANDLER), "search", "--status", "done"],
|
idx = json.load(f)
|
||||||
capture_output=True, text=True, timeout=30
|
topics = []
|
||||||
)
|
for tid, t in idx.get("topics", {}).items():
|
||||||
if result.returncode != 0:
|
status = t.get("status", "active")
|
||||||
raise Exception(f"Handler error: {result.stderr}")
|
if status in ("done", "completed"):
|
||||||
topics = json.loads(result.stdout)
|
# Lade den vollen Inhalt aus der topic-Datei
|
||||||
|
topic_file = CURRENT_DIR / f"topic-{tid}.md"
|
||||||
|
if topic_file.exists():
|
||||||
|
content = topic_file.read_text(encoding="utf-8")
|
||||||
|
# Entferne Frontmatter für reinen Content
|
||||||
|
if content.startswith("---"):
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
content = parts[2].strip()
|
||||||
|
t["content"] = content
|
||||||
|
else:
|
||||||
|
t["content"] = ""
|
||||||
|
topics.append(t)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False))
|
print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Alternative: auch 'completed' suchen
|
|
||||||
try:
|
|
||||||
result2 = subprocess.run(
|
|
||||||
["python3", str(HANDLER), "search", "--status", "completed"],
|
|
||||||
capture_output=True, text=True, timeout=30
|
|
||||||
)
|
|
||||||
if result2.returncode == 0:
|
|
||||||
topics_completed = json.loads(result2.stdout)
|
|
||||||
topics.extend(topics_completed)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not topics:
|
if not topics:
|
||||||
print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False))
|
print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def parse_engram(row: sqlite3.Row) -> dict:
|
|||||||
verdict = "confirmed_false"
|
verdict = "confirmed_false"
|
||||||
else:
|
else:
|
||||||
verdict = "unknown"
|
verdict = "unknown"
|
||||||
return {
|
result = {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"content": row["content"],
|
"content": row["content"],
|
||||||
"confidence": meta.get("confidence", 0.0),
|
"confidence": meta.get("confidence", 0.0),
|
||||||
@@ -81,6 +81,12 @@ def parse_engram(row: sqlite3.Row) -> dict:
|
|||||||
"access_count": meta.get("access_count", 0),
|
"access_count": meta.get("access_count", 0),
|
||||||
"grounding": meta.get("grounding", 0),
|
"grounding": meta.get("grounding", 0),
|
||||||
}
|
}
|
||||||
|
# Vorschläge aus metadata
|
||||||
|
if "link_suggestions" in meta:
|
||||||
|
result["link_suggestions"] = meta["link_suggestions"]
|
||||||
|
if "predictive_links" in meta:
|
||||||
|
result["predictive_links"] = meta["predictive_links"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
@@ -902,6 +908,31 @@ def api_refresh(engram_id: str):
|
|||||||
return {"success": True, "new_confidence": round(conf, 2)}
|
return {"success": True, "new_confidence": round(conf, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/links/accept")
|
||||||
|
def api_accept_link(from_id: str = Form(...), to_id: str = Form(...)):
|
||||||
|
"""Erstelle einen Link zwischen zwei Engrammen (aus Vorschlag)."""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
# Prüfe Existenz beider Engramme
|
||||||
|
for eid in (from_id, to_id):
|
||||||
|
if not c.execute("SELECT 1 FROM engrams WHERE id = ?", (eid,)).fetchone():
|
||||||
|
conn.close()
|
||||||
|
return JSONResponse({"error": f"Engram {eid} not found"}, status_code=404)
|
||||||
|
# Vermeide Duplikate
|
||||||
|
c.execute("SELECT 1 FROM engrams_links WHERE from_id = ? AND to_id = ?", (from_id, to_id))
|
||||||
|
if c.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True, "message": "link already exists"}
|
||||||
|
# Link erstellen
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO engrams_links (from_id, to_id) VALUES (?, ?)",
|
||||||
|
(from_id, to_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/engrams")
|
@app.post("/api/engrams")
|
||||||
def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")):
|
def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")):
|
||||||
engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}"
|
engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ PartOf=openclaw-secondbrain.target
|
|||||||
|
|
||||||
[Path]
|
[Path]
|
||||||
PathModified=/root/.openclaw/workspace/memory
|
PathModified=/root/.openclaw/workspace/memory
|
||||||
DirectoryNotEmpty=true
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -170,7 +170,7 @@ async function loadCards() {
|
|||||||
|
|
||||||
const data = await api(url);
|
const data = await api(url);
|
||||||
state.items = data.items;
|
state.items = data.items;
|
||||||
renderCards();
|
renderCardsWithSuggestions();
|
||||||
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
|
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
|
||||||
document.getElementById('btnPrev').disabled = state.offset === 0;
|
document.getElementById('btnPrev').disabled = state.offset === 0;
|
||||||
document.getElementById('btnNext').disabled = data.items.length < state.limit;
|
document.getElementById('btnNext').disabled = data.items.length < state.limit;
|
||||||
@@ -1114,6 +1114,53 @@ setInterval(() => {
|
|||||||
// ─── Init ───────────────────────────────────────────────────────────────────
|
// ─── Init ───────────────────────────────────────────────────────────────────
|
||||||
loadStats();
|
loadStats();
|
||||||
loadCards();
|
loadCards();
|
||||||
|
function renderCardsWithSuggestions() {
|
||||||
|
const el = document.getElementById('cards');
|
||||||
|
el.innerHTML = state.items.map(item => {
|
||||||
|
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
|
||||||
|
const suggHtml = suggestions.length ? suggestions.map(s => `
|
||||||
|
<div class="suggestion">
|
||||||
|
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
|
||||||
|
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
|
||||||
|
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
|
||||||
|
</div>
|
||||||
|
`).join('') : '<span class="muted">Keine Vorschläge</span>';
|
||||||
|
return `
|
||||||
|
<div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
|
||||||
|
${renderVerdictPill(item)}
|
||||||
|
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
|
||||||
|
<span class="date">${fmtDate(item.created)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" onclick="showDetail('${item.id}')">
|
||||||
|
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
|
||||||
|
</div>
|
||||||
|
<div class="suggestions">
|
||||||
|
<strong>Vorschläge:</strong> ${suggHtml}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
|
||||||
|
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
|
||||||
|
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptLink(fromId, toId, ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
await api('/api/links/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({from_id: fromId, to_id: toId})
|
||||||
|
});
|
||||||
|
alert('Link erstellt');
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user