ICGenAIShare06 commited on
Commit
2e40b4d
Β·
verified Β·
1 Parent(s): 3dcbd80

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1045 -499
app.py CHANGED
@@ -1,10 +1,11 @@
1
  """
2
  ShopSite AI - Small Business Website Generator
3
- Block-based editing: structured ops via Python, creative blocks via Qwen Coder
 
4
  """
5
 
6
  import gradio as gr
7
- import requests
8
  import json
9
  import zipfile
10
  import os
@@ -12,98 +13,212 @@ import base64
12
  import io
13
  import re
14
  import shutil
 
15
  import urllib.parse
16
  from pathlib import Path
17
  import spaces
18
 
19
  try:
20
  import torch
21
- from diffusers import AutoPipelineForText2Image
22
- SD_AVAILABLE = True
23
  except ImportError:
 
 
 
 
 
 
 
24
  SD_AVAILABLE = False
25
- print("⚠️ diffusers/torch not installed. Poster generation disabled.")
26
 
27
  # ============================================================
28
  # CONFIG
29
  # ============================================================
30
- OLLAMA_BASE = "http://localhost:11434"
31
- QWEN_MODEL = "qwen2.5:7b"
32
- QWEN_CODER_MODEL = "qwen2.5-coder:7b"
33
- SD_MODEL_ID = "stabilityai/sd-turbo"
34
 
35
- WORK_DIR = Path("./workspace")
36
- WORK_DIR.mkdir(exist_ok=True)
37
  TEMPLATE_DIR = Path("./templates")
38
 
39
- # Global state
40
- current_html = ""
41
- current_menu_data = {}
42
- current_site_info = {}
 
 
43
  current_template_key = "warm"
44
- sd_pipe = None
45
-
46
- # Editable block definitions - markers in template HTML
47
- BLOCKS = {
48
- "Hero Banner": {
49
- "start": "<!-- BLOCK:HERO:START -->",
50
- "end": "<!-- BLOCK:HERO:END -->",
51
- "desc": "Shop name, tagline, background decorations",
52
- "mode": "coder", # Creative: Qwen Coder modifies HTML
53
- },
54
- "Promo / Event": {
55
- "start": "<!-- BLOCK:PROMO:START -->",
56
- "end": "<!-- BLOCK:PROMO:END -->",
57
- "desc": "Promotional banner or event poster area",
58
- "mode": "coder",
59
- },
60
- "About / Story": {
61
- "start": "<!-- BLOCK:ABOUT:START -->",
62
- "end": "<!-- BLOCK:ABOUT:END -->",
63
- "desc": "Brand story and shop description",
64
- "mode": "coder",
65
- },
66
- "Contact Info": {
67
- "start": "<!-- BLOCK:CONTACT:START -->",
68
- "end": "<!-- BLOCK:CONTACT:END -->",
69
- "desc": "Phone, address, opening hours",
70
- "mode": "python", # Structured: Python modifies data
71
- },
72
- "Menu Items": {
73
- "start": "",
74
- "end": "",
75
- "desc": "Add, remove, or change menu items and prices",
76
- "mode": "python",
77
- },
78
- "Style / Colors": {
79
- "start": "",
80
- "end": "",
81
- "desc": "Change colors, fonts, and visual style",
82
- "mode": "python",
83
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  }
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  # ============================================================
88
- # OLLAMA
89
  # ============================================================
90
- def ollama_chat(model, system_prompt, user_message, temperature=0.3):
91
- try:
92
- resp = requests.post(
93
- f"{OLLAMA_BASE}/api/chat",
94
- json={"model": model, "messages": [
95
- {"role": "system", "content": system_prompt},
96
- {"role": "user", "content": user_message},
97
- ], "stream": False, "options": {"temperature": temperature}},
98
- timeout=180,
 
99
  )
100
- resp.raise_for_status()
101
- return resp.json()["message"]["content"]
102
- except requests.exceptions.ConnectionError:
103
- return "ERROR: Cannot connect to Ollama. Run: ollama serve"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  except Exception as e:
105
  return f"ERROR: {e}"
106
 
 
 
 
107
 
108
  def parse_json_from_response(text):
109
  m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
@@ -120,7 +235,6 @@ def parse_json_from_response(text):
120
  pass
121
  return {}
122
 
123
-
124
  # ============================================================
125
  # MENU ZIP
126
  # ============================================================
@@ -128,15 +242,12 @@ def process_menu_zip(zip_file):
128
  menu = {}
129
  if zip_file is None:
130
  return menu
131
-
132
  extract_dir = WORK_DIR / "menu_images"
133
  if extract_dir.exists():
134
  shutil.rmtree(extract_dir)
135
  extract_dir.mkdir(parents=True)
136
-
137
  with zipfile.ZipFile(zip_file, 'r') as zf:
138
  zf.extractall(extract_dir)
139
-
140
  for root, dirs, files in os.walk(extract_dir):
141
  rel = Path(root).relative_to(extract_dir)
142
  if str(rel).startswith(('__', '.')):
@@ -146,11 +257,9 @@ def process_menu_zip(zip_file):
146
  continue
147
  if not fname.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
148
  continue
149
-
150
  fpath = Path(root) / fname
151
  parts = fpath.relative_to(extract_dir).parts
152
  category = parts[-2] if len(parts) >= 2 else "Menu"
153
-
154
  stem = fpath.stem
155
  last_us = stem.rfind('_')
156
  if last_us > 0:
@@ -158,30 +267,24 @@ def process_menu_zip(zip_file):
158
  try:
159
  price = float(stem[last_us + 1:].strip())
160
  except ValueError:
161
- name_part = stem.replace('_', ' ').strip()
162
- price = 0.0
163
  else:
164
- name_part = stem.replace('_', ' ').strip()
165
- price = 0.0
166
-
167
  with open(fpath, 'rb') as f:
168
  img_bytes = f.read()
169
  img_b64 = base64.b64encode(img_bytes).decode('utf-8')
170
- ext = fpath.suffix.lower()
171
  mime = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'}
172
-
173
  if category not in menu:
174
  menu[category] = []
175
  menu[category].append({
176
- "name": name_part,
177
- "price": price,
178
- "image_base64": f"data:{mime.get(ext, 'image/png')};base64,{img_b64}",
179
  })
180
  return menu
181
 
182
-
183
  # ============================================================
184
- # TEMPLATE ENGINE
185
  # ============================================================
186
  def build_category_tabs(menu_data):
187
  return "\n ".join(
@@ -189,7 +292,6 @@ def build_category_tabs(menu_data):
189
  for cat in menu_data.keys()
190
  )
191
 
192
-
193
  def build_menu_html(menu_data):
194
  html = ""
195
  for cat, items in menu_data.items():
@@ -197,146 +299,292 @@ def build_menu_html(menu_data):
197
  html += f' <div class="menu-category-title">{cat}</div>\n'
198
  for item in items:
199
  price_str = f"${item['price']:.2f}" if item['price'] > 0 else ""
200
- img_src = item.get('image_base64', '')
201
- if img_src:
202
- img_tag = f'<img class="menu-item-img" src="{img_src}" alt="{item["name"]}" loading="lazy">'
203
- else:
204
- img_tag = '<div class="menu-item-img" style="background:linear-gradient(135deg,var(--secondary),var(--primary));opacity:0.3;"></div>'
205
- html += f''' <div class="menu-item" data-item-name="{item['name']}">
206
- {img_tag}
207
- <div class="menu-item-info">
208
- <div class="menu-item-name">{item['name']}</div>
209
- <div class="menu-item-price">{price_str}</div>
210
- </div>
211
- </div>\n'''
212
  html += ' </div>\n'
213
  return html
214
 
215
-
 
 
216
  def load_template(key):
217
  path = TEMPLATE_DIR / f"{key}.html"
218
  if not path.exists():
219
  path = TEMPLATE_DIR / "warm.html"
220
- if not path.exists():
221
- return "<html><body><h1>Template not found</h1></body></html>"
222
  return path.read_text(encoding='utf-8')
223
 
224
-
225
  def rebuild_html():
226
- """Rebuild full HTML from template + current data. Single source of truth."""
227
  global current_html
228
- s = current_site_info
229
- address = s.get("address", "")
230
-
231
- template = load_template(current_template_key)
232
-
233
- replacements = {
234
- "{{SHOP_NAME}}": s.get("shop_name", "My Shop"),
235
- "{{TAGLINE}}": s.get("description", "Welcome!")[:80],
236
- "{{DESCRIPTION}}": s.get("description", ""),
237
- "{{PHONE}}": s.get("phone", ""),
238
- "{{ADDRESS}}": address,
239
- "{{ADDRESS_ENCODED}}": urllib.parse.quote_plus(address),
240
- "{{HOURS}}": s.get("hours", ""),
241
- "{{LOCATION_SHORT}}": address.split(",")[0] if "," in address else address[:30],
242
- "{{PROMO_TITLE}}": s.get("promo_title", "Coming Soon"),
243
- "{{PROMO_SUBTITLE}}": s.get("promo_subtitle", "Stay tuned for updates"),
244
- }
245
 
246
  html = template
247
- for key, val in replacements.items():
248
- html = html.replace(key, val)
249
-
250
  html = html.replace("<!-- {{CATEGORY_TABS}} -->", build_category_tabs(current_menu_data))
251
- html = html.replace("<!-- {{MENU_ITEMS}} -->", build_menu_html(current_menu_data))
252
-
253
- # Inject poster if exists
254
- poster = s.get("poster_base64", "")
255
- if poster:
256
- pat = r'<div class="promo-placeholder"[^>]*>.*?</div>'
257
- repl = f'<img src="{poster}" alt="Poster" style="width:100%;height:200px;object-fit:cover;display:block;">'
258
- html = re.sub(pat, repl, html, flags=re.DOTALL, count=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
- # Inject custom CSS overrides
261
- css = s.get("custom_css", "")
262
  if css:
263
- html = html.replace("</style>", f"\n/* Custom */\n{css}\n</style>")
264
-
265
- # Apply any saved block overrides
266
- for block_name, block_html in s.get("block_overrides", {}).items():
267
- block_def = BLOCKS.get(block_name)
268
- if block_def and block_def["start"] and block_def["end"]:
269
- start = block_def["start"]
270
- end = block_def["end"]
271
- pat = re.escape(start) + r'.*?' + re.escape(end)
272
- replacement = start + "\n" + block_html + "\n" + end
273
- html = re.sub(pat, replacement, html, flags=re.DOTALL, count=1)
274
 
275
  current_html = html
276
  return html
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
  # ============================================================
280
- # BLOCK EXTRACTION
281
  # ============================================================
282
- def extract_block(html, block_name):
283
- """Extract HTML content between block markers."""
284
- block_def = BLOCKS.get(block_name)
285
- if not block_def or not block_def["start"]:
286
- return None
287
- start = block_def["start"]
288
- end = block_def["end"]
289
- pat = re.escape(start) + r'\n?(.*?)\n?' + re.escape(end)
290
- m = re.search(pat, html, re.DOTALL)
291
- return m.group(1) if m else None
 
 
 
 
 
 
292
 
 
 
 
293
 
294
- def replace_block(html, block_name, new_content):
295
- """Replace a block's content in HTML."""
296
- block_def = BLOCKS.get(block_name)
297
- if not block_def or not block_def["start"]:
298
- return html
299
- start = block_def["start"]
300
- end = block_def["end"]
301
- pat = re.escape(start) + r'.*?' + re.escape(end)
302
- replacement = start + "\n" + new_content + "\n" + end
303
- return re.sub(pat, replacement, html, flags=re.DOTALL, count=1)
304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
  # ============================================================
307
- # INTENT PARSING - Qwen 2.5 β†’ JSON
308
  # ============================================================
309
- PARSE_SYSTEM_CONTACT = """Parse the user's instruction about contact/business info into JSON.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
- Output format:
312
- {"actions": [{"field": "...", "value": "..."}]}
313
-
314
- Available fields: shop_name, description, phone, address, hours, promo_title, promo_subtitle
 
315
 
316
- Examples:
317
- - "Change phone to 555-1234" β†’ {"actions":[{"field":"phone","value":"555-1234"}]}
318
- - "Update hours to Mon-Sat 8am-9pm" β†’ {"actions":[{"field":"hours","value":"Mon-Sat 8am-9pm"}]}
319
- - "Change shop name to Blue Bottle" β†’ {"actions":[{"field":"shop_name","value":"Blue Bottle"}]}
 
320
 
321
- Output ONLY JSON."""
 
 
322
 
323
- PARSE_SYSTEM_MENU = """Parse the user's instruction about menu items into JSON.
 
 
324
 
325
- Output format:
326
- {"actions": [{"op": "add|remove|change_price", "name": "...", "price": 0.0, "category": "..."}]}
 
327
 
328
- Examples:
329
- - "Add Iced Mocha $6 in Coffee" β†’ {"actions":[{"op":"add","name":"Iced Mocha","price":6.00,"category":"Coffee"}]}
330
- - "Remove Espresso" β†’ {"actions":[{"op":"remove","name":"Espresso"}]}
331
- - "Change Latte price to $5.50" β†’ {"actions":[{"op":"change_price","name":"Latte","price":5.50}]}
332
- - "Add Tiramisu $8 in Desserts and remove Muffin" β†’ {"actions":[{"op":"add","name":"Tiramisu","price":8.00,"category":"Desserts"},{"op":"remove","name":"Muffin"}]}
333
 
334
- Output ONLY JSON."""
 
 
 
 
 
 
335
 
 
 
 
336
  PARSE_SYSTEM_STYLE = """Parse the user's instruction about visual style into JSON.
337
 
338
- Output format:
339
- {"actions": [{"prop": "...", "value": "..."}]}
340
 
341
  Available props:
342
  - primary, secondary, accent, bg, text (CSS color values like #006400)
@@ -345,55 +593,200 @@ Available props:
345
 
346
  Examples:
347
  - "Change primary color to forest green" β†’ {"actions":[{"prop":"primary","value":"#228B22"}]}
348
- - "Make it dark theme, black background" β†’ {"actions":[{"prop":"bg","value":"#1a1a1a"},{"prop":"text","value":"#f0f0f0"}]}
349
  - "Use Georgia for headings" β†’ {"actions":[{"prop":"font_heading","value":"Georgia, serif"}]}
350
 
351
  Output ONLY JSON."""
352
 
353
- CODER_BLOCK_SYSTEM = """You are a frontend developer. You will receive a small HTML block from a mobile website and an instruction to modify it.
 
 
354
 
355
- RULES:
356
- - Output ONLY the modified HTML block. No explanations, no markdown code blocks, no ```html.
357
- - Keep the same HTML structure and CSS classes.
358
- - Only change content/styling as requested.
359
- - Do NOT add <html>, <head>, <body>, or <style> tags. Only output the inner block content.
360
- - Keep it concise and mobile-friendly.
361
- - You may add inline styles if needed for visual changes.
362
- - Preserve all existing CSS classes and data attributes."""
363
 
 
 
 
 
 
364
 
365
- # ============================================================
366
- # PYTHON EXECUTORS for structured operations
367
- # ============================================================
368
- def execute_contact_actions(actions):
369
- """Update site_info fields."""
370
- messages = []
371
  for a in actions:
372
- field = a.get("field", "")
373
- value = a.get("value", "")
374
- if field in current_site_info:
375
- current_site_info[field] = value
376
- messages.append(f"Updated {field}")
 
 
 
 
 
377
  else:
378
- messages.append(f"Unknown field: {field}")
 
 
 
379
  rebuild_html()
380
- return messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
- def execute_menu_actions(actions, uploaded_image_b64=None):
384
- """Add/remove/modify menu items in current_menu_data."""
385
  messages = []
386
  for a in actions:
387
- op = a.get("op", "")
388
  name = a.get("name", "")
389
 
390
  if op == "add":
391
- cat = a.get("category", "Menu")
392
  price = a.get("price", 0.0)
393
  if cat not in current_menu_data:
394
  current_menu_data[cat] = []
395
- new_item = {"name": name, "price": price, "image_base64": uploaded_image_b64 or ""}
396
- current_menu_data[cat].append(new_item)
397
  messages.append(f"Added {name} (${price:.2f}) to {cat}")
398
 
399
  elif op == "remove":
@@ -401,14 +794,10 @@ def execute_menu_actions(actions, uploaded_image_b64=None):
401
  for cat, items in current_menu_data.items():
402
  for i, item in enumerate(items):
403
  if item["name"].lower() == name.lower():
404
- items.pop(i)
405
- messages.append(f"Removed {name} from {cat}")
406
- found = True
407
- break
408
- if found:
409
- break
410
  if not found:
411
- messages.append(f"Item '{name}' not found")
412
 
413
  elif op == "change_price":
414
  price = a.get("price", 0.0)
@@ -416,177 +805,133 @@ def execute_menu_actions(actions, uploaded_image_b64=None):
416
  for cat, items in current_menu_data.items():
417
  for item in items:
418
  if item["name"].lower() == name.lower():
419
- item["price"] = price
420
- messages.append(f"Changed {name} price to ${price:.2f}")
421
- found = True
422
- break
423
- if found:
424
- break
425
  if not found:
426
- messages.append(f"Item '{name}' not found")
427
-
428
- rebuild_html()
429
- return messages
430
-
431
-
432
- def execute_style_actions(actions):
433
- """Modify CSS variables via custom_css injection."""
434
- css_var_map = {
435
- "primary": "--primary",
436
- "secondary": "--secondary",
437
- "accent": "--accent",
438
- "bg": "--bg",
439
- "text": "--text",
440
- "font_heading": None, # special handling
441
- "font_body": None,
442
- "card_radius": "--card-radius",
443
- }
444
- lines = []
445
- messages = []
446
- for a in actions:
447
- prop = a.get("prop", "")
448
- value = a.get("value", "")
449
-
450
- if prop in ("font_heading", "font_body"):
451
- # Override via more specific CSS
452
- if prop == "font_heading":
453
- lines.append(f".hero h1, .section-title h2, .menu-header h1, .menu-category-title, .menu-item-price {{ font-family: {value} !important; }}")
454
- else:
455
- lines.append(f"body, .menu-item-name, .about-card p, .contact-text .value {{ font-family: {value} !important; }}")
456
- messages.append(f"Changed {prop} to {value}")
457
- elif prop in css_var_map and css_var_map[prop]:
458
- lines.append(f":root {{ {css_var_map[prop]}: {value}; }}")
459
- messages.append(f"Changed {prop} to {value}")
460
- else:
461
- messages.append(f"Unknown style prop: {prop}")
462
-
463
- if lines:
464
- existing = current_site_info.get("custom_css", "")
465
- current_site_info["custom_css"] = existing + "\n" + "\n".join(lines)
466
 
467
  rebuild_html()
468
- return messages
469
-
 
470
 
471
  # ============================================================
472
- # CODER BLOCK EDITING - Qwen Coder on isolated block only
473
  # ============================================================
474
- def edit_block_with_coder(block_name, user_instruction):
475
- """Extract block β†’ send to Qwen Coder with instruction β†’ replace block."""
476
- global current_html
477
-
478
- block_html = extract_block(current_html, block_name)
479
- if block_html is None:
480
- return f"❌ Could not find block: {block_name}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
- prompt = f"""Here is the current HTML block for "{block_name}":
 
 
483
 
484
- {block_html}
 
 
485
 
486
- User instruction: {user_instruction}
487
 
488
- Output the modified HTML block:"""
489
 
490
- raw = ollama_chat(QWEN_CODER_MODEL, CODER_BLOCK_SYSTEM, prompt, temperature=0.3)
491
 
492
  if raw.startswith("ERROR:"):
493
- return f"❌ {raw}"
494
-
495
- # Clean response
496
- new_block = raw.strip()
497
- new_block = re.sub(r'^```html?\s*\n?', '', new_block)
498
- new_block = re.sub(r'\n?```\s*$', '', new_block)
499
 
500
- # Sanity check: block shouldn't contain full document tags
501
- if '<!DOCTYPE' in new_block or '<html' in new_block.lower():
502
- return "❌ Coder returned a full page instead of a block. Try again with a simpler instruction."
 
 
 
 
 
 
 
 
 
 
 
503
 
504
- # Save override and rebuild
505
- if "block_overrides" not in current_site_info:
506
- current_site_info["block_overrides"] = {}
507
- current_site_info["block_overrides"][block_name] = new_block
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
 
509
  rebuild_html()
510
- return f"βœ… {block_name} updated!"
511
-
512
-
513
- # ============================================================
514
- # MAIN EDIT HANDLER
515
- # ============================================================
516
- def handle_edit(block_choice, user_message, uploaded_image, chat_history):
517
- """Route edit to the correct handler based on block type."""
518
- global current_html
519
-
520
- chat_history = chat_history or []
521
-
522
- if not current_html:
523
- chat_history.append({"role": "user", "content": f"[{block_choice}] {user_message}"})
524
- chat_history.append({"role": "assistant", "content": "⚠️ Generate a website first in the Create tab."})
525
- return chat_history, preview(current_html)
526
 
527
- chat_history.append({"role": "user", "content": f"[{block_choice}] {user_message}"})
528
-
529
- block_def = BLOCKS[block_choice]
530
-
531
- # ---- Python-handled blocks ----
532
- if block_choice == "Contact Info":
533
- raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_CONTACT, user_message)
534
- if raw.startswith("ERROR:"):
535
- chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
536
- return chat_history, preview(current_html)
537
- parsed = parse_json_from_response(raw)
538
- actions = parsed.get("actions", [])
539
- if not actions:
540
- chat_history.append({"role": "assistant", "content": f"❌ Could not parse. Raw: {raw[:150]}"})
541
- return chat_history, preview(current_html)
542
- results = execute_contact_actions(actions)
543
- chat_history.append({"role": "assistant", "content": "πŸ”§ " + " Β· ".join(results) + "\n\nβœ… Done!"})
544
-
545
- elif block_choice == "Menu Items":
546
- raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_MENU, user_message)
547
- if raw.startswith("ERROR:"):
548
- chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
549
- return chat_history, preview(current_html)
550
- parsed = parse_json_from_response(raw)
551
- actions = parsed.get("actions", [])
552
- if not actions:
553
- chat_history.append({"role": "assistant", "content": f"❌ Could not parse. Raw: {raw[:150]}"})
554
- return chat_history, preview(current_html)
555
-
556
- # Handle uploaded image
557
- img_b64 = None
558
- if uploaded_image:
559
- try:
560
- with open(uploaded_image, 'rb') as f:
561
- img_b64 = f"data:image/png;base64,{base64.b64encode(f.read()).decode()}"
562
- except Exception:
563
- pass
564
 
