| """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__)) |
| |
| os.environ.setdefault("CODEAGENT_PROJ", HERE) |
| os.environ.setdefault("CODEAGENT_LLAMA_BIN", "llama-server") |
| sys.path.insert(0, HERE) |
| sys.path.insert(0, os.path.join(HERE, "backend")) |
|
|
| 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")) |
| |
| |
| |
| |
| |
| |
| |
| NPRED = int(os.environ.get("CODEAGENT_NPRED", "6144")) |
|
|
| |
| |
| 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") |
|
|
| |
| |
| _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) |
|
|
| |
| |
| |
| 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__() |
| atexit.register(lambda: SERVER.__exit__(None, None, None)) |
| print("[app] llama-server healthy - ready.", flush=True) |
|
|
| |
| 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) |
|
|
| |
| |
| |
| |
| |
| try: |
| os.makedirs("/workspace", exist_ok=True) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| EXAMPLES = [ |
| |
| |
| "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.", |
| |
| |
| |
| |
| "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.", |
| |
| "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 |
| |
| |
| |
| _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): |
| |
| |
| 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 |
| |
| 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: |
| |
| |
| |
| out.append(html.escape(final[:max_chars])) |
| return "\n\n".join(out) |
|
|
|
|
| IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp") |
| SVG_EXT = (".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: |
| 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_") |
| 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: |
| continue |
| b = _render_media_file(p, outdir) |
| 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} |
|
|
|
|
| |
| |
| |
| |
| |
| _CANCEL_FLAGS = {} |
|
|
|
|
| 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,}") |
|
|
|
|
| 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: |
| return True |
| if len(set(t.replace(" ", ""))) <= 1: |
| return True |
| if not _WORD_RE.search(t): |
| 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 |
| |
| |
| |
| 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 |
| |
| |
| |
| |
| chat = chat + [{"role": "user", "content": html.escape(task)}] |
| cancel = _cancel_event(sess) |
| if cancel is not None: |
| cancel.clear() |
| |
| |
| prev = sess.get("history") or [] |
| base = len(prev) |
|
|
| holder = {} |
| def _work(): |
| try: |
| |
| |
| |
| |
| holder["res"] = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0, |
| n_predict=NPRED, |
| keep_workspace=True, sandbox=sess.get("sandbox"), history=sess.get("history")) |
| except Exception as e: |
| holder["err"] = e |
| worker = threading.Thread(target=_work, daemon=True) |
| worker.start() |
|
|
| |
| |
| 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" |
| yield chat + [{"role": "assistant", |
| "content": f'<span class="live-ind"><span class="live-cursor">▍</span> {label}</span>'}], 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") |
| sess["history"] = res.get("messages") |
| _register_sandbox(sess) |
| transcript = _render(res["messages"][base:]) |
| header = (f"_iters={res.get('iters')} · tool-calls={res.get('tool_calls_made')} · " |
| f"{res.get('tool_counts')}_\n\n") |
| |
| |
| 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}] |
| |
| |
| media, sess["mtime"] = _media_messages(res.get("workspace"), since=sess.get("mtime") or 0.0) |
| |
| |
| |
| media = media + _extra_media(["/workspace"], since_ts=t0, exclude_dir=res.get("workspace")) |
| |
| |
| |
| _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: |
| media = media + [{"role": "assistant", "content": f"⚠️ HTML note: {_warn}"}] |
| chat = chat + media |
| sess["chat"] = chat |
| 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: |
| |
| |
| res = agent.run_agent(SERVER, TOK, task, max_iters=MAX_ITERS, temperature=0.0, |
| n_predict=NPRED, keep_workspace=False) |
| except Exception as e: |
| 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() |
| try: |
| if sess and sess.get("sandbox") is not None: |
| sess["sandbox"].cleanup() |
| except Exception: |
| pass |
| fresh = _new_session() |
| fresh["token"] = tok |
| if ev is not None: |
| ev.clear() |
| return [], fresh, "" |
|
|
|
|
| MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 |
|
|
|
|
| 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" |
| |
| if sess.get("sandbox") is None: |
| sess["sandbox"] = agent.Sandbox() |
| sb = sess["sandbox"] |
| _register_sandbox(sess) |
| 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__}" |
| |
| 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" |
|
|
|
|
| |
| |
| |
| |
| SANDBOX_TTL_S = int(os.environ.get("CODEAGENT_SANDBOX_TTL", str(30 * 60))) |
| _SWEEP_PREFIXES = ("agent_ws_", "codeagent_out_") |
|
|
|
|
| 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() |
|
|
|
|
| |
| |
| |
| _SESSION_SANDBOXES = {} |
|
|
|
|
| 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) |
| except Exception: |
| pass |
| _sweep_orphans() |
|
|
|
|
| |
| |
| |
| |
| |
| |
| from gradio.themes.utils import colors, sizes, fonts |
|
|
| |
| |
| |
| |
| |
| |
| THEME = gr.themes.Base( |
| primary_hue=colors.orange, |
| secondary_hue=colors.orange, |
| neutral_hue=colors.stone, |
| spacing_size=sizes.spacing_sm, |
| radius_size=sizes.radius_sm, |
| text_size=sizes.text_md, |
| |
| |
| 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", |
| |
| 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", |
| |
| 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", |
| |
| 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 = """ |
| /* ============================================================================ |
| "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; } |
| |
| /* 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; } |
| } |
| """ |
|
|
| |
| |
| |
| |
| |
| |
| 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); |
| }); |
| } |
| """ |
|
|
| |
| |
| |
| |
| 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>' |
| |
| 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>') |
|
|
| |
| with gr.Blocks(title="MiniCPM5-1B-Agent") as demo: |
| |
| |
| sess = gr.State(_new_session) |
| |
| |
| gr.HTML('<div id="dropzone"><div class="dz-inner">📎 Drop any file in the workspace</div></div>', |
| elem_id="dropzone-host") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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") |
| |
| |
| |
| with gr.Column(scale=1, min_width=92): |
| btn = gr.Button("Send", variant="primary", elem_id="send", scale=1) |
| |
| |
| |
| |
| drop = gr.File(label="attach", file_count="single", elem_id="dropfile", height=64) |
| attach_status = gr.Markdown("", elem_id="attachstatus") |
| |
| gr.Examples(EXAMPLES, inputs=inp, elem_id="examples", label="") |
| |
| gr.HTML(FOOTER_HTML, elem_id="footer-host") |
| _outs = [chat, sess, inp] |
| |
| |
| |
| |
| |
| |
| 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_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") |
| |
| |
| |
| |
| chat.clear(reset_session, inputs=[sess], outputs=_outs, cancels=[run_evt, sub_evt], |
| api_visibility="undocumented") |
| |
| drop.upload(attach_file, inputs=[drop, inp, sess], outputs=[inp, sess, attach_status], |
| api_visibility="undocumented").then( |
| lambda: None, outputs=[drop], api_visibility="undocumented") |
| |
| |
| |
| gr.api(run_coding_task, api_name="run_coding_task") |
| |
| demo.unload(on_unload) |
|
|
| |
| |
| demo.queue(default_concurrency_limit=1, max_size=16) |
|
|
| if __name__ == "__main__": |
| |
| |
| |
| demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False, |
| theme=THEME, css=CSS, js=DROP_JS, mcp_server=True) |
|
|