Add FastAPI dashboard MVP

This commit is contained in:
2026-05-26 21:02:39 +02:00
parent e1640071e4
commit 83b85cb760
8 changed files with 1201 additions and 2 deletions

View File

@@ -8,7 +8,8 @@ An embeddable, offline-first memory system for AI agents with correctness tracki
- **ChromaDB Vector Store** (`src/chroma_store.py`) — Semantic similarity search - **ChromaDB Vector Store** (`src/chroma_store.py`) — Semantic similarity search
- **Neural Confidence Scorer** (`src/neural_scorer.py`) — PyTorch RL net, trains on confirm/reject feedback - **Neural Confidence Scorer** (`src/neural_scorer.py`) — PyTorch RL net, trains on confirm/reject feedback
- **Hybrid Retrieval** (`src/retriever.py`) — Keyword + Semantic + Neural fusion - **Hybrid Retrieval** (`src/retriever.py`) — Keyword + Semantic + Neural fusion
- **Streamlit Dashboard** (`src/app_dashboard.py`) — Search, confirm/reject, neural training UI - **FastAPI Dashboard** (`fastapi_app.py`) — Lightweight web UI (search + confirm/reject) and JSON API
- **Streamlit Dashboard** (`src/app_dashboard.py`) — (optional) richer UI for neural training, etc.
- **Graph Visualization** (`src/graph_view.py`) — Interactive Cytoscape.js graph with confidence colors - **Graph Visualization** (`src/graph_view.py`) — Interactive Cytoscape.js graph with confidence colors
## Architecture ## Architecture
@@ -16,3 +17,19 @@ An embeddable, offline-first memory system for AI agents with correctness tracki
## Obsidian ## Obsidian
Setup and timers: `second-brain/docs/OBSIDIAN.md` Setup and timers: `second-brain/docs/OBSIDIAN.md`
## Quickstart (Dashboard)
Install minimal dashboard deps:
`python3 -m pip install -r requirements-dashboard.txt`
Run:
`SECOND_BRAIN_WORKSPACE="$(pwd)" python3 fastapi_app.py`
Then open: `http://localhost:8501/`
Port is configurable via `SECOND_BRAIN_PORT` (or `PORT`), e.g.:
`SECOND_BRAIN_WORKSPACE="$(pwd)" SECOND_BRAIN_PORT=8502 python3 fastapi_app.py`

86
RUNBOOK.md Normal file
View File

@@ -0,0 +1,86 @@
# Second-Brain 2.0 (Grundversion) — Runbook
This is the operational quick-reference for the shipped systemd timers/services and the optional FastAPI dashboard backend.
Repository root (on host): `/root/.openclaw/workspace`
## Systemd units (cron jobs)
Unit files are shipped in `systemd/` (repo root). Install them into `/etc/systemd/system/` (symlink or copy), then reload:
```bash
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.service /etc/systemd/system/
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.timer /etc/systemd/system/
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-memory-archive.* /etc/systemd/system/
sudo systemctl daemon-reload
```
Enable timers:
```bash
sudo systemctl enable --now openclaw-secondbrain-ingest-memory.timer
sudo systemctl enable --now openclaw-secondbrain-index-vectors.timer
sudo systemctl enable --now openclaw-secondbrain-review.timer
sudo systemctl enable --now openclaw-secondbrain-backup.timer
sudo systemctl enable --now openclaw-secondbrain-heartbeat.timer
sudo systemctl enable --now openclaw-secondbrain-proactive-search.timer
sudo systemctl enable --now openclaw-memory-archive.timer
# Optional (Obsidian coupling)
sudo systemctl enable --now openclaw-secondbrain-ingest-obsidian.timer
sudo systemctl enable --now openclaw-secondbrain-export-obsidian.timer
```
Verify scheduling:
```bash
sudo systemctl list-timers --all | grep -E 'openclaw-(secondbrain|memory-archive)' || true
```
Run a job once:
```bash
sudo systemctl start openclaw-secondbrain-ingest-memory.service
sudo systemctl status openclaw-secondbrain-ingest-memory.service --no-pager
sudo journalctl -u openclaw-secondbrain-ingest-memory.service -n 200 --no-pager
```
Wrapper logs:
```bash
tail -n 200 /root/.openclaw/workspace/cron_wrapper.log
```
## FastAPI dashboard (manual start)
FastAPI entrypoint:
```bash
cd /root/.openclaw/workspace
python3 -m pip install -r second-brain/requirements-dashboard.txt
SECOND_BRAIN_WORKSPACE="/root/.openclaw/workspace/second-brain" python3 second-brain/fastapi_app.py
```
Default port is `8501` (same as Streamlit default). Do not run both on the same port.
Endpoint smoke tests:
```bash
curl -fsS http://127.0.0.1:8501/api/stats
curl -fsS "http://127.0.0.1:8501/api/engrams?limit=1&offset=0"
curl -fsS "http://127.0.0.1:8501/api/search?q=test&limit=1"
```
## DB quick check
```bash
python3 - <<'PY'
import sqlite3
db="/root/.openclaw/workspace/second-brain/data/brain.sqlite"
con=sqlite3.connect(db)
cur=con.cursor()
print(cur.execute("PRAGMA integrity_check").fetchone()[0])
print("engrams:", cur.execute("SELECT COUNT(*) FROM engrams").fetchone()[0])
con.close()
PY
```