565
- results = execute_menu_actions(actions, img_b64)
566
- extra = " (with photo)" if img_b64 else ""
567
- chat_history.append({"role": "assistant", "content": "πŸ”§ " + " Β· ".join(results) + extra + "\n\nβœ… Done!"})
568
-
569
- elif block_choice == "Style / Colors":
570
- raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_STYLE, user_message)
571
- if raw.startswith("ERROR:"):
572
- chat_history.append({"role": "assistant", "content": f"❌ {raw}"})
573
- return chat_history, preview(current_html)
574
- parsed = parse_json_from_response(raw)
575
- actions = parsed.get("actions", [])
576
- if not actions:
577
- chat_history.append({"role": "assistant", "content": f"❌ Could not parse. Raw: {raw[:150]}"})
578
- return chat_history, preview(current_html)
579
- results = execute_style_actions(actions)
580
- chat_history.append({"role": "assistant", "content": "🎨 " + " Β· ".join(results) + "\n\nβœ… Done!"})
581
-
582
- # ---- Coder-handled blocks ----
583
- elif block_def["mode"] == "coder":
584
- chat_history.append({"role": "assistant", "content": f"πŸ”§ Sending to Coder: editing {block_choice}..."})
585
- result = edit_block_with_coder(block_choice, user_message)
586
- chat_history.append({"role": "assistant", "content": result})
587
-
588
- return chat_history, preview(current_html)
589
 
 
 
 
 
 
 
590
 
591
  # ============================================================
592
  # POSTER
@@ -597,7 +942,6 @@ def load_sd_model():
597
  return sd_pipe
598
  if not SD_AVAILABLE:
599
  return None
