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 | """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 = ( | |
| "### ▍ 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" | |
| "📊 charts & images · 🖱️ little web apps · 🧮 compute & web look-ups" | |
| ) | |
| _MODEL_REPO = os.environ.get("MODEL_REPO", "Luminia/MiniCPM5-1B-Agent-GGUF") | |
| FOOTER_HTML = ( | |
| '<div id="appfooter">' | |
| '<span class="ft-mark">▍</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 ↗</a>' | |
| '<span class="ft-sep">·</span><span>CPU ~4 min/turn</span>' | |
| '<span class="ft-sep">·</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">📎 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) | |