Spaces:
Running on Zero
Running on Zero
File size: 5,876 Bytes
c36f659 d73facb c36f659 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | """Gemma 4 E2B (QAT Mobile) multimodal chat demo for HF Spaces ZeroGPU."""
# `spaces` MUST be imported before torch / transformers: it monkey-patches
# torch.cuda.*, which must happen before CUDA is initialized in this process.
import spaces # noqa: F401
import os
import threading
from collections.abc import Iterator
from typing import Any
import torch
import gradio as gr
from transformers import (
AutoProcessor,
AutoModelForMultimodalLM,
TextIteratorStreamer,
)
MODEL_ID = os.environ.get("MODEL_ID", "unsloth/gemma-4-E2B-it-qat-mobile")
# HF_TOKEN is only required if MODEL_ID points at a gated repo. This model is
# public, so it can be left unset.
HF_TOKEN = os.environ.get("HF_TOKEN")
DEFAULT_SYSTEM = (
"You are Gemma 4, a friendly, helpful multimodal assistant. "
"Be concise and accurate."
)
# Load eagerly at module scope. On ZeroGPU this call is intercepted: weights are
# packed to disk here, then streamed into VRAM on the first @spaces.GPU entry.
print(f"[app] loading model: {MODEL_ID}", flush=True)
processor = AutoProcessor.from_pretrained(MODEL_ID, token=HF_TOKEN)
model = AutoModelForMultimodalLM.from_pretrained(
MODEL_ID,
dtype="auto",
device_map="cuda",
token=HF_TOKEN,
)
model.eval()
print("[app] model ready", flush=True)
def _user_content_blocks(value: Any) -> list[dict]:
"""Turn a Gradio multimodal user value into Gemma 4 content blocks.
Gradio hands us either a plain string (text-only turn) or a dict with
``{"text": str, "files": [paths]}``. Gemma 4 expects images *before* text.
"""
if value is None:
return []
if isinstance(value, str):
text = value.strip()
return [{"type": "text", "text": text}] if text else []
if isinstance(value, dict):
blocks: list[dict] = []
for path in value.get("files") or []:
blocks.append({"type": "image", "url": path})
text = (value.get("text") or "").strip()
if text:
blocks.append({"type": "text", "text": text})
return blocks
return []
def _build_messages(
history: list[dict],
message: dict,
system_prompt: str,
enable_thinking: bool,
) -> list[dict]:
messages: list[dict] = [
{"role": "system", "content": system_prompt.strip() or DEFAULT_SYSTEM}
]
for turn in history:
role = turn.get("role")
content = turn.get("content")
if role == "user":
blocks = _user_content_blocks(content)
if blocks:
messages.append({"role": "user", "content": blocks})
elif role == "assistant" and isinstance(content, str) and content.strip():
# Per Gemma 4 best practice: prior assistant turns carry only the
# final answer, never the thinking trace.
messages.append({"role": "assistant", "content": content})
blocks = _user_content_blocks(message)
messages.append({"role": "user", "content": blocks})
return messages
@spaces.GPU(duration=120)
def respond(
message: dict,
history: list[dict],
system_prompt: str,
enable_thinking: bool,
max_new_tokens: int,
temperature: float,
top_p: float,
top_k: int,
) -> Iterator[str]:
"""Stream a multimodal chat completion from Gemma 4 E2B.
Yielded strings are the growing assistant reply (shown live in the UI).
"""
user_blocks = _user_content_blocks(message)
if not user_blocks:
yield "Please enter a message or attach an image first."
return
messages = _build_messages(history, message, system_prompt, enable_thinking)
inputs = processor.apply_chat_template(
messages,
tokenize=True,
return_dict=True,
return_tensors="pt",
add_generation_prompt=True,
enable_thinking=enable_thinking,
).to(model.device)
streamer = TextIteratorStreamer(
processor.tokenizer,
skip_prompt=True,
skip_special_tokens=True,
)
do_sample = temperature > 0
gen_kwargs = dict(
inputs,
max_new_tokens=int(max_new_tokens),
top_p=float(top_p),
top_k=int(top_k),
do_sample=do_sample,
streamer=streamer,
)
if do_sample:
gen_kwargs["temperature"] = float(temperature)
worker = threading.Thread(target=model.generate, kwargs=gen_kwargs)
worker.start()
partial = ""
try:
for chunk in streamer:
partial += chunk
yield partial
finally:
worker.join()
demo = gr.ChatInterface(
fn=respond,
multimodal=True,
title="Gemma 4 E2B (QAT Mobile) \u00b7 Multimodal Demo",
description=(
"Chat with `unsloth/gemma-4-E2B-it-qat-mobile` \u2014 a 2.3B-effective-parameter "
"multimodal Gemma 4 model (text + image). Upload an image and ask about it, "
"or just chat. The first reply after a cold start may be slow while ZeroGPU "
"streams the weights into VRAM."
),
additional_inputs=[
gr.Textbox(
value=DEFAULT_SYSTEM,
label="System prompt",
lines=2,
),
gr.Checkbox(value=False, label="Enable thinking mode"),
gr.Slider(64, 2048, value=512, step=64, label="Max new tokens"),
gr.Slider(0.0, 1.5, value=1.0, step=0.05, label="Temperature (0 = greedy)"),
gr.Slider(0.1, 1.0, value=0.95, step=0.05, label="Top-p"),
gr.Slider(0, 128, value=64, step=1, label="Top-k"),
],
examples=[
[{"text": "Write a short poem about a curious cat.", "files": []}],
[{"text": "Explain quantization-aware training in two sentences.", "files": []}],
[{"text": "Give me three name ideas for a friendly code-review bot.", "files": []}],
],
cache_examples=False,
fill_height=True,
)
if __name__ == "__main__":
demo.launch(mcp_server=True)
|