pmrinal2005 commited on
Commit
071e3db
·
verified ·
1 Parent(s): 0cebb30

Upload folder using huggingface_hub

Browse files
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 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,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
- If vision is available (mmproj loaded), images are embedded as data URIs in
4
- a multimodal `content` list. Otherwise, we fall back to a text-only message
5
- so the model never errors out on image input.
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 agent_outputs,
23
- each with thinking + stance + tts_speech_text + tts_voice_design{voice_id,pace,tone}
 
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 build_messages(user_text: str, image: Optional[Image.Image], hg_context: str = ""):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ctx = f"\n\n[Hypergraph context]\n{hg_context}" if hg_context else ""
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  # Vision available → multimodal content list
41
- if image is not None and MMPROJ_PATH:
42
- user_content = [
43
- {"type": "image_url", "image_url": {"url": _img_to_data_uri(image)}},
44
- {"type": "text", "text": user_text + ctx},
45
- ]
 
 
 
 
 
 
46
  return [
47
  {"role": "system", "content": SYSTEM_PROMPT},
48
  {"role": "user", "content": user_content},
49
  ]
50
 
51
- # No vision plain text. If user attached an image, note it textually.
52
  note = ""
53
- if image is not None and not MMPROJ_PATH:
54
- note = "\n\n[Note: user attached an image but the vision projector is not loaded; respond based on text only.]"
 
 
 
55
 
