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()