105
docs/RELEASE_CHECKLIST.md Normal file
View File

@@ -0,0 +1,105 @@
# Second-Brain 2.0 — Release Checklist (Grundversion + FastAPI)
Scope: OpenClaw workspace at `/root/.openclaw/workspace` with `second-brain/` and the shipped `systemd/` unit files.
This checklist is copy/paste friendly. Run as a user with `sudo` (systemd commands need it).
## 0) Preconditions (one-time per host)
### Repo + Python sanity
```bash
cd /root/.openclaw/workspace
test -d second-brain || { echo "missing: second-brain/"; exit 1; }
python3 --version
```
### Ensure systemd units are installed (copy/symlink)
```bash
ls -la /etc/systemd/system/openclaw-secondbrain-*.timer /etc/systemd/system/openclaw-secondbrain-*.service 2>/dev/null || true
ls -la /etc/systemd/system/openclaw-memory-archive.* 2>/dev/null || true
```
If missing, install them (symlink is fine; copy is fine too):
```bash
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.service /etc/systemd/system/
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-*.timer /etc/systemd/system/
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-memory-archive.* /etc/systemd/system/
sudo ln -sf /root/.openclaw/workspace/systemd/openclaw-secondbrain-dashboard.service /etc/systemd/system/
sudo systemctl daemon-reload
```
### Enable timers
```bash
sudo systemctl enable --now openclaw-secondbrain-backup.timer
sudo systemctl enable --now openclaw-secondbrain-ingest-memory.timer
sudo systemctl enable --now openclaw-secondbrain-index-vectors.timer
sudo systemctl enable --now openclaw-secondbrain-review.timer
sudo systemctl enable --now openclaw-secondbrain-heartbeat.timer
sudo systemctl enable --now openclaw-secondbrain-proactive-search.timer
sudo systemctl enable --now openclaw-memory-archive.timer
```
## 1) Release QA — systemd status + timers
### Verify timers are active and scheduled
```bash
sudo systemctl list-timers --all | grep -E 'openclaw-(secondbrain|memory-archive)' || true
sudo systemctl --failed --no-pager || true
```
### Verify the oneshot services can run successfully (manual trigger)
```bash
sudo systemctl start openclaw-secondbrain-ingest-memory.service
sudo systemctl start openclaw-secondbrain-index-vectors.service
sudo systemctl start openclaw-secondbrain-review.service
sudo systemctl start openclaw-secondbrain-backup.service
sudo systemctl start openclaw-secondbrain-heartbeat.service
sudo systemctl start openclaw-secondbrain-proactive-search.service
sudo systemctl start openclaw-memory-archive.service
```
Logs:
```bash
tail -n 200 /root/.openclaw/workspace/cron_wrapper.log
sudo journalctl -u openclaw-secondbrain-review.service -n 200 --no-pager
```
## 2) Release QA — data + DB invariants
```bash
ls -la /root/.openclaw/workspace/second-brain/data/brain.sqlite
python3 - <<'PY'
import sqlite3
db="/root/.openclaw/workspace/second-brain/data/brain.sqlite"
con=sqlite3.connect(db)
cur=con.cursor()
print("integrity_check:", cur.execute("PRAGMA integrity_check").fetchone()[0])
print("engrams:", cur.execute("SELECT COUNT(*) FROM engrams").fetchone()[0])
con.close()
PY
```
## 3) Release QA — FastAPI (HTTP endpoints + logs)
FastAPI service:
```bash
sudo systemctl enable --now openclaw-secondbrain-dashboard.service
sudo systemctl status openclaw-secondbrain-dashboard.service --no-pager
```
Endpoint checks:
```bash
curl -fsS http://127.0.0.1:8501/healthz && echo
curl -fsS http://127.0.0.1:8501/api/config
curl -fsS http://127.0.0.1:8501/api/stats
curl -fsS "http://127.0.0.1:8501/api/engrams?limit=1&offset=0"
```

