#!/usr/bin/env python3
"""
Step3-VL-10B Gradio Interface
"""
import os
import sys
import time
import re
import io
import base64
from typing import Optional, List, Generator
from PIL import Image
import gradio as gr
import spaces
from transformers import AutoProcessor, AutoModelForCausalLM
def set_pythonpath(path: str, allow_missing: bool = False):
"""Prepend path to PYTHONPATH and sys.path."""
if not path:
return
if not allow_missing and not os.path.isdir(path):
return
if path not in sys.path:
sys.path.insert(0, path)
os.environ["PYTHONPATH"] = (
path
+ os.pathsep
+ os.environ.get("PYTHONPATH", "")
)
def resolve_remote_snapshot_path(model_name: str, revision: Optional[str]):
"""Resolve HF cache snapshot path for remote model."""
if not model_name or not revision:
return None
hub_cache = os.environ.get("HUGGINGFACE_HUB_CACHE")
if not hub_cache:
return None
model_dir = f"models--{model_name.replace('/', '--')}"
return os.path.join(hub_cache, model_dir, "snapshots", revision)
def ensure_model_repo_on_pythonpath(model_name: str, revision: Optional[str] = None):
"""Add local/remote model repo path to PYTHONPATH and sys.path."""
if not model_name:
return
if os.path.isdir(model_name):
set_pythonpath(model_name)
return
snapshot_path = resolve_remote_snapshot_path(model_name, revision)
if snapshot_path:
# Path may not exist until after first download.
set_pythonpath(snapshot_path, allow_missing=True)
MODEL_NAME = os.getenv("MODEL_NAME", "stepfun-ai/Step3-VL-10B")
ensure_model_repo_on_pythonpath(MODEL_NAME)
_MODEL = None
_PROCESSOR = None
_LOADED_MODEL_NAME = None
@spaces.GPU
def get_model(model_name: Optional[str] = None):
"""Load and cache the Step3-VL-10B model and processor."""
global _MODEL, _PROCESSOR, _LOADED_MODEL_NAME
if model_name is None:
model_name = MODEL_NAME
if _MODEL is not None and _PROCESSOR is not None and _LOADED_MODEL_NAME == model_name:
return _MODEL, _PROCESSOR
key_mapping = {
"^vision_model": "model.vision_model",
r"^model(?!\.(language_model|vision_model))": "model.language_model",
"vit_large_projector": "model.vit_large_projector",
}
print(f"加载模型: {model_name}")
revision = None
if os.path.isdir(model_name):
# Ensure local custom modules (e.g., configuration_step_vl.py) are importable.
ensure_model_repo_on_pythonpath(model_name, revision)
_PROCESSOR = AutoProcessor.from_pretrained(
model_name,
trust_remote_code=True,
fix_mistral_regex=True,
revision=revision
)
_MODEL = AutoModelForCausalLM.from_pretrained(
model_name,
trust_remote_code=True,
device_map="auto",
torch_dtype="auto",
key_mapping=key_mapping,
revision=revision
).eval()
_LOADED_MODEL_NAME = model_name
return _MODEL, _PROCESSOR
@spaces.GPU
def warmup_model(model_name: Optional[str] = None):
"""Warmup the model once at app startup."""
get_model(model_name)
return True
def resolve_generation_tokens(processor):
"""Resolve eos/pad token ids and stop strings for safer stopping."""
tokenizer = getattr(processor, "tokenizer", processor)
eos_ids = []
eos_token_id = getattr(tokenizer, "eos_token_id", None)
if eos_token_id is not None:
if isinstance(eos_token_id, (list, tuple)):
eos_ids.extend(list(eos_token_id))
else:
eos_ids.append(eos_token_id)
def _try_add_token(token_str: str):
try:
token_id = tokenizer.convert_tokens_to_ids(token_str)
unk_id = getattr(tokenizer, "unk_token_id", None)
if token_id is not None and token_id != unk_id:
eos_ids.append(token_id)
except Exception:
return
# Common end-of-turn markers across chat templates
for token_str in [
"<|eot_id|>",
"<|end_of_turn|>",
"<|end_of_text|>",
"<|im_end|>",
"",
]:
_try_add_token(token_str)
# De-duplicate while preserving order
seen = set()
eos_ids = [i for i in eos_ids if not (i in seen or seen.add(i))]
pad_token_id = getattr(tokenizer, "pad_token_id", None)
if pad_token_id is None and eos_ids:
pad_token_id = eos_ids[0]
stop_strings = [
"\nassistant",
"\nAssistant",
"<|eot_id|>",
"<|end_of_turn|>",
"<|end_of_text|>",
"<|im_end|>",
"",
]
return eos_ids, pad_token_id, stop_strings
def truncate_on_stop_strings(text: str, stop_strings):
if not text:
return text
earliest = None
for s in stop_strings:
idx = text.find(s)
if idx != -1 and (earliest is None or idx < earliest):
earliest = idx
return text if earliest is None else text[:earliest].rstrip()
def escape_html(text):
"""Escape HTML special characters to prevent XSS"""
if not isinstance(text, str):
return text
return (text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'"))
def load_image(image_path: Optional[str]) -> Optional[Image.Image]:
"""Load image from filepath for model input."""
if not image_path:
return None
if not os.path.exists(image_path):
return None
try:
image = Image.open(image_path)
image.load() # Ensure file is fully read before temp cleanup.
if image.mode != "RGB":
image = image.convert("RGB")
return image
except Exception as e:
print(f"[ERROR] Failed to load image: {e}")
return None
def image_to_data_url(image: Image.Image) -> Optional[str]:
"""Convert PIL image to data URL for stable chat rendering."""
if image is None:
return None
try:
buffer = io.BytesIO()
image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
except Exception as e:
print(f"[ERROR] Failed to encode image: {e}")
return None
def _strip_data_urls(text: str) -> str:
if not text:
return text
# Remove base64 data URLs to avoid massive token counts.
return re.sub(r"data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=\s]+", "", text)
def _strip_html(text: str) -> str:
if not text:
return text
return re.sub(r"<[^>]+>", "", text)
def _extract_text_content(content) -> Optional[str]:
if content is None:
return None
if isinstance(content, str):
cleaned = _strip_data_urls(content)
cleaned = _strip_html(cleaned)
return cleaned.strip() if cleaned.strip() else None
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
text = "\n".join(p for p in parts if p)
return text.strip() if text.strip() else None
return None
def format_messages(history, user_text, image: Optional[Image.Image] = None):
"""Format message list for Step3-VL-10B."""
messages: List[dict] = []
if not history:
history = []
# 处理历史记录(仅保留文本)
for item in history:
role = item.get("role") if isinstance(item, dict) else getattr(item, "role", None)
content = item.get("content") if isinstance(item, dict) else getattr(item, "content", None)
raw_text = item.get("raw_text") if isinstance(item, dict) else None
if not role or content is None:
continue
# 如果内容包含思考块,提取最终回答
if role == "assistant" and isinstance(content, str) and '
' in content:
pattern = r'
.*?
\s*
\s*'
remaining_content = re.sub(pattern, '', content, flags=re.DOTALL).strip()
if remaining_content and not remaining_content.startswith('<'):
content = remaining_content
else:
continue
if role == "user" and raw_text is not None:
text_content = raw_text
else:
text_content = _extract_text_content(content)
if isinstance(text_content, str) and text_content.strip():
messages.append({
"role": role,
"content": [{"type": "text", "text": text_content}]
})
# 添加当前用户消息(支持图像 + 文本)
content_list = []
if image is not None:
content_list.append({"type": "image", "image": image})
if user_text:
content_list.append({"type": "text", "text": user_text})
if content_list:
if len(content_list) == 1 and content_list[0]["type"] == "text":
messages.append({
"role": "user",
"content": [{"type": "text", "text": content_list[0]["text"]}]
})
else:
messages.append({"role": "user", "content": content_list})
return messages
def build_user_display(image_url: Optional[str], user_text: Optional[str]) -> str:
parts = []
if image_url:
parts.append(
f''
f'

'
f'
'
)
if user_text:
parts.append(user_text)
return "\n\n".join(parts).strip()
@spaces.GPU
def chat(user_text, image_file, history, max_tokens, temperature, top_p, show_thinking=True, model_name=None):
"""Chat function for Step3-VL-10B."""
if model_name is None:
model_name = MODEL_NAME
if not user_text and not image_file:
yield history or [], "", None
return
# Ensure history is a list and formatted correctly
history = history or []
clean_history = []
for item in history:
if isinstance(item, dict) and 'role' in item and 'content' in item:
clean_history.append(item)
elif hasattr(item, "role") and hasattr(item, "content"):
clean_history.append(item)
history = clean_history
# Load image if provided
image = load_image(image_file)
image_url = image_to_data_url(image) if image is not None else None
messages = format_messages(history, user_text, image)
if not messages:
yield history or [], "", None
return
# Update history with user message
user_display = build_user_display(image_url, user_text)
history.append({
"role": "user",
"content": user_display,
"raw_text": user_text or ""
})
# Add thinking placeholder
if show_thinking:
history.append({
"role": "assistant",
"content": (
'\n'
'\n'
'
Processing your request...
\n'
'
'
)
})
else:
history.append({
"role": "assistant",
"content": "⏳ Generating response..."
})
yield history, "", None
start_time = time.time()
try:
# 直接复用已加载的全局模型,必要时再触发加载
model = _MODEL
processor = _PROCESSOR
if model is None or processor is None or _LOADED_MODEL_NAME != model_name:
model, processor = get_model(model_name)
inputs = processor.apply_chat_template(
messages, add_generation_prompt=True, tokenize=True,
return_dict=True, return_tensors="pt"
).to(model.device)
gen_kwargs = {
"max_new_tokens": max_tokens,
"do_sample": temperature > 0
}
if temperature > 0:
gen_kwargs["temperature"] = temperature
gen_kwargs["top_p"] = top_p
eos_ids, pad_token_id, stop_strings = resolve_generation_tokens(processor)
if eos_ids:
gen_kwargs["eos_token_id"] = eos_ids if len(eos_ids) > 1 else eos_ids[0]
if pad_token_id is not None:
gen_kwargs["pad_token_id"] = pad_token_id
generate_ids = model.generate(**inputs, **gen_kwargs)
decoded = processor.decode(
generate_ids[0, inputs["input_ids"].shape[-1]:],
skip_special_tokens=True
)
decoded = truncate_on_stop_strings(decoded, stop_strings).strip()
if "" in decoded:
think_content, response_content = decoded.split("", 1)
if think_content.startswith(""):
think_content = think_content[len(""):].strip()
response_content = response_content.strip()
if show_thinking:
escaped_think = escape_html(think_content)
formatted_content = (
f'\n'
f'\n'
f'
{escaped_think}
\n'
f'
\n\n'
f'{response_content}'
)
history[-1]["content"] = formatted_content
else:
history[-1]["content"] = response_content
else:
history[-1]["content"] = decoded
elapsed_time = time.time() - start_time
print(f"[MODEL] ✅ SUCCESS - Time: {elapsed_time:.2f}s")
yield history, "", None
except Exception as e:
elapsed_time = time.time() - start_time
import traceback
print(f"[MODEL] ❌ FAILED - Error: {traceback.format_exc()}, Time: {elapsed_time:.2f}s")
history[-1]["content"] = f"❌ Error: {str(e)}"
yield history, "", None
# Custom CSS for better UI
custom_css = """
/* 全局样式 */
.gradio-container {
max-width: 100% !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* 标题样式 */
.app-header {
text-align: center;
padding: 2.5rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow: hidden;
border-radius: 16px;
margin-bottom: 1.5rem;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.35);
}
/* 标题背景装饰 */
.app-header::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
animation: rotate 20s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.app-header h1 {
margin: 0;
font-size: 2.8rem;
font-weight: 700;
color: white !important;
text-shadow: 0 3px 6px rgba(0, 0, 0, 0.25);
letter-spacing: 1px;
position: relative;
z-index: 1;
}
.app-header p {
color: rgba(255, 255, 255, 0.95) !important;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1;
line-height: 1.5;
}
/* 聊天框样式 */
.chatbot-container {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
/* 思考过程样式 - 模仿Claude/ChatGPT的风格 */
.thinking-block {
background: linear-gradient(135deg, #f5f7fa 0%, #eef2f7 100%);
border-left: 4px solid #667eea;
padding: 16px 20px;
margin: 12px 0;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.thinking-header {
display: flex;
align-items: center;
font-weight: 600;
color: #667eea;
margin-bottom: 10px;
font-size: 0.95rem;
}
.thinking-content {
background: #ffffff;
padding: 12px 16px;
border-radius: 6px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.6;
color: #374151;
white-space: pre-wrap;
word-wrap: break-word;
border: 1px solid #e5e7eb;
}
/* 回复分隔线 */
.response-divider {
border: none;
height: 2px;
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
margin: 20px 0;
}
/* 按钮样式 */
.primary-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border: none !important;
transition: all 0.3s ease !important;
}
.primary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
}
/* 左侧面板样式 */
.left-panel {
background: #f9fafb;
border-radius: 12px;
padding: 1rem;
height: 100%;
}
/* 输入框样式 */
.input-box textarea {
border-radius: 8px !important;
border: 2px solid #e5e7eb !important;
transition: border-color 0.3s ease !important;
}
.input-box textarea:focus {
border-color: #667eea !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important;
}
/* 聊天中展示上传图片 */
.uploaded-image img {
max-width: 100%;
border-radius: 8px;
border: 1px solid #e5e7eb;
display: block;
}
/* 输入区域标题 */
h3 {
color: #374151;
font-size: 1.1rem;
margin: 1rem 0 0.5rem 0;
}
/* 聊天消息样式优化 */
.message-wrap {
padding: 1rem !important;
}
.message {
padding: 1rem !important;
border-radius: 12px !important;
line-height: 1.6 !important;
}
/* 用户消息 */
.message.user {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: white !important;
}
/* 助手消息 */
.message.bot {
background: #f9fafb !important;
border: 1px solid #e5e7eb !important;
}
/* 左侧面板整体样式 */
.left-column {
background: linear-gradient(to bottom, #ffffff 0%, #f9fafb 100%);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/* 按钮容器样式 */
.button-row {
margin-top: 1rem;
gap: 0.5rem;
}
/* Dark Mode Support */
.dark .message.bot {
background: #1f2937 !important;
border: 1px solid #374151 !important;
color: #e5e7eb !important;
}
.dark .thinking-block {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
border-left: 4px solid #4f46e5;
}
.dark .thinking-content {
background: #111827;
color: #e5e7eb;
border: 1px solid #374151;
}
.dark .thinking-header {
color: #818cf8;
}
.dark .left-panel {
background: #111827;
}
.dark .left-column {
background: linear-gradient(to bottom, #1f2937 0%, #111827 100%);
}
.dark .input-box textarea {
background-color: #1f2937;
border-color: #374151 !important;
color: #e5e7eb;
}
.dark h3 {
color: #e5e7eb;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
"""
# Gradio Interface
with gr.Blocks(title="Step3-VL-10B", css=custom_css, theme=gr.themes.Soft()) as demo:
model_ready = gr.State(False)
# Header
gr.HTML("""
""")
with gr.Row():
# Left Panel - Input Area
with gr.Column(scale=1, min_width=350):
# Configuration
with gr.Accordion("⚙️ Configuration", open=False):
max_tokens = gr.Slider(
1, 56000,
value=16384,
label="Max Tokens",
info="Maximum tokens to generate"
)
temperature = gr.Slider(
0.0, 2.0,
value=1.0,
label="Temperature",
info="Higher = more random"
)
top_p = gr.Slider(
0.0, 1.0,
value=1.0,
label="Top P",
info="Nucleus sampling"
)
show_thinking = gr.Checkbox(
label="💭 Show Thinking Process",
value=True,
info="Display reasoning steps"
)
# Input Area
gr.Markdown("### 📝 Your Input")
user_text = gr.Textbox(
label="Text Message",
lines=4,
placeholder="Type your message here...",
elem_classes=["input-box"],
show_label=False
)
image_file = gr.Image(
label="🖼️ Image Input",
type="filepath",
elem_classes=["image-upload"]
)
# Buttons
with gr.Row():
clear_btn = gr.Button("🗑️ Clear", scale=1, size="lg")
submit_btn = gr.Button(
"🚀 Send",
variant="primary",
scale=2,
size="lg",
elem_classes=["primary-btn"]
)
# Usage Guide at bottom
with gr.Accordion("📖 Quick Guide", open=False):
gr.Markdown("""
**Usage:**
- Enter text, upload an image, or do both
- Supports multi-turn conversation
- Toggle the thinking process display
**Tips:**
- Use an image for better visual Q&A
- Thinking steps show in a blue block
- Adjust settings in Configuration
""")
# Right Panel - Conversation Area
with gr.Column(scale=2):
chatbot = gr.Chatbot(
label="💬 Conversation",
height=700,
type="messages",
elem_classes=["chatbot-container"],
show_label=True,
avatar_images=(None, None),
bubble_full_width=False,
sanitize_html=False
)
submit_btn.click(
fn=chat,
inputs=[user_text, image_file, chatbot, max_tokens, temperature, top_p, show_thinking],
outputs=[chatbot, user_text, image_file]
)
clear_btn.click(
fn=lambda: ([], "", None),
outputs=[chatbot, user_text, image_file]
)
demo.load(
fn=warmup_model,
inputs=None,
outputs=model_ready
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=7860)
parser.add_argument("--model", default=MODEL_NAME)
parser.add_argument("--share", action="store_true", help="启用 Gradio 共享链接")
args = parser.parse_args()
# 更新全局模型名称
if args.model:
MODEL_NAME = args.model
print(f"启动Gradio: http://{args.host}:{args.port}")
print(f"模型: {MODEL_NAME}")
try:
demo.launch(server_name=args.host, server_port=args.port, share=args.share)
except ValueError as e:
if "localhost is not accessible" in str(e) and not args.share:
print("检测到 localhost 不可访问,自动启用 share=True 重试。")
demo.launch(server_name=args.host, server_port=args.port, share=True)
else:
raise