""" ShopSite AI - Small Business Website Generator Block-based page builder: users compose pages from reusable blocks, LLM (Qwen Coder) rewrites individual block HTML on natural language instruction. """ import gradio as gr from PIL import Image, ImageDraw, ImageFont import json import zipfile import os import base64 import io import re import shutil import uuid import urllib.parse from pathlib import Path import spaces try: import torch from transformers import AutoModelForCausalLM, AutoTokenizer TORCH_AVAILABLE = True except ImportError: TORCH_AVAILABLE = False print("⚠️ torch/transformers not installed. LLM features disabled.") try: from diffusers import AutoPipelineForText2Image SD_AVAILABLE = True and TORCH_AVAILABLE except (ImportError, RuntimeError): SD_AVAILABLE = False print("⚠️ diffusers not available. Poster generation disabled.") # ============================================================ # CONFIG # ============================================================ QWEN_MODEL = "Qwen/Qwen2.5-7B-Instruct" QWEN_CODER_MODEL = "Qwen/Qwen2.5-Coder-14B-Instruct" SD_MODEL_ID = "stabilityai/sd-turbo" WORK_DIR = Path("./workspace"); WORK_DIR.mkdir(exist_ok=True) TEMPLATE_DIR = Path("./templates") # ============================================================ # GLOBAL STATE # ============================================================ current_html = "" current_menu_data = {} current_site_info = {} current_template_key = "warm" page_blocks = [] # [{"id": str, "type": str, "html": str}, ...] sd_pipe = None # Menu item HTML template — LLM can rewrite this to change structure # Placeholders: {name}, {price}, {img_tag} MENU_ITEM_TEMPLATE_DEFAULT = """\ """ current_menu_item_template = MENU_ITEM_TEMPLATE_DEFAULT # ============================================================ # BLOCK DEFAULTS (warm-theme HTML that works with CSS variables) # ============================================================ BLOCK_DEFAULTS = { "Hero Banner": """\
Est. 2024

Shop Name

Welcome to our shop

Open Daily
Visit Us
""", "Promo / Event": """\

What's New

Coming Soon Stay tuned for updates
""", "About / Story": """\

Our Story

Welcome to our shop. We are passionate about quality and great service. Come visit us and experience the difference.

""", "Contact Info": """\

Find Us

Phone
Address
Hours
""", "Announcement": """\
📢 Notice

Add your announcement here.

""", "Menu Preview": """\

Our Menu

