the-echo / echo /app.py
frankyy03's picture
Translate UI to English
74f2db1 verified
"""
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()