agharsallah
feat(feed): update media rendering to use native components with badges instead of inline tags
7c81add
Raw
History Blame Contribute Delete
6.68 kB
"""Pure HTML renderer for the Fishbowl narrator/event feed (the transcript).
This mirrors the ``Feed`` component of the React prototype (``ui/raw/show.jsx``) and the
feed styling in ``ui/raw/show.css``. It is a *pure* string function over the view-model
dict produced by :func:`src.ui.fishbowl.view_model_at` β€” no Gradio import, no sibling
render imports β€” so the same projection can be served as HTML by the Gradio shell or a
future ``gr.Server``.
Each feed item (``vm["feed"][i]``) is tagged by ``kind``:
* ``narrate{voice,text}`` β†’ the narrator's voice + line, with an optional typewriter
slice on the *head* (latest) narrate item.
* ``say{agent,said,thought,mood}`` β†’ an agent name + what it said aloud, and β€” when the
mind-reader is on and a thought exists β€” the inner thought in a ``thought`` span.
* ``poke{label,text}`` β†’ a bolt-tagged disturbance line.
* ``verdict{text,reveal,agent}`` β†’ a lime-accented verdict line.
Every piece of model/agent text is escaped with :func:`html.escape`.
"""
from __future__ import annotations
import html
from src.ui.fishbowl.adapter import agent_color, agent_color_dim
_BOLT = "⚑" # poke lines carry a lightning bolt
_SCALES = "βš–" # the verdict line carries the scales of judgement
_QUILL = "βœ’" # the rafters-critic tags its color commentary
def _last_narrate_index(feed: list[dict]) -> int | None:
"""Index of the latest ``narrate`` item β€” the one that gets the typewriter slice."""
for i in range(len(feed) - 1, -1, -1):
if feed[i].get("kind") == "narrate":
return i
return None
def _narrate_line(*, voice_name: str, text: str, typing: bool) -> str:
speaker = html.escape(voice_name)
body = html.escape(text)
p_cls = ' class="caret"' if typing else ""
return f'<div class="fe narr"><span class="narr-voice">{speaker}</span><p{p_cls}>{body}</p></div>'
def _say_line(item: dict, *, mind_reader: bool) -> str:
name = html.escape(item.get("agent") or "")
said = html.escape(item.get("said") or "")
line = f'<div class="say-line"><b class="disp">{name}</b><span>{said}</span></div>'
thought = item.get("thought")
if mind_reader and thought:
thought_html = html.escape(thought)
line += f'<div class="say-think">↳ <i class="thought">{thought_html}</i></div>'
# The line wears the speaker's own phosphor (name, accent border, thought tint) so the
# transcript is colour-coded to the cast β€” the same hue as their MindCard and avatar.
# A hue-less line (e.g. an un-cast actor) falls back to the CSS default accent.
hue = item.get("hue")
style = f' style="--ac:{agent_color(int(hue))};--acd:{agent_color_dim(int(hue))}"' if hue is not None else ""
return f'<div class="fe say"{style}>{line}</div>'
def _poke_line(item: dict) -> str:
label = html.escape(item.get("label") or "DISTURBANCE")
text = html.escape(item.get("text") or "")
return f'<div class="fe poke"><span class="poke-tag">{_BOLT} {label}</span><p>{text}</p></div>'
def _verdict_line(item: dict) -> str:
text = html.escape(item.get("text") or "")
return f'<div class="fe verdict-fe"><span class="poke-tag">{_SCALES} VERDICT</span><p>{text}</p></div>'
def _commentate_line(item: dict) -> str:
"""A color-commentary card: the critic's funny line, with badges for any media.
The image and audio themselves render as NATIVE gr.Image / gr.Audio in the "FROM THE
RAFTERS" cutaway (app shell), not as inline ``<img>``/``<audio>`` here β€” in Gradio 5+
the static-file route moved to ``/gradio_api/file=``, so the old hand-built ``/file=``
tags 404. The feed keeps the text beat plus small ``🎨 illustrated`` / ``πŸ”Š voiced``
badges so the transcript still records that a beat carried media; a text-only beat
(offline, or a live media failure) renders as just the tagged line."""
text = html.escape(item.get("text") or "")
badges = []
if (item.get("image") or {}).get("src"):
badges.append('<span class="cm-badge">&#127912; illustrated</span>')
if (item.get("audio") or {}).get("src"):
badges.append('<span class="cm-badge">&#128266; voiced</span>')
badge_html = f'<div class="cm-badges">{"".join(badges)}</div>' if badges else ""
parts = [
f'<span class="poke-tag cm-tag">{_QUILL} FROM THE RAFTERS</span>',
f'<p class="cm-text">{text}</p>',
badge_html,
]
# Wear the critic's own phosphor (its manifest hue), like every other coloured feed line.
hue = item.get("hue")
style = f' style="--ac:{agent_color(int(hue))};--acd:{agent_color_dim(int(hue))}"' if hue is not None else ""
return f'<div class="fe commentate"{style}>' + "".join(parts) + "</div>"
def render_feed(
vm: dict,
*,
mind_reader: bool,
typed_n: int | None = None,
dense: bool = False,
) -> str:
"""Render the narrator/event transcript as a single HTML string.
Parameters
----------
vm:
A view-model dict from :func:`src.ui.fishbowl.view_model_at`. Reads
``vm["feed"]`` and ``vm["voice_meta"]["name"]``.
mind_reader:
When ``True``, ``say`` lines append the agent's inner ``thought``.
typed_n:
When not ``None``, the *head* (latest) ``narrate`` line is sliced to this many
characters β€” the typewriter effect. A trailing caret is shown while the slice is
shorter than the full text.
dense:
When ``True``, add the compact ``dense`` modifier to the feed container.
"""
feed = vm.get("feed") or []
voice_name = (vm.get("voice_meta") or {}).get("name") or "NARRATOR"
head_narrate = _last_narrate_index(feed)
rows: list[str] = []
for idx, item in enumerate(feed):
kind = item.get("kind")
if kind == "narrate":
full = item.get("text") or ""
if typed_n is not None and idx == head_narrate:
shown = full[: max(0, typed_n)]
typing = len(shown) < len(full)
else:
shown = full
typing = False
rows.append(_narrate_line(voice_name=voice_name, text=shown, typing=typing))
elif kind == "poke":
rows.append(_poke_line(item))
elif kind == "verdict":
rows.append(_verdict_line(item))
elif kind == "say":
rows.append(_say_line(item, mind_reader=mind_reader))
elif kind == "commentate":
rows.append(_commentate_line(item))
# unknown kinds are silently skipped
container_cls = "feed scroll dense" if dense else "feed scroll"
return f'<div class="{container_cls}">' + "".join(rows) + "</div>"