Spaces:
Sleeping
Sleeping
| """CraftPilot - AI-powered craft business assistant. | |
| Upload a photo of your handmade craft, get a complete marketplace listing | |
| with catalog metadata, product descriptions, social captions, and pricing. | |
| Built for the Build Small Hackathon 2026. | |
| Multi-agent pipeline: Vision -> Cataloger -> Copywriter + Pricer | |
| Single model (MiniCPM-V 2.6, ~8B) running locally via llama.cpp. | |
| No cloud APIs. | |
| """ | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| import tempfile | |
| import threading | |
| import time | |
| import gradio as gr | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from PIL import Image | |
| from agents import cataloger, copywriter, pricer | |
| from agents.llm import LLMClient | |
| from agents.models import AgentTrace, PipelineResult | |
| from agents.pipeline import VISION_PROMPT | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # HF token: env var > token.txt fallback | |
| hf_token = os.getenv("HF_TOKEN") | |
| if not hf_token: | |
| _token_path = os.path.join(os.path.dirname(__file__), "token.txt") | |
| if os.path.exists(_token_path): | |
| hf_token = open(_token_path).read().strip() | |
| if hf_token: | |
| os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token | |
| TRACES_DATASET = "skamathramesh/craftpilot-traces" | |
| def _upload_trace_async(trace_json: str): | |
| """Upload trace JSON to HF dataset in background thread.""" | |
| def _upload(): | |
| try: | |
| api = HfApi(token=hf_token) | |
| filename = f"trace_{int(time.time())}_{os.getpid()}.json" | |
| api.upload_file( | |
| path_or_fileobj=trace_json.encode(), | |
| path_in_repo=f"traces/{filename}", | |
| repo_id=TRACES_DATASET, | |
| repo_type="dataset", | |
| ) | |
| logger.info("Trace uploaded: %s", filename) | |
| except Exception as e: | |
| logger.warning("Trace upload failed (non-fatal): %s", e) | |
| if hf_token: | |
| threading.Thread(target=_upload, daemon=True).start() | |
| CRAFT_TYPES = [ | |
| "Crochet", | |
| "Embroidery", | |
| "Painting", | |
| "Sewing", | |
| "Knitting", | |
| "Other", | |
| ] | |
| MODEL_REPO = os.getenv("MODEL_REPO", "openbmb/MiniCPM-V-2_6-gguf") | |
| MODEL_FILE = os.getenv("MODEL_FILE", "ggml-model-Q4_K_M.gguf") | |
| PROJ_FILE = os.getenv("PROJ_FILE", "mmproj-model-f16.gguf") | |
| _llm_client: LLMClient | None = None | |
| CUSTOM_HEAD = '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Source+Sans+3:wght@300;400;500;600&display=swap">' | |
| CUSTOM_CSS = """ | |
| :root { | |
| --craft-amber: #d4830a; | |
| --craft-amber-light: #f5e6cc; | |
| --craft-terracotta: #c1644a; | |
| --craft-cream: #faf6f0; | |
| --craft-warm-gray: #6b5e53; | |
| --craft-dark: #3a2e26; | |
| --craft-sage: #8a9a7b; | |
| --craft-linen: #f0ebe3; | |
| } | |
| .gradio-container { | |
| max-width: 100% !important; | |
| background: var(--craft-cream) !important; | |
| font-family: 'Source Sans 3', sans-serif !important; | |
| padding: 0 2rem !important; | |
| } | |
| /* Header */ | |
| #craft-header { | |
| text-align: center; | |
| padding: 2rem 1rem 1.5rem; | |
| background: linear-gradient(135deg, var(--craft-amber-light) 0%, var(--craft-linen) 50%, #e8ddd0 100%); | |
| border-radius: 16px; | |
| margin-bottom: 1.5rem; | |
| border: 1px solid rgba(212, 131, 10, 0.15); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #craft-header::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23d4830a' fill-opacity='0.04'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); | |
| pointer-events: none; | |
| } | |
| #craft-header h1 { | |
| font-family: 'DM Serif Display', serif !important; | |
| font-size: 2.8rem !important; | |
| color: var(--craft-dark) !important; | |
| margin: 0 0 0.25rem !important; | |
| letter-spacing: -0.02em; | |
| } | |
| #craft-header h3 { | |
| font-family: 'Source Sans 3', sans-serif !important; | |
| color: var(--craft-warm-gray) !important; | |
| font-weight: 400 !important; | |
| font-size: 1.1rem !important; | |
| margin: 0 !important; | |
| letter-spacing: 0.03em; | |
| } | |
| #craft-header p { | |
| color: var(--craft-warm-gray) !important; | |
| font-size: 0.95rem !important; | |
| max-width: 600px; | |
| margin: 0.75rem auto 0 !important; | |
| line-height: 1.5; | |
| } | |
| /* Input panel */ | |
| #input-panel { | |
| background: white !important; | |
| border-radius: 14px !important; | |
| border: 1px solid rgba(107, 94, 83, 0.1) !important; | |
| padding: 1.25rem !important; | |
| box-shadow: 0 2px 12px rgba(58, 46, 38, 0.06) !important; | |
| } | |
| /* Primary button */ | |
| #analyze-btn { | |
| background: linear-gradient(135deg, var(--craft-amber) 0%, var(--craft-terracotta) 100%) !important; | |
| border: none !important; | |
| color: white !important; | |
| font-family: 'Source Sans 3', sans-serif !important; | |
| font-weight: 600 !important; | |
| font-size: 1.05rem !important; | |
| letter-spacing: 0.03em; | |
| padding: 0.85rem 2rem !important; | |
| border-radius: 10px !important; | |
| transition: all 0.25s ease !important; | |
| box-shadow: 0 4px 14px rgba(212, 131, 10, 0.3) !important; | |
| } | |
| #analyze-btn:hover { | |
| transform: translateY(-1px) !important; | |
| box-shadow: 0 6px 20px rgba(212, 131, 10, 0.4) !important; | |
| } | |
| /* Export button */ | |
| #export-btn { | |
| border: 1px solid var(--craft-amber) !important; | |
| color: var(--craft-amber) !important; | |
| background: white !important; | |
| font-weight: 500 !important; | |
| border-radius: 8px !important; | |
| } | |
| #export-btn:hover { | |
| background: var(--craft-amber-light) !important; | |
| } | |
| /* Output panel */ | |
| #output-panel { | |
| background: white !important; | |
| border-radius: 14px !important; | |
| border: 1px solid rgba(107, 94, 83, 0.1) !important; | |
| box-shadow: 0 2px 12px rgba(58, 46, 38, 0.06) !important; | |
| } | |
| /* Tab styling */ | |
| .tabs > .tab-nav > button { | |
| font-family: 'Source Sans 3', sans-serif !important; | |
| font-weight: 500 !important; | |
| font-size: 0.9rem !important; | |
| color: var(--craft-warm-gray) !important; | |
| border-bottom: 2px solid transparent !important; | |
| padding: 0.6rem 1rem !important; | |
| } | |
| .tabs > .tab-nav > button.selected { | |
| color: var(--craft-amber) !important; | |
| border-bottom-color: var(--craft-amber) !important; | |
| background: rgba(212, 131, 10, 0.05) !important; | |
| } | |
| /* Summary card styles */ | |
| .summary-section { | |
| padding: 1rem 1.25rem; | |
| margin: 0.5rem 0; | |
| border-left: 3px solid var(--craft-amber); | |
| background: var(--craft-cream); | |
| border-radius: 0 8px 8px 0; | |
| } | |
| /* Pipeline progress stepper */ | |
| .pipeline-status { | |
| padding: 2.5rem 1.5rem; | |
| text-align: center; | |
| } | |
| .pipeline-status .status-label { | |
| font-size: 1.15rem; | |
| font-weight: 500; | |
| color: var(--craft-amber); | |
| margin-bottom: 1.5rem; | |
| animation: pulse 1.8s ease-in-out infinite; | |
| } | |
| .pipeline-steps { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0; | |
| margin: 0 auto; | |
| max-width: 420px; | |
| } | |
| .pipeline-step { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| flex: 1; | |
| position: relative; | |
| } | |
| .step-dot { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 50%; | |
| background: var(--craft-linen); | |
| border: 2px solid #d5ccc3; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| color: #b0a89e; | |
| transition: all 0.3s ease; | |
| z-index: 1; | |
| } | |
| .step-dot.done { | |
| background: var(--craft-amber); | |
| border-color: var(--craft-amber); | |
| color: white; | |
| } | |
| .step-dot.active { | |
| background: white; | |
| border-color: var(--craft-amber); | |
| color: var(--craft-amber); | |
| box-shadow: 0 0 0 4px rgba(212, 131, 10, 0.15); | |
| animation: pulse-ring 1.8s ease-in-out infinite; | |
| } | |
| .step-label { | |
| font-size: 0.7rem; | |
| color: #b0a89e; | |
| margin-top: 0.4rem; | |
| font-weight: 500; | |
| white-space: nowrap; | |
| } | |
| .step-label.done, .step-label.active { | |
| color: var(--craft-warm-gray); | |
| } | |
| .step-connector { | |
| height: 2px; | |
| flex: 1; | |
| background: #d5ccc3; | |
| margin: 0 -2px; | |
| margin-bottom: 1.2rem; | |
| } | |
| .step-connector.done { | |
| background: var(--craft-amber); | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| @keyframes pulse-ring { | |
| 0%, 100% { box-shadow: 0 0 0 4px rgba(212, 131, 10, 0.15); } | |
| 50% { box-shadow: 0 0 0 6px rgba(212, 131, 10, 0.08); } | |
| } | |
| /* Auto-cycling wave animation for loading state */ | |
| .pipeline-status.loading .step-dot { | |
| background: white; | |
| border-color: var(--craft-amber); | |
| color: var(--craft-amber); | |
| animation: dot-wave 2.4s ease-in-out infinite; | |
| } | |
| .pipeline-status.loading .step-connector { | |
| background: linear-gradient(90deg, var(--craft-amber), #d5ccc3); | |
| } | |
| @keyframes dot-wave { | |
| 0%, 100% { transform: scale(1); opacity: 0.5; } | |
| 50% { transform: scale(1.2); opacity: 1; background: var(--craft-amber); color: white; } | |
| } | |
| /* Footer */ | |
| #craft-footer { | |
| text-align: center; | |
| padding: 1.5rem 1rem; | |
| margin-top: 1rem; | |
| } | |
| #craft-footer p { | |
| color: var(--craft-warm-gray) !important; | |
| font-size: 0.8rem !important; | |
| opacity: 0.7; | |
| } | |
| /* Markdown content styling */ | |
| .prose h2 { | |
| font-family: 'DM Serif Display', serif !important; | |
| color: var(--craft-dark) !important; | |
| } | |
| .prose strong { | |
| color: var(--craft-dark) !important; | |
| } | |
| /* Image upload area */ | |
| .image-container { | |
| border: 2px dashed rgba(212, 131, 10, 0.25) !important; | |
| border-radius: 12px !important; | |
| background: var(--craft-cream) !important; | |
| } | |
| /* Dropdown & textbox refinements */ | |
| .gradio-container input, .gradio-container textarea, .gradio-container select { | |
| font-family: 'Source Sans 3', sans-serif !important; | |
| border-radius: 8px !important; | |
| } | |
| label > span { | |
| font-family: 'Source Sans 3', sans-serif !important; | |
| font-weight: 500 !important; | |
| color: var(--craft-warm-gray) !important; | |
| font-size: 0.9rem !important; | |
| } | |
| """ | |
| def download_models() -> tuple[str, str]: | |
| """Download GGUF model from HF Hub. Returns (model_path, proj_path).""" | |
| logger.info("Downloading models from %s...", MODEL_REPO) | |
| model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE) | |
| proj_path = hf_hub_download(repo_id=MODEL_REPO, filename=PROJ_FILE) | |
| logger.info("Models downloaded.") | |
| return model_path, proj_path | |
| def get_client() -> LLMClient: | |
| """Lazy-load the unified model client.""" | |
| global _llm_client | |
| if _llm_client is None: | |
| model_path, proj_path = download_models() | |
| _llm_client = LLMClient( | |
| model_path=model_path, projection_path=proj_path | |
| ) | |
| return _llm_client | |
| def _format_vision(result: PipelineResult) -> str: | |
| if not result.image_description: | |
| return "" | |
| return f"### What the AI sees\n\n{result.image_description}" | |
| def _format_catalog(result: PipelineResult) -> str: | |
| cat = result.catalog | |
| if not cat: | |
| return "" | |
| tags = " ".join(f"`{t}`" for t in cat.tags) | |
| return ( | |
| f"### Catalog Entry\n\n" | |
| f"| Field | Value |\n" | |
| f"|-------|-------|\n" | |
| f"| **Category** | {cat.category} / {cat.sub_category} |\n" | |
| f"| **Materials** | {', '.join(cat.materials)} |\n" | |
| f"| **Colors** | {', '.join(cat.colors)} |\n" | |
| f"| **Size** | {cat.estimated_size} |\n" | |
| f"| **Complexity** | {cat.complexity} |\n\n" | |
| f"**Tags:** {tags}" | |
| ) | |
| def _format_copy(result: PipelineResult) -> str: | |
| if not result.copy_data: | |
| return "" | |
| c = result.copy_data | |
| copy_text = ( | |
| f"### {c.title}\n\n" | |
| f"**One-liner:** {c.short_desc}\n\n" | |
| f"---\n\n" | |
| f"**Full Description**\n\n{c.long_desc}\n\n" | |
| f"---\n\n" | |
| f"**Instagram Captions**\n\n" | |
| ) | |
| for i, cap in enumerate(c.captions, 1): | |
| copy_text += f"{i}. {cap}\n\n" | |
| return copy_text | |
| def _format_pricing(result: PipelineResult) -> str: | |
| if not result.pricing: | |
| return "" | |
| p = result.pricing | |
| price_text = ( | |
| f"### Pricing Recommendation\n\n" | |
| f"## ${p.suggested_price_min} \u2013 ${p.suggested_price_max}\n\n" | |
| f"---\n\n" | |
| f"**Rationale**\n\n{p.reasoning}\n" | |
| ) | |
| if p.cost_breakdown: | |
| price_text += f"\n---\n\n**Cost Breakdown**\n\n{p.cost_breakdown}\n" | |
| return price_text | |
| def _format_traces(result: PipelineResult) -> str: | |
| if not result.traces: | |
| return "" | |
| trace_parts = [ | |
| f"### Pipeline Trace\n\n" | |
| f"**Total time:** {result.total_duration_ms}ms \n" | |
| f"**Agents:** {len(result.traces)} steps\n\n" | |
| f"| Step | Agent | Duration |\n" | |
| f"|------|-------|----------|\n" | |
| ] | |
| for i, t in enumerate(result.traces, 1): | |
| trace_parts.append(f"| {i} | {t.agent_name} | {t.duration_ms}ms |\n") | |
| trace_json = json.dumps( | |
| [t.model_dump() for t in result.traces], indent=2 | |
| ) | |
| trace_parts.append(f"\n<details><summary>Raw JSON</summary>\n\n```json\n{trace_json}\n```\n</details>") | |
| return "".join(trace_parts) | |
| def _make_trace_file(result: PipelineResult) -> str | None: | |
| if not result.traces: | |
| return None | |
| trace_json = json.dumps( | |
| [t.model_dump() for t in result.traces], indent=2 | |
| ) | |
| trace_file = tempfile.NamedTemporaryFile( | |
| mode="w", suffix=".json", prefix="craftpilot-trace-", delete=False | |
| ) | |
| trace_file.write(trace_json) | |
| trace_file.close() | |
| return trace_file.name | |
| def _build_export_text(result: PipelineResult) -> str: | |
| """Build a plain text listing ready to copy-paste to Etsy or Instagram.""" | |
| lines = [] | |
| if result.copy_data: | |
| c = result.copy_data | |
| lines.append(f"PRODUCT TITLE: {c.title}") | |
| lines.append("") | |
| lines.append(f"SHORT DESCRIPTION: {c.short_desc}") | |
| lines.append("") | |
| lines.append("FULL DESCRIPTION:") | |
| lines.append(c.long_desc) | |
| lines.append("") | |
| if result.catalog: | |
| cat = result.catalog | |
| lines.append(f"CATEGORY: {cat.category} / {cat.sub_category}") | |
| lines.append(f"MATERIALS: {', '.join(cat.materials)}") | |
| lines.append(f"COLORS: {', '.join(cat.colors)}") | |
| lines.append(f"SIZE: {cat.estimated_size}") | |
| lines.append(f"TAGS: {', '.join(cat.tags)}") | |
| lines.append("") | |
| if result.pricing: | |
| p = result.pricing | |
| lines.append(f"SUGGESTED PRICE: ${p.suggested_price_min} - ${p.suggested_price_max}") | |
| lines.append("") | |
| if result.copy_data: | |
| lines.append("INSTAGRAM CAPTIONS:") | |
| for i, cap in enumerate(result.copy_data.captions, 1): | |
| lines.append(f" {i}. {cap}") | |
| lines.append("") | |
| lines.append("---") | |
| lines.append("Generated by CraftPilot | craftpilot.hf.space") | |
| return "\n".join(lines) | |
| def _status_html(active_step: int, label: str) -> str: | |
| """Build an HTML progress stepper. active_step: 0-3 (or -1 for pre-pipeline).""" | |
| steps = ["Vision", "Catalog", "Copy", "Pricing"] | |
| parts = ['<div class="pipeline-status">'] | |
| parts.append(f'<div class="status-label">{label}</div>') | |
| parts.append('<div class="pipeline-steps">') | |
| for i, name in enumerate(steps): | |
| if i > 0: | |
| conn_cls = "step-connector done" if i <= active_step else "step-connector" | |
| parts.append(f'<div class="{conn_cls}"></div>') | |
| if i < active_step: | |
| dot_cls, lbl_cls, icon = "step-dot done", "step-label done", "✓" | |
| elif i == active_step: | |
| dot_cls, lbl_cls, icon = "step-dot active", "step-label active", str(i + 1) | |
| else: | |
| dot_cls, lbl_cls, icon = "step-dot", "step-label", str(i + 1) | |
| parts.append(f'<div class="pipeline-step"><div class="{dot_cls}">{icon}</div>') | |
| parts.append(f'<div class="{lbl_cls}">{name}</div></div>') | |
| parts.append("</div></div>") | |
| return "".join(parts) | |
| def _summary_html(result: PipelineResult) -> str: | |
| """Build an HTML summary for gr.HTML output.""" | |
| parts = [] | |
| if result.copy_data: | |
| c = result.copy_data | |
| parts.append(f"<h2>{c.title}</h2>") | |
| parts.append(f"<p><em>{c.short_desc}</em></p>") | |
| if result.pricing: | |
| p = result.pricing | |
| parts.append(f"<h3>Suggested Price: ${p.suggested_price_min} \u2013 ${p.suggested_price_max}</h3>") | |
| parts.append("<hr>") | |
| if result.catalog: | |
| cat = result.catalog | |
| tags = " ".join(f"<code>{t}</code>" for t in cat.tags[:6]) | |
| parts.append( | |
| f"<p><strong>Category:</strong> {cat.category} / {cat.sub_category}<br>" | |
| f"<strong>Materials:</strong> {', '.join(cat.materials)}<br>" | |
| f"<strong>Colors:</strong> {', '.join(cat.colors)}<br>" | |
| f"<strong>Complexity:</strong> {cat.complexity}</p>" | |
| f"<p>{tags}</p>" | |
| ) | |
| parts.append("<hr>") | |
| if result.copy_data: | |
| parts.append(f"<p><strong>Description</strong></p><p>{result.copy_data.long_desc}</p>") | |
| parts.append("<hr>") | |
| parts.append("<p><strong>Instagram Captions</strong></p><ol>") | |
| for cap in result.copy_data.captions: | |
| parts.append(f"<li>{cap}</li>") | |
| parts.append("</ol>") | |
| if result.pricing and result.pricing.reasoning: | |
| parts.append(f"<hr><p><strong>Pricing Rationale</strong></p><p>{result.pricing.reasoning}</p>") | |
| if result.pricing.cost_breakdown: | |
| parts.append(f"<p><strong>Cost Breakdown:</strong> {result.pricing.cost_breakdown}</p>") | |
| if result.traces: | |
| agent_times = " \u2192 ".join( | |
| f"{t.agent_name} ({t.duration_ms}ms)" for t in result.traces | |
| ) | |
| parts.append( | |
| f"<hr><p><small>Pipeline: {agent_times} " | |
| f"| Total: {result.total_duration_ms}ms</small></p>" | |
| ) | |
| return f'<div class="prose">{"".join(parts)}</div>' | |
| # Stores the last pipeline result for tab-select handlers. | |
| _last_result: PipelineResult | None = None | |
| _last_trace_path: str | None = None | |
| _last_export_path: str | None = None | |
| def analyze_craft( | |
| image: Image.Image | None, | |
| craft_type: str, | |
| user_notes: str, | |
| material_cost: str, | |
| time_hours: str, | |
| ): | |
| """Streaming generator → gr.HTML (single output). | |
| Uses gr.HTML to bypass Svelte 5 i18n bug that crashes gr.Markdown | |
| during streaming. Tab content populated via tab-select handlers. | |
| """ | |
| global _last_result, _last_trace_path, _last_export_path | |
| _last_result = None | |
| _last_trace_path = None | |
| _last_export_path = None | |
| if image is None: | |
| yield "<p>Upload an image to get started.</p>" | |
| return | |
| if not craft_type: | |
| yield "<p>Please select a craft type.</p>" | |
| return | |
| cost = float(material_cost) if material_cost else None | |
| hours = float(time_hours) if time_hours else None | |
| notes = user_notes.strip() or None | |
| try: | |
| yield _status_html(-1, "Loading model...") | |
| llm = get_client() | |
| start = time.monotonic() | |
| traces: list[AgentTrace] = [] | |
| result = PipelineResult(image_description="") | |
| # Stage 1: Vision | |
| yield _status_html(0, "Analyzing your craft...") | |
| description, vision_ms = llm.describe_image(image, VISION_PROMPT) | |
| traces.append(AgentTrace( | |
| agent_name="vision", input_text="[image]", | |
| output_data={"description": description}, | |
| duration_ms=vision_ms, model_id=llm.model_id, | |
| )) | |
| result.image_description = description | |
| result.traces = list(traces) | |
| result.total_duration_ms = int((time.monotonic() - start) * 1000) | |
| # Stage 2: Cataloger | |
| yield ( | |
| _status_html(1, "Cataloging item...") + | |
| f'<hr><p><strong>Vision</strong> ({vision_ms}ms)</p><p>{description}</p>' | |
| ) | |
| try: | |
| catalog_result, catalog_trace = asyncio.run( | |
| cataloger.run(llm, description, craft_type.lower(), notes) | |
| ) | |
| traces.append(catalog_trace) | |
| result.catalog = catalog_result | |
| except Exception as e: | |
| logger.warning("Cataloger failed: %s", e) | |
| result.traces = list(traces) | |
| result.total_duration_ms = int((time.monotonic() - start) * 1000) | |
| # Stage 3: Copywriter | |
| if result.catalog: | |
| yield _status_html(2, "Writing copy...") + '<hr>' + _summary_html(result) | |
| try: | |
| copy_result, copy_trace = asyncio.run( | |
| copywriter.run(llm, craft_type.lower(), result.catalog, notes) | |
| ) | |
| traces.append(copy_trace) | |
| result.copy_data = copy_result | |
| except Exception as e: | |
| logger.warning("Copywriter failed: %s", e) | |
| result.traces = list(traces) | |
| result.total_duration_ms = int((time.monotonic() - start) * 1000) | |
| # Stage 4: Pricer | |
| if result.catalog: | |
| yield _status_html(3, "Calculating pricing...") + '<hr>' + _summary_html(result) | |
| try: | |
| price_result, price_trace = asyncio.run( | |
| pricer.run(llm, craft_type.lower(), result.catalog, cost, hours) | |
| ) | |
| traces.append(price_trace) | |
| result.pricing = price_result | |
| except Exception as e: | |
| logger.warning("Pricer failed: %s", e) | |
| result.traces = list(traces) | |
| result.total_duration_ms = int((time.monotonic() - start) * 1000) | |
| # Upload trace to HF dataset (background, non-blocking) | |
| trace_json = json.dumps( | |
| [t.model_dump() for t in result.traces], indent=2 | |
| ) | |
| _upload_trace_async(trace_json) | |
| # Build download files for tab handlers | |
| _last_trace_path = _make_trace_file(result) | |
| export_text = _build_export_text(result) | |
| export_file = tempfile.NamedTemporaryFile( | |
| mode="w", suffix=".txt", prefix="craftpilot-listing-", delete=False | |
| ) | |
| export_file.write(export_text) | |
| export_file.close() | |
| _last_export_path = export_file.name | |
| _last_result = result | |
| # Final: render summary as HTML | |
| yield _summary_html(result) | |
| except Exception as e: | |
| logger.exception("Pipeline failed") | |
| yield f"<p><strong>Error:</strong> {e}</p>" | |
| def _on_vision_tab(): | |
| if not _last_result: | |
| return "" | |
| return _format_vision(_last_result) | |
| def _on_catalog_tab(): | |
| if not _last_result: | |
| return "" | |
| return _format_catalog(_last_result) | |
| def _on_copy_tab(): | |
| if not _last_result: | |
| return "", gr.DownloadButton(visible=False) | |
| return ( | |
| _format_copy(_last_result), | |
| gr.DownloadButton(value=_last_export_path, visible=True) if _last_export_path else gr.DownloadButton(visible=False), | |
| ) | |
| def _on_pricing_tab(): | |
| if not _last_result: | |
| return "" | |
| return _format_pricing(_last_result) | |
| def _on_traces_tab(): | |
| if not _last_result: | |
| return "", gr.DownloadButton(visible=False) | |
| return ( | |
| _format_traces(_last_result), | |
| gr.DownloadButton(value=_last_trace_path, visible=True) if _last_trace_path else gr.DownloadButton(visible=False), | |
| ) | |
| def build_ui() -> gr.Blocks: | |
| """Build the Gradio interface.""" | |
| with gr.Blocks( | |
| title="CraftPilot \u2014 AI Craft Business Assistant", | |
| ) as app: | |
| # Header | |
| gr.Markdown( | |
| "# CraftPilot\n" | |
| "### Photo in, marketplace listing out\n" | |
| "Upload a photo of your handmade craft and get catalog data, " | |
| "product copy, social captions, and fair pricing \u2014 all from a " | |
| "single small model running locally. No cloud APIs.", | |
| elem_id="craft-header", | |
| ) | |
| with gr.Row(equal_height=False): | |
| # Left: inputs | |
| with gr.Column(scale=1, min_width=340): | |
| with gr.Group(elem_id="input-panel"): | |
| image_input = gr.Image( | |
| label="Your Craft", | |
| type="pil", | |
| height=360, | |
| ) | |
| craft_type = gr.Dropdown( | |
| choices=CRAFT_TYPES, | |
| label="Craft Type", | |
| value="Crochet", | |
| ) | |
| user_notes = gr.Textbox( | |
| label="Notes (optional)", | |
| placeholder="e.g. Made with organic cotton, took 3 evenings...", | |
| lines=2, | |
| ) | |
| with gr.Row(): | |
| material_cost = gr.Textbox( | |
| label="Material Cost ($)", | |
| placeholder="e.g. 15", | |
| ) | |
| time_hours = gr.Textbox( | |
| label="Time Spent (hrs)", | |
| placeholder="e.g. 8", | |
| ) | |
| analyze_btn = gr.Button( | |
| "Analyze My Craft", | |
| variant="primary", | |
| size="lg", | |
| elem_id="analyze-btn", | |
| ) | |
| gr.Examples( | |
| examples=[ | |
| ["examples/crochet.png", "Crochet", "Handmade amigurumi with cotton yarn", "16", "6"], | |
| ["examples/embroidery.jpeg", "Embroidery", "Hand-stitched floral pattern on Jeans", "20", "10"], | |
| ["examples/sew_keychain.jpeg", "Sewing", "Fabric keychain with felt and thread", "5", "2"], | |
| ], | |
| inputs=[image_input, craft_type, user_notes, material_cost, time_hours], | |
| label="Try these examples", | |
| ) | |
| # Right: outputs | |
| with gr.Column(scale=2, min_width=500, elem_id="output-panel"): | |
| with gr.Tabs(): | |
| with gr.Tab("Summary"): | |
| # gr.HTML instead of gr.Markdown — experiment to | |
| # bypass Svelte 5 i18n crash during streaming | |
| summary_output = gr.HTML( | |
| value="<p><em>Your results will appear here after analysis.</em></p>", | |
| elem_classes=["prose"], | |
| ) | |
| with gr.Tab("Vision") as vision_tab: | |
| vision_output = gr.Markdown(elem_classes=["prose"]) | |
| with gr.Tab("Catalog") as catalog_tab: | |
| catalog_output = gr.Markdown(elem_classes=["prose"]) | |
| with gr.Tab("Copy") as copy_tab: | |
| copy_output = gr.Markdown(elem_classes=["prose"]) | |
| export_download = gr.DownloadButton( | |
| label="Export Listing (Etsy / Instagram)", | |
| elem_id="export-btn", | |
| visible=False, | |
| ) | |
| with gr.Tab("Pricing") as pricing_tab: | |
| price_output = gr.Markdown(elem_classes=["prose"]) | |
| with gr.Tab("Agent Traces") as traces_tab: | |
| trace_output = gr.Markdown(elem_classes=["prose"]) | |
| trace_download = gr.DownloadButton( | |
| label="Download Agent Trace JSON", | |
| visible=False, | |
| ) | |
| # Streaming generator → single gr.HTML output (bypasses i18n crash?) | |
| analyze_btn.click( | |
| fn=analyze_craft, | |
| inputs=[ | |
| image_input, | |
| craft_type, | |
| user_notes, | |
| material_cost, | |
| time_hours, | |
| ], | |
| outputs=[summary_output], | |
| show_progress="minimal", | |
| ) | |
| # Tab-select handlers: populate content + downloads on demand | |
| vision_tab.select(fn=_on_vision_tab, outputs=[vision_output]) | |
| catalog_tab.select(fn=_on_catalog_tab, outputs=[catalog_output]) | |
| copy_tab.select(fn=_on_copy_tab, outputs=[copy_output, export_download]) | |
| pricing_tab.select(fn=_on_pricing_tab, outputs=[price_output]) | |
| traces_tab.select(fn=_on_traces_tab, outputs=[trace_output, trace_download]) | |
| # Footer | |
| gr.Markdown( | |
| "Built for the Build Small Hackathon 2026 \u00b7 Backyard AI track \n" | |
| "MiniCPM-V 2.6 (~8B) via llama.cpp \u00b7 No cloud APIs \n" | |
| "Llama Champion \u00b7 Off the Grid \u00b7 Sharing is Caring \u00b7 Field Notes", | |
| elem_id="craft-footer", | |
| ) | |
| return app | |
| if __name__ == "__main__": | |
| # Pre-load models at startup (avoids 60s request timeout on HF Spaces) | |
| logger.info("Pre-loading models at startup...") | |
| try: | |
| get_client() | |
| logger.info("Models ready!") | |
| except Exception as e: | |
| logger.error("Failed to load models: %s", e) | |
| theme = gr.themes.Soft( | |
| primary_hue="amber", | |
| secondary_hue="orange", | |
| neutral_hue="stone", | |
| font=gr.themes.GoogleFont("Source Sans 3"), | |
| font_mono=gr.themes.GoogleFont("JetBrains Mono"), | |
| ) | |
| app = build_ui() | |
| app.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| theme=theme, | |
| css=CUSTOM_CSS, | |
| head=CUSTOM_HEAD, | |
| ) | |