418
fastapi_app.py Normal file
View File

@@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""
Second Brain FastAPI Dashboard
Goals:
- "Release-ready" defaults (no hardcoded absolute paths)
- Minimal config via env vars
- Serves the existing static dashboard (templates/dashboard.html + static/)
"""
import json
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, Form, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
# ─── Config ──────────────────────────────────────────────────────────────────
REPO_ROOT = Path(__file__).resolve().parent
WORKSPACE = Path(os.environ.get("SECOND_BRAIN_WORKSPACE", str(REPO_ROOT))).resolve()
DB_PATH = Path(os.environ.get("SECOND_BRAIN_DB_PATH", str(WORKSPACE / "data" / "brain.sqlite"))).resolve()
PORT = int(os.environ.get("SECOND_BRAIN_PORT", os.environ.get("PORT", "8501")))
HOST = os.environ.get("SECOND_BRAIN_HOST", "0.0.0.0")
def create_app() -> FastAPI:
app = FastAPI(title="Second Brain Dashboard")
static_dir = WORKSPACE / "static"
if static_dir.is_dir():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
return app
app = create_app()
# ─── Helpers ─────────────────────────────────────────────────────────────────
def get_db():
if not DB_PATH.exists():
raise FileNotFoundError(f"DB not found: {DB_PATH}")
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def parse_engram(row: sqlite3.Row) -> dict:
meta = json.loads(row["metadata_json"] or "{}")
correctness = json.loads(row["correctness_json"] or "{}")
return {
"id": row["id"],
"content": row["content"],
"confidence": meta.get("confidence", 0.0),
"confirmed": correctness.get("confirmed", False),
"confirmations": correctness.get("confirmations", 0),
"rejections": correctness.get("rejections", 0),
"tags": meta.get("tags", []),
"created": meta.get("created", row["created_at"]),
"modified": meta.get("modified", row["modified_at"]),
"last_reviewed": correctness.get("last_reviewed"),
"review_history": correctness.get("review_history", []),
"source": meta.get("source", "unknown"),
"access_count": meta.get("access_count", 0),
"grounding": meta.get("grounding", 0),
}
# ─── API Endpoints ───────────────────────────────────────────────────────────
@app.get("/healthz", response_class=PlainTextResponse)
def healthz():
return "ok"
@app.get("/api/config")
def api_config():
return {
"workspace": str(WORKSPACE),
"db_path": str(DB_PATH),
}
@app.exception_handler(FileNotFoundError)
def handle_file_not_found(request: Request, exc: FileNotFoundError):
return JSONResponse(
status_code=503,
content={
"error": str(exc),
"hint": "Set SECOND_BRAIN_DB_PATH or SECOND_BRAIN_WORKSPACE to a valid location.",
},
)
@app.exception_handler(sqlite3.Error)
def handle_sqlite_error(request: Request, exc: sqlite3.Error):
return JSONResponse(status_code=500, content={"error": f"sqlite error: {exc}"})
@app.get("/api/stats")
def api_stats():
conn = get_db()
c = conn.cursor()
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]
confirmed = c.execute(
"SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1"
).fetchone()[0]
pending = total - confirmed
errors = c.execute(
"SELECT COUNT(*) FROM engrams WHERE json_extract(metadata_json, '$.tags') LIKE '%error%'"
).fetchone()[0]
avg_conf = c.execute(
"SELECT AVG(json_extract(metadata_json, '$.confidence')) FROM engrams"
).fetchone()[0] or 0.0
conn.close()
return {
"total": total,
"confirmed": confirmed,
"pending": pending,
"errors": errors,
"avg_confidence": round(avg_conf, 2),
}
@app.get("/api/engrams")
def api_engrams(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
tag: str = Query(None),
confirmed: bool = Query(None),
search: str = Query(None),
min_confidence: float = Query(0.0),
):
conn = get_db()
c = conn.cursor()
where_clauses = ["json_extract(metadata_json, '$.confidence') >= ?"]
params = [min_confidence]
if tag:
where_clauses.append("json_extract(metadata_json, '$.tags') LIKE ?")
params.append(f'%"{tag}"%')
if confirmed is not None:
where_clauses.append(
f"json_extract(correctness_json, '$.confirmed') = {int(confirmed)}"
)
if search:
# Use FTS
try:
ids = [
r[0] for r in c.execute(
"SELECT rowid FROM engrams_fts WHERE content MATCH ? LIMIT 200",
(search,)
).fetchall()
]
if ids:
placeholders = ",".join("?" * len(ids))
where_clauses.append(f"id IN ({placeholders})")
params.extend(ids)
else:
# Full-text fallback on content
where_clauses.append("content LIKE ?")
params.append(f"%{search}%")
except Exception:
where_clauses.append("content LIKE ?")
params.append(f"%{search}%")
where_sql = " AND ".join(where_clauses)
rows = c.execute(
f"""
SELECT * FROM engrams
WHERE {where_sql}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
params + [limit, offset],
).fetchall()
result = [parse_engram(r) for r in rows]
conn.close()
return {"items": result, "limit": limit, "offset": offset}
@app.get("/api/engrams/{engram_id}")
def api_engram_detail(engram_id: str):
conn = get_db()
c = conn.cursor()
row = c.execute("SELECT * FROM engrams WHERE id = ?", (engram_id,)).fetchone()
if not row:
conn.close()
return JSONResponse({"error": "Not found"}, status_code=404)
# Links
links = c.execute(
"SELECT to_id FROM engrams_links WHERE from_id = ?", (engram_id,)
).fetchall()
result = parse_engram(row)
result["links"] = [r[0] for r in links]
conn.close()
return result
@app.post("/api/engrams/{engram_id}/confirm")
def api_confirm(engram_id: str, reason: str = Form("")):
conn = get_db()
c = conn.cursor()
row = c.execute(
"SELECT correctness_json FROM engrams WHERE id = ?", (engram_id,)
).fetchone()
if not row:
conn.close()
return JSONResponse({"error": "Not found"}, status_code=404)
correctness = json.loads(row["correctness_json"] or "{}")
correctness["confirmed"] = True
correctness["confirmations"] = correctness.get("confirmations", 0) + 1
correctness["last_reviewed"] = datetime.now(timezone.utc).isoformat()
review_history = correctness.get("review_history", [])
review_history.append({
"by": "web",
"action": "confirm",
"at": datetime.now(timezone.utc).isoformat(),
"note": reason or "confirmed via dashboard",
})
correctness["review_history"] = review_history
c.execute(
"UPDATE engrams SET correctness_json = ?, modified_at = ? WHERE id = ?",
(json.dumps(correctness), datetime.now(timezone.utc).isoformat(), engram_id),
)
conn.commit()
conn.close()
return {"success": True, "engram_id": engram_id}
@app.post("/api/engrams/{engram_id}/reject")
def api_reject(engram_id: str, reason: str = Form("")):
conn = get_db()
c = conn.cursor()
row = c.execute(
"SELECT correctness_json FROM engrams WHERE id = ?", (engram_id,)
).fetchone()
if not row:
conn.close()
return JSONResponse({"error": "Not found"}, status_code=404)
correctness = json.loads(row["correctness_json"] or "{}")
correctness["confirmed"] = False
correctness["rejections"] = correctness.get("rejections", 0) + 1
correctness["last_reviewed"] = datetime.now(timezone.utc).isoformat()
review_history = correctness.get("review_history", [])
review_history.append({
"by": "web",
"action": "reject",
"at": datetime.now(timezone.utc).isoformat(),
"note": reason or "rejected via dashboard",
})
correctness["review_history"] = review_history
c.execute(
"UPDATE engrams SET correctness_json = ?, modified_at = ? WHERE id = ?",
(json.dumps(correctness), datetime.now(timezone.utc).isoformat(), engram_id),
)
conn.commit()
conn.close()
return {"success": True, "engram_id": engram_id}
@app.post("/api/engrams/{engram_id}/refresh")
def api_refresh(engram_id: str):
conn = get_db()
c = conn.cursor()
row = c.execute(
"SELECT metadata_json, correctness_json FROM engrams WHERE id = ?", (engram_id,)
).fetchone()
if not row:
conn.close()
return JSONResponse({"error": "Not found"}, status_code=404)
meta = json.loads(row["metadata_json"] or "{}")
correctness = json.loads(row["correctness_json"] or "{}")
# Simple heuristic: confidence based on confirmations vs rejections
conf = 0.5
conf += 0.1 * correctness.get("confirmations", 0)
conf -= 0.15 * correctness.get("rejections", 0)
conf = max(0.1, min(1.0, conf))
meta["confidence"] = round(conf, 2)
meta["modified"] = datetime.now(timezone.utc).isoformat()
c.execute(
"UPDATE engrams SET metadata_json = ?, modified_at = ? WHERE id = ?",
(json.dumps(meta), datetime.now(timezone.utc).isoformat(), engram_id),
)
conn.commit()
conn.close()
return {"success": True, "new_confidence": round(conf, 2)}
@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]}"
now = datetime.now(timezone.utc).isoformat()
meta = {
"source": source,
"confidence": 0.5,
"created": now,
"modified": now,
"access_count": 0,
"last_accessed": now,
"tags": [t.strip() for t in tags.split(",") if t.strip()] or ["web"],
"session_id": None,
"agent_id": None,
"grounding": 0,
"hash": "",
}
correctness = {
"confirmed": False,
"confirmations": 0,
"rejections": 0,
"last_reviewed": None,
"review_history": [],
}
conn = get_db()
c = conn.cursor()
c.execute(
"""
INSERT INTO engrams (id, content, metadata_json, correctness_json, links_json, hierarchy_json, created_at, modified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(engram_id, content, json.dumps(meta), json.dumps(correctness), "[]", '{"parent": null, "children": [], "depth": 0}', now, now),
)
conn.commit()
conn.close()
return {"success": True, "engram_id": engram_id}
@app.get("/api/pending")
def api_pending(limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)):
conn = get_db()
c = conn.cursor()
rows = c.execute(
"""
SELECT * FROM engrams
WHERE json_extract(correctness_json, '$.confirmed') = 0
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
result = [parse_engram(r) for r in rows]
conn.close()
return {"items": result, "limit": limit, "offset": offset}
@app.get("/api/search")
def api_search(
q: str = Query(..., min_length=1),
min_confidence: float = Query(0.0),
limit: int = Query(20, ge=1, le=100),
):
conn = get_db()
c = conn.cursor()
try:
ids = [
r[0] for r in c.execute(
"SELECT rowid FROM engrams_fts WHERE content MATCH ? LIMIT 200",
(q,)
).fetchall()
]
if ids:
placeholders = ",".join("?" * len(ids))
rows = c.execute(
f"""
SELECT * FROM engrams
WHERE id IN ({placeholders})
AND json_extract(metadata_json, '$.confidence') >= ?
ORDER BY created_at DESC
LIMIT ?
""",
ids + [min_confidence, limit],
).fetchall()
else:
rows = []
except Exception:
rows = c.execute(
"""
SELECT * FROM engrams
WHERE content LIKE ? AND json_extract(metadata_json, '$.confidence') >= ?
ORDER BY created_at DESC
LIMIT ?
""",
(f"%{q}%", min_confidence, limit),
).fetchall()
result = [parse_engram(r) for r in rows]
conn.close()
return {"items": result, "query": q}
# ─── Frontend ────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
with open(WORKSPACE / "templates" / "dashboard.html", "r", encoding="utf-8") as f:
html = f.read()
return HTMLResponse(content=html)
# ─── Main ────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run("fastapi_app:app", host=HOST, port=PORT)

View File

@@ -0,0 +1,4 @@
fastapi>=0.110
uvicorn[standard]>=0.23
jinja2>=3.1
python-multipart>=0.0.9

View File

@@ -66,12 +66,19 @@ class Correctness:
return self.confirmations / total return self.confirmations / total
def to_dict(self) -> dict: def to_dict(self) -> dict:
# Backwards/robustness: older code paths may have appended raw dicts.
review_history: List[dict] = []
for entry in self.review_history:
if isinstance(entry, dict):
review_history.append(entry)
else:
review_history.append(entry.to_dict())
return { return {
"confirmed": self.confirmed, "confirmed": self.confirmed,
"confirmations": self.confirmations, "confirmations": self.confirmations,
"rejections": self.rejections, "rejections": self.rejections,
"last_reviewed": self.last_reviewed, "last_reviewed": self.last_reviewed,
"review_history": [r.to_dict() for r in self.review_history], "review_history": review_history,
} }
@classmethod @classmethod

316
static/style.css Normal file
View File

@@ -0,0 +1,316 @@
/* ─── Reset & Base ────────────────────────────────────────────────────────── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0f0f12;
color: #e8e8ee;
-webkit-font-smoothing: antialiased;
overscroll-behavior-y: contain;
}
.app {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
background: #141419;
}
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */
.stats-bar {
display: flex;
justify-content: space-around;
padding: 10px 8px;
background: linear-gradient(180deg, #1a1a22 0%, #131318 100%);
border-bottom: 1px solid #252530;
position: sticky;
top: 0;
z-index: 50;
}
.stat {
text-align: center;
min-width: 60px;
}
.stat-num {
display: block;
font-size: 1.3rem;
font-weight: 700;
color: #6c8af5;
line-height: 1.2;
}
.stat-label {
display: block;
font-size: 0.65rem;
color: #888899;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
/* ─── Search ──────────────────────────────────────────────────────────────── */
.search-box {
display: flex;
gap: 8px;
padding: 10px 12px;
background: #141419;
}
#searchInput {
flex: 1;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px 14px;
color: #e8e8ee;
font-size: 0.95rem;
outline: none;
}
#searchInput:focus { border-color: #6c8af5; }
#filterSelect {
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px;
color: #e8e8ee;
font-size: 0.85rem;
outline: none;
}
/* ─── New Engram ──────────────────────────────────────────────────────────── */
.new-engram {
padding: 0 12px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.new-engram textarea {
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px;
color: #e8e8ee;
font-size: 0.9rem;
resize: vertical;
min-height: 50px;
}
.new-engram input {
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 8px 10px;
color: #e8e8ee;
font-size: 0.85rem;
}
.new-engram button {
background: #3a7d3a;
border: none;
border-radius: 10px;
padding: 10px;
color: #fff;
font-weight: 600;
font-size: 0.95rem;
}
/* ─── Cards ───────────────────────────────────────────────────────────────── */
.cards {
padding: 4px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
background: #1a1a24;
border: 1px solid #252533;
border-radius: 14px;
overflow: hidden;
transition: transform 0.15s ease, border-color 0.2s ease;
touch-action: manipulation;
}
.card:active { transform: scale(0.985); }
.card.confirmed { border-left: 4px solid #3a7d3a; }
.card.rejected { border-left: 4px solid #8a3a3a; }
.card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 6px;
border-bottom: 1px solid #20202a;
}
.conf-badge {
font-size: 0.75rem;
font-weight: 700;
color: #fff;
padding: 2px 8px;
border-radius: 20px;
min-width: 36px;
text-align: center;
}
.tags {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
background: #2a2a3a;
color: #8a9aff;
font-size: 0.68rem;
padding: 2px 8px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.date {
font-size: 0.65rem;
color: #666677;
white-space: nowrap;
}
.card-body {
padding: 10px 12px;
font-size: 0.9rem;
line-height: 1.45;
color: #ccccdd;
cursor: pointer;
}
.card-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 10px;
border-top: 1px solid #20202a;
}
.reason-input {
flex: 1;
background: #14141a;
border: 1px solid #2a2a3a;
border-radius: 8px;
padding: 6px 10px;
color: #b0b0c0;
font-size: 0.8rem;
}
.actions {
display: flex;
gap: 6px;
}
.actions button {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s;
}
.actions button:active { transform: scale(0.9); }
.btn-ok { background: #2a5a2a; }
.btn-no { background: #5a2a2a; }
.btn-archive { background: #2a2a4a; }
/* ─── Pagination ──────────────────────────────────────────────────────────── */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 14px;
}
.pagination button {
background: #252535;
border: none;
border-radius: 10px;
width: 40px;
height: 40px;
color: #e8e8ee;
font-size: 1.1rem;
}
.pagination button:disabled {
opacity: 0.3;
}
#pageNum {
font-weight: 700;
color: #6c8af5;
}
/* ─── Footer ──────────────────────────────────────────────────────────────── */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px 20px;
color: #555566;
font-size: 0.75rem;
}
.refresh-btn {
background: #252535;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
color: #8888aa;
font-size: 1.2rem;
}
/* ─── Modal ───────────────────────────────────────────────────────────────── */
.modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 100;
overflow-y: auto;
padding: 20px 16px;
}
.modal.open { display: block; }
.modal-content {
background: #1a1a24;
border: 1px solid #333344;
border-radius: 16px;
padding: 20px 16px;
max-width: 480px;
margin: 0 auto;
position: relative;
}
.close-btn {
position: absolute;
top: 10px;
right: 14px;
background: none;
border: none;
color: #8888aa;
font-size: 1.5rem;
cursor: pointer;
}
.history {
list-style: none;
font-size: 0.8rem;
color: #9999aa;
}
.history li {
padding: 4px 0;
border-bottom: 1px solid #20202a;
}
.detail-content {
background: #14141a;
border-radius: 10px;
padding: 12px;
font-size: 0.88rem;
line-height: 1.5;
margin: 8px 0;
max-height: 40vh;
overflow-y: auto;
}
/* ─── Scrollbar ───────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333344; border-radius: 4px; }
/* ─── Touch fix ───────────────────────────────────────────────────────────── */
@media (pointer: coarse) {
button, .card { -webkit-tap-highlight-color: transparent; }
}

246
templates/dashboard.html Normal file
View File

@@ -0,0 +1,246 @@
<!DOCTYPE html>
<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">
<title>🧠 Second Brain</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app">
<!-- Stats Header -->
<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="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>
</header>
<!-- Search -->
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
<select id="filterSelect">
<option value="all">Alle</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="errors">Errors</option>
</select>
</div>
<!-- New Engram -->
<div class="new-engram">
<textarea id="newContent" placeholder="Neues Engramm..."></textarea>
<input type="text" id="newTags" placeholder="Tags (comma sep)" />
<button onclick="createEngram()"> Speichern</button>
</div>
<!-- Cards -->
<div class="cards" id="cards"></div>
<!-- Pagination -->
<div class="pagination" id="pagination">
<button id="btnPrev" onclick="prevPage()"></button>
<span id="pageNum">1</span>
<button id="btnNext" onclick="nextPage()"></button>
</div>
<div class="footer">
<span id="lastUpdate">--:--</span>
<button onclick="manualRefresh()" class="refresh-btn"></button>
</div>
</div>
<!-- Detail Modal -->
<div class="modal" id="detailModal">
<div class="modal-content">
<button class="close-btn" onclick="closeModal()">×</button>
<div id="modalBody"></div>
</div>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let state = {
items: [],
offset: 0,
limit: 10,
filter: 'all',
search: '',
autoRefresh: true,
};
// ─── Fetch ──────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch(path, opts);
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
return r.json();
}
async function loadStats() {
const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
}
async function loadCards() {
let url = `/api/engrams?limit=${state.limit}&offset=${state.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 === 'errors') url += '&tag=error';
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;
}
function renderCards() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => `
<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>
<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="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('');
}
function fmtDate(iso) {
const d = new Date(iso);
return `${d.getDate().toString().padStart(2,'0')}.${(d.getMonth()+1).toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
// ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev) {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
await api(`/api/engrams/${id}/confirm`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
}
async function reject(id, ev) {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
await api(`/api/engrams/${id}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
}
async function refresh(id, ev) {
ev.stopPropagation();
await api(`/api/engrams/${id}/refresh`, {method: 'POST'});
await loadCards();
}
async function createEngram() {
const content = document.getElementById('newContent').value;
const tags = document.getElementById('newTags').value;
if (!content.trim()) return;
await api('/api/engrams', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({content, tags})
});
document.getElementById('newContent').value = '';
document.getElementById('newTags').value = '';
await loadCards(); await loadStats();
}
async function showDetail(id) {
const item = await api(`/api/engrams/${id}`);
const body = document.getElementById('modalBody');
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>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${h.action} (${h.note})</li>`).join('')}
</ul>
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
`;
document.getElementById('detailModal').classList.add('open');
}
function closeModal() {
document.getElementById('detailModal').classList.remove('open');
}
// ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() {
state.offset += state.limit;
loadCards();
}
function prevPage() {
state.offset = Math.max(0, state.offset - state.limit);
loadCards();
}
function manualRefresh() {
loadCards(); loadStats();
}
// ─── Search ─────────────────────────────────────────────────────────────────
document.getElementById('searchInput').addEventListener('input', (e) => {
state.search = e.target.value;
state.offset = 0;
loadCards();
});
document.getElementById('filterSelect').addEventListener('change', (e) => {
state.filter = e.target.value;
state.offset = 0;
loadCards();
});
// ─── Auto Refresh ───────────────────────────────────────────────────────────
setInterval(() => {
if (!state.autoRefresh) return;
loadStats();
loadCards();
const now = new Date();
document.getElementById('lastUpdate').textContent =
`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
}, 5000);
// ─── Init ───────────────────────────────────────────────────────────────────
loadStats();
loadCards();
</script>
</body>
</html>