ICGenAIShare06 commited on
Commit
497c1c6
·
verified ·
1 Parent(s): 847c8bf

Upload app.py

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