"""
ChessQween — Play against zhoudoe23/CheesQween
Hugging Face Space | Gradio app
"""
import os
os.environ["CUDA_VISIBLE_DEVICES"] = ""
import re
import random
import chess
import chess.svg
import chess.pgn
import gradio as gr
import torch
import spaces
torch.cuda.is_available = lambda: False
from transformers import GPT2LMHeadModel, GPT2Tokenizer
import outlines
from typing import Literal
from transformers import AutoTokenizer, AutoModelForCausalLM
# ──────────────────────────────────────────────────────────────────────────────
# Available models
# ──────────────────────────────────────────────────────────────────────────────
AVAILABLE_MODELS = {
"ChessQween": "zhoudoe23/ChessQween-124m",
"ChessQween1.5-nano": "zhoudoe23/ChessQween1.5-nano",
"ChessQween2-tiny": "zhoudoe23/ChessQween2-tiny",
"ChessQween3-base": "zhoudoe23/ChessQween3-base",
}
MODEL_DESCRIPTIONS = {
"ChessQween": "Trained on 200,000 best games on Lichess, at least 1900 elo",
"ChessQween1.5-nano": "A smaller version, but trained on 400,000 master games, at least 2300 elo",
"ChessQween2-tiny": "New model type with new tokenizer",
"ChessQween3-base": "Trained with Stockfish's best moves, using FEN board",
}
MODEL_TYPE_MAP = {
"ChessQween": 1,
"ChessQween1.5-nano": 1,
"ChessQween2-tiny": 2,
"ChessQween3-base": 3,
}
device = torch.device("cpu")
# Lazy cache: {model_id: (tokenizer, model)}
_model_cache: dict = {}
@spaces.GPU(duration=30)
def foo(bar):
return bar
current_model_type = 1
def load_model(model_key: str):
"""Load (or retrieve from cache) tokenizer + model for the given key."""
model_id = AVAILABLE_MODELS[model_key]
if model_id not in _model_cache:
print(f"Loading {model_id} …")
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token
raw_model = AutoModelForCausalLM.from_pretrained(model_id)
raw_model.to(device)
raw_model.eval()
raw_model.config.use_cache = True
# ─────────── 新增:用 Outlines 包装模型 ───────────
# outlines 会封装原始模型和 tokenizer,接管 token 级别的掩码生成
# outlines_model = outlines.from_transformers(raw_model, tokenizer)
# 将 outlines_model 存入缓存(tokenizer 依然保留,方便后续拼接 prompt 选用)
# _model_cache[model_id] = (tokenizer, raw_model, outlines_model)
_model_cache[model_id] = (tokenizer, raw_model)
print(f"✓ {model_id} ready on {device}")
return _model_cache[model_id]
# ──────────────────────────────────────────────────────────────────────────────
# Chess / model logic
# ──────────────────────────────────────────────────────────────────────────────
def get_history_uci(board: chess.Board) -> str:
rounds = []
current_round = []
for move in board.move_stack:
current_round.append(move.uci())
if len(current_round) == 2:
rounds.append(" ".join(current_round))
current_round = []
if current_round: # 当前轮只有白方走了一步
rounds.append(" ".join(current_round))
if not rounds:
return ""
history_str = " | ".join(rounds)
# 关键逻辑:如果是偶数步(轮到白方走棋),且不是开局,末尾必须补上 " |"
if len(board.move_stack) % 2 == 0 and len(board.move_stack) > 0:
history_str += " |"
return " " + history_str
def board_to_prompt(board: chess.Board, model_type) -> str:
if model_type == 1:
game = chess.pgn.Game()
node = game
for move in board.move_stack:
node = node.add_variation(move)
exporter = chess.pgn.StringExporter(headers=False, variations=False, comments=False)
pgn = game.accept(exporter).strip()
pgn = re.sub(r"\s*[\*\d][-\d/]*\s*$", "", pgn).strip().replace("\n"," ")
full_move = board.fullmove_number
# 修复后的逻辑:
if board.turn == chess.WHITE:
# 白方回合:拼接回合数和点,例如 " 2."
prompt = f"Result: 1-0 | {pgn} {full_move}."
else:
# 黑方回合:如果你的训练集黑方前面只有空格,直接在白方走法后加空格!
# 绝不能加孤零零的数字 {full_move}
prompt = f"Result: 0-1 | {pgn}"
return prompt
elif model_type == 2:
if board.turn == chess.WHITE:
return f"<|WHITE|>{get_history_uci(board)}"
else:
return f"<|BLACK|>{get_history_uci(board)}"
elif model_type == 3:
fen_list = board.fen().split()
board_content = " ".join(list(fen_list[0]))
side = "<|WHITE|>" if fen_list[1] == 'w' else "<|BLACK|>"
castling = fen_list[2]
en_passant = fen_list[3]
return f"{board_content} {side} {castling} {en_passant} 0 1 >"
def extract_move(text: str, board: chess.Board):
text = re.sub(r"^\s*\d+\.+\s*", "", text).strip()
for token in text.split()[:5]:
clean = re.sub(r"[!?+#,;]+$", "", token)
try:
move = board.parse_san(clean)
if move in board.legal_moves:
return move
except Exception:
pass
try:
move = chess.Move.from_uci(clean.lower())
if move in board.legal_moves:
return move
except Exception:
pass
return None
@torch.no_grad()
def get_model_move(board: chess.Board, model_key: str):
tokenizer, model = load_model(model_key)
model_type = MODEL_TYPE_MAP[model_key]
prompt = board_to_prompt(board, model_type)
print("Current prompt: "+prompt)
inputs = tokenizer(prompt, return_tensors="pt").to(device)
outputs = model.generate(
inputs.input_ids,
max_new_tokens=12,
do_sample=False,
# do_sample=True,
# temperature=0.3,
# top_k=40,
# top_p=0.9,
repetition_penalty=1.1,
pad_token_id=tokenizer.eos_token_id,
eos_token_id=tokenizer.eos_token_id,
)
new_tokens = outputs[0][inputs.input_ids.shape[1]:]
generated = tokenizer.decode(new_tokens, skip_special_tokens=True)
move = extract_move(generated, board)
if move:
return move, True
print(f"Wrong move: {generated}")
return random.choice(list(board.legal_moves)), False
# @torch.no_grad()
# def get_model_move(board: chess.Board, model_key: str):
# # 👈 取出 raw_model 和 outlines_model
# tokenizer, raw_model, outlines_model = load_model(model_key)
# model_type = MODEL_TYPE_MAP[model_key]
# prompt = board_to_prompt(board, model_type)
# print("Current prompt:", prompt)
# # ------------------ 旧模型 (Model Type 1) ------------------
# if model_type == 1:
# legal_sans = [board.san(m) for m in board.legal_moves]
# choices = [" " + san for san in legal_sans]
# MoveType = Literal.__getitem__(tuple(choices))
# # 使用 outlines_model
# generator = outlines.Generator(outlines_model, MoveType)
# generated = generator(
# prompt, temperature=0.3, top_k=40, top_p=0.9
# ).strip()
# move = extract_move(generated, board)
# if move:
# return move, True
# # ------------------ 新模型 (Model Type 2) ------------------
# elif model_type == 2:
# legal_moves = list(board.legal_moves)
# legal_ucis = [m.uci() for m in legal_moves]
# # 1. 编码 Prompt
# input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
# # 👈 核心修改:使用原生 raw_model 计算 Tensor 输入
# outputs = raw_model(input_ids)
# next_token_logits = outputs.logits[0, -1, :].clone()
# # 2. 🚀 重复走法惩罚 (防死循环摆烂)
# for move in legal_moves:
# uci_str = move.uci()
# if uci_str in tokenizer.vocab:
# token_id = tokenizer.vocab[uci_str]
# board.push(move)
# return random.choice(list(board.legal_moves)), False
# ──────────────────────────────────────────────────────────────────────────────
# Board rendering
# ──────────────────────────────────────────────────────────────────────────────
PIECE_COLORS = {
"square light": "#f0d9b5",
"square dark": "#b58863",
"square light lastmove": "#cdd16e",
"square dark lastmove": "#aaa23a",
}
def render_board_html(board: chess.Board, last_move=None, flipped=False, size=480):
check_square = board.king(board.turn) if board.is_check() else None
svg = chess.svg.board(
board,
lastmove=last_move,
check=check_square,
flipped=flipped,
size=size,
colors=PIECE_COLORS,
)
return f"""
"""
def get_legal_moves_san(board: chess.Board):
moves = []
for move in board.legal_moves:
try:
moves.append(board.san(move))
except Exception:
pass
return sorted(moves)
def format_move_history(board: chess.Board):
if not board.move_stack:
return "No moves yet."
temp = chess.Board()
lines = []
moves = list(board.move_stack)
i = 0
while i < len(moves):
move_num = temp.fullmove_number
white_san = temp.san(moves[i])
temp.push(moves[i])
i += 1
if i < len(moves):
black_san = temp.san(moves[i])
temp.push(moves[i])
i += 1
lines.append(
f"{move_num}. "
f"{white_san} "
f"{black_san}"
)
else:
lines.append(
f"{move_num}. "
f"{white_san}"
)
visible = lines[-10:]
html = ""
html += "
".join(visible)
html += "
"
return html
def game_status(board: chess.Board, player_color: str):
empty_board = chess.Board()
print(f"Current PGN: \n{empty_board.variation_san(board.move_stack)}")
if board.is_checkmate():
winner = "Black" if board.turn == chess.WHITE else "White"
if (winner == "White") == (player_color == "white"):
return "♟ Checkmate — You win! 🎉", "win"
else:
return "♟ Checkmate — AI wins!", "loss"
if board.is_stalemate():
return "½ Stalemate — Draw", "draw"
if board.is_insufficient_material():
return "½ Insufficient material — Draw", "draw"
if board.is_seventyfive_moves():
return "½ 75-move rule — Draw", "draw"
if board.is_fivefold_repetition():
return "½ Fivefold repetition — Draw", "draw"
if board.is_check():
return "⚠ Check!", "check"
whose = "Your turn" if (board.turn == chess.WHITE) == (player_color == "white") else "AI is thinking…"
return whose, "playing"
# ──────────────────────────────────────────────────────────────────────────────
# Gradio callbacks
# ──────────────────────────────────────────────────────────────────────────────
def update_model_description(model_key: str):
desc = MODEL_DESCRIPTIONS.get(model_key, "")
hf_id = AVAILABLE_MODELS.get(model_key, "")
return (
f""
)
def new_game(player_color_choice: str, model_key: str):
"""Reset the board and, if player chose Black, let the model move first."""
board = chess.Board()
player_color = "white" if player_color_choice == "⬜ White (move first)" else "black"
flipped = (player_color == "black")
last_move = None
log_lines = []
# If player chose Black, model plays White first
if player_color == "black":
move, legal = get_model_move(board, model_key)
san = board.san(move)
board.push(move)
last_move = move
log_lines.append(f"{model_key} opens with **{san}**")
legal_moves = get_legal_moves_san(board)
status_text, _ = game_status(board, player_color)
board_html = render_board_html(board, last_move=last_move, flipped=flipped)
history_html = format_move_history(board)
log_html = "
".join(log_lines) if log_lines else "Game started."
state = {
"fen": board.fen(),
"move_stack": [m.uci() for m in board.move_stack],
"player_color": player_color,
"last_move_uci": last_move.uci() if last_move else None,
"game_over": False,
"model_key": model_key,
}
return (
board_html,
gr.Dropdown(choices=legal_moves, value=None, interactive=True, label="Your move"),
status_text,
history_html,
log_html,
state,
)
def make_player_move(move_san: str, state: dict):
"""Apply the player's chosen move, then let the model respond."""
if not state or state.get("game_over"):
return (
gr.update(), gr.update(), "Game is over. Start a new game.",
gr.update(), gr.update(), state,
)
if not move_san:
return (
gr.update(), gr.update(), "Please select a move first.",
gr.update(), gr.update(), state,
)
board = chess.Board()
for uci in state["move_stack"]:
board.push(chess.Move.from_uci(uci))
player_color = state["player_color"]
model_key = state.get("model_key", "ChessSLM")
flipped = (player_color == "black")
log_lines = []
try:
player_move = board.parse_san(move_san)
except Exception:
return (
gr.update(), gr.update(), f"Invalid move: {move_san}",
gr.update(), gr.update(), state,
)
board.push(player_move)
log_lines.append(f"You played **{move_san}**")
last_move = player_move
status_text, status_key = game_status(board, player_color)
game_over = status_key in ("win", "loss", "draw")
if not game_over:
model_move_obj, legal = get_model_move(board, model_key)
model_san = board.san(model_move_obj)
board.push(model_move_obj)
last_move = model_move_obj
flag = "" if legal else " *(random fallback)*"
log_lines.append(f"{model_key} plays **{model_san}**{flag}")
status_text, status_key = game_status(board, player_color)
game_over = status_key in ("win", "loss", "draw")
state = {
"fen": board.fen(),
"move_stack": [m.uci() for m in board.move_stack],
"player_color": player_color,
"last_move_uci": last_move.uci() if last_move else None,
"game_over": game_over,
"model_key": model_key,
}
legal_moves = [] if game_over else get_legal_moves_san(board)
board_html = render_board_html(board, last_move=last_move, flipped=flipped)
history_html = format_move_history(board)
log_html = "
".join(
[f"{l}" for l in log_lines]
)
return (
board_html,
gr.Dropdown(choices=legal_moves, value=None, interactive=not game_over, label="Your move"),
status_text,
history_html,
log_html,
state,
)
# ──────────────────────────────────────────────────────────────────────────────
# CSS
# ──────────────────────────────────────────────────────────────────────────────
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
body, .gradio-container {
background: #0d0d0d !important;
color: #e8d5a3 !important;
}
.gradio-container {
max-width: 1100px !important;
margin: 0 auto !important;
font-family: 'Crimson Text', Georgia, serif !important;
}
h1, h2, h3 { font-family: 'Cinzel', serif !important; letter-spacing: 0.08em; }
#title-block {
text-align: center;
padding: 2rem 0 1rem;
border-bottom: 1px solid #3d2b0e;
margin-bottom: 1.5rem;
}
/* ── Model selector card ── */
#model-selector-card {
background: linear-gradient(135deg, #161005 0%, #1e1810 100%);
border: 1px solid #5a4020;
border-radius: 8px;
padding: 1rem 1.2rem;
margin-bottom: 0.6rem;
box-shadow: inset 0 1px 0 rgba(212,168,67,0.08);
}
#model-desc {
margin-top: 0.4rem;
font-family: 'Crimson Text', serif;
font-size: 0.92em;
line-height: 1.5;
color: #8a7a5a;
}
#status-bar {
text-align: center;
font-family: 'Cinzel', serif;
font-size: 1.1em;
letter-spacing: 0.05em;
padding: 0.6rem 1rem;
border-radius: 6px;
background: #1a1208;
border: 1px solid #4a3520;
color: #f0c060;
}
button.primary {
background: linear-gradient(135deg, #8b6914 0%, #c4922a 50%, #8b6914 100%) !important;
border: 1px solid #d4a843 !important;
color: #fff8e8 !important;
font-family: 'Cinzel', serif !important;
letter-spacing: 0.06em !important;
font-size: 0.9em !important;
border-radius: 4px !important;
transition: all 0.2s ease !important;
}
button.primary:hover {
background: linear-gradient(135deg, #a07820 0%, #d4a843 50%, #a07820 100%) !important;
box-shadow: 0 0 16px rgba(212,168,67,0.4) !important;
}
button.secondary {
background: #1e1810 !important;
border: 1px solid #5a4020 !important;
color: #c8a96e !important;
font-family: 'Cinzel', serif !important;
letter-spacing: 0.04em !important;
border-radius: 4px !important;
}
select, .gr-dropdown select {
background: #1a1208 !important;
border: 1px solid #5a4020 !important;
color: #e8d5a3 !important;
font-family: 'Crimson Text', serif !important;
font-size: 1em !important;
}
#move-log {
background: #0f0c06 !important;
border: 1px solid #3d2b0e !important;
border-radius: 6px;
padding: 0.8rem 1rem;
font-family: 'Crimson Text', serif;
font-size: 0.95em;
line-height: 1.8;
min-height: 80px;
color: #c8a96e;
}
#history-panel {
background: #0f0c06 !important;
border: 1px solid #3d2b0e !important;
border-radius: 6px;
padding: 0.8rem 1rem;
min-height: 200px;
max-height: 320px;
overflow-y: auto;
}
.gr-radio label { color: #e8d5a3 !important; font-family: 'Crimson Text', serif !important; }
label span {
color: #a08050 !important;
font-family: 'Cinzel', serif !important;
font-size: 0.8em !important;
letter-spacing: 0.06em !important;
text-transform: uppercase !important;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0d0d0d; }
::-webkit-scrollbar-thumb { background: #5a4020; border-radius: 3px; }
"""
# ──────────────────────────────────────────────────────────────────────────────
# Layout
# ──────────────────────────────────────────────────────────────────────────────
SECTION = ""
LABEL = lambda t: f"{t}
"
with gr.Blocks(css=CSS, title="ChessQween — Play vs AI") as demo:
state = gr.State({})
# ── Header ────────────────────────────────────────────────────────────────
gr.HTML("""
♛ ChessQween
Play against a GPT-2 model trained on 200,000 chess games
""")
# ── Main layout ───────────────────────────────────────────────────────────
with gr.Row():
# Left: board
with gr.Column(scale=3):
board_display = gr.HTML(
value=render_board_html(chess.Board()),
label="Board",
)
status_display = gr.HTML(
value="Choose your colour and press New Game
"
)
# Right: controls
with gr.Column(scale=2):
# ── Opponent selector ──────────────────────────────────────────
gr.HTML(LABEL("CHOOSE OPPONENT"))
gr.HTML("")
model_dropdown = gr.Dropdown(
choices=list(AVAILABLE_MODELS.keys()),
value="ChessQween",
label="Opponent model",
interactive=True,
)
model_desc_display = gr.HTML(
value=update_model_description("ChessQween"),
)
gr.HTML("
") # close card
gr.HTML(SECTION)
# ── New game ───────────────────────────────────────────────────
gr.HTML(LABEL("NEW GAME"))
color_choice = gr.Radio(
choices=["⬜ White (move first)", "⬛ Black (move second)"],
value="⬜ White (move first)",
label="Play as",
)
new_game_btn = gr.Button("♟ New Game", variant="primary", size="lg")
gr.HTML(SECTION)
# ── Your move ─────────────────────────────────────────────────
gr.HTML(LABEL("YOUR MOVE"))
move_dropdown = gr.Dropdown(
choices=[],
value=None,
label="Select move (SAN notation)",
interactive=False,
)
move_btn = gr.Button("▶ Make Move", variant="secondary")
gr.HTML(SECTION)
# ── Move log ──────────────────────────────────────────────────
gr.HTML(LABEL("MOVE LOG"))
log_display = gr.HTML(
value="Start a new game to begin.
",
)
gr.HTML(SECTION)
# ── Game history ──────────────────────────────────────────────
gr.HTML(LABEL("GAME HISTORY"))
history_display = gr.HTML(
value="No moves yet.
",
)
# ── Footer ────────────────────────────────────────────────────────────────
gr.HTML("""
Model by
FlameF0X
· GPT-2 pre-trained on PGN games
· Move selection uses top-k sampling (temp=0.3)
""")
# ── Wiring ────────────────────────────────────────────────────────────────
# Live-update description when model changes
model_dropdown.change(
fn=update_model_description,
inputs=[model_dropdown],
outputs=[model_desc_display],
)
new_game_btn.click(
fn=new_game,
inputs=[color_choice, model_dropdown],
outputs=[board_display, move_dropdown, status_display,
history_display, log_display, state],
)
move_btn.click(
fn=make_player_move,
inputs=[move_dropdown, state],
outputs=[board_display, move_dropdown, status_display,
history_display, log_display, state],
)
move_dropdown.select(
fn=make_player_move,
inputs=[move_dropdown, state],
outputs=[board_display, move_dropdown, status_display,
history_display, log_display, state],
)
if __name__ == "__main__":
demo.launch()
demo.launch(mcp_server=True)