600
- print("Loading SD Turbo...")
601
  sd_pipe = AutoPipelineForText2Image.from_pretrained(
602
  SD_MODEL_ID,
603
  torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
@@ -608,42 +952,173 @@ def load_sd_model():
608
  sd_pipe.enable_attention_slicing()
609
  return sd_pipe
610
 
 
 
 
 
 
 
 
611
 
612
- def gen_poster_prompt(desc):
613
- sys = """Generate a Stable Diffusion prompt for a poster background.
614
- ONLY output the prompt. Under 50 words. No text/letters in image. Focus on mood, color, atmosphere. Add "high quality, professional, 4k"."""
615
- raw = ollama_chat(QWEN_MODEL, sys, desc, temperature=0.7)
616
- return raw.strip().strip('"\'') if not raw.startswith("ERROR:") else "beautiful poster background, professional, soft lighting, 4k"
617
-
618
-
619
- def generate_poster(desc, progress=gr.Progress()):
620
- if not SD_AVAILABLE:
621
- return None, "SD not installed."
622
- progress(0.1, desc="Generating prompt...")
623
- prompt = gen_poster_prompt(desc)
624
- progress(0.3, desc="Loading SD Turbo...")
625
- pipe = load_sd_model()
626
- if not pipe:
627
- return None, "Failed to load SD."
628
- progress(0.5, desc="Generating...")
629
- with torch.no_grad():
630
- img = pipe(prompt=prompt, num_inference_steps=4, guidance_scale=0.0, width=512, height=768).images[0]
631
- img.save(WORK_DIR / "latest_poster.png")
632
- progress(1.0)
633
- return img, f"**Prompt:** {prompt}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
 
 
635
 
636
- def poster_flow(desc, add_to_site):
637
- global current_html
638
- img, info = generate_poster(desc)
639
- if add_to_site and img and current_html:
640
- buf = io.BytesIO()
641
- img.save(buf, format='PNG')
642
- current_site_info["poster_base64"] = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
643
- rebuild_html()
644
- info += "\n\nβœ… Added to banner!"
645
- return img, info, preview(current_html) if current_html else ""
646
 
 
 
 
 
 
647
 
648
  # ============================================================
649
  # HELPERS
@@ -656,30 +1131,18 @@ def preview(html):
656
  return '<div style="padding:40px;text-align:center;color:#999;">No website yet. Create one first.</div>'
657
  return f'<div style="max-width:480px;margin:0 auto;height:700px;overflow-y:auto;border:1px solid #ddd;border-radius:12px;"><iframe srcdoc="{esc(html)}" style="width:100%;height:100%;border:none;"></iframe></div>'
658
 
659
-
660
- # ============================================================
661
- # HANDLERS
662
- # ============================================================
663
- def create_website(shop_name, desc, phone, addr, hours, style, menu_zip, progress=gr.Progress()):
664
- global current_menu_data, current_site_info, current_template_key
665
-
666
- progress(0.1, desc="Processing menu...")
667
- current_menu_data = process_menu_zip(menu_zip)
668
- current_site_info = {
669
- "shop_name": shop_name or "My Shop",
670
- "description": desc or "Welcome to our shop!",
671
- "phone": phone or "", "address": addr or "", "hours": hours or "",
672
- "promo_title": "Coming Soon", "promo_subtitle": "Stay tuned for updates",
673
- "custom_css": "", "poster_base64": "", "block_overrides": {},
674
- }
675
- current_template_key = {"Warm & Cozy": "warm", "Dark & Elegant": "dark"}.get(style, "warm")
676
-
677
- progress(0.5, desc="Building...")
678
- rebuild_html()
679
- n = sum(len(v) for v in current_menu_data.values())
680
- progress(1.0)
681
- return preview(current_html), f"βœ… Done! {n} items, {len(current_menu_data)} categories."
682
-
683
 
684
  def download():
685
  if not current_html:
@@ -688,7 +1151,6 @@ def download():
688
  p.write_text(current_html, encoding='utf-8')
689
  return str(p)
690
 
691
-
692
  # ============================================================
693
  # UI
694
  # ============================================================
@@ -701,101 +1163,180 @@ def build_app():
701
  with gr.Row():
702
  with gr.Column(scale=1):
703
  gr.Markdown("### Business Info")
704
- shop_name = gr.Textbox(label="Shop Name", placeholder="The Cozy Bean")
705
- desc = gr.Textbox(label="Description", placeholder="A neighborhood coffee shop...", lines=3)
706
- phone = gr.Textbox(label="Phone", placeholder="+44 20 7946 0958")
707
- addr = gr.Textbox(label="Address", placeholder="42 High Street, London")
708
- hours = gr.Textbox(label="Hours", placeholder="Mon-Fri 7am-7pm")
709
- style = gr.Dropdown(label="Template", choices=["Warm & Cozy", "Dark & Elegant"], value="Warm & Cozy")
710
  gr.Markdown("### Menu ZIP\n`Category/Name_Price.png`")
711
- menu_zip = gr.File(label="Upload ZIP", file_types=[".zip"])
712
  create_btn = gr.Button("πŸš€ Generate", variant="primary", size="lg")
713
- status = gr.Markdown("")
714
  with gr.Column(scale=1):
715
  gr.Markdown("### πŸ“± Preview")
716
  create_prev = gr.HTML()
717
- create_btn.click(create_website, [shop_name, desc, phone, addr, hours, style, menu_zip], [create_prev, status])
718
 
719
  # ---- Tab 2: Edit ----
720
  with gr.Tab("πŸ“ Edit Website"):
721
  with gr.Row():
722
  with gr.Column(scale=1):
723
- gr.Markdown("### Select a section to edit")
724
-
725
- block_choice = gr.Dropdown(
726
- label="Section",
727
- choices=list(BLOCKS.keys()),
728
- value="Hero Banner",
729
- )
730
-
731
- # Dynamic hint based on selection
732
- block_hint = gr.Markdown("*Hero Banner: Shop name, tagline, background decorations*")
733
-
734
- def update_hint(choice):
735
- b = BLOCKS[choice]
736
- mode_label = "πŸ€– AI Coder" if b["mode"] == "coder" else "⚑ Instant"
737
- examples = {
738
- "Hero Banner": "e.g., *Make the tagline more inviting* Β· *Add a warm welcome message* Β· *Make it feel luxurious*",
739
- "Promo / Event": "e.g., *Add text: Grand Opening Feb 28* Β· *Make it feel festive and exciting*",
740
- "About / Story": "e.g., *Write a cozy brand story about our 10-year journey* Β· *Make it shorter and punchier*",
741
- "Contact Info": "e.g., *Change phone to 555-1234* Β· *Update hours to Mon-Sat 8am-9pm*",
742
- "Menu Items": "e.g., *Add Iced Mocha $6 in Coffee* Β· *Remove Espresso* Β· *Change Latte to $5.50*",
743
- "Style / Colors": "e.g., *Change primary color to forest green* Β· *Use Georgia for headings* Β· *Make background darker*",
744
- }
745
- return f"**{mode_label}** β€” {b['desc']}\n\n{examples.get(choice, '')}"
746
-
747
- block_choice.change(update_hint, block_choice, block_hint)
748
-
749
- chatbot = gr.Chatbot(height=300)
750
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  with gr.Row():
752
- msg = gr.Textbox(label="Instruction", placeholder="Describe what to change...", lines=2, scale=3)
753
- img = gr.Image(label="πŸ“· Photo", type="filepath", scale=1, height=100)
754
- send_btn = gr.Button("Send", variant="primary")
755
 
756
  with gr.Column(scale=1):
757
  gr.Markdown("### πŸ“± Live Preview")
758
  edit_prev = gr.HTML()
759
 
760
- send_btn.click(
761
- handle_edit, [block_choice, msg, img, chatbot], [chatbot, edit_prev]
762
- ).then(lambda: ("", None), outputs=[msg, img])
763
- msg.submit(
764
- handle_edit, [block_choice, msg, img, chatbot], [chatbot, edit_prev]
765
- ).then(lambda: ("", None), outputs=[msg, img])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
 
767
  # ---- Tab 3: Poster ----
768
  with gr.Tab("🎨 Poster"):
769
  with gr.Row():
770
  with gr.Column(scale=1):
771
  gr.Markdown("### Event / Promotion")
772
- edesc = gr.Textbox(label="Description", placeholder="Spring launch of Sakura Latte...", lines=4)
773
- add2site = gr.Checkbox(label="Add to website banner", value=True)
774
- pbtn = gr.Button("🎨 Generate Poster", variant="primary", size="lg")
775
- pinfo = gr.Markdown("")
 
 
 
 
 
 
 
 
 
776
  with gr.Column(scale=1):
777
  gr.Markdown("### Result")
778
- pimg = gr.Image(label="Poster", type="pil")
779
  gr.Markdown("### πŸ“± Preview")
780
  pprev = gr.HTML()
781
- pbtn.click(poster_flow, [edesc, add2site], [pimg, pinfo, pprev])
 
 
 
782
 
783
  # ---- Tab 4: Download ----
784
  with gr.Tab("πŸ’Ύ Download"):
785
  gr.Markdown("### Download as single HTML\nFully self-contained with embedded images.")
786
- dbtn = gr.Button("πŸ’Ύ Download", variant="primary")
787
  dfile = gr.File(label="Website file")
788
  dbtn.click(download, outputs=dfile)
789
 
790
- return app
 
 
 
 
 
 
 
 
 
 
 
 
 
791
 
 
792
 
793
  # ============================================================
794
  # MAIN
795
  # ============================================================
796
  if __name__ == "__main__":
797
  print("=" * 50)
798
- print(" ShopSite AI")
799
  print("=" * 50)
800
 
801
  if not TEMPLATE_DIR.exists():
@@ -803,19 +1344,24 @@ if __name__ == "__main__":
803
  else:
804
  print(f"\nβœ… Templates: {', '.join(t.stem for t in TEMPLATE_DIR.glob('*.html'))}")
805
 
806
- try:
807
- r = requests.get(f"{OLLAMA_BASE}/api/tags", timeout=5)
808
- models = [m["name"] for m in r.json().get("models", [])]
809
- print(f"βœ… Ollama: {', '.join(models)}")
810
- except Exception:
811
- print("❌ Ollama not connected. Run: ollama serve")
 
 
812
 
813
  if SD_AVAILABLE:
814
- print(f"βœ… SD Turbo. CUDA: {torch.cuda.is_available()}")
815
- if torch.cuda.is_available():
816
- print(f" GPU: {torch.cuda.get_device_name(0)}, {torch.cuda.get_device_properties(0).total_memory/1024**3:.1f}GB")
817
  else:
818
- print("⚠️ Poster generation disabled.")
819
 
820
  print("\nπŸš€ http://127.0.0.1:7860\n")
821
- build_app().launch(server_name="127.0.0.1", server_port=7860)
 
 
 
 
 
 
1
  """
2
  ShopSite AI - Small Business Website Generator
3
+ Block-based page builder: users compose pages from reusable blocks,
4
+ LLM (Qwen Coder) rewrites individual block HTML on natural language instruction.
5
  """
6
 
7
  import gradio as gr
8
+ from PIL import Image, ImageDraw, ImageFont
9
  import json
10
  import zipfile
11
  import os
 
13
  import io
14
  import re
15
  import shutil
16
+ import uuid
17
  import urllib.parse
18
  from pathlib import Path
19
  import spaces
20
 
21
  try:
22
  import torch
23
+ from transformers import AutoModelForCausalLM, AutoTokenizer
24
+ TORCH_AVAILABLE = True
25
  except ImportError:
26
+ TORCH_AVAILABLE = False
27
+ print("⚠️ torch/transformers not installed. LLM features disabled.")
28
+
29
+ try:
30
+ from diffusers import AutoPipelineForText2Image
31
+ SD_AVAILABLE = True and TORCH_AVAILABLE
32
+ except (ImportError, RuntimeError):
33
  SD_AVAILABLE = False
34
+ print("⚠️ diffusers not available. Poster generation disabled.")
35
 
36
  # ============================================================
37
  # CONFIG
38
  # ============================================================
39
+ QWEN_MODEL = "Qwen/Qwen2.5-7B-Instruct"
40
+ QWEN_CODER_MODEL = "Qwen/Qwen2.5-Coder-14B-Instruct"
41
+ SD_MODEL_ID = "stabilityai/sd-turbo"
 
42
 
43
+ WORK_DIR = Path("./workspace"); WORK_DIR.mkdir(exist_ok=True)
 
44
  TEMPLATE_DIR = Path("./templates")
45
 
46
+ # ============================================================
47
+ # GLOBAL STATE
48
+ # ============================================================
49
+ current_html = ""
50
+ current_menu_data = {}
51
+ current_site_info = {}
52
  current_template_key = "warm"
53
+ page_blocks = [] # [{"id": str, "type": str, "html": str}, ...]
54
+ sd_pipe = None
55
+
56
+ # Menu item HTML template β€” LLM can rewrite this to change structure
57
+ # Placeholders: {name}, {price}, {img_tag}
58
+ MENU_ITEM_TEMPLATE_DEFAULT = """\
59
+ <div class="menu-item" data-item-name="{name}">
60
+ {img_tag}
61
+ <div class="menu-item-info">
62
+ <div class="menu-item-name">{name}</div>
63
+ <div class="menu-item-price">{price}</div>
64
+ </div>
65
+ </div>"""
66
+ current_menu_item_template = MENU_ITEM_TEMPLATE_DEFAULT
67
+
68
+ # ============================================================
69
+ # BLOCK DEFAULTS (warm-theme HTML that works with CSS variables)
70
+ # ============================================================
71
+ BLOCK_DEFAULTS = {
72
+ "Hero Banner": """\
73
+ <div class="hero">
74
+ <div class="hero-badge">
75
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
76
+ Est. 2024
77
+ </div>
78
+ <h1>Shop Name</h1>
79
+ <p class="hero-tagline">Welcome to our shop</p>
80
+ </div>
81
+ <div class="info-pills">
82
+ <div class="pill">
83
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
84
+ <span>Open Daily</span>
85
+ </div>
86
+ <div class="pill">
87
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
88
+ <span>Visit Us</span>
89
+ </div>
90
+ </div>""",
91
+
92
+ "Promo / Event": """\
93
+ <div class="section-title">
94
+ <h2>What's New</h2>
95
+ <div class="line"></div>
96
+ </div>
97
+ <div class="promo-banner">
98
+ <div class="promo-placeholder">
99
+ <span>Coming Soon</span>
100
+ <small>Stay tuned for updates</small>
101
+ </div>
102
+ </div>""",
103
+
104
+ "About / Story": """\
105
+ <div class="section-title">
106
+ <h2>Our Story</h2>
107
+ <div class="line"></div>
108
+ </div>
109
+ <div class="about-card">
110
+ <p>Welcome to our shop. We are passionate about quality and great service. Come visit us and experience the difference.</p>
111
+ </div>""",
112
+
113
+ "Contact Info": """\
114
+ <div class="section-title">
115
+ <h2>Find Us</h2>
116
+ <div class="line"></div>
117
+ </div>
118
+ <div class="contact-section">
119
+ <a class="contact-item" href="">
120
+ <div class="contact-icon">
121
+ <svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
122
+ </div>
123
+ <div class="contact-text"><div class="label">Phone</div><div class="value">β€”</div></div>
124
+ </a>
125
+ <div class="contact-item">
126
+ <div class="contact-icon">
127
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
128
+ </div>
129
+ <div class="contact-text"><div class="label">Address</div><div class="value">β€”</div></div>
130
+ </div>
131
+ <div class="contact-item">
132
+ <div class="contact-icon">
133
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
134
+ </div>
135
+ <div class="contact-text"><div class="label">Hours</div><div class="value">β€”</div></div>
136
+ </div>
137
+ </div>
138
+ <div class="spacer-lg"></div>""",
139
+
140
+ "Announcement": """\
141
+ <div style="margin:16px 20px;">
142
+ <div style="background:var(--bg-warm,#F5EDE3);border-radius:var(--card-radius,18px);padding:20px 24px;border-left:4px solid var(--primary);">
143
+ <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-muted);font-weight:700;margin-bottom:8px;">πŸ“’ Notice</div>
144
+ <p style="font-size:15px;line-height:1.6;color:var(--text);">Add your announcement here.</p>
145
+ </div>
146
+ </div>""",
147
+
148
+ "Menu Preview": """\
149
+ <div class="section-title">
150
+ <h2>Our Menu</h2>
151
+ <div class="line"></div>
152
+ </div>
153
+ <div style="padding:0 24px 16px;text-align:center;">
154
+ <button onclick="switchPage('menu')" style="background:var(--primary);color:var(--bg);border:none;padding:12px 32px;border-radius:100px;font-size:14px;font-weight:600;cursor:pointer;letter-spacing:0.04em;">View Full Menu β†’</button>
155
+ </div>""",
156
  }
157
 
158
+ # ============================================================
159
+ # BLOCK HELPERS
160
+ # ============================================================
161
+ def _uid():
162
+ return str(uuid.uuid4())[:6]
163
+
164
+ def _block_label(block):
165
+ return f"{block['type']} [{block['id']}]"
166
+
167
+ def _find_block(label):
168
+ for b in page_blocks:
169
+ if _block_label(b) == label:
170
+ return b
171
+ return None
172
+
173
+ def get_block_choices():
174
+ return [_block_label(b) for b in page_blocks]
175
 
176
  # ============================================================
177
+ # HuggingFace LOCAL INFERENCE
178
  # ============================================================
179
+ _hf_models = {}
180
+
181
+ def load_hf_model(model_id):
182
+ if model_id not in _hf_models:
183
+ print(f"Loading {model_id}...")
184
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
185
+ model = AutoModelForCausalLM.from_pretrained(
186
+ model_id,
187
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
188
+ device_map="auto",
189
  )
190
+ _hf_models[model_id] = (tokenizer, model)
191
+ print(f"βœ… {model_id} loaded.")
192
+ return _hf_models[model_id]
193
+
194
+ def _ollama_chat(model, system_prompt, user_message, temperature=0.3):
195
+ """Inner implementation β€” safe to call from within a @spaces.GPU context."""
196
+ if not TORCH_AVAILABLE:
197
+ return "ERROR: torch/transformers not installed."
198
+ try:
199
+ tokenizer, hf_model = load_hf_model(model)
200
+ messages = [
201
+ {"role": "system", "content": system_prompt},
202
+ {"role": "user", "content": user_message},
203
+ ]
204
+ text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
205
+ inputs = tokenizer(text, return_tensors="pt").to(hf_model.device)
206
+ max_new_tokens = 1024 if model == QWEN_CODER_MODEL else 512
207
+ with torch.no_grad():
208
+ outputs = hf_model.generate(
209
+ **inputs,
210
+ max_new_tokens=max_new_tokens,
211
+ temperature=temperature if temperature > 0 else None,
212
+ do_sample=temperature > 0,
213
+ pad_token_id=tokenizer.eos_token_id,
214
+ )
215
+ return tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
216
  except Exception as e:
217
  return f"ERROR: {e}"
218
 
219
+ @spaces.GPU
220
+ def ollama_chat(model, system_prompt, user_message, temperature=0.3):
221
+ return _ollama_chat(model, system_prompt, user_message, temperature)
222
 
223
  def parse_json_from_response(text):
224
  m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
 
235
  pass
236
  return {}
237
 
 
238
  # ============================================================
239
  # MENU ZIP
240
  # ============================================================
 
242
  menu = {}
243
  if zip_file is None:
244
  return menu
 
245
  extract_dir = WORK_DIR / "menu_images"
246
  if extract_dir.exists():
247
  shutil.rmtree(extract_dir)
248
  extract_dir.mkdir(parents=True)
 
249
  with zipfile.ZipFile(zip_file, 'r') as zf:
250
  zf.extractall(extract_dir)
 
251
  for root, dirs, files in os.walk(extract_dir):
252
  rel = Path(root).relative_to(extract_dir)
253
  if str(rel).startswith(('__', '.')):
 
257
  continue
258
  if not fname.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
259
  continue
 
260
  fpath = Path(root) / fname
261
  parts = fpath.relative_to(extract_dir).parts
262
  category = parts[-2] if len(parts) >= 2 else "Menu"
 
263
  stem = fpath.stem
264
  last_us = stem.rfind('_')
265
  if last_us > 0:
 
267
  try:
268
  price = float(stem[last_us + 1:].strip())
269
  except ValueError:
270
+ name_part = stem.replace('_', ' ').strip(); price = 0.0
 
271
  else:
272
+ name_part = stem.replace('_', ' ').strip(); price = 0.0
 
 
273
  with open(fpath, 'rb') as f:
274
  img_bytes = f.read()
275
  img_b64 = base64.b64encode(img_bytes).decode('utf-8')
276
+ ext = fpath.suffix.lower()
277
  mime = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'}
 
278
  if category not in menu:
279
  menu[category] = []
280
  menu[category].append({
281
+ "name": name_part, "price": price,
282
+ "image_base64": f"data:{mime.get(ext,'image/png')};base64,{img_b64}",
 
283
  })
284
  return menu
285
 
 
286
  # ============================================================
287
+ # MENU HTML BUILDERS
288
  # ============================================================
289
  def build_category_tabs(menu_data):
290
  return "\n ".join(
 
292
  for cat in menu_data.keys()
293
  )
294
 
 
295
  def build_menu_html(menu_data):
296
  html = ""
297
  for cat, items in menu_data.items():
 
299
  html += f' <div class="menu-category-title">{cat}</div>\n'
300
  for item in items:
301
  price_str = f"${item['price']:.2f}" if item['price'] > 0 else ""
302
+ img_src = item.get('image_base64', '')
303
+ img_tag = (f'<img class="menu-item-img" src="{img_src}" alt="{item["name"]}" loading="lazy">'
304
+ if img_src else
305
+ '<div class="menu-item-img" style="background:linear-gradient(135deg,var(--secondary),var(--primary));opacity:0.3;"></div>')
306
+ html += (current_menu_item_template
307
+ .replace("{name}", item["name"])
308
+ .replace("{price}", price_str)
309
+ .replace("{img_tag}", img_tag)
310
+ .replace("{img_src}", img_src)
311
+ .replace("{description}", item.get("description", "")) + "\n")
 
 
312
  html += ' </div>\n'
313
  return html
314
 
315
+ # ============================================================
316
+ # TEMPLATE ENGINE
317
+ # ============================================================
318
  def load_template(key):
319
  path = TEMPLATE_DIR / f"{key}.html"
320
  if not path.exists():
321
  path = TEMPLATE_DIR / "warm.html"
 
 
322
  return path.read_text(encoding='utf-8')
323
 
 
324
  def rebuild_html():
 
325
  global current_html
326
+ template = load_template(current_template_key)
327
+ home_html = "\n".join(b["html"] for b in page_blocks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
  html = template
330
+ html = html.replace("<!-- {{HOME_BLOCKS}} -->", home_html)
 
 
331
  html = html.replace("<!-- {{CATEGORY_TABS}} -->", build_category_tabs(current_menu_data))
332
+ html = html.replace("<!-- {{MENU_ITEMS}} -->", build_menu_html(current_menu_data))
333
+ html = html.replace("{{SHOP_NAME}}", current_site_info.get("shop_name", "My Shop"))
334
+
335
+ # Poster carousel injection (into any .promo-placeholder found in blocks)
336
+ posters = current_site_info.get("posters", [])
337
+ if posters:
338
+ if len(posters) == 1:
339
+ carousel_html = f'<img src="{posters[0]}" alt="Poster" style="width:100%;height:auto;display:block;">'
340
+ else:
341
+ slides = "\n".join(
342
+ f'<div class="ps-slide{" ps-active" if i==0 else ""}"><img src="{p}" alt="Poster {i+1}" style="width:100%;height:auto;display:block;"></div>'
343
+ for i, p in enumerate(posters)
344
+ )
345
+ dots = "\n".join(
346
+ f'<span class="ps-dot{" ps-dot-on" if i==0 else ""}" onclick="psGo({i})"></span>'
347
+ for i in range(len(posters))
348
+ )
349
+ carousel_html = (
350
+ f'<div class="ps-wrap">{slides}'
351
+ f'<button class="ps-btn ps-l" onclick="psMove(-1)">&#10094;</button>'
352
+ f'<button class="ps-btn ps-r" onclick="psMove(1)">&#10095;</button>'
353
+ f'<div class="ps-dots">{dots}</div></div>'
354
+ )
355
+ pat = r'<div class="promo-placeholder"[^>]*>.*?</div>'
356
+ html = re.sub(pat, carousel_html, html, flags=re.DOTALL, count=1)
357
+ carousel_css = (
358
+ ".ps-wrap{position:relative;overflow:hidden;border-radius:var(--card-radius,12px);}"
359
+ ".ps-slide{display:none;}.ps-slide.ps-active{display:block;}"
360
+ ".ps-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.45);"
361
+ "color:#fff;border:none;padding:10px 14px;font-size:18px;cursor:pointer;z-index:10;border-radius:6px;}"
362
+ ".ps-l{left:8px;}.ps-r{right:8px;}"
363
+ ".ps-dots{position:absolute;bottom:10px;width:100%;text-align:center;}"
364
+ ".ps-dot{display:inline-block;width:8px;height:8px;background:rgba(255,255,255,0.5);"
365
+ "border-radius:50%;margin:0 3px;cursor:pointer;}"
366
+ ".ps-dot.ps-dot-on{background:#fff;}"
367
+ )
368
+ carousel_js = (
369
+ "<script>(function(){var idx=0;"
370
+ "function show(n){var s=document.querySelectorAll('.ps-slide');"
371
+ "var d=document.querySelectorAll('.ps-dot');if(!s.length)return;"
372
+ "idx=(n+s.length)%s.length;"
373
+ "s.forEach(function(e){e.classList.remove('ps-active');});"
374
+ "d.forEach(function(e){e.classList.remove('ps-dot-on');});"
375
+ "s[idx].classList.add('ps-active');"
376
+ "if(d[idx])d[idx].classList.add('ps-dot-on');}"
377
+ "window.psMove=function(d){show(idx+d);};"
378
+ "window.psGo=function(n){show(n);};"
379
+ "})();</script>"
380
+ )
381
+ html = html.replace("</style>", f"\n/* Carousel */\n{carousel_css}\n</style>", 1)
382
+ html = html.replace("</body>", f"\n{carousel_js}\n</body>", 1)
383
 
384
+ # Custom CSS overrides
385
+ css = current_site_info.get("custom_css", "")
386
  if css:
387
+ html = html.replace("</style>", f"\n/* Custom */\n{css}\n</style>", 1)
 
 
 
 
 
 
 
 
 
 
388
 
389
  current_html = html
390
  return html
391
 
392
+ # ============================================================
393
+ # BLOCK MANAGEMENT HANDLERS
394
+ # ============================================================
395
+ def _make_menu_preview_html():
396
+ if not current_menu_data:
397
+ return BLOCK_DEFAULTS["Menu Preview"]
398
+ preview_items = []
399
+ for items in current_menu_data.values():
400
+ for item in items:
401
+ preview_items.append(item)
402
+ if len(preview_items) >= 3:
403
+ break
404
+ if len(preview_items) >= 3:
405
+ break
406
+ rows = ""
407
+ for item in preview_items:
408
+ price_str = f"${item['price']:.2f}" if item.get('price', 0) > 0 else ""
409
+ img_src = item.get('image_base64', '')
410
+ img_html = (f'<img src="{img_src}" alt="{item["name"]}" style="width:56px;height:56px;border-radius:10px;object-fit:cover;flex-shrink:0;">'
411
+ if img_src else
412
+ '<div style="width:56px;height:56px;border-radius:10px;background:linear-gradient(135deg,var(--secondary),var(--primary));opacity:0.4;flex-shrink:0;"></div>')
413
+ rows += f""" <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);">
414
+ {img_html}
415
+ <div>
416
+ <div style="font-size:13px;font-weight:600;color:var(--text);">{item['name']}</div>
417
+ <div style="font-size:13px;color:var(--primary);font-weight:600;">{price_str}</div>
418
+ </div>
419
+ </div>\n"""
420
+ return f"""\
421
+ <div class="section-title">
422
+ <h2>Our Menu</h2>
423
+ <div class="line"></div>
424
+ </div>
425
+ <div style="margin:0 24px;padding:0 20px;background:var(--bg-card);border-radius:var(--card-radius);border:1px solid var(--border);">
426
+ {rows} <div style="padding:14px 0;text-align:center;">
427
+ <button onclick="switchPage('menu')" style="background:var(--primary);color:var(--bg);border:none;padding:10px 28px;border-radius:100px;font-size:13px;font-weight:600;cursor:pointer;">View Full Menu β†’</button>
428
+ </div>
429
+ </div>"""
430
+
431
+ def handle_add_block(block_type, selected_label):
432
+ if block_type == "Menu Preview":
433
+ initial_html = _make_menu_preview_html()
434
+ else:
435
+ initial_html = BLOCK_DEFAULTS.get(block_type, "")
436
+ new_block = {"id": _uid(), "type": block_type, "html": initial_html}
437
+ if selected_label and _find_block(selected_label):
438
+ idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1)
439
+ page_blocks.insert(idx + 1, new_block)
440
+ else:
441
+ page_blocks.append(new_block)
442
+ rebuild_html()
443
+ choices = get_block_choices()
444
+ new_label = _block_label(new_block)
445
+ return preview(current_html), gr.update(choices=choices, value=new_label)
446
+
447
+ def handle_remove_block(selected_label):
448
+ global page_blocks
449
+ if not selected_label:
450
+ return preview(current_html), gr.update()
451
+ page_blocks = [b for b in page_blocks if _block_label(b) != selected_label]
452
+ rebuild_html()
453
+ choices = get_block_choices()
454
+ return preview(current_html), gr.update(choices=choices, value=choices[0] if choices else None)
455
+
456
+ def handle_move_up(selected_label):
457
+ idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1)
458
+ if idx > 0:
459
+ page_blocks[idx - 1], page_blocks[idx] = page_blocks[idx], page_blocks[idx - 1]
460
+ rebuild_html()
461
+ return preview(current_html), gr.update(choices=get_block_choices(), value=selected_label)
462
+
463
+ def handle_move_down(selected_label):
464
+ idx = next((i for i, b in enumerate(page_blocks) if _block_label(b) == selected_label), -1)
465
+ if 0 <= idx < len(page_blocks) - 1:
466
+ page_blocks[idx], page_blocks[idx + 1] = page_blocks[idx + 1], page_blocks[idx]
467
+ rebuild_html()
468
+ return preview(current_html), gr.update(choices=get_block_choices(), value=selected_label)
469
 
