Nekochu's picture
live-stream: separate <think> from the answer. Backend marks the think->answer boundary; UI labels the live text 'thinking' WHILE reasoning, then collapses it to a 'reasoned' tag and shows the answer distinctly - so the streaming think is never mistaken for the final answer.
52dd24c verified
Raw
History Blame Contribute Delete
68.1 kB
"""MiniCPM5-1B-Agent - agentic coding demo Space.
Wires the SAME backend agent loop used by the eval (backend/agent.py: think -> tool-call ->
run -> read output -> debug -> verify) around the shipped Q8_0 GGUF, served by llama-server.
The user gives a coding task; the agent writes/runs/fixes code in a sandbox and we render the
full write->run->verify trajectory + final answer + produced files INLINE in the chat thread.
Runtime layout (Docker /app): app.py · backend/agent.py · data/schema.py · tokenizer/ · model.gguf
Env (set by Dockerfile, overridable for local test):
CODEAGENT_PROJ -> dir holding data/schema.py (default: this file's dir)
CODEAGENT_LLAMA_BIN -> llama-server binary (default: "llama-server")
CODEAGENT_GGUF -> path to the Q8_0 GGUF (default: /app/model.gguf)
CODEAGENT_CTX -> llama-server context (default 8192)
CODEAGENT_THREADS -> llama-server threads (default 2 - free 2-vCPU tier)
"""
import os, sys, glob, html, atexit, shutil, tempfile, time, threading
HERE = os.path.dirname(os.path.abspath(__file__))
# agent.py reads these; set BEFORE importing it so PROJ/LLAMA_BIN resolve to the Space layout.
os.environ.setdefault("CODEAGENT_PROJ", HERE) # -> data/schema.py is at HERE/data
os.environ.setdefault("CODEAGENT_LLAMA_BIN", "llama-server")
sys.path.insert(0, HERE) # for `import backend.agent`
sys.path.insert(0, os.path.join(HERE, "backend")) # agent.py also imported flat
import gradio as gr
from transformers import AutoTokenizer
import backend.agent as agent
GGUF = os.environ.get("CODEAGENT_GGUF", "/app/model.gguf")
CTX = int(os.environ.get("CODEAGENT_CTX", "8192"))
THREADS = int(os.environ.get("CODEAGENT_THREADS", "2"))
MAX_ITERS = int(os.environ.get("CODEAGENT_MAX_ITERS", "8"))
# PER-TURN ACTION budget (phase B). The runaway <think> is bounded SEPARATELY (CODEAGENT_THINK_CAP, default
# 1024), so this only sizes the model's ACTION (a tool call / a file write / the final answer). Balance: at
# ~8 tok/s on the free 2-vCPU tier, a full-budget action costs ~budget/8 seconds, so an over-large cap makes
# a verbose turn take 20+ min. 6144 fits a clean, compact file write (the prompt now tells the model to WRITE
# the file, not paste it) while keeping turns reasonable; and since the Chatbot no longer strips iframe srcdoc
# (sanitize_html=False), a page that DOES exceed the cap now renders PARTIALLY via the salvage path instead of
# as a blank box - so truncation degrades gracefully rather than failing hard. Override via CODEAGENT_NPRED.
NPRED = int(os.environ.get("CODEAGENT_NPRED", "6144"))
# Private deployment: if the GGUF isn't already on disk, pull it from the (PRIVATE) model repo using the
# HF_TOKEN Space secret. (Local/public runs instead point CODEAGENT_GGUF at an existing file.)
if not os.path.exists(GGUF) and os.environ.get("MODEL_REPO"):
from huggingface_hub import hf_hub_download
_repo, _fn = os.environ["MODEL_REPO"], os.environ["MODEL_FILE"]
print(f"[app] downloading {_repo}/{_fn} (private; HF_TOKEN={'set' if os.environ.get('HF_TOKEN') else 'MISSING'}) ...", flush=True)
GGUF = hf_hub_download(repo_id=_repo, filename=_fn, token=os.environ.get("HF_TOKEN"), local_dir="/app")
# The tokenizer lives WITH the model (in the GGUF repo under tokenizer/) - one source of truth, never bundled
# in the Space. Pull it from MODEL_REPO with the same HF_TOKEN secret used for the GGUF.
_tokdir = os.path.join(HERE, "tokenizer")
if not os.path.exists(os.path.join(_tokdir, "tokenizer.json")) and os.environ.get("MODEL_REPO"):
from huggingface_hub import snapshot_download
print(f"[app] downloading tokenizer from {os.environ['MODEL_REPO']}/tokenizer ...", flush=True)
snapshot_download(repo_id=os.environ["MODEL_REPO"], allow_patterns="tokenizer/*",
token=os.environ.get("HF_TOKEN"), local_dir=HERE)
print(f"[app] loading tokenizer from {_tokdir} ...", flush=True)
TOK = AutoTokenizer.from_pretrained(_tokdir, trust_remote_code=True)
# SECURITY: the GGUF + tokenizer are now downloaded, so HF_TOKEN is no longer needed. DROP it from the
# process env BEFORE any per-turn Sandbox is created - the bash tool runs model-emitted shell with the full
# inherited env, so leaving the private-repo secret in os.environ would let a task `echo $HF_TOKEN` exfil it.
os.environ.pop("HF_TOKEN", None)
os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)
print(f"[app] starting llama-server on {GGUF} (ctx={CTX}, threads={THREADS}, ngl=0) ...", flush=True)
SERVER = agent.LlamaServer(GGUF, port=8099, ctx=CTX, threads=THREADS, ngl=0)
SERVER.__enter__() # block until /health ok; kept alive for the app lifetime
atexit.register(lambda: SERVER.__exit__(None, None, None))
print("[app] llama-server healthy - ready.", flush=True)
# Web tools: AUTO-DETECT at startup (no manual flag). If the internet is reachable, turn on web_search/web_fetch.
WEB_ON = agent.web_available()
if WEB_ON:
agent.enable_web()
print("[app] internet reachable -> web tools ON (web_search + web_fetch incl. Reddit via arctic_shift)", flush=True)
else:
print("[app] no internet -> web tools OFF (fully local / Off-the-Grid)", flush=True)
# Best-effort: make /workspace exist + writable. The 1B (Claude-Code habit) sometimes savefig's/writes to an
# ABSOLUTE '/workspace/...' path INSIDE its script - the bash-path rewrite can't reach in-code paths, so without
# this the write would fail (or land at an unscanned root) and the artifact would never render. With /workspace
# present, those writes succeed and _extra_media() scans it (this-turn-only). Harmless if perms deny it (the
# model then uses relative paths in the per-session sandbox, which _media_messages already scans).
try:
os.makedirs("/workspace", exist_ok=True)
except Exception:
pass
# Every example is a NATURAL request (how a real user actually asks - no backend hand-holding; the
# save/run/verify conventions live in the SYSTEM PROMPT, not here) and was VALIDATED on the shipped dpo_v3
# GGUF through THIS agent loop. The first two PASS on the fine-tune but FAIL on the un-tuned base, so they
# showcase what the training added. The 1B handles a chart, an HTML mini-app, and a computed answer, but ONLY
# when the prompt is concrete (name the .html file / "hard-coded, no internet"); an open-ended "make a web
# page" reliably gets mis-read as a Python CLI or triggers a pointless web_search. (Charts/GIFs/games need
# step-by-step spoon-feeding a 1B can't infer from a natural prompt - left out.)
# Each example exercises a DISTINCT capability so clicking through them shows the range: a matplotlib
# chart -> PNG, an interactive HTML mini-app, and a compute + web look-up.
EXAMPLES = [
# python chart -> image shown inline in the chat (phrased as "Python script ... saves a PNG ... run it" so the 1B
# writes a .py + runs it, instead of dumping code into the .png; full ctx + the save-plots system rule help)
"Write a Python script that makes a bar chart of the values 30, 45, 25 labeled A, B, C, saves it as chart.png, then run it.",
# interactive HTML mini-app -> renders live inline. EXPLICIT phrasing ("Write an HTML page <file>.html ...
# hard-coded ... no internet") so the 1B writes a single self-contained .html FILE instead of mis-reading
# "web page" as a Python CLI or web_search'ing (the open-ended "make a little web page" prompt reliably
# botched both). Mirrors the write-a-file pattern the 1B nails.
"Write an HTML page quote.html with a button that shows a different random quote on each click. Use a few hard-coded quotes in JavaScript - no internet needed.",
# practical compute + WEB look-up (when online): "look up this year's avg rent" implicitly needs web_search
"How many $40 video games can I buy in a year if I make $2000 a month and pay rent? Look up this year's average rent in the USA, then work it out for me.",
]
import re as _re
# Display-only cruft: control tokens AND leaked tool-call XML. When the 1B emits a malformed/partial tool call,
# raw <function>/<param>/CDATA fragments can spill into the rendered bubble (e.g. "...]]></param></function>");
# strip those tags from the SHOWN text only. This does NOT touch the agent loop's real XML parsing in agent.py.
_CRUFT = _re.compile(
r"</?think>|<\|im_(start|end)\|>|<\|endoftext\|>"
r"|</?function[^>]*>|</?param[^>]*>|<!\[CDATA\[|\]\]>")
def _clean(t):
"""Display-only: strip special/control tokens AND leaked tool-call XML that can spill into rendered text
(no effect on the agent loop's parsing)."""
return _CRUFT.sub("", t or "").strip()
def _fmt_tool_call(c):
# Tool calls are stored in the canonical NESTED shape {"type":"function","function":{name,arguments}}
# (agent.py run_agent). Read the nested function.name/function.arguments directly; tolerate a flat dict.
fn = c.get("function") or c
name = fn.get("name", "?")
arguments = fn.get("arguments") or {}
arg_preview = ", ".join(f"{k}={str(v)[:60]!r}" for k, v in list(arguments.items())[:3])
return f"🔧 **`{name}`**({arg_preview})"
def _tool_disclosure(body, max_chars=1500):
"""One collapsible <details> block for a tool's (possibly long) output - de-dupes the two identical
blocks the renderer used for role:tool and the legacy <tool_response> user message."""
return f"<details><summary>📤 tool output</summary>\n\n```\n{str(body)[:max_chars]}\n```\n</details>"
def _assistant_parts(m):
"""Read an assistant turn's display parts from the CANONICAL stored fields (reasoning_content,
tool_calls, content) rather than re-parsing content. On a tool-calling turn the loop already split
<think>/tool-calls out and set content="", so re-parsing would recover nothing. Returns
(reasoning, tool_calls, final)."""
return (_clean(m.get("reasoning_content") or ""),
m.get("tool_calls") or [],
_clean(m.get("content") or ""))
def _render(messages, max_chars=1500):
"""Render the write->run->verify trajectory as readable markdown (assistant turns only - the user's
task is already shown as their own chat bubble). Renders the CANONICAL message fields the agent loop
stored (reasoning_content, tool_calls, content) directly - it does NOT re-parse content, because on a
tool-calling turn the loop already split <think>/tool-calls out and set content="", so re-parsing would
recover nothing and the turn would render blank."""
out = []
for m in messages:
role = m.get("role")
content = m.get("content") or ""
if role in ("system", "user"):
if role == "user" and content.startswith("<tool_response>"):
body = content.replace("<tool_response>", "").replace("</tool_response>", "").strip()
out.append(_tool_disclosure(body, max_chars))
continue
if role == "tool":
out.append(_tool_disclosure(content, max_chars))
continue
# assistant: render the canonical fields the loop stored (reasoning_content, tool_calls, content).
reasoning, tool_calls, final = _assistant_parts(m)
if reasoning:
out.append(f"<details><summary>💭 thinking</summary>\n\n{html.escape(reasoning[:max_chars])}\n</details>")
for c in tool_calls:
out.append(_fmt_tool_call(c))
if final:
# The final answer renders as PLAIN chat-bubble text (it is already in the assistant bubble) - no
# "🤖"-narration prefix or bold framing that made it read like internal monologue / blur with the
# user's own turn. Reasoning + tool output stay in their collapsible <details> blocks above.
out.append(html.escape(final[:max_chars]))
return "\n\n".join(out)
IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp")
SVG_EXT = (".svg",) # rendered inline as HTML (gr.Image rasterizers don't reliably handle SVG)
VID_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
def _copy_out(p, outdir):
"""Copy an artifact OUT of the (ephemeral, per-session) workspace into a stable temp dir Gradio can
serve, and return the destination path."""
dst = os.path.join(outdir, os.path.basename(p))
shutil.copy2(p, dst)
return dst
def _media_bubble(content):
"""Wrap a media component (gr.Image/gr.Video/gr.HTML) as one assistant chat message so it renders
INLINE in the thread (ChatGPT-style), not in a separate box."""
return {"role": "assistant", "content": content}
def _html_iframe(body):
"""A live, SANDBOXED iframe for an agent-produced .html page (opaque origin: no allow-same-origin, so
the page's JS/forms/buttons run but stay contained), wrapped in a tiny browser-chrome "live preview"
frame so the running mini-app reads as a real artifact, not a floating white rectangle. srcdoc is
html.escape'd (quote=True default) so a " in the page can't break out of the attribute. Sizing/border
come from CSS (.lp-frame / .lp-frame iframe)."""
iframe = (f'<iframe sandbox="allow-scripts" referrerpolicy="no-referrer" '
f'srcdoc="{html.escape(body)}"></iframe>')
return (f'<div class="lp-frame">'
f'<div class="lp-bar"><span class="lp-dot"></span>'
f'<span class="lp-label">live preview</span></div>{iframe}</div>')
_HTML_START = _re.compile(r"<!DOCTYPE html|<html[\s>]", _re.IGNORECASE)
def _salvage_html(text):
"""The 1B often PASTES a full HTML page into its prose answer instead of write()-ing a file (so no .html
file exists to iframe). Extract the document so the demo still renders it LIVE - and tolerate a page that
got CUT OFF mid-tag (no </html>): render what we have (browsers are lenient) and flag it so the chat can
say WHY it looks broken. Returns (html, warning) - warning is None when the page is complete."""
if not isinstance(text, str):
return None, None
m = _HTML_START.search(text)
if not m:
return None, None
doc = text[m.start():]
end = doc.lower().rfind("</html>")
if end != -1: # complete page: trim trailing prose after </html>
return doc[:end + 7].strip(), None
return doc.strip(), ("the model pasted the page into its reply instead of saving it to a file, then hit the "
"length limit and got cut off (no `</html>`). Rendering the partial page; ask it to "
"*save the page to an .html file with the write tool* so the whole thing renders.")
def _valid_image(p):
"""True only if p starts with a real raster-image magic number. Guards against the 1B 'writing' a chart.png
as TEXT (a bogus file that would render as an empty/0x0 bubble) - such a file fails this check and is skipped."""
try:
with open(p, "rb") as f:
head = f.read(12)
except OSError:
return False
return (head.startswith(b"\x89PNG") or head.startswith(b"\xff\xd8\xff") or head[:4] == b"GIF8"
or head[:2] == b"BM" or (head[:4] == b"RIFF" and head[8:12] == b"WEBP"))
def _render_media_file(p, outdir):
"""Map ONE produced file to an inline chat bubble by extension, or None if it's not media. png/jpg/gif/... ->
gr.Image; .svg / .html -> a sandboxed iframe (SVG is active content, so never inline it raw into the parent
DOM); video -> gr.Video. Shared by the sandbox scan AND the extra-dir scan."""
ext = os.path.splitext(p)[1].lower()
try:
if ext in IMG_EXT:
return _media_bubble(gr.Image(_copy_out(p, outdir))) if _valid_image(p) else None
if ext in VID_EXT:
return _media_bubble(gr.Video(_copy_out(p, outdir)))
if ext in SVG_EXT or ext in (".html", ".htm"):
return _media_bubble(gr.HTML(_html_iframe(open(p, encoding="utf-8", errors="replace").read())))
except Exception:
return None
return None
def _extra_media(dirs, since_ts, exclude_dir):
"""Catch media the model wrote to an ABSOLUTE path OUTSIDE its per-session sandbox - e.g. a savefig to
'/workspace/chart.png' INSIDE the script (a Claude-Code habit the bash-path rewrite can't reach, so the PNG
lands at /workspace, not the scanned sandbox dir). Scan the given dirs for media modified THIS turn
(mtime > since_ts) so a fresh session never re-shows a prior session's files (concurrency_limit=1 => one run
at a time, race-free). Dirs that don't exist (e.g. /workspace on Windows) are skipped; the sandbox is excluded."""
msgs = []
outdir = tempfile.mkdtemp(prefix="codeagent_out_")
exclude = os.path.abspath(exclude_dir) if exclude_dir else None
for d in dirs:
if not (d and os.path.isdir(d)) or (exclude and os.path.abspath(d) == exclude):
continue
for p in sorted(glob.glob(os.path.join(d, "**", "*"), recursive=True))[:40]:
if not os.path.isfile(p):
continue
try:
if os.path.getmtime(p) <= since_ts:
continue
except OSError:
continue
b = _render_media_file(p, outdir)
if b is not None:
msgs.append(b)
return msgs
def _media_messages(ws, since=0.0):
"""Scan the persistent workspace and turn whatever the agent PRODUCED/CHANGED THIS TURN into inline
chat messages (gr.Chatbot type='messages'): images/gifs/charts (png/jpg/jpeg/gif/bmp/webp) -> gr.Image,
.svg -> inline gr.HTML, videos -> gr.Video, .html -> a live sandboxed iframe via gr.HTML. Non-media
files are ignored. `since` is an mtime watermark: only files modified
after it are shown, so prior turns' artifacts are NOT re-rendered. Returns (msgs, new_watermark).
Nothing is hard-coded to a task."""
msgs, watermark = [], since
if not (ws and os.path.isdir(ws)):
return msgs, watermark
outdir = tempfile.mkdtemp(prefix="codeagent_out_") # copy out so Gradio can serve a stable path
for p in sorted(glob.glob(os.path.join(ws, "**", "*"), recursive=True))[:40]:
if not os.path.isfile(p):
continue
try:
mt = os.path.getmtime(p)
except OSError:
continue
if mt > watermark:
watermark = mt
if mt <= since: # produced/changed in an EARLIER turn -> skip
continue
b = _render_media_file(p, outdir) # png/jpg/.. -> Image; svg/html -> sandboxed iframe; video -> Video
if b is not None:
msgs.append(b)
return msgs, watermark
def _new_session():
"""A fresh per-browser-session state. The DISPLAYED chat lives in `chat` here (gr.State) so the
Chatbot is purely an OUTPUT - component-valued media bubbles never round-trip through the Chatbot's
preprocess. `mtime` is the media watermark; `sandbox`/`history` persist the agent workspace + convo;
`token` is the Gradio session_hash so demo.unload can clean THIS session's sandbox on leave."""
return {"sandbox": None, "history": None, "chat": [], "mtime": 0.0, "token": None}
# When the user clicks "🔄 New" mid-generation, Gradio's cancels= stops THIS run generator (frees the UI for
# the fresh session). reset_session ALSO flips this per-token flag so, if the generator yields once more before
# cancellation lands, its live indicator switches from "Ns..." to "cancel Ns..." - the user sees New registered
# and the old turn aborting. (run_agent has no cooperative abort hook, so the background worker finishes the
# current turn on its own; its result is simply discarded because the session was reset.)
_CANCEL_FLAGS = {} # session token -> threading.Event (set => the in-flight run for this session is cancelling)
def _cancel_event(sess):
tok = (sess or {}).get("token")
if not tok:
return None
ev = _CANCEL_FLAGS.get(tok)
if ev is None:
ev = _CANCEL_FLAGS[tok] = threading.Event()
return ev
_WORD_RE = _re.compile(r"[A-Za-z]{3,}") # an alphabetic word of length >= 3
def _is_gibberish(task):
"""CONSERVATIVE frontend guard ONLY (does NOT alter the model's act-not-ask behavior on real tasks): treat
a task as gibberish if it is too short or has no real word - e.g. an accidental 'dd'. Anything with a
>=3-letter alphabetic word (a genuine short request like 'plot sin x') is NOT gibberish and runs normally."""
t = (task or "").strip()
if len(t.replace(" ", "")) <= 3: # <= 3 non-space chars (e.g. "dd", "ok", "?!")
return True
if len(set(t.replace(" ", ""))) <= 1: # a single repeated char (e.g. "aaaa")
return True
if not _WORD_RE.search(t): # no alphabetic word of length >= 3 anywhere
return True
return False
def run(task, sess, request: gr.Request = None):
"""One turn (STREAMING generator): append the user's task + the agent's trajectory and any produced media
INLINE in the chat. The displayed chat is driven from gr.State (sess['chat']); the Chatbot itself is never
read as input. The blocking agent loop runs in a worker thread while we yield a live elapsed-time indicator
("Ns...") so the UI stays responsive AND so Gradio has yield points to CANCEL on (the "🔄 New" button uses
cancels= to abort this generator)."""
sess = sess or _new_session()
if request is not None and not sess.get("token"):
sess["token"] = getattr(request, "session_hash", None)
task = (task or "").strip()
chat = list(sess.get("chat") or [])
if not task:
yield chat, sess, ""
return
# Frontend gibberish guard: don't burn the agent loop on an accidental "dd". Keep the typed input so the
# user can fix it; just nudge (transient - not persisted to sess['chat']) for a real task. (Conservative -
# real short requests still run; see _is_gibberish.)
if _is_gibberish(task):
nudge = chat + [{"role": "assistant",
"content": "What would you like me to build or run? Describe a coding task."}]
yield nudge, sess, gr.update()
return
# html.escape the user's text for DISPLAY only (the agent still gets the raw `task`): with sanitize_html
# off, an unescaped angle-bracket the user types (e.g. "<h1>" in a coding question, or a pasted snippet)
# would otherwise be interpreted as live HTML in the bubble - vanishing from view (or, for <script>/
# onerror, executing). Escaping shows it verbatim and closes the user-input injection path.
chat = chat + [{"role": "user", "content": html.escape(task)}]
cancel = _cancel_event(sess)
if cancel is not None:
cancel.clear() # fresh turn: not cancelling (yet)
# run_agent returns the FULL cumulative conversation (it prepends list(history) + [task]); record where
# this turn's new messages start so we render ONLY the just-produced turn, not all of history every time.
prev = sess.get("history") or []
base = len(prev)
holder = {}
def _on_step(msgs, partial=None): # STREAM: snapshot trajectory-so-far + the in-progress generation
holder["partial"] = list(msgs) # shallow copy: the worker keeps appending to `msgs`
holder["gen"] = partial # token-level: the text being generated NOW (None once the step lands)
def _work():
try:
# MULTI-TURN: reuse the session's sandbox + conversation so files persist and you can iterate.
# n_predict=32768: give the model room for full multi-step reasoning (the run_agent default of 1024
# truncates the <think> trace mid-thought). ctx stays the env value (131072), so the prompt budget
# is ctx - n_predict and long sessions still fit.
holder["res"] = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0,
n_predict=NPRED, on_step=_on_step,
keep_workspace=True, sandbox=sess.get("sandbox"), history=sess.get("history"))
except Exception as e: # noqa: BLE001 - surfaced to the user below
holder["err"] = e
worker = threading.Thread(target=_work, daemon=True)
worker.start()
# Live indicator while the worker runs: a transient assistant bubble appended ONLY to the yielded chat
# (never persisted to sess['chat']). Switches to a "cancel Ns..." readout once 🔄 New flips the flag.
t0 = time.time()
while worker.is_alive():
worker.join(timeout=1.0)
if not worker.is_alive():
break
secs = int(time.time() - t0)
cancelling = cancel is not None and cancel.is_set()
label = f"cancel {secs}s... (starting a new chat)" if cancelling else f"working... {secs}s"
foot = f'<span class="live-ind"><span class="live-cursor">▍</span> {label}</span>'
# STREAM: show the trajectory produced SO FAR (each completed step appears live: tool call -> its output
# -> next call), with the working footer beneath it - instead of a bare timer for the whole run. partial
# is set by _on_step; partial[base:] mirrors the FINAL render slice (line below) so the view is consistent.
partial = holder.get("partial")
gen = holder.get("gen")
parts = []
if partial is not None and len(partial) > base + 1:
parts.append(_render(partial[base:]))
if gen: # token-level: the generation typing out now (escaped, muted, tail)
# split think vs answer on the </think> boundary (the backend marks it) so the live <think> is clearly
# labelled WHILE reasoning, then COLLAPSES to a small tag once done - so it's never mistaken for the answer.
_think, _sep, _ans = gen.partition("</think>")
_think = _think.replace("<think>", "").strip()
cur = '<span class="live-cursor">▍</span>'
if not _sep: # still reasoning
parts.append(f'<div class="live-gen"><span class="live-think-tag">▍ thinking</span> '
f'{html.escape(_think[-600:])}{cur}</div>')
else: # reasoning done -> collapse think to a tag, show the answer typing
ans = html.escape(_ans.strip()[-700:])
parts.append(f'<div class="live-think-tag">▍ reasoned</div>'
f'<div class="live-answer">{ans}{cur}</div>')
parts.append(foot)
yield chat + [{"role": "assistant", "content": "\n\n".join(parts)}], sess, ""
if "err" in holder:
e = holder["err"]
chat = chat + [{"role": "assistant", "content":
("⚠️ The agent couldn't finish this one - a 1B on free CPU can struggle on hard tasks. "
"Try rephrasing, or click the 🗑 trash icon (top-right of the chat) for a fresh session.\n\n"
f"`{type(e).__name__}: {str(e)[:300]}`")}]
sess["chat"] = chat
yield chat, sess, ""
return
res = holder["res"]
sess["sandbox"] = res.get("sandbox") # persist workspace + history for the NEXT turn (iterate further)
sess["history"] = res.get("messages")
_register_sandbox(sess) # so demo.unload can rmtree THIS session's dir on leave
transcript = _render(res["messages"][base:]) # only THIS turn's new assistant + tool messages
header = (f"_iters={res.get('iters')} · tool-calls={res.get('tool_calls_made')} · "
f"{res.get('tool_counts')}_\n\n")
# token-speed readout: a small, muted, right-aligned line at the END of the assistant turn (Gradio has no
# native t/s widget, so render it as styled HTML appended to the final text bubble). Omit if missing/zero.
tps = res.get("tps") or {}
tg, pp = tps.get("tg") or 0, tps.get("pp") or 0
speed = ""
if tg > 0:
pp_part = f" - PP {pp:.0f} tok/s" if pp > 0 else ""
speed = (f'\n\n<div style="text-align:right;font-size:0.78em;opacity:0.55">'
f'TG {tg:.0f} tok/s{pp_part}</div>')
chat = chat + [{"role": "assistant", "content": header + transcript + speed}]
# matplotlib (MPLBACKEND=Agg) saves plots to PNG files, so charts flow through this same inline path.
# mtime watermark: only files produced/changed THIS turn become bubbles (no re-showing prior artifacts).
media, sess["mtime"] = _media_messages(res.get("workspace"), since=sess.get("mtime") or 0.0)
# ALSO catch media the model wrote to an ABSOLUTE path OUTSIDE the per-session sandbox (e.g. a savefig to
# '/workspace/chart.png' inside the script - the bash-path rewrite can't reach in-code paths). this-turn-only
# (mtime > t0, the run start) so no cross-session leak. /workspace is the dominant Claude-Code habit.
media = media + _extra_media(["/workspace"], since_ts=t0, exclude_dir=res.get("workspace"))
# SALVAGE: a 1B often pastes a full HTML page into its prose answer instead of write()-ing an .html file
# (so _media_messages finds no file to iframe). If no .html was written this turn but the answer/transcript
# contains a full HTML document, render it LIVE anyway so the web-page example still works.
_ws = res.get("workspace")
if not (_ws and glob.glob(os.path.join(_ws, "**", "*.html"), recursive=True)):
_doc, _warn = _salvage_html(res.get("final", ""))
if not _doc:
_doc, _warn = _salvage_html(transcript)
if _doc:
media = media + [_media_bubble(gr.HTML(_html_iframe(_doc)))]
if _warn: # surface the truncation so the user knows WHY it looks broken
media = media + [{"role": "assistant", "content": f"⚠️ HTML note: {_warn}"}]
chat = chat + media
sess["chat"] = chat # persist the displayed chat in gr.State (drives the Chatbot)
yield chat, sess, ""
def run_coding_task(task: str) -> str:
"""Run a coding task with the MiniCPM5-1B agent and return the final answer.
The agent writes, runs, and fixes code in a fresh sandbox (the write -> run -> verify loop),
then returns its final answer as plain text. This is the MCP-callable entrypoint: an external
MCP client (Claude Desktop, Cursor, Cline, ...) can call it to have the agent complete a coding
task. Each call is independent and uses its own ephemeral workspace (no shared session state),
so it is safe to call concurrently with the web UI.
Args:
task (str): A natural-language coding request, e.g. "Write a Python script that makes a bar
chart of 30, 45, 25 labeled A, B, C, saves it as chart.png, then run it."
Returns:
str: The agent's final answer text. Note: any files the agent produces (charts, pages) live
in its ephemeral sandbox and are not returned by this MCP tool, only the textual answer.
"""
task = (task or "").strip()
if not task:
return "Please provide a coding task to run."
try:
# Own sandbox per call (sandbox=None), cleaned up afterward (keep_workspace=False). Same
# n_predict=32768 as the UI so reasoning is not truncated.
res = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0,
n_predict=NPRED, keep_workspace=False)
except Exception as e: # noqa: BLE001 - surfaced to the MCP client as text
return f"The agent could not finish this task: {type(e).__name__}: {str(e)[:300]}"
return _clean(res.get("final") or "") or "(the agent finished without a textual answer)"
def reset_session(sess):
"""Drop the persistent workspace + history and start a FRESH session (its own new sandbox dir). If a turn
is in flight, flip its cancel flag so the live indicator shows 'cancel Ns...' (the run generator is also
aborted by cancels= on the button); carry the session token into the new state so cancels= keeps targeting
the right session."""
tok = (sess or {}).get("token")
ev = _CANCEL_FLAGS.get(tok) if tok else None
if ev is not None:
ev.set() # signal the in-flight run to show 'cancelling' before cancels= lands
try:
if sess and sess.get("sandbox") is not None:
sess["sandbox"].cleanup() # rmtree the old per-session workspace so files never leak/accumulate
except Exception:
pass
fresh = _new_session()
fresh["token"] = tok # same browser session keeps its token (clean flag registry lookup)
if ev is not None:
ev.clear() # the old turn is gone; the fresh session starts un-cancelled
return [], fresh, ""
MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # reject files larger than ~2GB
def attach_file(filepath, task, sess, request: gr.Request = None):
"""DRAG-DROP / attach (ChatGPT-style): save the dropped file INTO the current session's sandbox
workspace and APPEND its '/workspace/<name>' path as plain text to the chat input, so the agent
sees the path and the user can still edit/remove it. Returns (updated_textbox, sess, status_html)."""
sess = sess or _new_session()
if request is not None and not sess.get("token"):
sess["token"] = getattr(request, "session_hash", None)
task = task or ""
if not filepath:
return gr.update(), sess, ""
try:
size = os.path.getsize(filepath)
except OSError:
return gr.update(), sess, "⚠️ couldn't read the dropped file"
if size > MAX_UPLOAD_BYTES:
return gr.update(), sess, f"⚠️ file too large ({size // (1024*1024)} MB) - max 2 GB"
# ensure THIS session owns a sandbox (its own fresh per-session working dir) before writing into it.
if sess.get("sandbox") is None:
sess["sandbox"] = agent.Sandbox()
sb = sess["sandbox"]
_register_sandbox(sess) # so demo.unload can rmtree THIS session's dir on leave
name = os.path.basename(filepath)
try:
dst = sb._resolve(name)
shutil.copy2(filepath, dst)
except Exception as e:
return gr.update(), sess, f"⚠️ couldn't save the file: {type(e).__name__}"
# don't let the just-uploaded file get rendered as a "produced" media bubble next turn.
try:
mt = os.path.getmtime(dst)
if mt > (sess.get("mtime") or 0.0):
sess["mtime"] = mt
except OSError:
pass
ref = f"/workspace/{name}"
new_task = (task.rstrip() + (" " if task.strip() else "") + ref).lstrip()
return new_task, sess, f"📎 added `{ref}` ({size // 1024 if size < 1024*1024 else size // (1024*1024)}"\
f"{' KB' if size < 1024*1024 else ' MB'}) - edit or remove the path before sending"
# ── Session isolation: this Space is SHARED (one container, many browser sessions). Each session gets
# its OWN tempfile.mkdtemp sandbox (agent.Sandbox), so files never leak/accumulate between users. We also
# sweep ORPHANED sandbox temp dirs (a user who left without firing demo.unload, e.g. a crashed tab) older
# than SANDBOX_TTL_S, so disk doesn't fill up on the free 2-vCPU tier.
SANDBOX_TTL_S = int(os.environ.get("CODEAGENT_SANDBOX_TTL", str(30 * 60))) # ~30 min
_SWEEP_PREFIXES = ("agent_ws_", "codeagent_out_") # Sandbox dirs + the copied-out media dirs
def _sweep_orphans():
"""rmtree any sandbox/media temp dirs older than SANDBOX_TTL_S (by mtime). Best-effort; never raises."""
root = tempfile.gettempdir()
cutoff = time.time() - SANDBOX_TTL_S
try:
names = os.listdir(root)
except OSError:
return
for n in names:
if not n.startswith(_SWEEP_PREFIXES):
continue
p = os.path.join(root, n)
try:
if os.path.isdir(p) and os.path.getmtime(p) < cutoff:
shutil.rmtree(p, ignore_errors=True)
except OSError:
pass
def _sweeper_loop():
while True:
time.sleep(SANDBOX_TTL_S // 3 or 600)
_sweep_orphans()
threading.Thread(target=_sweeper_loop, daemon=True).start()
# Per-session sandbox registry so demo.unload can clean THE session that just left. Gradio's unload
# handler can't receive gr.State, so we map a session token -> its Sandbox and clean by token on unload.
# (The TTL sweeper is the backstop for any session that leaves without firing unload, e.g. a crashed tab.)
_SESSION_SANDBOXES = {} # token -> agent.Sandbox
def _register_sandbox(sess):
if sess and sess.get("sandbox") is not None and sess.get("token"):
_SESSION_SANDBOXES[sess["token"]] = sess["sandbox"]
def on_unload(request: gr.Request):
"""Fires when a browser session ENDS (tab closed / navigated away, possibly mid-run). Clean up ONLY
that session's sandbox dir - NEVER the shared llama-server (other users depend on it)."""
try:
tok = getattr(request, "session_hash", None)
sb = _SESSION_SANDBOXES.pop(tok, None)
if sb is not None:
sb.cleanup()
_CANCEL_FLAGS.pop(tok, None) # drop this session's cancel flag too
except Exception:
pass
_sweep_orphans() # also clear anything else already past TTL
# ── Custom theme: "Solder & Amber" - a warm-charcoal WORKBENCH look ────────────────────────────────
# Built on gr.themes.Base (minimal defaults -> we own the palette). Density comes from the SIZE TOKENS
# (spacing_sm / radius / text_sm) and tight padding tokens; the css= block below adds the workbench
# texture, role-by-gutter chat treatment, and micro-interactions the tokens can't express. Palette:
# ACCENT #E8932B amber (the single action color: primary button, focus ring, links, gutter rail)
# SUCCESS #5E8C7D oxidized teal (reserved for "verify passed") · base charcoal #1A1614, text parchment.
from gradio.themes.utils import colors, sizes, fonts
# "Solder & Amber" - a warm-charcoal WORKBENCH theme (a tiny 1B running real bash/python on a free CPU reads
# as a powered-on workbench, not a cloud chatbot). Palette: charcoal #1A1614 base, taupe-brown #3A2F27 raised
# surface, amber #E8932B the single action color, oxidized teal #5E8C7D reserved ONLY for "verify passed",
# parchment #E8DDD0 / taupe #C8B6A6 text. We set the LIGHT and DARK token variants to the SAME values so the
# Gradio theme toggle can't drift the look - it is one cohesive workbench in either mode (and that collapses
# the old body:not(.dark)/body.dark CSS duality, killing a whole class of contrast bugs).
THEME = gr.themes.Base(
primary_hue=colors.orange, # amber action family
secondary_hue=colors.orange,
neutral_hue=colors.stone, # warm neutral (charcoal/taupe), not cool slate
spacing_size=sizes.spacing_sm,
radius_size=sizes.radius_sm, # tighter, more engineered corners than the old radius_lg
text_size=sizes.text_md,
# Anti-AI-slop type: an industrial humanist grotesk for chrome (NOT Inter/Roboto/system), and a condensed
# engineered mono with a vintage-terminal feel for code/tool-calls/transcript. Both load from Google Fonts.
font=(fonts.GoogleFont("Hanken Grotesk"), "ui-sans-serif", "system-ui", "sans-serif"),
font_mono=(fonts.GoogleFont("Martian Mono"), "ui-monospace", "monospace"),
).set(
body_background_fill="#1A1614",
body_background_fill_dark="#1A1614",
background_fill_primary="#1A1614",
background_fill_primary_dark="#1A1614",
background_fill_secondary="#241E1A",
background_fill_secondary_dark="#241E1A",
body_text_color="#E8DDD0",
body_text_color_dark="#E8DDD0",
body_text_color_subdued="#C8B6A6",
body_text_color_subdued_dark="#C8B6A6",
block_title_text_color="#E8DDD0",
block_title_text_color_dark="#E8DDD0",
block_label_text_color="#C8B6A6",
block_label_text_color_dark="#C8B6A6",
border_color_primary="#3A2F27",
border_color_primary_dark="#3A2F27",
# Amber action, dark text-on-amber for crisp contrast.
button_primary_background_fill="#E8932B",
button_primary_background_fill_hover="#F2A23E",
button_primary_background_fill_dark="#E8932B",
button_primary_background_fill_hover_dark="#F2A23E",
button_primary_text_color="#1A1614",
button_primary_text_color_dark="#1A1614",
button_secondary_background_fill="transparent",
button_secondary_background_fill_hover="#2E251F",
button_secondary_background_fill_dark="transparent",
button_secondary_background_fill_hover_dark="#2E251F",
button_secondary_text_color="#C8B6A6",
button_secondary_text_color_dark="#C8B6A6",
button_secondary_border_color="#3A2F27",
button_secondary_border_color_dark="#3A2F27",
# Recessed input well, amber focus ring.
input_background_fill="#241E1A",
input_background_fill_dark="#241E1A",
input_border_color="#3A2F27",
input_border_color_dark="#3A2F27",
input_border_color_focus="#E8932B",
input_border_color_focus_dark="#E8932B",
# Depth from deep warm shadows (dark theme).
shadow_drop="0 1px 2px 0 rgba(0,0,0,0.30)",
shadow_drop_lg="0 6px 20px -4px rgba(0,0,0,0.45)",
block_background_fill="#1A1614",
block_background_fill_dark="#1A1614",
block_border_width="0px",
block_shadow="none",
block_label_background_fill="transparent",
block_label_background_fill_dark="transparent",
block_padding="6px",
button_large_padding="9px 16px",
button_small_padding="6px 12px",
form_gap_width="0px",
link_text_color="#E8932B",
link_text_color_dark="#E8932B",
link_text_color_hover="#F2A23E",
)
# css = only the true deltas the theme tokens can't express. ChatGPT-style bubbles, a no-gap single-page
# column, a thin scrollbar, and one terse status line. Targets stable elem_id / elem_classes (per the
# Gradio custom-CSS guidance) rather than fragile internal selectors.
CSS = """
/* ============================================================================
"SOLDER & AMBER" - one cohesive warm-charcoal WORKBENCH skin. The theme tokens
set the light AND dark variants identically, so NONE of these rules need a
body.dark / body:not(.dark) guard (that duality was a contrast-bug factory).
ROLES are distinguished by SIDE + an amber GUTTER, never by an alternating
background fill - that alternation was the "green/gray inconsistency" the user
saw. Accent amber #E8932B = the only action color; oxidized teal #5E8C7D is
reserved for success ("verify passed"); parchment #E8DDD0 / taupe #C8B6A6 text.
============================================================================ */
/* ---- Page canvas: warm charcoal + an engineered dot-grid + a faint warm vignette
and barely-there grain, so the wide side-margins and an empty thread read as
graph-paper on a workbench, never a blank slab. Fixed so it doesn't scroll.
The Gradio container is made transparent so this texture shows through behind
and around the 880px column (incl. behind the flat, gutter-only agent turns). */
body {
background-color: #1A1614 !important;
background-image:
radial-gradient(circle at center, rgba(220,182,140,0.17) 1.1px, transparent 1.7px),
radial-gradient(135% 95% at 50% -8%, rgba(232,147,43,0.10), transparent 60%),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
background-size: 24px 24px, 100% 100%, 150px 150px;
background-position: 0 0, 0 0, 0 0;
background-attachment: fixed, fixed, fixed;
background-repeat: repeat, no-repeat, repeat;
}
.gradio-container { background: transparent !important; }
/* Global text: parchment everywhere by default; muted taupe for secondary; amber for links.
(Theme tokens already do most of this; these pins stop any stray white-on-charcoal.) */
.gradio-container, .prose, .prose *, p, li, span, label, .markdown, .markdown *,
#chat .message, #chat .message * { color: #E8DDD0; }
small, #chat summary, .ft-sep, #appfooter span { color: #C8B6A6; }
a, .markdown a, #chat a, #appfooter a { color: #E8932B; }
a:hover, #appfooter a:hover { color: #F2A23E; }
/* App column: centered, capped, app-like (not a landing page). */
.gradio-container { max-width: 880px !important; margin: 0 auto !important; padding: 2px 12px 4px !important; }
footer { display: none !important; } /* drop the default Gradio footer */
.gradio-container > .main, .gradio-container .contain { padding-top: 0 !important; gap: 4px !important; }
.gradio-container .contain > *:first-child { margin-top: 0 !important; }
#dropzone-host { padding: 0 !important; margin: 0 !important; min-height: 0 !important; height: 0 !important;
overflow: visible !important; border: none !important; }
/* tight, compact column - minimal gaps between chat / composer / examples / footer */
.gradio-container .gap { gap: 4px !important; }
.gradio-container .form { gap: 4px !important; }
#composer { gap: 6px !important; align-items: flex-start !important; margin: 2px 0 0 !important; }
/* ---- Web-tools badge (now lives in the footer, not a top strip) ---- */
.badge { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.04em;
padding: 1px 8px; border-radius: 999px; border: 1px solid; text-transform: uppercase; }
.badge-on { color: #8FBFAE; border-color: #355247; background: rgba(94,140,125,0.10); }
.badge-off { color: #A89A8B; border-color: #3A2F27; background: transparent; }
/* ---- Chat thread ---- */
#chat { border: none !important; box-shadow: none !important; background: transparent !important; }
#chat .message-row { padding: 0 !important; }
#chat .message {
border: none !important; line-height: 1.6 !important; padding: 11px 15px !important;
animation: fadeRise 0.16s ease-out both; /* every bubble "arrives" */
}
/* USER turn: ONE bubble. Gradio NESTS .message.user (outer) > [data-testid="user"] (inner); styling BOTH gave
a DOUBLE box. So the OUTER .message.user is the single taupe bubble (right-aligned, hugs its text via
fit-content + the faint amber top-edge), and the INNER [data-testid="user"] is FLATTENED (transparent, no
box/padding) so it just holds the text. */
#chat .message.user {
margin-left: auto !important; width: fit-content !important; max-width: 86% !important;
background: #3A2F27 !important; color: #E8DDD0 !important;
padding: 9px 14px !important;
border-radius: 11px !important; border-top: 1px solid rgba(232,147,43,0.40) !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.30) !important;
}
#chat .message.user [data-testid="user"], #chat [data-testid="user"] {
background: transparent !important; border: none !important; border-radius: 0 !important;
box-shadow: none !important; padding: 0 !important; margin: 0 !important;
}
/* tighten the markdown <p> inside the user bubble so a short message isn't a tall box */
#chat .message.user p, #chat [data-testid="user"] p { margin: 0 !important; }
#chat .message.user *, #chat [data-testid="user"] * { color: #E8DDD0 !important; }
/* ASSISTANT turn: left-aligned, NO fill - sits flat on the dotted grid, marked ONLY by a 2px amber gutter
(the "machine output" rail). Text + media + warnings are ALL bot bubbles, so they share this gutter and a
variable number of them still reads as ONE consistent agent turn (no more transparent-media gaps). */
#chat .message.bot, #chat [data-testid="bot"], #chat .message.component, #chat .message.html {
margin-right: auto !important; max-width: 100% !important;
background: transparent !important; color: #E8DDD0 !important;
border-left: 2px solid #E8932B !important; border-radius: 0 8px 8px 0 !important;
padding: 9px 14px 9px 15px !important; box-shadow: none !important;
}
#chat .message.bot *, #chat [data-testid="bot"] * { color: #E8DDD0; }
/* media (image / svg / video / iframe) bubbles: keep the amber gutter, drop padding so the artifact fills. */
#chat .message:has(img), #chat .message:has(video), #chat .message:has(iframe), #chat .message:has(.lp-frame) {
padding: 6px 6px 6px 14px !important; background: transparent !important;
}
#chat img, #chat video { border-radius: 8px !important; }
/* live indicator: a blinking amber block-cursor + muted italic readout (not a "real" gray bubble popping in). */
.live-ind { font-style: italic; color: #C8B6A6; font-family: var(--font-mono); font-size: 13px; }
.live-cursor { color: #E8932B; font-weight: 700; animation: blink 1.05s steps(1) infinite; }
/* token-level streaming: the generation typing out (muted, monospace, wraps; the tail of the in-progress text) */
.live-gen { font-family: var(--font-mono); font-size: 12px; color: #9C8C7C; white-space: pre-wrap;
word-break: break-word; opacity: 0.9; border-left: 2px solid #3A2F27; padding-left: 10px; margin: 2px 0; }
/* think label (small amber tag) vs the live answer (brighter than the muted think) so they're never confused */
.live-think-tag { font-family: var(--font-mono); font-size: 11px; color: #E8932B; opacity: 0.8; font-style: italic; margin: 2px 0; }
.live-answer { font-family: var(--font-mono); font-size: 13px; color: #E8DDD0; white-space: pre-wrap;
word-break: break-word; border-left: 2px solid #E8932B; padding-left: 10px; margin: 2px 0; }
/* code + inline code: dark warm fill, mono, parchment text. */
#chat pre, #chat pre code, #chat code, #chat :not(pre) > code {
background-color: #120F0D !important; color: #EBD9C2 !important;
border: 1px solid #2A2219 !important; border-radius: 7px !important; font-family: var(--font-mono) !important;
}
#chat pre { padding: 10px 12px !important; }
/* per-turn meta header (iters/tool-calls) + token-speed: quiet mono so they read as a stats strip, not prose. */
#chat .message.bot em:first-child { color: #A89A8B; font-style: normal; font-family: var(--font-mono); font-size: 12px; }
/* <details> thinking / tool-output: etched panel, [+]/[-] toggle instead of a triangle, amber-tinted summary. */
#chat details {
margin: 6px 0 !important; background-color: #1E1813 !important;
border: 1px solid #2E251F !important; border-left: 2px solid #5E4A35 !important;
border-radius: 7px !important; padding: 5px 10px !important;
}
#chat details, #chat details * { color: #D7C9B8 !important; }
#chat details pre, #chat details code { background-color: #120F0D !important; color: #EBD9C2 !important; }
#chat summary { cursor: pointer; font-size: 0.9em; color: #C8B6A6 !important; list-style: none;
font-family: var(--font-mono); }
#chat summary::-webkit-details-marker { display: none; }
#chat summary::before { content: "[+] "; color: #E8932B; font-family: var(--font-mono); }
#chat details[open] > summary::before { content: "[-] "; }
/* copy buttons on code/messages: readable warm chip. */
#chat .message button, #chat .copy-button, #chat button[title*="opy"], #chat .code_wrap button, #chat [class*="copy"] {
color: #D7C9B8 !important; background-color: #2A2219 !important;
border: 1px solid #3A2F27 !important; border-radius: 6px !important; opacity: 1 !important;
}
/* ---- Inline HTML "live preview": wrap the sandboxed iframe in a tiny browser-chrome frame so the running
mini-app reads as a real artifact, not a floating rectangle. ---- */
.lp-frame { border: 1px solid #3A2F27 !important; border-radius: 9px; overflow: hidden;
box-shadow: 0 6px 20px -6px rgba(0,0,0,0.5); background: #0E0C0A; }
.lp-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
background: #221C17; border-bottom: 1px solid #2E251F; }
.lp-bar .lp-dot { width: 9px; height: 9px; border-radius: 50%; background: #E8932B;
box-shadow: 0 0 6px rgba(232,147,43,0.6); }
.lp-bar .lp-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;
text-transform: uppercase; color: #A89A8B; }
.lp-frame iframe { display: block; width: 100%; height: 480px; border: 0; background: #ffffff; }
/* ---- Composer: amber focus ring + a busy "running" state on Send ---- */
#prompt textarea {
border-radius: 11px !important; padding: 11px 14px !important; font-size: 15px !important;
background: #241E1A !important; box-shadow: inset 0 1px 2px rgba(0,0,0,0.35) !important; color: #E8DDD0 !important;
}
#prompt textarea::placeholder { color: #9C8E7E !important; } /* >=4.5:1 on the #241E1A well (WCAG AA) */
#prompt textarea:focus { box-shadow: 0 0 0 2px rgba(232,147,43,0.30) !important; }
/* In Gradio 6 a gr.Button's elem_id lands on the <button> itself, so #send IS the button (the old
"#send button" descendant selector matched nothing -> the mono styling was dead CSS). Target #send. */
#send {
flex: 0 0 auto !important; width: auto !important; min-width: 0 !important;
max-width: 104px !important; white-space: nowrap !important;
border-radius: 9px !important; font-size: 14px !important; font-weight: 700 !important;
font-family: var(--font-mono) !important; letter-spacing: 0.03em !important;
padding: 9px 14px !important; color: #1A1614 !important; transition: filter 0.14s ease, transform 0.06s ease;
}
#send:hover { filter: brightness(1.06); }
#send:active { transform: translateY(1px); }
#send:disabled { opacity: 0.6 !important; cursor: progress !important; filter: none !important; }
/* ---- Example prompts: etched warm pills, amber border + lift on hover ---- */
#examples { border: none !important; background: transparent !important; padding: 0 !important; margin: 2px 0 0 !important; }
#examples .examples-table, #examples table, #examples tbody, #examples tr {
border: none !important; display: flex !important; flex-wrap: wrap !important; gap: 6px !important;
}
#examples button, #examples td {
border-radius: 8px !important; border: 1px solid #3A2F27 !important;
background: #221C17 !important; color: #C8B6A6 !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.02) !important;
font-family: var(--font-mono) !important; font-size: 12.5px !important; line-height: 1.3 !important;
padding: 6px 12px !important; margin: 0 !important; max-width: 360px !important;
white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important;
transition: border-color 0.14s ease, color 0.14s ease, transform 0.08s ease;
}
#examples button:hover, #examples td:hover {
border-color: #E8932B !important; color: #E8DDD0 !important; transform: translateY(-1px);
}
/* ---- Footer: ONE ultra-quiet line carrying real signal - model link + the (otherwise invisible) MCP endpoint. */
#footer-host { padding: 0 !important; margin: 4px 0 0 !important; }
#appfooter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
border-top: 1px solid #2A2219; padding: 9px 4px 4px; font-size: 11.5px;
font-family: var(--font-mono); color: #8C7D6E; letter-spacing: 0.01em; }
#appfooter code { background: #1E1813; border: 1px solid #2E251F; border-radius: 5px;
padding: 1px 5px; font-size: 11px; color: #C8B6A6; }
#appfooter .ft-sep { color: #6E6055; } /* faintly visible separators (were 2.23:1, effectively invisible) */
#appfooter .ft-mark { color: #E8932B; font-weight: 700; letter-spacing: -1px; } /* small amber identity mark */
#appfooter .ft-name { color: #C8B6A6; }
/* ---- Attach control (hidden) + amber drag-anywhere overlay ---- */
#dropfile { display: none !important; }
#attachstatus { font-size: 12.5px !important; color: #C8B6A6 !important; margin: 0 !important; padding: 0 !important; text-align: center; }
#attachstatus:empty { display: none !important; }
#dropzone { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: center;
background: rgba(232,147,43,0.08); backdrop-filter: blur(2px); pointer-events: none; }
#dropzone.drag-active { display: flex; }
#dropzone .dz-inner { font-size: 17px; font-weight: 600; color: #E8932B; text-align: center; line-height: 1.6;
font-family: var(--font-mono); border: 2px dashed #E8932B; border-radius: 14px; padding: 34px 46px;
background: #1A1614; animation: pulseBorder 1.4s ease-in-out infinite; }
/* ---- Thin warm scrollbar ---- */
* { scrollbar-width: thin; scrollbar-color: rgba(120,104,88,0.4) transparent; }
*::-webkit-scrollbar { width: 7px; height: 7px; }
*::-webkit-scrollbar-thumb { background: rgba(120,104,88,0.4); border-radius: 6px; }
*::-webkit-scrollbar-thumb:hover { background: rgba(120,104,88,0.65); }
/* ---- Motion (all subtle, sub-200ms; fully disabled under prefers-reduced-motion) ---- */
@keyframes fadeRise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes blink { 50% { opacity: 0.15; } }
@keyframes pulseBorder { 0%,100% { border-color: #E8932B; } 50% { border-color: #8a5a1c; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition-duration: 0.01ms !important; }
}
"""
# Tiny JS: a ChatGPT-style DRAG-ANYWHERE drop overlay. While a file is dragged anywhere over the page we show
# the #dropzone overlay ("Drop any file in the workspace"); it hides on drop OR when the drag leaves the page /
# is cancelled. On drop we forward the dropped file into the (visually hidden) gr.File's <input type=file> and
# fire a 'change' event, so Gradio runs the SAME attach_file handler (save into sandbox + append /workspace/<name>
# to the input). A dragenter/dragleave DEPTH counter keeps child elements from flickering the overlay. elem_id
# targets (#dropzone, #dropfile) are stable per the Gradio custom-CSS/JS guidance.
DROP_JS = """
() => {
const z = document.getElementById('dropzone'); if (!z) return;
let depth = 0;
const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
const show = () => z.classList.add('drag-active');
const hide = () => { depth = 0; z.classList.remove('drag-active'); };
// forward the dropped file into Gradio's hidden file input so its .upload/attach_file wiring fires.
const forward = (files) => {
if (!files || !files.length) return;
const input = document.querySelector('#dropfile input[type=file]');
if (!input) return;
try { input.files = files; } catch (err) { /* some browsers disallow assigning .files; ignore */ }
input.dispatchEvent(new Event('change', { bubbles: true }));
};
window.addEventListener('dragenter', (e) => { if (hasFiles(e)) { depth++; show(); } });
window.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); }); // allow drop anywhere
window.addEventListener('dragleave', () => { depth = Math.max(0, depth - 1); if (depth === 0) hide(); });
// drag cancelled / pointer left the window entirely -> hide.
window.addEventListener('dragend', hide);
document.addEventListener('mouseleave', () => { if (z.classList.contains('drag-active')) hide(); });
window.addEventListener('drop', (e) => {
e.preventDefault();
hide();
if (hasFiles(e)) forward(e.dataTransfer.files);
});
}
"""
# Workbench chrome (computed once at startup). No top status strip (it duplicated the footer); instead the
# empty-thread PLACEHOLDER teaches the write->run->verify value prop, and ONE consolidated FOOTER carries all
# the runtime context (model/quant/runtime/tier + live web badge) plus the model card + the otherwise-invisible
# MCP endpoint - each fact stated exactly once. All load-bearing info, no filler.
WELCOME = (
"### &#9613; I write, run, and verify code, on a free CPU\n\n"
"Give me a coding task. I reason in `<think>`, then use **bash / write / read / edit** to build it, "
"run it in a sandbox, read the output, debug, and show the result inline.\n\n"
"&#128202; charts &amp; images &middot; &#128433;&#65039; little web apps &middot; &#129518; compute &amp; web look-ups"
)
_MODEL_REPO = os.environ.get("MODEL_REPO", "Luminia/MiniCPM5-1B-Agent-GGUF")
FOOTER_HTML = (
'<div id="appfooter">'
'<span class="ft-mark">&#9613;</span>'
# "Q8_0 GGUF" IS the model-card link (no separate "model card" item; name + web badge dropped as redundant)
f'<a href="https://huggingface.co/{_MODEL_REPO}" target="_blank" rel="noopener" title="model card">Q8_0 GGUF &#8599;</a>'
'<span class="ft-sep">&middot;</span><span>CPU ~4 min/turn</span>'
'<span class="ft-sep">&middot;</span>'
'<span>MCP <code>run_coding_task</code> at <code>/gradio_api/mcp/</code></span>'
'</div>')
# Gradio 6: app-level theme/css/js live on demo.launch(...), NOT on gr.Blocks(...). Only `title` stays here.
with gr.Blocks(title="MiniCPM5-1B-Agent") as demo:
# No hero banner and no top status strip (it duplicated the footer) - the chat opens at the top; all
# runtime context lives once in the footer.
sess = gr.State(_new_session) # FRESH per-browser-session state (own sandbox, own chat)
# Drag-drop overlay affordance (ChatGPT-style): a full-app dashed drop zone that only appears while a
# file is being dragged (toggled by DROP_JS). Always in the DOM but visually inert until .drag-active.
gr.HTML('<div id="dropzone"><div class="dz-inner">&#128206; Drop any file in the workspace</div></div>',
elem_id="dropzone-host")
# Gradio 6: show_copy_button -> buttons=["copy"]; allow_tags pinned to False explicitly (its default
# flips to True in 6.x, which would change how the rendered <details>/<div> trajectory HTML is handled
# vs what this app was built and tested against - keep the prior, tested behavior). `placeholder` shows
# the WELCOME empty-state only while the thread is empty (auto-hides on the first real message).
# sanitize_html=False is REQUIRED: in Gradio 6.x the Chatbot's default HTML sanitizer STRIPS the
# srcdoc attribute off our live-preview <iframe> (and force-sets sandbox="allow-scripts"), so a produced
# web page would render as a BLANK white box. We don't rely on the sanitizer for safety: all chat content
# is app-generated, model-emitted text is html.escape()'d in _render, and the iframe is sandboxed (opaque
# origin, no allow-same-origin) with an html.escape'd srcdoc. allow_tags=False stays (custom-tag policy).
chat = gr.Chatbot(height=360, show_label=False, render_markdown=True,
elem_id="chat", buttons=["copy"], allow_tags=False, sanitize_html=False,
avatar_images=None, placeholder=WELCOME)
with gr.Row(elem_id="composer"):
inp = gr.Textbox(show_label=False, lines=1, max_lines=6,
placeholder="Ask MiniCPM5-1B-Agent CPU, to run some code • Upload files here → workspace.",
scale=6, autofocus=True, container=False, elem_id="prompt")
# Just [Send]. The Chatbot's built-in TRASH icon (top-right) is the single session control - it clears
# the chat AND (via chat.clear below) resets the session + aborts any in-flight run. (The old separate
# "🔄 New" button was redundant clutter; folded into the trash icon.)
with gr.Column(scale=1, min_width=92):
btn = gr.Button("Send", variant="primary", elem_id="send", scale=1)
# Hidden attach control (the REAL upload target): its underlying <input type=file> + .upload wiring stay
# intact, but the widget is VISUALLY HIDDEN (CSS #dropfile{display:none}). The affordance is dragging a
# file ANYWHERE over the page -> DROP_JS forwards the dropped file into this input's .files and fires a
# 'change' event, so Gradio saves it into the session sandbox and appends /workspace/<name> to the input.
drop = gr.File(label="attach", file_count="single", elem_id="dropfile", height=64)
attach_status = gr.Markdown("", elem_id="attachstatus")
# All 3 examples visible; the flex-wrap CSS lets them wrap to a 2nd row (no funny single-line truncation).
gr.Examples(EXAMPLES, inputs=inp, elem_id="examples", label="")
# One ultra-quiet footer line: model card link + the (otherwise invisible) MCP endpoint - real signal.
gr.HTML(FOOTER_HTML, elem_id="footer-host")
_outs = [chat, sess, inp]
# Gradio 6: event listeners use api_visibility=... (the old show_api=False). These UI handlers are stateful
# generators (gr.State / gr.update / component-valued bubbles) that make no sense as MCP tools, so hide them
# from API docs + the MCP server ("undocumented" == old show_api=False; the single MCP tool is the clean
# run_coding_task endpoint wired via gr.api below).
# Drive the displayed chat from gr.State (sess), NOT from the Chatbot component: inputs never include
# `chat`, so component-valued media bubbles never round-trip through the Chatbot's preprocess.
run_evt = btn.click(run, inputs=[inp, sess], outputs=_outs, concurrency_limit=1,
api_visibility="undocumented")
sub_evt = inp.submit(run, inputs=[inp, sess], outputs=_outs, concurrency_limit=1,
api_visibility="undocumented")
# Clear the transient "added /workspace/x ... before sending" attach hint once the turn finishes, so it
# doesn't linger (saying "before sending") under an empty composer into the next turn. Chained as a
# separate .then on the SAME event objects (run_evt/sub_evt stay the run generators, so cancels= still
# targets them); attach_status is NOT in _outs, so the run generator's 3-tuple yields are untouched.
_clear_attach = lambda: gr.update(value="")
run_evt.then(_clear_attach, outputs=[attach_status], api_visibility="undocumented")
sub_evt.then(_clear_attach, outputs=[attach_status], api_visibility="undocumented")
# The Chatbot's built-in TRASH icon (top-right) is the single session control: clicking it fires chat.clear
# -> reset_session (fresh session: new sandbox/history/cleared chat) AND cancels= aborts any in-flight run at
# its next yield. reset_session flips the cancel flag first so the live indicator shows 'cancel Ns...'.
# (gr.Chatbot.clear fires on the trash-icon click - verified in the Gradio 6 docs.)
chat.clear(reset_session, inputs=[sess], outputs=_outs, cancels=[run_evt, sub_evt],
api_visibility="undocumented")
# Drag-drop / attach: save into the session sandbox + append the path; clear the file widget after.
drop.upload(attach_file, inputs=[drop, inp, sess], outputs=[inp, sess, attach_status],
api_visibility="undocumented").then(
lambda: None, outputs=[drop], api_visibility="undocumented")
# MCP server: expose the agent as ONE clean, typed, documented tool an external MCP client can call to run a
# coding task. gr.api registers a pure-logic endpoint (fully typed signature -> MCP schema) with no UI, so it
# does not disturb the Gradio interface above. demo.launch(mcp_server=True) below turns it into an MCP tool.
gr.api(run_coding_task, api_name="run_coding_task")
# Session isolation: clean THIS session's sandbox when the browser disconnects (never the shared server).
demo.unload(on_unload)
# Free 2-vCPU CPU Space with ONE shared llama-server: serialize model use so concurrent users queue rather
# than thrash the single server. default_concurrency_limit=1 (one inference at a time), bounded queue.
demo.queue(default_concurrency_limit=1, max_size=16)
if __name__ == "__main__":
# Gradio 6: app-level theme/css/js moved here from gr.Blocks(...). mcp_server=True publishes the MCP server
# at /gradio_api/mcp/ (the run_coding_task tool). ssr_mode=False so the GoogleFont theme renders correctly
# (Gradio SSR drops construction-time fonts).
demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False,
theme=THEME, css=CSS, js=DROP_JS, mcp_server=True)