"""F4 - Daimon's entire UI as ONE gr.Blocks app (the Off-Brand custom UI, mounted at "/" via gr.mount_gradio_app - no separate static frontend, no second isolated Gradio app at "/gradio"). Same living loop (engine/loop.py) as before; this module is just its window. Layout (one gr.Row, three gr.Column): - Chat - gr.Chatbot + textbox, streamed turn-by-turn (F2) - State vector - the 10-layer spec (engine/recompile.layer_summaries), rendered as custom HTML: bars for L3/L5, GitHub-style diffs for their most recent mutations, qualitative summaries + edit-policy badges for the other 8 layers. - PERSONA.md + audit log + governance demo (F3) The CSS below is Daimon's own dark theme (ported from the previous app/frontend/styles.css), passed to gr.mount_gradio_app(..., css=...) so the whole page - chrome included - looks like Daimon, not default Gradio. """ from __future__ import annotations import html import sys from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parent.parent if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) import gradio as gr # noqa: E402 from openai import APIConnectionError # noqa: E402 from engine import governance_demo, recompile # noqa: E402 from engine.loop import SLUG, finish_turn, step_stream # noqa: E402 from engine.spec_bridge import get_state # noqa: E402 # ── Daimon's dark theme, ported from app/frontend/styles.css ─────────────── CUSTOM_CSS = """ :root { --bg: #0a0a10; --panel: #14141f; --panel-2: #191926; --panel-border: rgba(255, 255, 255, 0.08); --ink: #f1f0f7; --ink-dim: #908dab; --accent: #8c7bf6; --accent-dim: #5b4fc4; --accent-soft: rgba(140, 123, 246, 0.14); --baseline: #4fd1c5; --wall: rgba(255, 255, 255, 0.14); --clamp: #f0a93a; --block: #f06a6a; --mono: "Roboto Mono", "Courier New", monospace; } .gradio-container { background: var(--bg) !important; color: var(--ink); } /* Lock the page itself - only the 3 columns below scroll internally. html/body get a real 100vh so Gradio's own flex-grow chain (.gradio-container > .main.app > .wrap > main.contain > outer .column) resolves to a definite height - but every flex item in that chain has the default min-height:auto (= its content's height), which blocks flex-grow from ever shrinking it below content size. Reset min-height to 0 at every level so the chain actually shrinks to the viewport, then #app-row fills what's left and each panel column scrolls on its own. */ html, body, gradio-app { height: 100% !important; overflow: hidden !important; margin: 0 !important; } gradio-app, .gradio-container, .gradio-container .app, .gradio-container .wrap, main.contain, main.contain > .column { min-height: 0 !important; } /* The header markdown blocks above #app-row must keep their natural size (not get squeezed to ~0 by #app-row's flex-grow); only #app-row grows. */ main.contain > .column > .block { flex: 0 0 auto !important; } #app-row { flex: 1 1 auto !important; min-height: 0 !important; overflow: hidden !important; flex-wrap: nowrap !important; } #app-row > .column { height: 100% !important; min-height: 0 !important; overflow-y: auto !important; overflow-x: hidden !important; flex-wrap: nowrap !important; } #app-row > .column > .block { flex: 0 0 auto !important; width: 100% !important; } /* State vector - 10 layer cards */ #layers { display: flex; flex-direction: column; gap: 0.6rem; } .layer-card { border: 1px solid var(--panel-border); border-radius: 10px; background: var(--panel-2); padding: 0.6rem 0.75rem; display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 0.6rem; } .layer-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; } .layer-title { font-family: var(--mono); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink); font-weight: 700; } .layer-badge { font-family: var(--mono); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink-dim); border: 1px solid var(--panel-border); border-radius: 999px; padding: 0.1em 0.6em; white-space: nowrap; } .layer-badge.edit-human { border-color: var(--block); color: var(--block); } .layer-badge.edit-gov { border-color: var(--accent); color: var(--accent); } .layer-badge.edit-review { border-color: var(--clamp); color: var(--clamp); } .layer-lines { margin: 0; padding-left: 1.1rem; font-size: 0.78rem; color: var(--ink-dim); display: flex; flex-direction: column; gap: 0.25rem; } .bar-row { display: grid; grid-template-columns: 9rem 1fr 3.5rem; align-items: center; gap: 0.5rem; font-family: var(--mono); font-size: 0.75rem; } .bar-track { position: relative; height: 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.05); border: 1px solid var(--wall); } .bar-fill { position: absolute; top: 1px; bottom: 1px; left: 0; width: 3px; border-radius: 999px; background: var(--accent); } .bar-fill.clamped { background: var(--clamp); box-shadow: 0 0 8px var(--clamp); } .bar-baseline { position: absolute; top: -2px; bottom: -2px; width: 1px; background: var(--baseline); opacity: 0.7; } .bar-value { text-align: right; color: var(--ink-dim); } /* GitHub-style diffs - per-layer (state vector) and audit log */ .layer-diffs, #audit-log { display: flex; flex-direction: column; gap: 0.35rem; } .diff-block { border: 1px solid var(--panel-border); border-radius: 8px; overflow: hidden; } .diff-block.clamped { border-color: var(--clamp); } .diff-block.blocked { border-color: var(--block); } .diff-meta { padding: 0.2rem 0.5rem; font-size: 0.62rem; color: var(--ink-dim); background: var(--panel-2); border-bottom: 1px dashed var(--panel-border); } .diff-line { padding: 0.12rem 0.5rem; font-family: var(--mono); font-size: 0.7rem; white-space: pre-wrap; } .diff-line.removed { background: rgba(240, 106, 106, 0.12); color: #ffb4b4; } .diff-line.removed::before { content: "- "; } .diff-line.added { background: rgba(94, 226, 150, 0.12); color: #a3f0c2; } .diff-line.added::before { content: "+ "; } /* Governance demo */ .gov-card { border: 1px solid var(--panel-border); border-radius: 10px; padding: 0.6rem 0.75rem; background: var(--panel-2); margin-bottom: 0.5rem; font-size: 0.8rem; } .gov-card.rejected { border-color: var(--block); } .gov-card.clamped { border-color: var(--clamp); } .gov-card .gov-title { text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.65rem; color: var(--ink-dim); margin-bottom: 0.25rem; } .gov-card > div + div { margin-top: 0.25rem; } .gov-card .gov-raw { margin-top: 0.35rem; padding-top: 0.35rem; border-top: 1px dashed var(--panel-border); color: var(--ink-dim); font-size: 0.65rem; word-break: break-all; } .legend-dot { display: inline-block; width: 0.6em; height: 0.6em; border-radius: 50%; margin: 0 0.15em; } .legend-dot.edit-human { background: var(--block); } .legend-dot.edit-gov { background: var(--accent); } .legend-dot.edit-review { background: var(--clamp); } """ # ── Server-side ports of app/frontend/brain.js's render helpers ──────────── def _pct(value: float, range_: list[float]) -> float: lo, hi = range_ span = max(hi - lo, 1e-9) return max(0.0, min(100.0, (value - lo) / span * 100.0)) def _edit_class(policy: str | None) -> str: if not policy: return "" if "human" in policy: return "edit-human" if "review" in policy: return "edit-review" return "edit-gov" def _relative_word_baseline(value: Any, mean: float, range_: list[float]) -> str | None: if not isinstance(value, (int, float)): return None word = recompile._relative_word(value, mean, range_) return "at baseline" if word == "at" else f"{word} baseline" def _build_bar_row(f: dict) -> str: return ( '
' f'
{html.escape(f["label"])}
' '
' f'
' f'
' "
" f'
{f["value"]:.2f}
' "
" ) def _build_diff_block(entry: dict, envs: dict[str, dict]) -> str: env = envs.get(entry["field"]) classes = "diff-block" if entry.get("governance_blocked"): classes += " blocked" elif entry.get("clamped"): classes += " clamped" from_v, to_v = entry["from"], entry["to"] from_s = f"{from_v:.2f}" if isinstance(from_v, (int, float)) else str(from_v) to_s = f"{to_v:.2f}" if isinstance(to_v, (int, float)) else str(to_v) before_word = _relative_word_baseline(from_v, env["mean"], env["range"]) if env else None after_word = _relative_word_baseline(to_v, env["mean"], env["range"]) if env else None added_extra = "" if entry.get("clamped"): added_extra += " [clamped to wall]" if entry.get("governance_blocked"): added_extra += " [blocked]" field = html.escape(entry["field"]) removed = f"{field}: {from_s}" + (f" ({before_word})" if before_word else "") added = f"{field}: {to_s}" + (f" ({after_word})" if after_word else "") + added_extra return ( f'
' f'
{field} - {html.escape(entry["reason"])}
' f'
{removed}
' f'
{added}
' "
" ) # traits.* -> L3 (Personality); affect.*/mood.* -> L5 (Affect & Mood) _LAYER_DIFF_PREFIXES = {3: ("traits.",), 5: ("affect.", "mood.")} def render_layers_html(layers: list[dict], mutation_log: list[dict], envs: dict[str, dict]) -> str: blocks = [] for layer in layers: head = f'
L{layer["n"]} · {html.escape(layer["title"])}' if layer["edit_policy"]: head += ( f'' f'{html.escape(layer["edit_policy"].replace("_", " "))}' ) head += "
" body = "".join(_build_bar_row(f) for f in layer["fields"]) if layer["lines"]: body += '" diffs = "" prefixes = _LAYER_DIFF_PREFIXES.get(layer["n"]) if prefixes: recent = [e for e in mutation_log if e["field"].startswith(prefixes)][-2:] recent.reverse() if recent: diffs = '
' + "".join(_build_diff_block(e, envs) for e in recent) + "
" blocks.append(f'
{head}{body}{diffs}
') return '
' + "".join(blocks) + "
" def render_audit_html(entries: list[dict], envs: dict[str, dict]) -> str: if not entries: return '