470
  # ============================================================
471
+ # LLM BLOCK EDITOR
472
  # ============================================================
473
+ CODER_SYSTEM = """You are a frontend developer editing a mobile website HTML block.
474
+
475
+ RULES:
476
+ - Output ONLY the modified HTML. No explanation, no markdown fences, no ```html.
477
+ - Keep the mobile-friendly layout and existing CSS classes.
478
+ - Only change what the instruction asks.
479
+ - Do NOT output <html>, <head>, <body>, or <style> tags β€” only the inner block content.
480
+ - You may add inline styles for visual adjustments.
481
+ - Preserve existing CSS class names and structure unless explicitly asked to change them."""
482
+
483
+ def edit_block_with_llm(selected_label, instruction, chat_history):
484
+ chat_history = chat_history or []
485
+
486
+ if not current_html:
487
+ chat_history.append((None, "⚠️ Generate a website first in the Create tab."))
488
+ return chat_history, preview(current_html)
489
 
490
+ if not selected_label:
491
+ chat_history.append((None, "⚠️ Select a block from the list first."))
492
+ return chat_history, preview(current_html)
493
 
494
+ block = _find_block(selected_label)
495
+ if not block:
496
+ chat_history.append((None, "❌ Block not found."))
497
+ return chat_history, preview(current_html)
 
 
 
 
 
 
498
 
