Compare commits
11 Commits
feature/ev
...
optimierun
| Author | SHA1 | Date | |
|---|---|---|---|
| 680b3869bb | |||
| dab1b84a68 | |||
| c22c7be444 | |||
| 9ec1e0d28f | |||
| f8de7e626b | |||
| 432d758b90 | |||
| 788ee1539d | |||
| 1db483c053 | |||
| 672077a14c | |||
| 0ff6db73ea | |||
| 2024e2850d |
@@ -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]}"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ body {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #141419;
|
background: #141419;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */
|
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */
|
||||||
@@ -75,10 +76,22 @@ body {
|
|||||||
/* ─── Search ──────────────────────────────────────────────────────────────── */
|
/* ─── Search ──────────────────────────────────────────────────────────────── */
|
||||||
.search-box {
|
.search-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: #141419;
|
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 */
|
/* tab buttons styled via .tabs-bar */
|
||||||
|
|
||||||
@@ -196,7 +209,9 @@ body {
|
|||||||
.legend-dot.match{ background:#f7d154; }
|
.legend-dot.match{ background:#f7d154; }
|
||||||
.graph-hint{ padding: 4px 12px 10px; }
|
.graph-hint{ padding: 4px 12px 10px; }
|
||||||
#searchInput {
|
#searchInput {
|
||||||
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: #1e1e28;
|
background: #1e1e28;
|
||||||
border: 1px solid #2a2a3a;
|
border: 1px solid #2a2a3a;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -207,6 +222,8 @@ body {
|
|||||||
}
|
}
|
||||||
#searchInput:focus { border-color: #6c8af5; }
|
#searchInput:focus { border-color: #6c8af5; }
|
||||||
#filterSelect {
|
#filterSelect {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: #1e1e28;
|
background: #1e1e28;
|
||||||
border: 1px solid #2a2a3a;
|
border: 1px solid #2a2a3a;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -216,6 +233,32 @@ body {
|
|||||||
outline: none;
|
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 ──────────────────────────────────────────────────────────── */
|
||||||
.new-engram {
|
.new-engram {
|
||||||
padding: 0 12px 8px;
|
padding: 0 12px 8px;
|
||||||
@@ -266,6 +309,10 @@ body {
|
|||||||
transition: transform 0.15s ease, border-color 0.2s ease;
|
transition: transform 0.15s ease, border-color 0.2s ease;
|
||||||
touch-action: manipulation;
|
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:active { transform: scale(0.985); }
|
||||||
.card.confirmed { border-left: 4px solid #3a7d3a; }
|
.card.confirmed { border-left: 4px solid #3a7d3a; }
|
||||||
.card.rejected { border-left: 4px solid #8a3a3a; }
|
.card.rejected { border-left: 4px solid #8a3a3a; }
|
||||||
@@ -455,3 +502,13 @@ body {
|
|||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
button, .card { -webkit-tap-highlight-color: transparent; }
|
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]
|
[Path]
|
||||||
PathModified=/root/.openclaw/workspace/memory
|
PathModified=/root/.openclaw/workspace/memory
|
||||||
DirectoryNotEmpty=true
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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>
|
<title>🧠 Second Brain</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -12,8 +12,10 @@
|
|||||||
<header class="stats-bar" id="statsBar">
|
<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="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="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="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="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>
|
</header>
|
||||||
|
|
||||||
<div class="tabs-bar">
|
<div class="tabs-bar">
|
||||||
@@ -24,14 +26,23 @@
|
|||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
|
<div class="search-row">
|
||||||
<select id="filterSelect">
|
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
|
||||||
<option value="all">Alle</option>
|
</div>
|
||||||
<option value="pending">Pending</option>
|
<div class="search-row">
|
||||||
<option value="confirmed">Confirmed</option>
|
<select id="filterSelect">
|
||||||
<option value="rejected">Rejected</option>
|
<option value="all">Alle</option>
|
||||||
<option value="errors">Errors</option>
|
<option value="pending">Pending</option>
|
||||||
</select>
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- New Engram -->
|
<!-- New Engram -->
|
||||||
@@ -108,6 +119,7 @@ let state = {
|
|||||||
autoRefresh: true,
|
autoRefresh: true,
|
||||||
view: 'cards',
|
view: 'cards',
|
||||||
lastEvent: null,
|
lastEvent: null,
|
||||||
|
selectedId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Fetch ──────────────────────────────────────────────────────────────────
|
// ─── Fetch ──────────────────────────────────────────────────────────────────
|
||||||
@@ -132,8 +144,10 @@ async function loadStats() {
|
|||||||
const s = await api('/api/stats');
|
const s = await api('/api/stats');
|
||||||
document.getElementById('statTotal').textContent = s.total;
|
document.getElementById('statTotal').textContent = s.total;
|
||||||
document.getElementById('statConfirmed').textContent = s.confirmed;
|
document.getElementById('statConfirmed').textContent = s.confirmed;
|
||||||
|
document.getElementById('statRejected').textContent = (s.rejected ?? '-');
|
||||||
document.getElementById('statPending').textContent = s.pending;
|
document.getElementById('statPending').textContent = s.pending;
|
||||||
document.getElementById('statErrors').textContent = s.errors;
|
document.getElementById('statErrors').textContent = s.errors;
|
||||||
|
document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatsFromEvent(ev) {
|
function updateStatsFromEvent(ev) {
|
||||||
@@ -141,8 +155,10 @@ function updateStatsFromEvent(ev) {
|
|||||||
const s = ev.stats;
|
const s = ev.stats;
|
||||||
document.getElementById('statTotal').textContent = s.total;
|
document.getElementById('statTotal').textContent = s.total;
|
||||||
document.getElementById('statConfirmed').textContent = s.confirmed;
|
document.getElementById('statConfirmed').textContent = s.confirmed;
|
||||||
|
if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-');
|
||||||
document.getElementById('statPending').textContent = s.pending;
|
document.getElementById('statPending').textContent = s.pending;
|
||||||
document.getElementById('statErrors').textContent = s.errors;
|
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) {
|
function setView(view) {
|
||||||
@@ -161,19 +177,74 @@ function setView(view) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCards() {
|
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.search) url += `&search=${encodeURIComponent(state.search)}`;
|
||||||
if (state.filter === 'confirmed') url += '&confirmed=1';
|
if (state.filter === 'confirmed') url += '&confirmed=1';
|
||||||
if (state.filter === 'pending') url += '&confirmed=0';
|
if (state.filter === 'pending') url += '&confirmed=0';
|
||||||
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
|
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
|
||||||
if (state.filter === 'errors') url += '&tag=error';
|
if (state.filter === 'errors') url += '&tag=error';
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await api(url);
|
async function exportCurrent() {
|
||||||
state.items = data.items;
|
const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase();
|
||||||
renderCards();
|
const limit = 100;
|
||||||
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
|
const max = 5000;
|
||||||
document.getElementById('btnPrev').disabled = state.offset === 0;
|
let offset = 0;
|
||||||
document.getElementById('btnNext').disabled = data.items.length < state.limit;
|
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() {
|
async function loadStatus() {
|
||||||
@@ -1001,9 +1072,11 @@ function escapeHtml(t) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||||
async function confirm(id, ev) {
|
async function confirm(id, ev, ctx = 'card') {
|
||||||
ev.stopPropagation();
|
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`, {
|
await api(`/api/engrams/${id}/confirm`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
@@ -1014,9 +1087,11 @@ async function confirm(id, ev) {
|
|||||||
if (state.view === 'status') loadStatus();
|
if (state.view === 'status') loadStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reject(id, ev) {
|
async function reject(id, ev, ctx = 'card') {
|
||||||
ev.stopPropagation();
|
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`, {
|
await api(`/api/engrams/${id}/reject`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
@@ -1052,18 +1127,62 @@ async function createEngram() {
|
|||||||
async function showDetail(id) {
|
async function showDetail(id) {
|
||||||
const item = await api(`/api/engrams/${id}`);
|
const item = await api(`/api/engrams/${id}`);
|
||||||
const body = document.getElementById('modalBody');
|
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 = `
|
body.innerHTML = `
|
||||||
<h3>Engramm ${item.id.substring(0,8)}</h3>
|
<h3>Engramm <span class="pill">${item.id.substring(0,8)}</span></h3>
|
||||||
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p>
|
<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>
|
||||||
<p><b>Confirmed:</b> ${item.confirmed ? '✅' : '❌'}</p>
|
<div class="kv-row"><div class="kv-key">source</div><div class="kv-val">${escapeHtml(item.source || '-')}</div></div>
|
||||||
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p>
|
<div class="kv-row"><div class="kv-key">created</div><div class="kv-val">${fmtDate(item.created)}</div></div>
|
||||||
<p><b>Content:</b></p>
|
<div class="kv-row"><div class="kv-key">modified</div><div class="kv-val">${fmtDate(item.modified)}</div></div>
|
||||||
<div class="detail-content">${escapeHtml(item.content)}</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>
|
||||||
<p><b>History:</b></p>
|
<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>
|
||||||
<ul class="history">
|
|
||||||
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)} — ${h.action} (${h.note})</li>`).join('')}
|
<div style="margin-top:10px"><b>Content</b></div>
|
||||||
</ul>
|
<div class="detail-content">${escapeHtml(item.content || '')}</div>
|
||||||
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
|
|
||||||
|
<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.evidence.map(e => `<li>${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</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');
|
document.getElementById('detailModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -1072,6 +1191,15 @@ function closeModal() {
|
|||||||
document.getElementById('detailModal').classList.remove('open');
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Pagination ─────────────────────────────────────────────────────────────
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
state.offset += state.limit;
|
state.offset += state.limit;
|
||||||
@@ -1114,6 +1242,92 @@ setInterval(() => {
|
|||||||
// ─── Init ───────────────────────────────────────────────────────────────────
|
// ─── Init ───────────────────────────────────────────────────────────────────
|
||||||
loadStats();
|
loadStats();
|
||||||
loadCards();
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user