Spaces:
Sleeping
Sleeping
File size: 9,253 Bytes
897d5bd 74f2db1 897d5bd 74f2db1 897d5bd 74f2db1 897d5bd 74f2db1 897d5bd 74f2db1 897d5bd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | """
echo/app.py
-----------
The Gradio front-end: plant a seed, watch the tree of alternate lives grow, and
walk it. UI-only — it drives the orchestrator and renders LifeTree.to_graph().
First cut runs entirely on MockLLM + mock tools, so it launches offline with no
GPU and no ML deps. Swapping in the real model later is a one-line change in
`_new_orchestrator()`.
Run it:
python -m echo.app
Design notes:
* The tree visual is a read-only vis-network embedded via <iframe srcdoc>. We do
NOT try to route SVG clicks back into Python (fragile in Gradio); all
interaction is native components. Node selection is the dropdown.
* gr.HTML strips <script>, so the vis-network document is wrapped in an iframe
srcdoc, which renders its own document (scripts included).
* gr.State holds the whole Orchestrator. Fine with MockLLM (tiny). When the real
LocalLLM is plugged in (roadmap item 3), the loaded model must NOT live in
State — keep only the serialized LifeTree in State and hold the model in a
module-global. (Document that in CLAUDE.md when implementing item 3.)
"""
from __future__ import annotations
import html as _html
import json
import gradio as gr
# Import the orchestrator explicitly — never via core/__init__ (circular import).
from .core.orchestrator import Orchestrator
from .llm.client import MockLLM
from .tools.research import MockResearch
from .tools.voice import MockVoice
_DEFAULT_SEED = "I stayed instead of moving abroad"
_AUDIO_EXTS = (".wav", ".mp3", ".ogg", ".flac")
def _new_orchestrator() -> Orchestrator:
"""The one place that wires the engine. Swap MockLLM -> LocalLLM here."""
return Orchestrator(MockLLM(), MockResearch(), MockVoice())
# --------------------------------------------------------------- tree renderer
def _valence_color(v: float) -> str:
"""Continuous gradient: cold slate (struggling) -> warm gold (flourishing)."""
v = max(-1.0, min(1.0, float(v)))
t = (v + 1.0) / 2.0
cold = (0x3A, 0x4A, 0x5A)
gold = (0xE8, 0xB8, 0x4B)
rgb = tuple(round(cold[i] + (gold[i] - cold[i]) * t) for i in range(3))
return "#%02X%02X%02X" % rgb
def _render_tree_html(graph: dict, active_id: str) -> str:
"""Build the vis-network document and wrap it in an iframe srcdoc."""
nodes = []
for n in graph["nodes"]:
is_active = n["id"] == active_id
node = {
"id": n["id"],
"label": n.get("label") or "…",
"title": (n.get("summary") or "")[:120], # tooltip; capped for size
"color": {
"background": _valence_color(n["valence"]),
"border": "#F2D27A" if is_active else "#1c2530",
},
"borderWidth": 4 if is_active else 1,
"shadow": bool(is_active),
"font": {"color": "#e8e6df", "size": 13},
}
if n.get("has_voice"):
# a subtle dashed ring marks nodes that have a spoken line
node["shapeProperties"] = {"borderDashes": [4, 3]}
nodes.append(node)
edges = [
{
"from": e["from"],
"to": e["to"],
"label": (e.get("label") or "")[:35], # fork text; capped for size
"arrows": "to",
"font": {"color": "#9aa7b0", "size": 10, "strokeWidth": 0},
"color": {"color": "#44525e"},
}
for e in graph["edges"]
]
doc = (
"<!DOCTYPE html><html><head>"
'<script src="https://unpkg.com/vis-network/standalone/umd/'
'vis-network.min.js"></script>'
"<style>html,body{margin:0;height:100%;background:#11151a;}"
"#net{width:100%;height:100%;}</style></head>"
'<body><div id="net"></div><script>'
"const nodes=new vis.DataSet(" + json.dumps(nodes) + ");"
"const edges=new vis.DataSet(" + json.dumps(edges) + ");"
"new vis.Network(document.getElementById('net'),{nodes,edges},{"
"layout:{hierarchical:{direction:'UD',sortMethod:'directed',"
"levelSeparation:95,nodeSpacing:150}},"
"physics:false,interaction:{hover:true,dragNodes:false,zoomView:true},"
"nodes:{shape:'dot',size:16}});"
"</script></body></html>"
)
srcdoc = _html.escape(doc, quote=True)
return (
f'<iframe srcdoc="{srcdoc}" '
'style="width:100%;height:520px;border:0;border-radius:12px;'
'background:#11151a;"></iframe>'
)
_EMPTY_TREE = (
"<div style='height:520px;display:flex;align-items:center;"
"justify-content:center;background:#11151a;border-radius:12px;"
"color:#6b7680;font-family:sans-serif;'>Plant the tree to begin.</div>"
)
# ------------------------------------------------------------------ rendering
def _render(orch: Orchestrator, active_id: str):
"""Compute every panel + tree output for a given active node (7 outputs)."""
tree = orch.tree
node = tree.nodes[active_id]
html = _render_tree_html(orch.graph(), active_id)
# dropdown: depth · occupation · dominant_feeling (read from node, not graph)
choices = [
(f"{n.depth} · {n.facts.occupation} · {n.tone.dominant_feeling}", n.node_id)
for n in sorted(tree.nodes.values(), key=lambda x: (x.depth, x.node_id))
]
summary = f"### {node.facts.constraints_text()}\n\n{node.summary}"
if node.voice_line:
summary += f"\n\n> *“{node.voice_line}”*"
forks = node.pending_forks
fa = (gr.update(value=forks[0], visible=True) if len(forks) > 0
else gr.update(visible=False))
fb = (gr.update(value=forks[1], visible=True) if len(forks) > 1
else gr.update(visible=False))
path = node.voice_audio_path
audio_val = path if (path and path.lower().endswith(_AUDIO_EXTS)) else None
branch = "```\n" + tree.branch_narrative(active_id) + "\n```"
return (
gr.update(value=html),
gr.update(choices=choices, value=active_id),
gr.update(value=summary),
gr.update(value=audio_val),
fa,
fb,
gr.update(value=branch),
)
_BLANK = (gr.update(),) * 7
# -------------------------------------------------------------------- handlers
def plant(seed: str, base_age):
orch = _new_orchestrator()
root = orch.seed((seed or "").strip() or _DEFAULT_SEED, base_age=int(base_age or 24))
return (orch, root.node_id, *_render(orch, root.node_id))
def select_node(orch, selected_id):
if orch is None or not selected_id:
return (selected_id, *_BLANK)
return (selected_id, *_render(orch, selected_id))
def choose(orch, active_id, i: int):
if orch is None or not active_id:
return (orch, active_id, *_BLANK)
node = orch.tree.nodes.get(active_id)
if node is None or i >= len(node.pending_forks):
return (orch, active_id, *_BLANK)
child = orch.choose_fork(active_id, i)
return (orch, child.node_id, *_render(orch, child.node_id))
def show_final(orch):
if orch is None:
return gr.update(value="*Plant the tree first.*")
return gr.update(value=orch.final_map_summary())
# ------------------------------------------------------------------ blocks/app
def build_demo() -> gr.Blocks:
with gr.Blocks(title="The Echo") as demo:
gr.Markdown("# The Echo\n*the lives you didn't live*")
orch_state = gr.State()
active_state = gr.State()
with gr.Row():
with gr.Column(scale=3):
tree_html = gr.HTML(_EMPTY_TREE)
with gr.Column(scale=2):
seed_in = gr.Textbox(label="The fork in your life",
value=_DEFAULT_SEED, lines=2)
age_in = gr.Number(label="Starting age", value=24, precision=0)
plant_btn = gr.Button("Plant the tree", variant="primary")
node_dd = gr.Dropdown(label="You are here", choices=[],
interactive=True)
summary_md = gr.Markdown()
audio = gr.Audio(label="This echo's voice", interactive=False)
with gr.Row():
fork_a_btn = gr.Button("…", visible=False)
fork_b_btn = gr.Button("…", visible=False)
branch_md = gr.Markdown()
with gr.Accordion("The final map", open=False):
final_btn = gr.Button("See the final map")
final_md = gr.Markdown()
common = [tree_html, node_dd, summary_md, audio,
fork_a_btn, fork_b_btn, branch_md]
plant_btn.click(plant, [seed_in, age_in],
[orch_state, active_state, *common])
node_dd.change(select_node, [orch_state, node_dd],
[active_state, *common])
fork_a_btn.click(lambda o, a: choose(o, a, 0),
[orch_state, active_state],
[orch_state, active_state, *common])
fork_b_btn.click(lambda o, a: choose(o, a, 1),
[orch_state, active_state],
[orch_state, active_state, *common])
final_btn.click(show_final, [orch_state], [final_md])
return demo
def main() -> None:
build_demo().launch(theme=gr.themes.Base())
if __name__ == "__main__":
main()
|