Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |