Compare commits
10 Commits
master
...
optimierun
| Author | SHA1 | Date | |
|---|---|---|---|
| 680b3869bb | |||
| dab1b84a68 | |||
| c22c7be444 | |||
| 9ec1e0d28f | |||
| f8de7e626b | |||
| 432d758b90 | |||
| 788ee1539d | |||
| 1db483c053 | |||
| 672077a14c | |||
| 0ff6db73ea |
@@ -15,34 +15,35 @@ from pathlib import Path
|
||||
|
||||
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
|
||||
WORKSPACE = Path("/root/.openclaw/workspace")
|
||||
HANDLER = WORKSPACE / "context-buffer" / "handler.py"
|
||||
CURRENT_DIR = WORKSPACE / "context-buffer" / "current"
|
||||
|
||||
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:
|
||||
result = subprocess.run(
|
||||
["python3", str(HANDLER), "search", "--status", "done"],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Handler error: {result.stderr}")
|
||||
topics = json.loads(result.stdout)
|
||||
with open(index_path) as f:
|
||||
idx = json.load(f)
|
||||
topics = []
|
||||
for tid, t in idx.get("topics", {}).items():
|
||||
status = t.get("status", "active")
|
||||
if status in ("done", "completed"):
|
||||
# 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:
|
||||
print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False))
|
||||
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:
|
||||
print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False))
|
||||
return
|
||||
|
||||
@@ -63,7 +63,7 @@ def parse_engram(row: sqlite3.Row) -> dict:
|
||||
verdict = "confirmed_false"
|
||||
else:
|
||||
verdict = "unknown"
|
||||
return {
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"content": row["content"],
|
||||
"confidence": meta.get("confidence", 0.0),
|
||||
@@ -81,6 +81,12 @@ def parse_engram(row: sqlite3.Row) -> dict:
|
||||
"access_count": meta.get("access_count", 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:
|
||||
@@ -902,6 +908,31 @@ def api_refresh(engram_id: str):
|
||||
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")
|
||||
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]}"
|
||||
|
||||
@@ -14,6 +14,7 @@ body {
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
background: #141419;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */
|
||||
@@ -75,10 +76,22 @@ body {
|
||||
/* ─── Search ──────────────────────────────────────────────────────────────── */
|
||||
.search-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #141419;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
}
|
||||
.search-row:first-child {
|
||||
width: 100%;
|
||||
}
|
||||
.search-row:last-child {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* tab buttons styled via .tabs-bar */
|
||||
|
||||
@@ -196,7 +209,9 @@ body {
|
||||
.legend-dot.match{ background:#f7d154; }
|
||||
.graph-hint{ padding: 4px 12px 10px; }
|
||||
#searchInput {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
@@ -207,6 +222,8 @@ body {
|
||||
}
|
||||
#searchInput:focus { border-color: #6c8af5; }
|
||||
#filterSelect {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
@@ -216,6 +233,32 @@ body {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#exportFormat {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
color: #e8e8ee;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1e1e28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: #cfd3ff;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-export:active { transform: scale(0.98); }
|
||||
|
||||
/* ─── New Engram ──────────────────────────────────────────────────────────── */
|
||||
.new-engram {
|
||||
padding: 0 12px 8px;
|
||||
@@ -266,6 +309,10 @@ body {
|
||||
transition: transform 0.15s ease, border-color 0.2s ease;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.card.selected {
|
||||
border-color: #6c8af5;
|
||||
box-shadow: 0 0 0 1px rgba(108,138,245,0.25) inset;
|
||||
}
|
||||
.card:active { transform: scale(0.985); }
|
||||
.card.confirmed { border-left: 4px solid #3a7d3a; }
|
||||
.card.rejected { border-left: 4px solid #8a3a3a; }
|
||||
@@ -455,3 +502,13 @@ body {
|
||||
@media (pointer: coarse) {
|
||||
button, .card { -webkit-tap-highlight-color: transparent; }
|
||||
}
|
||||
|
||||
/* ─── Small Screens ────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 420px) {
|
||||
html { font-size: 13px; }
|
||||
.stat { min-width: 48px; }
|
||||
.stat-num { font-size: 1.15rem; }
|
||||
.tabs-bar { top: 48px; }
|
||||
.modal { padding: 14px 10px; }
|
||||
.modal-content { padding: 16px 12px; }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ PartOf=openclaw-secondbrain.target
|
||||
|
||||
[Path]
|
||||
PathModified=/root/.openclaw/workspace/memory
|
||||
DirectoryNotEmpty=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>🧠 Second Brain</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
@@ -12,8 +12,10 @@
|
||||
<header class="stats-bar" id="statsBar">
|
||||
<div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statRejected">-</span><span class="stat-label">Rej</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div>
|
||||
<div class="stat"><span class="stat-num" id="statAvgConf">-</span><span class="stat-label">Avg</span></div>
|
||||
</header>
|
||||
|
||||
<div class="tabs-bar">
|
||||
@@ -24,7 +26,10 @@
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<div class="search-row">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<select id="filterSelect">
|
||||
<option value="all">Alle</option>
|
||||
<option value="pending">Pending</option>
|
||||
@@ -32,6 +37,12 @@
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="errors">Errors</option>
|
||||
</select>
|
||||
<select id="exportFormat" title="Export format">
|
||||
<option value="jsonl">JSONL</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
<button class="btn-export" onclick="exportCurrent()">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Engram -->
|
||||
@@ -108,6 +119,7 @@ let state = {
|
||||
autoRefresh: true,
|
||||
view: 'cards',
|
||||
lastEvent: null,
|
||||
selectedId: null,
|
||||
};
|
||||
|
||||
// ─── Fetch ──────────────────────────────────────────────────────────────────
|
||||
@@ -132,8 +144,10 @@ async function loadStats() {
|
||||
const s = await api('/api/stats');
|
||||
document.getElementById('statTotal').textContent = s.total;
|
||||
document.getElementById('statConfirmed').textContent = s.confirmed;
|
||||
document.getElementById('statRejected').textContent = (s.rejected ?? '-');
|
||||
document.getElementById('statPending').textContent = s.pending;
|
||||
document.getElementById('statErrors').textContent = s.errors;
|
||||
document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
|
||||
}
|
||||
|
||||
function updateStatsFromEvent(ev) {
|
||||
@@ -141,8 +155,10 @@ function updateStatsFromEvent(ev) {
|
||||
const s = ev.stats;
|
||||
document.getElementById('statTotal').textContent = s.total;
|
||||
document.getElementById('statConfirmed').textContent = s.confirmed;
|
||||
if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-');
|
||||
document.getElementById('statPending').textContent = s.pending;
|
||||
document.getElementById('statErrors').textContent = s.errors;
|
||||
if (document.getElementById('statAvgConf')) document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
@@ -161,19 +177,74 @@ function setView(view) {
|
||||
}
|
||||
|
||||
async function loadCards() {
|
||||
let url = `/api/engrams?limit=${state.limit}&offset=${state.offset}`;
|
||||
const data = await api(buildEngramsUrl(state.limit, state.offset));
|
||||
state.items = data.items;
|
||||
renderCardsWithSuggestions();
|
||||
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
|
||||
document.getElementById('btnPrev').disabled = state.offset === 0;
|
||||
document.getElementById('btnNext').disabled = data.items.length < state.limit;
|
||||
}
|
||||
|
||||
function buildEngramsUrl(limit, offset) {
|
||||
let url = `/api/engrams?limit=${limit}&offset=${offset}`;
|
||||
if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
|
||||
if (state.filter === 'confirmed') url += '&confirmed=1';
|
||||
if (state.filter === 'pending') url += '&confirmed=0';
|
||||
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
|
||||
if (state.filter === 'errors') url += '&tag=error';
|
||||
return url;
|
||||
}
|
||||
|
||||
const data = await api(url);
|
||||
state.items = data.items;
|
||||
renderCards();
|
||||
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
|
||||
document.getElementById('btnPrev').disabled = state.offset === 0;
|
||||
document.getElementById('btnNext').disabled = data.items.length < state.limit;
|
||||
async function exportCurrent() {
|
||||
const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase();
|
||||
const limit = 100;
|
||||
const max = 5000;
|
||||
let offset = 0;
|
||||
let all = [];
|
||||
while (all.length < max) {
|
||||
const data = await api(buildEngramsUrl(limit, offset));
|
||||
const items = data.items || [];
|
||||
all = all.concat(items);
|
||||
if (items.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
const safe = (s) => String(s ?? '').replace(/[\\r\\n]+/g, ' ').trim();
|
||||
let payload = '';
|
||||
let mime = 'text/plain';
|
||||
if (fmt === 'csv') {
|
||||
mime = 'text/csv';
|
||||
const esc = (v) => '\"' + String(v ?? '').replace(/\"/g, '\"\"') + '\"';
|
||||
payload += ['id','created','source','confidence','verdict','tags','content'].join(',') + '\\n';
|
||||
for (const it of all) {
|
||||
payload += [
|
||||
esc(it.id),
|
||||
esc(it.created),
|
||||
esc(it.source),
|
||||
esc(it.confidence),
|
||||
esc(it.verdict),
|
||||
esc((it.tags || []).join('|')),
|
||||
esc((it.content || '').replace(/\\r?\\n/g, '\\\\n')),
|
||||
].join(',') + '\\n';
|
||||
}
|
||||
} else {
|
||||
mime = 'application/x-ndjson';
|
||||
payload = all.map(x => JSON.stringify(x)).join('\\n') + (all.length ? '\\n' : '');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
|
||||
const filename = `second-brain_${ymd}_${safe(state.filter || 'all')}_${fmt}.${fmt}`;
|
||||
|
||||
const blob = new Blob([payload], {type: mime});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
@@ -1001,9 +1072,11 @@ function escapeHtml(t) {
|
||||
}
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||
async function confirm(id, ev) {
|
||||
async function confirm(id, ev, ctx = 'card') {
|
||||
ev.stopPropagation();
|
||||
const reason = document.getElementById('reason-'+id).value;
|
||||
const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
|
||||
const reasonEl = document.getElementById(reasonElId);
|
||||
const reason = reasonEl ? reasonEl.value : '';
|
||||
await api(`/api/engrams/${id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
@@ -1014,9 +1087,11 @@ async function confirm(id, ev) {
|
||||
if (state.view === 'status') loadStatus();
|
||||
}
|
||||
|
||||
async function reject(id, ev) {
|
||||
async function reject(id, ev, ctx = 'card') {
|
||||
ev.stopPropagation();
|
||||
const reason = document.getElementById('reason-'+id).value;
|
||||
const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
|
||||
const reasonEl = document.getElementById(reasonElId);
|
||||
const reason = reasonEl ? reasonEl.value : '';
|
||||
await api(`/api/engrams/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
@@ -1052,18 +1127,62 @@ async function createEngram() {
|
||||
async function showDetail(id) {
|
||||
const item = await api(`/api/engrams/${id}`);
|
||||
const body = document.getElementById('modalBody');
|
||||
const links = (item.links || []);
|
||||
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>';
|
||||
body.innerHTML = `
|
||||
<h3>Engramm ${item.id.substring(0,8)}</h3>
|
||||
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p>
|
||||
<p><b>Confirmed:</b> ${item.confirmed ? '✅' : '❌'}</p>
|
||||
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p>
|
||||
<p><b>Content:</b></p>
|
||||
<div class="detail-content">${escapeHtml(item.content)}</div>
|
||||
<p><b>History:</b></p>
|
||||
<h3>Engramm <span class="pill">${item.id.substring(0,8)}</span></h3>
|
||||
<div class="kv-row"><div class="kv-key">verdict</div><div class="kv-val">${renderVerdictPill(item)} <span class="muted small">${Math.round(item.confidence*100)}%</span></div></div>
|
||||
<div class="kv-row"><div class="kv-key">source</div><div class="kv-val">${escapeHtml(item.source || '-')}</div></div>
|
||||
<div class="kv-row"><div class="kv-key">created</div><div class="kv-val">${fmtDate(item.created)}</div></div>
|
||||
<div class="kv-row"><div class="kv-key">modified</div><div class="kv-val">${fmtDate(item.modified)}</div></div>
|
||||
<div class="kv-row"><div class="kv-key">access</div><div class="kv-val">${item.access_count ?? 0} • grounding ${item.grounding ?? 0}</div></div>
|
||||
<div class="kv-row"><div class="kv-key">tags</div><div class="kv-val">${(item.tags || []).map(t => '<span class="tag">'+escapeHtml(t)+'</span>').join(' ') || '-'}</div></div>
|
||||
|
||||
<div style="margin-top:10px"><b>Content</b></div>
|
||||
<div class="detail-content">${escapeHtml(item.content || '')}</div>
|
||||
|
||||
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
|
||||
<div class="panel-title">Vorschläge</div>
|
||||
<div class="suggestions">${suggHtml}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
|
||||
<div class="panel-title">Links</div>
|
||||
<div>
|
||||
${links.length ? links.map(l => `<span class="pill" style="cursor:pointer" onclick="showDetail('${l}')">${l.substring(0,8)}</span>`).join(' ') : '<span class="muted">none</span>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${Array.isArray(item.evidence) && item.evidence.length ? `
|
||||
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
|
||||
<div class="panel-title">Evidence</div>
|
||||
<ul class="history">
|
||||
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)} — ${h.action} (${h.note})</li>`).join('')}
|
||||
${item.evidence.map(e => `<li>${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}</li>`).join('')}
|
||||
</ul>
|
||||
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
|
||||
<div class="panel-title">History</div>
|
||||
<ul class="history">
|
||||
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)} — ${escapeHtml(h.action)} ${h.note ? ('(' + escapeHtml(h.note) + ')') : ''}</li>`).join('') || '<li class=\"muted\">-</li>'}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-footer" style="margin-top:10px">
|
||||
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-modal-${item.id}"/>
|
||||
<div class="actions">
|
||||
<button class="btn-ok" onclick="confirm('${item.id}', event, 'modal')">✅</button>
|
||||
<button class="btn-no" onclick="reject('${item.id}', event, 'modal')">❌</button>
|
||||
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('detailModal').classList.add('open');
|
||||
}
|
||||
@@ -1072,6 +1191,15 @@ function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function selectCard(id) {
|
||||
state.selectedId = id;
|
||||
renderCardsWithSuggestions();
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector(`.card[data-id=\"${id}\"]`);
|
||||
if (el) el.scrollIntoView({block: 'nearest', behavior: 'smooth'});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ─── Pagination ─────────────────────────────────────────────────────────────
|
||||
function nextPage() {
|
||||
state.offset += state.limit;
|
||||
@@ -1114,6 +1242,92 @@ setInterval(() => {
|
||||
// ─── Init ───────────────────────────────────────────────────────────────────
|
||||
loadStats();
|
||||
loadCards();
|
||||
document.getElementById('detailModal').addEventListener('click', (e) => {
|
||||
if (e.target && e.target.id === 'detailModal') closeModal();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
if (e.key === '/' && !(e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT'))) {
|
||||
e.preventDefault();
|
||||
document.getElementById('searchInput')?.focus();
|
||||
}
|
||||
|
||||
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT')) return;
|
||||
if (document.getElementById('detailModal')?.classList.contains('open')) return;
|
||||
|
||||
if (e.key === 'g') setView('graph');
|
||||
if (e.key === 's') setView('status');
|
||||
if (e.key === '1') setView('cards');
|
||||
|
||||
if (state.view !== 'cards') return;
|
||||
if (!state.items || !state.items.length) return;
|
||||
|
||||
const idxOf = (id) => state.items.findIndex(x => x.id === id);
|
||||
let idx = state.selectedId ? idxOf(state.selectedId) : -1;
|
||||
if (idx < 0) idx = 0;
|
||||
|
||||
if (e.key === 'j') {
|
||||
idx = Math.min(state.items.length - 1, idx + 1);
|
||||
selectCard(state.items[idx].id);
|
||||
} else if (e.key === 'k') {
|
||||
idx = Math.max(0, idx - 1);
|
||||
selectCard(state.items[idx].id);
|
||||
} else if (e.key === 'Enter') {
|
||||
showDetail(state.items[idx].id);
|
||||
} else if (e.key === 'c') {
|
||||
confirm(state.items[idx].id, e);
|
||||
} else if (e.key === 'r') {
|
||||
reject(state.items[idx].id, e);
|
||||
}
|
||||
});
|
||||
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.id === state.selectedId ? 'selected' : ''} ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}" onclick="selectCard('${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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user