HomePilot Deploy Bot commited on
Commit
f0ae034
·
unverified ·
1 Parent(s): 632cf08

chore: sync installer from monorepo

Browse files
Files changed (5) hide show
  1. Dockerfile +18 -0
  2. README.md +3 -27
  3. requirements.txt +3 -3
  4. server.py +154 -0
  5. static/index.html +560 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ curl git git-lfs ca-certificates \
5
+ && rm -rf /var/lib/apt/lists/* \
6
+ && git lfs install \
7
+ && useradd -m -u 1000 app
8
+
9
+ WORKDIR /app
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+ RUN chown -R app:app /app
15
+ USER app
16
+
17
+ EXPOSE 7860
18
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -3,32 +3,8 @@ title: HomePilot Installer
3
  emoji: 🏠
4
  colorFrom: indigo
5
  colorTo: purple
6
- sdk: gradio
7
- sdk_version: 5.29.0
8
- app_file: app.py
9
  pinned: true
10
- short_description: Install HomePilot into your own HF Space
11
  ---
12
-
13
- # HomePilot Installer
14
-
15
- Install your own private HomePilot AI assistant on Hugging Face Spaces.
16
-
17
- ## What it does
18
-
19
- 1. Authenticates with your HF token
20
- 2. Creates a new private Docker Space in your account
21
- 3. Pushes the HomePilot template (backend + frontend + Ollama + 14 Chata personas)
22
- 4. Guides you to enable GPU hardware
23
-
24
- ## Requirements
25
-
26
- - A [Hugging Face account](https://huggingface.co/join)
27
- - A [write-access token](https://huggingface.co/settings/tokens)
28
- - (Optional) GPU hardware on the target Space for better performance
29
-
30
- ## Pre-installed content
31
-
32
- - **14 Chata social personas** (Starter + Retro packs)
33
- - **Ollama** with qwen2.5:1.5b (auto-pulled on first start)
34
- - **HomePilot** full-stack (FastAPI backend + React frontend)
 
3
  emoji: 🏠
4
  colorFrom: indigo
5
  colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: true
9
+ short_description: Deploy your private AI assistant in 2 minutes
10
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,3 +1,3 @@
1
- gradio==5.29.0
2
- huggingface_hub>=0.30.0
3
- requests>=2.31
 
1
+ fastapi==0.115.12
2
+ uvicorn[standard]==0.34.1
3
+ httpx==0.28.1
server.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HomePilot Installer — API backend.
3
+
4
+ Three endpoints:
5
+ POST /api/verify — validate HF token, return username
6
+ POST /api/install — create Space, clone template, push
7
+ GET / — serve the installer HTML
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+ from fastapi import FastAPI, Request
18
+ from fastapi.responses import FileResponse, JSONResponse
19
+
20
+ app = FastAPI(title="HomePilot Installer")
21
+ TEMPLATE = os.environ.get("TEMPLATE_REPO", "ruslanmv/HomePilot")
22
+ STATIC_DIR = Path(__file__).parent / "static"
23
+
24
+
25
+ @app.get("/")
26
+ def index():
27
+ return FileResponse(STATIC_DIR / "index.html")
28
+
29
+
30
+ @app.get("/static/{path:path}")
31
+ def static_files(path: str):
32
+ f = STATIC_DIR / path
33
+ if f.exists() and f.is_file():
34
+ return FileResponse(f)
35
+ return JSONResponse({"error": "not found"}, 404)
36
+
37
+
38
+ @app.post("/api/verify")
39
+ async def verify(request: Request):
40
+ body = await request.json()
41
+ token = body.get("token", "")
42
+ if not token or len(token) < 8:
43
+ return JSONResponse({"ok": False, "error": "Token vacío"})
44
+ try:
45
+ async with httpx.AsyncClient(timeout=10) as c:
46
+ r = await c.get("https://huggingface.co/api/whoami-v2",
47
+ headers={"Authorization": f"Bearer {token}"})
48
+ if r.status_code == 200:
49
+ name = r.json().get("name", "")
50
+ return {"ok": True, "username": name}
51
+ return JSONResponse({"ok": False, "error": f"HTTP {r.status_code}"})
52
+ except Exception as e:
53
+ return JSONResponse({"ok": False, "error": str(e)})
54
+
55
+
56
+ @app.post("/api/install")
57
+ async def install(request: Request):
58
+ body = await request.json()
59
+ token = body.get("token", "")
60
+ username = body.get("username", "")
61
+ space_name = body.get("space_name", "HomePilot")
62
+ private = body.get("private", True)
63
+ model = body.get("model", "qwen2.5:1.5b")
64
+
65
+ if not token or not username:
66
+ return JSONResponse({"ok": False, "error": "Missing token/username"})
67
+
68
+ repo_id = f"{username}/{space_name}"
69
+ steps = []
70
+
71
+ try:
72
+ # 1. Create Space
73
+ async with httpx.AsyncClient(timeout=30) as c:
74
+ r = await c.post("https://huggingface.co/api/repos/create",
75
+ headers={"Authorization": f"Bearer {token}",
76
+ "Content-Type": "application/json"},
77
+ json={"type": "space", "name": space_name,
78
+ "private": private, "sdk": "docker"})
79
+ if r.status_code in (200, 201):
80
+ steps.append("Space creado")
81
+ elif r.status_code == 409:
82
+ steps.append("Space existente — actualizando")
83
+ else:
84
+ return JSONResponse({"ok": False, "error": f"Create failed: {r.text[:200]}",
85
+ "steps": steps})
86
+
87
+ # 2. Clone template + push
88
+ with tempfile.TemporaryDirectory() as tmp:
89
+ remote = f"https://user:{token}@huggingface.co/spaces/{repo_id}"
90
+ tpl_remote = f"https://user:{token}@huggingface.co/spaces/{TEMPLATE}"
91
+
92
+ subprocess.run(["git", "-c", "credential.helper=", "clone", "--depth", "1",
93
+ tpl_remote, f"{tmp}/tpl"], capture_output=True, timeout=60)
94
+
95
+ clone = subprocess.run(["git", "-c", "credential.helper=", "clone", "--depth", "1",
96
+ remote, f"{tmp}/sp"], capture_output=True, timeout=30)
97
+ sp = Path(f"{tmp}/sp")
98
+ if clone.returncode != 0:
99
+ sp.mkdir(parents=True, exist_ok=True)
100
+ subprocess.run(["git", "init", "-b", "main", str(sp)], capture_output=True)
101
+ subprocess.run(["git", "-C", str(sp), "remote", "add", "origin", remote],
102
+ capture_output=True)
103
+
104
+ for item in sp.iterdir():
105
+ if item.name != ".git":
106
+ shutil.rmtree(item) if item.is_dir() else item.unlink()
107
+
108
+ tpl = Path(f"{tmp}/tpl")
109
+ for item in tpl.iterdir():
110
+ if item.name == ".git":
111
+ continue
112
+ dest = sp / item.name
113
+ shutil.copytree(item, dest) if item.is_dir() else shutil.copy2(item, dest)
114
+
115
+ # Copy .gitattributes for LFS
116
+ ga = tpl / ".gitattributes"
117
+ if ga.exists():
118
+ shutil.copy2(ga, sp / ".gitattributes")
119
+
120
+ steps.append("Template clonado")
121
+
122
+ # Patch model
123
+ start = sp / "start.sh"
124
+ if start.exists():
125
+ start.write_text(start.read_text().replace(
126
+ "OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:1.5b}",
127
+ f"OLLAMA_MODEL=${{OLLAMA_MODEL:-{model}}}"))
128
+ steps.append(f"Modelo: {model}")
129
+
130
+ # Git push
131
+ subprocess.run(["git", "lfs", "install", "--local"],
132
+ capture_output=True, cwd=str(sp))
133
+ subprocess.run(["git", "lfs", "track", "*.hpersona", "*.png", "*.webp"],
134
+ capture_output=True, cwd=str(sp))
135
+ subprocess.run(["git", "-C", str(sp), "-c", "user.email=i@hp.dev",
136
+ "-c", "user.name=HP", "add", "-A"],
137
+ capture_output=True, timeout=30)
138
+ subprocess.run(["git", "-C", str(sp), "-c", "user.email=i@hp.dev",
139
+ "-c", "user.name=HP", "commit", "-m",
140
+ f"HomePilot installed ({model})"],
141
+ capture_output=True, timeout=30)
142
+ push = subprocess.run(["git", "-C", str(sp), "push", "--force",
143
+ remote, "HEAD:main"],
144
+ capture_output=True, text=True, timeout=120)
145
+ if push.returncode != 0:
146
+ return JSONResponse({"ok": False, "error": push.stderr[:300], "steps": steps})
147
+
148
+ steps.append("Desplegado")
149
+
150
+ url = f"https://huggingface.co/spaces/{repo_id}"
151
+ return {"ok": True, "repo_id": repo_id, "url": url, "steps": steps}
152
+
153
+ except Exception as e:
154
+ return JSONResponse({"ok": False, "error": str(e), "steps": steps})
static/index.html ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
6
+ <title>HomePilot — Deploy Your Private AI</title>
7
+ <style>
8
+ /* ── Reset + Base ── */
9
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
10
+ :root{
11
+ --bg:#09090b; --surface:#111113; --card:#161618; --elevated:#1c1c1f;
12
+ --border:rgba(255,255,255,0.06); --border-h:rgba(255,255,255,0.12);
13
+ --text:#e4e4e7; --muted:#a1a1aa; --dim:#71717a; --faint:#3f3f46;
14
+ --cyan:#06b6d4; --blue:#3b82f6; --purple:#8b5cf6; --pink:#ec4899; --green:#22c55e;
15
+ --grad:linear-gradient(135deg,#06b6d4,#3b82f6,#8b5cf6);
16
+ --glow:0 0 40px rgba(59,130,246,0.12);
17
+ --r:12px;
18
+ --font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
19
+ }
20
+ html{background:var(--bg);color:var(--text);font-family:var(--font)}
21
+ body{min-height:100dvh;display:flex;flex-direction:column;align-items:center}
22
+ a{color:var(--blue);text-decoration:none}
23
+ a:hover{color:var(--cyan)}
24
+
25
+ /* ── Layout ── */
26
+ .wrap{width:100%;max-width:580px;padding:0 20px}
27
+
28
+ /* ── Hero ── */
29
+ .hero{text-align:center;padding:56px 20px 8px;position:relative}
30
+ .hero::before{content:'';position:absolute;top:-50%;left:50%;transform:translateX(-50%);
31
+ width:140%;height:120%;background:radial-gradient(ellipse 50% 40% at 50% 0%,rgba(59,130,246,0.1),transparent);pointer-events:none}
32
+ .hero-icon{font-size:52px;position:relative}
33
+ .hero h1{font-size:clamp(1.7rem,4.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;
34
+ line-height:1.15;margin-top:16px;position:relative}
35
+ .hero h1 span{background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
36
+ .hero p{color:var(--dim);font-size:14px;line-height:1.6;margin:10px auto 0;max-width:420px;position:relative}
37
+
38
+ /* ── Trust bar ── */
39
+ .trust{display:flex;justify-content:center;gap:20px;padding:24px 0 36px;flex-wrap:wrap}
40
+ .trust-item{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--muted)}
41
+
42
+ /* ── Steps ── */
43
+ .step{margin-bottom:20px}
44
+ .step-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
45
+ .step-num{width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;
46
+ font-size:12px;font-weight:800;color:white;flex-shrink:0}
47
+ .step-num.s1{background:linear-gradient(135deg,var(--cyan),var(--blue))}
48
+ .step-num.s2{background:linear-gradient(135deg,var(--blue),var(--purple))}
49
+ .step-num.s3{background:linear-gradient(135deg,var(--purple),var(--pink))}
50
+ .step-label{font-size:15px;font-weight:700;color:var(--text)}
51
+ .step-desc{font-size:12px;color:var(--dim);margin-top:1px}
52
+
53
+ /* ── Card ── */
54
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
55
+ padding:20px;transition:border-color .2s}
56
+ .card:hover{border-color:var(--border-h)}
57
+
58
+ /* ── Inputs ── */
59
+ .field{margin-bottom:14px}
60
+ .field:last-child{margin-bottom:0}
61
+ .field label{display:block;font-size:11px;font-weight:600;color:var(--dim);
62
+ text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}
63
+ .field input,.field select{width:100%;height:44px;padding:0 14px;font-size:15px;font-family:var(--font);
64
+ color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:10px;
65
+ outline:none;transition:border-color .2s}
66
+ .field input:focus,.field select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(59,130,246,.1)}
67
+ .field input::placeholder{color:var(--faint)}
68
+ .field select{appearance:none;cursor:pointer;
69
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
70
+ background-repeat:no-repeat;background-position:right 14px center}
71
+ .field select option{background:var(--card);color:var(--text)}
72
+ .row{display:flex;gap:12px}
73
+ .row>.field{flex:1}
74
+
75
+ /* ── Toggle ── */
76
+ .toggle{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--muted);
77
+ user-select:none;height:44px}
78
+ .toggle input{display:none}
79
+ .toggle .track{width:36px;height:20px;border-radius:10px;background:var(--faint);position:relative;transition:.2s}
80
+ .toggle input:checked+.track{background:var(--blue)}
81
+ .toggle .track::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;
82
+ border-radius:50%;background:white;transition:.2s}
83
+ .toggle input:checked+.track::after{left:18px}
84
+
85
+ /* ── Buttons ── */
86
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:44px;
87
+ padding:0 24px;border:none;border-radius:10px;font-size:14px;font-weight:700;
88
+ font-family:var(--font);cursor:pointer;transition:all .2s;letter-spacing:-.01em}
89
+ .btn-primary{background:var(--grad);color:white;box-shadow:var(--glow);width:100%}
90
+ .btn-primary:hover{transform:translateY(-1px);box-shadow:0 0 48px rgba(59,130,246,.2)}
91
+ .btn-primary:disabled{opacity:.5;cursor:not-allowed;transform:none}
92
+ .btn-sm{height:36px;padding:0 16px;font-size:13px;border-radius:8px;background:var(--blue);color:white}
93
+ .btn-sm:hover{background:var(--cyan)}
94
+ .btn-sm:disabled{opacity:.5;cursor:not-allowed}
95
+ .btn-outline{height:40px;padding:0 20px;font-size:13px;border-radius:8px;
96
+ background:transparent;border:1px solid var(--border);color:var(--muted)}
97
+ .btn-outline:hover{border-color:var(--border-h);color:var(--text)}
98
+
99
+ /* ── Status ── */
100
+ .status{font-size:13px;margin-top:8px;min-height:20px;font-weight:500}
101
+ .status.ok{color:var(--green)} .status.err{color:#ef4444}
102
+
103
+ /* ── Accordion ── */
104
+ .accordion-btn{width:100%;display:flex;align-items:center;justify-content:space-between;
105
+ padding:14px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
106
+ color:var(--muted);font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;
107
+ font-family:var(--font)}
108
+ .accordion-btn:hover{border-color:var(--border-h);color:var(--text)}
109
+ .accordion-btn .arrow{transition:transform .2s}
110
+ .accordion-btn.open .arrow{transform:rotate(180deg)}
111
+ .accordion-body{overflow:hidden;max-height:0;transition:max-height .3s ease}
112
+ .accordion-body.open{max-height:500px}
113
+ .accordion-inner{padding:16px;background:var(--surface);border:1px solid var(--border);
114
+ border-top:none;border-radius:0 0 var(--r) var(--r)}
115
+
116
+ /* ── Persona chips ── */
117
+ .pack-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
118
+ color:var(--dim);margin:12px 0 6px}
119
+ .pack-label:first-child{margin-top:0}
120
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
121
+ .chip{padding:5px 10px;border-radius:8px;font-size:12px;font-weight:500;color:var(--text);
122
+ background:var(--card);border:1px solid var(--border);transition:all .15s;white-space:nowrap}
123
+ .chip:hover{border-color:var(--border-h);transform:translateY(-1px)}
124
+
125
+ /* ── Log ── */
126
+ .log{background:var(--bg);border:1px solid var(--border);border-radius:var(--r);
127
+ padding:16px;margin-top:12px;font-family:'SF Mono','Fira Code',monospace;font-size:13px;
128
+ line-height:1.8;color:var(--muted);min-height:0;display:none;white-space:pre-wrap}
129
+ .log.visible{display:block}
130
+ .log .ok{color:var(--green)} .log .info{color:var(--blue)} .log .err{color:#ef4444}
131
+
132
+ /* ── Success ── */
133
+ .success{text-align:center;padding:32px 20px;display:none}
134
+ .success.visible{display:block}
135
+ .success h2{font-size:22px;font-weight:800;margin:12px 0 8px}
136
+ .success p{color:var(--dim);font-size:14px;margin-bottom:20px}
137
+ .success .links{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
138
+
139
+ /* ── Footer ── */
140
+ .footer{padding:40px 0 24px;text-align:center;border-top:1px solid var(--border);
141
+ margin-top:auto;width:100%;max-width:580px}
142
+ .footer p{font-size:12px;color:var(--faint)}
143
+
144
+ /* ── Lang picker ── */
145
+ .lang-btn{background:transparent;border:1px solid var(--border);color:var(--dim);
146
+ padding:4px 10px;border-radius:6px;font-size:11px;font-weight:600;cursor:pointer;
147
+ font-family:var(--font);transition:all .15s}
148
+ .lang-btn:hover{border-color:var(--border-h);color:var(--text)}
149
+ .lang-btn.active{background:var(--blue);border-color:var(--blue);color:white}
150
+
151
+ /* ── Responsive ── */
152
+ @media(max-width:480px){
153
+ .hero{padding-top:40px}
154
+ .trust{gap:12px}
155
+ .row{flex-direction:column;gap:8px}
156
+ }
157
+
158
+ /* ── Animations ── */
159
+ @keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
160
+ .step{animation:fadeUp .4s ease both}
161
+ .step:nth-child(2){animation-delay:.1s}
162
+ .step:nth-child(3){animation-delay:.2s}
163
+ @keyframes spin{to{transform:rotate(360deg)}}
164
+ .spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);
165
+ border-top-color:white;border-radius:50%;animation:spin .6s linear infinite}
166
+ </style>
167
+ </head>
168
+ <body>
169
+
170
+ <!-- ── HERO ── -->
171
+ <div class="hero">
172
+ <div class="hero-icon">🏠</div>
173
+ <h1><span>Tu IA privada en 2 minutos</span></h1>
174
+ <p>Despliega HomePilot con Ollama y 14 personas AI en tu propio Hugging Face Space. Sin código. Privado por defecto.</p>
175
+ </div>
176
+
177
+ <!-- Language picker -->
178
+ <div style="display:flex;justify-content:center;gap:4px;padding:0 0 8px">
179
+ <button class="lang-btn active" data-lang="en" onclick="setLang('en')">EN</button>
180
+ <button class="lang-btn" data-lang="es" onclick="setLang('es')">ES</button>
181
+ <button class="lang-btn" data-lang="pt" onclick="setLang('pt')">PT</button>
182
+ <button class="lang-btn" data-lang="fr" onclick="setLang('fr')">FR</button>
183
+ <button class="lang-btn" data-lang="de" onclick="setLang('de')">DE</button>
184
+ </div>
185
+
186
+ <div class="trust">
187
+ <div class="trust-item"><span style="color:var(--green)">🔒</span> Privado</div>
188
+ <div class="trust-item"><span style="color:var(--blue)">🧠</span> Ollama</div>
189
+ <div class="trust-item"><span style="color:var(--purple)">⚡</span> GPU ready</div>
190
+ <div class="trust-item"><span style="color:var(--pink)">🎭</span> 14 personas</div>
191
+ </div>
192
+
193
+ <div class="wrap" id="main-flow">
194
+
195
+ <!-- ── STEP 1 ── -->
196
+ <div class="step">
197
+ <div class="step-head">
198
+ <div class="step-num s1">1</div>
199
+ <div>
200
+ <div class="step-label" id="s1-label">Connect your account</div>
201
+ <div class="step-desc" id="s1-desc">A <a href="https://huggingface.co/settings/tokens" target="_blank">HF token</a> with write permission. We don't store credentials.</div>
202
+ </div>
203
+ </div>
204
+ <div class="card">
205
+ <div class="row">
206
+ <div class="field" style="flex:3">
207
+ <label id="lbl-token">Token</label>
208
+ <input type="password" id="token" placeholder="hf_..." autocomplete="off">
209
+ </div>
210
+ <div style="flex:1;display:flex;align-items:flex-end">
211
+ <button class="btn-sm" id="verify-btn" style="width:100%">Conectar</button>
212
+ </div>
213
+ </div>
214
+ <div class="status" id="auth-status"></div>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- ── STEP 2 ── -->
219
+ <div class="step">
220
+ <div class="step-head">
221
+ <div class="step-num s2">2</div>
222
+ <div>
223
+ <div class="step-label" id="s2-label">Configure</div>
224
+ <div class="step-desc" id="s2-desc">Everything has defaults — only change if you want.</div>
225
+ </div>
226
+ </div>
227
+ <div class="card">
228
+ <div class="row">
229
+ <div class="field">
230
+ <label id="lbl-space">Space name</label>
231
+ <input type="text" id="space-name" value="HomePilot">
232
+ </div>
233
+ <div style="display:flex;align-items:flex-end;padding-bottom:2px">
234
+ <label class="toggle" id="lbl-private">
235
+ <input type="checkbox" id="private" checked>
236
+ <div class="track"></div>
237
+ Private
238
+ </label>
239
+ </div>
240
+ </div>
241
+ <div class="field">
242
+ <label id="lbl-model">LLM Model</label>
243
+ <select id="model">
244
+ <option value="qwen2.5:1.5b" selected>qwen2.5 1.5b — fast, ideal to start</option>
245
+ <option value="qwen2.5:3b">qwen2.5 3b — better quality</option>
246
+ <option value="llama3:8b">llama3 8b — powerful (needs GPU)</option>
247
+ <option value="gemma:2b">gemma 2b — balanced</option>
248
+ </select>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <!-- ── PERSONAS ── -->
254
+ <div style="margin-bottom:20px">
255
+ <button class="accordion-btn" id="acc-btn">
256
+ 🎭 14 AI personas included
257
+ <svg class="arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
258
+ </button>
259
+ <div class="accordion-body" id="acc-body">
260
+ <div class="accordion-inner">
261
+ <p style="color:var(--dim);font-size:12px;margin-bottom:12px" id="acc-sub">Auto-imported on first start.</p>
262
+ <div class="pack-label">Starter Pack</div>
263
+ <div class="chips">
264
+ <div class="chip">🌙 LunaLite</div>
265
+ <div class="chip">😎 ChillBro</div>
266
+ <div class="chip">🔍 Curiosa</div>
267
+ <div class="chip">⚡ HypeKid</div>
268
+ </div>
269
+ <div class="pack-label">Retro Pack</div>
270
+ <div class="chips">
271
+ <div class="chip">🔋 Volt</div>
272
+ <div class="chip">⚔️ Ronin</div>
273
+ <div class="chip">🦖 Kaiju</div>
274
+ <div class="chip">💾 Glitch</div>
275
+ <div class="chip">🗺️ Quest</div>
276
+ <div class="chip">🧠 Sigma</div>
277
+ <div class="chip">🃏 Loki</div>
278
+ <div class="chip">🌳 OldRoot</div>
279
+ <div class="chip">🔮 Morphling</div>
280
+ <div class="chip">🌌 Nova</div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ </div>
285
+
286
+ <!-- ── STEP 3 ── -->
287
+ <div class="step">
288
+ <div class="step-head">
289
+ <div class="step-num s3">3</div>
290
+ <div>
291
+ <div class="step-label" id="s3-label">Deploy</div>
292
+ <div class="step-desc" id="s3-desc">One click. Your HomePilot will be ready in ~3 minutes.</div>
293
+ </div>
294
+ </div>
295
+ <button class="btn btn-primary" id="install-btn" disabled>
296
+ 🚀 Deploy HomePilot
297
+ </button>
298
+ <div class="log" id="log"></div>
299
+ </div>
300
+
301
+ <!-- ── SUCCESS ── -->
302
+ <div class="success" id="success">
303
+ <div style="font-size:48px">🎉</div>
304
+ <h2>Your HomePilot is running!</h2>
305
+ <p id="success-msg">Wait ~3 minutes for the first build.</p>
306
+ <div class="links">
307
+ <a class="btn-outline" id="open-space" href="#" target="_blank">Open Space →</a>
308
+ <a class="btn-outline" id="go-chata" href="https://huggingface.co/spaces/ruslanmv/Chata" target="_blank">Go to Chata</a>
309
+ </div>
310
+ </div>
311
+
312
+ </div>
313
+
314
+ <!-- ── FOOTER ── -->
315
+ <div class="footer">
316
+ <p>
317
+ <a href="https://ruslanmv.com/HomePilot/">HomePilot</a> ·
318
+ <a href="https://huggingface.co/spaces/ruslanmv/Chata">Chata</a> ·
319
+ <a href="https://github.com/ruslanmv/HomePilot">GitHub</a>
320
+ </p>
321
+ </div>
322
+
323
+ <script>
324
+ const $ = s => document.querySelector(s);
325
+ let username = '';
326
+
327
+ // ── i18n ──
328
+ const T = {
329
+ en: {
330
+ hero: 'Your private AI in 2 minutes',
331
+ heroSub: 'Deploy HomePilot with Ollama and 14 AI personas on your own Hugging Face Space. No code. Private by default.',
332
+ trust: ['Private', 'Ollama', 'GPU ready', '14 personas'],
333
+ s1: 'Connect your account', s1d: 'with write permission. We don\'t store credentials.',
334
+ s1token: 'A <a href="https://huggingface.co/settings/tokens" target="_blank">HF token</a>',
335
+ connect: 'Connect', connected: 'Connected as',
336
+ s2: 'Configure', s2d: 'Everything has defaults — only change if you want.',
337
+ spaceName: 'Space name', private: 'Private', model: 'LLM Model',
338
+ m1: 'fast, ideal to start', m2: 'better quality', m3: 'powerful (needs GPU)', m4: 'balanced',
339
+ personas: '14 AI personas included', personaSub: 'Auto-imported on first start.',
340
+ s3: 'Deploy', s3d: 'One click. Your HomePilot will be ready in ~3 minutes.',
341
+ deployBtn: '🚀 Deploy HomePilot', deploying: 'Deploying...', retry: '🚀 Retry',
342
+ starting: 'Starting installation...', deployed: 'Deployed successfully!',
343
+ successH: 'Your HomePilot is running!', successP: 'Wait ~3 minutes for the first build.',
344
+ openSpace: 'Open Space →', goChata: 'Go to Chata',
345
+ tokenEmpty: 'Empty or invalid token', verifyFirst: 'Connect your account first (Step 1)',
346
+ },
347
+ es: {
348
+ hero: 'Tu IA privada en 2 minutos',
349
+ heroSub: 'Despliega HomePilot con Ollama y 14 personas AI en tu propio Hugging Face Space. Sin código. Privado por defecto.',
350
+ trust: ['Privado', 'Ollama', 'GPU ready', '14 personas'],
351
+ s1: 'Conecta tu cuenta', s1d: 'con permiso write. No almacenamos credenciales.',
352
+ s1token: 'Un <a href="https://huggingface.co/settings/tokens" target="_blank">token de HF</a>',
353
+ connect: 'Conectar', connected: 'Conectado como',
354
+ s2: 'Configura', s2d: 'Todo tiene valores por defecto — solo cambia si quieres.',
355
+ spaceName: 'Nombre del Space', private: 'Privado', model: 'Modelo LLM',
356
+ m1: 'rápido, ideal para empezar', m2: 'mejor calidad', m3: 'poderoso (necesita GPU)', m4: 'equilibrado',
357
+ personas: '14 personas AI incluidas', personaSub: 'Se importan automáticamente al iniciar.',
358
+ s3: 'Despliega', s3d: 'Un clic. Tu HomePilot estará listo en ~3 minutos.',
359
+ deployBtn: '🚀 Desplegar HomePilot', deploying: 'Desplegando...', retry: '🚀 Reintentar',
360
+ starting: 'Iniciando instalación...', deployed: '¡Desplegado exitosamente!',
361
+ successH: '¡Tu HomePilot está en marcha!', successP: 'Espera ~3 minutos para el primer build.',
362
+ openSpace: 'Abrir Space →', goChata: 'Ir a Chata',
363
+ tokenEmpty: 'Token vacío o inválido', verifyFirst: 'Conecta tu cuenta primero (Paso 1)',
364
+ },
365
+ pt: {
366
+ hero: 'Sua IA privada em 2 minutos',
367
+ heroSub: 'Implante o HomePilot com Ollama e 14 personas AI no seu próprio Hugging Face Space. Sem código. Privado por padrão.',
368
+ trust: ['Privado', 'Ollama', 'GPU ready', '14 personas'],
369
+ s1: 'Conecte sua conta', s1d: 'com permissão write. Não armazenamos credenciais.',
370
+ s1token: 'Um <a href="https://huggingface.co/settings/tokens" target="_blank">token do HF</a>',
371
+ connect: 'Conectar', connected: 'Conectado como',
372
+ s2: 'Configure', s2d: 'Tudo tem valores padrão — mude apenas se quiser.',
373
+ spaceName: 'Nome do Space', private: 'Privado', model: 'Modelo LLM',
374
+ m1: 'rápido, ideal para começar', m2: 'melhor qualidade', m3: 'poderoso (precisa GPU)', m4: 'equilibrado',
375
+ personas: '14 personas AI incluídas', personaSub: 'Importadas automaticamente ao iniciar.',
376
+ s3: 'Implante', s3d: 'Um clique. Seu HomePilot estará pronto em ~3 minutos.',
377
+ deployBtn: '🚀 Implantar HomePilot', deploying: 'Implantando...', retry: '🚀 Tentar novamente',
378
+ starting: 'Iniciando instalação...', deployed: 'Implantado com sucesso!',
379
+ successH: 'Seu HomePilot está rodando!', successP: 'Aguarde ~3 minutos para o primeiro build.',
380
+ openSpace: 'Abrir Space →', goChata: 'Ir para Chata',
381
+ tokenEmpty: 'Token vazio ou inválido', verifyFirst: 'Conecte sua conta primeiro (Passo 1)',
382
+ },
383
+ fr: {
384
+ hero: 'Votre IA privée en 2 minutes',
385
+ heroSub: 'Déployez HomePilot avec Ollama et 14 personas AI sur votre propre Hugging Face Space. Sans code. Privé par défaut.',
386
+ trust: ['Privé', 'Ollama', 'GPU ready', '14 personas'],
387
+ s1: 'Connectez votre compte', s1d: 'avec permission write. Nous ne stockons pas vos identifiants.',
388
+ s1token: 'Un <a href="https://huggingface.co/settings/tokens" target="_blank">token HF</a>',
389
+ connect: 'Connecter', connected: 'Connecté en tant que',
390
+ s2: 'Configurez', s2d: 'Tout a des valeurs par défaut — changez seulement si vous voulez.',
391
+ spaceName: 'Nom du Space', private: 'Privé', model: 'Modèle LLM',
392
+ m1: 'rapide, idéal pour commencer', m2: 'meilleure qualité', m3: 'puissant (nécessite GPU)', m4: 'équilibré',
393
+ personas: '14 personas AI incluses', personaSub: 'Importées automatiquement au démarrage.',
394
+ s3: 'Déployez', s3d: 'Un clic. Votre HomePilot sera prêt en ~3 minutes.',
395
+ deployBtn: '🚀 Déployer HomePilot', deploying: 'Déploiement...', retry: '🚀 Réessayer',
396
+ starting: 'Début de l\'installation...', deployed: 'Déployé avec succès !',
397
+ successH: 'Votre HomePilot est en marche !', successP: 'Attendez ~3 minutes pour le premier build.',
398
+ openSpace: 'Ouvrir Space →', goChata: 'Aller sur Chata',
399
+ tokenEmpty: 'Token vide ou invalide', verifyFirst: 'Connectez votre compte d\'abord (Étape 1)',
400
+ },
401
+ de: {
402
+ hero: 'Ihre private KI in 2 Minuten',
403
+ heroSub: 'Deployen Sie HomePilot mit Ollama und 14 AI-Personas auf Ihrem eigenen Hugging Face Space. Ohne Code. Standardmäßig privat.',
404
+ trust: ['Privat', 'Ollama', 'GPU ready', '14 Personas'],
405
+ s1: 'Konto verbinden', s1d: 'mit Schreibberechtigung. Wir speichern keine Zugangsdaten.',
406
+ s1token: 'Ein <a href="https://huggingface.co/settings/tokens" target="_blank">HF-Token</a>',
407
+ connect: 'Verbinden', connected: 'Verbunden als',
408
+ s2: 'Konfigurieren', s2d: 'Alles hat Standardwerte — ändern Sie nur, was Sie möchten.',
409
+ spaceName: 'Space-Name', private: 'Privat', model: 'LLM-Modell',
410
+ m1: 'schnell, ideal zum Starten', m2: 'bessere Qualität', m3: 'leistungsstark (GPU nötig)', m4: 'ausgewogen',
411
+ personas: '14 AI-Personas enthalten', personaSub: 'Werden beim ersten Start automatisch importiert.',
412
+ s3: 'Deployen', s3d: 'Ein Klick. Ihr HomePilot ist in ~3 Minuten bereit.',
413
+ deployBtn: '🚀 HomePilot deployen', deploying: 'Wird deployed...', retry: '🚀 Erneut versuchen',
414
+ starting: 'Installation wird gestartet...', deployed: 'Erfolgreich deployed!',
415
+ successH: 'Ihr HomePilot läuft!', successP: 'Warten Sie ~3 Minuten auf den ersten Build.',
416
+ openSpace: 'Space öffnen →', goChata: 'Zu Chata',
417
+ tokenEmpty: 'Token leer oder ungültig', verifyFirst: 'Verbinden Sie zuerst Ihr Konto (Schritt 1)',
418
+ },
419
+ };
420
+
421
+ let lang = 'en';
422
+ function detectLang() {
423
+ const nav = (navigator.language || '').slice(0,2).toLowerCase();
424
+ return T[nav] ? nav : 'en';
425
+ }
426
+ function setLang(l) {
427
+ lang = l;
428
+ const t = T[l];
429
+ // Hero
430
+ document.querySelector('.hero h1 span').textContent = t.hero;
431
+ document.querySelector('.hero p').textContent = t.heroSub;
432
+ // Trust
433
+ const ti = document.querySelectorAll('.trust-item');
434
+ t.trust.forEach((v,i) => { if(ti[i]) ti[i].lastChild.textContent = ' '+v; });
435
+ // Steps
436
+ document.querySelector('#s1-label').textContent = t.s1;
437
+ document.querySelector('#s1-desc').innerHTML = t.s1token + ' ' + t.s1d;
438
+ document.querySelector('#s2-label').textContent = t.s2;
439
+ document.querySelector('#s2-desc').textContent = t.s2d;
440
+ document.querySelector('#s3-label').textContent = t.s3;
441
+ document.querySelector('#s3-desc').textContent = t.s3d;
442
+ // Labels
443
+ document.querySelector('#lbl-token').textContent = 'Token';
444
+ document.querySelector('#lbl-space').textContent = t.spaceName;
445
+ document.querySelector('#lbl-private').lastChild.textContent = ' '+t.private;
446
+ document.querySelector('#lbl-model').textContent = t.model;
447
+ // Buttons
448
+ document.querySelector('#verify-btn').textContent = t.connect;
449
+ document.querySelector('#install-btn').innerHTML = t.deployBtn;
450
+ // Accordion
451
+ document.querySelector('#acc-btn').firstChild.textContent = '🎭 ' + t.personas + ' ';
452
+ document.querySelector('#acc-sub').textContent = t.personaSub;
453
+ // Models
454
+ const opts = document.querySelectorAll('#model option');
455
+ const ms = [t.m1, t.m2, t.m3, t.m4];
456
+ opts.forEach((o,i) => { if(ms[i]) o.textContent = o.value.split(':').join(' ') + ' — ' + ms[i]; });
457
+ // Success
458
+ document.querySelector('#success h2').textContent = t.successH;
459
+ document.querySelector('#open-space').textContent = t.openSpace;
460
+ document.querySelector('#go-chata').textContent = t.goChata;
461
+ // Lang picker
462
+ document.querySelectorAll('.lang-btn').forEach(b => {
463
+ b.classList.toggle('active', b.dataset.lang === l);
464
+ });
465
+ }
466
+
467
+
468
+ // Accordion
469
+ $('#acc-btn').onclick = () => {
470
+ $('#acc-btn').classList.toggle('open');
471
+ $('#acc-body').classList.toggle('open');
472
+ };
473
+
474
+ // Verify
475
+ $('#verify-btn').onclick = async () => {
476
+ const btn = $('#verify-btn');
477
+ const token = $('#token').value.trim();
478
+ if (!token) return;
479
+ btn.disabled = true;
480
+ btn.textContent = '...';
481
+ try {
482
+ const r = await fetch('/api/verify', {
483
+ method: 'POST',
484
+ headers: {'Content-Type': 'application/json'},
485
+ body: JSON.stringify({token})
486
+ });
487
+ const d = await r.json();
488
+ const st = $('#auth-status');
489
+ if (d.ok) {
490
+ username = d.username;
491
+ st.textContent = '✅ Conectado como ' + d.username;
492
+ st.className = 'status ok';
493
+ $('#install-btn').disabled = false;
494
+ } else {
495
+ st.textContent = '❌ ' + (d.error || 'Error');
496
+ st.className = 'status err';
497
+ }
498
+ } catch(e) {
499
+ $('#auth-status').textContent = '❌ ' + e.message;
500
+ $('#auth-status').className = 'status err';
501
+ }
502
+ btn.disabled = false;
503
+ btn.textContent = 'Conectar';
504
+ };
505
+
506
+ // Install
507
+ $('#install-btn').onclick = async () => {
508
+ const btn = $('#install-btn');
509
+ btn.disabled = true;
510
+ btn.innerHTML = '<span class="spinner"></span> Desplegando...';
511
+
512
+ const log = $('#log');
513
+ log.classList.add('visible');
514
+ log.innerHTML = '<span class="info">▸ Iniciando instalación...</span>\n';
515
+
516
+ try {
517
+ const r = await fetch('/api/install', {
518
+ method: 'POST',
519
+ headers: {'Content-Type': 'application/json'},
520
+ body: JSON.stringify({
521
+ token: $('#token').value.trim(),
522
+ username,
523
+ space_name: $('#space-name').value.trim() || 'HomePilot',
524
+ private: $('#private').checked,
525
+ model: $('#model').value
526
+ })
527
+ });
528
+ const d = await r.json();
529
+
530
+ if (d.steps) {
531
+ log.innerHTML = d.steps.map(s => `<span class="info">✓ ${s}</span>`).join('\n') + '\n';
532
+ }
533
+
534
+ if (d.ok) {
535
+ log.innerHTML += '<span class="ok">✅ ¡Desplegado exitosamente!</span>';
536
+ // Show success
537
+ $('#success').classList.add('visible');
538
+ $('#open-space').href = d.url;
539
+ $('#success-msg').textContent = d.repo_id + ' — espera ~3 min para el build.';
540
+ btn.style.display = 'none';
541
+ } else {
542
+ log.innerHTML += `<span class="err">❌ ${d.error || 'Error desconocido'}</span>`;
543
+ btn.disabled = false;
544
+ btn.innerHTML = '🚀 Reintentar';
545
+ }
546
+ } catch(e) {
547
+ log.innerHTML += `<span class="err">❌ ${e.message}</span>`;
548
+ btn.disabled = false;
549
+ btn.innerHTML = '🚀 Reintentar';
550
+ }
551
+ };
552
+
553
+ // Enter key on token
554
+ $('#token').onkeydown = e => { if (e.key === 'Enter') $('#verify-btn').click(); };
555
+
556
+ // Auto-detect language on load
557
+ setLang(detectLang());
558
+ </script>
559
+ </body>
560
+ </html>