| import os
|
| import re
|
| import io
|
| import json
|
| import base64
|
| import time
|
| import uuid
|
| import datetime
|
| import contextlib
|
| import urllib.request
|
| import html as _html
|
| from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
| import ctypes
|
| import glob
|
| import site
|
|
|
|
|
| def _preload_cuda():
|
| bases = set(site.getsitepackages())
|
| try:
|
| bases.add(site.getusersitepackages())
|
| except Exception:
|
| pass
|
| libs = []
|
| for base in bases:
|
| libs += glob.glob(os.path.join(base, "nvidia", "*", "lib", "*.so*"))
|
| priority = {"cuda_runtime": 0, "cublas": 1}
|
|
|
| def _key(p):
|
| for name, rank in priority.items():
|
| if name in p:
|
| return rank
|
| return 2
|
|
|
| for so in sorted(set(libs), key=_key):
|
| try:
|
| ctypes.CDLL(so, mode=ctypes.RTLD_GLOBAL)
|
| except OSError:
|
| pass
|
|
|
|
|
| _preload_cuda()
|
|
|
| import gradio as gr
|
| import spaces
|
|
|
|
|
|
|
| _GR_MAJOR = int(gr.__version__.split(".")[0])
|
| _HTML_RAW = {"sanitize_html": False} if _GR_MAJOR >= 5 else {}
|
| from huggingface_hub import hf_hub_download
|
| from llama_cpp import Llama
|
|
|
|
|
| GGUF_REPO = os.environ.get("GGUF_REPO", "").strip()
|
| GGUF_FILE = os.environ.get("GGUF_FILE", "").strip()
|
| if not GGUF_REPO or not GGUF_FILE:
|
| raise RuntimeError("Set GGUF_REPO and GGUF_FILE as Space secrets (private GGUF repo + file path).")
|
| N_CTX = int(os.environ.get("N_CTX", "16384"))
|
|
|
| print("Downloading GGUF from the Hub ...", flush=True)
|
| MODEL_PATH = hf_hub_download(GGUF_REPO, GGUF_FILE)
|
| print("GGUF ready at", MODEL_PATH, flush=True)
|
|
|
| _LLM = None
|
|
|
|
|
|
|
| try:
|
| import llama_cpp as _lcpp
|
| _Q8 = int(getattr(_lcpp, "GGML_TYPE_Q8_0", 8))
|
| except Exception:
|
| _Q8 = 8
|
| KV_Q8 = os.environ.get("KV_Q8", "0") != "0"
|
|
|
|
|
| def _get_llm():
|
| global _LLM
|
| if _LLM is None:
|
| kw = dict(
|
| model_path=MODEL_PATH,
|
| n_gpu_layers=-1,
|
| n_ctx=N_CTX,
|
| verbose=False,
|
| )
|
| if KV_Q8:
|
| kw["flash_attn"] = True
|
| kw["type_k"] = _Q8
|
| kw["type_v"] = _Q8
|
| try:
|
| _LLM = Llama(**kw)
|
| except Exception as e:
|
| print(f"LLM init failed ({e}); retrying plain", flush=True)
|
| _LLM = Llama(model_path=MODEL_PATH, n_gpu_layers=-1, n_ctx=N_CTX, verbose=False)
|
| return _LLM
|
|
|
|
|
| _THINK = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
| _CODE_BLOCK = re.compile(r"```([\w+-]*)\s*\n(.*?)```", re.DOTALL)
|
| _TOOL_BLOCK = re.compile(r"```tool\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
|
| _PY_BLOCK = re.compile(r"```(?:python|py)\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
|
|
|
| _FILE_BLOCK = re.compile(r"```file:([^\n`]+)\n(.*?)```", re.DOTALL)
|
|
|
|
|
|
|
|
|
| _ARTIFACT_BLOCK = re.compile(
|
| r"```(?:file:[^\n`]+|html|htm|css|js|javascript|xml|svg)[^\n]*\n.*?```",
|
| re.DOTALL | re.IGNORECASE,
|
| )
|
| _ARTIFACT_OPEN = re.compile(
|
| r"```(?:file:[^\n`]+|html|htm|css|js|javascript|xml|svg)\b", re.IGNORECASE
|
| )
|
|
|
|
|
|
|
| _ARTIFACT_START = re.compile(
|
| r"```file:[^\n`]+"
|
| r"|```(?:html|htm|css|js|javascript|xml|svg)\b"
|
| r"|<!doctype\s+html"
|
| r"|<html[\s>]"
|
| r"|<script[\s>]"
|
| r"|<style[\s>]",
|
| re.IGNORECASE,
|
| )
|
|
|
|
|
| _HTML_DOC = re.compile(r"(?:<!doctype\s+html|<html[\s>]).*</html\s*>", re.IGNORECASE | re.DOTALL)
|
| _HTML_OPEN = re.compile(r"<!doctype\s+html|<html[\s>]", re.IGNORECASE)
|
|
|
| _NATIVE_TOOL = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
|
|
|
|
|
| def _artifact_name(head):
|
| """Filename for an artifact fence opener line like ```file:foo.css or ```html."""
|
| m = re.match(r"```file:([^\n`]+)", head)
|
| if m:
|
| return m.group(1).strip()
|
| lang = re.match(r"```([a-z]+)", head.lower())
|
| return {"css": "styles.css", "js": "script.js", "javascript": "script.js"}.get(
|
| lang.group(1) if lang else "", "index.html"
|
| )
|
|
|
|
|
| def _split(text):
|
| """Return (clean_answer, thinking). Robust to the common the model-thinking case
|
| where the chat template injects the opening <think> so the model only emits
|
| the CLOSING </think> (no opening tag in the stream)."""
|
| text = text or ""
|
| if "</think>" in text:
|
|
|
| i = text.index("</think>")
|
| thinking = text[:i].replace("<think>", "").strip()
|
| answer = text[i + len("</think>"):]
|
| elif "<think>" in text:
|
|
|
| i = text.index("<think>")
|
| answer = text[:i]
|
| thinking = text[i + len("<think>"):].strip()
|
| else:
|
| answer, thinking = text, ""
|
| answer = _TOOL_BLOCK.sub("", answer)
|
| answer = answer.replace("<think>", "").replace("</think>", "")
|
| return answer.strip(), thinking
|
|
|
|
|
| def _extract_doc(answer):
|
| """Assemble a single self-contained HTML document from the answer's
|
| HTML/CSS/JS code blocks, to render in the preview iframe."""
|
| htmls, csss, jss = [], [], []
|
| for lang, body in _CODE_BLOCK.findall(answer):
|
| l = (lang or "").lower().strip()
|
| b = body.strip()
|
| if not b or l == "tool":
|
| continue
|
| low = b.lower()
|
| if l in ("html", "htm") or "<!doctype" in low or "<html" in low or "<body" in low:
|
| htmls.append(b)
|
| elif l == "css":
|
| csss.append(b)
|
| elif l in ("js", "javascript"):
|
| jss.append(b)
|
| elif l == "" and "<" in b and ">" in b:
|
| htmls.append(b)
|
| doc = htmls[0] if htmls else ""
|
| if not doc and (csss or jss):
|
| doc = "<!DOCTYPE html><html><head><meta charset='utf-8'></head><body></body></html>"
|
| if not doc:
|
| return ""
|
| if "<html" not in doc.lower() and "<!doctype" not in doc.lower():
|
| doc = (
|
| "<!DOCTYPE html><html><head><meta charset='utf-8'></head><body>\n"
|
| + doc
|
| + "\n</body></html>"
|
| )
|
| if csss and "<style" not in doc.lower():
|
| style = "<style>\n" + "\n".join(csss) + "\n</style>"
|
| doc = doc.replace("</head>", style + "</head>", 1) if "</head>" in doc else style + doc
|
| if jss and "<script" not in doc.lower():
|
| script = "<script>\n" + "\n".join(jss) + "\n</script>"
|
| doc = doc.replace("</body>", script + "</body>", 1) if "</body>" in doc else doc + script
|
| return doc
|
|
|
|
|
| def _extract_html_doc(text):
|
| """Pull a whole HTML document out of raw text by TAG boundaries, ignoring
|
| fences/prose. Handles a continuation-split or unterminated document."""
|
| m = _HTML_DOC.search(text or "")
|
| if m:
|
| return m.group(0).strip()
|
| o = _HTML_OPEN.search(text or "")
|
| if o:
|
| doc = text[o.start():].strip()
|
| if "</html" not in doc.lower():
|
| doc += "\n</html>"
|
| return doc
|
| return ""
|
|
|
|
|
| def _best_doc(text):
|
| """The most complete renderable doc: prefer whichever of the fenced-block
|
| assembly or the tag-based extraction is longer (more complete)."""
|
| a = _extract_doc(text or "")
|
| b = _extract_html_doc(text or "")
|
| return b if len(b) > len(a) else a
|
|
|
|
|
| def _strip_artifacts(text):
|
| """Keep the chat clean and LEAK-PROOF: as soon as deliverable code/markup
|
| begins, cut everything from there and leave a pointer. Whatever messy mix of
|
| fences, raw tags, or continuation noise follows can never reach the chat."""
|
| if not text:
|
| return text
|
| m = _ARTIFACT_START.search(text)
|
| if not m:
|
| return text
|
| head = text[m.start(): m.start() + 80]
|
| fm = re.match(r"```file:([^\n`]+)", head)
|
| name = fm.group(1).strip() if fm else "index.html"
|
| pointer = f"📄 `{name}` → see **Preview** / **Code**"
|
| return (text[: m.start()].rstrip() + "\n\n" + pointer).strip()
|
|
|
|
|
| def _capture_artifacts(text, files):
|
| """Route the model's deliverable into the write_file tool's virtual FS.
|
| Returns [(path, bytes), ...] for the tools trace."""
|
| captured = []
|
| for path, content in _FILE_BLOCK.findall(text or ""):
|
| path, content = path.strip(), content.strip("\n")
|
| if path:
|
| files[path] = content
|
| captured.append((path, len(content)))
|
| if not any(p.lower().endswith((".html", ".htm")) for p, _ in captured):
|
| doc = _best_doc(text or "")
|
| if doc:
|
| files["index.html"] = doc
|
| captured.append(("index.html", len(doc)))
|
| return captured
|
|
|
|
|
| def _doc_from_files(files):
|
| """Newest .html file the model wrote this turn (the renderable artifact)."""
|
| html = [p for p in files if p.lower().endswith((".html", ".htm"))]
|
| if not html:
|
| return "", ""
|
| p = html[-1]
|
| return files[p], p
|
|
|
|
|
| def _current_doc(files, answer):
|
| """Prefer a file the model WROTE; fall back to scraping the raw text."""
|
| doc, name = _doc_from_files(files)
|
| if doc:
|
| return doc, name
|
| return _best_doc(answer), "index.html"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _t_web_search(query="", **_):
|
| try:
|
| from ddgs import DDGS
|
| except Exception:
|
| from duckduckgo_search import DDGS
|
| rows = list(DDGS().text(str(query), max_results=5))
|
| if not rows:
|
| return "(no results)"
|
| return "\n".join(
|
| f"- {r.get('title','')}: {(r.get('body') or '')[:200]} ({r.get('href','')})"
|
| for r in rows
|
| )
|
|
|
|
|
| def _t_open_url(url="", **_):
|
| req = urllib.request.Request(str(url), headers={"User-Agent": "Mozilla/5.0"})
|
| with urllib.request.urlopen(req, timeout=15) as r:
|
| raw = r.read(300000).decode("utf-8", "ignore")
|
| text = re.sub(r"<script.*?</script>|<style.*?</style>", " ", raw, flags=re.S | re.I)
|
| text = re.sub(r"<[^>]+>", " ", text)
|
| text = re.sub(r"\s+", " ", text).strip()
|
| return text[:4000] or "(empty page)"
|
|
|
|
|
| def _t_python(code="", **_):
|
| buf = io.StringIO()
|
| g = {"__name__": "__main__"}
|
| try:
|
| with contextlib.redirect_stdout(buf):
|
| exec(str(code), g)
|
| except Exception as e:
|
| return f"{buf.getvalue()}\n[error] {type(e).__name__}: {e}"[:4000]
|
| return (buf.getvalue() or "(ran, no stdout)")[:4000]
|
|
|
|
|
| def _t_write_file(files, path="", content="", **_):
|
| path = (str(path) or "index.html").strip().lstrip("/")
|
| files[path] = str(content)
|
| return f"wrote {path} ({len(files[path])} bytes)" + (
|
| " — rendering in Preview" if path.lower().endswith((".html", ".htm")) else ""
|
| )
|
|
|
|
|
| def _t_read_file(files, path="", **_):
|
| path = str(path).strip().lstrip("/")
|
| if path in files:
|
| return files[path][:4000]
|
| return f"[no such file: {path}] — files: {', '.join(sorted(files)) or '(none)'}"
|
|
|
|
|
| def _t_list_files(files, **_):
|
| if not files:
|
| return "(no files yet)"
|
| return "\n".join(f"{p} ({len(files[p])} bytes)" for p in sorted(files))
|
|
|
|
|
|
|
| FILE_TOOLS = {
|
| "write_file": _t_write_file,
|
| "read_file": _t_read_file,
|
| "list_files": _t_list_files,
|
| }
|
|
|
| TOOLS = {"web_search": _t_web_search, "open_url": _t_open_url, "python": _t_python}
|
| MAX_TOOL_STEPS = 6
|
| MAX_CONTINUE = 3
|
|
|
| TOOLS_GUIDE = (
|
| "You can call real tools that run live in this chat. Keep the chat itself short — "
|
| "do NOT paste whole files or large output as plain text.\n"
|
| "\n"
|
| "FILES (the deliverable) — for any web app, page, game, or UI, just write the code in a "
|
| "normal ```html block (and ```css / ```js if you split it). It is automatically SAVED as "
|
| "a file, rendered live in the Preview panel, and its source shown in the Code panel — you "
|
| "do NOT need to repeat it in the message. For several named files use ```file:path blocks, "
|
| "e.g. ```file:game.js. In the chat write only a one-line note about what you built.\n"
|
| "Inspect saved files with a tool block: ```tool\n{\"name\": \"list_files\", \"args\": {}}\n``` "
|
| "or {\"name\": \"read_file\", \"args\": {\"path\": \"index.html\"}}\n"
|
| "\n"
|
| "PYTHON (compute) — write a normal ```python block with print(); it is EXECUTED and you get "
|
| "stdout back as a TOOL RESULT. Use it for math, data, and checks.\n"
|
| "\n"
|
| "WEB — to search or fetch, emit ONE tool block and stop:\n"
|
| "```tool\n{\"name\": \"web_search\", \"args\": {\"query\": \"...\"}}\n```\n"
|
| "(or {\"name\": \"open_url\", \"args\": {\"url\": \"...\"}}). You then receive a TOOL RESULT "
|
| "and continue.\n"
|
| "\n"
|
| "Use one tool per step, only when needed. When finished, give a brief final answer."
|
| )
|
|
|
|
|
| def _parse_call(text, seen):
|
| """Detect an *action* tool call that needs a result fed back: explicit
|
| ```tool JSON, native <tool_call> JSON, or a bare ```python block.
|
| Deliverable file blocks (html/css/js/file:) are NOT handled here — they are
|
| captured directly into the filesystem by _capture_artifacts. Returns a call
|
| dict or None; `seen` dedupes python blocks already run this turn."""
|
| text = text or ""
|
| blocks = list(_TOOL_BLOCK.findall(text)) + list(_NATIVE_TOOL.findall(text))
|
| for block in blocks:
|
| try:
|
| call = json.loads(block.strip())
|
| except Exception:
|
| continue
|
| if isinstance(call, dict) and "name" in call:
|
|
|
| call["args"] = call.get("args") or call.get("arguments") or {}
|
| call.pop("arguments", None)
|
| return call
|
| pys = _PY_BLOCK.findall(text)
|
| if pys:
|
| code = pys[-1].strip()
|
| h = ("py", hash(code))
|
| if h not in seen:
|
| return {"name": "python", "args": {"code": code}, "_h": h}
|
| return None
|
|
|
|
|
| def _exec_tool(call, files):
|
| name = call.get("name")
|
| args = call.get("args") or {}
|
| if not isinstance(args, dict):
|
| args = {"query": args} if name == "web_search" else {"code": str(args)}
|
| try:
|
| if name in FILE_TOOLS:
|
| return str(FILE_TOOLS[name](files, **args))[:4000]
|
| fn = TOOLS.get(name)
|
| if not fn:
|
| return f"[unknown tool: {name}]"
|
| return str(fn(**args))[:4000]
|
| except Exception as e:
|
| return f"[tool error] {type(e).__name__}: {e}"
|
|
|
|
|
|
|
|
|
|
|
| def _placeholder(title, sub, building=False):
|
| cls = "pv-empty pv-building" if building else "pv-empty"
|
| return (
|
| f'<div class="{cls}"><div class="pv-empty__glyph">⚗️</div>'
|
| f'<div class="pv-empty__title">{title}</div>'
|
| f'<div class="pv-empty__sub">{sub}</div></div>'
|
| )
|
|
|
|
|
| _EMPTY_PREVIEW = _placeholder(
|
| "Живой превью",
|
| "SICS-1 рассуждает, строит и рендерит самодостаточный артефакт прямо здесь.",
|
| )
|
| _BUILDING_PREVIEW = _placeholder(
|
| "Собираю артефакт",
|
| "Формирую самодостаточную страницу из ответа модели.",
|
| building=True,
|
| )
|
| _NO_REASONING = "_В этом шаге нет рассуждения `<think>`._"
|
| _NO_TOOLS = "_Инструменты в этом шаге не использовались._"
|
|
|
|
|
| def _iframe(doc, name="artifact.html"):
|
| if not doc or not doc.strip():
|
| return _EMPTY_PREVIEW
|
| esc = _html.escape(doc, quote=True)
|
| pill = _html.escape(name or "artifact.html", quote=True)
|
|
|
|
|
| data_url = "data:text/html;base64," + base64.b64encode(doc.encode("utf-8")).decode("ascii")
|
|
|
|
|
| sandbox = ("allow-scripts allow-same-origin allow-modals allow-forms "
|
| "allow-popups allow-popups-to-escape-sandbox "
|
| "allow-top-navigation-by-user-activation")
|
| h = "clamp(440px,62vh,760px)"
|
| return (
|
| f'<div style="overflow:hidden;border-radius:14px;height:{h};">'
|
| '<div class="bchrome" style="height:100%;display:flex;flex-direction:column;">'
|
| '<div class="bchrome__bar">'
|
| '<span class="bchrome__dots"><i></i><i></i><i></i></span>'
|
| f'<span class="bchrome__pill">{pill}</span>'
|
| '<span class="bchrome__actions">'
|
| f'<a href="{data_url}" target="pvframe" title="Reload">↻</a>'
|
| f'<a href="{data_url}" target="_blank" rel="noopener" title="Open in new tab">↗</a>'
|
| '</span></div>'
|
| f'<iframe name="pvframe" srcdoc="{esc}" sandbox="{sandbox}"'
|
| f' style="display:block;width:100%;flex:1;min-height:0;border:0;background:#fff;"></iframe>'
|
| '</div>'
|
| '</div>'
|
| )
|
|
|
|
|
|
|
|
|
|
|
| SKILLS = {
|
| "Single-file HTML artifact": (
|
| "When the user wants a web app, page, game, or visual UI, WRITE ONE complete, "
|
| "self-contained HTML file (inline CSS and JS) to disk with a ```file:index.html block "
|
| "— never paste the file in the chat. No external files. Avoid CDNs unless asked."
|
| ),
|
| "Tailwind via Play CDN": (
|
| "Style with Tailwind using the Play CDN (<script src=\"https://cdn.tailwindcss.com\">). "
|
| "Prefer utility classes; keep custom CSS minimal."
|
| ),
|
| "Canvas game loop": (
|
| "For games, use a <canvas> with a requestAnimationFrame loop, keyboard and touch "
|
| "controls, a score, and a restart. Target smooth 60fps."
|
| ),
|
| "Inline data-viz": (
|
| "For charts or dashboards, draw with inline SVG or Canvas (no chart libraries). "
|
| "Label axes, add a legend, and animate transitions."
|
| ),
|
| "Refined dark UI": (
|
| "Default to a refined dark interface: tinted near-black surfaces, one accent color, "
|
| "soft shadows, generous spacing, no pure black or white."
|
| ),
|
| "Mobile-first": (
|
| "Mobile-first and responsive: fluid layout, large tap targets, works from 320px up."
|
| ),
|
| "Accessible by default": (
|
| "Semantic HTML, alt text, ARIA where needed, visible focus rings, sufficient contrast."
|
| ),
|
| "Tasteful micro-interactions": (
|
| "Add restrained micro-interactions: ease-out transitions on hover and state changes, "
|
| "subtle entrance animations. Never animate layout in a way that janks."
|
| ),
|
| }
|
| DEFAULT_SKILLS = ["Single-file HTML artifact", "Tasteful micro-interactions"]
|
|
|
| DEFAULT_SYS = (
|
| "You are SICS-1-35B, a sharp reasoning and building model — the engine behind "
|
| "«Фабрика гипотез». Reason inside <think> ... </think>, then give a focused, "
|
| "complete answer. Prefer runnable, self-contained solutions over fragments."
|
| )
|
|
|
|
|
|
|
|
|
|
|
| EFFORT_HINTS = {
|
| "low": (
|
| "Reasoning effort: low. Think very briefly inside <think>...</think> "
|
| "(a couple of lines at most), then answer directly. Favor speed."
|
| ),
|
| "medium": (
|
| "Reasoning effort: medium. Think inside <think>...</think> with a short, "
|
| "focused chain, then answer."
|
| ),
|
| "high": (
|
| "Reasoning effort: high. Think thoroughly inside <think>...</think> — "
|
| "consider edge cases and alternatives — then give a careful answer."
|
| ),
|
| }
|
| DEFAULT_EFFORT = "low"
|
|
|
|
|
| def _compose_system(base, selected, custom, use_tools, effort=DEFAULT_EFFORT):
|
| base = (base or "").strip()
|
| parts = [base] if base else []
|
| hint = EFFORT_HINTS.get((effort or "").lower())
|
| if hint:
|
| parts.append(hint)
|
| if use_tools:
|
| parts.append(TOOLS_GUIDE)
|
| lines = []
|
| for label in selected or []:
|
| instr = SKILLS.get(label)
|
| if instr:
|
| lines.append(f"- {label}: {instr}")
|
| for raw in (custom or "").splitlines():
|
| raw = raw.strip().lstrip("-").strip()
|
| if raw:
|
| lines.append(f"- {raw}")
|
| if lines:
|
| parts.append("Active skills (apply every one that is relevant):\n" + "\n".join(lines))
|
| return "\n\n".join(parts)
|
|
|
|
|
| def _tools_md(trace):
|
| if not trace:
|
| return _NO_TOOLS
|
| out = []
|
| for i, (call, result) in enumerate(trace, 1):
|
| name = call.get("name")
|
| args = call.get("args", {}) or {}
|
| if name == "write_file":
|
| nbytes = call.get("_bytes", len(str(args.get("content", ""))))
|
| disp = json.dumps({"path": args.get("path", ""), "bytes": nbytes})
|
| else:
|
| disp = json.dumps(args, ensure_ascii=False)
|
| if len(disp) > 280:
|
| disp = disp[:280] + "…"
|
| out.append(f"**{i}. `{name}`** `{disp}`\n\n```\n{result[:1200]}\n```")
|
| return "\n\n".join(out)
|
|
|
|
|
| def _g(o, key, default=None):
|
| """Get a field from a value that may be a dict OR a typed object."""
|
| if isinstance(o, dict):
|
| return o.get(key, default)
|
| return getattr(o, key, default)
|
|
|
|
|
| def _chunk_fields(chunk):
|
| """Extract (content_delta, finish_reason) from a streaming chat chunk.
|
| llama-cpp-python returns either dict chunks or typed objects (Choice) across
|
| builds, so access both ways."""
|
| choices = _g(chunk, "choices") or []
|
| if not choices:
|
| return "", None
|
| ch = choices[0]
|
| finish = _g(ch, "finish_reason")
|
| delta = _g(ch, "delta") or {}
|
| content = _g(delta, "content", "") or ""
|
| return content, finish
|
|
|
|
|
| def _count_tokens(llm, text):
|
| """Best-effort token count for the live ↑/↓ counter."""
|
| try:
|
| return len(llm.tokenize(text.encode("utf-8"), add_bos=False, special=True))
|
| except Exception:
|
| return max(1, len(text) // 4)
|
|
|
|
|
|
|
|
|
|
|
| def _content_text(c):
|
| """Flatten a chat message's content to plain text. On follow-up turns Gradio
|
| returns content as a list of segment dicts ([{'type':'text','text':...}]),
|
| not a string — coerce it so the LLM and tokenizer always get a str."""
|
| if isinstance(c, str):
|
| return c
|
| if isinstance(c, list):
|
| parts = []
|
| for seg in c:
|
| if isinstance(seg, dict):
|
| parts.append(seg.get("text") or "")
|
| elif isinstance(seg, str):
|
| parts.append(seg)
|
| return "\n".join(p for p in parts if p)
|
| return str(c or "")
|
|
|
|
|
| @spaces.GPU(duration=60)
|
| def _agent_stream(message, history, system_prompt, temperature, max_tokens, use_tools):
|
| llm = _get_llm()
|
| msgs = []
|
| if system_prompt and system_prompt.strip():
|
| msgs.append({"role": "system", "content": system_prompt.strip()})
|
| for m in history:
|
| role = m.get("role")
|
| content = _content_text(m.get("content"))
|
| if role in ("user", "assistant") and content.strip():
|
| msgs.append({"role": role, "content": content})
|
| msgs.append({"role": "user", "content": _content_text(message)})
|
|
|
| trace = []
|
| seen = set()
|
| files = {}
|
| last_answer = ""
|
| last_thinking = ""
|
| transcript = ""
|
| in_tok = 0
|
| out_tok = 0
|
|
|
| for step in range(MAX_TOOL_STEPS if use_tools else 1):
|
| out = ""
|
| cont = 0
|
|
|
|
|
|
|
| while True:
|
| if not out:
|
| gen_msgs = msgs
|
| else:
|
| mid_code = bool(_ARTIFACT_START.search(out))
|
| cont_instr = (
|
| "Continue the file EXACTLY where you stopped. Output ONLY the raw "
|
| "remaining file content — no prose, no explanations, no markdown "
|
| "fences, no backticks. Do not repeat anything already written; "
|
| "resume from the next character."
|
| ) if mid_code else (
|
| "Continue EXACTLY where you stopped. Do not repeat or restate any "
|
| "earlier text and do not restart — emit only the next characters."
|
| )
|
| gen_msgs = msgs + [
|
| {"role": "assistant", "content": out},
|
| {"role": "user", "content": cont_instr},
|
| ]
|
| in_tok += _count_tokens(llm, "\n".join(m.get("content", "") for m in gen_msgs))
|
| finish = None
|
| try:
|
| for chunk in llm.create_chat_completion(
|
| messages=gen_msgs,
|
| max_tokens=int(max_tokens),
|
| temperature=float(temperature),
|
| stream=True,
|
| ):
|
| delta, fr = _chunk_fields(chunk)
|
| if fr:
|
| finish = fr
|
| if not delta:
|
| continue
|
| out += delta
|
| out_tok += 1
|
| ans, think = _split(transcript + out)
|
| disp = _strip_artifacts(ans)
|
| doc, name = _current_doc(files, ans)
|
| yield disp or "…", transcript + out, think, _tools_md(trace), doc, name, in_tok, out_tok
|
| except Exception as stream_err:
|
| try:
|
| resp = llm.create_chat_completion(
|
| messages=gen_msgs, max_tokens=int(max_tokens),
|
| temperature=float(temperature), stream=False,
|
| )
|
| ch0 = (_g(resp, "choices") or [None])[0]
|
| msg = _g(ch0, "message") or {}
|
| full = _g(msg, "content", "") or ""
|
| finish = _g(ch0, "finish_reason")
|
| out += full
|
| out_tok += _count_tokens(llm, full)
|
| ans, think = _split(transcript + out)
|
| yield _strip_artifacts(ans) or "…", transcript + out, think, _tools_md(trace), \
|
| *_current_doc(files, ans), in_tok, out_tok
|
| except Exception as gen_err:
|
| err = f"⚠️ generation error: {type(gen_err).__name__}: {gen_err}"
|
| yield err, transcript + "\n" + err, last_thinking, _tools_md(trace), "", "index.html", in_tok, out_tok
|
| return
|
| if finish == "length" and cont < MAX_CONTINUE:
|
| cont += 1
|
| continue
|
| break
|
|
|
| transcript += out + "\n\n"
|
| last_answer, last_thinking = _split(transcript)
|
| msgs.append({"role": "assistant", "content": out})
|
|
|
|
|
| if use_tools:
|
| for path, nbytes in _capture_artifacts(out, files):
|
| trace.append((
|
| {"name": "write_file", "args": {"path": path}, "_bytes": nbytes},
|
| f"wrote {path} ({nbytes} bytes) — rendered in Preview",
|
| ))
|
|
|
| disp_answer = _strip_artifacts(last_answer)
|
| call = _parse_call(out, seen) if use_tools else None
|
| doc, name = _current_doc(files, last_answer)
|
| if not call:
|
| break
|
| if "_h" in call:
|
| seen.add(call["_h"])
|
| yield (disp_answer or "…"), transcript, last_thinking, _tools_md(
|
| trace + [(call, "running…")]
|
| ), doc, name, in_tok, out_tok
|
| result = _exec_tool(call, files)
|
| trace.append((call, result))
|
| msgs.append({"role": "user", "content": f"TOOL RESULT ({call.get('name')}):\n{result}"})
|
| doc, name = _current_doc(files, last_answer)
|
| yield (disp_answer or "…"), transcript, last_thinking, _tools_md(trace), doc, name, in_tok, out_tok
|
|
|
| doc, name = _current_doc(files, last_answer)
|
| yield (_strip_artifacts(last_answer) or "…"), transcript, last_thinking, _tools_md(trace), doc, name, in_tok, out_tok
|
|
|
|
|
| def _gen_status(in_tok, out_tok):
|
| """Live status strip shown while generating: a pulsing wing + token meters."""
|
| return (
|
| "<div class='gen-status'>"
|
| "<span class='gen-wing'>⚗️</span>"
|
| f"<span class='gen-tok'><b class='up'>↑</b> {in_tok:,} in</span>"
|
| f"<span class='gen-tok'><b class='dn'>↓</b> {out_tok:,} out</span>"
|
| "</div>"
|
| )
|
|
|
|
|
| def _chat_message(answer, thinking, tools, streaming=False, in_tok=0, out_tok=0):
|
| """Compose the chat bubble: a live status strip while generating, then
|
| collapsible <details> for reasoning and tools, then the clean answer.
|
| Reasoning auto-opens while thinking (no answer yet)."""
|
| answer = (answer or "").strip()
|
| parts = []
|
| if streaming:
|
| parts.append(_gen_status(in_tok, out_tok))
|
| think = (thinking or "").strip()
|
| if think:
|
| opened = " open" if (streaming and not answer) else ""
|
| parts.append(
|
| f"<details class='turn-fold'{opened}>"
|
| f"<summary>🧠 Reasoning</summary>\n\n{think}\n\n</details>"
|
| )
|
| trace = (tools or "").strip()
|
| if trace and trace != _NO_TOOLS:
|
| n = trace.count("\n\n**") + (1 if trace.startswith("**") else 0)
|
| label = f"🛠 Tools · {n}" if n else "🛠 Tools"
|
| parts.append(
|
| f"<details class='turn-fold'>"
|
| f"<summary>{label}</summary>\n\n{trace}\n\n</details>"
|
| )
|
| if answer:
|
| parts.append(answer)
|
| return "\n\n".join(parts) if parts else "…"
|
|
|
|
|
| def respond(message, history, base_sys, skills, custom_skills, use_tools,
|
| effort, temperature, max_tokens, meta):
|
| meta = meta or []
|
| if not message or not message.strip():
|
| yield history or [], "", "", _NO_REASONING, "", _EMPTY_PREVIEW, _NO_TOOLS, meta
|
| return
|
| system_prompt = _compose_system(base_sys, skills, custom_skills, use_tools, effort)
|
| history = (history or []) + [
|
| {"role": "user", "content": message},
|
| {"role": "assistant", "content": ""},
|
| ]
|
| prior = history[:-2]
|
| answer = raw = thinking = tools = doc = ""
|
| doc_name = "artifact.html"
|
| in_tok = out_tok = 0
|
| try:
|
| for answer, raw, thinking, tools, doc, doc_name, in_tok, out_tok in _agent_stream(
|
| message, prior, system_prompt, temperature, max_tokens, use_tools
|
| ):
|
| history[-1]["content"] = _chat_message(
|
| answer, thinking, tools, streaming=True, in_tok=in_tok, out_tok=out_tok
|
| )
|
| prev = _BUILDING_PREVIEW if doc else _EMPTY_PREVIEW
|
| yield history, "", raw, (thinking or _NO_REASONING), doc, prev, (tools or _NO_TOOLS), meta
|
| except Exception:
|
| import traceback
|
| tb = traceback.format_exc()
|
| history[-1]["content"] = f"⚠️ **generation crashed**\n\n```\n{tb[-1800:]}\n```"
|
| yield history, "", tb, (thinking or _NO_REASONING), doc, _EMPTY_PREVIEW, (tools or _NO_TOOLS), meta
|
| return
|
| history[-1]["content"] = _chat_message(answer, thinking, tools, streaming=False)
|
| meta = meta + [{
|
| "turn_id": uuid.uuid4().hex, "user": message, "answer": answer,
|
| "reasoning": thinking, "code": doc, "tools": tools,
|
| }]
|
| yield history, "", raw, (thinking or _NO_REASONING), doc, _iframe(doc, doc_name), (tools or _NO_TOOLS), meta
|
|
|
|
|
|
|
|
|
|
|
| HERO = """
|
| <div class="hero">
|
| <div class="hero__badge">⚗️</div>
|
| <div class="hero__body">
|
| <div class="hero__eyebrow">Фабрика гипотез · AI co-scientist</div>
|
| <div class="hero__title">SICS-1<span>-35B</span></div>
|
| <div class="hero__sub">Из анализа хвостов флотации — ранжированные, обоснованные и проверяемые гипотезы для извлечения Ni и Cu.</div>
|
| <div class="hero__chips">
|
| <span class="chip">35B MoE · reasoning</span>
|
| <span class="chip">GGUF · Q4_K_M</span>
|
| <span class="chip chip--accent">ZeroGPU · llama.cpp</span>
|
| <span class="chip chip--copper">Ni · Cu · хвосты флотации</span>
|
| </div>
|
| </div>
|
| </div>
|
| """
|
|
|
| CSS = """
|
| :root{
|
| /* deep slate / graphite base — industrial, metallurgy-adjacent */
|
| --bg: oklch(0.17 0.018 248);
|
| --surface: oklch(0.21 0.020 248);
|
| --surface-2: oklch(0.26 0.022 248);
|
| --line: oklch(0.36 0.022 248);
|
| --line-soft: oklch(0.36 0.022 248 / 0.55);
|
| --text: oklch(0.97 0.008 240);
|
| --muted: oklch(0.73 0.020 245);
|
| /* teal (blue→green) primary + copper secondary — Cu/Ni recovery */
|
| --accent: oklch(0.80 0.130 190);
|
| --accent-2: oklch(0.76 0.135 55);
|
| --accent-soft: oklch(0.80 0.130 190 / 0.14);
|
| --accent-ring: oklch(0.80 0.130 190 / 0.32);
|
| --copper-soft: oklch(0.76 0.135 55 / 0.15);
|
| --radius: 14px;
|
| --radius-sm: 10px;
|
| --pill: 999px;
|
| --ease: cubic-bezier(.2,.7,.2,1);
|
| --shadow-1: 0 1px 2px oklch(0.10 0.02 248 / 0.40);
|
| --shadow-2: 0 10px 26px -14px oklch(0.06 0.02 248 / 0.60);
|
| --shadow-pop: 0 18px 40px -24px oklch(0.05 0.02 248 / 0.72);
|
| }
|
| .gradio-container{max-width:1320px !important;width:100% !important;margin:0 auto !important;}
|
| /* force the app to actually fill the width (Gradio sometimes leaves it shrunk-left) */
|
| .gradio-container > .main,
|
| .gradio-container .main > .wrap,
|
| .gradio-container .main .contain,
|
| .gradio-container .main .contain > .row{width:100% !important;}
|
| .gradio-container .main .contain > .row{flex-wrap:nowrap;}
|
| footer{display:none !important;}
|
|
|
| /* crisp, consistent focus + selection + thin scrollbars */
|
| .gradio-container :focus-visible{outline:2px solid var(--accent) !important;outline-offset:2px;border-radius:6px;}
|
| ::selection{background:var(--accent-soft);color:var(--text);}
|
| *{scrollbar-width:thin;scrollbar-color:var(--line) transparent;}
|
| ::-webkit-scrollbar{width:10px;height:10px;}
|
| ::-webkit-scrollbar-thumb{background:var(--line);border-radius:var(--pill);border:2px solid transparent;background-clip:content-box;}
|
| ::-webkit-scrollbar-thumb:hover{background:var(--muted);}
|
| @media (prefers-reduced-motion: reduce){
|
| *,*::before,*::after{animation-duration:.001ms !important;transition-duration:.001ms !important;}
|
| }
|
|
|
| .hero{display:flex;gap:18px;align-items:center;padding:20px 22px;border:1px solid var(--line);
|
| border-radius:18px;position:relative;overflow:hidden;background:
|
| radial-gradient(120% 160% at 0% 0%, var(--accent-soft), transparent 55%),
|
| radial-gradient(120% 160% at 100% 0%, var(--copper-soft), transparent 60%),
|
| var(--surface);box-shadow:var(--shadow-2);}
|
| /* thin teal→copper seam along the top edge */
|
| .hero::before{content:"";position:absolute;inset:0 0 auto 0;height:2px;
|
| background:linear-gradient(90deg, var(--accent), oklch(0.72 0.10 150), var(--accent-2));opacity:.85;}
|
| .hero__badge{display:grid;place-items:center;width:56px;height:56px;flex:none;border-radius:15px;
|
| font-size:28px;background:var(--accent-soft);border:1px solid var(--accent-ring);
|
| box-shadow:0 1px 0 oklch(1 0 0 / 0.06) inset, 0 8px 22px -14px var(--accent-ring);}
|
| .hero__eyebrow{font-size:11.5px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;
|
| color:var(--accent);margin-bottom:5px;}
|
| .hero__title{font-size:27px;font-weight:800;letter-spacing:-0.015em;color:var(--text);line-height:1.05;}
|
| .hero__title span{color:var(--muted);font-weight:600;}
|
| .hero__sub{color:var(--muted);font-size:14px;margin-top:5px;max-width:62ch;line-height:1.5;}
|
| .hero__chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:13px;}
|
| .chip{font-size:12px;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);
|
| padding:4px 11px;border-radius:999px;line-height:1.5;font-variant-numeric:tabular-nums;}
|
| .chip--accent{color:var(--text);border-color:var(--accent-ring);background:var(--accent-soft);}
|
| .chip--copper{color:var(--text);border-color:oklch(0.76 0.135 55 / 0.5);background:var(--copper-soft);}
|
| .chip--link{color:var(--accent);text-decoration:none;}
|
| .chip--link:hover{background:var(--accent-soft);}
|
|
|
| .bchrome{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);
|
| box-shadow:0 18px 40px -24px oklch(0 0 0 / 0.7);}
|
| .bchrome__bar{display:flex;align-items:center;gap:10px;padding:9px 12px;background:var(--surface-2);
|
| border-bottom:1px solid var(--line);}
|
| .bchrome__dots{display:inline-flex;gap:6px;}
|
| .bchrome__dots i{width:11px;height:11px;border-radius:50%;background:var(--line);display:block;}
|
| .bchrome__dots i:nth-child(1){background:oklch(0.66 0.17 25);}
|
| .bchrome__dots i:nth-child(2){background:oklch(0.78 0.15 85);}
|
| .bchrome__dots i:nth-child(3){background:oklch(0.74 0.16 150);}
|
| .bchrome__pill{flex:1;text-align:center;font-size:12px;color:var(--muted);background:var(--bg);
|
| border:1px solid var(--line);border-radius:8px;padding:3px 10px;max-width:340px;margin:0 auto;
|
| font-family:ui-monospace,monospace;}
|
| .bchrome__actions{display:inline-flex;gap:4px;}
|
| .bchrome__actions a{all:unset;cursor:pointer;color:var(--muted);font-size:15px;width:26px;height:26px;
|
| display:grid;place-items:center;border-radius:7px;text-decoration:none;}
|
| .bchrome__actions a:hover{color:var(--text);background:var(--bg);}
|
| .bchrome iframe{display:block;width:100%;height:clamp(440px,62vh,760px);border:0;background:#fff;}
|
|
|
| .pv-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;
|
| gap:8px;min-height:clamp(440px,62vh,760px);border:1px dashed var(--line);border-radius:14px;
|
| background:
|
| radial-gradient(60% 60% at 50% 38%, var(--accent-soft), transparent 70%),
|
| repeating-linear-gradient(45deg, transparent 0 13px, oklch(1 0 0 / 0.012) 13px 14px),
|
| var(--surface);}
|
| .pv-empty__glyph{font-size:40px;opacity:0.9;}
|
| .pv-empty__title{font-size:16px;font-weight:700;color:var(--text);}
|
| .pv-empty__sub{font-size:13px;color:var(--muted);max-width:36ch;}
|
| .pv-building .pv-empty__glyph{animation:floaty 1.6s ease-in-out infinite;}
|
| @keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
|
|
|
| /* ---- pill-group controls: skills (multi) + effort segmented (single) ---- */
|
| .skills .wrap, .seg .wrap{gap:8px !important;}
|
| .skills label, .seg label{border:1px solid var(--line) !important;background:var(--surface-2) !important;
|
| border-radius:var(--pill) !important;padding:7px 14px !important;color:var(--muted) !important;
|
| cursor:pointer;transition:border-color .18s var(--ease),background .18s var(--ease),color .18s var(--ease),transform .12s var(--ease);}
|
| .skills label:hover, .seg label:hover{border-color:var(--accent) !important;color:var(--text) !important;}
|
| .skills label:active, .seg label:active{transform:translateY(1px);}
|
| .skills label:has(input:checked), .seg label:has(input:checked){
|
| border-color:var(--accent) !important;background:var(--accent-soft) !important;color:var(--text) !important;}
|
| .seg input[type="radio"]{position:absolute;opacity:0;width:0;height:0;}
|
|
|
| /* ---- tabs -> a segmented pill control instead of the default underline ---- */
|
| .tab-nav, div.tab-nav{border:none !important;gap:4px;padding:4px;background:var(--surface-2);
|
| border:1px solid var(--line) !important;border-radius:12px;flex-wrap:wrap;}
|
| .tab-nav button{border:none !important;border-radius:9px !important;color:var(--muted) !important;
|
| font-weight:600;font-size:13px;padding:7px 14px !important;transition:color .18s var(--ease),background .18s var(--ease);}
|
| .tab-nav button:hover{color:var(--text) !important;background:var(--bg);}
|
| .tab-nav button.selected{color:var(--text) !important;background:var(--accent-soft) !important;
|
| box-shadow:inset 0 0 0 1px var(--accent);}
|
|
|
| /* ---- examples -> clickable chips instead of a clunky table ---- */
|
| #examples{border:none !important;background:transparent !important;}
|
| #examples .label-wrap, #examples > .label, #examples thead{display:none !important;}
|
| #examples table, #examples tbody, #examples .table-wrap{border:none !important;background:transparent !important;
|
| display:block !important;box-shadow:none !important;}
|
| #examples tbody{display:flex !important;flex-wrap:wrap;gap:8px;}
|
| #examples tr{display:block !important;border:none !important;background:transparent !important;}
|
| #examples td, #examples .gallery-item{border:1px solid var(--line) !important;background:var(--surface-2) !important;
|
| border-radius:var(--pill) !important;padding:7px 14px !important;color:var(--muted) !important;font-size:13px !important;
|
| cursor:pointer;transition:border-color .18s var(--ease),background .18s var(--ease),color .18s var(--ease);
|
| white-space:normal;max-width:260px;line-height:1.35;}
|
| #examples td:hover, #examples .gallery-item:hover{border-color:var(--accent) !important;color:var(--text) !important;
|
| background:var(--accent-soft) !important;}
|
|
|
| /* ---- chat surface + bubbles ---- */
|
| .chatbot, .bubble-wrap{background:transparent !important;border:none !important;}
|
| .message, .message-row .message{border-radius:14px !important;border:1px solid var(--line) !important;
|
| box-shadow:none !important;line-height:1.55;}
|
| .user .message, .user-row .message, .message.user{background:var(--accent-soft) !important;border-color:transparent !important;}
|
| .bot .message, .bot-row .message, .message.bot{background:var(--surface-2) !important;}
|
|
|
| /* ---- live generation status: pulsing wing + token meters ---- */
|
| .gen-status{display:inline-flex;align-items:center;gap:14px;padding:6px 14px;margin:0 0 10px;
|
| border:1px solid var(--line);border-radius:var(--pill);background:var(--surface-2);
|
| font-size:12.5px;color:var(--muted);font-variant-numeric:tabular-nums;}
|
| .gen-wing{display:inline-block;font-size:16px;animation:wingpulse 1.15s var(--ease) infinite;
|
| filter:drop-shadow(0 0 7px var(--accent-ring));}
|
| @keyframes wingpulse{
|
| 0%,100%{opacity:1;transform:scale(1) translateY(0);}
|
| 50%{opacity:.45;transform:scale(1.14) translateY(-1px);}
|
| }
|
| .gen-tok{display:inline-flex;align-items:center;gap:5px;}
|
| .gen-tok b{font-style:normal;font-weight:800;font-size:13px;}
|
| .gen-tok .up{color:var(--accent-2);}
|
| .gen-tok .dn{color:var(--accent);}
|
|
|
| /* ---- collapsible reasoning / tools inside a chat bubble ---- */
|
| .turn-fold{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--bg);
|
| margin:0 0 8px;overflow:hidden;}
|
| .turn-fold > summary{cursor:pointer;list-style:none;padding:8px 12px;font-size:12.5px;font-weight:600;
|
| color:var(--muted);display:flex;align-items:center;gap:6px;user-select:none;
|
| transition:color .15s var(--ease),background .15s var(--ease);}
|
| .turn-fold > summary::-webkit-details-marker{display:none;}
|
| .turn-fold > summary::after{content:"⌄";margin-left:auto;transition:transform .2s var(--ease);font-size:14px;}
|
| .turn-fold[open] > summary{color:var(--text);border-bottom:1px solid var(--line);background:var(--surface-2);}
|
| .turn-fold[open] > summary::after{transform:rotate(180deg);}
|
| .turn-fold > summary:hover{color:var(--text);}
|
| .turn-fold > *:not(summary){padding:4px 12px 10px;font-size:13px;}
|
| .turn-fold pre{background:var(--bg) !important;border:1px solid var(--line);border-radius:8px;}
|
|
|
| /* ---- command bar (the main input) ---- */
|
| #cmdbar textarea, #cmdbar input{font-size:15px !important;padding:13px 16px !important;border-radius:var(--radius) !important;
|
| background:var(--surface) !important;border:1px solid var(--line) !important;color:var(--text) !important;
|
| transition:border-color .18s var(--ease),box-shadow .18s var(--ease);}
|
| #cmdbar textarea:focus, #cmdbar input:focus{border-color:var(--accent) !important;
|
| box-shadow:0 0 0 3px var(--accent-soft) !important;outline:none !important;}
|
|
|
| /* ---- buttons: soft depth + hover lift ---- */
|
| .gradio-container button.primary{box-shadow:var(--shadow-1);font-weight:650;
|
| transition:transform .12s var(--ease),box-shadow .18s var(--ease),filter .18s var(--ease);
|
| background-image:linear-gradient(180deg, oklch(1 0 0 / 0.06), transparent) !important;}
|
| .gradio-container button.primary:hover{transform:translateY(-1px);box-shadow:var(--shadow-2);filter:brightness(1.05);}
|
| .gradio-container button.primary:active{transform:translateY(0);box-shadow:var(--shadow-1);}
|
| .gradio-container button.secondary{background:var(--surface-2) !important;border:1px solid var(--line) !important;
|
| color:var(--muted) !important;transition:border-color .18s var(--ease),color .18s var(--ease),transform .12s var(--ease);}
|
| .gradio-container button.secondary:hover{border-color:var(--accent) !important;color:var(--text) !important;}
|
| .gradio-container button.secondary:active{transform:translateY(1px);}
|
|
|
| /* ---- accordions + blocks: quieter borders, rounder ---- */
|
| .gradio-container .block, .gradio-container .form{border-radius:var(--radius) !important;}
|
| .gradio-container .label-wrap > span, .gradio-container span[data-testid="block-info"]{color:var(--muted) !important;}
|
| .accordion, .gradio-accordion{border:1px solid var(--line) !important;border-radius:var(--radius) !important;
|
| background:var(--surface) !important;}
|
|
|
| /* ---- sliders ---- */
|
| input[type="range"]{accent-color:var(--accent);}
|
|
|
| /* ---- subtle entrance for the whole app ---- */
|
| .gradio-container > .main, .gradio-container .contain{animation:appfade .4s var(--ease) both;}
|
| @keyframes appfade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
| """
|
|
|
| THEME = gr.themes.Base(
|
| primary_hue=gr.themes.colors.teal,
|
| secondary_hue=gr.themes.colors.amber,
|
| neutral_hue=gr.themes.colors.slate,
|
|
|
| font=["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
|
| font_mono=["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
| ).set(
|
| body_background_fill="oklch(0.17 0.018 248)",
|
| body_background_fill_dark="oklch(0.17 0.018 248)",
|
| background_fill_primary="oklch(0.21 0.020 248)",
|
| background_fill_primary_dark="oklch(0.21 0.020 248)",
|
| background_fill_secondary="oklch(0.26 0.022 248)",
|
| block_background_fill="oklch(0.21 0.020 248)",
|
| block_border_color="oklch(0.36 0.022 248)",
|
| border_color_primary="oklch(0.36 0.022 248)",
|
| body_text_color="oklch(0.97 0.008 240)",
|
| body_text_color_subdued="oklch(0.73 0.020 245)",
|
| block_label_text_color="oklch(0.73 0.020 245)",
|
| button_primary_background_fill="oklch(0.80 0.130 190)",
|
| button_primary_background_fill_hover="oklch(0.84 0.130 190)",
|
| button_primary_text_color="oklch(0.18 0.020 248)",
|
| input_background_fill="oklch(0.20 0.020 248)",
|
| block_radius="14px",
|
| )
|
|
|
|
|
| EXAMPLES = [
|
| "Сформулируй 3 проверяемые гипотезы, как поднять извлечение меди из хвостов флотации.",
|
| "Построй интерактивный дашборд извлечения Ni и Cu из хвостов флотации.",
|
| "Визуализируй кривую кинетики пенной флотации на canvas.",
|
| "Сделай калькулятор извлечения металла: содержания в питании и хвостах → % извлечения.",
|
| "Тепловая карта содержания Ni по пробам — нарисуй инлайн SVG с легендой.",
|
| "Объясни механизм пенной флотации простыми словами, как хорошему другу.",
|
| ]
|
|
|
| with gr.Blocks(title="SICS-1-35B · Фабрика гипотез", fill_height=True, fill_width=True) as demo:
|
| gr.HTML(HERO)
|
| meta_state = gr.State([])
|
| with gr.Row(equal_height=False):
|
| with gr.Column(scale=5):
|
| chatbot = gr.Chatbot(
|
| height=460, show_label=False, sanitize_html=False,
|
| placeholder="Спросите SICS-1 — гипотеза, расчёт, артефакт или поиск источников.",
|
| )
|
| msg = gr.Textbox(
|
| placeholder='напр. «построй дашборд извлечения Ni/Cu из хвостов» · Enter — отправить',
|
| show_label=False, autofocus=True, lines=1, elem_id="cmdbar",
|
| )
|
| with gr.Row():
|
| send = gr.Button("Отправить", variant="primary", scale=3)
|
| clear = gr.Button("Очистить", scale=1)
|
| gr.Examples(examples=EXAMPLES, inputs=msg, label="Примеры", elem_id="examples")
|
| with gr.Column(scale=6):
|
| with gr.Tabs():
|
| with gr.Tab("Превью"):
|
| preview = gr.HTML(_EMPTY_PREVIEW, **_HTML_RAW)
|
| with gr.Tab("Инструменты"):
|
| tools_box = gr.Markdown(_NO_TOOLS)
|
| with gr.Tab("Код"):
|
| code_box = gr.Code(label=None, language="html")
|
| with gr.Tab("Рассуждение"):
|
| think_box = gr.Markdown(_NO_REASONING)
|
| with gr.Tab("Raw"):
|
| raw_box = gr.Textbox(show_label=False, lines=18, max_lines=18)
|
| with gr.Accordion("Навыки и инструменты", open=True):
|
| use_tools = gr.Checkbox(
|
| value=True,
|
| label="Инструменты · web · fetch · python",
|
| )
|
| effort = gr.Radio(
|
| choices=["low", "medium", "high"], value=DEFAULT_EFFORT,
|
| label="Глубина рассуждения",
|
| info="low — короче и быстрее · high — глубже и медленнее",
|
| elem_classes=["seg"],
|
| )
|
| skills = gr.CheckboxGroup(
|
| choices=list(SKILLS.keys()), value=DEFAULT_SKILLS,
|
| show_label=False, elem_classes=["skills"],
|
| )
|
| custom_skills = gr.Textbox(
|
| label="Свои навыки (по одному в строке)",
|
| placeholder="Всегда добавляй горячие клавиши\nПиши лаконичный, комментированный код",
|
| lines=2,
|
| )
|
| with gr.Accordion("Настройки", open=False):
|
| system_prompt = gr.Textbox(value=DEFAULT_SYS, label="Базовый системный промпт", lines=3)
|
| temperature = gr.Slider(0.0, 1.5, value=0.6, step=0.05, label="Температура")
|
| max_tokens = gr.Slider(256, 8192, value=6144, step=128, label="Макс. токенов / шаг")
|
|
|
| inputs = [msg, chatbot, system_prompt, skills, custom_skills, use_tools,
|
| effort, temperature, max_tokens, meta_state]
|
| outputs = [chatbot, msg, raw_box, think_box, code_box, preview, tools_box, meta_state]
|
| send.click(respond, inputs, outputs)
|
| msg.submit(respond, inputs, outputs)
|
| clear.click(
|
| lambda: ([], "", "", _NO_REASONING, "", _EMPTY_PREVIEW, _NO_TOOLS, []), None, outputs
|
| )
|
|
|
| demo.queue().launch(theme=THEME, css=CSS, ssr_mode=False, show_error=True)
|
|
|