56
  return [
57
  {"role": "system", "content": SYSTEM_PROMPT},
58
- {"role": "user", "content": user_text + ctx + note},
 
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. The frontend talks ONLY to /api/*."""
2
- import io, json, traceback, base64
3
- from PIL import Image
4
- from fastapi import UploadFile, File, Form, HTTPException
5
- from fastapi.responses import JSONResponse, FileResponse
6
- from fastapi.staticfiles import StaticFiles
7
-
8
- import spaces
9
-
10
- from .config import AUDIO_CACHE
11
- from .model_loader import make_llm
12
- from .grammar import load_grammar
13
- from .prompt_builder import build_messages, new_session_meta
14
- from .schema import ElysiumEnvelope, ElysiumResponse
15
- from .hypergraph import persistence
16
- from .hypergraph.engine import Hypergraph
17
- from .tools.dispatcher import execute_all
18
- from .tts.debate_sequencer import build_debate
19
-
20
-
21
- # ─── Singletons ───
22
- HG: Hypergraph = persistence.load()
23
- GRAMMAR = load_grammar()
24
-
25
-
26
- # ─── GPU-bound inference ───
27
- @spaces.GPU(duration=120)
28
- def _gpu_infer(messages: list, max_tokens: int = 4096) -> str:
29
- llm = make_llm()
30
- out = llm.create_chat_completion(
31
- messages=messages,
32
- max_tokens=max_tokens,
33
- temperature=0.7,
34
- grammar=GRAMMAR, # strict JSON at sampling time
35
- )
36
- return out["choices"][0]["message"]["content"]
37
-
38
-
39
- def _fallback_envelope(user_text: str, err: str) -> dict:
40
- meta = new_session_meta()
41
- resp = ElysiumResponse(
42
- session_id=meta["session_id"],
43
- timestamp_utc=meta["timestamp_utc"],
44
- interaction_type="SIMPLE_REPLY",
45
- direct_answer=f"(fallback) {err}",
46
- )
47
- return {"user_msg": user_text, "elysium_response": resp.model_dump()}
48
-
49
-
50
- def attach(app):
51
- """Register all /api routes on the gr.Server FastAPI app."""
52
-
53
- # mount /audio for generated debate wavs
54
- app.mount("/audio", StaticFiles(directory=str(AUDIO_CACHE)), name="audio")
55
-
56
- @app.get("/api/health")
57
- async def health():
58
- return {"status": "ok",
59
- "nodes": HG.node_count(),
60
- "edges": HG.edge_count(),
61
- "grammar": GRAMMAR is not None}
62
-
63
- @app.get("/api/hypergraph")
64
- async def hypergraph():
65
- nodes, edges = [], []
66
- for i in HG.g.node_indexes():
67
- d = HG.g[i]
68
- nodes.append({"node_id": d["node_id"], "label": d["label"],
69
- "node_type": d["node_type"], "payload": d.get("payload", {})})
70
- for s, t in HG.g.edge_list():
71
- d = HG.g.get_edge_data(s, t)
72
- edges.append({"edge_id": d["edge_id"],
73
- "source_node_id": HG.g[s]["node_id"],
74
- "target_node_id": HG.g[t]["node_id"],
75
- "edge_type": d["edge_type"], "weight": d["weight"]})
76
- return {"nodes": nodes, "edges": edges,
77
- "node_count": HG.node_count(), "edge_count": HG.edge_count()}
78
-
79
- @app.post("/api/turn")
80
- async def turn(user_text: str = Form(""), image: UploadFile = File(None)):
81
- try:
82
- # 1. Load image if present
83
- img = None
84
- if image is not None:
85
- content = await image.read()
86
- if content:
87
- try:
88
- img = Image.open(io.BytesIO(content))
89
- except Exception:
90
- img = None
91
-
92
- # 2. Build messages with hypergraph context
93
- messages = build_messages(user_text, img, HG.context_summary())
94
-
95
- # 3. GPU inference (returns strict JSON)
96
- raw = _gpu_infer(messages)
97
-
98
- # 4. Parse
99
- try:
100
- envelope = ElysiumEnvelope.model_validate_json(raw)
101
- except Exception as parse_err:
102
- # try to extract any JSON object from raw
103
- try:
104
- blob = json.loads(raw)
105
- if "elysium_response" not in blob:
106
- # wrap as direct_answer
107
- meta = new_session_meta()
108
- envelope = ElysiumEnvelope(
109
- user_msg=user_text,
110
- elysium_response=ElysiumResponse(
111
- session_id=meta["session_id"],
112
- timestamp_utc=meta["timestamp_utc"],
113
- interaction_type="SIMPLE_REPLY",
114
- direct_answer=str(blob)[:600]))
115
- else:
116
- envelope = ElysiumEnvelope.model_validate(blob)
117
- except Exception:
118
- return JSONResponse(_fallback_envelope(user_text, f"parse_error: {parse_err}"))
119
-
120
- resp = envelope.elysium_response
121
-
122
- # 5. Apply hypergraph delta
123
- HG.apply_delta(resp.hypergraph_delta)
124
- persistence.save(HG)
125
-
126
- # 6. Execute tools
127
- tool_results = execute_all(resp.tool_calls) if resp.tool_calls else []
128
-
129
- # 7. Build audio drama if needed
130
- audio_url = None
131
- if resp.council_deliberation.debate_mode == "AUDIO_DRAMA" \
132
- and resp.council_deliberation.agent_outputs:
133
- try:
134
- audio_url = build_debate(
135
- [a.model_dump() for a in resp.council_deliberation.agent_outputs]
136
- )
137
- except Exception as e:
138
- print(f"[tts] debate failed: {e}")
139
-
140
- payload = envelope.model_dump()
141
- payload["_runtime"] = {
142
- "tool_results": tool_results,
143
- "audio_url": audio_url,
144
- "hypergraph": {"nodes": HG.node_count(), "edges": HG.edge_count()},
145
- }
146
- return JSONResponse(payload)
147
-
148
- except Exception as e:
149
- traceback.print_exc()
150
- return JSONResponse(_fallback_envelope(user_text, str(e)), status_code=200)
151
-
152
- @app.post("/api/reset")
153
- async def reset():
154
- """Wipe the hypergraph — start a new civilization."""
155
- global HG
156
- HG = Hypergraph()
157
- persistence.save(HG)
158
- return {"status": "reset"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 multiple agent utterances into one debate audio drama."""
2
- import uuid
3
- from pathlib import Path
4
- from pydub import AudioSegment
5
- from ..config import AUDIO_CACHE
6
- from .voxcpm_engine import synthesize
7
-
8
-
9
- def build_debate(agent_outputs: list) -> str | None:
10
- """Returns a server-relative path under /audio/... for the frontend."""
11
- if not agent_outputs:
12
- return None
13
- track = AudioSegment.silent(duration=300)
14
- for ao in agent_outputs:
15
- text = ao.get("tts_speech_text", "")
16
- voice = ao.get("tts_voice_design", {}) or {}
17
- wav_path = synthesize(text, voice)
18
- try:
19
- seg = AudioSegment.from_wav(wav_path)
20
- track += seg + AudioSegment.silent(duration=400)
21
- except Exception:
22
- continue
23
- out_path = AUDIO_CACHE / f"debate_{uuid.uuid4().hex}.wav"
24
- track.export(out_path, format="wav")
25
- # Return /audio/<filename> for the static mount
26
- return f"/audio/{out_path.name}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- window.ElysiumAPI = {
3
- async turn(text, file) {
4
- const fd = new FormData();
5
- fd.append('user_text', text);
6
- if (file) fd.append('image', file);
7
- const r = await fetch('/api/turn', { method: 'POST', body: fd });
8
- return r.json();
9
- },
10
- async health() { return (await fetch('/api/health')).json(); },
11
- async hypergraph() { return (await fetch('/api/hypergraph')).json(); },
12
- async reset() { return (await fetch('/api/reset', { method: 'POST' })).json(); },
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: wire input /api/turn → update canvas + council. */
2
- (() => {
3
- const input = document.getElementById('q-input');
4
- const send = document.getElementById('q-send');
5
- const file = document.getElementById('q-file');
6
- const seedHint = document.getElementById('seed-hint');
7
-
8
- function toast(msg, kind = '') {
9
- const t = document.createElement('div');
10
- t.className = 'toast ' + kind; t.textContent = msg;
11
- document.getElementById('toasts').appendChild(t);
12
- setTimeout(() => t.remove(), 4500);
13
- }
14
-
15
- // Restore hypergraph on load
16
- async function restore() {
17
- try {
18
- const h = await ElysiumAPI.hypergraph();
19
- (h.nodes || []).forEach(n => window.elysiumAddNode(n));
20
- (h.edges || []).forEach(e => window.elysiumAddEdge(e));
21
- if ((h.nodes || []).length > 1) seedHint.classList.add('hidden');
22
- updateLegend();
23
- document.getElementById('s-engage').textContent = h.node_count || 0;
24
- document.getElementById('s-laws').textContent = h.edge_count || 0;
25
- } catch(e) { /* offline first-boot */ }
26
- }
27
- restore();
28
-
29
- // Wire send
30
- let busy = false;
31
- async function submit() {
32
- if (busy) return;
33
- const text = input.value.trim();
34
- if (!text && !file.files.length) return;
35
- busy = true; send.classList.add('loading'); send.textContent = '◐';
36
- seedHint.classList.add('hidden');
37
- input.value = ''; input.placeholder = 'Council deliberating…';
38
- try {
39
- const data = await ElysiumAPI.turn(text, file.files[0]);
40
- file.value = '';
41
- handleResponse(data);
42
- } catch(e) {
43
- toast('Inference failed: ' + e.message, 'error');
44
- } finally {
45
- busy = false; send.classList.remove('loading'); send.textContent = '→';
46
- input.placeholder = 'Speak to your council…';
47
- }
48
- }
49
- send.onclick = submit;
50
- input.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
51
-
52
- function handleResponse(payload) {
53
- const resp = payload.elysium_response || {};
54
- const rt = payload._runtime || {};
55
- // 1. apply hypergraph delta
56
- const delta = resp.hypergraph_delta || {};
57
- (delta.nodes_added || []).forEach(n => window.elysiumAddNode(n));
58
- (delta.edges_added || []).forEach(e => window.elysiumAddEdge(e));
59
-
60
- // 2. UI directives
61
- const ui = resp.ui_directives || {};
62
- (ui.bioluminescence_pulse_nodes || []).forEach(id => window.elysiumPulse(id, 1400));
63
- document.body.dataset.alert = ui.alert_level || 'CALM';
64
- if (ui.camera_focus_node_id) window.elysiumFocus(ui.camera_focus_node_id);
65
-
66
- // 3. council
67
- window.renderCouncil(resp, rt);
68
-
69
- // 4. stats
70
- const meta = resp.metadata || {};
71
- document.getElementById('s-engage').textContent = meta.hypergraph_node_count ?? '0';
72
- document.getElementById('s-laws').textContent = meta.hypergraph_edge_count ?? '0';
73
- document.getElementById('s-impl').textContent =
74
- Math.round((1 - (resp.strain_metadata?.cognitive_strain ?? 0.3)) * 100) + '%';
75
- document.getElementById('s-health').textContent = `${(rt.hypergraph?.nodes ?? 0)}`;
76
-
77
- // 5. agent count
78
- const ag = (resp.council_deliberation?.agent_outputs || []).length;
79
- document.getElementById('agent-count').textContent = `+${ag}`;
80
-
81
- // 6. legend
82
- updateLegend();
83
-
84
- // 7. tool toasts
85
- (rt.tool_results || []).forEach(tr => {
86
- const ok = tr.result && !tr.result.error;
87
- toast(`🔧 ${tr.tool_name}: ${ok ? 'ok' : (tr.result?.error || 'offline')}`,
88
- ok ? '' : 'warn');
89
- });
90
-
91
- // 8. direct answer toast if no agents
92
- if (!ag && resp.direct_answer) toast(resp.direct_answer);
93
- }
94
-
95
- function updateLegend() {
96
- const counts = {};
97
- window.ELYSIUM.nodes.forEach(n => counts[n.type] = (counts[n.type] || 0) + 1);
98
- const order = ['CORE','DOMAIN','AGENT','TOOL','PROJECT','LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY'];
99
- const items = order.filter(t => counts[t]).map(t => {
100
- const c = window.colorFor(t);
101
- return `<div class="legend-item" data-type="${t}">
102
- <span class="dot" style="background:${c};color:${c}"></span>
103
- <span class="badge">${counts[t]}</span>
104
- <span class="lbl">${t.replace('_',' ').toLowerCase()}</span>
105
- </div>`;
106
- }).join('');
107
- document.getElementById('legend-items').innerHTML = items;
108
- document.getElementById('legend-gaps').textContent =
109
- Math.min(99, Math.round((1 - (counts.AGENT || 0) / Math.max(1, counts.DOMAIN || 1)) * 50)) + '%';
110
- document.getElementById('legend-updates').textContent =
111
- Math.min(99, window.ELYSIUM.nodes.size * 3) + '%';
112
- }
113
-
114
- // RI Analysis = fit all + show health
115
- document.getElementById('ri-analysis').onclick = () => {
116
- window.elysiumFitAll();
117
- toast('🔍 RI Analysis: civilization-wide view');
118
- };
119
- document.getElementById('my-agent').onclick = () => {
120
- toast(`🤖 Active agents: ${document.getElementById('agent-count').textContent}`);
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
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
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
- const canvas = document.getElementById('elysium-canvas');
4
- const ctx = canvas.getContext('2d', { alpha: false });
5
-
6
- const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
7
- function resize() {
8
- canvas.width = innerWidth * DPR;
9
- canvas.height = innerHeight * DPR;
10
- canvas.style.width = innerWidth + 'px';
11
- canvas.style.height = innerHeight + 'px';
12
- }
13
- resize(); addEventListener('resize', resize);
14
-
15
- // World state
16
- const E = window.ELYSIUM = {
17
- cam: { x: 0, y: 0, z: 1, tx: 0, ty: 0, tz: 1, vx: 0, vy: 0, drag: false },
18
- nodes: new Map(), edges: [], spawning: [],
19
- hover: null, selected: null,
20
- };
21
-
22
- // Seed CORE
23
- E.nodes.set('CORE', {
24
- node_id: 'CORE', x: 0, y: 0, type: 'CORE', label: 'ELYSIUM',
25
- radius: 34, color: '#c8a44a', phase: 0, born: performance.now() - 1000,
26
- });
27
-
28
- // ──────── PAN / ZOOM ────────
29
- let lastX = 0, lastY = 0;
30
- function clientToWorld(cx, cy) {
31
- return {
32
- x: (cx - innerWidth / 2) / E.cam.z + E.cam.x,
33
- y: (cy - innerHeight / 2) / E.cam.z + E.cam.y,
34
- };
35
- }
36
-
37
- canvas.addEventListener('pointerdown', e => {
38
- // hit-test a node first
39
- const w = clientToWorld(e.clientX, e.clientY);
40
- let hit = null;
41
- E.nodes.forEach(n => {
42
- if (Math.hypot(n.x - w.x, n.y - w.y) < n.radius * 1.15) hit = n;
43
- });
44
- if (hit) { showNodeDetail(hit, e.clientX, e.clientY); return; }
45
- E.cam.drag = true; canvas.classList.add('dragging');
46
- lastX = e.clientX; lastY = e.clientY;
47
- canvas.setPointerCapture(e.pointerId);
48
- });
49
- canvas.addEventListener('pointermove', e => {
50
- if (!E.cam.drag) {
51
- const w = clientToWorld(e.clientX, e.clientY);
52
- let hover = null;
53
- E.nodes.forEach(n => { if (Math.hypot(n.x - w.x, n.y - w.y) < n.radius * 1.15) hover = n; });
54
- E.hover = hover; canvas.style.cursor = hover ? 'pointer' : '';
55
- return;
56
- }
57
- const dx = (e.clientX - lastX) / E.cam.z;
58
- const dy = (e.clientY - lastY) / E.cam.z;
59
- E.cam.x -= dx; E.cam.y -= dy;
60
- E.cam.vx = dx * 0.85; E.cam.vy = dy * 0.85;
61
- E.cam.tx = E.cam.x; E.cam.ty = E.cam.y;
62
- lastX = e.clientX; lastY = e.clientY;
63
- });
64
- function endPan(e){ E.cam.drag = false; canvas.classList.remove('dragging');
65
- try { canvas.releasePointerCapture(e.pointerId); } catch{} }
66
- canvas.addEventListener('pointerup', endPan);
67
- canvas.addEventListener('pointercancel', endPan);
68
-
69
- // wheel zoom
70
- canvas.addEventListener('wheel', e => {
71
- e.preventDefault();
72
- const factor = e.deltaY > 0 ? 0.9 : 1.1;
73
- const w = clientToWorld(e.clientX, e.clientY);
74
- E.cam.tz = Math.max(0.15, Math.min(4, E.cam.z * factor));
75
- E.cam.tx = w.x - (e.clientX - innerWidth / 2) / E.cam.tz;
76
- E.cam.ty = w.y - (e.clientY - innerHeight / 2) / E.cam.tz;
77
- }, { passive: false });
78
-
79
- // double-click zoom-in
80
- canvas.addEventListener('dblclick', e => {
81
- const w = clientToWorld(e.clientX, e.clientY);
82
- E.cam.tz = Math.min(4, E.cam.z * 1.6);
83
- E.cam.tx = w.x; E.cam.ty = w.y;
84
- });
85
-
86
- // ──────── TOUCH (pinch) ────────
87
- let touchDist = 0, touchMid = null;
88
- canvas.addEventListener('touchstart', e => {
89
- if (e.touches.length === 2) {
90
- e.preventDefault();
91
- const [a, b] = e.touches;
92
- touchDist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
93
- touchMid = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
94
- }
95
- }, { passive: false });
96
- canvas.addEventListener('touchmove', e => {
97
- if (e.touches.length === 2 && touchDist) {
98
- e.preventDefault();
99
- const [a, b] = e.touches;
100
- const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
101
- const factor = d / touchDist; touchDist = d;
102
- const w = clientToWorld(touchMid.x, touchMid.y);
103
- E.cam.tz = Math.max(0.15, Math.min(4, E.cam.z * factor));
104
- E.cam.tx = w.x - (touchMid.x - innerWidth / 2) / E.cam.tz;
105
- E.cam.ty = w.y - (touchMid.y - innerHeight / 2) / E.cam.tz;
106
- }
107
- }, { passive: false });
108
- canvas.addEventListener('touchend', () => { touchDist = 0; });
109
-
110
- // ──────── ZOOM BTNS ────────
111
- document.getElementById('z-in').onclick = () => E.cam.tz = Math.min(4, E.cam.z * 1.35);
112
- document.getElementById('z-out').onclick = () => E.cam.tz = Math.max(0.15, E.cam.z / 1.35);
113
- document.getElementById('z-fit').onclick = fitAll;
114
-
115
- function fitAll() {
116
- if (E.nodes.size === 0) return;
117
- let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
118
- E.nodes.forEach(n => { mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
119
- mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y); });
120
- const pad = 180;
121
- E.cam.tx = (mnX + mxX) / 2;
122
- E.cam.ty = (mnY + mxY) / 2;
123
- E.cam.tz = Math.min(4, Math.max(0.25,
124
- Math.min(innerWidth / (mxX - mnX + pad), innerHeight / (mxY - mnY + pad))));
125
- }
126
- window.elysiumFitAll = fitAll;
127
-
128
- // ──────── MINIMAP ────────
129
- const mini = document.getElementById('minimap');
130
- const mctx = mini.getContext('2d');
131
- function drawMinimap() {
132
- mini.width = 160; mini.height = 100;
133
- mctx.fillStyle = 'rgba(8,24,30,.95)';
134
- mctx.fillRect(0, 0, 160, 100);
135
- if (E.nodes.size === 0) return;
136
- let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
137
- E.nodes.forEach(n => { mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
138
- mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y); });
139
- const pad = 80; mnX -= pad; mnY -= pad; mxX += pad; mxY += pad;
140
- const sx = 160 / (mxX - mnX), sy = 100 / (mxY - mnY);
141
- // edges
142
- mctx.strokeStyle = 'rgba(0,200,160,.18)'; mctx.lineWidth = .5;
143
- E.edges.forEach(ed => {
144
- const s = E.nodes.get(ed.src), t = E.nodes.get(ed.dst);
145
- if (!s || !t) return;
146
- mctx.beginPath();
147
- mctx.moveTo((s.x - mnX) * sx, (s.y - mnY) * sy);
148
- mctx.lineTo((t.x - mnX) * sx, (t.y - mnY) * sy);
149
- mctx.stroke();
150
- });
151
- // nodes
152
- E.nodes.forEach(n => {
153
- mctx.fillStyle = n.color;
154
- mctx.beginPath(); mctx.arc((n.x - mnX) * sx, (n.y - mnY) * sy, 2.2, 0, Math.PI * 2);
155
- mctx.fill();
156
- });
157
- // viewport rect
158
- const vx = (E.cam.x - innerWidth / 2 / E.cam.z - mnX) * sx;
159
- const vy = (E.cam.y - innerHeight / 2 / E.cam.z - mnY) * sy;
160
- const vw = innerWidth / E.cam.z * sx, vh = innerHeight / E.cam.z * sy;
161
- mctx.strokeStyle = 'rgba(0,229,255,.7)'; mctx.lineWidth = 1;
162
- mctx.setLineDash([3, 3]);
163
- mctx.strokeRect(vx, vy, vw, vh);
164
- mctx.setLineDash([]);
165
- }
166
- // minimap click → pan
167
- mini.addEventListener('click', e => {
168
- if (E.nodes.size === 0) return;
169
- const rect = mini.getBoundingClientRect();
170
- const fx = (e.clientX - rect.left) / rect.width;
171
- const fy = (e.clientY - rect.top) / rect.height;
172
- let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
173
- E.nodes.forEach(n => { mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
174
- mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y); });
175
- const pad = 80; mnX -= pad; mnY -= pad; mxX += pad; mxY += pad;
176
- E.cam.tx = mnX + fx * (mxX - mnX);
177
- E.cam.ty = mnY + fy * (mxY - mnY);
178
- });
179
-
180
- // ──────── NODE DETAIL POPOVER ────────
181
- function showNodeDetail(n, sx, sy) {
182
- const el = document.getElementById('node-detail');
183
- el.style.left = Math.min(innerWidth - 300, sx + 16) + 'px';
184
- el.style.top = Math.min(innerHeight - 200, sy) + 'px';
185
- el.innerHTML = `
186
- <div class="nd-title">
187
- <span style="width:14px;height:14px;border-radius:50%;background:${n.color};
188
- box-shadow:0 0 8px ${n.color}"></span>${n.label || n.node_id}</div>
189
- <div class="nd-type">${n.type}</div>
190
- <div class="nd-stats">
191
- <div class="nd-pill"><b>${(Math.random()*40+10|0)}</b>activity</div>
192
- <div class="nd-pill"><b>${(Math.random()*70+10|0)}%</b>weight</div>
193
- <div class="nd-pill"><b>${(Math.random()*30+5|0)}</b>links</div>
194
- </div>
195
- <div style="font-size:11px;color:var(--muted);line-height:1.5">
196
- Node connected to your civilization mycelium. Pan and zoom to explore.
197
- </div>`;
198
- el.classList.add('show');
199
- clearTimeout(showNodeDetail._t);
200
- showNodeDetail._t = setTimeout(() => el.classList.remove('show'), 4500);
201
- }
202
-
203
- // ──────── PUBLIC API ────────
204
- const SPAWN_RADIUS = 280;
205
- window.elysiumAddNode = function (node, parentId = 'CORE') {
206
- if (E.nodes.has(node.node_id)) return;
207
- const parent = E.nodes.get(parentId) || E.nodes.get('CORE') || { x: 0, y: 0 };
208
- const idx = E.nodes.size;
209
- const angle = idx * 0.7 + Math.random() * 0.6 - 0.3;
210
- const dist = SPAWN_RADIUS + (idx * 14) % 180;
211
- const tx = parent.x + Math.cos(angle) * dist;
212
- const ty = parent.y + Math.sin(angle) * dist;
213
- const type = node.node_type || 'DOMAIN';
214
- const radius = type === 'CORE' ? 34 :
215
- type === 'AGENT' ? 22 :
216
- type === 'TOOL' ? 14 : 18;
217
- E.nodes.set(node.node_id, {
218
- node_id: node.node_id,
219
- x: parent.x, y: parent.y, tx, ty,
220
- type, label: node.label || node.node_id,
221
- radius, color: window.colorFor(type),
222
- phase: Math.random() * Math.PI * 2,
223
- born: performance.now(),
224
- spawnFrom: parentId,
225
- });
226
- // auto-pan a little toward new node
227
- E.cam.tx = E.cam.x * 0.85 + tx * 0.15;
228
- E.cam.ty = E.cam.y * 0.85 + ty * 0.15;
229
- };
230
-
231
- window.elysiumAddEdge = function (edge) {
232
- E.edges.push({
233
- src: edge.source_node_id, dst: edge.target_node_id,
234
- type: edge.edge_type, weight: edge.weight ?? 0.5,
235
- color: edge.edge_type === 'CONFLICT' ? 'rgba(220,80,80,.55)' :
236
- edge.edge_type === 'COALITION' ? 'rgba(120,190,255,.6)' :
237
- edge.edge_type === 'CAUSAL' ? 'rgba(0,229,255,.45)' :
238
- 'rgba(200,164,74,.5)',
239
- born: performance.now(), dashOffset: 0,
240
- });
241
- };
242
-
243
- window.elysiumPulse = function (nodeId, ms = 1200) {
244
- const n = E.nodes.get(nodeId); if (n) n.pulseUntil = performance.now() + ms;
245
- };
246
-
247
- window.elysiumFocus = function (nodeId) {
248
- const n = E.nodes.get(nodeId); if (!n) return;
249
- E.cam.tx = n.x; E.cam.ty = n.y;
250
- E.cam.tz = Math.max(1, E.cam.z);
251
- };
252
-
253
- // ──────── RENDER LOOP ────────
254
- function lerp(a, b, t) { return a + (b - a) * t; }
255
- function loop(t) {
256
- // animate spawning nodes toward target
257
- E.nodes.forEach(n => {
258
- if (n.tx != null) {
259
- n.x = lerp(n.x, n.tx, 0.12);
260
- n.y = lerp(n.y, n.ty, 0.12);
261
- if (Math.abs(n.x - n.tx) < 0.3 && Math.abs(n.y - n.ty) < 0.3) {
262
- n.x = n.tx; n.y = n.ty; n.tx = null; n.ty = null;
263
- }
264
- }
265
- });
266
- // smooth camera
267
- E.cam.z = lerp(E.cam.z, E.cam.tz, 0.10);
268
- if (!E.cam.drag) {
269
- E.cam.x = lerp(E.cam.x, E.cam.tx, 0.12);
270
- E.cam.y = lerp(E.cam.y, E.cam.ty, 0.12);
271
- E.cam.x -= E.cam.vx; E.cam.y -= E.cam.vy;
272
- E.cam.vx *= 0.92; E.cam.vy *= 0.92;
273
- if (Math.abs(E.cam.vx) < 0.05) E.cam.vx = 0;
274
- if (Math.abs(E.cam.vy) < 0.05) E.cam.vy = 0;
275
- }
276
-
277
- // background haze
278
- ctx.fillStyle = '#0a1a20';
279
- ctx.fillRect(0, 0, canvas.width, canvas.height);
280
- const grad = ctx.createRadialGradient(canvas.width/2, canvas.height/2, 0,
281
- canvas.width/2, canvas.height/2, canvas.width*0.55);
282
- grad.addColorStop(0, 'rgba(10,48,64,.22)');
283
- grad.addColorStop(1, 'rgba(6,14,18,0)');
284
- ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height);
285
-
286
- // particles (subtle ambient)
287
- drawAmbient(ctx, t);
288
-
289
- // world transform
290
- ctx.save();
291
- ctx.scale(DPR, DPR);
292
- ctx.translate(innerWidth / 2, innerHeight / 2);
293
- ctx.scale(E.cam.z, E.cam.z);
294
- ctx.translate(-E.cam.x, -E.cam.y);
295
-
296
- // edges
297
- E.edges.forEach(e => {
298
- const s = E.nodes.get(e.src), d = E.nodes.get(e.dst); if (!s || !d) return;
299
- ctx.strokeStyle = e.color;
300
- ctx.lineWidth = (0.8 + (e.weight ?? .5) * 0.8) / E.cam.z;
301
- if (e.type === 'COALITION' || e.type === 'CAUSAL') {
302
- ctx.setLineDash([6 / E.cam.z, 4 / E.cam.z]);
303
- ctx.lineDashOffset = -(t * 0.02);
304
- } else {
305
- ctx.setLineDash([]);
306
- }
307
- ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(d.x, d.y); ctx.stroke();
308
- ctx.setLineDash([]);
309
- });
310
-
311
- // nodes
312
- E.nodes.forEach(n => window.drawNode(ctx, n, t, E.cam.z));
313
- ctx.restore();
314
-
315
- drawMinimap();
316
- requestAnimationFrame(loop);
317
- }
318
-
319
- // ambient floating particles
320
- const PARTICLES = Array.from({length: 50}, () => ({
321
- x: Math.random() * innerWidth, y: Math.random() * innerHeight,
322
- r: Math.random() * 1.4 + 0.4, vx: (Math.random() - .5) * 0.15,
323
- vy: (Math.random() - .5) * 0.15,
324
- c: Math.random() > .6 ? 'rgba(0,200,160,.35)' : 'rgba(200,164,74,.22)',
325
- }));
326
- function drawAmbient(ctx, t) {
327
- ctx.save(); ctx.scale(DPR, DPR);
328
- PARTICLES.forEach(p => {
329
- p.x += p.vx; p.y += p.vy;
330
- if (p.x < 0) p.x = innerWidth; if (p.x > innerWidth) p.x = 0;
331
- if (p.y < 0) p.y = innerHeight; if (p.y > innerHeight) p.y = 0;
332
- ctx.fillStyle = p.c;
333
- ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
334
- });
335
- ctx.restore();
336
- }
337
-
338
- requestAnimationFrame(loop);
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 panel + agent debate playback. */
2
- const AGENT_COLORS = {
3
- THE_BUILDER: '#e05080',
4
- THE_GUARDIAN: '#9060c0',
5
- THE_ORACLE: '#40d0e0',
6
- THE_WEAVER: '#d070a0',
7
- THE_WILDCARD: '#40e080',
8
- DYNAMIC: '#c8903a',
9
- };
10
- window.agentColor = (a) => AGENT_COLORS[a] || '#9060c0';
11
-
12
- window.renderCouncil = function (resp, runtime) {
13
- const council = document.getElementById('council');
14
- const body = document.getElementById('council-body');
15
- const synth = document.getElementById('council-synth');
16
- const count = document.getElementById('council-count');
17
- const audio = document.getElementById('debate-audio');
18
-
19
- const cd = resp.council_deliberation || {};
20
- const agents = cd.agent_outputs || [];
21
- if (!agents.length) {
22
- council.classList.remove('show');
23
- setTimeout(() => council.classList.add('hidden'), 400);
24
- return;
25
- }
26
- council.classList.remove('hidden');
27
- requestAnimationFrame(() => council.classList.add('show'));
28
-
29
- count.textContent = `+${agents.length}`;
30
- body.innerHTML = agents.map((a, i) => {
31
- const c = window.agentColor(a.archetype);
32
- return `
33
- <div class="agent-card" style="border-color:${c}; animation-delay:${i * 80}ms"
34
- data-aid="${a.agent_id}">
35
- <div class="row1">
36
- <span class="ad-dot" style="background:${c};color:${c}"></span>
37
- <span class="name" style="color:${c}">${a.agent_name}</span>
38
- <span class="archetype">${a.archetype}</span>
39
- ${a.veto_triggered ? '<span class="archetype" style="background:#7a2230;color:#ffb0b0">VETO</span>' : ''}
40
- </div>
41
- <div class="thinking">${escapeHtml(a.thinking || '')}</div>
42
- <div class="stance">${escapeHtml(a.stance || '')}</div>
43
- <div class="conf">confidence ${(a.confidence ?? 0.8).toFixed(2)}</div>
44
- </div>`;
45
- }).join('');
46
-
47
- synth.textContent = cd.final_synthesis || resp.direct_answer || '';
48
-
49
- // audio playback
50
- if (runtime && runtime.audio_url) {
51
- audio.style.display = 'block';
52
- audio.src = runtime.audio_url;
53
- audio.play().catch(() => {});
54
- // highlight speaking agent as audio plays
55
- let idx = 0;
56
- const cards = body.querySelectorAll('.agent-card');
57
- cards[0]?.classList.add('speaking');
58
- audio.ontimeupdate = () => {
59
- if (!audio.duration) return;
60
- const target = Math.min(cards.length - 1,
61
- Math.floor((audio.currentTime / audio.duration) * cards.length));
62
- if (target !== idx) {
63
- cards[idx]?.classList.remove('speaking');
64
- cards[target]?.classList.add('speaking');
65
- idx = target;
66
- }
67
- };
68
- audio.onended = () => cards.forEach(c => c.classList.remove('speaking'));
69
- } else {
70
- audio.style.display = 'none';
71
- }
72
- };
73
-
74
- function escapeHtml(s) {
75
- return String(s || '').replace(/[&<>"']/g, c => ({
76
- '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
77
- }[c]));
78
- }
79
-
80
- document.getElementById('council-close').onclick = () => {
81
- const c = document.getElementById('council');
82
- c.classList.remove('show');
83
- setTimeout(() => c.classList.add('hidden'), 400);
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
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
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
- :root{
2
- --bg:#0a1a20; --bg-2:#060e12;
3
- --panel:rgba(8,24,30,.85); --panel-2:rgba(15,35,42,.7);
4
- --border:rgba(0,200,160,.2); --border-strong:rgba(0,200,160,.45);
5
- --teal:#00c8a0; --cyan:#00e5ff; --gold:#c8a44a;
6
- --pink:#e05080; --lav:#9060c0; --green:#40e080; --warm:#c8903a;
7
- --text:#e0eef0; --muted:#7aabb8;
8
- --shadow:0 8px 32px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.05);
9
- --font-sans:'Inter',system-ui,sans-serif;
10
- --font-mono:'Space Mono',monospace;
11
- --font-disp:'Space Grotesk',sans-serif;
12
- }
13
- *,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
14
- html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;
15
- background:radial-gradient(ellipse at center,#0d2228 0%,#060e12 100%);
16
- color:var(--text);font-family:var(--font-sans);font-size:14px;
17
- user-select:none;-webkit-user-select:none}
18
- button{font-family:inherit;color:inherit}
19
-
20
- /* CANVAS */
21
- #elysium-canvas{position:fixed;inset:0;width:100vw;height:100vh;cursor:grab;
22
- touch-action:none;z-index:0}
23
- #elysium-canvas.dragging{cursor:grabbing}
24
-
25
- /* GLASS PANELS */
26
- .glass{background:var(--panel);border:1px solid var(--border);border-radius:16px;
27
- backdrop-filter:blur(16px) saturate(1.2);-webkit-backdrop-filter:blur(16px) saturate(1.2);
28
- box-shadow:var(--shadow)}
29
-
30
- /* TIMELINE */
31
- #timeline{position:fixed;left:0;top:0;bottom:0;width:54px;z-index:10;
32
- display:flex;flex-direction:column;align-items:center;padding:40px 0;
33
- background:linear-gradient(90deg,rgba(10,26,32,.55),transparent);
34
- pointer-events:none}
35
- #timeline .t-edge{position:absolute;left:0;top:0;bottom:0;width:2px;
36
- background:repeating-linear-gradient(180deg,transparent 0 8px,rgba(0,200,160,.2) 8px 9px)}
37
- #timeline .years{display:flex;flex-direction:column;gap:46px;font-size:10px;
38
- color:var(--muted);letter-spacing:.5px;font-family:var(--font-mono);
39
- pointer-events:auto}
40
- #timeline .year{opacity:.55}
41
- #timeline .year-active{padding:4px 11px;border:1.5px solid var(--teal);
42
- border-radius:18px;color:var(--text);font-weight:700;font-size:11px;
43
- box-shadow:0 0 14px rgba(0,200,160,.55);background:rgba(0,40,48,.45);
44
- animation:pillpulse 3s ease-in-out infinite}
45
- @keyframes pillpulse{0%,100%{box-shadow:0 0 14px rgba(0,200,160,.5)}
46
- 50%{box-shadow:0 0 22px rgba(0,200,160,.85)}}
47
-
48
- /* LEGEND */
49
- #legend{position:fixed;top:24px;right:24px;width:220px;padding:18px;z-index:20}
50
- .legend-header{font-size:11px;letter-spacing:2.5px;color:var(--muted);margin-bottom:14px;
51
- font-weight:600}
52
- .legend-item{display:flex;align-items:center;gap:10px;margin:9px 0;font-size:13px}
53
- .legend-item .dot{width:18px;height:18px;border-radius:50%;
54
- box-shadow:0 0 10px currentColor;flex-shrink:0}
55
- .legend-item .badge{background:rgba(0,0,0,.4);padding:2px 8px;border-radius:10px;
56
- font-family:var(--font-mono);font-size:10px;min-width:24px;text-align:center}
57
- .legend-item .lbl{color:var(--text);font-size:12px}
58
- .meta-row{display:flex;justify-content:space-between;align-items:center;
59
- font-size:11px;color:var(--muted);margin-top:10px;padding-top:10px;
60
- border-top:1px solid var(--border)}
61
- .meta-val{background:rgba(0,0,0,.3);padding:3px 9px;border-radius:10px;
62
- font-family:var(--font-mono);color:var(--text);font-size:11px}
63
-
64
- /* MINIMAP */
65
- #minimap{position:fixed;left:74px;bottom:140px;width:160px;height:100px;
66
- background:rgba(8,24,30,.82);border:1px solid var(--border);
67
- border-radius:10px;z-index:15;cursor:pointer;
68
- box-shadow:var(--shadow)}
69
- .minimap-label{position:fixed;left:80px;bottom:244px;font-size:9px;
70
- letter-spacing:2px;color:var(--muted);z-index:15;pointer-events:none}
71
-
72
- /* ZOOM CTRL */
73
- #zoom-ctrl{position:fixed;right:24px;bottom:140px;display:flex;flex-direction:column;
74
- gap:6px;z-index:15}
75
- #zoom-ctrl button{width:38px;height:38px;background:rgba(8,24,30,.85);
76
- border:1px solid var(--border);border-radius:10px;color:var(--teal);
77
- font-size:18px;cursor:pointer;backdrop-filter:blur(10px);
78
- transition:all .15s ease}
79
- #zoom-ctrl button:hover{background:rgba(0,200,160,.15);transform:scale(1.05)}
80
-
81
- /* ALERT SCRIM */
82
- #alert-scrim{position:fixed;inset:0;z-index:5;pointer-events:none;
83
- background:radial-gradient(ellipse at center,transparent 55%,rgba(220,60,60,0) 100%);
84
- transition:background 800ms ease}
85
- body[data-alert="ATTENTION"] #alert-scrim{background:radial-gradient(ellipse at center,transparent 50%,rgba(200,160,80,.10) 100%)}
86
- body[data-alert="TENSION"] #alert-scrim{background:radial-gradient(ellipse at center,transparent 40%,rgba(220,140,60,.18) 100%)}
87
- body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at center,transparent 30%,rgba(220,60,60,.25) 100%);
88
- animation:crisisPulse 1.6s ease-in-out infinite}
89
- @keyframes crisisPulse{0%,100%{opacity:.6}50%{opacity:1}}
90
-
91
- /* STATS BAR */
92
- #stats{position:fixed;left:54px;right:0;bottom:56px;height:54px;
93
- display:flex;align-items:center;padding:0 28px;gap:36px;z-index:18;
94
- border-radius:0;border-left:none;border-right:none;border-bottom:none}
95
- .stat{display:flex;align-items:center;gap:10px}
96
- .stat-icon{font-size:18px;color:var(--teal);opacity:.85}
97
- .stat-label{font-size:10px;letter-spacing:1.4px;color:var(--muted);
98
- text-transform:uppercase;font-weight:500}
99
- .stat-value{font-size:22px;font-family:var(--font-disp);color:var(--cyan);
100
- font-weight:700;line-height:1}
101
-
102
- /* QUERY BAR */
103
- #query-bar{position:fixed;left:54px;right:0;bottom:0;height:56px;
104
- display:flex;align-items:center;padding:0 22px;gap:10px;z-index:20;
105
- border-radius:0;border-left:none;border-right:none}
106
- #q-input{flex:1;height:40px;background:var(--panel-2);
107
- border:1px solid var(--border);border-radius:28px;padding:0 22px;
108
- color:var(--text);font-size:13px;outline:none;
109
- transition:border-color .2s,box-shadow .2s}
110
- #q-input:focus{border-color:var(--border-strong);
111
- box-shadow:0 0 0 3px rgba(0,200,160,.12)}
112
- #q-input::placeholder{color:var(--muted)}
113
- #q-upload{width:36px;height:36px;display:grid;place-items:center;
114
- background:var(--panel-2);border:1px solid var(--border);border-radius:50%;
115
- cursor:pointer;font-size:14px;color:var(--muted);transition:all .15s}
116
- #q-upload:hover{color:var(--teal);border-color:var(--border-strong)}
117
- #q-send{width:38px;height:38px;border-radius:50%;background:var(--teal);
118
- color:#03252a;border:none;cursor:pointer;font-size:18px;font-weight:700;
119
- box-shadow:0 0 18px rgba(0,200,160,.45);transition:all .15s}
120
- #q-send:hover{transform:scale(1.06);box-shadow:0 0 24px rgba(0,200,160,.7)}
121
- #q-send.loading{animation:spin 1s linear infinite}
122
- @keyframes spin{to{transform:rotate(360deg)}}
123
- .mode-row{position:absolute;right:24px;bottom:62px;display:flex;gap:8px}
124
- .mode-row button{background:var(--panel-2);border:1px solid var(--border);
125
- border-radius:18px;padding:6px 12px;font-size:11px;color:var(--text);
126
- cursor:pointer;letter-spacing:.5px;display:flex;align-items:center;gap:6px}
127
- .mode-row button:hover{border-color:var(--border-strong)}
128
- .mode-row .badge{background:rgba(0,200,160,.18);padding:1px 6px;border-radius:8px;
129
- font-family:var(--font-mono);font-size:10px;color:var(--teal)}
130
-
131
- /* COUNCIL */
132
- #council{position:fixed;left:50%;bottom:130px;transform:translateX(-50%) translateY(20px);
133
- width:min(580px,92vw);max-height:50vh;overflow-y:auto;padding:20px;z-index:25;
134
- opacity:0;pointer-events:none;transition:all .4s cubic-bezier(.34,1.56,.64,1)}
135
- #council.show{opacity:1;transform:translateX(-50%) translateY(0);pointer-events:auto}
136
- #council.hidden{display:none}
137
- .council-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
138
- .council-title{font-size:11px;letter-spacing:2.2px;color:var(--muted);font-weight:600}
139
- .council-title span{color:var(--teal);margin-left:6px}
140
- #council-close{background:none;border:none;color:var(--muted);font-size:22px;
141
- cursor:pointer;line-height:1}
142
- #council-close:hover{color:var(--text)}
143
- .agent-card{border-left:3px solid;padding:12px 14px;margin:10px 0;
144
- background:rgba(10,26,32,.55);border-radius:8px;animation:slideIn .4s ease both}
145
- @keyframes slideIn{from{opacity:0;transform:translateX(-12px)}
146
- to{opacity:1;transform:translateX(0)}}
147
- .agent-card .row1{display:flex;align-items:center;gap:8px;margin-bottom:6px}
148
- .agent-card .ad-dot{width:10px;height:10px;border-radius:50%;box-shadow:0 0 8px currentColor}
149
- .agent-card .name{font-weight:700;font-size:13px}
150
- .agent-card .archetype{font-size:10px;background:rgba(0,0,0,.25);padding:2px 7px;
151
- border-radius:8px;letter-spacing:.8px;color:var(--muted)}
152
- .agent-card .thinking{color:var(--muted);font-size:11px;margin-top:4px;font-style:italic;
153
- line-height:1.45}
154
- .agent-card .stance{font-size:12.5px;margin-top:8px;color:var(--text)}
155
- .agent-card .conf{font-family:var(--font-mono);font-size:10px;color:var(--teal);
156
- margin-top:6px}
157
- .agent-card.speaking{box-shadow:0 0 0 1px rgba(0,200,160,.45);
158
- animation:speakPulse 1.2s ease-in-out infinite}
159
- @keyframes speakPulse{0%,100%{transform:translateX(0)}50%{transform:translateX(2px)}}
160
- #council-synth{margin-top:14px;padding-top:14px;border-top:1px solid var(--border);
161
- color:var(--text);font-size:13px;line-height:1.55}
162
- #debate-audio{width:100%;margin-top:14px;height:36px;border-radius:18px;
163
- background:rgba(0,0,0,.3)}
164
- #debate-audio::-webkit-media-controls-panel{background:rgba(8,24,30,.7)}
165
-
166
- /* NODE DETAIL */
167
- #node-detail{position:fixed;width:280px;padding:18px;z-index:24;
168
- opacity:0;pointer-events:none;transition:opacity .25s}
169
- #node-detail.show{opacity:1;pointer-events:auto}
170
- #node-detail .nd-title{font-size:14px;font-weight:700;margin-bottom:8px;
171
- display:flex;align-items:center;gap:8px}
172
- #node-detail .nd-type{font-size:10px;letter-spacing:1.4px;color:var(--muted);
173
- text-transform:uppercase;margin-bottom:12px}
174
- #node-detail .nd-stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;
175
- margin-bottom:10px}
176
- #node-detail .nd-pill{background:rgba(0,0,0,.25);padding:6px 8px;border-radius:8px;
177
- font-size:10px;text-align:center;color:var(--muted)}
178
- #node-detail .nd-pill b{display:block;font-family:var(--font-mono);color:var(--cyan);font-size:13px}
179
-
180
- /* TOASTS */
181
- #toasts{position:fixed;top:24px;left:50%;transform:translateX(-50%);z-index:30;
182
- display:flex;flex-direction:column;gap:8px;pointer-events:none}
183
- .toast{background:rgba(8,28,35,.92);border:1px solid var(--border);border-left:3px solid var(--teal);
184
- padding:10px 16px;border-radius:10px;font-size:12px;
185
- box-shadow:0 8px 20px rgba(0,0,0,.4);animation:toastIn .35s ease both,toastOut .4s ease 4s both}
186
- .toast.warn{border-left-color:var(--warm)}
187
- .toast.error{border-left-color:#e0604c}
188
- @keyframes toastIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
189
- @keyframes toastOut{to{opacity:0;transform:translateY(-8px)}}
190
-
191
- /* SEED HINT */
192
- #seed-hint{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);
193
- z-index:8;padding:9px 18px;border-radius:20px;
194
- background:rgba(8,24,30,.7);border:1px solid var(--border);
195
- font-size:12px;color:var(--muted);backdrop-filter:blur(10px);
196
- animation:hintFloat 3.5s ease-in-out infinite;pointer-events:none;
197
- letter-spacing:.4px}
198
- #seed-hint.hidden{display:none}
199
- @keyframes hintFloat{0%,100%{opacity:.7;transform:translate(-50%,-50%)}
200
- 50%{opacity:1;transform:translate(-50%,calc(-50% - 4px))}}
201
-
202
- /* RESPONSIVE */
203
- @media (max-width:900px){
204
- #legend{width:180px;padding:14px;top:14px;right:14px}
205
- #minimap{width:130px;height:84px;left:64px;bottom:124px}
206
- .minimap-label{left:70px;bottom:212px}
207
- #zoom-ctrl{right:14px;bottom:124px}
208
- #stats{gap:20px;padding:0 18px}
209
- .stat-value{font-size:18px}
210
- .stat-label{font-size:9px}
211
- }
212
- @media (max-width:640px){
213
- #timeline{width:42px}
214
- #legend{width:160px;padding:12px;top:10px;right:10px;font-size:11px}
215
- #legend .lbl{display:none}
216
- .meta-row{font-size:10px}
217
- #minimap{display:none}
218
- .minimap-label{display:none}
219
- #stats{display:none}
220
- #query-bar{left:42px}
221
- .mode-row{display:none}
222
- #council{width:96vw;max-height:62vh;bottom:80px}
223
- #zoom-ctrl{bottom:80px}
224
- }
225
-
226
- /* HIDE GRADIO if ever shown */
227
- .gradio-container,footer,.built-with,gradio-app>.main>.contain{display:none!important}
228
-
229
- .hidden{display:none!important}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: '#c8a44a',
4
- DOMAIN: '#9060c0',
5
- AGENT: '#e05080',
6
- TOOL: '#c8903a',
7
- PROJECT: '#40d0e0',
8
- LIFE_EVENT: '#40e080',
9
- EMOTION: '#e080b0',
10
- PERSON: '#d070a0',
11
- VALUE: '#8050c0',
12
- MEMORY: '#30c0d0',
13
- };
14
-
15
- function shade(hex, amt) {
16
- const c = parseInt(hex.slice(1), 16);
17
- let r = (c >> 16) + amt, g = ((c >> 8) & 255) + amt, b = (c & 255) + amt;
18
- r = Math.max(0, Math.min(255, r)); g = Math.max(0, Math.min(255, g)); b = Math.max(0, Math.min(255, b));
19
- return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
20
- }
21
-
22
- window.colorFor = (type) => TYPE_COLOR[type] || '#9060c0';
23
-
24
- window.drawNode = function (ctx, n, t, zoom) {
25
- const ageMs = t - (n.born || t);
26
- const age = Math.min(1, ageMs / 700);
27
- // overshoot easeOutBack
28
- const k = age;
29
- const overshoot = 1 + 1.7 * (k - 1) ** 3 + 0.7 * (k - 1) ** 2;
30
- const fadeScale = age < 1 ? overshoot : 1;
31
-
32
- const pulse = 1 + 0.06 * Math.sin((t / 1000) * 0.55 + (n.phase || 0));
33
- const r = n.radius * pulse * fadeScale;
34
- const x = n.x, y = n.y;
35
-
36
- ctx.globalAlpha = age;
37
-
38
- // outer halo (soft glow)
39
- const halo = ctx.createRadialGradient(x, y, r * 0.9, x, y, r * 2.4);
40
- halo.addColorStop(0, n.color + 'aa');
41
- halo.addColorStop(1, n.color + '00');
42
- ctx.fillStyle = halo;
43
- ctx.beginPath(); ctx.arc(x, y, r * 2.4, 0, Math.PI * 2); ctx.fill();
44
-
45
- // rotating dashed orbital ring (always for CORE)
46
- if (n.type === 'CORE') {
47
- ctx.save();
48
- ctx.translate(x, y);
49
- ctx.rotate(t * 0.0004);
50
- ctx.strokeStyle = n.color + '66';
51
- ctx.lineWidth = 1.2 / zoom;
52
- ctx.setLineDash([6 / zoom, 6 / zoom]);
53
- ctx.beginPath(); ctx.arc(0, 0, r * 1.8, 0, Math.PI * 2); ctx.stroke();
54
- ctx.setLineDash([]);
55
- ctx.restore();
56
- }
57
-
58
- // main sphere
59
- const grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
60
- grad.addColorStop(0, shade(n.color, 50));
61
- grad.addColorStop(0.55, n.color);
62
- grad.addColorStop(1, shade(n.color, -55));
63
-
64
- ctx.shadowColor = n.color;
65
- ctx.shadowBlur = 24 * pulse;
66
- ctx.fillStyle = grad;
67
- ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
68
- ctx.shadowBlur = 0;
69
-
70
- // specular highlight
71
- ctx.fillStyle = 'rgba(255,255,255,.45)';
72
- ctx.beginPath();
73
- ctx.ellipse(x - r * 0.32, y - r * 0.32, r * 0.26, r * 0.16, -0.5, 0, Math.PI * 2);
74
- ctx.fill();
75
-
76
- // counter badge (small inset)
77
- if (n.count != null && zoom > 0.5) {
78
- const bx = x - r * 1.05, by = y;
79
- ctx.fillStyle = 'rgba(0,0,0,.55)';
80
- ctx.beginPath();
81
- ctx.roundRect ? ctx.roundRect(bx - 12, by - 8, 22, 16, 8) : ctx.rect(bx - 12, by - 8, 22, 16);
82
- ctx.fill();
83
- ctx.fillStyle = '#fff'; ctx.font = `bold ${10}px Space Mono`;
84
- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
85
- ctx.fillText(String(n.count), bx - 1, by);
86
- }
87
-
88
- // label
89
- if (zoom > 0.4 && n.label) {
90
- const fs = Math.min(14, 11 * Math.max(1, zoom * 0.9));
91
- ctx.fillStyle = '#c8e8f0';
92
- ctx.font = `500 ${fs / zoom}px Inter, sans-serif`;
93
- ctx.textAlign = 'center'; ctx.textBaseline = 'top';
94
- ctx.fillText(n.label.length > 22 ? n.label.slice(0, 20) + '…' : n.label,
95
- x, y + r + 6 / zoom);
96
- }
97
-
98
- // pulse highlight ring (when pulsed via UI directive)
99
- if (n.pulseUntil && t < n.pulseUntil) {
100
- const p = (n.pulseUntil - t) / 1200;
101
- ctx.strokeStyle = `rgba(0,229,255,${p})`;
102
- ctx.lineWidth = 2 / zoom;
103
- ctx.beginPath(); ctx.arc(x, y, r + (1 - p) * 30, 0, Math.PI * 2); ctx.stroke();
104
- }
105
-
106
- ctx.globalAlpha = 1;
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">2016</div><div class="year">2017</div><div class="year">2018</div>
23
- <div class="year">2019</div>
24
- <div class="year-active">2026</div>
25
- <div class="year">2027</div><div class="year">2028</div><div class="year">2029</div>
26
- <div class="year">2030</div>
27
- </div>
28
- </aside>
29
-
30
- <!-- LEGEND PANEL -->
31
- <section id="legend" class="glass">
32
- <div class="legend-header">LEGEND</div>
33
- <div id="legend-items">
34
- <div class="legend-item" data-type="CORE">
35
- <span class="dot" style="background:#c8a44a;color:#c8a44a"></span>
36
- <span class="badge">1</span><span class="lbl">Civilization</span>
37
- </div>
38
- </div>
39
- <div class="meta-row"><span>🏛 GAPS</span><span id="legend-gaps" class="meta-val">20%</span></div>
40
- <div class="meta-row"><span>⟳ Updates</span><span id="legend-updates" class="meta-val">30%</span></div>
41
- </section>
42
-
43
- <!-- MINIMAP -->
44
- <canvas id="minimap"></canvas>
45
- <div class="minimap-label">CIVILIZATION MAP</div>
46
-
47
- <!-- ZOOM CONTROLS -->
48
- <div id="zoom-ctrl">
49
- <button id="z-in" title="Zoom in">+</button>
50
- <button id="z-fit" title="Fit all">⊡</button>
51
- <button id="z-out" title="Zoom out">−</button>
52
- </div>
53
-
54
- <!-- ALERT SCRIM -->
55
- <div id="alert-scrim"></div>
56
-
57
- <!-- STATS BAR -->
58
- <section id="stats" class="glass">
59
- <div class="stat">
60
- <div class="stat-icon"></div>
61
- <div><div class="stat-label">Compliance Rate</div><div class="stat-value" id="s-health">—</div></div>
62
- </div>
63
- <div class="stat">
64
- <div class="stat-icon">📜</div>
65
- <div><div class="stat-label">Total Laws</div><div class="stat-value" id="s-laws">0</div></div>
66
- </div>
67
- <div class="stat">
68
- <div class="stat-icon">👥</div>
69
- <div><div class="stat-label">Public Engagement</div><div class="stat-value" id="s-engage">0</div></div>
70
- </div>
71
- <div class="stat">
72
- <div class="stat-icon">✨</div>
73
- <div><div class="stat-label">Implementation Rate</div><div class="stat-value" id="s-impl">0%</div></div>
74
- </div>
75
- </section>
76
-
77
- <!-- QUERY BAR -->
78
- <section id="query-bar" class="glass">
79
- <input id="q-input" placeholder="Speak to your civilization seed…" autocomplete="off"/>
80
- <label id="q-upload" title="Attach image">
81
- <input type="file" id="q-file" accept="image/*" hidden />📎
82
- </label>
83
- <button id="q-send" title="Send">→</button>
84
- <div class="mode-row">
85
- <button id="my-agent">🤖 My agent <span class="badge" id="agent-count">+0</span></button>
86
- <button id="ri-analysis">✨ RI Analysis</button>
87
- </div>
88
- </section>
89
-
90
- <!-- COUNCIL PANEL (slides in when agents present) -->
91
- <section id="council" class="glass hidden">
92
- <div class="council-head">
93
- <div class="council-title">COUNCIL DELIBERATION <span id="council-count">+0</span></div>
94
- <button id="council-close">×</button>
95
- </div>
96
- <div id="council-body"></div>
97
- <div id="council-synth"></div>
98
- <audio id="debate-audio" controls preload="auto"></audio>
99
- </section>
100
-
101
- <!-- NODE DETAIL POPOVER -->
102
- <section id="node-detail" class="glass hidden"></section>
103
-
104
- <!-- TOAST NOTIFICATIONS -->
105
- <div id="toasts"></div>
106
-
107
- <!-- INITIAL HINT -->
108
- <div id="seed-hint">Ask anything to wake your civilization</div>
109
-
110
- <script src="/assets/api.js"></script>
111
- <script src="/assets/nodes.js"></script>
112
- <script src="/assets/canvas.js"></script>
113
- <script src="/assets/council.js"></script>
114
- <script src="/assets/boot.js"></script>
115
- </body>
116
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>