build-small-hackathon/MiniCPM5-1B-Agent
3
1"""MiniCPM5-1B-Agent - agentic coding demo Space.2 3Wires the SAME backend agent loop used by the eval (backend/agent.py: think -> tool-call ->4run -> read output -> debug -> verify) around the shipped Q8_0 GGUF, served by llama-server.5The user gives a coding task; the agent writes/runs/fixes code in a sandbox and we render the6full write->run->verify trajectory + final answer + produced files INLINE in the chat thread.7 8Runtime layout (Docker /app): app.py · backend/agent.py · data/schema.py · tokenizer/ · model.gguf9Env (set by Dockerfile, overridable for local test):10 CODEAGENT_PROJ -> dir holding data/schema.py (default: this file's dir)11 CODEAGENT_LLAMA_BIN -> llama-server binary (default: "llama-server")12 CODEAGENT_GGUF -> path to the Q8_0 GGUF (default: /app/model.gguf)13 CODEAGENT_CTX -> llama-server context (default 8192)14 CODEAGENT_THREADS -> llama-server threads (default 2 - free 2-vCPU tier)15"""16import os, sys, glob, html, atexit, shutil, tempfile, time, threading17 18HERE = os.path.dirname(os.path.abspath(__file__))19# agent.py reads these; set BEFORE importing it so PROJ/LLAMA_BIN resolve to the Space layout.20os.environ.setdefault("CODEAGENT_PROJ", HERE) # -> data/schema.py is at HERE/data21os.environ.setdefault("CODEAGENT_LLAMA_BIN", "llama-server")22sys.path.insert(0, HERE) # for `import backend.agent`23sys.path.insert(0, os.path.join(HERE, "backend")) # agent.py also imported flat24 25import gradio as gr26from transformers import AutoTokenizer27import backend.agent as agent28 29GGUF = os.environ.get("CODEAGENT_GGUF", "/app/model.gguf")30CTX = int(os.environ.get("CODEAGENT_CTX", "8192"))31THREADS = int(os.environ.get("CODEAGENT_THREADS", "2"))32MAX_ITERS = int(os.environ.get("CODEAGENT_MAX_ITERS", "8"))33# PER-TURN ACTION budget (phase B). The runaway <think> is bounded SEPARATELY (CODEAGENT_THINK_CAP, default34# 1024), so this only sizes the model's ACTION (a tool call / a file write / the final answer). Balance: at35# ~8 tok/s on the free 2-vCPU tier, a full-budget action costs ~budget/8 seconds, so an over-large cap makes36# a verbose turn take 20+ min. 6144 fits a clean, compact file write (the prompt now tells the model to WRITE37# the file, not paste it) while keeping turns reasonable; and since the Chatbot no longer strips iframe srcdoc38# (sanitize_html=False), a page that DOES exceed the cap now renders PARTIALLY via the salvage path instead of39# as a blank box - so truncation degrades gracefully rather than failing hard. Override via CODEAGENT_NPRED.40NPRED = int(os.environ.get("CODEAGENT_NPRED", "6144"))41 42# Private deployment: if the GGUF isn't already on disk, pull it from the (PRIVATE) model repo using the43# HF_TOKEN Space secret. (Local/public runs instead point CODEAGENT_GGUF at an existing file.)44if not os.path.exists(GGUF) and os.environ.get("MODEL_REPO"):45 from huggingface_hub import hf_hub_download46 _repo, _fn = os.environ["MODEL_REPO"], os.environ["MODEL_FILE"]47 print(f"[app] downloading {_repo}/{_fn} (private; HF_TOKEN={'set' if os.environ.get('HF_TOKEN') else 'MISSING'}) ...", flush=True)48 GGUF = hf_hub_download(repo_id=_repo, filename=_fn, token=os.environ.get("HF_TOKEN"), local_dir="/app")49 50# The tokenizer lives WITH the model (in the GGUF repo under tokenizer/) - one source of truth, never bundled51# in the Space. Pull it from MODEL_REPO with the same HF_TOKEN secret used for the GGUF.52_tokdir = os.path.join(HERE, "tokenizer")53if not os.path.exists(os.path.join(_tokdir, "tokenizer.json")) and os.environ.get("MODEL_REPO"):54 from huggingface_hub import snapshot_download55 print(f"[app] downloading tokenizer from {os.environ['MODEL_REPO']}/tokenizer ...", flush=True)56 snapshot_download(repo_id=os.environ["MODEL_REPO"], allow_patterns="tokenizer/*",57 token=os.environ.get("HF_TOKEN"), local_dir=HERE)58print(f"[app] loading tokenizer from {_tokdir} ...", flush=True)59TOK = AutoTokenizer.from_pretrained(_tokdir, trust_remote_code=True)60 61# SECURITY: the GGUF + tokenizer are now downloaded, so HF_TOKEN is no longer needed. DROP it from the62# process env BEFORE any per-turn Sandbox is created - the bash tool runs model-emitted shell with the full63# inherited env, so leaving the private-repo secret in os.environ would let a task `echo $HF_TOKEN` exfil it.64os.environ.pop("HF_TOKEN", None)65os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)66 67print(f"[app] starting llama-server on {GGUF} (ctx={CTX}, threads={THREADS}, ngl=0) ...", flush=True)68SERVER = agent.LlamaServer(GGUF, port=8099, ctx=CTX, threads=THREADS, ngl=0)69SERVER.__enter__() # block until /health ok; kept alive for the app lifetime70atexit.register(lambda: SERVER.__exit__(None, None, None))71print("[app] llama-server healthy - ready.", flush=True)72 73# Web tools: AUTO-DETECT at startup (no manual flag). If the internet is reachable, turn on web_search/web_fetch.74WEB_ON = agent.web_available()75if WEB_ON:76 agent.enable_web()77 print("[app] internet reachable -> web tools ON (web_search + web_fetch incl. Reddit via arctic_shift)", flush=True)78else:79 print("[app] no internet -> web tools OFF (fully local / Off-the-Grid)", flush=True)80 81# Best-effort: make /workspace exist + writable. The 1B (Claude-Code habit) sometimes savefig's/writes to an82# ABSOLUTE '/workspace/...' path INSIDE its script - the bash-path rewrite can't reach in-code paths, so without83# this the write would fail (or land at an unscanned root) and the artifact would never render. With /workspace84# present, those writes succeed and _extra_media() scans it (this-turn-only). Harmless if perms deny it (the85# model then uses relative paths in the per-session sandbox, which _media_messages already scans).86try:87 os.makedirs("/workspace", exist_ok=True)88except Exception:89 pass90 91# Every example is a NATURAL request (how a real user actually asks - no backend hand-holding; the92# save/run/verify conventions live in the SYSTEM PROMPT, not here) and was VALIDATED on the shipped dpo_v393# GGUF through THIS agent loop. The first two PASS on the fine-tune but FAIL on the un-tuned base, so they94# showcase what the training added. The 1B handles a chart, an HTML mini-app, and a computed answer, but ONLY95# when the prompt is concrete (name the .html file / "hard-coded, no internet"); an open-ended "make a web96# page" reliably gets mis-read as a Python CLI or triggers a pointless web_search. (Charts/GIFs/games need97# step-by-step spoon-feeding a 1B can't infer from a natural prompt - left out.)98# Each example exercises a DISTINCT capability so clicking through them shows the range: a matplotlib99# chart -> PNG, an interactive HTML mini-app, and a compute + web look-up.100EXAMPLES = [101 # python chart -> image shown inline in the chat (phrased as "Python script ... saves a PNG ... run it" so the 1B102 # writes a .py + runs it, instead of dumping code into the .png; full ctx + the save-plots system rule help)103 "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.",104 # interactive HTML mini-app -> renders live inline. EXPLICIT phrasing ("Write an HTML page <file>.html ...105 # hard-coded ... no internet") so the 1B writes a single self-contained .html FILE instead of mis-reading106 # "web page" as a Python CLI or web_search'ing (the open-ended "make a little web page" prompt reliably107 # botched both). Mirrors the write-a-file pattern the 1B nails.108 "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.",109 # practical compute + WEB look-up (when online): "look up this year's avg rent" implicitly needs web_search110 "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.",111]112 113 114import re as _re115# Display-only cruft: control tokens AND leaked tool-call XML. When the 1B emits a malformed/partial tool call,116# raw <function>/<param>/CDATA fragments can spill into the rendered bubble (e.g. "...]]></param></function>");117# strip those tags from the SHOWN text only. This does NOT touch the agent loop's real XML parsing in agent.py.118_CRUFT = _re.compile(119 r"</?think>|<\|im_(start|end)\|>|<\|endoftext\|>"120 r"|</?function[^>]*>|</?param[^>]*>|<!\[CDATA\[|\]\]>")121 122 123def _clean(t):124 """Display-only: strip special/control tokens AND leaked tool-call XML that can spill into rendered text125 (no effect on the agent loop's parsing)."""126 return _CRUFT.sub("", t or "").strip()127 128 129def _fmt_tool_call(c):130 # Tool calls are stored in the canonical NESTED shape {"type":"function","function":{name,arguments}}131 # (agent.py run_agent). Read the nested function.name/function.arguments directly; tolerate a flat dict.132 fn = c.get("function") or c133 name = fn.get("name", "?")134 arguments = fn.get("arguments") or {}135 arg_preview = ", ".join(f"{k}={str(v)[:60]!r}" for k, v in list(arguments.items())[:3])136 return f"🔧 **`{name}`**({arg_preview})"137 138 139def _tool_disclosure(body, max_chars=1500):140 """One collapsible <details> block for a tool's (possibly long) output - de-dupes the two identical141 blocks the renderer used for role:tool and the legacy <tool_response> user message."""142 return f"<details><summary>📤 tool output</summary>\n\n```\n{str(body)[:max_chars]}\n```\n</details>"143 144 145def _assistant_parts(m):146 """Read an assistant turn's display parts from the CANONICAL stored fields (reasoning_content,147 tool_calls, content) rather than re-parsing content. On a tool-calling turn the loop already split148 <think>/tool-calls out and set content="", so re-parsing would recover nothing. Returns149 (reasoning, tool_calls, final)."""150 return (_clean(m.get("reasoning_content") or ""),151 m.get("tool_calls") or [],152 _clean(m.get("content") or ""))153 154 155def _render(messages, max_chars=1500):156 """Render the write->run->verify trajectory as readable markdown (assistant turns only - the user's157 task is already shown as their own chat bubble). Renders the CANONICAL message fields the agent loop158 stored (reasoning_content, tool_calls, content) directly - it does NOT re-parse content, because on a159 tool-calling turn the loop already split <think>/tool-calls out and set content="", so re-parsing would160 recover nothing and the turn would render blank."""161 out = []162 for m in messages:163 role = m.get("role")164 content = m.get("content") or ""165 if role in ("system", "user"):166 if role == "user" and content.startswith("<tool_response>"):167 body = content.replace("<tool_response>", "").replace("</tool_response>", "").strip()168 out.append(_tool_disclosure(body, max_chars))169 continue170 if role == "tool":171 out.append(_tool_disclosure(content, max_chars))172 continue173 # assistant: render the canonical fields the loop stored (reasoning_content, tool_calls, content).174 reasoning, tool_calls, final = _assistant_parts(m)175 if reasoning:176 out.append(f"<details><summary>💭 thinking</summary>\n\n{html.escape(reasoning[:max_chars])}\n</details>")177 for c in tool_calls:178 out.append(_fmt_tool_call(c))179 if final:180 # The final answer renders as PLAIN chat-bubble text (it is already in the assistant bubble) - no181 # "🤖"-narration prefix or bold framing that made it read like internal monologue / blur with the182 # user's own turn. Reasoning + tool output stay in their collapsible <details> blocks above.183 out.append(html.escape(final[:max_chars]))184 return "\n\n".join(out)185 186 187IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp")188SVG_EXT = (".svg",) # rendered inline as HTML (gr.Image rasterizers don't reliably handle SVG)189VID_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")190 191 192def _copy_out(p, outdir):193 """Copy an artifact OUT of the (ephemeral, per-session) workspace into a stable temp dir Gradio can194 serve, and return the destination path."""195 dst = os.path.join(outdir, os.path.basename(p))196 shutil.copy2(p, dst)197 return dst198 199 200def _media_bubble(content):201 """Wrap a media component (gr.Image/gr.Video/gr.HTML) as one assistant chat message so it renders202 INLINE in the thread (ChatGPT-style), not in a separate box."""203 return {"role": "assistant", "content": content}204 205 206def _html_iframe(body):207 """A live, SANDBOXED iframe for an agent-produced .html page (opaque origin: no allow-same-origin, so208 the page's JS/forms/buttons run but stay contained), wrapped in a tiny browser-chrome "live preview"209 frame so the running mini-app reads as a real artifact, not a floating white rectangle. srcdoc is210 html.escape'd (quote=True default) so a " in the page can't break out of the attribute. Sizing/border211 come from CSS (.lp-frame / .lp-frame iframe)."""212 iframe = (f'<iframe sandbox="allow-scripts" referrerpolicy="no-referrer" '213 f'srcdoc="{html.escape(body)}"></iframe>')214 return (f'<div class="lp-frame">'215 f'<div class="lp-bar"><span class="lp-dot"></span>'216 f'<span class="lp-label">live preview</span></div>{iframe}</div>')217 218 219_HTML_START = _re.compile(r"<!DOCTYPE html|<html[\s>]", _re.IGNORECASE)220 221 222def _salvage_html(text):223 """The 1B often PASTES a full HTML page into its prose answer instead of write()-ing a file (so no .html224 file exists to iframe). Extract the document so the demo still renders it LIVE - and tolerate a page that225 got CUT OFF mid-tag (no </html>): render what we have (browsers are lenient) and flag it so the chat can226 say WHY it looks broken. Returns (html, warning) - warning is None when the page is complete."""227 if not isinstance(text, str):228 return None, None229 m = _HTML_START.search(text)230 if not m:231 return None, None232 doc = text[m.start():]233 end = doc.lower().rfind("</html>")234 if end != -1: # complete page: trim trailing prose after </html>235 return doc[:end + 7].strip(), None236 return doc.strip(), ("the model pasted the page into its reply instead of saving it to a file, then hit the "237 "length limit and got cut off (no `</html>`). Rendering the partial page; ask it to "238 "*save the page to an .html file with the write tool* so the whole thing renders.")239 240 241def _valid_image(p):242 """True only if p starts with a real raster-image magic number. Guards against the 1B 'writing' a chart.png243 as TEXT (a bogus file that would render as an empty/0x0 bubble) - such a file fails this check and is skipped."""244 try:245 with open(p, "rb") as f:246 head = f.read(12)247 except OSError:248 return False249 return (head.startswith(b"\x89PNG") or head.startswith(b"\xff\xd8\xff") or head[:4] == b"GIF8"250 or head[:2] == b"BM" or (head[:4] == b"RIFF" and head[8:12] == b"WEBP"))251 252 253def _render_media_file(p, outdir):254 """Map ONE produced file to an inline chat bubble by extension, or None if it's not media. png/jpg/gif/... ->255 gr.Image; .svg / .html -> a sandboxed iframe (SVG is active content, so never inline it raw into the parent256 DOM); video -> gr.Video. Shared by the sandbox scan AND the extra-dir scan."""257 ext = os.path.splitext(p)[1].lower()258 try:259 if ext in IMG_EXT:260 return _media_bubble(gr.Image(_copy_out(p, outdir))) if _valid_image(p) else None261 if ext in VID_EXT:262 return _media_bubble(gr.Video(_copy_out(p, outdir)))263 if ext in SVG_EXT or ext in (".html", ".htm"):264 return _media_bubble(gr.HTML(_html_iframe(open(p, encoding="utf-8", errors="replace").read())))265 except Exception:266 return None267 return None268 269 270def _extra_media(dirs, since_ts, exclude_dir):271 """Catch media the model wrote to an ABSOLUTE path OUTSIDE its per-session sandbox - e.g. a savefig to272 '/workspace/chart.png' INSIDE the script (a Claude-Code habit the bash-path rewrite can't reach, so the PNG273 lands at /workspace, not the scanned sandbox dir). Scan the given dirs for media modified THIS turn274 (mtime > since_ts) so a fresh session never re-shows a prior session's files (concurrency_limit=1 => one run275 at a time, race-free). Dirs that don't exist (e.g. /workspace on Windows) are skipped; the sandbox is excluded."""276 msgs = []277 outdir = tempfile.mkdtemp(prefix="codeagent_out_")278 exclude = os.path.abspath(exclude_dir) if exclude_dir else None279 for d in dirs:280 if not (d and os.path.isdir(d)) or (exclude and os.path.abspath(d) == exclude):281 continue282 for p in sorted(glob.glob(os.path.join(d, "**", "*"), recursive=True))[:40]:283 if not os.path.isfile(p):284 continue285 try:286 if os.path.getmtime(p) <= since_ts:287 continue288 except OSError:289 continue290 b = _render_media_file(p, outdir)291 if b is not None:292 msgs.append(b)293 return msgs294 295 296def _media_messages(ws, since=0.0):297 """Scan the persistent workspace and turn whatever the agent PRODUCED/CHANGED THIS TURN into inline298 chat messages (gr.Chatbot type='messages'): images/gifs/charts (png/jpg/jpeg/gif/bmp/webp) -> gr.Image,299 .svg -> inline gr.HTML, videos -> gr.Video, .html -> a live sandboxed iframe via gr.HTML. Non-media300 files are ignored. `since` is an mtime watermark: only files modified301 after it are shown, so prior turns' artifacts are NOT re-rendered. Returns (msgs, new_watermark).302 Nothing is hard-coded to a task."""303 msgs, watermark = [], since304 if not (ws and os.path.isdir(ws)):305 return msgs, watermark306 outdir = tempfile.mkdtemp(prefix="codeagent_out_") # copy out so Gradio can serve a stable path307 for p in sorted(glob.glob(os.path.join(ws, "**", "*"), recursive=True))[:40]:308 if not os.path.isfile(p):309 continue310 try:311 mt = os.path.getmtime(p)312 except OSError:313 continue314 if mt > watermark:315 watermark = mt316 if mt <= since: # produced/changed in an EARLIER turn -> skip317 continue318 b = _render_media_file(p, outdir) # png/jpg/.. -> Image; svg/html -> sandboxed iframe; video -> Video319 if b is not None:320 msgs.append(b)321 return msgs, watermark322 323 324def _new_session():325 """A fresh per-browser-session state. The DISPLAYED chat lives in `chat` here (gr.State) so the326 Chatbot is purely an OUTPUT - component-valued media bubbles never round-trip through the Chatbot's327 preprocess. `mtime` is the media watermark; `sandbox`/`history` persist the agent workspace + convo;328 `token` is the Gradio session_hash so demo.unload can clean THIS session's sandbox on leave."""329 return {"sandbox": None, "history": None, "chat": [], "mtime": 0.0, "token": None}330 331 332# When the user clicks "🔄 New" mid-generation, Gradio's cancels= stops THIS run generator (frees the UI for333# the fresh session). reset_session ALSO flips this per-token flag so, if the generator yields once more before334# cancellation lands, its live indicator switches from "Ns..." to "cancel Ns..." - the user sees New registered335# and the old turn aborting. (run_agent has no cooperative abort hook, so the background worker finishes the336# current turn on its own; its result is simply discarded because the session was reset.)337_CANCEL_FLAGS = {} # session token -> threading.Event (set => the in-flight run for this session is cancelling)338 339 340def _cancel_event(sess):341 tok = (sess or {}).get("token")342 if not tok:343 return None344 ev = _CANCEL_FLAGS.get(tok)345 if ev is None:346 ev = _CANCEL_FLAGS[tok] = threading.Event()347 return ev348 349 350_WORD_RE = _re.compile(r"[A-Za-z]{3,}") # an alphabetic word of length >= 3351 352 353def _is_gibberish(task):354 """CONSERVATIVE frontend guard ONLY (does NOT alter the model's act-not-ask behavior on real tasks): treat355 a task as gibberish if it is too short or has no real word - e.g. an accidental 'dd'. Anything with a356 >=3-letter alphabetic word (a genuine short request like 'plot sin x') is NOT gibberish and runs normally."""357 t = (task or "").strip()358 if len(t.replace(" ", "")) <= 3: # <= 3 non-space chars (e.g. "dd", "ok", "?!")359 return True360 if len(set(t.replace(" ", ""))) <= 1: # a single repeated char (e.g. "aaaa")361 return True362 if not _WORD_RE.search(t): # no alphabetic word of length >= 3 anywhere363 return True364 return False365 366 367def run(task, sess, request: gr.Request = None):368 """One turn (STREAMING generator): append the user's task + the agent's trajectory and any produced media369 INLINE in the chat. The displayed chat is driven from gr.State (sess['chat']); the Chatbot itself is never370 read as input. The blocking agent loop runs in a worker thread while we yield a live elapsed-time indicator371 ("Ns...") so the UI stays responsive AND so Gradio has yield points to CANCEL on (the "🔄 New" button uses372 cancels= to abort this generator)."""373 sess = sess or _new_session()374 if request is not None and not sess.get("token"):375 sess["token"] = getattr(request, "session_hash", None)376 task = (task or "").strip()377 chat = list(sess.get("chat") or [])378 if not task:379 yield chat, sess, ""380 return381 # Frontend gibberish guard: don't burn the agent loop on an accidental "dd". Keep the typed input so the382 # user can fix it; just nudge (transient - not persisted to sess['chat']) for a real task. (Conservative -383 # real short requests still run; see _is_gibberish.)384 if _is_gibberish(task):385 nudge = chat + [{"role": "assistant",386 "content": "What would you like me to build or run? Describe a coding task."}]387 yield nudge, sess, gr.update()388 return389 # html.escape the user's text for DISPLAY only (the agent still gets the raw `task`): with sanitize_html390 # off, an unescaped angle-bracket the user types (e.g. "<h1>" in a coding question, or a pasted snippet)391 # would otherwise be interpreted as live HTML in the bubble - vanishing from view (or, for <script>/392 # onerror, executing). Escaping shows it verbatim and closes the user-input injection path.393 chat = chat + [{"role": "user", "content": html.escape(task)}]394 cancel = _cancel_event(sess)395 if cancel is not None:396 cancel.clear() # fresh turn: not cancelling (yet)397 # run_agent returns the FULL cumulative conversation (it prepends list(history) + [task]); record where398 # this turn's new messages start so we render ONLY the just-produced turn, not all of history every time.399 prev = sess.get("history") or []400 base = len(prev)401 402 holder = {}403 def _on_step(msgs, partial=None): # STREAM: snapshot trajectory-so-far + the in-progress generation404 holder["partial"] = list(msgs) # shallow copy: the worker keeps appending to `msgs`405 holder["gen"] = partial # token-level: the text being generated NOW (None once the step lands)406 def _work():407 try:408 # MULTI-TURN: reuse the session's sandbox + conversation so files persist and you can iterate.409 # n_predict=32768: give the model room for full multi-step reasoning (the run_agent default of 1024410 # truncates the <think> trace mid-thought). ctx stays the env value (131072), so the prompt budget411 # is ctx - n_predict and long sessions still fit.412 holder["res"] = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0,413 n_predict=NPRED, on_step=_on_step,414 keep_workspace=True, sandbox=sess.get("sandbox"), history=sess.get("history"))415 except Exception as e: # noqa: BLE001 - surfaced to the user below416 holder["err"] = e417 worker = threading.Thread(target=_work, daemon=True)418 worker.start()419 420 # Live indicator while the worker runs: a transient assistant bubble appended ONLY to the yielded chat421 # (never persisted to sess['chat']). Switches to a "cancel Ns..." readout once 🔄 New flips the flag.422 t0 = time.time()423 while worker.is_alive():424 worker.join(timeout=1.0)425 if not worker.is_alive():426 break427 secs = int(time.time() - t0)428 cancelling = cancel is not None and cancel.is_set()429 label = f"cancel {secs}s... (starting a new chat)" if cancelling else f"working... {secs}s"430 foot = f'<span class="live-ind"><span class="live-cursor">▍</span> {label}</span>'431 # STREAM: show the trajectory produced SO FAR (each completed step appears live: tool call -> its output432 # -> next call), with the working footer beneath it - instead of a bare timer for the whole run. partial433 # is set by _on_step; partial[base:] mirrors the FINAL render slice (line below) so the view is consistent.434 partial = holder.get("partial")435 gen = holder.get("gen")436 parts = []437 if partial is not None and len(partial) > base + 1:438 parts.append(_render(partial[base:]))439 if gen: # token-level: the generation typing out now (escaped, muted, tail)440 # split think vs answer on the </think> boundary (the backend marks it) so the live <think> is clearly441 # labelled WHILE reasoning, then COLLAPSES to a small tag once done - so it's never mistaken for the answer.442 _think, _sep, _ans = gen.partition("</think>")443 _think = _think.replace("<think>", "").strip()444 cur = '<span class="live-cursor">▍</span>'445 if not _sep: # still reasoning446 parts.append(f'<div class="live-gen"><span class="live-think-tag">▍ thinking</span> '447 f'{html.escape(_think[-600:])}{cur}</div>')448 else: # reasoning done -> collapse think to a tag, show the answer typing449 ans = html.escape(_ans.strip()[-700:])450 parts.append(f'<div class="live-think-tag">▍ reasoned</div>'451 f'<div class="live-answer">{ans}{cur}</div>')452 parts.append(foot)453 yield chat + [{"role": "assistant", "content": "\n\n".join(parts)}], sess, ""454 455 if "err" in holder:456 e = holder["err"]457 chat = chat + [{"role": "assistant", "content":458 ("⚠️ The agent couldn't finish this one - a 1B on free CPU can struggle on hard tasks. "459 "Try rephrasing, or click the 🗑 trash icon (top-right of the chat) for a fresh session.\n\n"460 f"`{type(e).__name__}: {str(e)[:300]}`")}]461 sess["chat"] = chat462 yield chat, sess, ""463 return464 res = holder["res"]465 sess["sandbox"] = res.get("sandbox") # persist workspace + history for the NEXT turn (iterate further)466 sess["history"] = res.get("messages")467 _register_sandbox(sess) # so demo.unload can rmtree THIS session's dir on leave468 transcript = _render(res["messages"][base:]) # only THIS turn's new assistant + tool messages469 header = (f"_iters={res.get('iters')} · tool-calls={res.get('tool_calls_made')} · "470 f"{res.get('tool_counts')}_\n\n")471 # token-speed readout: a small, muted, right-aligned line at the END of the assistant turn (Gradio has no472 # native t/s widget, so render it as styled HTML appended to the final text bubble). Omit if missing/zero.473 tps = res.get("tps") or {}474 tg, pp = tps.get("tg") or 0, tps.get("pp") or 0475 speed = ""476 if tg > 0:477 pp_part = f" - PP {pp:.0f} tok/s" if pp > 0 else ""478 speed = (f'\n\n<div style="text-align:right;font-size:0.78em;opacity:0.55">'479 f'TG {tg:.0f} tok/s{pp_part}</div>')480 chat = chat + [{"role": "assistant", "content": header + transcript + speed}]481 # matplotlib (MPLBACKEND=Agg) saves plots to PNG files, so charts flow through this same inline path.482 # mtime watermark: only files produced/changed THIS turn become bubbles (no re-showing prior artifacts).483 media, sess["mtime"] = _media_messages(res.get("workspace"), since=sess.get("mtime") or 0.0)484 # ALSO catch media the model wrote to an ABSOLUTE path OUTSIDE the per-session sandbox (e.g. a savefig to485 # '/workspace/chart.png' inside the script - the bash-path rewrite can't reach in-code paths). this-turn-only486 # (mtime > t0, the run start) so no cross-session leak. /workspace is the dominant Claude-Code habit.487 media = media + _extra_media(["/workspace"], since_ts=t0, exclude_dir=res.get("workspace"))488 # SALVAGE: a 1B often pastes a full HTML page into its prose answer instead of write()-ing an .html file489 # (so _media_messages finds no file to iframe). If no .html was written this turn but the answer/transcript490 # contains a full HTML document, render it LIVE anyway so the web-page example still works.491 _ws = res.get("workspace")492 if not (_ws and glob.glob(os.path.join(_ws, "**", "*.html"), recursive=True)):493 _doc, _warn = _salvage_html(res.get("final", ""))494 if not _doc:495 _doc, _warn = _salvage_html(transcript)496 if _doc:497 media = media + [_media_bubble(gr.HTML(_html_iframe(_doc)))]498 if _warn: # surface the truncation so the user knows WHY it looks broken499 media = media + [{"role": "assistant", "content": f"⚠️ HTML note: {_warn}"}]500 chat = chat + media501 sess["chat"] = chat # persist the displayed chat in gr.State (drives the Chatbot)502 yield chat, sess, ""503 504 505def run_coding_task(task: str) -> str:506 """Run a coding task with the MiniCPM5-1B agent and return the final answer.507 508 The agent writes, runs, and fixes code in a fresh sandbox (the write -> run -> verify loop),509 then returns its final answer as plain text. This is the MCP-callable entrypoint: an external510 MCP client (Claude Desktop, Cursor, Cline, ...) can call it to have the agent complete a coding511 task. Each call is independent and uses its own ephemeral workspace (no shared session state),512 so it is safe to call concurrently with the web UI.513 514 Args:515 task (str): A natural-language coding request, e.g. "Write a Python script that makes a bar516 chart of 30, 45, 25 labeled A, B, C, saves it as chart.png, then run it."517 518 Returns:519 str: The agent's final answer text. Note: any files the agent produces (charts, pages) live520 in its ephemeral sandbox and are not returned by this MCP tool, only the textual answer.521 """522 task = (task or "").strip()523 if not task:524 return "Please provide a coding task to run."525 try:526 # Own sandbox per call (sandbox=None), cleaned up afterward (keep_workspace=False). Same527 # n_predict=32768 as the UI so reasoning is not truncated.528 res = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0,529 n_predict=NPRED, keep_workspace=False)530 except Exception as e: # noqa: BLE001 - surfaced to the MCP client as text531 return f"The agent could not finish this task: {type(e).__name__}: {str(e)[:300]}"532 return _clean(res.get("final") or "") or "(the agent finished without a textual answer)"533 534 535def reset_session(sess):536 """Drop the persistent workspace + history and start a FRESH session (its own new sandbox dir). If a turn537 is in flight, flip its cancel flag so the live indicator shows 'cancel Ns...' (the run generator is also538 aborted by cancels= on the button); carry the session token into the new state so cancels= keeps targeting539 the right session."""540 tok = (sess or {}).get("token")541 ev = _CANCEL_FLAGS.get(tok) if tok else None542 if ev is not None:543 ev.set() # signal the in-flight run to show 'cancelling' before cancels= lands544 try:545 if sess and sess.get("sandbox") is not None:546 sess["sandbox"].cleanup() # rmtree the old per-session workspace so files never leak/accumulate547 except Exception:548 pass549 fresh = _new_session()550 fresh["token"] = tok # same browser session keeps its token (clean flag registry lookup)551 if ev is not None:552 ev.clear() # the old turn is gone; the fresh session starts un-cancelled553 return [], fresh, ""554 555 556MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # reject files larger than ~2GB557 558 559def attach_file(filepath, task, sess, request: gr.Request = None):560 """DRAG-DROP / attach (ChatGPT-style): save the dropped file INTO the current session's sandbox561 workspace and APPEND its '/workspace/<name>' path as plain text to the chat input, so the agent562 sees the path and the user can still edit/remove it. Returns (updated_textbox, sess, status_html)."""563 sess = sess or _new_session()564 if request is not None and not sess.get("token"):565 sess["token"] = getattr(request, "session_hash", None)566 task = task or ""567 if not filepath:568 return gr.update(), sess, ""569 try:570 size = os.path.getsize(filepath)571 except OSError:572 return gr.update(), sess, "⚠️ couldn't read the dropped file"573 if size > MAX_UPLOAD_BYTES:574 return gr.update(), sess, f"⚠️ file too large ({size // (1024*1024)} MB) - max 2 GB"575 # ensure THIS session owns a sandbox (its own fresh per-session working dir) before writing into it.576 if sess.get("sandbox") is None:577 sess["sandbox"] = agent.Sandbox()578 sb = sess["sandbox"]579 _register_sandbox(sess) # so demo.unload can rmtree THIS session's dir on leave580 name = os.path.basename(filepath)581 try:582 dst = sb._resolve(name)583 shutil.copy2(filepath, dst)584 except Exception as e:585 return gr.update(), sess, f"⚠️ couldn't save the file: {type(e).__name__}"586 # don't let the just-uploaded file get rendered as a "produced" media bubble next turn.587 try:588 mt = os.path.getmtime(dst)589 if mt > (sess.get("mtime") or 0.0):590 sess["mtime"] = mt591 except OSError:592 pass593 ref = f"/workspace/{name}"594 new_task = (task.rstrip() + (" " if task.strip() else "") + ref).lstrip()595 return new_task, sess, f"📎 added `{ref}` ({size // 1024 if size < 1024*1024 else size // (1024*1024)}"\596 f"{' KB' if size < 1024*1024 else ' MB'}) - edit or remove the path before sending"597 598 599# ── Session isolation: this Space is SHARED (one container, many browser sessions). Each session gets600# its OWN tempfile.mkdtemp sandbox (agent.Sandbox), so files never leak/accumulate between users. We also601# sweep ORPHANED sandbox temp dirs (a user who left without firing demo.unload, e.g. a crashed tab) older602# than SANDBOX_TTL_S, so disk doesn't fill up on the free 2-vCPU tier.603SANDBOX_TTL_S = int(os.environ.get("CODEAGENT_SANDBOX_TTL", str(30 * 60))) # ~30 min604_SWEEP_PREFIXES = ("agent_ws_", "codeagent_out_") # Sandbox dirs + the copied-out media dirs605 606 607def _sweep_orphans():608 """rmtree any sandbox/media temp dirs older than SANDBOX_TTL_S (by mtime). Best-effort; never raises."""609 root = tempfile.gettempdir()610 cutoff = time.time() - SANDBOX_TTL_S611 try:612 names = os.listdir(root)613 except OSError:614 return615 for n in names:616 if not n.startswith(_SWEEP_PREFIXES):617 continue618 p = os.path.join(root, n)619 try:620 if os.path.isdir(p) and os.path.getmtime(p) < cutoff:621 shutil.rmtree(p, ignore_errors=True)622 except OSError:623 pass624 625 626def _sweeper_loop():627 while True:628 time.sleep(SANDBOX_TTL_S // 3 or 600)629 _sweep_orphans()630 631 632threading.Thread(target=_sweeper_loop, daemon=True).start()633 634 635# Per-session sandbox registry so demo.unload can clean THE session that just left. Gradio's unload636# handler can't receive gr.State, so we map a session token -> its Sandbox and clean by token on unload.637# (The TTL sweeper is the backstop for any session that leaves without firing unload, e.g. a crashed tab.)638_SESSION_SANDBOXES = {} # token -> agent.Sandbox639 640 641def _register_sandbox(sess):642 if sess and sess.get("sandbox") is not None and sess.get("token"):643 _SESSION_SANDBOXES[sess["token"]] = sess["sandbox"]644 645 646def on_unload(request: gr.Request):647 """Fires when a browser session ENDS (tab closed / navigated away, possibly mid-run). Clean up ONLY648 that session's sandbox dir - NEVER the shared llama-server (other users depend on it)."""649 try:650 tok = getattr(request, "session_hash", None)651 sb = _SESSION_SANDBOXES.pop(tok, None)652 if sb is not None:653 sb.cleanup()654 _CANCEL_FLAGS.pop(tok, None) # drop this session's cancel flag too655 except Exception:656 pass657 _sweep_orphans() # also clear anything else already past TTL658 659 660# ── Custom theme: "Solder & Amber" - a warm-charcoal WORKBENCH look ────────────────────────────────661# Built on gr.themes.Base (minimal defaults -> we own the palette). Density comes from the SIZE TOKENS662# (spacing_sm / radius / text_sm) and tight padding tokens; the css= block below adds the workbench663# texture, role-by-gutter chat treatment, and micro-interactions the tokens can't express. Palette:664# ACCENT #E8932B amber (the single action color: primary button, focus ring, links, gutter rail)665# SUCCESS #5E8C7D oxidized teal (reserved for "verify passed") · base charcoal #1A1614, text parchment.666from gradio.themes.utils import colors, sizes, fonts667 668# "Solder & Amber" - a warm-charcoal WORKBENCH theme (a tiny 1B running real bash/python on a free CPU reads669# as a powered-on workbench, not a cloud chatbot). Palette: charcoal #1A1614 base, taupe-brown #3A2F27 raised670# surface, amber #E8932B the single action color, oxidized teal #5E8C7D reserved ONLY for "verify passed",671# parchment #E8DDD0 / taupe #C8B6A6 text. We set the LIGHT and DARK token variants to the SAME values so the672# Gradio theme toggle can't drift the look - it is one cohesive workbench in either mode (and that collapses673# the old body:not(.dark)/body.dark CSS duality, killing a whole class of contrast bugs).674THEME = gr.themes.Base(675 primary_hue=colors.orange, # amber action family676 secondary_hue=colors.orange,677 neutral_hue=colors.stone, # warm neutral (charcoal/taupe), not cool slate678 spacing_size=sizes.spacing_sm,679 radius_size=sizes.radius_sm, # tighter, more engineered corners than the old radius_lg680 text_size=sizes.text_md,681 # Anti-AI-slop type: an industrial humanist grotesk for chrome (NOT Inter/Roboto/system), and a condensed682 # engineered mono with a vintage-terminal feel for code/tool-calls/transcript. Both load from Google Fonts.683 font=(fonts.GoogleFont("Hanken Grotesk"), "ui-sans-serif", "system-ui", "sans-serif"),684 font_mono=(fonts.GoogleFont("Martian Mono"), "ui-monospace", "monospace"),685).set(686 body_background_fill="#1A1614",687 body_background_fill_dark="#1A1614",688 background_fill_primary="#1A1614",689 background_fill_primary_dark="#1A1614",690 background_fill_secondary="#241E1A",691 background_fill_secondary_dark="#241E1A",692 body_text_color="#E8DDD0",693 body_text_color_dark="#E8DDD0",694 body_text_color_subdued="#C8B6A6",695 body_text_color_subdued_dark="#C8B6A6",696 block_title_text_color="#E8DDD0",697 block_title_text_color_dark="#E8DDD0",698 block_label_text_color="#C8B6A6",699 block_label_text_color_dark="#C8B6A6",700 border_color_primary="#3A2F27",701 border_color_primary_dark="#3A2F27",702 # Amber action, dark text-on-amber for crisp contrast.703 button_primary_background_fill="#E8932B",704 button_primary_background_fill_hover="#F2A23E",705 button_primary_background_fill_dark="#E8932B",706 button_primary_background_fill_hover_dark="#F2A23E",707 button_primary_text_color="#1A1614",708 button_primary_text_color_dark="#1A1614",709 button_secondary_background_fill="transparent",710 button_secondary_background_fill_hover="#2E251F",711 button_secondary_background_fill_dark="transparent",712 button_secondary_background_fill_hover_dark="#2E251F",713 button_secondary_text_color="#C8B6A6",714 button_secondary_text_color_dark="#C8B6A6",715 button_secondary_border_color="#3A2F27",716 button_secondary_border_color_dark="#3A2F27",717 # Recessed input well, amber focus ring.718 input_background_fill="#241E1A",719 input_background_fill_dark="#241E1A",720 input_border_color="#3A2F27",721 input_border_color_dark="#3A2F27",722 input_border_color_focus="#E8932B",723 input_border_color_focus_dark="#E8932B",724 # Depth from deep warm shadows (dark theme).725 shadow_drop="0 1px 2px 0 rgba(0,0,0,0.30)",726 shadow_drop_lg="0 6px 20px -4px rgba(0,0,0,0.45)",727 block_background_fill="#1A1614",728 block_background_fill_dark="#1A1614",729 block_border_width="0px",730 block_shadow="none",731 block_label_background_fill="transparent",732 block_label_background_fill_dark="transparent",733 block_padding="6px",734 button_large_padding="9px 16px",735 button_small_padding="6px 12px",736 form_gap_width="0px",737 link_text_color="#E8932B",738 link_text_color_dark="#E8932B",739 link_text_color_hover="#F2A23E",740)741 742# css = only the true deltas the theme tokens can't express. ChatGPT-style bubbles, a no-gap single-page743# column, a thin scrollbar, and one terse status line. Targets stable elem_id / elem_classes (per the744# Gradio custom-CSS guidance) rather than fragile internal selectors.745CSS = """746/* ============================================================================747 "SOLDER & AMBER" - one cohesive warm-charcoal WORKBENCH skin. The theme tokens748 set the light AND dark variants identically, so NONE of these rules need a749 body.dark / body:not(.dark) guard (that duality was a contrast-bug factory).750 ROLES are distinguished by SIDE + an amber GUTTER, never by an alternating751 background fill - that alternation was the "green/gray inconsistency" the user752 saw. Accent amber #E8932B = the only action color; oxidized teal #5E8C7D is753 reserved for success ("verify passed"); parchment #E8DDD0 / taupe #C8B6A6 text.754 ============================================================================ */755 756/* ---- Page canvas: warm charcoal + an engineered dot-grid + a faint warm vignette757 and barely-there grain, so the wide side-margins and an empty thread read as758 graph-paper on a workbench, never a blank slab. Fixed so it doesn't scroll.759 The Gradio container is made transparent so this texture shows through behind760 and around the 880px column (incl. behind the flat, gutter-only agent turns). */761body {762 background-color: #1A1614 !important;763 background-image:764 radial-gradient(circle at center, rgba(220,182,140,0.17) 1.1px, transparent 1.7px),765 radial-gradient(135% 95% at 50% -8%, rgba(232,147,43,0.10), transparent 60%),766 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");767 background-size: 24px 24px, 100% 100%, 150px 150px;768 background-position: 0 0, 0 0, 0 0;769 background-attachment: fixed, fixed, fixed;770 background-repeat: repeat, no-repeat, repeat;771}772.gradio-container { background: transparent !important; }773 774/* Global text: parchment everywhere by default; muted taupe for secondary; amber for links.775 (Theme tokens already do most of this; these pins stop any stray white-on-charcoal.) */776.gradio-container, .prose, .prose *, p, li, span, label, .markdown, .markdown *,777#chat .message, #chat .message * { color: #E8DDD0; }778small, #chat summary, .ft-sep, #appfooter span { color: #C8B6A6; }779a, .markdown a, #chat a, #appfooter a { color: #E8932B; }780a:hover, #appfooter a:hover { color: #F2A23E; }781 782/* App column: centered, capped, app-like (not a landing page). */783.gradio-container { max-width: 880px !important; margin: 0 auto !important; padding: 2px 12px 4px !important; }784footer { display: none !important; } /* drop the default Gradio footer */785.gradio-container > .main, .gradio-container .contain { padding-top: 0 !important; gap: 4px !important; }786.gradio-container .contain > *:first-child { margin-top: 0 !important; }787#dropzone-host { padding: 0 !important; margin: 0 !important; min-height: 0 !important; height: 0 !important;788 overflow: visible !important; border: none !important; }789/* tight, compact column - minimal gaps between chat / composer / examples / footer */790.gradio-container .gap { gap: 4px !important; }791.gradio-container .form { gap: 4px !important; }792#composer { gap: 6px !important; align-items: flex-start !important; margin: 2px 0 0 !important; }793 794/* ---- Web-tools badge (now lives in the footer, not a top strip) ---- */795.badge { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.04em;796 padding: 1px 8px; border-radius: 999px; border: 1px solid; text-transform: uppercase; }797.badge-on { color: #8FBFAE; border-color: #355247; background: rgba(94,140,125,0.10); }798.badge-off { color: #A89A8B; border-color: #3A2F27; background: transparent; }799 800/* ---- Chat thread ---- */801#chat { border: none !important; box-shadow: none !important; background: transparent !important; }802#chat .message-row { padding: 0 !important; }803#chat .message {804 border: none !important; line-height: 1.6 !important; padding: 11px 15px !important;805 animation: fadeRise 0.16s ease-out both; /* every bubble "arrives" */806}807/* USER turn: ONE bubble. Gradio NESTS .message.user (outer) > [data-testid="user"] (inner); styling BOTH gave808 a DOUBLE box. So the OUTER .message.user is the single taupe bubble (right-aligned, hugs its text via809 fit-content + the faint amber top-edge), and the INNER [data-testid="user"] is FLATTENED (transparent, no810 box/padding) so it just holds the text. */811#chat .message.user {812 margin-left: auto !important; width: fit-content !important; max-width: 86% !important;813 background: #3A2F27 !important; color: #E8DDD0 !important;814 padding: 9px 14px !important;815 border-radius: 11px !important; border-top: 1px solid rgba(232,147,43,0.40) !important;816 box-shadow: 0 1px 2px rgba(0,0,0,0.30) !important;817}818#chat .message.user [data-testid="user"], #chat [data-testid="user"] {819 background: transparent !important; border: none !important; border-radius: 0 !important;820 box-shadow: none !important; padding: 0 !important; margin: 0 !important;821}822/* tighten the markdown <p> inside the user bubble so a short message isn't a tall box */823#chat .message.user p, #chat [data-testid="user"] p { margin: 0 !important; }824#chat .message.user *, #chat [data-testid="user"] * { color: #E8DDD0 !important; }825/* ASSISTANT turn: left-aligned, NO fill - sits flat on the dotted grid, marked ONLY by a 2px amber gutter826 (the "machine output" rail). Text + media + warnings are ALL bot bubbles, so they share this gutter and a827 variable number of them still reads as ONE consistent agent turn (no more transparent-media gaps). */828#chat .message.bot, #chat [data-testid="bot"], #chat .message.component, #chat .message.html {829 margin-right: auto !important; max-width: 100% !important;830 background: transparent !important; color: #E8DDD0 !important;831 border-left: 2px solid #E8932B !important; border-radius: 0 8px 8px 0 !important;832 padding: 9px 14px 9px 15px !important; box-shadow: none !important;833}834#chat .message.bot *, #chat [data-testid="bot"] * { color: #E8DDD0; }835/* media (image / svg / video / iframe) bubbles: keep the amber gutter, drop padding so the artifact fills. */836#chat .message:has(img), #chat .message:has(video), #chat .message:has(iframe), #chat .message:has(.lp-frame) {837 padding: 6px 6px 6px 14px !important; background: transparent !important;838}839#chat img, #chat video { border-radius: 8px !important; }840/* live indicator: a blinking amber block-cursor + muted italic readout (not a "real" gray bubble popping in). */841.live-ind { font-style: italic; color: #C8B6A6; font-family: var(--font-mono); font-size: 13px; }842.live-cursor { color: #E8932B; font-weight: 700; animation: blink 1.05s steps(1) infinite; }843/* token-level streaming: the generation typing out (muted, monospace, wraps; the tail of the in-progress text) */844.live-gen { font-family: var(--font-mono); font-size: 12px; color: #9C8C7C; white-space: pre-wrap;845 word-break: break-word; opacity: 0.9; border-left: 2px solid #3A2F27; padding-left: 10px; margin: 2px 0; }846/* think label (small amber tag) vs the live answer (brighter than the muted think) so they're never confused */847.live-think-tag { font-family: var(--font-mono); font-size: 11px; color: #E8932B; opacity: 0.8; font-style: italic; margin: 2px 0; }848.live-answer { font-family: var(--font-mono); font-size: 13px; color: #E8DDD0; white-space: pre-wrap;849 word-break: break-word; border-left: 2px solid #E8932B; padding-left: 10px; margin: 2px 0; }850 851/* code + inline code: dark warm fill, mono, parchment text. */852#chat pre, #chat pre code, #chat code, #chat :not(pre) > code {853 background-color: #120F0D !important; color: #EBD9C2 !important;854 border: 1px solid #2A2219 !important; border-radius: 7px !important; font-family: var(--font-mono) !important;855}856#chat pre { padding: 10px 12px !important; }857/* per-turn meta header (iters/tool-calls) + token-speed: quiet mono so they read as a stats strip, not prose. */858#chat .message.bot em:first-child { color: #A89A8B; font-style: normal; font-family: var(--font-mono); font-size: 12px; }859 860/* <details> thinking / tool-output: etched panel, [+]/[-] toggle instead of a triangle, amber-tinted summary. */861#chat details {862 margin: 6px 0 !important; background-color: #1E1813 !important;863 border: 1px solid #2E251F !important; border-left: 2px solid #5E4A35 !important;864 border-radius: 7px !important; padding: 5px 10px !important;865}866#chat details, #chat details * { color: #D7C9B8 !important; }867#chat details pre, #chat details code { background-color: #120F0D !important; color: #EBD9C2 !important; }868#chat summary { cursor: pointer; font-size: 0.9em; color: #C8B6A6 !important; list-style: none;869 font-family: var(--font-mono); }870#chat summary::-webkit-details-marker { display: none; }871#chat summary::before { content: "[+] "; color: #E8932B; font-family: var(--font-mono); }872#chat details[open] > summary::before { content: "[-] "; }873/* copy buttons on code/messages: readable warm chip. */874#chat .message button, #chat .copy-button, #chat button[title*="opy"], #chat .code_wrap button, #chat [class*="copy"] {875 color: #D7C9B8 !important; background-color: #2A2219 !important;876 border: 1px solid #3A2F27 !important; border-radius: 6px !important; opacity: 1 !important;877}878 879/* ---- Inline HTML "live preview": wrap the sandboxed iframe in a tiny browser-chrome frame so the running880 mini-app reads as a real artifact, not a floating rectangle. ---- */881.lp-frame { border: 1px solid #3A2F27 !important; border-radius: 9px; overflow: hidden;882 box-shadow: 0 6px 20px -6px rgba(0,0,0,0.5); background: #0E0C0A; }883.lp-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;884 background: #221C17; border-bottom: 1px solid #2E251F; }885.lp-bar .lp-dot { width: 9px; height: 9px; border-radius: 50%; background: #E8932B;886 box-shadow: 0 0 6px rgba(232,147,43,0.6); }887.lp-bar .lp-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;888 text-transform: uppercase; color: #A89A8B; }889.lp-frame iframe { display: block; width: 100%; height: 480px; border: 0; background: #ffffff; }890 891/* ---- Composer: amber focus ring + a busy "running" state on Send ---- */892#prompt textarea {893 border-radius: 11px !important; padding: 11px 14px !important; font-size: 15px !important;894 background: #241E1A !important; box-shadow: inset 0 1px 2px rgba(0,0,0,0.35) !important; color: #E8DDD0 !important;895}896#prompt textarea::placeholder { color: #9C8E7E !important; } /* >=4.5:1 on the #241E1A well (WCAG AA) */897#prompt textarea:focus { box-shadow: 0 0 0 2px rgba(232,147,43,0.30) !important; }898/* In Gradio 6 a gr.Button's elem_id lands on the <button> itself, so #send IS the button (the old899 "#send button" descendant selector matched nothing -> the mono styling was dead CSS). Target #send. */900#send {901 flex: 0 0 auto !important; width: auto !important; min-width: 0 !important;902 max-width: 104px !important; white-space: nowrap !important;903 border-radius: 9px !important; font-size: 14px !important; font-weight: 700 !important;904 font-family: var(--font-mono) !important; letter-spacing: 0.03em !important;905 padding: 9px 14px !important; color: #1A1614 !important; transition: filter 0.14s ease, transform 0.06s ease;906}907#send:hover { filter: brightness(1.06); }908#send:active { transform: translateY(1px); }909#send:disabled { opacity: 0.6 !important; cursor: progress !important; filter: none !important; }910 911/* ---- Example prompts: etched warm pills, amber border + lift on hover ---- */912#examples { border: none !important; background: transparent !important; padding: 0 !important; margin: 2px 0 0 !important; }913#examples .examples-table, #examples table, #examples tbody, #examples tr {914 border: none !important; display: flex !important; flex-wrap: wrap !important; gap: 6px !important;915}916#examples button, #examples td {917 border-radius: 8px !important; border: 1px solid #3A2F27 !important;918 background: #221C17 !important; color: #C8B6A6 !important;919 box-shadow: inset 0 1px 0 rgba(255,255,255,0.02) !important;920 font-family: var(--font-mono) !important; font-size: 12.5px !important; line-height: 1.3 !important;921 padding: 6px 12px !important; margin: 0 !important; max-width: 360px !important;922 white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important;923 transition: border-color 0.14s ease, color 0.14s ease, transform 0.08s ease;924}925#examples button:hover, #examples td:hover {926 border-color: #E8932B !important; color: #E8DDD0 !important; transform: translateY(-1px);927}928 929/* ---- Footer: ONE ultra-quiet line carrying real signal - model link + the (otherwise invisible) MCP endpoint. */930#footer-host { padding: 0 !important; margin: 4px 0 0 !important; }931#appfooter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;932 border-top: 1px solid #2A2219; padding: 9px 4px 4px; font-size: 11.5px;933 font-family: var(--font-mono); color: #8C7D6E; letter-spacing: 0.01em; }934#appfooter code { background: #1E1813; border: 1px solid #2E251F; border-radius: 5px;935 padding: 1px 5px; font-size: 11px; color: #C8B6A6; }936#appfooter .ft-sep { color: #6E6055; } /* faintly visible separators (were 2.23:1, effectively invisible) */937#appfooter .ft-mark { color: #E8932B; font-weight: 700; letter-spacing: -1px; } /* small amber identity mark */938#appfooter .ft-name { color: #C8B6A6; }939 940/* ---- Attach control (hidden) + amber drag-anywhere overlay ---- */941#dropfile { display: none !important; }942#attachstatus { font-size: 12.5px !important; color: #C8B6A6 !important; margin: 0 !important; padding: 0 !important; text-align: center; }943#attachstatus:empty { display: none !important; }944#dropzone { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: center;945 background: rgba(232,147,43,0.08); backdrop-filter: blur(2px); pointer-events: none; }946#dropzone.drag-active { display: flex; }947#dropzone .dz-inner { font-size: 17px; font-weight: 600; color: #E8932B; text-align: center; line-height: 1.6;948 font-family: var(--font-mono); border: 2px dashed #E8932B; border-radius: 14px; padding: 34px 46px;949 background: #1A1614; animation: pulseBorder 1.4s ease-in-out infinite; }950 951/* ---- Thin warm scrollbar ---- */952* { scrollbar-width: thin; scrollbar-color: rgba(120,104,88,0.4) transparent; }953*::-webkit-scrollbar { width: 7px; height: 7px; }954*::-webkit-scrollbar-thumb { background: rgba(120,104,88,0.4); border-radius: 6px; }955*::-webkit-scrollbar-thumb:hover { background: rgba(120,104,88,0.65); }956 957/* ---- Motion (all subtle, sub-200ms; fully disabled under prefers-reduced-motion) ---- */958@keyframes fadeRise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }959@keyframes blink { 50% { opacity: 0.15; } }960@keyframes pulseBorder { 0%,100% { border-color: #E8932B; } 50% { border-color: #8a5a1c; } }961@media (prefers-reduced-motion: reduce) {962 *, *::before, *::after { animation: none !important; transition-duration: 0.01ms !important; }963}964"""965 966# Tiny JS: a ChatGPT-style DRAG-ANYWHERE drop overlay. While a file is dragged anywhere over the page we show967# the #dropzone overlay ("Drop any file in the workspace"); it hides on drop OR when the drag leaves the page /968# is cancelled. On drop we forward the dropped file into the (visually hidden) gr.File's <input type=file> and969# fire a 'change' event, so Gradio runs the SAME attach_file handler (save into sandbox + append /workspace/<name>970# to the input). A dragenter/dragleave DEPTH counter keeps child elements from flickering the overlay. elem_id971# targets (#dropzone, #dropfile) are stable per the Gradio custom-CSS/JS guidance.972DROP_JS = """973() => {974 const z = document.getElementById('dropzone'); if (!z) return;975 let depth = 0;976 const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');977 const show = () => z.classList.add('drag-active');978 const hide = () => { depth = 0; z.classList.remove('drag-active'); };979 // forward the dropped file into Gradio's hidden file input so its .upload/attach_file wiring fires.980 const forward = (files) => {981 if (!files || !files.length) return;982 const input = document.querySelector('#dropfile input[type=file]');983 if (!input) return;984 try { input.files = files; } catch (err) { /* some browsers disallow assigning .files; ignore */ }985 input.dispatchEvent(new Event('change', { bubbles: true }));986 };987 window.addEventListener('dragenter', (e) => { if (hasFiles(e)) { depth++; show(); } });988 window.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); }); // allow drop anywhere989 window.addEventListener('dragleave', () => { depth = Math.max(0, depth - 1); if (depth === 0) hide(); });990 // drag cancelled / pointer left the window entirely -> hide.991 window.addEventListener('dragend', hide);992 document.addEventListener('mouseleave', () => { if (z.classList.contains('drag-active')) hide(); });993 window.addEventListener('drop', (e) => {994 e.preventDefault();995 hide();996 if (hasFiles(e)) forward(e.dataTransfer.files);997 });998}999"""1000 1001# Workbench chrome (computed once at startup). No top status strip (it duplicated the footer); instead the1002# empty-thread PLACEHOLDER teaches the write->run->verify value prop, and ONE consolidated FOOTER carries all1003# the runtime context (model/quant/runtime/tier + live web badge) plus the model card + the otherwise-invisible1004# MCP endpoint - each fact stated exactly once. All load-bearing info, no filler.1005WELCOME = (1006 "### ▍ I write, run, and verify code, on a free CPU\n\n"1007 "Give me a coding task. I reason in `<think>`, then use **bash / write / read / edit** to build it, "1008 "run it in a sandbox, read the output, debug, and show the result inline.\n\n"1009 "📊 charts & images · 🖱️ little web apps · 🧮 compute & web look-ups"1010)1011_MODEL_REPO = os.environ.get("MODEL_REPO", "Luminia/MiniCPM5-1B-Agent-GGUF")1012FOOTER_HTML = (1013 '<div id="appfooter">'1014 '<span class="ft-mark">▍</span>'1015 # "Q8_0 GGUF" IS the model-card link (no separate "model card" item; name + web badge dropped as redundant)1016 f'<a href="https://huggingface.co/{_MODEL_REPO}" target="_blank" rel="noopener" title="model card">Q8_0 GGUF ↗</a>'1017 '<span class="ft-sep">·</span><span>CPU ~4 min/turn</span>'1018 '<span class="ft-sep">·</span>'1019 '<span>MCP <code>run_coding_task</code> at <code>/gradio_api/mcp/</code></span>'1020 '</div>')1021 1022# Gradio 6: app-level theme/css/js live on demo.launch(...), NOT on gr.Blocks(...). Only `title` stays here.1023with gr.Blocks(title="MiniCPM5-1B-Agent") as demo:1024 # No hero banner and no top status strip (it duplicated the footer) - the chat opens at the top; all1025 # runtime context lives once in the footer.1026 sess = gr.State(_new_session) # FRESH per-browser-session state (own sandbox, own chat)1027 # Drag-drop overlay affordance (ChatGPT-style): a full-app dashed drop zone that only appears while a1028 # file is being dragged (toggled by DROP_JS). Always in the DOM but visually inert until .drag-active.1029 gr.HTML('<div id="dropzone"><div class="dz-inner">📎 Drop any file in the workspace</div></div>',1030 elem_id="dropzone-host")1031 # Gradio 6: show_copy_button -> buttons=["copy"]; allow_tags pinned to False explicitly (its default1032 # flips to True in 6.x, which would change how the rendered <details>/<div> trajectory HTML is handled1033 # vs what this app was built and tested against - keep the prior, tested behavior). `placeholder` shows1034 # the WELCOME empty-state only while the thread is empty (auto-hides on the first real message).1035 # sanitize_html=False is REQUIRED: in Gradio 6.x the Chatbot's default HTML sanitizer STRIPS the1036 # srcdoc attribute off our live-preview <iframe> (and force-sets sandbox="allow-scripts"), so a produced1037 # web page would render as a BLANK white box. We don't rely on the sanitizer for safety: all chat content1038 # is app-generated, model-emitted text is html.escape()'d in _render, and the iframe is sandboxed (opaque1039 # origin, no allow-same-origin) with an html.escape'd srcdoc. allow_tags=False stays (custom-tag policy).1040 chat = gr.Chatbot(height=360, show_label=False, render_markdown=True,1041 elem_id="chat", buttons=["copy"], allow_tags=False, sanitize_html=False,1042 avatar_images=None, placeholder=WELCOME)1043 with gr.Row(elem_id="composer"):1044 inp = gr.Textbox(show_label=False, lines=1, max_lines=6,1045 placeholder="Ask MiniCPM5-1B-Agent CPU, to run some code • Upload files here → workspace.",1046 scale=6, autofocus=True, container=False, elem_id="prompt")1047 # Just [Send]. The Chatbot's built-in TRASH icon (top-right) is the single session control - it clears1048 # the chat AND (via chat.clear below) resets the session + aborts any in-flight run. (The old separate1049 # "🔄 New" button was redundant clutter; folded into the trash icon.)1050 with gr.Column(scale=1, min_width=92):1051 btn = gr.Button("Send", variant="primary", elem_id="send", scale=1)1052 # Hidden attach control (the REAL upload target): its underlying <input type=file> + .upload wiring stay1053 # intact, but the widget is VISUALLY HIDDEN (CSS #dropfile{display:none}). The affordance is dragging a1054 # file ANYWHERE over the page -> DROP_JS forwards the dropped file into this input's .files and fires a1055 # 'change' event, so Gradio saves it into the session sandbox and appends /workspace/<name> to the input.1056 drop = gr.File(label="attach", file_count="single", elem_id="dropfile", height=64)1057 attach_status = gr.Markdown("", elem_id="attachstatus")1058 # All 3 examples visible; the flex-wrap CSS lets them wrap to a 2nd row (no funny single-line truncation).1059 gr.Examples(EXAMPLES, inputs=inp, elem_id="examples", label="")1060 # One ultra-quiet footer line: model card link + the (otherwise invisible) MCP endpoint - real signal.1061 gr.HTML(FOOTER_HTML, elem_id="footer-host")1062 _outs = [chat, sess, inp]1063 # Gradio 6: event listeners use api_visibility=... (the old show_api=False). These UI handlers are stateful1064 # generators (gr.State / gr.update / component-valued bubbles) that make no sense as MCP tools, so hide them1065 # from API docs + the MCP server ("undocumented" == old show_api=False; the single MCP tool is the clean1066 # run_coding_task endpoint wired via gr.api below).1067 # Drive the displayed chat from gr.State (sess), NOT from the Chatbot component: inputs never include1068 # `chat`, so component-valued media bubbles never round-trip through the Chatbot's preprocess.1069 run_evt = btn.click(run, inputs=[inp, sess], outputs=_outs, concurrency_limit=1,1070 api_visibility="undocumented")1071 sub_evt = inp.submit(run, inputs=[inp, sess], outputs=_outs, concurrency_limit=1,1072 api_visibility="undocumented")1073 # Clear the transient "added /workspace/x ... before sending" attach hint once the turn finishes, so it1074 # doesn't linger (saying "before sending") under an empty composer into the next turn. Chained as a1075 # separate .then on the SAME event objects (run_evt/sub_evt stay the run generators, so cancels= still1076 # targets them); attach_status is NOT in _outs, so the run generator's 3-tuple yields are untouched.1077 _clear_attach = lambda: gr.update(value="")1078 run_evt.then(_clear_attach, outputs=[attach_status], api_visibility="undocumented")1079 sub_evt.then(_clear_attach, outputs=[attach_status], api_visibility="undocumented")1080 # The Chatbot's built-in TRASH icon (top-right) is the single session control: clicking it fires chat.clear1081 # -> reset_session (fresh session: new sandbox/history/cleared chat) AND cancels= aborts any in-flight run at1082 # its next yield. reset_session flips the cancel flag first so the live indicator shows 'cancel Ns...'.1083 # (gr.Chatbot.clear fires on the trash-icon click - verified in the Gradio 6 docs.)1084 chat.clear(reset_session, inputs=[sess], outputs=_outs, cancels=[run_evt, sub_evt],1085 api_visibility="undocumented")1086 # Drag-drop / attach: save into the session sandbox + append the path; clear the file widget after.1087 drop.upload(attach_file, inputs=[drop, inp, sess], outputs=[inp, sess, attach_status],1088 api_visibility="undocumented").then(1089 lambda: None, outputs=[drop], api_visibility="undocumented")1090 # MCP server: expose the agent as ONE clean, typed, documented tool an external MCP client can call to run a1091 # coding task. gr.api registers a pure-logic endpoint (fully typed signature -> MCP schema) with no UI, so it1092 # does not disturb the Gradio interface above. demo.launch(mcp_server=True) below turns it into an MCP tool.1093 gr.api(run_coding_task, api_name="run_coding_task")1094 # Session isolation: clean THIS session's sandbox when the browser disconnects (never the shared server).1095 demo.unload(on_unload)1096 1097# Free 2-vCPU CPU Space with ONE shared llama-server: serialize model use so concurrent users queue rather1098# than thrash the single server. default_concurrency_limit=1 (one inference at a time), bounded queue.1099demo.queue(default_concurrency_limit=1, max_size=16)1100 1101if __name__ == "__main__":1102 # Gradio 6: app-level theme/css/js moved here from gr.Blocks(...). mcp_server=True publishes the MCP server1103 # at /gradio_api/mcp/ (the run_coding_task tool). ssr_mode=False so the GoogleFont theme renders correctly1104 # (Gradio SSR drops construction-time fonts).1105 demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False,1106 theme=THEME, css=CSS, js=DROP_JS, mcp_server=True)1107 