""", } # ============================================================ # BLOCK HELPERS # ============================================================ def _uid(): return str(uuid.uuid4())[:6] def _block_label(block): return f"{block['type']} [{block['id']}]" def _find_block(label): for b in page_blocks: if _block_label(b) == label: return b return None def get_block_choices(): return [_block_label(b) for b in page_blocks] # ============================================================ # HuggingFace LOCAL INFERENCE # ============================================================ _hf_models = {} def load_hf_model(model_id): if model_id not in _hf_models: print(f"Loading {model_id}...") tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32, device_map="auto", ) _hf_models[model_id] = (tokenizer, model) print(f"✅ {model_id} loaded.") return _hf_models[model_id] def _ollama_chat(model, system_prompt, user_message, temperature=0.3): """Inner implementation — safe to call from within a @spaces.GPU context.""" if not TORCH_AVAILABLE: return "ERROR: torch/transformers not installed." try: tokenizer, hf_model = load_hf_model(model) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}, ] text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tokenizer(text, return_tensors="pt").to(hf_model.device) max_new_tokens = 1024 if model == QWEN_CODER_MODEL else 512 with torch.no_grad(): outputs = hf_model.generate( **inputs, max_new_tokens=max_new_tokens, temperature=temperature if temperature > 0 else None, do_sample=temperature > 0, pad_token_id=tokenizer.eos_token_id, ) return tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True) except Exception as e: return f"ERROR: {e}" @spaces.GPU def ollama_chat(model, system_prompt, user_message, temperature=0.3): return _ollama_chat(model, system_prompt, user_message, temperature) def parse_json_from_response(text): m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL) if m: text = m.group(1) try: return json.loads(text.strip()) except json.JSONDecodeError: m2 = re.search(r'\{.*\}', text, re.DOTALL) if m2: try: return json.loads(m2.group()) except json.JSONDecodeError: pass return {} # ============================================================ # MENU ZIP # ============================================================ def process_menu_zip(zip_file): menu = {} if zip_file is None: return menu extract_dir = WORK_DIR / "menu_images" if extract_dir.exists(): shutil.rmtree(extract_dir) extract_dir.mkdir(parents=True) with zipfile.ZipFile(zip_file, 'r') as zf: zf.extractall(extract_dir) for root, dirs, files in os.walk(extract_dir): rel = Path(root).relative_to(extract_dir) if str(rel).startswith(('__', '.')): continue for fname in sorted(files): if fname.startswith(('.', '__')): continue if not fname.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): continue fpath = Path(root) / fname parts = fpath.relative_to(extract_dir).parts category = parts[-2] if len(parts) >= 2 else "Menu" stem = fpath.stem last_us = stem.rfind('_') if last_us > 0: name_part = stem[:last_us].replace('_', ' ').strip() try: price = float(stem[last_us + 1:].strip()) except ValueError: name_part = stem.replace('_', ' ').strip(); price = 0.0 else: name_part = stem.replace('_', ' ').strip(); price = 0.0 with open(fpath, 'rb') as f: img_bytes = f.read() img_b64 = base64.b64encode(img_bytes).decode('utf-8') ext = fpath.suffix.lower() mime = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'} if category not in menu: menu[category] = [] menu[category].append({ "name": name_part, "price": price, "image_base64": f"data:{mime.get(ext,'image/png')};base64,{img_b64}", }) return menu # ============================================================ # MENU HTML BUILDERS # ============================================================ def build_category_tabs(menu_data): return "\n ".join( f'
{cat}
' for cat in menu_data.keys() ) def build_menu_html(menu_data): html = "" for cat, items in menu_data.items(): html += f' \n' return html # ============================================================ # TEMPLATE ENGINE # ============================================================ def load_template(key): path = TEMPLATE_DIR / f"{key}.html" if not path.exists(): path = TEMPLATE_DIR / "warm.html" return path.read_text(encoding='utf-8') def rebuild_html(): global current_html template = load_template(current_template_key) home_html = "\n".join(b["html"] for b in page_blocks) html = template html = html.replace("", home_html) html = html.replace("", build_category_tabs(current_menu_data)) html = html.replace("", build_menu_html(current_menu_data)) html = html.replace("{{SHOP_NAME}}", current_site_info.get("shop_name", "My Shop")) # Poster carousel injection (into any .promo-placeholder found in blocks) posters = current_site_info.get("posters", []) if posters: if len(posters) == 1: carousel_html = f'Poster' else: slides = "\n".join( f'
Poster {i+1}
' for i, p in enumerate(posters) ) dots = "\n".join( f'' for i in range(len(posters)) ) carousel_html = ( f'
{slides}' f'' f'' f'
{dots}
' ) pat = r'
]*>.*?
' html = re.sub(pat, carousel_html, html, flags=re.DOTALL, count=1) carousel_css = ( ".ps-wrap{position:relative;overflow:hidden;border-radius:var(--card-radius,12px);}" ".ps-slide{display:none;}.ps-slide.ps-active{display:block;}" ".ps-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.45);" "color:#fff;border:none;padding:10px 14px;font-size:18px;cursor:pointer;z-index:10;border-radius:6px;}" ".ps-l{left:8px;}.ps-r{right:8px;}" ".ps-dots{position:absolute;bottom:10px;width:100%;text-align:center;}" ".ps-dot{display:inline-block;width:8px;height:8px;background:rgba(255,255,255,0.5);" "border-radius:50%;margin:0 3px;cursor:pointer;}" ".ps-dot.ps-dot-on{background:#fff;}" ) carousel_js = ( "" ) html = html.replace("", f"\n/* Carousel */\n{carousel_css}\n", 1) html = html.replace("", f"\n{carousel_js}\n", 1) # Custom CSS overrides css = current_site_info.get("custom_css", "") if css: html = html.replace("", f"\n/* Custom */\n{css}\n", 1) current_html = html return html # ============================================================ # BLOCK MANAGEMENT HANDLERS # ============================================================ def _make_menu_preview_html(): if not current_menu_data: return BLOCK_DEFAULTS["Menu Preview"] preview_items = [] for items in current_menu_data.values(): for item in items: preview_items.append(item) if len(preview_items) >= 3: break if len(preview_items) >= 3: break rows = "" for item in preview_items: price_str = f"${item['price']:.2f}" if item.get('price', 0) > 0 else "" img_src = item.get('image_base64', '') img_html = (f'{item[' if img_src else '
') rows += f"""
{img_html}
{item['name']}
{price_str}
\n""" return f"""\

Our Menu

{rows}
""" def handle_add_block(block_type, selected_label): if block_type == "Menu Preview": initial_html = _make_menu_preview_html() else: initial_html = BLOCK_DEFAULTS.get(block_type, "") new_block = {"id": _uid(), "type": block_type, "html": initial_html} if selected_label and _find_block(selected_label): idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1) page_blocks.insert(idx + 1, new_block) else: page_blocks.append(new_block) rebuild_html() choices = get_block_choices() new_label = _block_label(new_block) return preview(current_html), gr.update(choices=choices, value=new_label) def handle_remove_block(selected_label): global page_blocks if not selected_label: return preview(current_html), gr.update() page_blocks = [b for b in page_blocks if _block_label(b) != selected_label] rebuild_html() choices = get_block_choices() return preview(current_html), gr.update(choices=choices, value=choices[0] if choices else None) def handle_move_up(selected_label): idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1) if idx > 0: page_blocks[idx - 1], page_blocks[idx] = page_blocks[idx], page_blocks[idx - 1] rebuild_html() return preview(current_html), gr.update(choices=get_block_choices(), value=selected_label) def handle_move_down(selected_label): idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1) if 0 <= idx < len(page_blocks) - 1: page_blocks[idx], page_blocks[idx + 1] = page_blocks[idx + 1], page_blocks[idx] rebuild_html() return preview(current_html), gr.update(choices=get_block_choices(), value=selected_label) # ============================================================ # LLM BLOCK EDITOR # ============================================================ CODER_SYSTEM = """You are a frontend developer editing a mobile website HTML block. RULES: - Output ONLY the modified HTML. No explanation, no markdown fences, no ```html. - Keep the mobile-friendly layout and existing CSS classes. - Only change what the instruction asks. - Do NOT output , , , or - Then make the item itself a compact vertical block (display:block, not flex row) GENERAL RULES: - Output ONLY the modified HTML template. No explanation, no markdown fences, no ```html. - Keep {name}, {price}, and at least one of {img_tag} or {img_src} - For UI decorations (stars, spice icons, badges): write them as static HTML/emoji, NOT as a placeholder - Use CSS variables for colors (--primary, --bg-card, --text, --text-muted, --border) - Do NOT add , , or tags. A single