"""LayaChess on a Hugging Face Space (Gradio + ZeroGPU). The page is the local board (laya_chess/web/index.html) inside an iframe. Its API calls go to Python through a hidden Gradio textbox, one game per visitor. Only Laya's search runs on the GPU; ZeroGPU runs it in a separate process, so it gets the game as a list of moves and returns plain data. """ import html import json import os import shutil import threading import time import types import spaces # before torch, as ZeroGPU requires import chess import chess.engine import gradio as gr from laya_chess import play from laya_chess.model import DEFAULT_CHECKPOINT, LayaChessModel from laya_chess.search import MCTS MAX_THINK = float(os.environ.get("MAX_THINK", 30)) ON_SPACE = bool(os.environ.get("SPACE_ID")) MODEL = LayaChessModel(os.environ.get("CHECKPOINT", DEFAULT_CHECKPOINT), device="cuda" if ON_SPACE else "auto") SF_PATH = shutil.which("stockfish") or shutil.which("stockfish", path="/usr/games") SF = chess.engine.SimpleEngine.popen_uci(SF_PATH) if SF_PATH else None SF_LOCK = threading.Lock() if SF: SF.configure({"Threads": 1, "Hash": 64}) def gpu_duration(moves, seconds): # reserve only what the chosen thinking time needs: shorter reservations get better queue priority return int(seconds) + 20 @spaces.GPU(duration=gpu_duration) def gpu_search(moves, seconds): board = chess.Board() for m in moves: board.push_uci(m) r = MCTS(MODEL, batch_leaves=8).search(board, nodes=0 if seconds <= 0 else None, seconds=seconds if seconds > 0 else None) return {"move": r.move.uci(), "value": r.value, "nodes": r.nodes, "seconds": r.seconds, "pv": [m.uci() for m in r.pv], "scores": [(m.uci(), q, n) for m, q, n in r.scores]} class RemoteSearch: """Stands in for the game's MCTS: each search is one GPU call from the move list.""" def search(self, board, nodes=None, seconds=None): try: d = gpu_search([m.uci() for m in board.move_stack], 0.0 if nodes == 0 else float(seconds or 0)) except Exception as e: if "quota" in str(e).lower(): raise ValueError("You've used up today's free GPU time on Hugging Face (2 minutes a day logged out, " "5 minutes logged in), so Laya can't think right now. Log in to Hugging Face, or " "try again tomorrow.") from None raise mv = chess.Move.from_uci return types.SimpleNamespace(move=mv(d["move"]), value=d["value"], nodes=d["nodes"], seconds=d["seconds"], pv=[mv(m) for m in d["pv"]], scores=[(mv(m), q, n) for m, q, n in d["scores"]]) class SpaceGame(play.Game): def __init__(self): super().__init__(MODEL) self.mcts, self.sf, self.sf_limit = RemoteSearch(), SF, chess.engine.Limit(time=0.3) def new(self, color, think, use_book=True): super().new(color, think, use_book) self.mcts = RemoteSearch() @property def think(self): return self._think @think.setter def think(self, seconds): self._think = max(0.0, min(float(seconds), MAX_THINK)) def stockfish_view(self, board): with SF_LOCK: return super().stockfish_view(board) def state(self): s = super().state() s["model"]["device"] = "ZeroGPU" if ON_SPACE else s["model"]["device"] return s GAMES, GAMES_LOCK = {}, threading.Lock() def game_for(sid): now = time.time() with GAMES_LOCK: for k in [k for k, (_, seen) in GAMES.items() if now - seen > 3600]: del GAMES[k] g = GAMES[sid][0] if sid in GAMES else SpaceGame() GAMES[sid] = (g, now) return g def handle(req, request: gr.Request): """One board API call: {"id", "path", "body"} -> {"id", "data"} or {"id", "error"}.""" msg = json.loads(req or "{}") rid, path, body = msg.get("id"), msg.get("path", ""), msg.get("body") or {} g = game_for(request.session_hash or "local") try: with g.lock: if path == "/api/new": g.new(body.get("color", "white"), body.get("think", 0), body.get("book", True)) elif path == "/api/move": g.human_move(body["uci"]) elif path == "/api/engine": g.engine_move() elif path == "/api/takeover": g.takeover() elif path == "/api/hint": g.hint() elif path == "/api/undo": g.undo() elif path == "/api/settings": g.think = float(body.get("think", g.think)) elif path != "/api/state": raise ValueError("unknown call " + path) return json.dumps({"id": rid, "data": g.state()}) except Exception as e: # shown to the player as an alert, the board stays usable return json.dumps({"id": rid, "error": str(e) or e.__class__.__name__}) # the board page, with fetch() replaced by messages to this Gradio page BRIDGE = """""" def board_page(): page = (play.WEB / "index.html").read_text() start = page.index("async function api(") end = page.index("\n}\n", start) + 3 page = page[:start] + page[end:] # the bridge above defines api() page = page.replace("", BRIDGE + "", 1) return page.replace('', '') PARENT_JS = """""" CSS = ".laya-hidden { display: none !important; } #laya-frame { width: 100%; height: 900px; border: 0; }" INTRO = """**LayaChess**: [Laya](https://huggingface.co/convaiinnovations/laya), a 421M System 1 decision model, fine-tuned on 2M Stockfish-rated moves and wrapped in a tree search. Laya thinks on a shared free GPU, so the first move can take a while, and you may wait in a queue. Hugging Face gives each visitor 2 minutes of GPU time a day (5 if you're logged in), and Laya spends its thinking time from that: about 4 moves at 30 s, 12 at 10 s, many more at 3 s or instantly. [How it works](https://devroopsaha744.github.io/portfolio/blog/laya-chess/) · [Code](https://github.com/devroopsaha744/LayaChess) · [Video](https://www.youtube.com/watch?v=bPpAlWArs7E)""" with gr.Blocks(title="LayaChess") as demo: gr.Markdown(INTRO) gr.HTML(f'') req = gr.Textbox(elem_id="laya-req", elem_classes="laya-hidden", show_label=False) resp = gr.Textbox(elem_classes="laya-hidden", show_label=False) go = gr.Button(elem_id="laya-go", elem_classes="laya-hidden") go.click(handle, req, resp, concurrency_limit=16, show_progress="hidden") resp.change(None, resp, None, js="(v) => { window.layaReply(v); }") if __name__ == "__main__": demo.launch(head=PARENT_JS, css=CSS)