"""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 = '' 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
Raw JSON\n\n```json\n{trace_json}\n```\n
") 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 = ['
'] parts.append(f'
{label}
') parts.append('
') for i, name in enumerate(steps): if i > 0: conn_cls = "step-connector done" if i <= active_step else "step-connector" parts.append(f'
') 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'
{icon}
') parts.append(f'
{name}
') parts.append("
") 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"

{c.title}

") parts.append(f"

{c.short_desc}

") if result.pricing: p = result.pricing parts.append(f"

Suggested Price: ${p.suggested_price_min} \u2013 ${p.suggested_price_max}

") parts.append("
") if result.catalog: cat = result.catalog tags = " ".join(f"{t}" for t in cat.tags[:6]) parts.append( f"

Category: {cat.category} / {cat.sub_category}
" f"Materials: {', '.join(cat.materials)}
" f"Colors: {', '.join(cat.colors)}
" f"Complexity: {cat.complexity}

" f"

{tags}

" ) parts.append("
") if result.copy_data: parts.append(f"

Description

{result.copy_data.long_desc}

") parts.append("
") parts.append("

Instagram Captions

    ") for cap in result.copy_data.captions: parts.append(f"
  1. {cap}
  2. ") parts.append("
") if result.pricing and result.pricing.reasoning: parts.append(f"

Pricing Rationale

{result.pricing.reasoning}

") if result.pricing.cost_breakdown: parts.append(f"

Cost Breakdown: {result.pricing.cost_breakdown}

") if result.traces: agent_times = " \u2192 ".join( f"{t.agent_name} ({t.duration_ms}ms)" for t in result.traces ) parts.append( f"

Pipeline: {agent_times} " f"| Total: {result.total_duration_ms}ms

" ) return f'
{"".join(parts)}
' # 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 "

Upload an image to get started.

" return if not craft_type: yield "

Please select a craft type.

" 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'

Vision ({vision_ms}ms)

{description}

' ) 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...") + '
' + _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...") + '
' + _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"

Error: {e}

" 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="

Your results will appear here after analysis.

", 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, )