HomePilot Deploy Bot commited on
Commit
bd09507
·
unverified ·
1 Parent(s): 0612bd6

chore: sync installer from monorepo

Browse files
Files changed (1) hide show
  1. app.py +426 -314
app.py CHANGED
@@ -1,371 +1,509 @@
1
  """
2
- HomePilot Installer — Hugging Face Space
3
 
4
- A Gradio-based wizard that helps users install HomePilot into their own
5
- private HF Space. Uses the Two-Space Architecture:
6
-
7
- 1. THIS Space (Installer) — public, lightweight Gradio UI
8
- 2. User's Space (Builder) — private, Docker + Ollama + GPU
9
-
10
- The installer:
11
- - Authenticates via HF token
12
- - Creates a new private Docker Space in the user's account
13
- - Pushes the HomePilot template (Dockerfile + backend + frontend + personas)
14
- - Guides the user to enable GPU hardware
15
  """
16
 
17
  import json
18
  import os
 
19
  import subprocess
20
  import tempfile
21
- import time
22
  from pathlib import Path
23
 
24
  import gradio as gr
25
 
26
- # ── Constants ────────────────────────────────────────────
27
-
28
  TEMPLATE_REPO = os.environ.get("TEMPLATE_REPO", "ruslanmv/HomePilot")
29
- DEFAULT_SPACE_NAME = "HomePilot"
30
- BUILDER_DIR = Path(__file__).parent.parent / "builder"
31
 
32
- # If running standalone (not inside the full repo), check local dir
33
- if not BUILDER_DIR.exists():
34
- BUILDER_DIR = Path(__file__).parent / "builder"
35
 
36
- INSTALL_STEPS = [
37
- {"id": "auth", "label": "Autenticación", "icon": "🔑"},
38
- {"id": "configure", "label": "Configuración", "icon": "⚙️"},
39
- {"id": "install", "label": "Instalación", "icon": "🚀"},
40
- {"id": "done", "label": "Listo", "icon": "✅"},
41
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- # ── CSS (HomePilot dark theme) ───────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
- CUSTOM_CSS = """
46
  .gradio-container {
47
- background: linear-gradient(135deg, #0f0a1a 0%, #1a1030 50%, #0d1117 100%) !important;
48
- min-height: 100vh;
 
 
49
  }
50
- .main-header {
 
 
 
 
 
 
51
  text-align: center;
52
- padding: 2rem 1rem;
 
 
53
  }
54
- .main-header h1 {
55
- background: linear-gradient(135deg, #a855f7, #6366f1, #22d3ee);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  -webkit-background-clip: text;
57
  -webkit-text-fill-color: transparent;
58
- font-size: 2.5rem;
59
- font-weight: 900;
60
- letter-spacing: -0.02em;
61
  }
62
- .main-header p {
63
- color: #94a3b8;
64
- font-size: 0.95rem;
65
- margin-top: 0.5rem;
 
 
66
  }
67
- .step-card {
68
- background: rgba(255,255,255,0.03) !important;
69
- border: 1px solid rgba(255,255,255,0.08) !important;
70
- border-radius: 12px !important;
71
- padding: 1.5rem !important;
 
 
72
  }
73
- .status-badge {
74
- display: inline-block;
75
- padding: 4px 12px;
 
 
76
  border-radius: 999px;
77
- font-size: 0.75rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  font-weight: 700;
79
  text-transform: uppercase;
80
- letter-spacing: 0.05em;
 
 
 
81
  }
82
- .persona-grid {
 
83
  display: grid;
84
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
85
  gap: 8px;
86
- margin-top: 12px;
87
  }
88
- .persona-chip {
89
- background: rgba(168, 85, 247, 0.1);
90
- border: 1px solid rgba(168, 85, 247, 0.2);
91
- border-radius: 8px;
92
  padding: 8px 12px;
93
- color: #c084fc;
94
- font-size: 0.8rem;
95
  font-weight: 600;
 
 
 
 
 
 
96
  }
97
- """
 
 
 
 
98
 
99
- # ── Persona list ─────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- CHATA_PERSONAS = {
102
- "starter_pack": [
103
- "Lunalite Greeter", "Chillbro Regular",
104
- "Curiosa Driver", "Hypekid Reactions",
105
- ],
106
- "retro_pack": [
107
- "Volt Buddy", "Ronin Zero", "Rival Kaiju",
108
- "Glitchbyte", "Questkid 99", "Sigma Sage",
109
- "Wildcard Loki", "Oldroot Oracle", "Morphling X",
110
- "Nova Void",
111
- ],
112
  }
113
 
114
- # ── Core Functions ───────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  def validate_token(token: str) -> tuple[str, str, str]:
118
- """Validate HF token and return (status, username, message)."""
119
  if not token or len(token) < 10:
120
- return "error", "", "Token vacío o muy corto"
121
-
122
  try:
123
- result = subprocess.run(
124
- ["python3", "-c", f"""
125
- import requests
126
- r = requests.get("https://huggingface.co/api/whoami-v2",
127
- headers={{"Authorization": "Bearer {token}"}}, timeout=10)
128
- if r.ok:
129
- d = r.json()
130
- print(d.get("name", "unknown"))
131
- else:
132
- print(f"ERROR:{{r.status_code}}")
133
- """],
134
- capture_output=True, text=True, timeout=15
135
  )