499
+ user_msg = f"[{block['type']}] {instruction}"
500
+ prompt = f"""Current HTML block:
501
+ {block['html']}
502
+
503
+ Instruction: {instruction}
504
+
505
+ Output the modified HTML block:"""
506
+
507
+ raw = ollama_chat(QWEN_CODER_MODEL, CODER_SYSTEM, prompt, temperature=0.3)
508
+
509
+ if raw.startswith("ERROR:"):
510
+ chat_history.append((user_msg, f"❌ {raw}"))
511
+ return chat_history, preview(current_html)
512
+
513
+ new_html = raw.strip()
514
+ new_html = re.sub(r'^```html?\s*\n?', '', new_html)
515
+ new_html = re.sub(r'\n?```\s*$', '', new_html)
516
+
517
+ if '<!DOCTYPE' in new_html or '<html' in new_html.lower():
518
+ chat_history.append((user_msg, "❌ Model returned full page. Try a simpler instruction."))
519
+ return chat_history, preview(current_html)
520
+
521
+ block["html"] = new_html
522
+ rebuild_html()
523
+ chat_history.append((user_msg, f"βœ… {block['type']} updated!"))
524
+ return chat_history, preview(current_html)
525
 
526
  # ============================================================
527
+ # CUSTOM BLOCK GENERATOR
528
  # ============================================================
529
+ CUSTOM_BLOCK_SYSTEM = """You are generating a new HTML block for a mobile-first restaurant/shop website.
530
+ Generate a single self-contained HTML block based on the user's description.
531
+
532
+ Available CSS variables (already in the page):
533
+ --primary, --secondary, --accent
534
+ --bg, --bg-card, --bg-elevated
535
+ --text, --text-light, --text-muted
536
+ --border, --card-radius, --card-shadow
537
+
538
+ Available CSS classes:
539
+ .section-title h2 section header with decorative line
540
+ .about-card padded content card
541
+ .contact-section list container
542
+ .contact-item row with icon + text
543
+ .pill small rounded badge/tag
544
+ .spacer-lg bottom spacer
545
 
546
+ RULES:
547
+ - Output ONLY the HTML. No explanation, no markdown fences, no ```html.
548
+ - Mobile-friendly layout (max ~480px wide).
549
+ - Use CSS variables for all colors so it works with light and dark themes.
550
+ - Do NOT output <html>, <head>, <body>, or <style> tags."""
551
 
552
+ def generate_custom_block(description, chat_history):
553
+ chat_history = chat_history or []
554
+ if not current_html:
555
+ chat_history.append((None, "⚠️ Generate a website first."))
556
+ return chat_history, preview(current_html), gr.update()
557
 
558
+ user_msg = f"[Custom Block] {description}"
559
+ raw = ollama_chat(QWEN_CODER_MODEL, CUSTOM_BLOCK_SYSTEM,
560
+ f"Generate an HTML block for: {description}", temperature=0.4)
561
 
562
+ if raw.startswith("ERROR:"):
563
+ chat_history.append((user_msg, f"❌ {raw}"))
564
+ return chat_history, preview(current_html), gr.update()
565
 
566
+ new_html = raw.strip()
567
+ new_html = re.sub(r'^```html?\s*\n?', '', new_html)
568
+ new_html = re.sub(r'\n?```\s*$', '', new_html)
569
 
570
+ if '<!DOCTYPE' in new_html or '<html' in new_html.lower():
571
+ chat_history.append((user_msg, "❌ Model returned full page. Try a simpler description."))
572
+ return chat_history, preview(current_html), gr.update()
 
 
573
 
574
+ new_block = {"id": _uid(), "type": "Custom", "html": new_html}
575
+ page_blocks.append(new_block)
576
+ rebuild_html()
577
+ choices = get_block_choices()
578
+ new_label = _block_label(new_block)
579
+ chat_history.append((user_msg, "βœ… Custom block added! Select it to edit further."))
580
+ return chat_history, preview(current_html), gr.update(choices=choices, value=new_label)
581
 
582
+ # ============================================================
583
+ # STYLE EDITOR (CSS variable injection, no block needed)
584
+ # ============================================================
585
  PARSE_SYSTEM_STYLE = """Parse the user's instruction about visual style into JSON.
586
 
587
+ Output format: {"actions": [{"prop": "...", "value": "..."}]}
 
588
 
589
  Available props:
590
  - primary, secondary, accent, bg, text (CSS color values like #006400)
 
593
 
594
  Examples:
595
  - "Change primary color to forest green" β†’ {"actions":[{"prop":"primary","value":"#228B22"}]}
596
+ - "Dark theme, black background" β†’ {"actions":[{"prop":"bg","value":"#1a1a1a"},{"prop":"text","value":"#f0f0f0"}]}
597
  - "Use Georgia for headings" β†’ {"actions":[{"prop":"font_heading","value":"Georgia, serif"}]}
598
 
599
  Output ONLY JSON."""
600
 
601
+ def handle_style_edit(instruction, chat_history):
602
+ chat_history = chat_history or []
603
+ user_msg = f"[Style] {instruction}"
604
 
605
+ raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_STYLE, instruction)
606
+ if raw.startswith("ERROR:"):
607
+ chat_history.append((user_msg, f"❌ {raw}"))
608
+ return chat_history, preview(current_html)
 
 
 
 
609
 
610
+ parsed = parse_json_from_response(raw)
611
+ actions = parsed.get("actions", [])
612
+ if not actions:
613
+ chat_history.append((user_msg, f"❌ Could not parse. Raw: {raw[:150]}"))
614
+ return chat_history, preview(current_html)
615
 
616
+ css_var_map = {
617
+ "primary": "--primary", "secondary": "--secondary",
618
+ "accent": "--accent", "bg": "--bg", "text": "--text",
619
+ "card_radius": "--card-radius",
620
+ }
621
+ lines, messages = [], []
622
  for a in actions:
623
+ prop, value = a.get("prop", ""), a.get("value", "")
624
+ if prop == "font_heading":
625
+ lines.append(f".hero h1, .section-title h2, .menu-header h1, .menu-category-title {{ font-family: {value} !important; }}")
626
+ messages.append(f"Changed font_heading")
627
+ elif prop == "font_body":
628
+ lines.append(f"body {{ font-family: {value} !important; }}")
629
+ messages.append(f"Changed font_body")
630
+ elif prop in css_var_map:
631
+ lines.append(f":root {{ {css_var_map[prop]}: {value}; }}")
632
+ messages.append(f"Changed {prop} β†’ {value}")
633
  else:
634
+ messages.append(f"Unknown prop: {prop}")
635
+
636
+ if lines:
637
+ current_site_info["custom_css"] = current_site_info.get("custom_css", "") + "\n" + "\n".join(lines)
638
  rebuild_html()