(no mutations yet)

' return '
' + "".join(_build_diff_block(e, envs) for e in reversed(entries)) + "
" def render_governance_html(identity: dict, overflow: dict, reset: dict) -> str: parts = [] parts.append( '
' '
Scenario 1 - tried to rename Daimon\'s identity → blocked ✓
' f"
Attempted: set {html.escape(identity['field'])} directly.
" "
Result: refused before it ever reached state.json - " "identity.* has no declared range in personaxis.md, so the spec engine doesn't " "know how to mutate it at all.
" f"
Why: {html.escape(identity['explanation'])}
" f"
engine error: {html.escape(identity['cli_error'])}
" "
" ) parts.append( '
' '
Scenario 2 - pushed mood.tone far past its range → clamped ✓
' f"
Attempted: {html.escape(overflow['field'])} " f"{overflow['before']:.2f} + 5.0 (way outside its declared range).
" "
Result: the spec engine let the mutation through but capped the value " f"at the wall: {overflow['before']:.2f} → {overflow['after']:.2f} (see the audit log).
" f"
Why: {html.escape(overflow['explanation'])}
" "
" ) if reset.get("skipped"): parts.append('
Cleanup - mood.tone was already at baseline, nothing to reset
') else: r = reset["result"] parts.append( '
' '
Cleanup - mood.tone restored to baseline
' f"
Result: mood.tone {r['from']:.2f} → {r['to']:.2f}, " "logged as a normal audited mutation (actor: human-operator).
" "
" ) return "".join(parts) # ── State refresh shared by load / chat / governance demo ────────────────── def _refresh() -> tuple[str, str, str]: layers = recompile.layer_summaries(SLUG) envs = recompile.envelopes(SLUG) state = get_state(SLUG) layers_html = render_layers_html(layers, state["mutation_log"], envs) audit_html = render_audit_html(state["mutation_log"][-20:], envs) persona_md = recompile.render(SLUG) return layers_html, audit_html, persona_md def _on_load(): return _refresh() def _respond(message: str, history: list[dict]): """Stream the reply into `chatbot` and lock the input. Reasoning tokens (if TEXT_THINKING_MODE is on) land in their own collapsible bubble (gr.Chatbot metadata) right before the reply bubble. Does NOT touch the state-vector/persona/audit columns - `_post_process` (chained via `.then()`) runs the appraise/govern/recompile/memory step and refreshes those, and re-enables the input, once the reply is complete.""" if not message.strip(): yield history, gr.update(), gr.update(), "", "" return prior_history = history history = history + [{"role": "user", "content": message}, {"role": "assistant", "content": ""}] reply_idx = len(history) - 1 thinking_idx: int | None = None # Lock the textbox/button for the whole turn, including the # post-processing step that follows. yield history, gr.update(value="", interactive=False), gr.update(interactive=False), "", "" thinking_text = "" content_text = "" try: for kind, payload in step_stream(message, prior_history): if kind == "thinking": if thinking_idx is None: history.insert(reply_idx, {"role": "assistant", "content": "", "metadata": {"title": "Thinking...", "status": "pending"}}) thinking_idx = reply_idx reply_idx += 1 thinking_text += payload history[thinking_idx]["content"] = thinking_text elif kind == "content": content_text += payload history[reply_idx]["content"] = content_text elif kind == "error": content_text += f"(error: {payload})" history[reply_idx]["content"] = content_text elif kind == "done": reply = payload or content_text history[reply_idx]["content"] = reply if thinking_idx is not None: history[thinking_idx]["metadata"]["title"] = "Thinking" history[thinking_idx]["metadata"]["status"] = "done" yield history, gr.update(), gr.update(), message, reply return yield history, gr.update(), gr.update(), "", "" except APIConnectionError: history[reply_idx]["content"] = "Model server is not available. Try again in a moment." yield history, gr.update(interactive=True), gr.update(interactive=True), "", "" def _post_process(user_message: str, reply: str): """Chained after `_respond`: runs the appraise/govern/recompile/memory step (steps 2-6 of the living loop), refreshes the state-vector/persona/ audit columns, and unlocks the chat input.""" if not user_message: return gr.update(), gr.update(), gr.update(), gr.update(interactive=True), gr.update(interactive=True) finish_turn(user_message, reply) layers_html, audit_html, persona_md = _refresh() return layers_html, audit_html, persona_md, gr.update(interactive=True), gr.update(interactive=True) def _run_governance_demo(): identity = governance_demo.attempt_identity_change() overflow = governance_demo.attempt_envelope_overflow() reset = governance_demo.reset_mood_tone() gov_html = render_governance_html(identity, overflow, reset) layers_html, audit_html, persona_md = _refresh() return gov_html, layers_html, audit_html, persona_md # ── Layout ─────────────────────────────────────────────────────────────── INTRO = ( "Daimon lives in two files: `personaxis.md` (the 10-layer spec - baselines, " "ranges, hard limits) and `state.json` (live values + audit trail). Every message runs " "**respond -> appraise -> map -> govern/clamp -> recompile -> remember**. Only Personality " "(L3) and Affect & Mood (L5) have declared numeric ranges and move at runtime; the other 8 " "layers are shown read-only here and are edited by a human in `personaxis.md`." ) LAYERS_HINT = ( "Live readout of every layer in `personaxis.md`. L3 and L5 have bars (dashed mark = " "declared baseline, ends = walls) plus a GitHub-style diff (- before / + after) for their " "most recent edits. The rest are read-only summaries, tagged with who can edit them: " ' human approval · ' ' governance-controlled · ' ' review required.' ) AUDIT_HINT = ( "`field: from -> to (tags) - reason`, from `state.json`'s `mutation_log`. " ' clamped to a wall · ' ' structurally blocked.' ) GOV_HINT = ( "Two real checks against the spec engine (no model needed): an identity-change attempt " "(blocked - no declared range) and a `mood.tone` overflow (clamped to its wall), then a " "reset of `mood.tone` back to baseline." ) def build_demo() -> gr.Blocks: with gr.Blocks(title="Daimon", fill_height=True) as demo: gr.Markdown("# Daimon\n*Governed, self-evolving local AI persona - living loop*") gr.Markdown(INTRO) with gr.Row(elem_id="app-row"): with gr.Column(scale=10): gr.Markdown("## Talk to Daimon") chatbot = gr.Chatbot(height=480, elem_id="chat-log") with gr.Row(): msg = gr.Textbox(placeholder="Say something to Daimon...", show_label=False, scale=8) send_btn = gr.Button("Send", scale=1) with gr.Column(scale=13): gr.Markdown("## State vector - 10 layers") gr.Markdown(LAYERS_HINT) layers_html = gr.HTML() with gr.Column(scale=10): gr.Markdown("## PERSONA.md - live persona") persona_md = gr.Markdown() gr.Markdown("## Audit log") gr.Markdown(AUDIT_HINT) audit_html = gr.HTML() gr.Markdown("## Governance demo") gr.Markdown(GOV_HINT) gov_btn = gr.Button("Run governance demo") gov_html = gr.HTML() # gr.State is the only thing passed between the two stages: the user # message + finished reply, so _post_process can run finish_turn() # without re-deriving them from `chatbot`. pending_msg = gr.State("") pending_reply = gr.State("") stream_inputs = [msg, chatbot] stream_outputs = [chatbot, msg, send_btn, pending_msg, pending_reply] post_outputs = [layers_html, audit_html, persona_md, msg, send_btn] send_btn.click(_respond, inputs=stream_inputs, outputs=stream_outputs).then( _post_process, inputs=[pending_msg, pending_reply], outputs=post_outputs ) msg.submit(_respond, inputs=stream_inputs, outputs=stream_outputs).then( _post_process, inputs=[pending_msg, pending_reply], outputs=post_outputs ) gov_btn.click(_run_governance_demo, outputs=[gov_html, layers_html, audit_html, persona_md]) demo.load(_on_load, outputs=[layers_html, audit_html, persona_md]) return demo