Upload folder using huggingface_hub
Browse files- backend/config.py +12 -11
- backend/prompt_builder.py +64 -16
- backend/server.py +299 -158
- backend/tts/debate_sequencer.py +72 -26
- frontend/dist/assets/api.js +20 -13
- frontend/dist/assets/boot.js +315 -122
- frontend/dist/assets/canvas.js +444 -339
- frontend/dist/assets/council.js +295 -84
- frontend/dist/assets/elysium.css +409 -229
- frontend/dist/assets/nodes.js +133 -107
- frontend/dist/index.html +155 -116
backend/config.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Path detection — HF Spaces /data bucket vs local ./local_data clone.
|
| 2 |
|
| 3 |
-
Environment variables (defaults match the
|
| 4 |
ELYSIUM_MODEL_REPO default: build-small-hackathon/elysium-MiniCPM-V-4.6-F16-GGUF
|
| 5 |
ELYSIUM_GGUF_FILE default: elysium-f16.gguf
|
| 6 |
ELYSIUM_MMPROJ_FILE default: "" (no mmproj file is published yet — vision auto-disables)
|
|
@@ -28,22 +28,23 @@ NODE_POSITIONS = DATA_PATH / "node_positions"
|
|
| 28 |
REMINDERS_DB = DATA_PATH / "reminders.db"
|
| 29 |
CALENDAR_ICS = DATA_PATH / "calendar.ics"
|
| 30 |
OUTBOX = DATA_PATH / "outbox"
|
|
|
|
| 31 |
|
| 32 |
-
for d in (HYPERGRAPH_DB.parent, FOSSILS_DIR, AUDIO_CACHE, NODE_POSITIONS, OUTBOX):
|
| 33 |
d.mkdir(parents=True, exist_ok=True)
|
| 34 |
|
| 35 |
-
# ─── Model configuration ──────────────────────────────
|
| 36 |
-
# These defaults point at the REAL published repository:
|
| 37 |
-
# https://huggingface.co/build-small-hackathon/elysium-MiniCPM-V-4.6-F16-GGUF
|
| 38 |
-
# which currently contains:
|
| 39 |
-
# - elysium-f16.gguf (the main MiniCPM-V 4.6 fine-tuned weights)
|
| 40 |
-
# No mmproj file is published yet, so MMPROJ_FILE defaults to "" and vision
|
| 41 |
-
# is gracefully disabled at load time.
|
| 42 |
MODEL_REPO = os.environ.get("ELYSIUM_MODEL_REPO", "build-small-hackathon/elysium-MiniCPM-V-4.6-F16-GGUF")
|
| 43 |
GGUF_FILE = os.environ.get("ELYSIUM_GGUF_FILE", "elysium-f16.gguf")
|
| 44 |
MMPROJ_FILE = os.environ.get("ELYSIUM_MMPROJ_FILE", "") # empty → no vision
|
| 45 |
|
| 46 |
-
# Optional auth token (read token works for public repos too and avoids rate limits)
|
| 47 |
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
|
| 48 |
-
|
| 49 |
OFFLINE_MODE = os.environ.get("ELYSIUM_OFFLINE", "0") == "1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Path detection — HF Spaces /data bucket vs local ./local_data clone.
|
| 2 |
|
| 3 |
+
Environment variables (defaults match the REAL published repo):
|
| 4 |
ELYSIUM_MODEL_REPO default: build-small-hackathon/elysium-MiniCPM-V-4.6-F16-GGUF
|
| 5 |
ELYSIUM_GGUF_FILE default: elysium-f16.gguf
|
| 6 |
ELYSIUM_MMPROJ_FILE default: "" (no mmproj file is published yet — vision auto-disables)
|
|
|
|
| 28 |
REMINDERS_DB = DATA_PATH / "reminders.db"
|
| 29 |
CALENDAR_ICS = DATA_PATH / "calendar.ics"
|
| 30 |
OUTBOX = DATA_PATH / "outbox"
|
| 31 |
+
UPLOADS_DIR = DATA_PATH / "uploads"
|
| 32 |
|
| 33 |
+
for d in (HYPERGRAPH_DB.parent, FOSSILS_DIR, AUDIO_CACHE, NODE_POSITIONS, OUTBOX, UPLOADS_DIR):
|
| 34 |
d.mkdir(parents=True, exist_ok=True)
|
| 35 |
|
| 36 |
+
# ─── Model configuration (REAL published repo) ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
MODEL_REPO = os.environ.get("ELYSIUM_MODEL_REPO", "build-small-hackathon/elysium-MiniCPM-V-4.6-F16-GGUF")
|
| 38 |
GGUF_FILE = os.environ.get("ELYSIUM_GGUF_FILE", "elysium-f16.gguf")
|
| 39 |
MMPROJ_FILE = os.environ.get("ELYSIUM_MMPROJ_FILE", "") # empty → no vision
|
| 40 |
|
|
|
|
| 41 |
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
|
|
|
|
| 42 |
OFFLINE_MODE = os.environ.get("ELYSIUM_OFFLINE", "0") == "1"
|
| 43 |
+
|
| 44 |
+
# ─── Upload limits ──────────────────────────────────────────────────────────
|
| 45 |
+
MAX_UPLOAD_FILES = 2
|
| 46 |
+
MAX_UPLOAD_BYTES = 12 * 1024 * 1024 # 12 MB per file
|
| 47 |
+
ALLOWED_MIME_TYPES = {
|
| 48 |
+
"image/png", "image/jpeg", "image/webp", "image/gif",
|
| 49 |
+
"application/pdf",
|
| 50 |
+
}
|
backend/prompt_builder.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
"""Build messages for llama-cpp-python chat completion.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
import base64
|
| 8 |
import io
|
| 9 |
import uuid
|
| 10 |
import datetime
|
| 11 |
-
from typing import Optional
|
| 12 |
from PIL import Image
|
| 13 |
|
| 14 |
from .model_loader import MMPROJ_PATH
|
|
@@ -19,12 +19,18 @@ ElysiumResponse schema v1.0.0. No preamble. No markdown fences. JSON only.
|
|
| 19 |
|
| 20 |
Decide complexity dynamically:
|
| 21 |
- SIMPLE_REPLY: trivial Q — no agents (council_deliberation.agent_outputs = [])
|
| 22 |
-
- QUERY / MORNING_BRIEFING / EVENING_REPORT: spawn 1–5 agents in
|
| 23 |
-
each with thinking + stance + tts_speech_text +
|
|
|
|
| 24 |
- TOOL_REQUIRED: populate tool_calls when external data is needed
|
| 25 |
- SPECIATION_EVENT: only on unresolved cross-domain tension
|
| 26 |
- Always populate ui_directives (camera_focus_node_id, pulses, threads)
|
| 27 |
- All node_id and edge_id values must be unique strings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
"""
|
| 29 |
|
| 30 |
|
|
@@ -34,28 +40,70 @@ def _img_to_data_uri(img: Image.Image) -> str:
|
|
| 34 |
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 35 |
|
| 36 |
|
| 37 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
ctx = f"\n\n[Hypergraph context]\n{hg_context}" if hg_context else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
# Vision available → multimodal content list
|
| 41 |
-
if
|
| 42 |
-
user_content = [
|
| 43 |
-
|
| 44 |
-
{
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return [
|
| 47 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 48 |
{"role": "user", "content": user_content},
|
| 49 |
]
|
| 50 |
|
| 51 |
-
# No vision
|
| 52 |
note = ""
|
| 53 |
-
if
|
| 54 |
-
note = "\n\n[Note: user attached
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
return [
|
| 57 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 58 |
-
{"role": "user",
|
|
|
|
| 59 |
]
|
| 60 |
|
| 61 |
|
|
|
|
| 1 |
"""Build messages for llama-cpp-python chat completion.
|
| 2 |
|
| 3 |
+
Supports up to 2 multimodal attachments (images or PDFs). For PDFs we extract
|
| 4 |
+
text inline (since the vision projector handles images only). If vision is
|
| 5 |
+
unavailable we degrade gracefully to text-only.
|
| 6 |
"""
|
| 7 |
import base64
|
| 8 |
import io
|
| 9 |
import uuid
|
| 10 |
import datetime
|
| 11 |
+
from typing import Optional, List, Dict, Any
|
| 12 |
from PIL import Image
|
| 13 |
|
| 14 |
from .model_loader import MMPROJ_PATH
|
|
|
|
| 19 |
|
| 20 |
Decide complexity dynamically:
|
| 21 |
- SIMPLE_REPLY: trivial Q — no agents (council_deliberation.agent_outputs = [])
|
| 22 |
+
- COUNCIL_REPLY / QUERY / MORNING_BRIEFING / EVENING_REPORT: spawn 1–5 agents in
|
| 23 |
+
agent_outputs, each with thinking + stance + tts_speech_text +
|
| 24 |
+
tts_voice_design{voice_id,pace,tone}
|
| 25 |
- TOOL_REQUIRED: populate tool_calls when external data is needed
|
| 26 |
- SPECIATION_EVENT: only on unresolved cross-domain tension
|
| 27 |
- Always populate ui_directives (camera_focus_node_id, pulses, threads)
|
| 28 |
- All node_id and edge_id values must be unique strings
|
| 29 |
+
- Always include 'direct_answer' — a short human-readable answer to surface in toasts.
|
| 30 |
+
|
| 31 |
+
When the user attaches images or PDFs, analyze them, populate
|
| 32 |
+
multimodal_perception fields (ocr_extracted_text, image_scene_description,
|
| 33 |
+
document_type, visual_entities_detected), and reference them in your reasoning.
|
| 34 |
"""
|
| 35 |
|
| 36 |
|
|
|
|
| 40 |
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 41 |
|
| 42 |
|
| 43 |
+
def _pdf_to_text(pdf_bytes: bytes, max_chars: int = 8000) -> str:
|
| 44 |
+
try:
|
| 45 |
+
from PyPDF2 import PdfReader
|
| 46 |
+
rd = PdfReader(io.BytesIO(pdf_bytes))
|
| 47 |
+
out = []
|
| 48 |
+
for page in rd.pages[:12]:
|
| 49 |
+
try:
|
| 50 |
+
out.append(page.extract_text() or "")
|
| 51 |
+
except Exception:
|
| 52 |
+
continue
|
| 53 |
+
txt = "\n".join(out).strip()
|
| 54 |
+
return txt[:max_chars] if txt else "(PDF contained no extractable text)"
|
| 55 |
+
except Exception as e:
|
| 56 |
+
return f"(PDF parse error: {e})"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def build_messages(user_text: str,
|
| 60 |
+
attachments: Optional[List[Dict[str, Any]]] = None,
|
| 61 |
+
hg_context: str = ""):
|
| 62 |
+
"""attachments: list of {'kind': 'image'|'pdf', 'image': PIL.Image | None,
|
| 63 |
+
'bytes': bytes | None, 'name': str}
|
| 64 |
+
"""
|
| 65 |
ctx = f"\n\n[Hypergraph context]\n{hg_context}" if hg_context else ""
|
| 66 |
+
attachments = attachments or []
|
| 67 |
+
|
| 68 |
+
# Gather image and pdf attachments separately
|
| 69 |
+
image_atts = [a for a in attachments if a["kind"] == "image" and a.get("image") is not None]
|
| 70 |
+
pdf_atts = [a for a in attachments if a["kind"] == "pdf" and a.get("bytes")]
|
| 71 |
+
|
| 72 |
+
# Build inline PDF text block
|
| 73 |
+
pdf_block = ""
|
| 74 |
+
for i, p in enumerate(pdf_atts):
|
| 75 |
+
pdf_block += f"\n\n[Attached PDF #{i+1}: {p.get('name','document.pdf')}]\n"
|
| 76 |
+
pdf_block += _pdf_to_text(p["bytes"])
|
| 77 |
|
| 78 |
# Vision available → multimodal content list
|
| 79 |
+
if image_atts and MMPROJ_PATH:
|
| 80 |
+
user_content = []
|
| 81 |
+
for img_att in image_atts[:2]:
|
| 82 |
+
user_content.append({
|
| 83 |
+
"type": "image_url",
|
| 84 |
+
"image_url": {"url": _img_to_data_uri(img_att["image"])},
|
| 85 |
+
})
|
| 86 |
+
user_content.append({
|
| 87 |
+
"type": "text",
|
| 88 |
+
"text": (user_text or "(no text)") + pdf_block + ctx,
|
| 89 |
+
})
|
| 90 |
return [
|
| 91 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 92 |
{"role": "user", "content": user_content},
|
| 93 |
]
|
| 94 |
|
| 95 |
+
# No vision: include note if user attached images but vision is off
|
| 96 |
note = ""
|
| 97 |
+
if image_atts and not MMPROJ_PATH:
|
| 98 |
+
note = (f"\n\n[Note: user attached {len(image_atts)} image(s) but vision "
|
| 99 |
+
"projector is not loaded; describe based on filename + text only.]")
|
| 100 |
+
for img_att in image_atts:
|
| 101 |
+
note += f"\n - image filename: {img_att.get('name','image')}"
|
| 102 |
|
| 103 |
return [
|
| 104 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 105 |
+
{"role": "user",
|
| 106 |
+
"content": (user_text or "(no text)") + pdf_block + note + ctx},
|
| 107 |
]
|
| 108 |
|
| 109 |
|
backend/server.py
CHANGED
|
@@ -1,158 +1,299 @@
|
|
| 1 |
-
"""FastAPI routes attached to gr.Server.
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
from
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
"
|
| 144 |
-
"
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI routes attached to gr.Server.
|
| 2 |
+
|
| 3 |
+
Key changes vs previous build:
|
| 4 |
+
• /api/turn now accepts up to 2 attachments (images or PDFs) via repeated
|
| 5 |
+
'attachments' multipart fields.
|
| 6 |
+
• Hypergraph maintains per-node spawn parent so frontend can draw correct
|
| 7 |
+
mycelium threads.
|
| 8 |
+
• Returns a `metrics` block with civilization-meaningful values
|
| 9 |
+
(mycelium_density, council_activity, knowledge_growth, civilization_age)
|
| 10 |
+
instead of fake compliance/laws numbers.
|
| 11 |
+
• Audio drama segments are returned per-agent so the frontend can sync
|
| 12 |
+
play/pause + speaking highlight to each utterance.
|
| 13 |
+
• Removes the raw-JSON dump from the canvas: the frontend never displays
|
| 14 |
+
raw JSON; it consumes it for nodes / edges / council / TTS only.
|
| 15 |
+
"""
|
| 16 |
+
import io
|
| 17 |
+
import json
|
| 18 |
+
import time
|
| 19 |
+
import traceback
|
| 20 |
+
from typing import List, Optional
|
| 21 |
+
from PIL import Image
|
| 22 |
+
from fastapi import UploadFile, File, Form, HTTPException
|
| 23 |
+
from fastapi.responses import JSONResponse
|
| 24 |
+
from fastapi.staticfiles import StaticFiles
|
| 25 |
+
|
| 26 |
+
import spaces
|
| 27 |
+
|
| 28 |
+
from .config import (AUDIO_CACHE, MAX_UPLOAD_FILES, MAX_UPLOAD_BYTES,
|
| 29 |
+
ALLOWED_MIME_TYPES)
|
| 30 |
+
from .model_loader import make_llm
|
| 31 |
+
from .grammar import load_grammar
|
| 32 |
+
from .prompt_builder import build_messages, new_session_meta
|
| 33 |
+
from .schema import ElysiumEnvelope, ElysiumResponse
|
| 34 |
+
from .hypergraph import persistence
|
| 35 |
+
from .hypergraph.engine import Hypergraph
|
| 36 |
+
from .tools.dispatcher import execute_all
|
| 37 |
+
from .tts.debate_sequencer import build_debate, build_per_agent_audio
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ─── Singletons ─────────────────────────────────────────────────────────────
|
| 41 |
+
HG: Hypergraph = persistence.load()
|
| 42 |
+
GRAMMAR = load_grammar()
|
| 43 |
+
CIVILIZATION_START_TS = time.time()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ─── GPU-bound inference ────────────────────────────────────────────────────
|
| 47 |
+
@spaces.GPU(duration=120)
|
| 48 |
+
def _gpu_infer(messages: list, max_tokens: int = 4096) -> str:
|
| 49 |
+
llm = make_llm()
|
| 50 |
+
out = llm.create_chat_completion(
|
| 51 |
+
messages=messages,
|
| 52 |
+
max_tokens=max_tokens,
|
| 53 |
+
temperature=0.7,
|
| 54 |
+
grammar=GRAMMAR,
|
| 55 |
+
)
|
| 56 |
+
return out["choices"][0]["message"]["content"]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _fallback_envelope(user_text: str, err: str) -> dict:
|
| 60 |
+
meta = new_session_meta()
|
| 61 |
+
resp = ElysiumResponse(
|
| 62 |
+
session_id=meta["session_id"],
|
| 63 |
+
timestamp_utc=meta["timestamp_utc"],
|
| 64 |
+
interaction_type="SIMPLE_REPLY",
|
| 65 |
+
direct_answer=f"(fallback) {err}",
|
| 66 |
+
)
|
| 67 |
+
return {"user_msg": user_text, "elysium_response": resp.model_dump()}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _civilization_metrics(resp: ElysiumResponse) -> dict:
|
| 71 |
+
"""Real, meaningful metrics derived from the hypergraph state."""
|
| 72 |
+
nodes = HG.node_count()
|
| 73 |
+
edges = HG.edge_count()
|
| 74 |
+
|
| 75 |
+
# Mycelium density = edges per node, normalized to 0-100%
|
| 76 |
+
density = 0.0
|
| 77 |
+
if nodes > 0:
|
| 78 |
+
density = min(1.0, edges / max(1, nodes * 1.4))
|
| 79 |
+
|
| 80 |
+
# Council activity = number of active agents (capped at 5)
|
| 81 |
+
council_active = len(resp.council_deliberation.agent_outputs or [])
|
| 82 |
+
|
| 83 |
+
# Knowledge growth (this turn) = nodes added
|
| 84 |
+
knowledge_growth = len(resp.hypergraph_delta.nodes_added or [])
|
| 85 |
+
|
| 86 |
+
# Coherence = inverse of cognitive strain
|
| 87 |
+
coherence = 1.0 - float(resp.strain_metadata.cognitive_strain or 0.3)
|
| 88 |
+
|
| 89 |
+
age_seconds = time.time() - CIVILIZATION_START_TS
|
| 90 |
+
age_minutes = int(age_seconds / 60)
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"mycelium_density_pct": round(density * 100),
|
| 94 |
+
"council_active": council_active,
|
| 95 |
+
"knowledge_growth": knowledge_growth,
|
| 96 |
+
"coherence_pct": round(max(0.0, min(1.0, coherence)) * 100),
|
| 97 |
+
"civilization_age_min": age_minutes,
|
| 98 |
+
"nodes": nodes,
|
| 99 |
+
"edges": edges,
|
| 100 |
+
"alert_level": resp.ui_directives.alert_level or "CALM",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def _load_attachment(uf: UploadFile) -> Optional[dict]:
|
| 105 |
+
if uf is None or not uf.filename:
|
| 106 |
+
return None
|
| 107 |
+
raw = await uf.read()
|
| 108 |
+
if not raw:
|
| 109 |
+
return None
|
| 110 |
+
if len(raw) > MAX_UPLOAD_BYTES:
|
| 111 |
+
return {"kind": "error", "name": uf.filename,
|
| 112 |
+
"error": f"file too large ({len(raw)} > {MAX_UPLOAD_BYTES} bytes)"}
|
| 113 |
+
mime = (uf.content_type or "").lower()
|
| 114 |
+
if mime not in ALLOWED_MIME_TYPES:
|
| 115 |
+
# also accept by extension as last resort
|
| 116 |
+
low = uf.filename.lower()
|
| 117 |
+
if low.endswith(".pdf"):
|
| 118 |
+
mime = "application/pdf"
|
| 119 |
+
elif low.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
|
| 120 |
+
mime = "image/jpeg"
|
| 121 |
+
else:
|
| 122 |
+
return {"kind": "error", "name": uf.filename, "error": f"unsupported type: {mime}"}
|
| 123 |
+
|
| 124 |
+
if mime == "application/pdf":
|
| 125 |
+
return {"kind": "pdf", "bytes": raw, "name": uf.filename}
|
| 126 |
+
# image
|
| 127 |
+
try:
|
| 128 |
+
img = Image.open(io.BytesIO(raw))
|
| 129 |
+
img.load()
|
| 130 |
+
return {"kind": "image", "image": img, "name": uf.filename, "bytes": raw}
|
| 131 |
+
except Exception as e:
|
| 132 |
+
return {"kind": "error", "name": uf.filename, "error": f"image decode failed: {e}"}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def attach(app):
|
| 136 |
+
"""Register all /api routes on the gr.Server FastAPI app."""
|
| 137 |
+
|
| 138 |
+
app.mount("/audio", StaticFiles(directory=str(AUDIO_CACHE)), name="audio")
|
| 139 |
+
|
| 140 |
+
@app.get("/api/health")
|
| 141 |
+
async def health():
|
| 142 |
+
return {"status": "ok",
|
| 143 |
+
"nodes": HG.node_count(),
|
| 144 |
+
"edges": HG.edge_count(),
|
| 145 |
+
"grammar": GRAMMAR is not None,
|
| 146 |
+
"max_upload_files": MAX_UPLOAD_FILES}
|
| 147 |
+
|
| 148 |
+
@app.get("/api/hypergraph")
|
| 149 |
+
async def hypergraph():
|
| 150 |
+
nodes, edges = [], []
|
| 151 |
+
for i in HG.g.node_indexes():
|
| 152 |
+
d = HG.g[i]
|
| 153 |
+
nodes.append({
|
| 154 |
+
"node_id": d["node_id"],
|
| 155 |
+
"label": d.get("label", d["node_id"]),
|
| 156 |
+
"node_type": d.get("node_type", "DOMAIN"),
|
| 157 |
+
"payload": d.get("payload", {}),
|
| 158 |
+
"embedding_hint": d.get("embedding_hint", ""),
|
| 159 |
+
})
|
| 160 |
+
for s, t in HG.g.edge_list():
|
| 161 |
+
d = HG.g.get_edge_data(s, t)
|
| 162 |
+
edges.append({
|
| 163 |
+
"edge_id": d.get("edge_id", f"e_{s}_{t}"),
|
| 164 |
+
"source_node_id": HG.g[s]["node_id"],
|
| 165 |
+
"target_node_id": HG.g[t]["node_id"],
|
| 166 |
+
"edge_type": d.get("edge_type", "GENERIC"),
|
| 167 |
+
"weight": d.get("weight", 0.5),
|
| 168 |
+
"payload": d.get("payload", {}),
|
| 169 |
+
})
|
| 170 |
+
return {
|
| 171 |
+
"nodes": nodes, "edges": edges,
|
| 172 |
+
"node_count": HG.node_count(), "edge_count": HG.edge_count(),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
@app.get("/api/node/{node_id}")
|
| 176 |
+
async def node_detail(node_id: str):
|
| 177 |
+
"""Detail view for one node: payload, connections, related agents."""
|
| 178 |
+
if node_id not in HG._idx:
|
| 179 |
+
raise HTTPException(404, "node not found")
|
| 180 |
+
idx = HG._idx[node_id]
|
| 181 |
+
n = HG.g[idx]
|
| 182 |
+
|
| 183 |
+
incoming, outgoing = [], []
|
| 184 |
+
for s, t in HG.g.edge_list():
|
| 185 |
+
d = HG.g.get_edge_data(s, t)
|
| 186 |
+
if t == idx:
|
| 187 |
+
incoming.append({
|
| 188 |
+
"from": HG.g[s]["node_id"],
|
| 189 |
+
"from_label": HG.g[s].get("label", ""),
|
| 190 |
+
"edge_type": d.get("edge_type", ""),
|
| 191 |
+
"weight": d.get("weight", 0.5),
|
| 192 |
+
})
|
| 193 |
+
if s == idx:
|
| 194 |
+
outgoing.append({
|
| 195 |
+
"to": HG.g[t]["node_id"],
|
| 196 |
+
"to_label": HG.g[t].get("label", ""),
|
| 197 |
+
"edge_type": d.get("edge_type", ""),
|
| 198 |
+
"weight": d.get("weight", 0.5),
|
| 199 |
+
})
|
| 200 |
+
return {
|
| 201 |
+
"node_id": n["node_id"],
|
| 202 |
+
"label": n.get("label", n["node_id"]),
|
| 203 |
+
"node_type": n.get("node_type", ""),
|
| 204 |
+
"payload": n.get("payload", {}),
|
| 205 |
+
"embedding_hint": n.get("embedding_hint", ""),
|
| 206 |
+
"incoming": incoming,
|
| 207 |
+
"outgoing": outgoing,
|
| 208 |
+
"degree": len(incoming) + len(outgoing),
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
@app.post("/api/turn")
|
| 212 |
+
async def turn(user_text: str = Form(""),
|
| 213 |
+
attachments: List[UploadFile] = File(default=[])):
|
| 214 |
+
try:
|
| 215 |
+
# 1. Process up to MAX_UPLOAD_FILES attachments
|
| 216 |
+
atts = []
|
| 217 |
+
for uf in (attachments or [])[:MAX_UPLOAD_FILES]:
|
| 218 |
+
a = await _load_attachment(uf)
|
| 219 |
+
if a:
|
| 220 |
+
atts.append(a)
|
| 221 |
+
|
| 222 |
+
errors = [a for a in atts if a.get("kind") == "error"]
|
| 223 |
+
valid = [a for a in atts if a.get("kind") in ("image", "pdf")]
|
| 224 |
+
|
| 225 |
+
# 2. Build messages with hypergraph context
|
| 226 |
+
messages = build_messages(user_text, valid, HG.context_summary())
|
| 227 |
+
|
| 228 |
+
# 3. GPU inference (returns strict JSON)
|
| 229 |
+
raw = _gpu_infer(messages)
|
| 230 |
+
|
| 231 |
+
# 4. Parse
|
| 232 |
+
try:
|
| 233 |
+
envelope = ElysiumEnvelope.model_validate_json(raw)
|
| 234 |
+
except Exception as parse_err:
|
| 235 |
+
try:
|
| 236 |
+
blob = json.loads(raw)
|
| 237 |
+
if "elysium_response" not in blob:
|
| 238 |
+
meta = new_session_meta()
|
| 239 |
+
envelope = ElysiumEnvelope(
|
| 240 |
+
user_msg=user_text,
|
| 241 |
+
elysium_response=ElysiumResponse(
|
| 242 |
+
session_id=meta["session_id"],
|
| 243 |
+
timestamp_utc=meta["timestamp_utc"],
|
| 244 |
+
interaction_type="SIMPLE_REPLY",
|
| 245 |
+
direct_answer=str(blob)[:600]))
|
| 246 |
+
else:
|
| 247 |
+
envelope = ElysiumEnvelope.model_validate(blob)
|
| 248 |
+
except Exception:
|
| 249 |
+
return JSONResponse(
|
| 250 |
+
_fallback_envelope(user_text, f"parse_error: {parse_err}"))
|
| 251 |
+
|
| 252 |
+
resp = envelope.elysium_response
|
| 253 |
+
|
| 254 |
+
# 5. Apply hypergraph delta
|
| 255 |
+
HG.apply_delta(resp.hypergraph_delta)
|
| 256 |
+
persistence.save(HG)
|
| 257 |
+
|
| 258 |
+
# 6. Execute tools
|
| 259 |
+
tool_results = execute_all(resp.tool_calls) if resp.tool_calls else []
|
| 260 |
+
|
| 261 |
+
# 7. Build audio drama if needed (combined + per-agent)
|
| 262 |
+
audio_url = None
|
| 263 |
+
per_agent_audio = []
|
| 264 |
+
if resp.council_deliberation.debate_mode in ("AUDIO_DRAMA", "SILENT") \
|
| 265 |
+
and resp.council_deliberation.agent_outputs:
|
| 266 |
+
try:
|
| 267 |
+
agents_dump = [a.model_dump() for a in resp.council_deliberation.agent_outputs]
|
| 268 |
+
if resp.council_deliberation.debate_mode == "AUDIO_DRAMA":
|
| 269 |
+
audio_url = build_debate(agents_dump)
|
| 270 |
+
per_agent_audio = build_per_agent_audio(agents_dump)
|
| 271 |
+
except Exception as e:
|
| 272 |
+
print(f"[tts] debate failed: {e}")
|
| 273 |
+
traceback.print_exc()
|
| 274 |
+
|
| 275 |
+
payload = envelope.model_dump()
|
| 276 |
+
payload["_runtime"] = {
|
| 277 |
+
"tool_results": tool_results,
|
| 278 |
+
"audio_url": audio_url,
|
| 279 |
+
"per_agent_audio": per_agent_audio,
|
| 280 |
+
"metrics": _civilization_metrics(resp),
|
| 281 |
+
"attachment_errors": [{"name": e["name"], "error": e["error"]} for e in errors],
|
| 282 |
+
"attachments_processed": [
|
| 283 |
+
{"kind": a["kind"], "name": a["name"]} for a in valid
|
| 284 |
+
],
|
| 285 |
+
}
|
| 286 |
+
return JSONResponse(payload)
|
| 287 |
+
|
| 288 |
+
except Exception as e:
|
| 289 |
+
traceback.print_exc()
|
| 290 |
+
return JSONResponse(
|
| 291 |
+
_fallback_envelope(user_text, str(e)), status_code=200)
|
| 292 |
+
|
| 293 |
+
@app.post("/api/reset")
|
| 294 |
+
async def reset():
|
| 295 |
+
global HG, CIVILIZATION_START_TS
|
| 296 |
+
HG = Hypergraph()
|
| 297 |
+
persistence.save(HG)
|
| 298 |
+
CIVILIZATION_START_TS = time.time()
|
| 299 |
+
return {"status": "reset"}
|
backend/tts/debate_sequencer.py
CHANGED
|
@@ -1,26 +1,72 @@
|
|
| 1 |
-
"""Sequences
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
from
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sequences agent utterances into both a combined debate track AND
|
| 2 |
+
per-agent clips so the frontend can play them independently with synced
|
| 3 |
+
speaking highlights.
|
| 4 |
+
"""
|
| 5 |
+
import uuid
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from pydub import AudioSegment
|
| 8 |
+
from ..config import AUDIO_CACHE
|
| 9 |
+
from .voxcpm_engine import synthesize
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _agent_clip(ao: dict) -> str | None:
|
| 13 |
+
"""Synthesize and return server path to one agent's audio clip."""
|
| 14 |
+
text = ao.get("tts_speech_text", "")
|
| 15 |
+
voice = ao.get("tts_voice_design", {}) or {}
|
| 16 |
+
if not text.strip():
|
| 17 |
+
return None
|
| 18 |
+
try:
|
| 19 |
+
wav_path = synthesize(text, voice)
|
| 20 |
+
# voxcpm writes into AUDIO_CACHE already → just return /audio/<name>
|
| 21 |
+
return f"/audio/{Path(wav_path).name}"
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print(f"[tts] clip failed for agent {ao.get('agent_id')}: {e}")
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_per_agent_audio(agent_outputs: list) -> list:
|
| 28 |
+
"""Returns [{agent_id, agent_name, archetype, audio_url}] for each agent
|
| 29 |
+
with a clip. Frontend uses these for the My-Agent overlay's per-agent
|
| 30 |
+
play buttons.
|
| 31 |
+
"""
|
| 32 |
+
out = []
|
| 33 |
+
for ao in agent_outputs:
|
| 34 |
+
url = _agent_clip(ao)
|
| 35 |
+
out.append({
|
| 36 |
+
"agent_id": ao.get("agent_id", ""),
|
| 37 |
+
"agent_name": ao.get("agent_name", "Agent"),
|
| 38 |
+
"archetype": ao.get("archetype", "DYNAMIC"),
|
| 39 |
+
"audio_url": url,
|
| 40 |
+
"tts_text": ao.get("tts_speech_text", ""),
|
| 41 |
+
"confidence": ao.get("confidence", 0.8),
|
| 42 |
+
"stance": ao.get("stance", ""),
|
| 43 |
+
"thinking": ao.get("thinking", ""),
|
| 44 |
+
"voice_design": ao.get("tts_voice_design", {}),
|
| 45 |
+
})
|
| 46 |
+
return out
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def build_debate(agent_outputs: list) -> str | None:
|
| 50 |
+
"""Returns a server-relative path under /audio/... for the combined track."""
|
| 51 |
+
if not agent_outputs:
|
| 52 |
+
return None
|
| 53 |
+
track = AudioSegment.silent(duration=200)
|
| 54 |
+
for ao in agent_outputs:
|
| 55 |
+
text = ao.get("tts_speech_text", "")
|
| 56 |
+
voice = ao.get("tts_voice_design", {}) or {}
|
| 57 |
+
if not text.strip():
|
| 58 |
+
continue
|
| 59 |
+
try:
|
| 60 |
+
wav_path = synthesize(text, voice)
|
| 61 |
+
seg = AudioSegment.from_wav(wav_path)
|
| 62 |
+
track += seg + AudioSegment.silent(duration=350)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"[tts] segment failed: {e}")
|
| 65 |
+
continue
|
| 66 |
+
out_path = AUDIO_CACHE / f"debate_{uuid.uuid4().hex}.wav"
|
| 67 |
+
try:
|
| 68 |
+
track.export(out_path, format="wav")
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"[tts] export failed: {e}")
|
| 71 |
+
return None
|
| 72 |
+
return f"/audio/{out_path.name}"
|
frontend/dist/assets/api.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
-
/* Tiny client for /api/* endpoints.
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
fd
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
async
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Tiny client for /api/* endpoints.
|
| 2 |
+
Supports multiple attachments (max 2 enforced on UI side too). */
|
| 3 |
+
window.ElysiumAPI = {
|
| 4 |
+
async turn(text, files) {
|
| 5 |
+
const fd = new FormData();
|
| 6 |
+
fd.append('user_text', text || '');
|
| 7 |
+
(files || []).slice(0, 2).forEach(f => fd.append('attachments', f));
|
| 8 |
+
const r = await fetch('/api/turn', { method: 'POST', body: fd });
|
| 9 |
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
| 10 |
+
return r.json();
|
| 11 |
+
},
|
| 12 |
+
async health() { return (await fetch('/api/health')).json(); },
|
| 13 |
+
async hypergraph() { return (await fetch('/api/hypergraph')).json(); },
|
| 14 |
+
async nodeDetail(id){
|
| 15 |
+
const r = await fetch('/api/node/' + encodeURIComponent(id));
|
| 16 |
+
if (!r.ok) return null;
|
| 17 |
+
return r.json();
|
| 18 |
+
},
|
| 19 |
+
async reset() { return (await fetch('/api/reset',{method:'POST'})).json(); },
|
| 20 |
+
};
|
frontend/dist/assets/boot.js
CHANGED
|
@@ -1,122 +1,315 @@
|
|
| 1 |
-
/* Main app boot
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
(
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
document.
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Main app boot — wires UI ↔ /api/turn.
|
| 2 |
+
Critical responsibilities:
|
| 3 |
+
• NEVER render raw JSON on the canvas. JSON is parsed and routed:
|
| 4 |
+
hypergraph_delta → canvas nodes/edges
|
| 5 |
+
council_deliberation → Council overlay + TTS
|
| 6 |
+
ui_directives → pulses, focus, alert level
|
| 7 |
+
direct_answer → optional toast
|
| 8 |
+
metrics → bottom stats bar
|
| 9 |
+
• Disable input + paperclip + send while model is thinking (busy lock)
|
| 10 |
+
• File preview strip above textbox (image thumbs + PDF tiles)
|
| 11 |
+
• Max 2 attachments enforced client-side
|
| 12 |
+
• Legend updated live from real type counts
|
| 13 |
+
*/
|
| 14 |
+
(() => {
|
| 15 |
+
const input = document.getElementById('q-input');
|
| 16 |
+
const send = document.getElementById('q-send');
|
| 17 |
+
const fileEl = document.getElementById('q-file');
|
| 18 |
+
const upBtn = document.getElementById('q-upload');
|
| 19 |
+
const strip = document.getElementById('attach-strip');
|
| 20 |
+
const seedHint = document.getElementById('seed-hint');
|
| 21 |
+
|
| 22 |
+
// ── Toast helper ──
|
| 23 |
+
window.toast = function (msg, kind = '') {
|
| 24 |
+
const t = document.createElement('div');
|
| 25 |
+
t.className = 'toast ' + kind;
|
| 26 |
+
t.textContent = msg;
|
| 27 |
+
document.getElementById('toasts').appendChild(t);
|
| 28 |
+
setTimeout(() => t.remove(), 5200);
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// ── File attachment state (max 2) ──
|
| 32 |
+
const MAX_FILES = 2;
|
| 33 |
+
let attachments = []; // {file, name, kind, previewUrl}
|
| 34 |
+
|
| 35 |
+
function renderStrip() {
|
| 36 |
+
if (attachments.length === 0) {
|
| 37 |
+
strip.classList.add('hidden');
|
| 38 |
+
strip.innerHTML = '';
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
strip.classList.remove('hidden');
|
| 42 |
+
const hintHtml = attachments.length < MAX_FILES
|
| 43 |
+
? `<span class="attach-hint">${MAX_FILES - attachments.length} more allowed</span>`
|
| 44 |
+
: `<span class="attach-hint">max ${MAX_FILES} reached</span>`;
|
| 45 |
+
strip.innerHTML = attachments.map((a, i) => {
|
| 46 |
+
const preview = a.kind === 'image'
|
| 47 |
+
? `<img src="${a.previewUrl}" alt="">`
|
| 48 |
+
: `<div class="pdf-ico">PDF</div>`;
|
| 49 |
+
const sizeKb = (a.file.size / 1024).toFixed(0);
|
| 50 |
+
return `
|
| 51 |
+
<div class="preview-tile">
|
| 52 |
+
${preview}
|
| 53 |
+
<span class="nm" title="${escapeHtml(a.name)}">${escapeHtml(a.name)} · ${sizeKb}KB</span>
|
| 54 |
+
<button class="x" data-i="${i}" title="Remove">×</button>
|
| 55 |
+
</div>`;
|
| 56 |
+
}).join('') + hintHtml;
|
| 57 |
+
strip.querySelectorAll('.x').forEach(b => {
|
| 58 |
+
b.onclick = () => {
|
| 59 |
+
const i = +b.dataset.i;
|
| 60 |
+
const removed = attachments.splice(i, 1)[0];
|
| 61 |
+
if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
|
| 62 |
+
renderStrip();
|
| 63 |
+
};
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function addFiles(files) {
|
| 68 |
+
const list = Array.from(files);
|
| 69 |
+
for (const f of list) {
|
| 70 |
+
if (attachments.length >= MAX_FILES) {
|
| 71 |
+
window.toast(`Max ${MAX_FILES} attachments allowed`, 'warn');
|
| 72 |
+
break;
|
| 73 |
+
}
|
| 74 |
+
const mime = (f.type || '').toLowerCase();
|
| 75 |
+
const isImg = mime.startsWith('image/');
|
| 76 |
+
const isPdf = mime === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
| 77 |
+
if (!isImg && !isPdf) {
|
| 78 |
+
window.toast(`Unsupported file: ${f.name} (only images & PDFs)`, 'warn');
|
| 79 |
+
continue;
|
| 80 |
+
}
|
| 81 |
+
if (f.size > 12 * 1024 * 1024) {
|
| 82 |
+
window.toast(`${f.name} is too large (>12MB)`, 'warn');
|
| 83 |
+
continue;
|
| 84 |
+
}
|
| 85 |
+
attachments.push({
|
| 86 |
+
file: f,
|
| 87 |
+
name: f.name,
|
| 88 |
+
kind: isImg ? 'image' : 'pdf',
|
| 89 |
+
previewUrl: isImg ? URL.createObjectURL(f) : null,
|
| 90 |
+
});
|
| 91 |
+
}
|
| 92 |
+
renderStrip();
|
| 93 |
+
// reset native input so the same file can be re-selected if user removes it
|
| 94 |
+
fileEl.value = '';
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
fileEl.addEventListener('change', e => addFiles(e.target.files));
|
| 98 |
+
|
| 99 |
+
// ── Drag-and-drop onto the whole window (bonus polish) ──
|
| 100 |
+
['dragenter', 'dragover'].forEach(ev =>
|
| 101 |
+
window.addEventListener(ev, e => { e.preventDefault(); }));
|
| 102 |
+
window.addEventListener('drop', e => {
|
| 103 |
+
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
| 104 |
+
e.preventDefault();
|
| 105 |
+
addFiles(e.dataTransfer.files);
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
// ── Busy lock (disable input while model thinks) ──
|
| 109 |
+
function setBusy(b) {
|
| 110 |
+
document.body.dataset.busy = b ? '1' : '0';
|
| 111 |
+
input.disabled = b;
|
| 112 |
+
send.disabled = b;
|
| 113 |
+
if (b) {
|
| 114 |
+
send.classList.add('loading');
|
| 115 |
+
upBtn.classList.add('disabled');
|
| 116 |
+
fileEl.disabled = true;
|
| 117 |
+
input.dataset.prev = input.placeholder;
|
| 118 |
+
input.placeholder = 'Council deliberating…';
|
| 119 |
+
} else {
|
| 120 |
+
send.classList.remove('loading');
|
| 121 |
+
upBtn.classList.remove('disabled');
|
| 122 |
+
fileEl.disabled = false;
|
| 123 |
+
input.placeholder = input.dataset.prev || 'Speak to your civilization seed…';
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// ── Restore civilization on load ──
|
| 128 |
+
async function restore() {
|
| 129 |
+
try {
|
| 130 |
+
const h = await ElysiumAPI.hypergraph();
|
| 131 |
+
(h.nodes || []).forEach(n => {
|
| 132 |
+
if (n.node_id !== 'CORE') window.elysiumAddNode(n);
|
| 133 |
+
});
|
| 134 |
+
(h.edges || []).forEach(e => window.elysiumAddEdge(e));
|
| 135 |
+
if ((h.nodes || []).length > 0) seedHint.classList.add('hidden');
|
| 136 |
+
updateLegend();
|
| 137 |
+
updateMetrics({
|
| 138 |
+
nodes: h.node_count, edges: h.edge_count,
|
| 139 |
+
council_active: 0, knowledge_growth: 0,
|
| 140 |
+
civilization_age_min: 0,
|
| 141 |
+
mycelium_density_pct: h.node_count ? Math.round(Math.min(1, h.edge_count / Math.max(1, h.node_count * 1.4)) * 100) : 0,
|
| 142 |
+
coherence_pct: 70,
|
| 143 |
+
});
|
| 144 |
+
} catch (e) {
|
| 145 |
+
// first boot offline — fine
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
restore();
|
| 149 |
+
|
| 150 |
+
// ── SUBMIT ──
|
| 151 |
+
async function submit() {
|
| 152 |
+
if (document.body.dataset.busy === '1') return;
|
| 153 |
+
const text = input.value.trim();
|
| 154 |
+
if (!text && attachments.length === 0) return;
|
| 155 |
+
|
| 156 |
+
setBusy(true);
|
| 157 |
+
seedHint.classList.add('hidden');
|
| 158 |
+
input.value = '';
|
| 159 |
+
|
| 160 |
+
const files = attachments.map(a => a.file);
|
| 161 |
+
// clear preview strip
|
| 162 |
+
attachments.forEach(a => a.previewUrl && URL.revokeObjectURL(a.previewUrl));
|
| 163 |
+
attachments = [];
|
| 164 |
+
renderStrip();
|
| 165 |
+
|
| 166 |
+
try {
|
| 167 |
+
const data = await ElysiumAPI.turn(text, files);
|
| 168 |
+
handleResponse(data);
|
| 169 |
+
} catch (e) {
|
| 170 |
+
window.toast('Inference failed: ' + e.message, 'error');
|
| 171 |
+
} finally {
|
| 172 |
+
setBusy(false);
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
send.onclick = submit;
|
| 177 |
+
input.addEventListener('keydown', e => {
|
| 178 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 179 |
+
e.preventDefault();
|
| 180 |
+
submit();
|
| 181 |
+
}
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
// ── HANDLE MODEL RESPONSE ──
|
| 185 |
+
// Parses JSON envelope and routes data to the right UI subsystem.
|
| 186 |
+
// NEVER renders the JSON itself on the canvas.
|
| 187 |
+
function handleResponse(payload) {
|
| 188 |
+
const resp = payload.elysium_response || {};
|
| 189 |
+
const rt = payload._runtime || {};
|
| 190 |
+
|
| 191 |
+
// 0. Attachment errors → toast
|
| 192 |
+
(rt.attachment_errors || []).forEach(e =>
|
| 193 |
+
window.toast(`📎 ${e.name}: ${e.error}`, 'warn'));
|
| 194 |
+
|
| 195 |
+
// 1. Hypergraph delta → canvas
|
| 196 |
+
const delta = resp.hypergraph_delta || {};
|
| 197 |
+
(delta.nodes_added || []).forEach(n => {
|
| 198 |
+
// Find a parent hint: first edge whose target is this node
|
| 199 |
+
let parent = null;
|
| 200 |
+
for (const e of (delta.edges_added || [])) {
|
| 201 |
+
if (e.target_node_id === n.node_id) { parent = e.source_node_id; break; }
|
| 202 |
+
if (e.source_node_id === n.node_id) { parent = e.target_node_id; break; }
|
| 203 |
+
}
|
| 204 |
+
window.elysiumAddNode(n, parent);
|
| 205 |
+
});
|
| 206 |
+
(delta.edges_added || []).forEach(e => window.elysiumAddEdge(e));
|
| 207 |
+
|
| 208 |
+
// 2. UI directives
|
| 209 |
+
const ui = resp.ui_directives || {};
|
| 210 |
+
(ui.bioluminescence_pulse_nodes || []).forEach(id => window.elysiumPulse(id, 1500));
|
| 211 |
+
document.body.dataset.alert = ui.alert_level || 'CALM';
|
| 212 |
+
if (ui.camera_focus_node_id) {
|
| 213 |
+
// Pulse + focus for emphasis
|
| 214 |
+
window.elysiumPulse(ui.camera_focus_node_id, 1800);
|
| 215 |
+
setTimeout(() => window.elysiumFocus(ui.camera_focus_node_id), 350);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// 3. Council overlay + TTS
|
| 219 |
+
window.renderCouncil(resp, rt);
|
| 220 |
+
|
| 221 |
+
// 4. Metrics bar (REAL civilization metrics, not fake)
|
| 222 |
+
updateMetrics(rt.metrics || {});
|
| 223 |
+
|
| 224 |
+
// 5. Agent count badge
|
| 225 |
+
const ag = (resp.council_deliberation?.agent_outputs || []).length;
|
| 226 |
+
document.getElementById('agent-count').textContent = `+${ag}`;
|
| 227 |
+
|
| 228 |
+
// 6. Legend live update
|
| 229 |
+
updateLegend();
|
| 230 |
+
|
| 231 |
+
// 7. Tool toasts
|
| 232 |
+
(rt.tool_results || []).forEach(tr => {
|
| 233 |
+
const ok = tr.result && !tr.result.error;
|
| 234 |
+
window.toast(`🔧 ${tr.tool_name}: ${ok ? 'ok' : (tr.result?.error || 'offline')}`,
|
| 235 |
+
ok ? 'info' : 'warn');
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
// 8. Direct answer toast (only if no council, otherwise it's already in synthesis)
|
| 239 |
+
if (!ag && resp.direct_answer) {
|
| 240 |
+
window.toast(resp.direct_answer);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
// 9. Attachment processed confirmation
|
| 244 |
+
if ((rt.attachments_processed || []).length) {
|
| 245 |
+
const names = rt.attachments_processed.map(a => a.name).join(', ');
|
| 246 |
+
window.toast(`📎 Analyzed: ${names}`, 'info');
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function updateMetrics(m) {
|
| 251 |
+
document.getElementById('s-nodes').textContent = (m.nodes ?? window.ELYSIUM?.nodes.size ?? 1);
|
| 252 |
+
document.getElementById('s-edges').textContent = (m.edges ?? window.ELYSIUM?.edges.length ?? 0);
|
| 253 |
+
document.getElementById('s-council').textContent = (m.council_active ?? 0);
|
| 254 |
+
document.getElementById('s-growth').textContent = (m.knowledge_growth ?? 0);
|
| 255 |
+
document.getElementById('s-age').textContent = (m.civilization_age_min ?? 0);
|
| 256 |
+
document.getElementById('m-density').textContent = (m.mycelium_density_pct ?? 0) + '%';
|
| 257 |
+
document.getElementById('m-coherence').textContent = (m.coherence_pct ?? 70) + '%';
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function updateLegend() {
|
| 261 |
+
const counts = {};
|
| 262 |
+
window.ELYSIUM.nodes.forEach(n => counts[n.type] = (counts[n.type] || 0) + 1);
|
| 263 |
+
const order = ['CORE','CIVILIZATION','DOMAIN','AGENT','TOOL','PROJECT',
|
| 264 |
+
'LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY','FACT','CONCEPT','QUERY'];
|
| 265 |
+
const seen = new Set();
|
| 266 |
+
const parts = [];
|
| 267 |
+
order.filter(t => counts[t] && !seen.has(t)).forEach(t => {
|
| 268 |
+
seen.add(t);
|
| 269 |
+
const c = window.colorFor(t);
|
| 270 |
+
parts.push(`
|
| 271 |
+
<div class="legend-item" data-type="${t}">
|
| 272 |
+
<span class="dot" style="background:${c};color:${c}"></span>
|
| 273 |
+
<span class="badge">${counts[t]}</span>
|
| 274 |
+
<span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
|
| 275 |
+
</div>`);
|
| 276 |
+
});
|
| 277 |
+
// any types not in canonical order, append
|
| 278 |
+
Object.keys(counts).forEach(t => {
|
| 279 |
+
if (seen.has(t)) return;
|
| 280 |
+
const c = window.colorFor(t);
|
| 281 |
+
parts.push(`<div class="legend-item" data-type="${t}">
|
| 282 |
+
<span class="dot" style="background:${c};color:${c}"></span>
|
| 283 |
+
<span class="badge">${counts[t]}</span>
|
| 284 |
+
<span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
|
| 285 |
+
</div>`);
|
| 286 |
+
});
|
| 287 |
+
document.getElementById('legend-items').innerHTML = parts.join('');
|
| 288 |
+
// wire legend filter clicks
|
| 289 |
+
document.querySelectorAll('#legend-items .legend-item').forEach(el => {
|
| 290 |
+
el.onclick = () => {
|
| 291 |
+
const t = el.dataset.type;
|
| 292 |
+
window.elysiumFilterType?.(t);
|
| 293 |
+
// visual feedback
|
| 294 |
+
document.querySelectorAll('#legend-items .legend-item').forEach(x =>
|
| 295 |
+
x.style.opacity = window.ELYSIUM.filterType
|
| 296 |
+
? (x.dataset.type === window.ELYSIUM.filterType ? '1' : '.45')
|
| 297 |
+
: '1');
|
| 298 |
+
};
|
| 299 |
+
});
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// RI Analysis = fit-to-view + summary toast
|
| 303 |
+
document.getElementById('ri-analysis').onclick = () => {
|
| 304 |
+
window.elysiumFitAll();
|
| 305 |
+
const n = window.ELYSIUM.nodes.size;
|
| 306 |
+
const e = window.ELYSIUM.edges.length;
|
| 307 |
+
window.toast(`🔍 Civilization snapshot: ${n} nodes · ${e} threads`, 'info');
|
| 308 |
+
};
|
| 309 |
+
|
| 310 |
+
function escapeHtml(s) {
|
| 311 |
+
return String(s || '').replace(/[&<>"']/g, c => ({
|
| 312 |
+
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
| 313 |
+
}[c]));
|
| 314 |
+
}
|
| 315 |
+
})();
|
frontend/dist/assets/canvas.js
CHANGED
|
@@ -1,339 +1,444 @@
|
|
| 1 |
-
/* Google-Maps-style infinite-pan canvas with bioluminescent hypergraph.
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
const
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
canvas.
|
| 10 |
-
canvas.
|
| 11 |
-
canvas.style.
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
e.
|
| 72 |
-
const
|
| 73 |
-
|
| 74 |
-
E.cam.
|
| 75 |
-
E.cam.tx =
|
| 76 |
-
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
const
|
| 82 |
-
E.cam.
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
canvas.addEventListener('
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
canvas.addEventListener('
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
/
|
| 152 |
-
E.
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
function
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
E.nodes.
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
}
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Google-Maps-style infinite-pan canvas with bioluminescent hypergraph.
|
| 2 |
+
Handles: pan / zoom / pinch / inertia / minimap drag / click-to-detail. */
|
| 3 |
+
(() => {
|
| 4 |
+
const canvas = document.getElementById('elysium-canvas');
|
| 5 |
+
const ctx = canvas.getContext('2d', { alpha: false });
|
| 6 |
+
|
| 7 |
+
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
| 8 |
+
function resize() {
|
| 9 |
+
canvas.width = innerWidth * DPR;
|
| 10 |
+
canvas.height = innerHeight * DPR;
|
| 11 |
+
canvas.style.width = innerWidth + 'px';
|
| 12 |
+
canvas.style.height = innerHeight + 'px';
|
| 13 |
+
}
|
| 14 |
+
resize();
|
| 15 |
+
addEventListener('resize', resize);
|
| 16 |
+
|
| 17 |
+
// ──────── WORLD STATE ────────
|
| 18 |
+
const E = window.ELYSIUM = {
|
| 19 |
+
cam: { x: 0, y: 0, z: 1, tx: 0, ty: 0, tz: 1, vx: 0, vy: 0, drag: false },
|
| 20 |
+
nodes: new Map(),
|
| 21 |
+
edges: [],
|
| 22 |
+
hover: null,
|
| 23 |
+
selected: null,
|
| 24 |
+
filterType: null, // legend click filters
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
// Seed CORE
|
| 28 |
+
E.nodes.set('CORE', {
|
| 29 |
+
node_id: 'CORE', x: 0, y: 0,
|
| 30 |
+
type: 'CORE', label: 'ELYSIUM',
|
| 31 |
+
radius: 36, color: '#ffb840',
|
| 32 |
+
phase: 0, born: performance.now() - 1000,
|
| 33 |
+
payload: { description: 'The seed of your civilization. Ask anything to grow new nodes.' },
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
// ���─────── COORDS ────────
|
| 37 |
+
function clientToWorld(cx, cy) {
|
| 38 |
+
return {
|
| 39 |
+
x: (cx - innerWidth / 2) / E.cam.z + E.cam.x,
|
| 40 |
+
y: (cy - innerHeight / 2) / E.cam.z + E.cam.y,
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function hitTest(cx, cy) {
|
| 45 |
+
const w = clientToWorld(cx, cy);
|
| 46 |
+
let best = null, bestD = Infinity;
|
| 47 |
+
E.nodes.forEach(n => {
|
| 48 |
+
const d = Math.hypot(n.x - w.x, n.y - w.y);
|
| 49 |
+
if (d < n.radius * 1.25 && d < bestD) { best = n; bestD = d; }
|
| 50 |
+
});
|
| 51 |
+
return best;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// ──────── PAN / TAP ────────
|
| 55 |
+
let lastX = 0, lastY = 0, downX = 0, downY = 0, downT = 0;
|
| 56 |
+
canvas.addEventListener('pointerdown', e => {
|
| 57 |
+
downX = e.clientX; downY = e.clientY; downT = performance.now();
|
| 58 |
+
E.cam.drag = true;
|
| 59 |
+
canvas.classList.add('dragging');
|
| 60 |
+
lastX = e.clientX; lastY = e.clientY;
|
| 61 |
+
canvas.setPointerCapture(e.pointerId);
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
canvas.addEventListener('pointermove', e => {
|
| 65 |
+
if (!E.cam.drag) {
|
| 66 |
+
const n = hitTest(e.clientX, e.clientY);
|
| 67 |
+
E.hover = n;
|
| 68 |
+
canvas.style.cursor = n ? 'pointer' : '';
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
const dx = (e.clientX - lastX) / E.cam.z;
|
| 72 |
+
const dy = (e.clientY - lastY) / E.cam.z;
|
| 73 |
+
E.cam.x -= dx; E.cam.y -= dy;
|
| 74 |
+
E.cam.vx = dx * 0.85; E.cam.vy = dy * 0.85;
|
| 75 |
+
E.cam.tx = E.cam.x; E.cam.ty = E.cam.y;
|
| 76 |
+
lastX = e.clientX; lastY = e.clientY;
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
function endPan(e) {
|
| 80 |
+
const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
|
| 81 |
+
const dt = performance.now() - downT;
|
| 82 |
+
E.cam.drag = false;
|
| 83 |
+
canvas.classList.remove('dragging');
|
| 84 |
+
try { canvas.releasePointerCapture(e.pointerId); } catch {}
|
| 85 |
+
// Treat as click if minimal movement + short duration
|
| 86 |
+
if (moved < 6 && dt < 350) {
|
| 87 |
+
const n = hitTest(e.clientX, e.clientY);
|
| 88 |
+
if (n) {
|
| 89 |
+
if (window.showNodeDetail) window.showNodeDetail(n, e.clientX, e.clientY);
|
| 90 |
+
} else {
|
| 91 |
+
if (window.hideNodeDetail) window.hideNodeDetail();
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
canvas.addEventListener('pointerup', endPan);
|
| 96 |
+
canvas.addEventListener('pointercancel', endPan);
|
| 97 |
+
|
| 98 |
+
// wheel zoom (zoom to cursor)
|
| 99 |
+
canvas.addEventListener('wheel', e => {
|
| 100 |
+
e.preventDefault();
|
| 101 |
+
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
| 102 |
+
const w = clientToWorld(e.clientX, e.clientY);
|
| 103 |
+
E.cam.tz = Math.max(0.15, Math.min(4, E.cam.z * factor));
|
| 104 |
+
E.cam.tx = w.x - (e.clientX - innerWidth / 2) / E.cam.tz;
|
| 105 |
+
E.cam.ty = w.y - (e.clientY - innerHeight / 2) / E.cam.tz;
|
| 106 |
+
}, { passive: false });
|
| 107 |
+
|
| 108 |
+
canvas.addEventListener('dblclick', e => {
|
| 109 |
+
const w = clientToWorld(e.clientX, e.clientY);
|
| 110 |
+
E.cam.tz = Math.min(4, E.cam.z * 1.6);
|
| 111 |
+
E.cam.tx = w.x; E.cam.ty = w.y;
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
// ──────── PINCH ZOOM ────────
|
| 115 |
+
let touchDist = 0, touchMid = null;
|
| 116 |
+
canvas.addEventListener('touchstart', e => {
|
| 117 |
+
if (e.touches.length === 2) {
|
| 118 |
+
e.preventDefault();
|
| 119 |
+
const [a, b] = e.touches;
|
| 120 |
+
touchDist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
| 121 |
+
touchMid = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
| 122 |
+
}
|
| 123 |
+
}, { passive: false });
|
| 124 |
+
canvas.addEventListener('touchmove', e => {
|
| 125 |
+
if (e.touches.length === 2 && touchDist) {
|
| 126 |
+
e.preventDefault();
|
| 127 |
+
const [a, b] = e.touches;
|
| 128 |
+
const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
| 129 |
+
const factor = d / touchDist; touchDist = d;
|
| 130 |
+
const w = clientToWorld(touchMid.x, touchMid.y);
|
| 131 |
+
E.cam.tz = Math.max(0.15, Math.min(4, E.cam.z * factor));
|
| 132 |
+
E.cam.tx = w.x - (touchMid.x - innerWidth / 2) / E.cam.tz;
|
| 133 |
+
E.cam.ty = w.y - (touchMid.y - innerHeight / 2) / E.cam.tz;
|
| 134 |
+
}
|
| 135 |
+
}, { passive: false });
|
| 136 |
+
canvas.addEventListener('touchend', () => { touchDist = 0; });
|
| 137 |
+
|
| 138 |
+
// ──────── ZOOM BTNS ────────
|
| 139 |
+
document.getElementById('z-in').onclick = () => E.cam.tz = Math.min(4, E.cam.z * 1.35);
|
| 140 |
+
document.getElementById('z-out').onclick = () => E.cam.tz = Math.max(0.15, E.cam.z / 1.35);
|
| 141 |
+
document.getElementById('z-fit').onclick = fitAll;
|
| 142 |
+
|
| 143 |
+
function fitAll() {
|
| 144 |
+
if (E.nodes.size === 0) return;
|
| 145 |
+
let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
|
| 146 |
+
E.nodes.forEach(n => {
|
| 147 |
+
mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
|
| 148 |
+
mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
|
| 149 |
+
});
|
| 150 |
+
const pad = 220;
|
| 151 |
+
E.cam.tx = (mnX + mxX) / 2;
|
| 152 |
+
E.cam.ty = (mnY + mxY) / 2;
|
| 153 |
+
if (mxX - mnX < 1 && mxY - mnY < 1) {
|
| 154 |
+
E.cam.tz = 1;
|
| 155 |
+
} else {
|
| 156 |
+
E.cam.tz = Math.min(2.2, Math.max(0.3,
|
| 157 |
+
Math.min(innerWidth / (mxX - mnX + pad),
|
| 158 |
+
innerHeight / (mxY - mnY + pad))));
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
window.elysiumFitAll = fitAll;
|
| 162 |
+
|
| 163 |
+
// ──────── MINIMAP (interactive) ────────
|
| 164 |
+
const mini = document.getElementById('minimap');
|
| 165 |
+
const mctx = mini.getContext('2d');
|
| 166 |
+
|
| 167 |
+
function computeBounds(pad = 100) {
|
| 168 |
+
let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
|
| 169 |
+
E.nodes.forEach(n => {
|
| 170 |
+
mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
|
| 171 |
+
mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
|
| 172 |
+
});
|
| 173 |
+
if (mxX - mnX < 100) { mnX -= 200; mxX += 200; }
|
| 174 |
+
if (mxY - mnY < 100) { mnY -= 200; mxY += 200; }
|
| 175 |
+
return { mnX: mnX - pad, mnY: mnY - pad,
|
| 176 |
+
mxX: mxX + pad, mxY: mxY + pad };
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function drawMinimap() {
|
| 180 |
+
const w = mini.clientWidth, h = mini.clientHeight;
|
| 181 |
+
if (mini.width !== w * DPR || mini.height !== h * DPR) {
|
| 182 |
+
mini.width = w * DPR; mini.height = h * DPR;
|
| 183 |
+
}
|
| 184 |
+
mctx.save();
|
| 185 |
+
mctx.scale(DPR, DPR);
|
| 186 |
+
// bg
|
| 187 |
+
mctx.fillStyle = 'rgba(2,16,22,1)';
|
| 188 |
+
mctx.fillRect(0, 0, w, h);
|
| 189 |
+
// grid
|
| 190 |
+
mctx.strokeStyle = 'rgba(0,229,200,.08)';
|
| 191 |
+
mctx.lineWidth = .5;
|
| 192 |
+
for (let i = 0; i < w; i += 12) { mctx.beginPath(); mctx.moveTo(i, 0); mctx.lineTo(i, h); mctx.stroke(); }
|
| 193 |
+
for (let j = 0; j < h; j += 12) { mctx.beginPath(); mctx.moveTo(0, j); mctx.lineTo(w, j); mctx.stroke(); }
|
| 194 |
+
|
| 195 |
+
if (E.nodes.size === 0) { mctx.restore(); return; }
|
| 196 |
+
const { mnX, mnY, mxX, mxY } = computeBounds();
|
| 197 |
+
const sx = w / (mxX - mnX), sy = h / (mxY - mnY);
|
| 198 |
+
|
| 199 |
+
// edges
|
| 200 |
+
mctx.lineWidth = .6;
|
| 201 |
+
E.edges.forEach(ed => {
|
| 202 |
+
const s = E.nodes.get(ed.src), t = E.nodes.get(ed.dst);
|
| 203 |
+
if (!s || !t) return;
|
| 204 |
+
mctx.strokeStyle = ed.color || 'rgba(0,229,200,.25)';
|
| 205 |
+
mctx.beginPath();
|
| 206 |
+
mctx.moveTo((s.x - mnX) * sx, (s.y - mnY) * sy);
|
| 207 |
+
mctx.lineTo((t.x - mnX) * sx, (t.y - mnY) * sy);
|
| 208 |
+
mctx.stroke();
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// nodes
|
| 212 |
+
E.nodes.forEach(n => {
|
| 213 |
+
const px = (n.x - mnX) * sx, py = (n.y - mnY) * sy;
|
| 214 |
+
mctx.shadowColor = n.color;
|
| 215 |
+
mctx.shadowBlur = 6;
|
| 216 |
+
mctx.fillStyle = n.color;
|
| 217 |
+
mctx.beginPath();
|
| 218 |
+
mctx.arc(px, py, n.type === 'CORE' ? 3.5 : 2.2, 0, Math.PI * 2);
|
| 219 |
+
mctx.fill();
|
| 220 |
+
});
|
| 221 |
+
mctx.shadowBlur = 0;
|
| 222 |
+
|
| 223 |
+
// viewport rect
|
| 224 |
+
const vx = (E.cam.x - innerWidth / 2 / E.cam.z - mnX) * sx;
|
| 225 |
+
const vy = (E.cam.y - innerHeight / 2 / E.cam.z - mnY) * sy;
|
| 226 |
+
const vw = innerWidth / E.cam.z * sx;
|
| 227 |
+
const vh = innerHeight / E.cam.z * sy;
|
| 228 |
+
mctx.strokeStyle = 'rgba(25,214,255,.9)';
|
| 229 |
+
mctx.lineWidth = 1.2;
|
| 230 |
+
mctx.setLineDash([3, 3]);
|
| 231 |
+
mctx.strokeRect(vx, vy, vw, vh);
|
| 232 |
+
mctx.setLineDash([]);
|
| 233 |
+
mctx.restore();
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// minimap drag / click to pan
|
| 237 |
+
let miniDrag = false;
|
| 238 |
+
function miniPan(e) {
|
| 239 |
+
const rect = mini.getBoundingClientRect();
|
| 240 |
+
const fx = (e.clientX - rect.left) / rect.width;
|
| 241 |
+
const fy = (e.clientY - rect.top) / rect.height;
|
| 242 |
+
const { mnX, mnY, mxX, mxY } = computeBounds();
|
| 243 |
+
E.cam.tx = mnX + fx * (mxX - mnX);
|
| 244 |
+
E.cam.ty = mnY + fy * (mxY - mnY);
|
| 245 |
+
}
|
| 246 |
+
mini.addEventListener('pointerdown', e => { miniDrag = true; miniPan(e); mini.setPointerCapture(e.pointerId); });
|
| 247 |
+
mini.addEventListener('pointermove', e => { if (miniDrag) miniPan(e); });
|
| 248 |
+
mini.addEventListener('pointerup', e => { miniDrag = false; try { mini.releasePointerCapture(e.pointerId); } catch{} });
|
| 249 |
+
mini.addEventListener('pointercancel', () => { miniDrag = false; });
|
| 250 |
+
|
| 251 |
+
// ──────── PUBLIC API ────────
|
| 252 |
+
const SPAWN_RADIUS = 280;
|
| 253 |
+
|
| 254 |
+
window.elysiumAddNode = function (node, parentHint) {
|
| 255 |
+
if (E.nodes.has(node.node_id)) return E.nodes.get(node.node_id);
|
| 256 |
+
const parentId = parentHint || 'CORE';
|
| 257 |
+
const parent = E.nodes.get(parentId) || E.nodes.get('CORE') || { x: 0, y: 0 };
|
| 258 |
+
const idx = E.nodes.size;
|
| 259 |
+
// golden-angle spiral around parent for organic feel
|
| 260 |
+
const golden = 2.39996;
|
| 261 |
+
const angle = (idx * golden) % (Math.PI * 2);
|
| 262 |
+
const dist = SPAWN_RADIUS + ((idx * 17) % 220);
|
| 263 |
+
const tx = parent.x + Math.cos(angle) * dist;
|
| 264 |
+
const ty = parent.y + Math.sin(angle) * dist;
|
| 265 |
+
const type = node.node_type || node.type || 'DOMAIN';
|
| 266 |
+
const radius = type === 'CORE' || type === 'CIVILIZATION' ? 36
|
| 267 |
+
: type === 'AGENT' ? 22
|
| 268 |
+
: type === 'TOOL' ? 14
|
| 269 |
+
: 18;
|
| 270 |
+
const n = {
|
| 271 |
+
node_id: node.node_id,
|
| 272 |
+
x: parent.x, y: parent.y, tx, ty,
|
| 273 |
+
type,
|
| 274 |
+
label: node.label || node.node_id,
|
| 275 |
+
radius,
|
| 276 |
+
color: window.colorFor(type),
|
| 277 |
+
phase: Math.random() * Math.PI * 2,
|
| 278 |
+
born: performance.now(),
|
| 279 |
+
payload: node.payload || {},
|
| 280 |
+
embedding_hint: node.embedding_hint || '',
|
| 281 |
+
};
|
| 282 |
+
E.nodes.set(node.node_id, n);
|
| 283 |
+
// gentle auto-pan toward new node
|
| 284 |
+
E.cam.tx = E.cam.x * 0.86 + tx * 0.14;
|
| 285 |
+
E.cam.ty = E.cam.y * 0.86 + ty * 0.14;
|
| 286 |
+
return n;
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
window.elysiumAddEdge = function (edge) {
|
| 290 |
+
// de-dupe
|
| 291 |
+
if (E.edges.some(e =>
|
| 292 |
+
e.src === edge.source_node_id &&
|
| 293 |
+
e.dst === edge.target_node_id &&
|
| 294 |
+
e.type === edge.edge_type)) return;
|
| 295 |
+
E.edges.push({
|
| 296 |
+
src: edge.source_node_id,
|
| 297 |
+
dst: edge.target_node_id,
|
| 298 |
+
type: edge.edge_type,
|
| 299 |
+
weight: edge.weight ?? 0.5,
|
| 300 |
+
color: edge.edge_type === 'CONFLICT' ? 'rgba(255,84,105,.6)' :
|
| 301 |
+
edge.edge_type === 'COALITION' ? 'rgba(167,107,255,.65)' :
|
| 302 |
+
edge.edge_type === 'CAUSAL' ? 'rgba(25,214,255,.55)' :
|
| 303 |
+
edge.edge_type === 'SUPPORTS' ? 'rgba(92,255,174,.55)' :
|
| 304 |
+
'rgba(255,184,64,.55)',
|
| 305 |
+
born: performance.now(),
|
| 306 |
+
});
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
window.elysiumPulse = function (nodeId, ms = 1400) {
|
| 310 |
+
const n = E.nodes.get(nodeId);
|
| 311 |
+
if (n) n.pulseUntil = performance.now() + ms;
|
| 312 |
+
};
|
| 313 |
+
|
| 314 |
+
window.elysiumFocus = function (nodeId) {
|
| 315 |
+
const n = E.nodes.get(nodeId);
|
| 316 |
+
if (!n) return;
|
| 317 |
+
E.cam.tx = n.x; E.cam.ty = n.y;
|
| 318 |
+
E.cam.tz = Math.max(1.1, E.cam.z);
|
| 319 |
+
};
|
| 320 |
+
|
| 321 |
+
window.elysiumSelect = function (nodeId) {
|
| 322 |
+
E.nodes.forEach(n => n.selected = false);
|
| 323 |
+
const n = E.nodes.get(nodeId);
|
| 324 |
+
if (n) { n.selected = true; E.selected = n; }
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
window.elysiumGetNode = (id) => E.nodes.get(id);
|
| 328 |
+
|
| 329 |
+
// legend filter
|
| 330 |
+
window.elysiumFilterType = function (type) {
|
| 331 |
+
E.filterType = (E.filterType === type) ? null : type;
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
// ──────── RENDER LOOP ────────
|
| 335 |
+
function lerp(a, b, t) { return a + (b - a) * t; }
|
| 336 |
+
|
| 337 |
+
function loop(t) {
|
| 338 |
+
// animate spawn-to-target
|
| 339 |
+
E.nodes.forEach(n => {
|
| 340 |
+
if (n.tx != null) {
|
| 341 |
+
n.x = lerp(n.x, n.tx, 0.12);
|
| 342 |
+
n.y = lerp(n.y, n.ty, 0.12);
|
| 343 |
+
if (Math.abs(n.x - n.tx) < 0.3 && Math.abs(n.y - n.ty) < 0.3) {
|
| 344 |
+
n.x = n.tx; n.y = n.ty; n.tx = null; n.ty = null;
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
// smooth camera
|
| 350 |
+
E.cam.z = lerp(E.cam.z, E.cam.tz, 0.10);
|
| 351 |
+
if (!E.cam.drag) {
|
| 352 |
+
E.cam.x = lerp(E.cam.x, E.cam.tx, 0.12);
|
| 353 |
+
E.cam.y = lerp(E.cam.y, E.cam.ty, 0.12);
|
| 354 |
+
E.cam.x -= E.cam.vx; E.cam.y -= E.cam.vy;
|
| 355 |
+
E.cam.vx *= 0.9; E.cam.vy *= 0.9;
|
| 356 |
+
if (Math.abs(E.cam.vx) < 0.05) E.cam.vx = 0;
|
| 357 |
+
if (Math.abs(E.cam.vy) < 0.05) E.cam.vy = 0;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// background
|
| 361 |
+
ctx.fillStyle = '#02080c';
|
| 362 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 363 |
+
const grad = ctx.createRadialGradient(
|
| 364 |
+
canvas.width / 2, canvas.height / 2, 0,
|
| 365 |
+
canvas.width / 2, canvas.height / 2, canvas.width * 0.6);
|
| 366 |
+
grad.addColorStop(0, 'rgba(10,48,72,.32)');
|
| 367 |
+
grad.addColorStop(1, 'rgba(2,8,12,0)');
|
| 368 |
+
ctx.fillStyle = grad;
|
| 369 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 370 |
+
|
| 371 |
+
drawAmbient(ctx, t);
|
| 372 |
+
|
| 373 |
+
// world transform
|
| 374 |
+
ctx.save();
|
| 375 |
+
ctx.scale(DPR, DPR);
|
| 376 |
+
ctx.translate(innerWidth / 2, innerHeight / 2);
|
| 377 |
+
ctx.scale(E.cam.z, E.cam.z);
|
| 378 |
+
ctx.translate(-E.cam.x, -E.cam.y);
|
| 379 |
+
|
| 380 |
+
// edges (under nodes)
|
| 381 |
+
E.edges.forEach(e => {
|
| 382 |
+
const s = E.nodes.get(e.src), d = E.nodes.get(e.dst);
|
| 383 |
+
if (!s || !d) return;
|
| 384 |
+
const dim = E.filterType && s.type !== E.filterType && d.type !== E.filterType ? 0.18 : 1;
|
| 385 |
+
ctx.globalAlpha = dim;
|
| 386 |
+
ctx.strokeStyle = e.color;
|
| 387 |
+
ctx.lineWidth = (0.8 + (e.weight ?? .5) * 1.0) / E.cam.z;
|
| 388 |
+
if (e.type === 'COALITION' || e.type === 'CAUSAL') {
|
| 389 |
+
ctx.setLineDash([6 / E.cam.z, 4 / E.cam.z]);
|
| 390 |
+
ctx.lineDashOffset = -(t * 0.025);
|
| 391 |
+
} else {
|
| 392 |
+
ctx.setLineDash([]);
|
| 393 |
+
}
|
| 394 |
+
ctx.shadowColor = e.color;
|
| 395 |
+
ctx.shadowBlur = 8;
|
| 396 |
+
ctx.beginPath();
|
| 397 |
+
ctx.moveTo(s.x, s.y);
|
| 398 |
+
ctx.lineTo(d.x, d.y);
|
| 399 |
+
ctx.stroke();
|
| 400 |
+
ctx.setLineDash([]);
|
| 401 |
+
ctx.shadowBlur = 0;
|
| 402 |
+
});
|
| 403 |
+
ctx.globalAlpha = 1;
|
| 404 |
+
|
| 405 |
+
// nodes
|
| 406 |
+
E.nodes.forEach(n => {
|
| 407 |
+
const dim = E.filterType && n.type !== E.filterType ? 0.22 : 1;
|
| 408 |
+
ctx.globalAlpha = dim;
|
| 409 |
+
window.drawNode(ctx, n, t, E.cam.z);
|
| 410 |
+
});
|
| 411 |
+
ctx.globalAlpha = 1;
|
| 412 |
+
ctx.restore();
|
| 413 |
+
|
| 414 |
+
drawMinimap();
|
| 415 |
+
requestAnimationFrame(loop);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// ambient particles
|
| 419 |
+
const PARTICLES = Array.from({ length: 60 }, () => ({
|
| 420 |
+
x: Math.random() * innerWidth,
|
| 421 |
+
y: Math.random() * innerHeight,
|
| 422 |
+
r: Math.random() * 1.4 + 0.4,
|
| 423 |
+
vx: (Math.random() - .5) * 0.18,
|
| 424 |
+
vy: (Math.random() - .5) * 0.18,
|
| 425 |
+
c: Math.random() > .6 ? 'rgba(0,229,200,.35)'
|
| 426 |
+
: Math.random() > .5 ? 'rgba(167,107,255,.28)'
|
| 427 |
+
: 'rgba(255,184,64,.22)',
|
| 428 |
+
}));
|
| 429 |
+
function drawAmbient(ctx, t) {
|
| 430 |
+
ctx.save(); ctx.scale(DPR, DPR);
|
| 431 |
+
PARTICLES.forEach(p => {
|
| 432 |
+
p.x += p.vx; p.y += p.vy;
|
| 433 |
+
if (p.x < 0) p.x = innerWidth; if (p.x > innerWidth) p.x = 0;
|
| 434 |
+
if (p.y < 0) p.y = innerHeight; if (p.y > innerHeight) p.y = 0;
|
| 435 |
+
ctx.fillStyle = p.c;
|
| 436 |
+
ctx.beginPath();
|
| 437 |
+
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
| 438 |
+
ctx.fill();
|
| 439 |
+
});
|
| 440 |
+
ctx.restore();
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
requestAnimationFrame(loop);
|
| 444 |
+
})();
|
frontend/dist/assets/council.js
CHANGED
|
@@ -1,84 +1,295 @@
|
|
| 1 |
-
/* Council
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Council overlay + node-detail popover.
|
| 2 |
+
Implements:
|
| 3 |
+
• Council slide-in panel with per-agent boxes (different neon colors)
|
| 4 |
+
• Play / Pause / Minimize / Close controls
|
| 5 |
+
• Per-agent mini play buttons synced to the combined audio
|
| 6 |
+
• Speaking-agent highlight that follows audio timeline
|
| 7 |
+
• Node-detail popover with payload, type, connections (image 4 style) */
|
| 8 |
+
const AGENT_COLORS = {
|
| 9 |
+
THE_BUILDER: '#ff4fa3',
|
| 10 |
+
THE_GUARDIAN: '#a76bff',
|
| 11 |
+
THE_ORACLE: '#19d6ff',
|
| 12 |
+
THE_WEAVER: '#ff80c4',
|
| 13 |
+
THE_WILDCARD: '#5cffae',
|
| 14 |
+
DYNAMIC: '#ffb840',
|
| 15 |
+
};
|
| 16 |
+
window.agentColor = (a) => AGENT_COLORS[a] || '#a76bff';
|
| 17 |
+
|
| 18 |
+
const overlay = () => document.getElementById('council-overlay');
|
| 19 |
+
const pill = () => document.getElementById('council-pill');
|
| 20 |
+
|
| 21 |
+
let _audio = null; // <audio> element
|
| 22 |
+
let _agentAudios = []; // [{audio_url, ...}, ...]
|
| 23 |
+
let _currentAgents = [];
|
| 24 |
+
|
| 25 |
+
function escapeHtml(s) {
|
| 26 |
+
return String(s || '').replace(/[&<>"']/g, c => ({
|
| 27 |
+
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
| 28 |
+
}[c]));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
window.renderCouncil = function (resp, runtime) {
|
| 32 |
+
const ov = overlay();
|
| 33 |
+
const body = document.getElementById('co-body');
|
| 34 |
+
const synth = document.getElementById('co-synth');
|
| 35 |
+
const count = document.getElementById('co-count');
|
| 36 |
+
const audio = document.getElementById('debate-audio');
|
| 37 |
+
_audio = audio;
|
| 38 |
+
|
| 39 |
+
const cd = resp.council_deliberation || {};
|
| 40 |
+
const agents = cd.agent_outputs || [];
|
| 41 |
+
_currentAgents = agents;
|
| 42 |
+
_agentAudios = (runtime && runtime.per_agent_audio) || [];
|
| 43 |
+
|
| 44 |
+
// No agents → hide overlay + pill
|
| 45 |
+
if (!agents.length) {
|
| 46 |
+
ov.classList.remove('show');
|
| 47 |
+
setTimeout(() => ov.classList.add('hidden'), 400);
|
| 48 |
+
pill().classList.add('hidden');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
ov.classList.remove('hidden');
|
| 53 |
+
requestAnimationFrame(() => ov.classList.add('show'));
|
| 54 |
+
|
| 55 |
+
count.textContent = `+${agents.length}`;
|
| 56 |
+
document.getElementById('pill-count').textContent = agents.length;
|
| 57 |
+
|
| 58 |
+
// Render each agent as a colored card
|
| 59 |
+
body.innerHTML = agents.map((a, i) => {
|
| 60 |
+
const c = window.agentColor(a.archetype);
|
| 61 |
+
const audioInfo = _agentAudios[i] || {};
|
| 62 |
+
const hasAudio = !!audioInfo.audio_url;
|
| 63 |
+
return `
|
| 64 |
+
<div class="agent-card" style="border-color:${c}; --card-glow:${c}33; animation-delay:${i * 80}ms"
|
| 65 |
+
data-aid="${escapeHtml(a.agent_id)}" data-idx="${i}">
|
| 66 |
+
<div class="row1">
|
| 67 |
+
<span class="ad-dot" style="background:${c};color:${c}"></span>
|
| 68 |
+
<span class="name" style="color:${c}">${escapeHtml(a.agent_name || 'Agent')}</span>
|
| 69 |
+
<span class="archetype">${escapeHtml((a.archetype || 'DYNAMIC').replace(/^THE_/, ''))}</span>
|
| 70 |
+
${a.veto_triggered ? '<span class="veto">VETO</span>' : ''}
|
| 71 |
+
</div>
|
| 72 |
+
<div class="thinking">${escapeHtml(a.thinking || '')}</div>
|
| 73 |
+
<div class="stance">${escapeHtml(a.tts_speech_text || a.stance || '')}</div>
|
| 74 |
+
<div class="footer-row">
|
| 75 |
+
<div class="conf">confidence ${(a.confidence ?? 0.8).toFixed(2)}</div>
|
| 76 |
+
${hasAudio ? `<button class="play-mini" data-aurl="${audioInfo.audio_url}" data-idx="${i}" title="Play this agent's voice">▶</button>` : ''}
|
| 77 |
+
</div>
|
| 78 |
+
</div>`;
|
| 79 |
+
}).join('');
|
| 80 |
+
|
| 81 |
+
synth.textContent = cd.final_synthesis || '';
|
| 82 |
+
|
| 83 |
+
// Wire per-agent mini-play buttons
|
| 84 |
+
body.querySelectorAll('.play-mini').forEach(btn => {
|
| 85 |
+
btn.onclick = (ev) => {
|
| 86 |
+
ev.stopPropagation();
|
| 87 |
+
const url = btn.dataset.aurl;
|
| 88 |
+
const idx = +btn.dataset.idx;
|
| 89 |
+
if (audio.src.endsWith(url) && !audio.paused) {
|
| 90 |
+
audio.pause();
|
| 91 |
+
btn.classList.remove('playing');
|
| 92 |
+
btn.textContent = '▶';
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
// stop any other mini playing
|
| 96 |
+
body.querySelectorAll('.play-mini.playing').forEach(b => {
|
| 97 |
+
b.classList.remove('playing'); b.textContent = '▶';
|
| 98 |
+
});
|
| 99 |
+
audio.src = url;
|
| 100 |
+
audio.play().catch(() => {});
|
| 101 |
+
btn.classList.add('playing');
|
| 102 |
+
btn.textContent = '⏸';
|
| 103 |
+
highlightAgent(idx);
|
| 104 |
+
audio.onended = () => {
|
| 105 |
+
btn.classList.remove('playing'); btn.textContent = '▶';
|
| 106 |
+
clearHighlight();
|
| 107 |
+
};
|
| 108 |
+
};
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// Combined audio drama
|
| 112 |
+
if (runtime && runtime.audio_url) {
|
| 113 |
+
audio.style.display = 'block';
|
| 114 |
+
audio.src = runtime.audio_url;
|
| 115 |
+
audio.dataset.combined = '1';
|
| 116 |
+
document.getElementById('co-play').disabled = false;
|
| 117 |
+
document.getElementById('co-pause').disabled = true;
|
| 118 |
+
// optional autoplay (best-effort, browsers may block)
|
| 119 |
+
audio.play().then(() => {
|
| 120 |
+
document.getElementById('co-play').disabled = true;
|
| 121 |
+
document.getElementById('co-pause').disabled = false;
|
| 122 |
+
}).catch(() => {});
|
| 123 |
+
|
| 124 |
+
const cards = body.querySelectorAll('.agent-card');
|
| 125 |
+
let idx = 0;
|
| 126 |
+
if (cards[0]) cards[0].classList.add('speaking');
|
| 127 |
+
audio.ontimeupdate = () => {
|
| 128 |
+
if (!audio.duration || audio.dataset.combined !== '1') return;
|
| 129 |
+
const target = Math.min(cards.length - 1,
|
| 130 |
+
Math.floor((audio.currentTime / audio.duration) * cards.length));
|
| 131 |
+
if (target !== idx) {
|
| 132 |
+
cards[idx]?.classList.remove('speaking');
|
| 133 |
+
cards[target]?.classList.add('speaking');
|
| 134 |
+
idx = target;
|
| 135 |
+
}
|
| 136 |
+
};
|
| 137 |
+
audio.onended = () => {
|
| 138 |
+
cards.forEach(c => c.classList.remove('speaking'));
|
| 139 |
+
document.getElementById('co-play').disabled = false;
|
| 140 |
+
document.getElementById('co-pause').disabled = true;
|
| 141 |
+
};
|
| 142 |
+
} else {
|
| 143 |
+
audio.style.display = 'none';
|
| 144 |
+
audio.removeAttribute('src');
|
| 145 |
+
document.getElementById('co-play').disabled = true;
|
| 146 |
+
document.getElementById('co-pause').disabled = true;
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
function highlightAgent(idx) {
|
| 151 |
+
const cards = document.querySelectorAll('#co-body .agent-card');
|
| 152 |
+
cards.forEach(c => c.classList.remove('speaking'));
|
| 153 |
+
cards[idx]?.classList.add('speaking');
|
| 154 |
+
}
|
| 155 |
+
function clearHighlight() {
|
| 156 |
+
document.querySelectorAll('#co-body .agent-card').forEach(c => c.classList.remove('speaking'));
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/* ── Controls ── */
|
| 160 |
+
document.getElementById('co-close').onclick = () => {
|
| 161 |
+
overlay().classList.remove('show');
|
| 162 |
+
setTimeout(() => overlay().classList.add('hidden'), 400);
|
| 163 |
+
pill().classList.add('hidden');
|
| 164 |
+
if (_audio) { _audio.pause(); _audio.currentTime = 0; }
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
document.getElementById('co-min').onclick = () => {
|
| 168 |
+
overlay().classList.add('minimized');
|
| 169 |
+
if (_currentAgents.length) pill().classList.remove('hidden');
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
document.getElementById('council-pill').onclick = () => {
|
| 173 |
+
overlay().classList.remove('minimized');
|
| 174 |
+
pill().classList.add('hidden');
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
document.getElementById('co-play').onclick = () => {
|
| 178 |
+
if (!_audio || !_audio.src) return;
|
| 179 |
+
_audio.dataset.combined = '1';
|
| 180 |
+
_audio.play().then(() => {
|
| 181 |
+
document.getElementById('co-play').disabled = true;
|
| 182 |
+
document.getElementById('co-pause').disabled = false;
|
| 183 |
+
}).catch(() => {});
|
| 184 |
+
};
|
| 185 |
+
document.getElementById('co-pause').onclick = () => {
|
| 186 |
+
if (!_audio) return;
|
| 187 |
+
_audio.pause();
|
| 188 |
+
document.getElementById('co-play').disabled = false;
|
| 189 |
+
document.getElementById('co-pause').disabled = true;
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
/* ── My-Agent button toggles the overlay ── */
|
| 193 |
+
document.getElementById('my-agent').onclick = () => {
|
| 194 |
+
const ov = overlay();
|
| 195 |
+
if (ov.classList.contains('hidden') || ov.classList.contains('minimized')) {
|
| 196 |
+
if (!_currentAgents.length) {
|
| 197 |
+
window.toast?.('No agents yet — ask a complex question to summon the council', 'info');
|
| 198 |
+
return;
|
| 199 |
+
}
|
| 200 |
+
ov.classList.remove('hidden', 'minimized');
|
| 201 |
+
requestAnimationFrame(() => ov.classList.add('show'));
|
| 202 |
+
pill().classList.add('hidden');
|
| 203 |
+
} else {
|
| 204 |
+
ov.classList.remove('show');
|
| 205 |
+
setTimeout(() => ov.classList.add('hidden'), 400);
|
| 206 |
+
}
|
| 207 |
+
};
|
| 208 |
+
|
| 209 |
+
/* ──────────────────────────────────────────────────────────
|
| 210 |
+
NODE DETAIL POPOVER — fetches /api/node/:id and renders
|
| 211 |
+
payload + type + connections (matches image 4 layout)
|
| 212 |
+
────────────────────────────────────────────────────────── */
|
| 213 |
+
window.showNodeDetail = async function (n, sx, sy) {
|
| 214 |
+
window.elysiumSelect?.(n.node_id);
|
| 215 |
+
const el = document.getElementById('node-detail');
|
| 216 |
+
el.style.left = Math.min(innerWidth - 340, Math.max(10, sx + 14)) + 'px';
|
| 217 |
+
el.style.top = Math.min(innerHeight - 340, Math.max(10, sy - 40)) + 'px';
|
| 218 |
+
|
| 219 |
+
// Initial skeleton so user sees something instantly
|
| 220 |
+
el.innerHTML = `
|
| 221 |
+
<div class="nd-head">
|
| 222 |
+
<div class="nd-title">
|
| 223 |
+
<span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
|
| 224 |
+
${escapeHtml(n.label)}</div>
|
| 225 |
+
<button class="nd-close" onclick="window.hideNodeDetail()">×</button>
|
| 226 |
+
</div>
|
| 227 |
+
<div class="nd-type">${escapeHtml(n.type)}</div>
|
| 228 |
+
<div class="nd-stats">
|
| 229 |
+
<div class="nd-pill"><b>…</b>links</div>
|
| 230 |
+
<div class="nd-pill"><b>…</b>in</div>
|
| 231 |
+
<div class="nd-pill"><b>…</b>out</div>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="nd-section">DESCRIPTION</div>
|
| 234 |
+
<div class="nd-payload">${escapeHtml(n.embedding_hint || (n.payload?.description) || 'Loading…')}</div>
|
| 235 |
+
`;
|
| 236 |
+
el.classList.add('show');
|
| 237 |
+
|
| 238 |
+
// Fetch enriched details
|
| 239 |
+
try {
|
| 240 |
+
const d = await window.ElysiumAPI.nodeDetail(n.node_id);
|
| 241 |
+
if (!d) return;
|
| 242 |
+
const conns = (d.incoming || []).concat(d.outgoing || []);
|
| 243 |
+
const connsHtml = conns.length
|
| 244 |
+
? conns.slice(0, 6).map(c => `
|
| 245 |
+
<div class="nd-conn">
|
| 246 |
+
<span class="ct">${escapeHtml(c.from_label || c.to_label || c.from || c.to)}</span>
|
| 247 |
+
<span class="cw">${(c.weight ?? 0.5).toFixed(2)}</span>
|
| 248 |
+
</div>`).join('')
|
| 249 |
+
: '<div class="nd-conn"><span class="ct">No connections yet</span></div>';
|
| 250 |
+
|
| 251 |
+
const payload = d.payload && Object.keys(d.payload).length
|
| 252 |
+
? Object.entries(d.payload).slice(0, 6).map(([k, v]) =>
|
| 253 |
+
`${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
|
| 254 |
+
).join('\n')
|
| 255 |
+
: (d.embedding_hint || 'No payload data');
|
| 256 |
+
|
| 257 |
+
el.innerHTML = `
|
| 258 |
+
<div class="nd-head">
|
| 259 |
+
<div class="nd-title">
|
| 260 |
+
<span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
|
| 261 |
+
${escapeHtml(d.label)}</div>
|
| 262 |
+
<button class="nd-close" onclick="window.hideNodeDetail()">×</button>
|
| 263 |
+
</div>
|
| 264 |
+
<div class="nd-type">${escapeHtml(d.node_type)}</div>
|
| 265 |
+
<div class="nd-stats">
|
| 266 |
+
<div class="nd-pill"><b>${d.degree}</b>links</div>
|
| 267 |
+
<div class="nd-pill"><b>${(d.incoming || []).length}</b>in</div>
|
| 268 |
+
<div class="nd-pill"><b>${(d.outgoing || []).length}</b>out</div>
|
| 269 |
+
</div>
|
| 270 |
+
<div class="nd-section">PAYLOAD</div>
|
| 271 |
+
<div class="nd-payload">${escapeHtml(payload)}</div>
|
| 272 |
+
<div class="nd-section">CONNECTIONS</div>
|
| 273 |
+
<div class="nd-conns">${connsHtml}</div>
|
| 274 |
+
`;
|
| 275 |
+
} catch (e) {
|
| 276 |
+
// local-only fallback
|
| 277 |
+
}
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
window.hideNodeDetail = function () {
|
| 281 |
+
document.getElementById('node-detail').classList.remove('show');
|
| 282 |
+
window.ELYSIUM?.nodes.forEach(n => n.selected = false);
|
| 283 |
+
};
|
| 284 |
+
|
| 285 |
+
// Close popover when clicking elsewhere (but not on canvas — canvas handles its own)
|
| 286 |
+
document.addEventListener('pointerdown', e => {
|
| 287 |
+
const nd = document.getElementById('node-detail');
|
| 288 |
+
if (nd.classList.contains('show') &&
|
| 289 |
+
!nd.contains(e.target) &&
|
| 290 |
+
e.target.id !== 'elysium-canvas') {
|
| 291 |
+
// canvas click closes via its own logic; only outside-of-popover clicks here
|
| 292 |
+
if (e.target.closest('#elysium-canvas')) return;
|
| 293 |
+
window.hideNodeDetail();
|
| 294 |
+
}
|
| 295 |
+
});
|
frontend/dist/assets/elysium.css
CHANGED
|
@@ -1,229 +1,409 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
--
|
| 9 |
-
--
|
| 10 |
-
--
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
#
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
.
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
#
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
#
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
/*
|
| 92 |
-
#
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
.
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
box-shadow:
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
#
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
.
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
.
|
| 148 |
-
.
|
| 149 |
-
|
| 150 |
-
.
|
| 151 |
-
|
| 152 |
-
.
|
| 153 |
-
line-height:1.45}
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
background:rgba(0,
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
#
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
background:
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
.
|
| 210 |
-
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
.
|
| 228 |
-
|
| 229 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
ELYSIUM — Dark Neon Bioluminescent Theme
|
| 3 |
+
Heavily inspired by the reference images:
|
| 4 |
+
deep teal-black backdrop, magenta/cyan/violet/gold neon nodes,
|
| 5 |
+
glassy panels with neon hairline borders.
|
| 6 |
+
============================================================ */
|
| 7 |
+
:root{
|
| 8 |
+
--bg-0:#02080c;
|
| 9 |
+
--bg-1:#06121a;
|
| 10 |
+
--bg-2:#0a1f2a;
|
| 11 |
+
|
| 12 |
+
--panel:rgba(6,18,26,.78);
|
| 13 |
+
--panel-2:rgba(10,30,42,.65);
|
| 14 |
+
--panel-3:rgba(2,12,18,.92);
|
| 15 |
+
|
| 16 |
+
--border:rgba(0,229,200,.18);
|
| 17 |
+
--border-strong:rgba(0,229,200,.55);
|
| 18 |
+
--border-magenta:rgba(255,80,180,.45);
|
| 19 |
+
|
| 20 |
+
--teal:#00e5c8;
|
| 21 |
+
--cyan:#19d6ff;
|
| 22 |
+
--gold:#ffb840;
|
| 23 |
+
--magenta:#ff4fa3;
|
| 24 |
+
--violet:#a76bff;
|
| 25 |
+
--green:#5cffae;
|
| 26 |
+
--warm:#ff9b3c;
|
| 27 |
+
--red:#ff5469;
|
| 28 |
+
|
| 29 |
+
--text:#e6fbff;
|
| 30 |
+
--muted:#7ea8b6;
|
| 31 |
+
--muted-2:#5a8794;
|
| 32 |
+
|
| 33 |
+
--shadow:0 10px 40px rgba(0,0,0,.55),
|
| 34 |
+
inset 0 1px 0 rgba(255,255,255,.04);
|
| 35 |
+
--neon-teal: 0 0 18px rgba(0,229,200,.55);
|
| 36 |
+
--neon-magenta:0 0 18px rgba(255,80,180,.55);
|
| 37 |
+
--neon-violet: 0 0 18px rgba(167,107,255,.55);
|
| 38 |
+
--neon-cyan: 0 0 18px rgba(25,214,255,.55);
|
| 39 |
+
--neon-gold: 0 0 22px rgba(255,184,64,.55);
|
| 40 |
+
|
| 41 |
+
--font-sans:'Inter',system-ui,sans-serif;
|
| 42 |
+
--font-mono:'Space Mono',monospace;
|
| 43 |
+
--font-disp:'Space Grotesk',sans-serif;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
*,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
| 47 |
+
html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;
|
| 48 |
+
background:
|
| 49 |
+
radial-gradient(ellipse 90% 70% at 50% 40%, #0a2434 0%, #03101a 55%, #01060b 100%);
|
| 50 |
+
color:var(--text);font-family:var(--font-sans);font-size:14px;
|
| 51 |
+
user-select:none;-webkit-user-select:none}
|
| 52 |
+
button{font-family:inherit;color:inherit;cursor:pointer}
|
| 53 |
+
|
| 54 |
+
/* ── CANVAS ───────────────────────────────────────────── */
|
| 55 |
+
#elysium-canvas{position:fixed;inset:0;width:100vw;height:100vh;cursor:grab;
|
| 56 |
+
touch-action:none;z-index:0}
|
| 57 |
+
#elysium-canvas.dragging{cursor:grabbing}
|
| 58 |
+
|
| 59 |
+
/* ── GLASS PANELS ─────────────────────────────────────── */
|
| 60 |
+
.glass{
|
| 61 |
+
background:linear-gradient(180deg, var(--panel), var(--panel-2));
|
| 62 |
+
border:1px solid var(--border);
|
| 63 |
+
border-radius:18px;
|
| 64 |
+
backdrop-filter:blur(18px) saturate(1.25);
|
| 65 |
+
-webkit-backdrop-filter:blur(18px) saturate(1.25);
|
| 66 |
+
box-shadow:var(--shadow);
|
| 67 |
+
position:relative;
|
| 68 |
+
}
|
| 69 |
+
.glass::before{
|
| 70 |
+
content:'';position:absolute;inset:0;border-radius:inherit;pointer-events:none;
|
| 71 |
+
background:linear-gradient(135deg, rgba(0,229,200,.06), transparent 40%, rgba(255,80,180,.04));
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* ── TIMELINE ─────────────────────────────────────────── */
|
| 75 |
+
#timeline{position:fixed;left:0;top:0;bottom:0;width:58px;z-index:10;
|
| 76 |
+
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
| 77 |
+
padding:40px 0;background:linear-gradient(90deg,rgba(2,10,14,.6),transparent);
|
| 78 |
+
pointer-events:none}
|
| 79 |
+
#timeline .t-edge{position:absolute;left:0;top:0;bottom:0;width:2px;
|
| 80 |
+
background:repeating-linear-gradient(180deg,transparent 0 8px,rgba(0,229,200,.18) 8px 9px)}
|
| 81 |
+
#timeline .years{display:flex;flex-direction:column;gap:38px;font-size:10px;
|
| 82 |
+
color:var(--muted-2);letter-spacing:.6px;font-family:var(--font-mono);pointer-events:auto}
|
| 83 |
+
#timeline .year{opacity:.45}
|
| 84 |
+
#timeline .year-active{padding:4px 12px;border:1.5px solid var(--teal);
|
| 85 |
+
border-radius:18px;color:var(--text);font-weight:700;font-size:11px;
|
| 86 |
+
box-shadow:var(--neon-teal),inset 0 0 8px rgba(0,229,200,.25);
|
| 87 |
+
background:rgba(0,40,48,.55);animation:pillpulse 3s ease-in-out infinite}
|
| 88 |
+
@keyframes pillpulse{0%,100%{box-shadow:var(--neon-teal),inset 0 0 8px rgba(0,229,200,.25)}
|
| 89 |
+
50%{box-shadow:0 0 28px rgba(0,229,200,.95),inset 0 0 14px rgba(0,229,200,.4)}}
|
| 90 |
+
|
| 91 |
+
/* ── LEGEND ───────────────────────────────────────────── */
|
| 92 |
+
#legend{position:fixed;top:22px;right:22px;width:236px;padding:18px 18px 14px;z-index:20}
|
| 93 |
+
.legend-header{font-size:11px;letter-spacing:2.6px;color:var(--muted);margin-bottom:12px;
|
| 94 |
+
font-weight:600;display:flex;justify-content:space-between;align-items:center}
|
| 95 |
+
.legend-header #legend-help{width:18px;height:18px;border-radius:50%;
|
| 96 |
+
background:rgba(0,229,200,.12);color:var(--teal);font-size:10px;font-weight:700;
|
| 97 |
+
display:grid;place-items:center;cursor:help}
|
| 98 |
+
.legend-item{display:flex;align-items:center;gap:10px;margin:8px 0;font-size:12px;
|
| 99 |
+
cursor:pointer;padding:3px 6px;border-radius:8px;transition:background .15s}
|
| 100 |
+
.legend-item:hover{background:rgba(0,229,200,.06)}
|
| 101 |
+
.legend-item .dot{width:18px;height:18px;border-radius:50%;flex-shrink:0;
|
| 102 |
+
box-shadow:0 0 12px currentColor, inset 0 -3px 6px rgba(0,0,0,.4)}
|
| 103 |
+
.legend-item .badge{background:rgba(0,0,0,.4);padding:2px 8px;border-radius:10px;
|
| 104 |
+
font-family:var(--font-mono);font-size:10px;min-width:24px;text-align:center;color:var(--text)}
|
| 105 |
+
.legend-item .lbl{color:var(--text);font-size:12px;text-transform:capitalize}
|
| 106 |
+
.meta-row{display:flex;justify-content:space-between;align-items:center;
|
| 107 |
+
font-size:11px;color:var(--muted);margin-top:10px;padding-top:10px;
|
| 108 |
+
border-top:1px solid var(--border)}
|
| 109 |
+
.meta-val{background:rgba(0,0,0,.35);padding:3px 9px;border-radius:10px;
|
| 110 |
+
font-family:var(--font-mono);color:var(--cyan);font-size:11px;
|
| 111 |
+
box-shadow:inset 0 0 8px rgba(0,229,200,.08)}
|
| 112 |
+
|
| 113 |
+
/* ── MINIMAP ──────────────────────────────────────────── */
|
| 114 |
+
#minimap-wrap{position:fixed;left:74px;bottom:140px;z-index:15;
|
| 115 |
+
background:rgba(2,12,18,.85);border:1px solid var(--border);border-radius:12px;
|
| 116 |
+
padding:6px 6px 4px;box-shadow:var(--shadow);backdrop-filter:blur(10px)}
|
| 117 |
+
.minimap-label{font-size:9px;letter-spacing:2.2px;color:var(--muted);
|
| 118 |
+
text-align:center;margin-bottom:4px;font-weight:600}
|
| 119 |
+
#minimap{display:block;width:170px;height:108px;border-radius:8px;cursor:crosshair;
|
| 120 |
+
background:rgba(2,16,22,.95)}
|
| 121 |
+
|
| 122 |
+
/* ── ZOOM CTRL ────────────────────────────────────────── */
|
| 123 |
+
#zoom-ctrl{position:fixed;right:24px;bottom:140px;display:flex;flex-direction:column;
|
| 124 |
+
gap:6px;z-index:15}
|
| 125 |
+
#zoom-ctrl button{width:38px;height:38px;background:rgba(6,18,26,.85);
|
| 126 |
+
border:1px solid var(--border);border-radius:10px;color:var(--teal);
|
| 127 |
+
font-size:18px;cursor:pointer;backdrop-filter:blur(10px);
|
| 128 |
+
transition:all .15s ease;box-shadow:var(--shadow)}
|
| 129 |
+
#zoom-ctrl button:hover{background:rgba(0,229,200,.15);border-color:var(--border-strong);
|
| 130 |
+
transform:scale(1.06);box-shadow:var(--neon-teal)}
|
| 131 |
+
|
| 132 |
+
/* ── ALERT SCRIM ──────────────────────────────────────── */
|
| 133 |
+
#alert-scrim{position:fixed;inset:0;z-index:5;pointer-events:none;
|
| 134 |
+
background:radial-gradient(ellipse at center,transparent 55%,transparent 100%);
|
| 135 |
+
transition:background 800ms ease}
|
| 136 |
+
body[data-alert="ATTENTION"] #alert-scrim{background:radial-gradient(ellipse at center,transparent 50%,rgba(255,184,64,.12) 100%)}
|
| 137 |
+
body[data-alert="TENSION"] #alert-scrim{background:radial-gradient(ellipse at center,transparent 40%,rgba(255,80,180,.18) 100%)}
|
| 138 |
+
body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at center,transparent 30%,rgba(255,84,105,.28) 100%);
|
| 139 |
+
animation:crisisPulse 1.6s ease-in-out infinite}
|
| 140 |
+
@keyframes crisisPulse{0%,100%{opacity:.6}50%{opacity:1}}
|
| 141 |
+
|
| 142 |
+
/* ── STATS BAR ────────────────────────────────────────── */
|
| 143 |
+
#stats{position:fixed;left:58px;right:0;bottom:84px;height:56px;
|
| 144 |
+
display:flex;align-items:center;padding:0 28px;gap:32px;z-index:18;
|
| 145 |
+
border-radius:0;border-left:none;border-right:none;border-bottom:none;
|
| 146 |
+
background:linear-gradient(180deg,rgba(2,12,18,.72),rgba(2,8,12,.92))}
|
| 147 |
+
.stat{display:flex;align-items:center;gap:10px}
|
| 148 |
+
.stat-icon{font-size:18px;color:var(--teal);opacity:.95;
|
| 149 |
+
text-shadow:0 0 10px rgba(0,229,200,.6)}
|
| 150 |
+
.stat-label{font-size:10px;letter-spacing:1.4px;color:var(--muted);
|
| 151 |
+
text-transform:uppercase;font-weight:500}
|
| 152 |
+
.stat-value{font-size:22px;font-family:var(--font-disp);color:var(--cyan);
|
| 153 |
+
font-weight:700;line-height:1;text-shadow:0 0 8px rgba(25,214,255,.45)}
|
| 154 |
+
|
| 155 |
+
/* ── QUERY AREA (preview strip + bar + mode row) ──────── */
|
| 156 |
+
#query-area{position:fixed;left:58px;right:0;bottom:0;z-index:20;
|
| 157 |
+
display:flex;flex-direction:column;gap:0}
|
| 158 |
+
|
| 159 |
+
#attach-strip{margin:0 22px 6px;padding:8px 10px;display:flex;gap:8px;
|
| 160 |
+
flex-wrap:wrap;align-items:center;border-radius:14px 14px 14px 14px;
|
| 161 |
+
border-bottom:none;max-height:84px;overflow:auto}
|
| 162 |
+
.preview-tile{display:flex;align-items:center;gap:8px;
|
| 163 |
+
background:rgba(0,229,200,.06);border:1px solid var(--border-strong);
|
| 164 |
+
border-radius:10px;padding:4px 8px 4px 4px;font-size:11px;color:var(--text)}
|
| 165 |
+
.preview-tile img{width:38px;height:38px;border-radius:6px;object-fit:cover;
|
| 166 |
+
box-shadow:0 0 8px rgba(0,229,200,.3)}
|
| 167 |
+
.preview-tile .pdf-ico{width:38px;height:38px;border-radius:6px;display:grid;
|
| 168 |
+
place-items:center;background:linear-gradient(135deg,#3a1a2a,#1a0a14);
|
| 169 |
+
color:var(--magenta);font-family:var(--font-mono);font-size:11px;font-weight:700;
|
| 170 |
+
box-shadow:var(--neon-magenta)}
|
| 171 |
+
.preview-tile .nm{max-width:120px;overflow:hidden;text-overflow:ellipsis;
|
| 172 |
+
white-space:nowrap;font-family:var(--font-mono)}
|
| 173 |
+
.preview-tile .x{background:none;border:none;color:var(--muted);font-size:14px;
|
| 174 |
+
cursor:pointer;padding:0 4px}
|
| 175 |
+
.preview-tile .x:hover{color:var(--red)}
|
| 176 |
+
.attach-hint{font-size:11px;color:var(--muted-2);margin-left:6px}
|
| 177 |
+
|
| 178 |
+
#query-bar{margin:0 22px 8px;height:54px;
|
| 179 |
+
display:flex;align-items:center;padding:0 12px;gap:10px;
|
| 180 |
+
border-radius:28px;border:1px solid var(--border-strong);
|
| 181 |
+
box-shadow:var(--shadow),0 0 0 1px rgba(0,229,200,.05)}
|
| 182 |
+
#q-input{flex:1;height:40px;background:transparent;border:none;
|
| 183 |
+
padding:0 12px;color:var(--text);font-size:14px;outline:none}
|
| 184 |
+
#q-input::placeholder{color:var(--muted)}
|
| 185 |
+
#q-input:disabled{opacity:.5;cursor:not-allowed}
|
| 186 |
+
|
| 187 |
+
#q-upload{width:38px;height:38px;display:grid;place-items:center;
|
| 188 |
+
background:rgba(0,229,200,.06);border:1px solid var(--border);border-radius:50%;
|
| 189 |
+
cursor:pointer;font-size:14px;color:var(--muted);transition:all .15s;flex-shrink:0}
|
| 190 |
+
#q-upload:hover{color:var(--teal);border-color:var(--border-strong);box-shadow:var(--neon-teal)}
|
| 191 |
+
#q-upload.disabled{opacity:.4;pointer-events:none}
|
| 192 |
+
|
| 193 |
+
#q-send{width:42px;height:42px;border-radius:50%;
|
| 194 |
+
background:linear-gradient(135deg, var(--teal), #00b89e);
|
| 195 |
+
color:#03252a;border:none;cursor:pointer;
|
| 196 |
+
display:grid;place-items:center;
|
| 197 |
+
box-shadow:var(--neon-teal),inset 0 1px 0 rgba(255,255,255,.3);
|
| 198 |
+
transition:all .15s;flex-shrink:0}
|
| 199 |
+
#q-send:hover:not(:disabled){transform:scale(1.08);box-shadow:0 0 28px rgba(0,229,200,.85)}
|
| 200 |
+
#q-send:disabled{opacity:.5;cursor:not-allowed;background:linear-gradient(135deg,#444,#222)}
|
| 201 |
+
#q-send.loading svg{animation:spin 1s linear infinite}
|
| 202 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 203 |
+
|
| 204 |
+
.mode-row{display:flex;justify-content:flex-end;gap:8px;
|
| 205 |
+
padding:0 26px 8px 0;align-items:center}
|
| 206 |
+
.mode-row button{background:rgba(6,18,26,.85);border:1px solid var(--border);
|
| 207 |
+
border-radius:18px;padding:6px 14px;font-size:11.5px;color:var(--text);
|
| 208 |
+
cursor:pointer;letter-spacing:.5px;display:flex;align-items:center;gap:6px;
|
| 209 |
+
transition:all .15s;backdrop-filter:blur(8px)}
|
| 210 |
+
.mode-row button:hover{border-color:var(--border-strong);
|
| 211 |
+
box-shadow:0 0 12px rgba(0,229,200,.3)}
|
| 212 |
+
.mode-row .badge{background:rgba(0,229,200,.18);padding:1px 8px;border-radius:8px;
|
| 213 |
+
font-family:var(--font-mono);font-size:10px;color:var(--teal)}
|
| 214 |
+
.mode-row .dot-mini{width:8px;height:8px;border-radius:50%;background:var(--magenta);
|
| 215 |
+
box-shadow:var(--neon-magenta);display:inline-block}
|
| 216 |
+
|
| 217 |
+
/* ── COUNCIL OVERLAY ──────────────────────────────────── */
|
| 218 |
+
#council-overlay{position:fixed;left:22px;top:22px;
|
| 219 |
+
width:360px;max-height:calc(100vh - 220px);padding:18px;z-index:25;
|
| 220 |
+
display:flex;flex-direction:column;
|
| 221 |
+
opacity:0;transform:translateX(-30px);pointer-events:none;
|
| 222 |
+
transition:all .35s cubic-bezier(.34,1.56,.64,1)}
|
| 223 |
+
#council-overlay.show{opacity:1;transform:translateX(0);pointer-events:auto}
|
| 224 |
+
#council-overlay.hidden{display:none}
|
| 225 |
+
#council-overlay.minimized{display:none}
|
| 226 |
+
|
| 227 |
+
.co-head{display:flex;justify-content:space-between;align-items:center;
|
| 228 |
+
margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid var(--border)}
|
| 229 |
+
.co-title{font-size:11px;letter-spacing:2.4px;color:var(--teal);font-weight:700;
|
| 230 |
+
display:flex;align-items:center;gap:8px}
|
| 231 |
+
.co-pulse{width:8px;height:8px;border-radius:50%;background:var(--teal);
|
| 232 |
+
box-shadow:var(--neon-teal);animation:copulse 1.4s ease-in-out infinite}
|
| 233 |
+
@keyframes copulse{0%,100%{opacity:.6;transform:scale(1)}50%{opacity:1;transform:scale(1.3)}}
|
| 234 |
+
.co-title span{color:var(--magenta);margin-left:4px;text-shadow:var(--neon-magenta)}
|
| 235 |
+
.co-controls{display:flex;gap:4px}
|
| 236 |
+
.co-controls button{background:rgba(0,229,200,.06);border:1px solid var(--border);
|
| 237 |
+
color:var(--text);width:28px;height:28px;border-radius:8px;
|
| 238 |
+
font-size:12px;cursor:pointer;display:grid;place-items:center;
|
| 239 |
+
transition:all .15s}
|
| 240 |
+
.co-controls button:hover:not(:disabled){background:rgba(0,229,200,.15);
|
| 241 |
+
box-shadow:var(--neon-teal)}
|
| 242 |
+
.co-controls button:disabled{opacity:.35;cursor:not-allowed}
|
| 243 |
+
#co-play{color:var(--green)}
|
| 244 |
+
#co-pause{color:var(--gold)}
|
| 245 |
+
#co-close{color:var(--red)}
|
| 246 |
+
|
| 247 |
+
#co-body{flex:1;overflow-y:auto;max-height:50vh;margin:-2px;padding:2px}
|
| 248 |
+
#co-body::-webkit-scrollbar{width:6px}
|
| 249 |
+
#co-body::-webkit-scrollbar-thumb{background:rgba(0,229,200,.25);border-radius:3px}
|
| 250 |
+
|
| 251 |
+
.agent-card{border-left:3px solid;padding:12px 14px;margin:10px 0;
|
| 252 |
+
background:linear-gradient(135deg, rgba(10,30,42,.6), rgba(6,18,26,.4));
|
| 253 |
+
border-radius:10px;animation:slideIn .4s ease both;
|
| 254 |
+
position:relative;overflow:hidden}
|
| 255 |
+
.agent-card::before{content:'';position:absolute;inset:0;pointer-events:none;
|
| 256 |
+
background:radial-gradient(circle at 10% 50%, var(--card-glow,transparent) 0%, transparent 60%);
|
| 257 |
+
opacity:.5}
|
| 258 |
+
@keyframes slideIn{from{opacity:0;transform:translateX(-12px)}
|
| 259 |
+
to{opacity:1;transform:translateX(0)}}
|
| 260 |
+
|
| 261 |
+
.agent-card .row1{display:flex;align-items:center;gap:8px;margin-bottom:6px;
|
| 262 |
+
position:relative;z-index:1}
|
| 263 |
+
.agent-card .ad-dot{width:14px;height:14px;border-radius:50%;
|
| 264 |
+
box-shadow:0 0 12px currentColor, inset 0 -2px 4px rgba(0,0,0,.4);flex-shrink:0}
|
| 265 |
+
.agent-card .name{font-weight:700;font-size:13px;flex:1}
|
| 266 |
+
.agent-card .archetype{font-size:9.5px;background:rgba(0,0,0,.35);padding:2px 7px;
|
| 267 |
+
border-radius:8px;letter-spacing:.8px;color:var(--muted);text-transform:uppercase}
|
| 268 |
+
.agent-card .veto{background:rgba(255,84,105,.2);color:#ffb0b0;
|
| 269 |
+
border:1px solid rgba(255,84,105,.45);font-size:9px;padding:2px 6px;
|
| 270 |
+
border-radius:6px;letter-spacing:.8px}
|
| 271 |
+
.agent-card .thinking{color:var(--muted);font-size:11px;margin-top:4px;font-style:italic;
|
| 272 |
+
line-height:1.45;position:relative;z-index:1}
|
| 273 |
+
.agent-card .stance{font-size:12.5px;margin-top:8px;color:var(--text);
|
| 274 |
+
position:relative;z-index:1;line-height:1.5}
|
| 275 |
+
.agent-card .footer-row{display:flex;justify-content:space-between;align-items:center;
|
| 276 |
+
margin-top:10px;position:relative;z-index:1}
|
| 277 |
+
.agent-card .conf{font-family:var(--font-mono);font-size:10px;color:var(--teal)}
|
| 278 |
+
.agent-card .play-mini{background:rgba(0,229,200,.12);border:1px solid var(--border-strong);
|
| 279 |
+
color:var(--teal);width:26px;height:26px;border-radius:50%;font-size:11px;
|
| 280 |
+
display:grid;place-items:center;cursor:pointer;transition:all .15s}
|
| 281 |
+
.agent-card .play-mini:hover{transform:scale(1.1);box-shadow:var(--neon-teal)}
|
| 282 |
+
.agent-card .play-mini.playing{background:var(--teal);color:#03252a}
|
| 283 |
+
.agent-card.speaking{box-shadow:0 0 0 1.5px var(--border-strong),0 0 24px rgba(0,229,200,.35);
|
| 284 |
+
animation:speakPulse 1.2s ease-in-out infinite}
|
| 285 |
+
@keyframes speakPulse{0%,100%{transform:translateX(0)}50%{transform:translateX(3px)}}
|
| 286 |
+
|
| 287 |
+
#co-synth{margin-top:14px;padding-top:14px;border-top:1px solid var(--border);
|
| 288 |
+
color:var(--text);font-size:12.5px;line-height:1.55;font-style:italic}
|
| 289 |
+
#co-synth:empty{display:none}
|
| 290 |
+
#debate-audio{width:100%;margin-top:10px;height:32px;display:none}
|
| 291 |
+
|
| 292 |
+
/* COUNCIL PILL (minimized state) */
|
| 293 |
+
#council-pill{position:fixed;left:22px;top:22px;z-index:24;
|
| 294 |
+
padding:8px 16px;border-radius:20px;background:rgba(6,18,26,.92);
|
| 295 |
+
border:1px solid var(--border-strong);color:var(--text);font-size:12px;
|
| 296 |
+
display:flex;align-items:center;gap:8px;cursor:pointer;
|
| 297 |
+
box-shadow:var(--neon-teal);animation:pillFloat 2s ease-in-out infinite}
|
| 298 |
+
@keyframes pillFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
|
| 299 |
+
#council-pill:hover{background:rgba(0,229,200,.15)}
|
| 300 |
+
#council-pill .dot-mini{width:8px;height:8px;border-radius:50%;background:var(--magenta);
|
| 301 |
+
box-shadow:var(--neon-magenta)}
|
| 302 |
+
|
| 303 |
+
/* ── NODE DETAIL POPOVER ──────────────────────────────── */
|
| 304 |
+
#node-detail{position:fixed;width:320px;padding:18px;z-index:30;
|
| 305 |
+
opacity:0;transform:translateY(8px);pointer-events:none;
|
| 306 |
+
transition:opacity .25s,transform .25s;
|
| 307 |
+
border:1px solid var(--border-strong);box-shadow:var(--neon-teal),var(--shadow)}
|
| 308 |
+
#node-detail.show{opacity:1;transform:translateY(0);pointer-events:auto}
|
| 309 |
+
.nd-head{display:flex;justify-content:space-between;align-items:flex-start;
|
| 310 |
+
margin-bottom:8px;gap:8px}
|
| 311 |
+
.nd-title{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px;
|
| 312 |
+
color:var(--text);line-height:1.3;flex:1}
|
| 313 |
+
.nd-title .nd-dot{width:16px;height:16px;border-radius:50%;flex-shrink:0;
|
| 314 |
+
box-shadow:0 0 12px currentColor}
|
| 315 |
+
.nd-close{background:none;border:none;color:var(--muted);font-size:20px;
|
| 316 |
+
cursor:pointer;padding:0;line-height:1}
|
| 317 |
+
.nd-close:hover{color:var(--text)}
|
| 318 |
+
.nd-type{font-size:10px;letter-spacing:1.6px;color:var(--cyan);
|
| 319 |
+
text-transform:uppercase;margin-bottom:12px;font-family:var(--font-mono);
|
| 320 |
+
text-shadow:0 0 8px rgba(25,214,255,.4)}
|
| 321 |
+
.nd-stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-bottom:12px}
|
| 322 |
+
.nd-pill{background:rgba(0,0,0,.3);padding:7px 8px;border-radius:8px;
|
| 323 |
+
font-size:9.5px;text-align:center;color:var(--muted);
|
| 324 |
+
border:1px solid var(--border);letter-spacing:.8px}
|
| 325 |
+
.nd-pill b{display:block;font-family:var(--font-mono);color:var(--cyan);font-size:14px;
|
| 326 |
+
margin-bottom:2px;text-shadow:0 0 6px rgba(25,214,255,.4)}
|
| 327 |
+
.nd-section{font-size:9.5px;letter-spacing:1.8px;color:var(--muted-2);
|
| 328 |
+
text-transform:uppercase;margin-top:14px;margin-bottom:6px}
|
| 329 |
+
.nd-payload{font-size:11px;color:var(--text);background:rgba(0,0,0,.25);
|
| 330 |
+
padding:8px 10px;border-radius:8px;font-family:var(--font-mono);
|
| 331 |
+
max-height:90px;overflow:auto;line-height:1.5;
|
| 332 |
+
border-left:2px solid var(--border-strong)}
|
| 333 |
+
.nd-conns{font-size:11px;color:var(--muted);max-height:100px;overflow:auto}
|
| 334 |
+
.nd-conn{display:flex;justify-content:space-between;padding:3px 0;
|
| 335 |
+
border-bottom:1px solid rgba(0,229,200,.07)}
|
| 336 |
+
.nd-conn .ct{color:var(--text);max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 337 |
+
.nd-conn .cw{color:var(--gold);font-family:var(--font-mono);font-size:10px}
|
| 338 |
+
|
| 339 |
+
/* ── TOASTS ───────────────────────────────────────────── */
|
| 340 |
+
#toasts{position:fixed;top:22px;left:50%;transform:translateX(-50%);z-index:40;
|
| 341 |
+
display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:90vw}
|
| 342 |
+
.toast{background:rgba(6,18,26,.95);border:1px solid var(--border-strong);
|
| 343 |
+
border-left:3px solid var(--teal);padding:10px 18px;border-radius:10px;
|
| 344 |
+
font-size:12px;color:var(--text);box-shadow:var(--neon-teal),var(--shadow);
|
| 345 |
+
animation:toastIn .35s ease both,toastOut .4s ease 4.5s both;
|
| 346 |
+
backdrop-filter:blur(10px)}
|
| 347 |
+
.toast.warn{border-left-color:var(--gold);box-shadow:var(--neon-gold),var(--shadow)}
|
| 348 |
+
.toast.error{border-left-color:var(--red);box-shadow:0 0 14px rgba(255,84,105,.5)}
|
| 349 |
+
.toast.info{border-left-color:var(--violet);box-shadow:var(--neon-violet),var(--shadow)}
|
| 350 |
+
@keyframes toastIn{from{opacity:0;transform:translateY(-12px)}
|
| 351 |
+
to{opacity:1;transform:translateY(0)}}
|
| 352 |
+
@keyframes toastOut{to{opacity:0;transform:translateY(-12px)}}
|
| 353 |
+
|
| 354 |
+
/* ── SEED HINT ────────────────────────────────────────── */
|
| 355 |
+
#seed-hint{position:fixed;left:50%;top:42%;transform:translate(-50%,-50%);
|
| 356 |
+
z-index:8;padding:10px 22px;border-radius:22px;
|
| 357 |
+
background:rgba(6,18,26,.7);border:1px solid var(--border-strong);
|
| 358 |
+
font-size:12.5px;color:var(--text);backdrop-filter:blur(10px);
|
| 359 |
+
animation:hintFloat 3.5s ease-in-out infinite;pointer-events:none;
|
| 360 |
+
letter-spacing:.5px;box-shadow:var(--neon-teal)}
|
| 361 |
+
#seed-hint.hidden{display:none}
|
| 362 |
+
@keyframes hintFloat{0%,100%{opacity:.7;transform:translate(-50%,-50%)}
|
| 363 |
+
50%{opacity:1;transform:translate(-50%,calc(-50% - 6px))}}
|
| 364 |
+
|
| 365 |
+
/* ── BUSY OVERLAY (blocks input while model thinks) ──── */
|
| 366 |
+
#busy-overlay{position:fixed;inset:0;z-index:35;pointer-events:none;
|
| 367 |
+
display:none;place-items:center;background:rgba(2,12,18,0);
|
| 368 |
+
transition:background .3s}
|
| 369 |
+
body[data-busy="1"] #busy-overlay{display:grid;background:rgba(2,12,18,.18)}
|
| 370 |
+
.busy-spin{width:48px;height:48px;border-radius:50%;
|
| 371 |
+
border:3px solid rgba(0,229,200,.15);
|
| 372 |
+
border-top-color:var(--teal);
|
| 373 |
+
animation:spin 1s linear infinite;
|
| 374 |
+
box-shadow:var(--neon-teal)}
|
| 375 |
+
.busy-text{margin-top:14px;font-size:11px;letter-spacing:2.4px;color:var(--teal);
|
| 376 |
+
text-transform:uppercase;font-weight:600;text-shadow:var(--neon-teal)}
|
| 377 |
+
|
| 378 |
+
/* ── RESPONSIVE ───────────────────────────────────────── */
|
| 379 |
+
@media (max-width:980px){
|
| 380 |
+
#legend{width:200px;padding:14px;top:14px;right:14px}
|
| 381 |
+
#minimap-wrap{padding:5px}
|
| 382 |
+
#minimap{width:140px;height:90px}
|
| 383 |
+
#zoom-ctrl{right:14px;bottom:124px}
|
| 384 |
+
#stats{gap:20px;padding:0 16px}
|
| 385 |
+
.stat-value{font-size:18px}
|
| 386 |
+
.stat-label{font-size:9px}
|
| 387 |
+
#council-overlay{width:300px}
|
| 388 |
+
}
|
| 389 |
+
@media (max-width:680px){
|
| 390 |
+
#timeline{width:44px}
|
| 391 |
+
#legend{width:170px;padding:12px;top:10px;right:10px;font-size:11px}
|
| 392 |
+
#legend .lbl{display:none}
|
| 393 |
+
.meta-row{font-size:10px}
|
| 394 |
+
#minimap-wrap{left:54px;bottom:118px}
|
| 395 |
+
#minimap{width:120px;height:80px}
|
| 396 |
+
#stats{gap:14px;padding:0 12px;height:48px;bottom:78px}
|
| 397 |
+
.stat-value{font-size:15px}
|
| 398 |
+
.stat-icon{font-size:14px}
|
| 399 |
+
#query-area{left:44px}
|
| 400 |
+
.mode-row{padding-right:14px}
|
| 401 |
+
#council-overlay{left:8px;right:8px;width:auto;top:8px;
|
| 402 |
+
max-height:calc(100vh - 200px)}
|
| 403 |
+
#zoom-ctrl{bottom:78px;right:10px}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
/* ── HIDE GRADIO if ever shown ─────────────────────────── */
|
| 407 |
+
.gradio-container,footer,.built-with,gradio-app>.main>.contain{display:none!important}
|
| 408 |
+
|
| 409 |
+
.hidden{display:none!important}
|
frontend/dist/assets/nodes.js
CHANGED
|
@@ -1,107 +1,133 @@
|
|
| 1 |
-
/* Bioluminescent node rendering. */
|
| 2 |
-
const TYPE_COLOR = {
|
| 3 |
-
CORE: '#
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
const
|
| 34 |
-
const
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
ctx.
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
ctx.
|
| 74 |
-
ctx.
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
ctx.
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Bioluminescent node rendering — dark-neon theme to match reference images. */
|
| 2 |
+
const TYPE_COLOR = {
|
| 3 |
+
CORE: '#ffb840', // golden seed
|
| 4 |
+
CIVILIZATION: '#ffb840',
|
| 5 |
+
DOMAIN: '#a76bff', // violet
|
| 6 |
+
AGENT: '#ff4fa3', // magenta
|
| 7 |
+
TOOL: '#ff9b3c', // warm orange
|
| 8 |
+
PROJECT: '#19d6ff', // cyan
|
| 9 |
+
LIFE_EVENT: '#5cffae', // green
|
| 10 |
+
EMOTION: '#ff80c4', // pink
|
| 11 |
+
PERSON: '#ff70b8', // pink
|
| 12 |
+
VALUE: '#7e5cff', // violet
|
| 13 |
+
MEMORY: '#00e5c8', // teal
|
| 14 |
+
FACT: '#19d6ff', // cyan
|
| 15 |
+
CONCEPT: '#a76bff',
|
| 16 |
+
QUERY: '#5cffae',
|
| 17 |
+
};
|
| 18 |
+
const DEFAULT_COLOR = '#a76bff';
|
| 19 |
+
|
| 20 |
+
function shade(hex, amt) {
|
| 21 |
+
const c = parseInt(hex.slice(1), 16);
|
| 22 |
+
let r = (c >> 16) + amt, g = ((c >> 8) & 255) + amt, b = (c & 255) + amt;
|
| 23 |
+
r = Math.max(0, Math.min(255, r));
|
| 24 |
+
g = Math.max(0, Math.min(255, g));
|
| 25 |
+
b = Math.max(0, Math.min(255, b));
|
| 26 |
+
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
window.colorFor = (type) => TYPE_COLOR[type] || DEFAULT_COLOR;
|
| 30 |
+
window.TYPE_COLOR = TYPE_COLOR;
|
| 31 |
+
|
| 32 |
+
window.drawNode = function (ctx, n, t, zoom) {
|
| 33 |
+
const ageMs = t - (n.born || t);
|
| 34 |
+
const age = Math.min(1, ageMs / 700);
|
| 35 |
+
const k = age;
|
| 36 |
+
const overshoot = age < 1
|
| 37 |
+
? 1 + 1.7 * (k - 1) ** 3 + 0.7 * (k - 1) ** 2
|
| 38 |
+
: 1;
|
| 39 |
+
|
| 40 |
+
const pulse = 1 + 0.06 * Math.sin((t / 1000) * 0.55 + (n.phase || 0));
|
| 41 |
+
const r = n.radius * pulse * overshoot;
|
| 42 |
+
const x = n.x, y = n.y;
|
| 43 |
+
|
| 44 |
+
ctx.globalAlpha = age;
|
| 45 |
+
|
| 46 |
+
// outer halo (soft glow)
|
| 47 |
+
const halo = ctx.createRadialGradient(x, y, r * 0.9, x, y, r * 2.6);
|
| 48 |
+
halo.addColorStop(0, n.color + 'aa');
|
| 49 |
+
halo.addColorStop(0.5, n.color + '33');
|
| 50 |
+
halo.addColorStop(1, n.color + '00');
|
| 51 |
+
ctx.fillStyle = halo;
|
| 52 |
+
ctx.beginPath(); ctx.arc(x, y, r * 2.6, 0, Math.PI * 2); ctx.fill();
|
| 53 |
+
|
| 54 |
+
// rotating dashed orbital ring (CORE only)
|
| 55 |
+
if (n.type === 'CORE' || n.type === 'CIVILIZATION') {
|
| 56 |
+
ctx.save();
|
| 57 |
+
ctx.translate(x, y);
|
| 58 |
+
ctx.rotate(t * 0.0004);
|
| 59 |
+
ctx.strokeStyle = n.color + '88';
|
| 60 |
+
ctx.lineWidth = 1.5 / zoom;
|
| 61 |
+
ctx.setLineDash([6 / zoom, 6 / zoom]);
|
| 62 |
+
ctx.beginPath(); ctx.arc(0, 0, r * 1.85, 0, Math.PI * 2); ctx.stroke();
|
| 63 |
+
ctx.setLineDash([]);
|
| 64 |
+
ctx.restore();
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// main sphere — neon gradient
|
| 68 |
+
const grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
|
| 69 |
+
grad.addColorStop(0, shade(n.color, 60));
|
| 70 |
+
grad.addColorStop(0.5, n.color);
|
| 71 |
+
grad.addColorStop(1, shade(n.color, -70));
|
| 72 |
+
|
| 73 |
+
ctx.shadowColor = n.color;
|
| 74 |
+
ctx.shadowBlur = 28 * pulse;
|
| 75 |
+
ctx.fillStyle = grad;
|
| 76 |
+
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
| 77 |
+
ctx.shadowBlur = 0;
|
| 78 |
+
|
| 79 |
+
// specular highlight
|
| 80 |
+
ctx.fillStyle = 'rgba(255,255,255,.5)';
|
| 81 |
+
ctx.beginPath();
|
| 82 |
+
ctx.ellipse(x - r * 0.32, y - r * 0.32, r * 0.28, r * 0.17, -0.5, 0, Math.PI * 2);
|
| 83 |
+
ctx.fill();
|
| 84 |
+
|
| 85 |
+
// selection ring
|
| 86 |
+
if (n.selected) {
|
| 87 |
+
ctx.strokeStyle = '#19d6ff';
|
| 88 |
+
ctx.lineWidth = 2.5 / zoom;
|
| 89 |
+
ctx.setLineDash([4 / zoom, 4 / zoom]);
|
| 90 |
+
ctx.beginPath(); ctx.arc(x, y, r + 8 / zoom, 0, Math.PI * 2); ctx.stroke();
|
| 91 |
+
ctx.setLineDash([]);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// counter badge
|
| 95 |
+
if (n.count != null && zoom > 0.5) {
|
| 96 |
+
const bx = x - r * 1.1, by = y - r * 0.8;
|
| 97 |
+
ctx.fillStyle = 'rgba(0,0,0,.7)';
|
| 98 |
+
ctx.strokeStyle = n.color + '88';
|
| 99 |
+
ctx.lineWidth = 1 / zoom;
|
| 100 |
+
if (ctx.roundRect) {
|
| 101 |
+
ctx.beginPath();
|
| 102 |
+
ctx.roundRect(bx - 12, by - 8, 22, 16, 8);
|
| 103 |
+
ctx.fill(); ctx.stroke();
|
| 104 |
+
}
|
| 105 |
+
ctx.fillStyle = '#fff';
|
| 106 |
+
ctx.font = `bold ${10}px Space Mono`;
|
| 107 |
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
| 108 |
+
ctx.fillText(String(n.count), bx - 1, by);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// label
|
| 112 |
+
if (zoom > 0.4 && n.label) {
|
| 113 |
+
const fs = Math.min(13, 11 * Math.max(1, zoom * 0.85));
|
| 114 |
+
// text shadow for legibility
|
| 115 |
+
ctx.fillStyle = 'rgba(0,0,0,.6)';
|
| 116 |
+
ctx.font = `500 ${fs / zoom}px Inter, sans-serif`;
|
| 117 |
+
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
| 118 |
+
const lbl = n.label.length > 24 ? n.label.slice(0, 22) + '…' : n.label;
|
| 119 |
+
ctx.fillText(lbl, x + 1 / zoom, y + r + 7 / zoom);
|
| 120 |
+
ctx.fillStyle = '#d8f6fa';
|
| 121 |
+
ctx.fillText(lbl, x, y + r + 6 / zoom);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// pulse highlight ring (from UI directive)
|
| 125 |
+
if (n.pulseUntil && t < n.pulseUntil) {
|
| 126 |
+
const p = (n.pulseUntil - t) / 1400;
|
| 127 |
+
ctx.strokeStyle = `rgba(25,214,255,${p})`;
|
| 128 |
+
ctx.lineWidth = 2.5 / zoom;
|
| 129 |
+
ctx.beginPath(); ctx.arc(x, y, r + (1 - p) * 36, 0, Math.PI * 2); ctx.stroke();
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
ctx.globalAlpha = 1;
|
| 133 |
+
};
|
frontend/dist/index.html
CHANGED
|
@@ -1,116 +1,155 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
|
| 6 |
-
<title>Elysium</title>
|
| 7 |
-
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ctext y='50' font-size='50'%3E🌿%3C/text%3E%3C/svg%3E">
|
| 8 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Mono:wght@400;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
| 11 |
-
<link rel="stylesheet" href="/assets/elysium.css" />
|
| 12 |
-
</head>
|
| 13 |
-
<body data-alert="CALM">
|
| 14 |
-
|
| 15 |
-
<!-- BACKGROUND CANVAS -->
|
| 16 |
-
<canvas id="elysium-canvas"></canvas>
|
| 17 |
-
|
| 18 |
-
<!-- LEFT TIMELINE -->
|
| 19 |
-
<aside id="timeline">
|
| 20 |
-
<div class="t-edge"></div>
|
| 21 |
-
<div class="years">
|
| 22 |
-
<div class="year">
|
| 23 |
-
<div class="year">
|
| 24 |
-
<div class="year
|
| 25 |
-
<div class="year">
|
| 26 |
-
<div class="year">
|
| 27 |
-
|
| 28 |
-
</
|
| 29 |
-
|
| 30 |
-
<
|
| 31 |
-
<
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
<
|
| 39 |
-
|
| 40 |
-
<
|
| 41 |
-
<
|
| 42 |
-
|
| 43 |
-
<
|
| 44 |
-
<
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
<
|
| 48 |
-
<div
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
<
|
| 53 |
-
|
| 54 |
-
<
|
| 55 |
-
<
|
| 56 |
-
|
| 57 |
-
<
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
<div
|
| 66 |
-
|
| 67 |
-
<div
|
| 68 |
-
|
| 69 |
-
<div
|
| 70 |
-
|
| 71 |
-
<div
|
| 72 |
-
|
| 73 |
-
<div
|
| 74 |
-
|
| 75 |
-
</
|
| 76 |
-
|
| 77 |
-
<
|
| 78 |
-
<
|
| 79 |
-
<
|
| 80 |
-
<
|
| 81 |
-
<
|
| 82 |
-
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
</
|
| 89 |
-
|
| 90 |
-
<
|
| 91 |
-
<
|
| 92 |
-
|
| 93 |
-
<
|
| 94 |
-
<
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
<
|
| 99 |
-
<
|
| 100 |
-
|
| 101 |
-
<
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
<
|
| 106 |
-
|
| 107 |
-
<
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
<
|
| 111 |
-
<
|
| 112 |
-
<
|
| 113 |
-
<
|
| 114 |
-
<
|
| 115 |
-
</
|
| 116 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
|
| 6 |
+
<title>Elysium — Living Civilization</title>
|
| 7 |
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ctext y='50' font-size='50'%3E🌿%3C/text%3E%3C/svg%3E">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Mono:wght@400;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="/assets/elysium.css" />
|
| 12 |
+
</head>
|
| 13 |
+
<body data-alert="CALM" data-busy="0">
|
| 14 |
+
|
| 15 |
+
<!-- BACKGROUND CANVAS (bioluminescent infinite world) -->
|
| 16 |
+
<canvas id="elysium-canvas"></canvas>
|
| 17 |
+
|
| 18 |
+
<!-- LEFT TIMELINE -->
|
| 19 |
+
<aside id="timeline">
|
| 20 |
+
<div class="t-edge"></div>
|
| 21 |
+
<div class="years">
|
| 22 |
+
<div class="year">2022</div>
|
| 23 |
+
<div class="year">2023</div>
|
| 24 |
+
<div class="year">2024</div>
|
| 25 |
+
<div class="year">2025</div>
|
| 26 |
+
<div class="year-active">2026</div>
|
| 27 |
+
<div class="year">2027</div>
|
| 28 |
+
<div class="year">2028</div>
|
| 29 |
+
<div class="year">2029</div>
|
| 30 |
+
<div class="year">2030</div>
|
| 31 |
+
</div>
|
| 32 |
+
</aside>
|
| 33 |
+
|
| 34 |
+
<!-- LEGEND PANEL (top right) -->
|
| 35 |
+
<section id="legend" class="glass">
|
| 36 |
+
<div class="legend-header">LEGEND <span id="legend-help" title="Click a swatch to filter by type">?</span></div>
|
| 37 |
+
<div id="legend-items"></div>
|
| 38 |
+
<div class="meta-row" title="Mycelium density — how interconnected the civilization is (edges per node)">
|
| 39 |
+
<span>🕸 Density</span><span id="m-density" class="meta-val">0%</span>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="meta-row" title="Coherence — inverse of cognitive strain. Higher = clearer thought.">
|
| 42 |
+
<span>✦ Coherence</span><span id="m-coherence" class="meta-val">70%</span>
|
| 43 |
+
</div>
|
| 44 |
+
</section>
|
| 45 |
+
|
| 46 |
+
<!-- CIVILIZATION MAP (minimap, fully interactive) -->
|
| 47 |
+
<div id="minimap-wrap">
|
| 48 |
+
<div class="minimap-label">CIVILIZATION MAP</div>
|
| 49 |
+
<canvas id="minimap"></canvas>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- ZOOM CONTROLS -->
|
| 53 |
+
<div id="zoom-ctrl">
|
| 54 |
+
<button id="z-in" title="Zoom in">+</button>
|
| 55 |
+
<button id="z-fit" title="Fit all">⊡</button>
|
| 56 |
+
<button id="z-out" title="Zoom out">−</button>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<!-- ALERT SCRIM (overlay tint for alert levels) -->
|
| 60 |
+
<div id="alert-scrim"></div>
|
| 61 |
+
|
| 62 |
+
<!-- STATS BAR — REPLACED with meaningful civilization metrics -->
|
| 63 |
+
<section id="stats" class="glass">
|
| 64 |
+
<div class="stat" title="Total nodes in your civilization mycelium">
|
| 65 |
+
<div class="stat-icon">🌐</div>
|
| 66 |
+
<div><div class="stat-label">Mycelium Nodes</div><div class="stat-value" id="s-nodes">1</div></div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="stat" title="Total mycelium threads connecting concepts">
|
| 69 |
+
<div class="stat-icon">⌬</div>
|
| 70 |
+
<div><div class="stat-label">Threads</div><div class="stat-value" id="s-edges">0</div></div>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="stat" title="Council agents active in this turn">
|
| 73 |
+
<div class="stat-icon">👁</div>
|
| 74 |
+
<div><div class="stat-label">Council Active</div><div class="stat-value" id="s-council">0</div></div>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="stat" title="Knowledge nodes grown in the last interaction">
|
| 77 |
+
<div class="stat-icon">✦</div>
|
| 78 |
+
<div><div class="stat-label">Growth (Δ)</div><div class="stat-value" id="s-growth">0</div></div>
|
| 79 |
+
</div>
|
| 80 |
+
<div class="stat" title="Minutes since this civilization awoke">
|
| 81 |
+
<div class="stat-icon">⏳</div>
|
| 82 |
+
<div><div class="stat-label">Age (min)</div><div class="stat-value" id="s-age">0</div></div>
|
| 83 |
+
</div>
|
| 84 |
+
</section>
|
| 85 |
+
|
| 86 |
+
<!-- QUERY BAR with FILE PREVIEW STRIP above it -->
|
| 87 |
+
<section id="query-area">
|
| 88 |
+
<div id="attach-strip" class="glass hidden"></div>
|
| 89 |
+
<div id="query-bar" class="glass">
|
| 90 |
+
<label id="q-upload" title="Attach up to 2 images / PDFs">
|
| 91 |
+
<input type="file" id="q-file" accept="image/*,application/pdf" hidden multiple />
|
| 92 |
+
<span class="paperclip">📎</span>
|
| 93 |
+
</label>
|
| 94 |
+
<input id="q-input" placeholder="Speak to your civilization seed…" autocomplete="off"/>
|
| 95 |
+
<button id="q-send" title="Send">
|
| 96 |
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
| 97 |
+
</button>
|
| 98 |
+
</div>
|
| 99 |
+
<div class="mode-row">
|
| 100 |
+
<button id="my-agent" title="Open Council overlay">
|
| 101 |
+
<span class="dot-mini"></span> My Agents <span class="badge" id="agent-count">+0</span>
|
| 102 |
+
</button>
|
| 103 |
+
<button id="ri-analysis" title="Fit-to-view + show health analysis">
|
| 104 |
+
✨ RI Analysis
|
| 105 |
+
</button>
|
| 106 |
+
</div>
|
| 107 |
+
</section>
|
| 108 |
+
|
| 109 |
+
<!-- COUNCIL OVERLAY (slide-in left panel, minimizable) -->
|
| 110 |
+
<section id="council-overlay" class="glass hidden">
|
| 111 |
+
<div class="co-head">
|
| 112 |
+
<div class="co-title">
|
| 113 |
+
<span class="co-pulse"></span>
|
| 114 |
+
COUNCIL <span id="co-count">+0</span>
|
| 115 |
+
</div>
|
| 116 |
+
<div class="co-controls">
|
| 117 |
+
<button id="co-play" title="Play all agents">▶</button>
|
| 118 |
+
<button id="co-pause" title="Pause" disabled>⏸</button>
|
| 119 |
+
<button id="co-min" title="Minimize">—</button>
|
| 120 |
+
<button id="co-close" title="Close">×</button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<div id="co-body"></div>
|
| 124 |
+
<div id="co-synth"></div>
|
| 125 |
+
<audio id="debate-audio" preload="auto"></audio>
|
| 126 |
+
</section>
|
| 127 |
+
|
| 128 |
+
<!-- COUNCIL MINIMIZED PILL -->
|
| 129 |
+
<button id="council-pill" class="glass hidden" title="Open council">
|
| 130 |
+
<span class="dot-mini"></span><span id="pill-count">0</span> agents
|
| 131 |
+
</button>
|
| 132 |
+
|
| 133 |
+
<!-- NODE DETAIL POPOVER (full explanation, like image 4) -->
|
| 134 |
+
<section id="node-detail" class="glass hidden"></section>
|
| 135 |
+
|
| 136 |
+
<!-- TOAST NOTIFICATIONS -->
|
| 137 |
+
<div id="toasts"></div>
|
| 138 |
+
|
| 139 |
+
<!-- INITIAL HINT (vanishes after first turn) -->
|
| 140 |
+
<div id="seed-hint">Ask anything to awaken your civilization</div>
|
| 141 |
+
|
| 142 |
+
<!-- BUSY OVERLAY: blocks input while model is thinking -->
|
| 143 |
+
<div id="busy-overlay" class="hidden">
|
| 144 |
+
<div class="busy-spin"></div>
|
| 145 |
+
<div class="busy-text">Council deliberating…</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<script src="/assets/api.js"></script>
|
| 149 |
+
<script src="/assets/nodes.js"></script>
|
| 150 |
+
<script src="/assets/canvas.js"></script>
|
| 151 |
+
<script src="/assets/council.js"></script>
|
| 152 |
+
<script src="/assets/boot.js"></script>
|
| 153 |
+
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v833ccba57c9e4d2798f2e76cebdd09a11778172276447" integrity="sha512-57MDmcccJXYtNnH+ZiBwzC4jb2rvgVCEokYN+L/nLlmO8rfYT/gIpW2A569iJ/3b+0UEasghjuZH/ma3wIs/EQ==" data-cf-beacon='{"version":"2024.11.0","token":"4edd5f8ec12a48cfa682ab8261b80a79","server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}' crossorigin="anonymous"></script>
|
| 154 |
+
</body>
|
| 155 |
+
</html>
|