136
- username = result.stdout.strip()
137
- if username.startswith("ERROR:"):
138
- return "error", "", f" Token inválido ({username})"
139
- if username:
140
- return "ok", username, f"✅ Autenticado como **{username}**"
141
- return "error", "", "❌ No pude verificar el token"
142
  except Exception as e:
143
- return "error", "", f"Error: {e}"
 
144
 
 
 
 
 
145
 
146
- def create_user_space(token: str, username: str, space_name: str,
147
- private: bool, model: str) -> tuple[str, str]:
148
- """Create a new Space in the user's account and push the template."""
149
  repo_id = f"{username}/{space_name}"
150
- log_lines = []
151
 
152
  def log(msg):
153
- log_lines.append(msg)
154
- return "\n".join(log_lines)
155
 
156
- yield log(f"📦 Creando Space **{repo_id}**...")
157
 
158
  try:
159
- # Step 1: Create the Space
160
  import requests
161
 
162
  r = requests.post(
163
  "https://huggingface.co/api/repos/create",
164
- headers={
165
- "Authorization": f"Bearer {token}",
166
- "Content-Type": "application/json",
167
- },
168
- json={
169
- "type": "space",
170
- "name": space_name,
171
- "private": private,
172
- "sdk": "docker",
173
- },
174
  timeout=30,
175
  )
176
  if r.ok:
177
- yield log(" Space creado")
178
  elif r.status_code == 409:
179
- yield log("⚠️ Space ya existe — actualizando")
180
  else:
181
- yield log(f" Error creando Space: {r.status_code} {r.text[:200]}")
182
  return
183
 
184
- # Step 2: Clone the template
185
- yield log("📥 Clonando template de HomePilot...")
186
 
187
  with tempfile.TemporaryDirectory() as tmpdir:
188
  remote = f"https://user:{token}@huggingface.co/spaces/{repo_id}"
 
189
 
190
- # Try to clone existing, or init new
191
- clone_result = subprocess.run(
192
  ["git", "-c", "credential.helper=", "clone", "--depth", "1",
193
- remote, tmpdir + "/space"],
194
- capture_output=True, text=True, timeout=30,
195
  )
196
- space_dir = tmpdir + "/space"
197
- if clone_result.returncode != 0:
198
- os.makedirs(space_dir, exist_ok=True)
199
- subprocess.run(["git", "init", "-b", "main", space_dir],
200
- capture_output=True, timeout=10)
201
- subprocess.run(
202
- ["git", "-C", space_dir, "remote", "add", "origin", remote],
203
- capture_output=True, timeout=10,
204
- )
205
-
206
- # Wipe and copy template
207
- for item in Path(space_dir).iterdir():
208
- if item.name != ".git":
209
- if item.is_dir():
210
- import shutil
211
- shutil.rmtree(item)
212
- else:
213
- item.unlink()
214
 
215
- yield log("📁 Copiando archivos del template...")
216
-
217
- # Copy from the template repo (clone it first)
218
- template_remote = f"https://user:{token}@huggingface.co/spaces/{TEMPLATE_REPO}"
219
- template_dir = tmpdir + "/template"
220
- subprocess.run(
221
  ["git", "-c", "credential.helper=", "clone", "--depth", "1",
222
- template_remote, template_dir],
223
- capture_output=True, text=True, timeout=60,
224
  )
 
 
 
 
225
 
226
- # Copy template files to user space
227
- import shutil
228
- for item in Path(template_dir).iterdir():
 
 
 
 
229
  if item.name == ".git":
230
  continue
231
  dest = Path(space_dir) / item.name
232
- if item.is_dir():
233
- shutil.copytree(item, dest)
234
- else:
235
- shutil.copy2(item, dest)
236
-
237
- # Customize README with user's settings
238
- readme_path = Path(space_dir) / "README.md"
239
- if readme_path.exists():
240
- content = readme_path.read_text()
241
- content = content.replace("ruslanmv/HomePilot", repo_id)
242
- readme_path.write_text(content)
243
-
244
- yield log(f"🤖 Configurando modelo: **{model}**...")
245
-
246
- # Patch start.sh with custom model
247
- start_path = Path(space_dir) / "start.sh"
248
- if start_path.exists():
249
- content = start_path.read_text()
250
- content = content.replace(
251
- 'OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:1.5b}',
252
- f'OLLAMA_MODEL=${{OLLAMA_MODEL:-{model}}}',
253
- )
254
- start_path.write_text(content)
255
-
256
- yield log("📤 Subiendo a Hugging Face...")
257
-
258
- # Setup LFS
259
- subprocess.run(["git", "lfs", "install", "--local"],
260
- capture_output=True, cwd=space_dir, timeout=10)
261
- subprocess.run(
262
- ["git", "lfs", "track", "*.hpersona", "*.png", "*.jpg",
263
- "*.webp", "*.svg", "*.woff", "*.woff2"],
264
- capture_output=True, cwd=space_dir, timeout=10,
265
- )
266
-
267
- # Commit and push
268
- subprocess.run(
269
- ["git", "-C", space_dir, "-c", "[email protected]",
270
- "-c", "user.name=HomePilot Installer", "add", "-A"],
271
- capture_output=True, timeout=30,
272
- )
273
- subprocess.run(
274
- ["git", "-C", space_dir, "-c", "[email protected]",
275
- "-c", "user.name=HomePilot Installer", "commit", "-m",
276
- "feat: HomePilot installed via installer wizard"],
277
- capture_output=True, timeout=30,
278
- )
279
-
280
- push_result = subprocess.run(
281
  ["git", "-C", space_dir, "push", "--force", remote, "HEAD:main"],
282
  capture_output=True, text=True, timeout=120,
283
  )