639
+ chat_history.append((user_msg, "🎨 " + " Β· ".join(messages) + "\n\nβœ… Done!"))
640
+ return chat_history, preview(current_html)
641
+
642
+ # ============================================================
643
+ # CREATE WEBSITE
644
+ # ============================================================
645
+ def _make_hero_html(shop_name, desc, hours, addr):
646
+ location_short = addr.split(",")[0][:25] if "," in addr else addr[:25]
647
+ return f"""\
648
+ <div class="hero">
649
+ <div class="hero-badge">
650
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
651
+ Est. 2024
652
+ </div>
653
+ <h1>{shop_name}</h1>
654
+ <p class="hero-tagline">{desc[:80]}</p>
655
+ </div>
656
+ <div class="info-pills">
657
+ <div class="pill">
658
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
659
+ <span>{hours or 'Open Daily'}</span>
660
+ </div>
661
+ <div class="pill">
662
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
663
+ <span>{location_short or 'Visit Us'}</span>
664
+ </div>
665
+ </div>"""
666
+
667
+ def _make_about_html(desc):
668
+ return f"""\
669
+ <div class="section-title">
670
+ <h2>Our Story</h2>
671
+ <div class="line"></div>
672
+ </div>
673
+ <div class="about-card">
674
+ <p>{desc}</p>
675
+ </div>"""
676
+
677
+ def _make_contact_html(phone, addr, hours):
678
+ addr_enc = urllib.parse.quote_plus(addr)
679
+ return f"""\
680
+ <div class="section-title">
681
+ <h2>Find Us</h2>
682
+ <div class="line"></div>
683
+ </div>
684
+ <div class="contact-section">
685
+ <a class="contact-item" href="tel:{phone}">
686
+ <div class="contact-icon">
687
+ <svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
688
+ </div>
689
+ <div class="contact-text"><div class="label">Phone</div><div class="value">{phone or 'β€”'}</div></div>
690
+ </a>
691
+ <a class="contact-item" href="https://maps.google.com/?q={addr_enc}" target="_blank">
692
+ <div class="contact-icon">
693
+ <svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
694
+ </div>
695
+ <div class="contact-text"><div class="label">Address</div><div class="value">{addr or 'β€”'}</div></div>
696
+ </a>
697
+ <div class="contact-item">
698
+ <div class="contact-icon">
699
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
700
+ </div>
701
+ <div class="contact-text"><div class="label">Hours</div><div class="value">{hours or 'β€”'}</div></div>
702
+ </div>
703
+ </div>
704
+ <div class="spacer-lg"></div>"""
705
+
706
+ def create_website(shop_name, desc, phone, addr, hours, style, menu_zip, progress=gr.Progress()):
707
+ global page_blocks, current_menu_data, current_site_info, current_template_key, current_menu_item_template
708
+ current_menu_item_template = MENU_ITEM_TEMPLATE_DEFAULT # reset on each new site
709
+
710
+ progress(0.1, desc="Processing menu...")
711
+ current_menu_data = process_menu_zip(menu_zip)
712
+ current_site_info = {
713
+ "shop_name": shop_name or "My Shop",
714
+ "description": desc or "Welcome to our shop!",
715
+ "phone": phone or "", "address": addr or "", "hours": hours or "",
716
+ "custom_css": "", "posters": [], "poster_base64": "",
717
+ }
718
+ current_template_key = {"Warm & Cozy": "warm", "Dark & Elegant": "dark"}.get(style, "warm")
719
 
720
+ progress(0.4, desc="Building blocks...")
721
+ page_blocks = [
722
+ {"id": _uid(), "type": "Hero Banner", "html": _make_hero_html(shop_name or "My Shop", desc or "Welcome!", hours or "", addr or "")},
723
+ {"id": _uid(), "type": "Promo / Event", "html": BLOCK_DEFAULTS["Promo / Event"]},
724
+ {"id": _uid(), "type": "About / Story", "html": _make_about_html(desc or "Welcome to our shop!")},
725
+ {"id": _uid(), "type": "Contact Info", "html": _make_contact_html(phone or "", addr or "", hours or "")},
726
+ ]
727
+
728
+ progress(0.8, desc="Rendering...")
729
+ rebuild_html()
730
+
731
+ n_items = sum(len(v) for v in current_menu_data.values())
732
+ n_cats = len(current_menu_data)
733
+ progress(1.0)
734
+ print(f"[create_website] page_blocks={len(page_blocks)}, html_len={len(current_html)}")
735
+ return preview(current_html), f"βœ… {n_items} items Β· {n_cats} categories"
736
+
737
+ # ============================================================
738
+ # MENU ITEM DATA EDITOR (add / remove / change price)
739
+ # ============================================================
740
+ PARSE_SYSTEM_MENU = """Parse the user's instruction about menu items into JSON.
741
+
742
+ Output format:
743
+ {"actions": [{"op": "add|remove|change_price", "name": "...", "price": 0.0, "category": "..."}]}
744
+
745
+ Examples:
746
+ - "Add Iced Mocha $6 in Coffee" β†’ {"actions":[{"op":"add","name":"Iced Mocha","price":6.00,"category":"Coffee"}]}
747
+ - "Remove Espresso" β†’ {"actions":[{"op":"remove","name":"Espresso"}]}
748
+ - "Change Latte price to $5.50" β†’ {"actions":[{"op":"change_price","name":"Latte","price":5.50}]}
749
+
750
+ Output ONLY JSON."""
751
+
752
+ def handle_menu_data_edit(instruction, uploaded_image, chat_history):
753
+ chat_history = chat_history or []
754
+ if not current_html:
755
+ chat_history.append((None, "⚠️ Generate a website first."))
756
+ return chat_history, preview(current_html)
757
+
758
+ user_msg = f"[Menu Items] {instruction}"
759
+ raw = ollama_chat(QWEN_MODEL, PARSE_SYSTEM_MENU, instruction)
760
+ if raw.startswith("ERROR:"):
761
+ chat_history.append((user_msg, f"❌ {raw}"))
762
+ return chat_history, preview(current_html)
763
+
764
+ parsed = parse_json_from_response(raw)
765
+ actions = parsed.get("actions", [])
766
+ if not actions:
767
+ chat_history.append((user_msg, f"❌ Could not parse. Raw: {raw[:150]}"))
768
+ return chat_history, preview(current_html)
769
+
770
+ # Handle optional uploaded image
771
+ img_b64 = None
772
+ if uploaded_image:
773
+ try:
774
+ with open(uploaded_image, 'rb') as f:
775
+ img_b64 = f"data:image/png;base64,{base64.b64encode(f.read()).decode()}"
776
+ except Exception:
777
+ pass
778
 
 
 
779
  messages = []
780
  for a in actions:
781
+ op = a.get("op", "")
782
  name = a.get("name", "")
783
 
784
  if op == "add":
785
+ cat = a.get("category", "Menu")
786
  price = a.get("price", 0.0)
787
  if cat not in current_menu_data:
788
  current_menu_data[cat] = []
789
+ current_menu_data[cat].append({"name": name, "price": price, "image_base64": img_b64 or ""})
 
790
  messages.append(f"Added {name} (${price:.2f}) to {cat}")
791
 
792
  elif op == "remove":
 
794
  for cat, items in current_menu_data.items():
795
  for i, item in enumerate(items):
796
  if item["name"].lower() == name.lower():
797
+ items.pop(i); messages.append(f"Removed {name}"); found = True; break
798
+ if found: break
 
 
 
 
799
  if not found:
800
+ messages.append(f"'{name}' not found")
801
 
802
  elif op == "change_price":
803
  price = a.get("price", 0.0)
 
805
  for cat, items in current_menu_data.items():
806
  for item in items:
807
  if item["name"].lower() == name.lower():
808
+ item["price"] = price; messages.append(f"{name} β†’ ${price:.2f}"); found = True; break
809
+ if found: break
 
 
 
 
810
  if not found:
