diff --git a/README.md b/README.md index 4507639..1f5b458 100644 --- a/README.md +++ b/README.md @@ -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 - **Neural Confidence Scorer** (`src/neural_scorer.py`) — PyTorch RL net, trains on confirm/reject feedback - **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 ## Architecture @@ -16,3 +17,19 @@ An embeddable, offline-first memory system for AI agents with correctness tracki ## Obsidian 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` diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..3702bc7 --- /dev/null +++ b/RUNBOOK.md @@ -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 +``` diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..72013c8 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -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" +``` diff --git a/fastapi_app.py b/fastapi_app.py new file mode 100644 index 0000000..79ed4da --- /dev/null +++ b/fastapi_app.py @@ -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) diff --git a/requirements-dashboard.txt b/requirements-dashboard.txt new file mode 100644 index 0000000..932e5ec --- /dev/null +++ b/requirements-dashboard.txt @@ -0,0 +1,4 @@ +fastapi>=0.110 +uvicorn[standard]>=0.23 +jinja2>=3.1 +python-multipart>=0.0.9 diff --git a/src/engram.py b/src/engram.py index 8fe479e..8eabbb8 100644 --- a/src/engram.py +++ b/src/engram.py @@ -66,12 +66,19 @@ class Correctness: return self.confirmations / total 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 { "confirmed": self.confirmed, "confirmations": self.confirmations, "rejections": self.rejections, "last_reviewed": self.last_reviewed, - "review_history": [r.to_dict() for r in self.review_history], + "review_history": review_history, } @classmethod diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b5d0b41 --- /dev/null +++ b/static/style.css @@ -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; } +} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..7bdc892 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,246 @@ + + +
+ + +