284
 
285
- if push_result.returncode == 0:
286
- yield log(" Template subido exitosamente")
287
  else:
288
- yield log(f" Error en push: {push_result.stderr[:300]}")
289
  return
290
 
291
  space_url = f"https://huggingface.co/spaces/{repo_id}"
292
  yield log(f"""
293
- 🎉 **¡Instalación completa!**
294
 
295
  Tu HomePilot está en: [{repo_id}]({space_url})
296
 
297
  **Próximos pasos:**
298
- 1. Ve a **Settings → Hardware** en tu Space
299
- 2. Selecciona **GPU (T4 small)** para mejor rendimiento
300
- 3. O déjalo en **CPU basic** (funciona pero más lento)
301
- 4. Espera ~2 min para el primer arranque (descarga modelo)
302
 
303
- **Personas pre-instaladas:** 14 Chata personas listas para usar
304
- """)
305
 
306
  except Exception as e:
307
- yield log(f" Error inesperado: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
 
310
  # ── Gradio UI ────────────────────────────────────────────
311
 
312
  def build_ui():
313
- with gr.Blocks(
314
- title="HomePilot Installer",
315
- css=CUSTOM_CSS,
316
- theme=gr.themes.Soft(
317
- primary_hue="purple",
318
- secondary_hue="blue",
319
- neutral_hue="slate",
320
- ),
321
- ) as app:
322
-
323
- # ── Header ──────────────────────────────────
324
  gr.HTML("""
325
- <div class="main-header">
326
- <h1>🏠 HomePilot Installer</h1>
327
- <p>Instala tu propio HomePilot con IA privada en Hugging Face Spaces</p>
328
- <div style="margin-top: 1rem; display: flex; justify-content: center; gap: 8px;">
329
- <span class="persona-chip">14 Chata Personas</span>
330
- <span class="persona-chip">Ollama Built-in</span>
331
- <span class="persona-chip">GPU Ready</span>
 
332
  </div>
333
  </div>
334
  """)
335
 
336
- # ── Step 1: Auth ────────────────────────────
337
- with gr.Group(elem_classes="step-card"):
338
- gr.Markdown("### 🔑 Paso 1 — Autenticación")
339
- gr.Markdown(
340
- "Necesitas un [token de Hugging Face](https://huggingface.co/settings/tokens) "
341
- "con permisos de **write**."
342
- )
 
 
 
 
343
  with gr.Row():
344
- token_input = gr.Textbox(
345
- label="HF Token",
346
- placeholder="hf_...",
347
- type="password",
348
- scale=3,
349
- )
350
  verify_btn = gr.Button("Verificar", variant="primary", scale=1)
351
  auth_status = gr.Markdown("")
352
  username_state = gr.State("")
353
 
354
- # ── Step 2: Configure ───────────────────────
355
- with gr.Group(elem_classes="step-card"):
356
- gr.Markdown("### ⚙️ Paso 2 — Configuración")
 
 
 
 
 
 
 
 
357
  with gr.Row():
358
- space_name = gr.Textbox(
359
- label="Nombre del Space",
360
- value="HomePilot",
361
- placeholder="HomePilot",
362
- scale=2,
363
- )
364
- private_toggle = gr.Checkbox(
365
- label="Privado",
366
- value=True,
367
- scale=1,
368
- )
369
  model_choice = gr.Dropdown(
370
  label="Modelo LLM",
371
  choices=[
@@ -373,82 +511,56 @@ def build_ui():
373
  ("Qwen 2.5 3B (mejor calidad)", "qwen2.5:3b"),
374
  ("Llama 3 8B (poderoso, necesita GPU)", "llama3:8b"),
375
  ("Gemma 2B (equilibrado)", "gemma:2b"),
376
- ("Phi 3 Mini (Microsoft, compacto)", "phi3:mini"),
377
  ],
378
  value="qwen2.5:1.5b",
379
  )
380
 
381
- # Persona preview
382
- gr.Markdown("#### 🎭 Personas pre-instaladas")
 
 
 
383
  gr.HTML("""
384
- <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 6px; margin: 8px 0;">
385
- <div class="persona-chip">🌙 Lunalite</div>
386
- <div class="persona-chip">😎 Chillbro</div>
387
- <div class="persona-chip">🔍 Curiosa</div>
388
- <div class="persona-chip"> Hypekid</div>
389
- <div class="persona-chip">🔋 Volt Buddy</div>
390
- <div class="persona-chip">⚔️ Ronin Zero</div>
391
- <div class="persona-chip">🦖 Rival Kaiju</div>
392
- <div class="persona-chip">💾 Glitchbyte</div>
393
- <div class="persona-chip">🗺️ Questkid 99</div>
394
- <div class="persona-chip">🧠 Sigma Sage</div>
395
- <div class="persona-chip">🃏 Wildcard Loki</div>
396
- <div class="persona-chip">🌳 Oldroot Oracle</div>
397
- <div class="persona-chip">🔮 Morphling X</div>
398
- <div class="persona-chip">🌌 Nova Void</div>
399
  </div>
400
  """)
 
 
 
401
 
402
- # ── Step 3: Install ─────────────────────────
403
- with gr.Group(elem_classes="step-card"):
404
- gr.Markdown("### 🚀 Paso 3 — Instalación")
405
- install_btn = gr.Button(
406
- "Instalar HomePilot →",
407
- variant="primary",
408
- size="lg",
409
- )
410
- install_log = gr.Markdown("")
411
-
412
- # ── Footer ──────────────────────────────────
413
  gr.HTML("""
414
- <div style="text-align: center; padding: 2rem 0; color: #64748b; font-size: 0.8rem;">
415
- <p>HomePilot Installer · Tu IA, tu máquina, tu privacidad</p>
416
- <p style="margin-top: 4px;">
417
- <a href="https://github.com/ruslanmv/HomePilot" style="color: #a78bfa;">GitHub</a> ·
418
- <a href="https://huggingface.co/spaces/ruslanmv/HomePilot" style="color: #a78bfa;">Template Space</a> ·
419
- <a href="https://huggingface.co/spaces/ruslanmv/Chata" style="color: #a78bfa;">Chata</a>
 
420
  </p>
421
  </div>
422
  """)
423
 
424
- # ── Events ──────────────────────────────────
425
-
426
  def on_verify(token):
427
  status, username, message = validate_token(token)
428
- return message, username
 
429
 
430
- verify_btn.click(
431
- fn=on_verify,
432
- inputs=[token_input],
433
- outputs=[auth_status, username_state],
434
- )
435
-
436
- install_btn.click(
437
- fn=create_user_space,
438
- inputs=[token_input, username_state, space_name,
439
- private_toggle, model_choice],
440
- outputs=[install_log],
441
- )
442
 
443
  return app
444
 
445
 
446
- # ── Entry point ──────────────────────────────────────────
447
-
448
  if __name__ == "__main__":
449
  app = build_ui()
450
- app.launch(
451
- server_name="0.0.0.0",
452
- server_port=int(os.environ.get("PORT", "7860")),
453
- share=False,
454
- )
 
1
  """
2
+ HomePilot Installer — Enterprise Edition
3
 
4
+ Premium installer wizard that deploys HomePilot into a user's own
5
+ private HF Space. Design language matches ruslanmv.com/HomePilot:
6
+ dark theme, cyan→blue→purple gradients, glass cards, smooth transitions.
 
 
 
 
 
 
 
 
7
  """
8
 
9
  import json
10
  import os
11
+ import shutil
12
  import subprocess
13
  import tempfile
 
14
  from pathlib import Path
15
 
16
  import gradio as gr
17
 
 
 
18
  TEMPLATE_REPO = os.environ.get("TEMPLATE_REPO", "ruslanmv/HomePilot")
 
 
19
 
20
+ # ── Persona data ─────────────────────────────────────────
 
 
21
 
22
+ PERSONAS = {
23
+ "starter": [
24
+ ("🌙", "LunaLite", "Soft Greeter"),
25
+ ("😎", "ChillBro", "Casual Regular"),
26
+ ("🔍", "Curiosa", "Question Driver"),
27
+ ("⚡", "HypeKid", "Reaction Engine"),
28
+ ],
29
+ "retro": [
30
+ ("🔋", "VoltBuddy", "Electric Companion"),
31
+ ("⚔️", "RoninZero", "Lone Warrior"),
32
+ ("🦖", "RivalKaiju", "Chaos Rival"),
33
+ ("💾", "Glitchbyte", "Digital Glitch"),
34
+ ("🗺️", "QuestKid", "Young Adventurer"),
35
+ ("🧠", "SigmaSage", "Quiet Strategist"),
36
+ ("🃏", "Wildcard", "Trickster"),
37
+ ("🌳", "OldRoot", "Ancient Mentor"),
38
+ ("🔮", "MorphlingX", "Transformer"),
39
+ ("🌌", "NovaVoid", "Cosmic Entity"),
40
+ ],
41
+ }
42
 
43
+ # ── CSS: HomePilot enterprise theme ──────────────────────
44
+
45
+ CSS = """
46
+ /* ── Base ── */
47
+ :root {
48
+ --hp-bg: #09090b;
49
+ --hp-surface: #111113;
50
+ --hp-card: #161618;
51
+ --hp-border: rgba(255,255,255,0.06);
52
+ --hp-border-hover: rgba(255,255,255,0.14);
53
+ --hp-text: #e4e4e7;
54
+ --hp-muted: #71717a;
55
+ --hp-cyan: #06b6d4;
56
+ --hp-blue: #3b82f6;
57
+ --hp-purple: #8b5cf6;
58
+ --hp-gradient: linear-gradient(135deg, #06b6d4, #3b82f6, #8b5cf6);
59
+ --hp-glow: 0 0 24px rgba(59,130,246,0.15);
60
+ --hp-radius: 12px;
61
+ }
62
 
 
63
  .gradio-container {
64
+ background: var(--hp-bg) !important;
65
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
66
+ max-width: 900px !important;
67
+ margin: 0 auto !important;
68
  }
69
+
70
+ /* Remove default Gradio chrome */
71
+ .gradio-container .main, footer { background: transparent !important; }
72
+ footer { display: none !important; }
73
+
74
+ /* ── Hero ── */
75
+ .hp-hero {
76
  text-align: center;
77
+ padding: 56px 24px 40px;
78
+ position: relative;
79
+ overflow: hidden;
80
  }
81
+ .hp-hero::before {
82
+ content: '';
83
+ position: absolute;
84
+ top: -40%;
85
+ left: 50%;
86
+ transform: translateX(-50%);
87
+ width: 120%;
88
+ height: 100%;
89
+ background: radial-gradient(ellipse 60% 50% at 50% 0%, rgba(59,130,246,0.08), transparent);
90
+ pointer-events: none;
91
+ }
92
+ .hp-hero h1 {
93
+ font-size: clamp(2rem, 5vw, 2.8rem);
94
+ font-weight: 800;
95
+ letter-spacing: -0.03em;
96
+ line-height: 1.1;
97
+ margin: 0;
98
+ position: relative;
99
+ }
100
+ .hp-hero h1 .gradient-text {
101
+ background: var(--hp-gradient);
102
  -webkit-background-clip: text;
103
  -webkit-text-fill-color: transparent;
104
+ background-clip: text;
 
 
105
  }
106
+ .hp-hero .subtitle {
107
+ color: var(--hp-muted);
108
+ font-size: 15px;
109
+ font-weight: 500;
110
+ margin-top: 12px;
111
+ position: relative;
112
  }
113
+ .hp-hero .badges {
114
+ display: flex;
115
+ justify-content: center;
116
+ gap: 8px;
117
+ margin-top: 20px;
118
+ flex-wrap: wrap;
119
+ position: relative;
120
  }
121
+ .hp-badge {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ gap: 6px;
125
+ padding: 6px 14px;
126
  border-radius: 999px;
127
+ font-size: 12px;
128
+ font-weight: 600;
129
+ letter-spacing: 0.02em;
130
+ border: 1px solid var(--hp-border);
131
+ background: var(--hp-surface);
132
+ color: var(--hp-text);
133
+ transition: all 0.2s;
134
+ }
135
+ .hp-badge:hover { border-color: var(--hp-border-hover); }
136
+ .hp-badge .dot {
137
+ width: 6px; height: 6px;
138
+ border-radius: 50%;
139
+ background: var(--hp-gradient);
140
+ }
141
+
142
+ /* ── Step cards ── */
143
+ .hp-step {
144
+ background: var(--hp-card) !important;
145
+ border: 1px solid var(--hp-border) !important;
146
+ border-radius: var(--hp-radius) !important;
147
+ padding: 28px !important;
148
+ margin-bottom: 16px !important;
149
+ transition: border-color 0.2s !important;
150
+ }
151
+ .hp-step:hover { border-color: var(--hp-border-hover) !important; }
152
+ .hp-step-header {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 12px;
156
+ margin-bottom: 16px;
157
+ }
158
+ .hp-step-num {
159
+ width: 32px; height: 32px;
160
+ border-radius: 10px;
161
+ background: var(--hp-gradient);
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: center;
165
+ font-size: 14px;
166
+ font-weight: 800;
167
+ color: white;
168
+ flex-shrink: 0;
169
+ }
170
+ .hp-step-title {
171
+ font-size: 17px;
172
+ font-weight: 700;
173
+ color: var(--hp-text);
174
+ letter-spacing: -0.01em;
175
+ }
176
+ .hp-step-desc {
177
+ font-size: 13px;
178
+ color: var(--hp-muted);
179
+ margin-top: 2px;
180
+ }
181
+
182
+ /* ── Inputs ── */
183
+ .hp-step input, .hp-step textarea {
184
+ background: var(--hp-bg) !important;
185
+ border: 1px solid var(--hp-border) !important;
186
+ border-radius: 10px !important;
187
+ color: var(--hp-text) !important;
188
+ font-size: 15px !important;
189
+ padding: 12px 16px !important;
190
+ transition: border-color 0.2s !important;
191
+ }
192
+ .hp-step input:focus, .hp-step textarea:focus {
193
+ border-color: var(--hp-blue) !important;
194
+ box-shadow: 0 0 0 3px rgba(59,130,246,0.1) !important;
195
+ }
196
+ .hp-step label { color: var(--hp-muted) !important; font-weight: 600 !important; font-size: 13px !important; }
197
+
198
+ /* ── Persona grid ── */
199
+ .hp-personas {
200
+ background: var(--hp-card);
201
+ border: 1px solid var(--hp-border);
202
+ border-radius: var(--hp-radius);
203
+ padding: 24px;
204
+ margin-top: 16px;
205
+ }
206
+ .hp-personas-title {
207
+ font-size: 14px;
208
+ font-weight: 700;
209
+ color: var(--hp-text);
210
+ margin-bottom: 4px;
211
+ }
212
+ .hp-personas-sub {
213
+ font-size: 12px;
214
+ color: var(--hp-muted);
215
+ margin-bottom: 16px;
216
+ }
217
+ .hp-pack-label {
218
+ font-size: 10px;
219
  font-weight: 700;
220
  text-transform: uppercase;
221
+ letter-spacing: 0.08em;
222
+ color: var(--hp-muted);
223
+ margin-bottom: 8px;
224
+ margin-top: 16px;
225
  }
226
+ .hp-pack-label:first-child { margin-top: 0; }
227
+ .hp-grid {
228
  display: grid;
229
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
230
  gap: 8px;
 
231
  }
232
+ .hp-chip {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 6px;
236
  padding: 8px 12px;
237
+ border-radius: 10px;
238
+ font-size: 12px;
239
  font-weight: 600;
240
+ color: var(--hp-text);
241
+ background: var(--hp-bg);
242
+ border: 1px solid var(--hp-border);
243
+ transition: all 0.2s;
244
+ white-space: nowrap;
245
+ overflow: hidden;
246
  }
247
+ .hp-chip:hover {
248
+ border-color: var(--hp-border-hover);
249
+ transform: translateY(-1px);
250
+ }
251
+ .hp-chip .emoji { font-size: 14px; }
252
 
253
+ /* ── Install button ── */
254
+ .hp-install-btn {
255
+ background: var(--hp-gradient) !important;
256
+ border: none !important;
257
+ border-radius: 12px !important;
258
+ color: white !important;
259
+ font-size: 16px !important;
260
+ font-weight: 700 !important;
261
+ letter-spacing: -0.01em !important;
262
+ padding: 14px 32px !important;
263
+ cursor: pointer !important;
264
+ box-shadow: var(--hp-glow) !important;
265
+ transition: all 0.2s !important;
266
+ width: 100% !important;
267
+ }
268
+ .hp-install-btn:hover {
269
+ transform: translateY(-1px) !important;
270
+ box-shadow: 0 0 32px rgba(59,130,246,0.25) !important;
271
+ }
272
 
273
+ /* ── Log output ── */
274
+ .hp-log {
275
+ background: var(--hp-bg) !important;
276
+ border: 1px solid var(--hp-border) !important;
277
+ border-radius: var(--hp-radius) !important;
278
+ padding: 20px !important;
279
+ font-family: 'SF Mono', 'Fira Code', monospace !important;
280
+ font-size: 13px !important;
281
+ line-height: 1.7 !important;
282
+ color: var(--hp-text) !important;
283
+ min-height: 60px !important;
284
  }
285
 
286
+ /* ── Footer ── */
287
+ .hp-footer {
288
+ text-align: center;
289
+ padding: 32px 16px;
290
+ border-top: 1px solid var(--hp-border);
291
+ margin-top: 32px;
292
+ }
293
+ .hp-footer p { color: var(--hp-muted); font-size: 12px; margin: 4px 0; }
294
+ .hp-footer a { color: var(--hp-blue); text-decoration: none; }
295
+ .hp-footer a:hover { color: var(--hp-cyan); }
296
+ """
297
+
298
+ # ── Core functions ───────────────────────────────────────
299
 
300
 
301
  def validate_token(token: str) -> tuple[str, str, str]:
 
302
  if not token or len(token) < 10:
303
+ return "error", "", "Token vacío o inválido"
 
304
  try:
305
+ import requests
306
+ r = requests.get(
307
+ "https://huggingface.co/api/whoami-v2",
308
+ headers={"Authorization": f"Bearer {token}"},
309
+ timeout=10,
 
 
 
 
 
 
 
310
  )
311
+ if r.ok:
312
+ name = r.json().get("name", "")
313
+ return "ok", name, f"Autenticado como **{name}**"
314
+ return "error", "", f"Token rechazado (HTTP {r.status_code})"
 
 
315
  except Exception as e:
316
+ return "error", "", f"Error de conexión: {e}"
317
+
318
 
319
+ def install_space(token, username, space_name, private, model):
320
+ if not username:
321
+ yield "⚠️ Primero verifica tu token en el Paso 1"
322
+ return
323
 
 
 
 
324
  repo_id = f"{username}/{space_name}"
325
+ lines = []
326
 
327
  def log(msg):
328
+ lines.append(msg)
329
+ return "\n".join(lines)
330
 
331
+ yield log(f" Creando Space **{repo_id}**...")
332
 
333
  try:
 
334
  import requests
335
 
336
  r = requests.post(
337
  "https://huggingface.co/api/repos/create",
338
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
339
+ json={"type": "space", "name": space_name, "private": private, "sdk": "docker"},
 
 
 
 
 
 
 
 
340
  timeout=30,
341
  )
342
  if r.ok:
343
+ yield log(" Space creado")
344
  elif r.status_code == 409:
345
+ yield log(" Space ya existe — actualizando")
346
  else:
347
+ yield log(f" Error: {r.status_code} {r.text[:200]}")
348
  return
349
 
350
+ yield log("▸ Clonando template HomePilot...")
 
351
 
352
  with tempfile.TemporaryDirectory() as tmpdir:
353
  remote = f"https://user:{token}@huggingface.co/spaces/{repo_id}"
354
+ template_remote = f"https://user:{token}@huggingface.co/spaces/{TEMPLATE_REPO}"
355
 
356
+ subprocess.run(
 
357
  ["git", "-c", "credential.helper=", "clone", "--depth", "1",
358
+ template_remote, f"{tmpdir}/template"],
359
+ capture_output=True, timeout=60,
360
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
+ clone = subprocess.run(
 
 
 
 
 
363
  ["git", "-c", "credential.helper=", "clone", "--depth", "1",
364
+ remote, f"{tmpdir}/space"],
365
+ capture_output=True, timeout=30,
366
  )
367
+ if clone.returncode != 0:
368
+ os.makedirs(f"{tmpdir}/space", exist_ok=True)
369
+ subprocess.run(["git", "init", "-b", "main", f"{tmpdir}/space"], capture_output=True)
370
+ subprocess.run(["git", "-C", f"{tmpdir}/space", "remote", "add", "origin", remote], capture_output=True)
371
 
372
+ space_dir = f"{tmpdir}/space"
373
+ for item in Path(space_dir).iterdir():
374
+ if item.name != ".git":
375
+ shutil.rmtree(item) if item.is_dir() else item.unlink()
376
+
377
+ yield log("▸ Copiando archivos...")
378
+ for item in Path(f"{tmpdir}/template").iterdir():
379
  if item.name == ".git":
380
  continue
381
  dest = Path(space_dir) / item.name
382
+ shutil.copytree(item, dest) if item.is_dir() else shutil.copy2(item, dest)
383
+
384
+ start_sh = Path(space_dir) / "start.sh"
385
+ if start_sh.exists():
386
+ content = start_sh.read_text()
387
+ content = content.replace("OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:1.5b}",
388
+ f"OLLAMA_MODEL=${{OLLAMA_MODEL:-{model}}}")
389
+ start_sh.write_text(content)
390
+
391
+ yield log(f" Modelo: **{model}**")
392
+ yield log("▸ Subiendo a Hugging Face...")
393
+
394
+ subprocess.run(["git", "lfs", "install", "--local"], capture_output=True, cwd=space_dir)
395
+ subprocess.run(["git", "lfs", "track", "*.hpersona", "*.png", "*.webp"],
396
+ capture_output=True, cwd=space_dir)
397
+ subprocess.run(["git", "-C", space_dir, "-c", "user.[email protected]",
398
+ "-c", "user.name=HomePilot Installer", "add", "-A"],
399
+ capture_output=True, timeout=30)
400
+ subprocess.run(["git", "-C", space_dir, "-c", "user.[email protected]",
401
+ "-c", "user.name=HomePilot Installer", "commit", "-m",
402
+ f"HomePilot installed ({model})"],
403
+ capture_output=True, timeout=30)
404
+ push = subprocess.run(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  ["git", "-C", space_dir, "push", "--force", remote, "HEAD:main"],
406
  capture_output=True, text=True, timeout=120,
407
  )
408
 
409
+ if push.returncode == 0:
410
+ yield log(" Subido exitosamente")
411
  else:
412
+ yield log(f" Push error: {push.stderr[:300]}")
413
  return
414
 
415
  space_url = f"https://huggingface.co/spaces/{repo_id}"
416
  yield log(f"""
417
+ **Instalación completa**
418
 
419
  Tu HomePilot está en: [{repo_id}]({space_url})
420
 
421
  **Próximos pasos:**
422
+ 1. Ve a **Settings → Hardware** y selecciona GPU (opcional)
423
+ 2. Espera ~3 min para el primer arranque
424
+ 3. El modelo se descarga automáticamente
 
425
 
426
+ **14 Chata personas** listas para usar.""")
 
427
 
428
  except Exception as e:
429
+ yield log(f" Error: {e}")
430
+
431
+
432
+ # ── Build personas HTML ──────────────────────────────────
433
+
434
+ def _persona_html():
435
+ parts = ['<div class="hp-personas">']
436
+ parts.append('<div class="hp-personas-title">Personas pre-instaladas</div>')
437
+ parts.append('<div class="hp-personas-sub">14 AI personas de Chata — se importan automáticamente al iniciar</div>')
438
+
439
+ parts.append('<div class="hp-pack-label">Starter Pack</div>')
440
+ parts.append('<div class="hp-grid">')
441
+ for emoji, name, _ in PERSONAS["starter"]:
442
+ parts.append(f'<div class="hp-chip"><span class="emoji">{emoji}</span>{name}</div>')
443
+ parts.append('</div>')
444
+
445
+ parts.append('<div class="hp-pack-label">Retro Pack</div>')
446
+ parts.append('<div class="hp-grid">')
447
+ for emoji, name, _ in PERSONAS["retro"]:
448
+ parts.append(f'<div class="hp-chip"><span class="emoji">{emoji}</span>{name}</div>')
449
+ parts.append('</div>')
450
+
451
+ parts.append('</div>')
452
+ return "\n".join(parts)
453
 
454
 
455
  # ── Gradio UI ────────────────────────────────────────────
456
 
457
  def build_ui():
458
+ with gr.Blocks(css=CSS, title="HomePilot Installer", theme=gr.themes.Soft(
459
+ primary_hue="blue", secondary_hue="purple", neutral_hue="zinc",
460
+ )) as app:
461
+
462
+ # ── Hero ──
 
 
 
 
 
 
463
  gr.HTML("""
464
+ <div class="hp-hero">
465
+ <h1>🏠 <span class="gradient-text">HomePilot Installer</span></h1>
466
+ <p class="subtitle">Instala tu propio HomePilot con IA privada en Hugging Face Spaces</p>
467
+ <div class="badges">
468
+ <span class="hp-badge"><span class="dot"></span>14 Chata Personas</span>
469
+ <span class="hp-badge"><span class="dot"></span>Ollama Built-in</span>
470
+ <span class="hp-badge"><span class="dot"></span>GPU Ready</span>
471
+ <span class="hp-badge"><span class="dot"></span>Private Space</span>
472
  </div>
473
  </div>
474
  """)
475
 
476
+ # ── Step 1: Auth ──
477
+ with gr.Group(elem_classes="hp-step"):
478
+ gr.HTML("""
479
+ <div class="hp-step-header">
480
+ <div class="hp-step-num">1</div>
481
+ <div>
482
+ <div class="hp-step-title">Autenticación</div>
483
+ <div class="hp-step-desc">Necesitas un <a href="https://huggingface.co/settings/tokens" target="_blank" style="color: var(--hp-blue)">token de HF</a> con permisos de write.</div>
484
+ </div>
485
+ </div>
486
+ """)
487
  with gr.Row():
488
+ token_input = gr.Textbox(label="HF Token", placeholder="hf_...", type="password", scale=3)
 
 
 
 
 
489
  verify_btn = gr.Button("Verificar", variant="primary", scale=1)
490
  auth_status = gr.Markdown("")
491
  username_state = gr.State("")
492
 
493
+ # ── Step 2: Config ──
494
+ with gr.Group(elem_classes="hp-step"):
495
+ gr.HTML("""
496
+ <div class="hp-step-header">
497
+ <div class="hp-step-num">2</div>
498
+ <div>
499
+ <div class="hp-step-title">Configuración</div>
500
+ <div class="hp-step-desc">Personaliza tu instalación</div>
501
+ </div>
502
+ </div>
503
+ """)
504
  with gr.Row():
505
+ space_name = gr.Textbox(label="Nombre del Space", value="HomePilot", scale=2)
506
+ private_toggle = gr.Checkbox(label="Privado", value=True, scale=1)
 
 
 
 
 
 
 
 
 
507
  model_choice = gr.Dropdown(
508
  label="Modelo LLM",
509
  choices=[
 
511
  ("Qwen 2.5 3B (mejor calidad)", "qwen2.5:3b"),
512
  ("Llama 3 8B (poderoso, necesita GPU)", "llama3:8b"),
513
  ("Gemma 2B (equilibrado)", "gemma:2b"),
514
+ ("Phi 3 Mini (compacto)", "phi3:mini"),
515
  ],
516
  value="qwen2.5:1.5b",
517
  )
518
 
519
+ # ── Persona preview ──
520
+ gr.HTML(_persona_html())
521
+
522
+ # ── Step 3: Install ──
523
+ with gr.Group(elem_classes="hp-step"):
524
  gr.HTML("""
525
+ <div class="hp-step-header">
526
+ <div class="hp-step-num">3</div>
527
+ <div>
528
+ <div class="hp-step-title">Instalación</div>
529
+ <div class="hp-step-desc">Un clic para desplegar tu HomePilot privado</div>
530
+ </div>
 
 
 
 
 
 
 
 
 
531
  </div>
532
  """)
533
+ install_btn = gr.Button("Instalar HomePilot →", variant="primary",
534
+ size="lg", elem_classes="hp-install-btn")
535
+ install_log = gr.Markdown("", elem_classes="hp-log")
536
 
537
+ # ── Footer ──
 
 
 
 
 
 
 
 
 
 
538
  gr.HTML("""
539
+ <div class="hp-footer">
540
+ <p style="font-size: 13px; color: var(--hp-text); font-weight: 600;">HomePilot Enterprise AI Assistant</p>
541
+ <p>
542
+ <a href="https://github.com/ruslanmv/HomePilot">GitHub</a> ·
543
+ <a href="https://huggingface.co/spaces/ruslanmv/HomePilot">Template</a> ·
544
+ <a href="https://huggingface.co/spaces/ruslanmv/Chata">Chata</a> ·
545
+ <a href="https://ruslanmv.com/HomePilot/">Web</a>
546
  </p>
547
  </div>
548
  """)
549
 
550
+ # ── Events ──
 
551
  def on_verify(token):
552
  status, username, message = validate_token(token)
553
+ icon = "✓" if status == "ok" else "✗"
554
+ return f"{icon} {message}", username
555
 
556
+ verify_btn.click(fn=on_verify, inputs=[token_input], outputs=[auth_status, username_state])
557
+ install_btn.click(fn=install_space,
558
+ inputs=[token_input, username_state, space_name, private_toggle, model_choice],
559
+ outputs=[install_log])
 
 
 
 
 
 
 
 
560
 
561
  return app
562
 
563
 
 
 
564
  if __name__ == "__main__":
565
  app = build_ui()
566
+ app.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")), share=False)