811
+ messages.append(f"'{name}' not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
 
813
  rebuild_html()
814
+ extra = " (with photo)" if img_b64 else ""
815
+ chat_history.append((user_msg, "πŸ”§ " + " Β· ".join(messages) + extra + "\n\nβœ… Done!"))
816
+ return chat_history, preview(current_html)
817
 
818
  # ============================================================
819
+ # MENU STRUCTURE EDITOR
820
  # ============================================================
821
+ MENU_STRUCTURE_SYSTEM = """You are editing the HTML template for a single menu item card on a mobile website.
822
+
823
+ Available placeholders:
824
+ {name} β€” item name text [REQUIRED β€” must keep]
825
+ {price} β€” item price text e.g. "$4.50" [REQUIRED β€” must keep]
826
+ {img_tag} β€” full <img> element with class="menu-item-img" (76Γ—76 px square)
827
+ {img_src} β€” raw image URL/base64 string β€” MUST be used as: <img src="{img_src}" ...> NEVER put {img_src} as text content inside a div or span
828
+ {description} β€” per-item text description string (may be empty) [use ONLY when adding a prose text description field]
829
+
830
+ IMAGE RULES (CRITICAL):
831
+ - {img_src} is a raw URL string. You MUST embed it like this: <img src="{img_src}" style="..." alt="{name}">
832
+ - NEVER write just {img_src} alone as element content β€” it will render as raw text on the page
833
+ - When changing image layout/size, use {img_src} with a custom <img> tag and omit {img_tag}
834
+
835
+ MULTI-COLUMN LAYOUT RULES:
836
+ - To make items display in 2 columns, add a <style> tag at the TOP of the template:
837
+ <style>.menu-category { display:grid; grid-template-columns:1fr 1fr; gap:12px; } .menu-category-title { grid-column:1/-1; }</style>
838
+ - Then make the item itself a compact vertical block (display:block, not flex row)
839
+
840
+ GENERAL RULES:
841
+ - Output ONLY the modified HTML template. No explanation, no markdown fences, no ```html.
842
+ - Keep {name}, {price}, and at least one of {img_tag} or {img_src}
843
+ - For UI decorations (stars, spice icons, badges): write them as static HTML/emoji, NOT as a placeholder
844
+ - Use CSS variables for colors (--primary, --bg-card, --text, --text-muted, --border)
845
+ - Do NOT add <html>, <head>, or <body> tags. A single <style> tag at the top is allowed for layout.
846
+ - Use inline styles freely to override any layout constraints"""
847
+
848
+ def edit_menu_structure(instruction, chat_history):
849
+ global current_menu_item_template
850
+ chat_history = chat_history or []
851
 
852
+ if not current_html:
853
+ chat_history.append((None, "⚠️ Generate a website first."))
854
+ return chat_history, preview(current_html)
855
 
856
+ user_msg = f"[Menu Structure] {instruction}"
857
+ prompt = f"""Current menu item template:
858
+ {current_menu_item_template}
859
 
860
+ Instruction: {instruction}
861
 
862
+ Output the modified template (keep {{name}}, {{price}}, {{img_tag}} placeholders):"""
863
 
864
+ raw = ollama_chat(QWEN_CODER_MODEL, MENU_STRUCTURE_SYSTEM, prompt, temperature=0.3)
865
 
866
  if raw.startswith("ERROR:"):
867
+ chat_history.append((user_msg, f"❌ {raw}"))
868
+ return chat_history, preview(current_html)
 
 
 
 
869
 
870
+ new_template = raw.strip()
871
+ new_template = re.sub(r'^```html?\s*\n?', '', new_template)
872
+ new_template = re.sub(r'\n?```\s*$', '', new_template)
873
+
874
+ # Auto-fix: {img_src} as bare text content (not inside any attribute or CSS url())
875
+ # Valid uses: src="{img_src}", url({img_src}), url('{img_src}'), url("{img_src}")
876
+ # Invalid (shows raw base64 on page): <div>{img_src}</div>
877
+ if "{img_src}" in new_template and not re.search(
878
+ r'(?:src\s*=\s*["\']?|url\s*\(\s*["\']?)\{img_src\}', new_template
879
+ ):
880
+ new_template = new_template.replace(
881
+ "{img_src}",
882
+ '<img src="{img_src}" style="width:100%;height:160px;object-fit:cover;border-radius:8px;" alt="{name}">'
883
+ )
884
 
885
+ # Validate required placeholders β€” {img_tag} can be replaced by {img_src}
886
+ missing = [p for p in ["{name}", "{price}"] if p not in new_template]
887
+ if "{img_tag}" not in new_template and "{img_src}" not in new_template:
888
+ missing.append("{img_tag} or {img_src}")
889
+ if missing:
890
+ chat_history.append((user_msg, f"❌ Model dropped placeholders: {missing}. Try again."))
891
+ return chat_history, preview_menu(current_html)
892
+
893
+ current_menu_item_template = new_template
894
+
895
+ # If {description} was added and items don't have descriptions yet, auto-generate them
896
+ if "{description}" in new_template:
897
+ items_needing_desc = [
898
+ item for items in current_menu_data.values() for item in items
899
+ if not item.get("description")
900
+ ]
901
+ if items_needing_desc:
902
+ chat_history.append((user_msg, "⏳ Generating item descriptions..."))
903
+ all_names = [item["name"] for item in items_needing_desc]
904
+ prompt = f"Generate descriptions for these menu items: {json.dumps(all_names)}"
905
+ raw2 = ollama_chat(QWEN_MODEL, DESCRIBE_SYSTEM, prompt, temperature=0.7)
906
+ parsed2 = parse_json_from_response(raw2)
907
+ desc_map = {d["name"]: d["description"] for d in parsed2.get("items", []) if "name" in d and "description" in d}
908
+ for items in current_menu_data.values():
909
+ for item in items:
910
+ if item["name"] in desc_map:
911
+ item["description"] = desc_map[item["name"]]
912
+ count = len(desc_map)
913
+ chat_history.append((None, f"βœ… Structure updated + {count} descriptions generated!"))
914
+ else:
915
+ chat_history.append((user_msg, "βœ… Menu item structure updated!"))
916
+ else:
917
+ chat_history.append((user_msg, "βœ… Menu item structure updated!"))
918
 
919
  rebuild_html()
920
+ return chat_history, preview_menu(current_html)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
 
922
+ DESCRIBE_SYSTEM = """Generate short, appetising descriptions for menu items.
923
+ Given a list of item names, output ONLY JSON:
924
+ {"items": [{"name": "...", "description": "..."}]}
925
+ Each description: 1 sentence, ≀12 words, mouth-watering and relevant to the item.
926
+ Output ONLY JSON."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
928
 
929
+ def reset_menu_structure(chat_history):
930
+ global current_menu_item_template
931
+ current_menu_item_template = MENU_ITEM_TEMPLATE_DEFAULT
932
+ rebuild_html()
933
+ chat_history = (chat_history or []) + [(None, "↩️ Menu structure reset to default.")]
934
+ return chat_history, preview_menu(current_html)
935
 
936
  # ============================================================
937
  # POSTER
 
942
  return sd_pipe
943
  if not SD_AVAILABLE:
944
  return None
 
945
  sd_pipe = AutoPipelineForText2Image.from_pretrained(
946
  SD_MODEL_ID,
947
  torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
 
952
  sd_pipe.enable_attention_slicing()
953
  return sd_pipe
954
 
955
+ PARSE_SYSTEM_POSTER = """Extract key promotional text from the user's description for a poster.
956
+ Output ONLY JSON: {"title": "...", "offer": "...", "detail": "...", "tagline": "..."}
957
+ - title: main headline (2-5 words)
958
+ - offer: the key offer (e.g. "20% OFF")
959
+ - detail: conditions or date (e.g. "Valid Mon-Fri")
960
+ - tagline: short catchy phrase
961
+ Output ONLY JSON."""
962
 
963
+ def extract_poster_info(desc):
964
+ raw = _ollama_chat(QWEN_MODEL, PARSE_SYSTEM_POSTER, desc, temperature=0.4)
965
+ if raw.startswith("ERROR:"):
966
+ return {"title": "Special Offer", "offer": "", "detail": "", "tagline": ""}
967
+ parsed = parse_json_from_response(raw)
968
+ return parsed if parsed else {"title": "Special Offer", "offer": "", "detail": "", "tagline": ""}
969
+
970
+ def gen_sd_prompt(desc, bg_style):
971
+ if bg_style == "Restaurant atmosphere":
972
+ sys = "Generate a Stable Diffusion prompt for a restaurant/cafe poster background. Show warm interior, food, bokeh. Under 40 words, no quality tags."
973
+ fallback = "warm cozy restaurant interior, wooden table, bokeh lights, appetizing food, soft warm lighting"
974
+ suffix = "no text, no words, no people, professional food photography, 4k, bokeh"
975
+ else:
976
+ sys = "Generate a Stable Diffusion prompt that visually matches the promotion. Style: vibrant, professional. Under 40 words, no quality tags."
977
+ fallback = "happy people enjoying food, warm atmosphere, vibrant colors"
978
+ suffix = "no text, no words, high quality, 4k, professional photography"
979
+ raw = _ollama_chat(QWEN_MODEL, sys, desc, temperature=0.5)
980
+ if raw.startswith("ERROR:"):
981
+ raw = fallback
982
+ return raw.strip().strip('"\'') + ", " + suffix
983
+
984
+ def compose_poster(bg_img, poster_info):
985
+ img = bg_img.copy().convert("RGBA")
986
+ w, h = img.size
987
+ overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
988
+ draw_ov = ImageDraw.Draw(overlay)
989
+ for i in range(h // 3, h):
990
+ alpha = int(210 * (i - h // 3) / (h * 2 // 3))
991
+ draw_ov.line([(0, i), (w, i)], fill=(0, 0, 0, min(alpha, 210)))
992
+ img = Image.alpha_composite(img, overlay)
993
+ draw = ImageDraw.Draw(img)
994
+
995
+ def get_font(size):
996
+ for fp in ["/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Arial.ttf",
997
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
998
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"]:
999
+ try:
1000
+ return ImageFont.truetype(fp, size=size)
1001
+ except Exception:
1002
+ continue
1003
+ return ImageFont.load_default()
1004
+
1005
+ def fit_font(text, base_size, min_size=12):
1006
+ max_w = int(w * 0.88)
1007
+ size = base_size
1008
+ while size >= min_size:
1009
+ font = get_font(size)
1010
+ bbox = draw.textbbox((0, 0), text, font=font)
1011
+ if bbox[2] - bbox[0] <= max_w:
1012
+ return font
1013
+ size = max(min_size, int(size * 0.82))
1014
+ return get_font(min_size)
1015
+
1016
+ def wrap_text(text, font, max_w):
1017
+ words, lines, cur = text.split(), [], []
1018
+ for word in words:
1019
+ test = " ".join(cur + [word])
1020
+ if draw.textbbox((0, 0), test, font=font)[2] <= max_w:
1021
+ cur.append(word)
1022
+ else:
1023
+ if cur: lines.append(" ".join(cur))
1024
+ cur = [word]
1025
+ if cur: lines.append(" ".join(cur))
1026
+ return lines or [text]
1027
+
1028
+ def draw_centered(text, font, y, color):
1029
+ if not text: return 0
1030
+ lines = wrap_text(text, font, int(w * 0.88))
1031
+ line_h = draw.textbbox((0, 0), "A", font=font)[3] + 4
1032
+ for i, line in enumerate(lines):
1033
+ tw = draw.textbbox((0, 0), line, font=font)[2]
1034
+ draw.text(((w - tw) // 2, y + i * line_h), line, font=font, fill=color)
1035
+ return line_h * len(lines)
1036
+
1037
+ y = h - int(h * 0.06)
1038
+ if poster_info.get("detail"):
1039
+ f = fit_font(poster_info["detail"], int(h * 0.040))
1040
+ draw_centered(poster_info["detail"], f, y - int(h * 0.045), (200, 200, 200, 255))
1041
+ y -= int(h * 0.07)
1042
+ if poster_info.get("offer"):
1043
+ f = fit_font(poster_info["offer"], int(h * 0.11))
1044
+ draw_centered(poster_info["offer"], f, y - int(h * 0.11), (255, 215, 50, 255))
1045
+ y -= int(h * 0.13)
1046
+ if poster_info.get("title"):
1047
+ f = fit_font(poster_info["title"], int(h * 0.065))
1048
+ draw_centered(poster_info["title"], f, y - int(h * 0.07), (255, 255, 255, 255))
1049
+ y -= int(h * 0.09)
1050
+ if poster_info.get("tagline"):
1051
+ f = fit_font(poster_info["tagline"], int(h * 0.038))
1052
+ draw_centered(poster_info["tagline"], f, y - int(h * 0.04), (160, 230, 160, 255))
1053
+ return img.convert("RGB")
1054
+
1055
+ def _poster_status():
1056
+ n = len(current_site_info.get("posters", []))
1057
+ return f"πŸ–ΌοΈ {n} poster{'s' if n != 1 else ''} in carousel" if n else "*No posters added yet*"
1058
+
1059
+ @spaces.GPU
1060
+ def _generate_poster_gpu(desc, bg_prompt, uploaded_bg, bg_style):
1061
+ """GPU-only: LLM + SD inference + compose. Returns (img, img_b64, info) or (None, None, error_msg)."""
1062
+ poster_info = extract_poster_info(desc)
1063
+ info = f"πŸ“ **Extracted:** {poster_info.get('title','')} Β· {poster_info.get('offer','')} Β· {poster_info.get('detail','')}"
1064
+
1065
+ if uploaded_bg is not None:
1066
+ try:
1067
+ bg_img = Image.open(uploaded_bg).convert("RGB").resize((512, 768), Image.LANCZOS)
1068
+ info += "\n\nπŸ–ΌοΈ Using uploaded background"
1069
+ except Exception as e:
1070
+ return None, None, f"❌ Failed to load image: {e}"
1071
+ else:
1072
+ sd_prompt = bg_prompt.strip() if bg_prompt and bg_prompt.strip() else gen_sd_prompt(desc, bg_style)
1073
+ pipe = load_sd_model()
1074
+ if not pipe:
1075
+ return None, None, "❌ Failed to load SD model."
1076
+ with torch.no_grad():
1077
+ bg_img = pipe(prompt=sd_prompt, num_inference_steps=4, guidance_scale=0.0, width=512, height=768).images[0]
1078
+ info += f"\n\n🎨 SD prompt: {sd_prompt}"
1079
+
1080
+ final_img = compose_poster(bg_img, poster_info)
1081
+ buf = io.BytesIO()
1082
+ final_img.save(buf, format='PNG')
1083
+ img_b64 = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
1084
+ return final_img, img_b64, info
1085
+
1086
+ def poster_flow(desc, bg_prompt, uploaded_bg, add_to_site, bg_style="Restaurant atmosphere"):
1087
+ """Non-GPU wrapper: calls GPU function then updates global state in main process."""
1088
+ if not SD_AVAILABLE and uploaded_bg is None:
1089
+ return None, "❌ SD not available. Upload a background image.", preview(current_html) if current_html else "", _poster_status()
1090
+
1091
+ final_img, img_b64, info = _generate_poster_gpu(desc, bg_prompt, uploaded_bg, bg_style)
1092
+
1093
+ if final_img is None:
1094
+ return None, info, preview(current_html) if current_html else "", _poster_status()
1095
+
1096
+ final_img.save(WORK_DIR / "latest_poster.png")
1097
+
1098
+ if add_to_site and current_html:
1099
+ if "posters" not in current_site_info:
1100
+ current_site_info["posters"] = []
1101
+ current_site_info["posters"].append(img_b64)
1102
+ rebuild_html()
1103
+ n = len(current_site_info["posters"])
1104
+ info += f"\n\nβœ… Added! {n} poster{'s' if n > 1 else ''} in carousel."
1105
 
1106
+ return final_img, info, preview(current_html) if current_html else "", _poster_status()
1107
 
1108
+ def remove_last_poster():
1109
+ posters = current_site_info.get("posters", [])
1110
+ if not posters:
1111
+ return "*No posters to remove*", preview(current_html)
1112
+ posters.pop()
1113
+ current_site_info["posters"] = posters
1114
+ rebuild_html()
1115
+ return _poster_status(), preview(current_html)
 
 
1116
 
1117
+ def clear_all_posters():
1118
+ current_site_info["posters"] = []
1119
+ current_site_info["poster_base64"] = ""
1120
+ rebuild_html()
1121
+ return "*No posters added yet*", preview(current_html)
1122
 
1123
  # ============================================================
1124
  # HELPERS
 
1131
  return '<div style="padding:40px;text-align:center;color:#999;">No website yet. Create one first.</div>'
1132
  return f'<div style="max-width:480px;margin:0 auto;height:700px;overflow-y:auto;border:1px solid #ddd;border-radius:12px;"><iframe srcdoc="{esc(html)}" style="width:100%;height:100%;border:none;"></iframe></div>'
1133
 
1134
+ def preview_menu(html):
1135
+ """Render preview with the Menu page active instead of Home."""
1136
+ if not html:
1137
+ return preview(html)
1138
+ html_m = html.replace(
1139
+ '<div class="page active" id="page-home">',
1140
+ '<div class="page" id="page-home">'
1141
+ ).replace(
1142
+ '<div class="page" id="page-menu">',
1143
+ '<div class="page active" id="page-menu">'
1144
+ )
1145
+ return preview(html_m)
 
 
 
 
 
 
 
 
 
 
 
 
1146
 
1147
  def download():
1148
  if not current_html:
 
1151
  p.write_text(current_html, encoding='utf-8')
1152
  return str(p)
1153
 
 
1154
  # ============================================================
1155
  # UI
1156
  # ============================================================
 
1163
  with gr.Row():
1164
  with gr.Column(scale=1):
1165
  gr.Markdown("### Business Info")
1166
+ shop_name = gr.Textbox(label="Shop Name", placeholder="The Cozy Bean")
1167
+ desc = gr.Textbox(label="Description", placeholder="A neighborhood coffee shop...", lines=3)
1168
+ phone = gr.Textbox(label="Phone", placeholder="+44 20 7946 0958")
1169
+ addr = gr.Textbox(label="Address", placeholder="42 High Street, London")
1170
+ hours = gr.Textbox(label="Hours", placeholder="Mon-Fri 7am-7pm")
1171
+ style = gr.Dropdown(label="Template", choices=["Warm & Cozy", "Dark & Elegant"], value="Warm & Cozy")
1172
  gr.Markdown("### Menu ZIP\n`Category/Name_Price.png`")
1173
+ menu_zip = gr.File(label="Upload ZIP", file_types=[".zip"])
1174
  create_btn = gr.Button("πŸš€ Generate", variant="primary", size="lg")
1175
+ status = gr.Markdown("")
1176
  with gr.Column(scale=1):
1177
  gr.Markdown("### πŸ“± Preview")
1178
  create_prev = gr.HTML()
 
1179
 
1180
  # ---- Tab 2: Edit ----
1181
  with gr.Tab("πŸ“ Edit Website"):
1182
  with gr.Row():
1183
  with gr.Column(scale=1):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184
 
1185
+ gr.Markdown("### Page Blocks")
1186
+ block_radio = gr.Radio(label="Select a block to edit", choices=[], interactive=True, value=None)
1187
+
1188
+ with gr.Row():
1189
+ move_up_btn = gr.Button("⬆️ Up", size="sm")
1190
+ move_dn_btn = gr.Button("⬇️ Down", size="sm")
1191
+ remove_btn = gr.Button("πŸ—‘οΈ Remove", size="sm")
1192
+
1193
+ with gr.Row():
1194
+ add_type = gr.Dropdown(
1195
+ choices=list(BLOCK_DEFAULTS.keys()),
1196
+ value="Hero Banner", label="New block type", scale=2
1197
+ )
1198
+ add_btn = gr.Button("βž• Add", size="sm", scale=1)
1199
+
1200
+ gr.Markdown("---")
1201
+ gr.Markdown("### ✨ Generate Custom Block")
1202
+ gr.Markdown("*Describe a new block β€” LLM generates the HTML from scratch*")
1203
+ with gr.Row():
1204
+ custom_block_msg = gr.Textbox(placeholder='e.g. "A loyalty card section with 10 stamp slots" Β· "Opening hours table"', lines=2, scale=3, label="")
1205
+ custom_block_btn = gr.Button("Generate", variant="primary", scale=1)
1206
+
1207
+ gr.Markdown("---")
1208
+ gr.Markdown("### Edit Selected Block")
1209
+ gr.Markdown("*Describe what to change β€” the LLM rewrites the block HTML*")
1210
+ chatbot = gr.Chatbot(height=280)
1211
+ with gr.Row():
1212
+ edit_msg = gr.Textbox(placeholder='e.g. "Add a description field to each menu item"', lines=2, scale=3, label="")
1213
+ edit_send = gr.Button("Send", variant="primary", scale=1)
1214
+
1215
+ gr.Markdown("### Style / Colors")
1216
+ with gr.Row():
1217
+ style_msg = gr.Textbox(placeholder='e.g. "Make primary color forest green"', scale=3, label="")
1218
+ style_send = gr.Button("Apply", scale=1)
1219
+
1220
+ gr.Markdown("### Menu Items")
1221
+ gr.Markdown("*Add, remove, or change prices of individual items*")
1222
+ with gr.Row():
1223
+ menu_data_msg = gr.Textbox(placeholder='e.g. "Add Iced Mocha $6 in Coffee" Β· "Remove Espresso" Β· "Change Latte to $5.50"', lines=2, scale=3, label="")
1224
+ menu_data_image = gr.Image(label="πŸ“· Photo", type="filepath", scale=1, height=120)
1225
+ menu_data_send = gr.Button("Send", variant="primary", scale=1)
1226
+
1227
+ gr.Markdown("### Menu Item Structure")
1228
+ gr.Markdown("*Rewrite the layout of every menu card β€” add descriptions, ratings, badges, etc.*")
1229
  with gr.Row():
1230
+ menu_struct_msg = gr.Textbox(placeholder='e.g. "Add a short description field below the item name"', lines=2, scale=3, label="")
1231
+ menu_struct_send = gr.Button("Send", variant="primary", scale=1)
1232
+ menu_reset_btn = gr.Button("↩️ Reset menu to default", size="sm")
1233
 
1234
  with gr.Column(scale=1):
1235
  gr.Markdown("### πŸ“± Live Preview")
1236
  edit_prev = gr.HTML()
1237
 
1238
+ # Custom block generation
1239
+ custom_block_btn.click(
1240
+ generate_custom_block, [custom_block_msg, chatbot], [chatbot, edit_prev, block_radio]
1241
+ ).then(lambda: "", outputs=custom_block_msg)
1242
+ custom_block_msg.submit(
1243
+ generate_custom_block, [custom_block_msg, chatbot], [chatbot, edit_prev, block_radio]
1244
+ ).then(lambda: "", outputs=custom_block_msg)
1245
+
1246
+ # Wire up block management
1247
+ add_btn.click(handle_add_block, [add_type, block_radio], [edit_prev, block_radio])
1248
+ remove_btn.click(handle_remove_block, block_radio, [edit_prev, block_radio])
1249
+ move_up_btn.click(handle_move_up, block_radio, [edit_prev, block_radio])
1250
+ move_dn_btn.click(handle_move_down, block_radio, [edit_prev, block_radio])
1251
+
1252
+ # Wire up LLM editing
1253
+ edit_send.click(
1254
+ edit_block_with_llm, [block_radio, edit_msg, chatbot], [chatbot, edit_prev]
1255
+ ).then(lambda: "", outputs=edit_msg)
1256
+ edit_msg.submit(
1257
+ edit_block_with_llm, [block_radio, edit_msg, chatbot], [chatbot, edit_prev]
1258
+ ).then(lambda: "", outputs=edit_msg)
1259
+
1260
+ # Style
1261
+ style_send.click(
1262
+ handle_style_edit, [style_msg, chatbot], [chatbot, edit_prev]
1263
+ ).then(lambda: "", outputs=style_msg)
1264
+
1265
+ # Menu item data editing
1266
+ menu_data_send.click(
1267
+ handle_menu_data_edit, [menu_data_msg, menu_data_image, chatbot], [chatbot, edit_prev]
1268
+ ).then(lambda: ("", None), outputs=[menu_data_msg, menu_data_image])
1269
+ menu_data_msg.submit(
1270
+ handle_menu_data_edit, [menu_data_msg, menu_data_image, chatbot], [chatbot, edit_prev]
1271
+ ).then(lambda: ("", None), outputs=[menu_data_msg, menu_data_image])
1272
+
1273
+ # Menu structure editing
1274
+ menu_struct_send.click(
1275
+ edit_menu_structure, [menu_struct_msg, chatbot], [chatbot, edit_prev]
1276
+ ).then(lambda: "", outputs=menu_struct_msg)
1277
+ menu_struct_msg.submit(
1278
+ edit_menu_structure, [menu_struct_msg, chatbot], [chatbot, edit_prev]
1279
+ ).then(lambda: "", outputs=menu_struct_msg)
1280
+ menu_reset_btn.click(reset_menu_structure, chatbot, [chatbot, edit_prev])
1281
 
1282
  # ---- Tab 3: Poster ----
1283
  with gr.Tab("🎨 Poster"):
1284
  with gr.Row():
1285
  with gr.Column(scale=1):
1286
  gr.Markdown("### Event / Promotion")
1287
+ edesc = gr.Textbox(label="Promotion description", placeholder="Student discount 20% off, valid Mon-Fri with student ID", lines=2)
1288
+ bg_prompt = gr.Textbox(label="Background prompt (optional)", placeholder="Leave empty to auto-generate", lines=2)
1289
+ bg_style = gr.Radio(choices=["Restaurant atmosphere", "Match the promotion"], value="Restaurant atmosphere", label="Auto-generate style")
1290
+ gr.Markdown("*Or upload your own background photo*")
1291
+ poster_bg = gr.Image(label="Background Image (optional)", type="filepath")
1292
+ add2site = gr.Checkbox(label="Add to website carousel", value=True)
1293
+ pbtn = gr.Button("🎨 Generate Poster", variant="primary", size="lg")
1294
+ pinfo = gr.Markdown("")
1295
+ gr.Markdown("### Manage Carousel")
1296
+ poster_status = gr.Markdown("*No posters added yet*")
1297
+ with gr.Row():
1298
+ remove_poster_btn = gr.Button("πŸ—‘οΈ Remove last", size="sm")
1299
+ clear_poster_btn = gr.Button("πŸ—‘οΈ Clear all", size="sm", variant="stop")
1300
  with gr.Column(scale=1):
1301
  gr.Markdown("### Result")
1302
+ pimg = gr.Image(label="Poster", type="pil")
1303
  gr.Markdown("### πŸ“± Preview")
1304
  pprev = gr.HTML()
1305
+
1306
+ pbtn.click(poster_flow, [edesc, bg_prompt, poster_bg, add2site, bg_style], [pimg, pinfo, pprev, poster_status])
1307
+ remove_poster_btn.click(remove_last_poster, outputs=[poster_status, pprev])
1308
+ clear_poster_btn.click(clear_all_posters, outputs=[poster_status, pprev])
1309
 
1310
  # ---- Tab 4: Download ----
1311
  with gr.Tab("πŸ’Ύ Download"):
1312
  gr.Markdown("### Download as single HTML\nFully self-contained with embedded images.")
1313
+ dbtn = gr.Button("πŸ’Ύ Download", variant="primary")
1314
  dfile = gr.File(label="Website file")
1315
  dbtn.click(download, outputs=dfile)
1316
 
1317
+ # Create: first update preview+status, then refresh block list
1318
+ create_btn.click(
1319
+ create_website,
1320
+ [shop_name, desc, phone, addr, hours, style, menu_zip],
1321
+ [create_prev, status]
1322
+ ).then(
1323
+ lambda: gr.update(choices=get_block_choices(), value=None),
1324
+ inputs=None,
1325
+ outputs=block_radio
1326
+ ).then(
1327
+ lambda: preview(current_html),
1328
+ inputs=None,
1329
+ outputs=edit_prev
1330
+ )
1331
 
1332
+ return app
1333
 
1334
  # ============================================================
1335
  # MAIN
1336
  # ============================================================
1337
  if __name__ == "__main__":
1338
  print("=" * 50)
1339
+ print(" ShopSite AI β€” Block-based page builder")
1340
  print("=" * 50)
1341
 
1342
  if not TEMPLATE_DIR.exists():
 
1344
  else:
1345
  print(f"\nβœ… Templates: {', '.join(t.stem for t in TEMPLATE_DIR.glob('*.html'))}")
1346
 
1347
+ if TORCH_AVAILABLE:
1348
+ device = "cuda" if torch.cuda.is_available() else "cpu"
1349
+ print(f"βœ… torch available. Device: {device}")
1350
+ print(f" General model: {QWEN_MODEL}")
1351
+ print(f" Coder model: {QWEN_CODER_MODEL}")
1352
+ print(" (Models downloaded from HuggingFace on first use)")
1353
+ else:
1354
+ print("❌ torch/transformers not installed. Run: pip install torch transformers accelerate")
1355
 
1356
  if SD_AVAILABLE:
1357
+ print(f"βœ… SD Turbo ready. CUDA: {torch.cuda.is_available()}")
 
 
1358
  else:
1359
+ print("⚠️ Poster generation disabled (diffusers not available).")
1360
 
1361
  print("\nπŸš€ http://127.0.0.1:7860\n")
1362
+ in_colab = "google.colab" in str(__import__("sys").modules)
1363
+ demo = build_app()
1364
+ if in_colab:
1365
+ demo.launch(share=True)
1366
+ else:
1367
+ demo.launch(server_name="0.0.0.0", server_port=7860)