superbrain-server 1.0.17 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/start.py +207 -212
package/package.json
CHANGED
package/payload/start.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
╔══════════════════════════════════════════════════════════════════╗
|
|
4
|
+
║ SuperBrain — First-Time Setup & Launcher ║
|
|
5
|
+
║ Run this once to configure everything, then again ║
|
|
6
|
+
║ any time to start the server. ║
|
|
7
|
+
╚══════════════════════════════════════════════════════════════════╝
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
|
-
python start.py
|
|
11
|
-
python start.py --reset
|
|
10
|
+
python start.py — interactive setup on first run, then start server
|
|
11
|
+
python start.py --reset — re-run the full setup wizard
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import sys
|
|
@@ -25,7 +25,7 @@ import importlib
|
|
|
25
25
|
import re
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
|
|
28
|
-
#
|
|
28
|
+
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
29
29
|
BASE_DIR = Path(__file__).parent.resolve()
|
|
30
30
|
VENV_DIR = BASE_DIR / ".venv"
|
|
31
31
|
API_KEYS = BASE_DIR / "config" / ".api_keys"
|
|
@@ -37,7 +37,7 @@ PYTHON = sys.executable # path that launched this script
|
|
|
37
37
|
VENV_PYTHON = (VENV_DIR / "Scripts" / "python.exe") if IS_WINDOWS else (VENV_DIR / "bin" / "python")
|
|
38
38
|
VENV_PIP = (VENV_DIR / "Scripts" / "pip.exe") if IS_WINDOWS else (VENV_DIR / "bin" / "pip")
|
|
39
39
|
|
|
40
|
-
#
|
|
40
|
+
# ── ANSI colours (stripped on Windows unless ANSICON / Windows Terminal) ──────
|
|
41
41
|
def _ansi(code): return f"\033[{code}m"
|
|
42
42
|
RESET = _ansi(0); BOLD = _ansi(1)
|
|
43
43
|
RED = _ansi(31); GREEN = _ansi(32); YELLOW = _ansi(33)
|
|
@@ -47,48 +47,48 @@ MAGENTA = _ansi(35)
|
|
|
47
47
|
MAG = MAGENTA
|
|
48
48
|
|
|
49
49
|
def link(url: str, text: str | None = None) -> str:
|
|
50
|
-
"""OSC 8 terminal hyperlink
|
|
50
|
+
"""OSC 8 terminal hyperlink — clickable in most modern terminals."""
|
|
51
51
|
label = text or url
|
|
52
52
|
return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
|
|
53
53
|
|
|
54
54
|
def banner():
|
|
55
55
|
art = f"""{CYAN}{BOLD}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
56
|
+
███████╗██╗ ██╗██████╗ ███████╗██████╗
|
|
57
|
+
██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗
|
|
58
|
+
███████╗██║ ██║██████╔╝█████╗ ██████╔╝
|
|
59
|
+
╚════██║██║ ██║██╔═══╝ ██╔══╝ ██╔══██╗
|
|
60
|
+
███████║╚██████╔╝██║ ███████╗██║ ██║
|
|
61
|
+
╚══════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
|
|
62
|
+
|
|
63
|
+
██████╗ ██████╗ █████╗ ██╗███╗ ██╗
|
|
64
|
+
██╔══██╗██╔══██╗██╔══██╗██║████╗ ██║
|
|
65
|
+
██████╔╝██████╔╝███████║██║██╔██╗ ██║
|
|
66
|
+
██╔══██╗██╔══██╗██╔══██║██║██║╚██╗██║
|
|
67
|
+
██████╔╝██║ ██║██║ ██║██║██║ ╚████║
|
|
68
|
+
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝
|
|
69
69
|
{RESET}"""
|
|
70
|
-
credit = (f" {DIM}made with {RESET}{MAG}
|
|
70
|
+
credit = (f" {DIM}made with {RESET}{MAG}❤{RESET}{DIM} by "
|
|
71
71
|
f"{link('https://github.com/sidinsearch', f'{BOLD}sidinsearch{RESET}{DIM}')}"
|
|
72
72
|
f"{RESET}\n")
|
|
73
73
|
print(art + credit)
|
|
74
74
|
|
|
75
|
-
def h1(msg): print(f"\n{BOLD}{CYAN}{'
|
|
76
|
-
def h2(msg): print(f"\n{BOLD}{BLUE}
|
|
77
|
-
def ok(msg): print(f" {GREEN}
|
|
78
|
-
def warn(msg):print(f" {YELLOW}
|
|
79
|
-
def err(msg): print(f" {RED}
|
|
75
|
+
def h1(msg): print(f"\n{BOLD}{CYAN}{'━'*64}{RESET}\n{BOLD} {msg}{RESET}\n{BOLD}{CYAN}{'━'*64}{RESET}")
|
|
76
|
+
def h2(msg): print(f"\n{BOLD}{BLUE} ▶ {msg}{RESET}")
|
|
77
|
+
def ok(msg): print(f" {GREEN}✓{RESET} {msg}")
|
|
78
|
+
def warn(msg):print(f" {YELLOW}⚠{RESET} {msg}")
|
|
79
|
+
def err(msg): print(f" {RED}✗{RESET} {msg}")
|
|
80
80
|
def info(msg):print(f" {DIM}{msg}{RESET}")
|
|
81
81
|
def nl(): print()
|
|
82
82
|
|
|
83
83
|
def ask(prompt, default=None, secret=False, paste=False):
|
|
84
84
|
"""
|
|
85
85
|
Prompt for input.
|
|
86
|
-
secret=True
|
|
87
|
-
paste=True
|
|
88
|
-
existing value is shown as
|
|
86
|
+
secret=True — uses getpass (hidden, no echo) — good for passwords typed char-by-char.
|
|
87
|
+
paste=True — uses plain input (visible) so Ctrl+V / right-click paste works;
|
|
88
|
+
existing value is shown as ●●●● to indicate something is already set.
|
|
89
89
|
"""
|
|
90
90
|
if paste and default:
|
|
91
|
-
display_default = f" [{DIM}
|
|
91
|
+
display_default = f" [{DIM}●●●● (already set — paste to replace){RESET}]"
|
|
92
92
|
elif default:
|
|
93
93
|
display_default = f" [{DIM}{default}{RESET}]"
|
|
94
94
|
else:
|
|
@@ -177,7 +177,7 @@ def ensure_runtime_dependencies():
|
|
|
177
177
|
return
|
|
178
178
|
|
|
179
179
|
warn(f"Missing runtime package(s): {', '.join(missing)}")
|
|
180
|
-
info("Installing missing runtime package(s) automatically
|
|
180
|
+
info("Installing missing runtime package(s) automatically …")
|
|
181
181
|
try:
|
|
182
182
|
run([str(VENV_PYTHON), "-m", "pip", "install", *missing])
|
|
183
183
|
ok("Runtime dependencies installed")
|
|
@@ -186,7 +186,7 @@ def ensure_runtime_dependencies():
|
|
|
186
186
|
info(f"Run manually: {VENV_PYTHON} -m pip install {' '.join(missing)}")
|
|
187
187
|
sys.exit(1)
|
|
188
188
|
|
|
189
|
-
#
|
|
189
|
+
# ── Helpers for live output displays ───────────────────────────────────────────────
|
|
190
190
|
BAR_WIDTH = 36
|
|
191
191
|
|
|
192
192
|
def _ascii_bar(completed: int, total: int, width: int = BAR_WIDTH) -> str:
|
|
@@ -195,7 +195,7 @@ def _ascii_bar(completed: int, total: int, width: int = BAR_WIDTH) -> str:
|
|
|
195
195
|
return ""
|
|
196
196
|
pct = min(completed / total, 1.0)
|
|
197
197
|
fill = int(width * pct)
|
|
198
|
-
bar = f"{GREEN}{'
|
|
198
|
+
bar = f"{GREEN}{'█' * fill}{DIM}{'░' * (width - fill)}{RESET}"
|
|
199
199
|
mb_d = completed / 1_048_576
|
|
200
200
|
mb_t = total / 1_048_576
|
|
201
201
|
return f"[{bar}] {mb_d:6.1f} / {mb_t:.1f} MB {pct*100:5.1f}%"
|
|
@@ -205,42 +205,42 @@ def _overwrite(line: str):
|
|
|
205
205
|
sys.stdout.write(f"\r {line}")
|
|
206
206
|
sys.stdout.flush()
|
|
207
207
|
|
|
208
|
-
#
|
|
209
|
-
# Step 1
|
|
210
|
-
#
|
|
208
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
209
|
+
# Step 1 — Virtual Environment
|
|
210
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
211
211
|
def setup_venv():
|
|
212
|
-
h1("Step 1 of 6
|
|
212
|
+
h1("Step 1 of 6 — Python Virtual Environment")
|
|
213
213
|
if VENV_DIR.exists():
|
|
214
214
|
ok(f"Virtual environment already exists at {VENV_DIR}")
|
|
215
215
|
return
|
|
216
|
-
h2("Creating virtual environment
|
|
216
|
+
h2("Creating virtual environment …")
|
|
217
217
|
run([PYTHON, "-m", "venv", str(VENV_DIR)])
|
|
218
218
|
ok(f"Virtual environment created at {VENV_DIR}")
|
|
219
219
|
|
|
220
|
-
#
|
|
221
|
-
# Step 2
|
|
222
|
-
#
|
|
220
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
221
|
+
# Step 2 — Install Dependencies
|
|
222
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
223
223
|
def install_deps():
|
|
224
|
-
h1("Step 2 of 7
|
|
224
|
+
h1("Step 2 of 7 — Installing Core Python Dependencies")
|
|
225
225
|
|
|
226
|
-
h2("Upgrading pip
|
|
226
|
+
h2("Upgrading pip …")
|
|
227
227
|
run([str(VENV_PYTHON), "-m", "pip", "install", "--quiet", "--upgrade", "pip"])
|
|
228
228
|
ok("pip up to date")
|
|
229
229
|
|
|
230
|
-
h2("Installing core runtime packages
|
|
230
|
+
h2("Installing core runtime packages …")
|
|
231
231
|
info("Optional packages such as local Whisper, OpenCV, and music ID are installed only when enabled.")
|
|
232
232
|
run([str(VENV_PIP), "install", "--progress-bar", "off", *CORE_PACKAGES])
|
|
233
233
|
ok(f"Installed {len(CORE_PACKAGES)} core package(s)")
|
|
234
234
|
|
|
235
|
-
#
|
|
236
|
-
#
|
|
235
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
236
|
+
# ── API key validators ────────────────────────────────────────────────────────
|
|
237
237
|
# Return values:
|
|
238
|
-
# (True, detail)
|
|
239
|
-
# (False, detail)
|
|
240
|
-
# (None, detail)
|
|
238
|
+
# (True, detail) — key is definitely valid
|
|
239
|
+
# (False, detail) — key is definitely INVALID (401 / explicit auth error)
|
|
240
|
+
# (None, detail) — could not verify (network, 403 scope, timeout, etc.)
|
|
241
241
|
|
|
242
242
|
def _validate_gemini(key: str):
|
|
243
|
-
"""Hit the Gemini models list endpoint
|
|
243
|
+
"""Hit the Gemini models list endpoint — any valid key returns 200."""
|
|
244
244
|
try:
|
|
245
245
|
import urllib.request as _r, json as _j
|
|
246
246
|
req = _r.Request(
|
|
@@ -256,7 +256,7 @@ def _validate_gemini(key: str):
|
|
|
256
256
|
return False, "invalid key (400 Bad Request)"
|
|
257
257
|
if "401" in msg:
|
|
258
258
|
return False, "invalid key (401 Unauthorized)"
|
|
259
|
-
# 403, timeouts, etc.
|
|
259
|
+
# 403, timeouts, etc. — cannot determine validity
|
|
260
260
|
return None, f"could not verify ({msg[:70]})"
|
|
261
261
|
|
|
262
262
|
def _validate_groq(key: str):
|
|
@@ -285,7 +285,7 @@ def _validate_groq(key: str):
|
|
|
285
285
|
except _e.HTTPError as e:
|
|
286
286
|
if e.code in (401, 400):
|
|
287
287
|
return False, f"invalid key ({e.code} {e.reason})"
|
|
288
|
-
# 403, 429, 503, etc.
|
|
288
|
+
# 403, 429, 503, etc. — key may be fine
|
|
289
289
|
return None, f"could not verify ({e.code} {e.reason})"
|
|
290
290
|
except Exception as e:
|
|
291
291
|
return None, f"could not verify ({str(e)[:70]})"
|
|
@@ -312,38 +312,38 @@ def _check_and_report(name: str, key: str, validator) -> str:
|
|
|
312
312
|
"""Validate `key`, print result inline, return the key unchanged."""
|
|
313
313
|
if not key:
|
|
314
314
|
return key
|
|
315
|
-
print(f" {DIM}Checking {name} key
|
|
315
|
+
print(f" {DIM}Checking {name} key …{RESET}", end="", flush=True)
|
|
316
316
|
result, detail = validator(key)
|
|
317
317
|
if result is True:
|
|
318
|
-
print(f"\r {GREEN}
|
|
318
|
+
print(f"\r {GREEN}✓{RESET} {name}: {detail} ")
|
|
319
319
|
elif result is False:
|
|
320
|
-
print(f"\r {RED}
|
|
321
|
-
warn(f"That key looks invalid
|
|
320
|
+
print(f"\r {RED}✗{RESET} {name}: {detail} ")
|
|
321
|
+
warn(f"That key looks invalid — double-check at the provider dashboard.")
|
|
322
322
|
else:
|
|
323
|
-
# None
|
|
323
|
+
# None — ambiguous, don't cry wolf
|
|
324
324
|
print(f"\r {YELLOW}~{RESET} {name}: {detail} ")
|
|
325
|
-
info("Could not reach the API right now
|
|
325
|
+
info("Could not reach the API right now — key saved, will be tested on first use.")
|
|
326
326
|
return key
|
|
327
327
|
|
|
328
|
-
# Step 3
|
|
329
|
-
#
|
|
328
|
+
# Step 3 — API Keys
|
|
329
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
330
330
|
def setup_api_keys():
|
|
331
|
-
h1("Step 3 of 7
|
|
331
|
+
h1("Step 3 of 7 — AI Provider Keys")
|
|
332
332
|
|
|
333
333
|
print(f"""
|
|
334
334
|
SuperBrain uses AI providers to analyse your saved content.
|
|
335
|
-
You need {BOLD}at least one{RESET} key
|
|
335
|
+
You need {BOLD}at least one{RESET} key — the router tries them in order and
|
|
336
336
|
falls back automatically.
|
|
337
337
|
|
|
338
|
-
Recommended: {GREEN}Gemini{RESET} (most generous free tier
|
|
338
|
+
Recommended: {GREEN}Gemini{RESET} (most generous free tier — 1 500 req/day)
|
|
339
339
|
|
|
340
340
|
Get free keys:
|
|
341
|
-
Gemini
|
|
342
|
-
Groq
|
|
343
|
-
OpenRouter
|
|
341
|
+
Gemini → {CYAN}https://aistudio.google.com/apikey{RESET}
|
|
342
|
+
Groq → {CYAN}https://console.groq.com/keys{RESET}
|
|
343
|
+
OpenRouter → {CYAN}https://openrouter.ai/keys{RESET}
|
|
344
344
|
|
|
345
345
|
Press {BOLD}Enter{RESET} to skip any key you don't have yet.
|
|
346
|
-
{DIM}Keys and passwords are visible as you paste
|
|
346
|
+
{DIM}Keys and passwords are visible as you paste — don't run setup in a screen share.{RESET}
|
|
347
347
|
""")
|
|
348
348
|
|
|
349
349
|
# Load existing values if re-running
|
|
@@ -371,14 +371,14 @@ def setup_api_keys():
|
|
|
371
371
|
print(f" {BOLD}Instagram Credentials{RESET}")
|
|
372
372
|
print(f"""
|
|
373
373
|
Used for downloading private/public Instagram posts.
|
|
374
|
-
{YELLOW}Use a secondary / burner account
|
|
374
|
+
{YELLOW}Use a secondary / burner account — NOT your main account.{RESET}
|
|
375
375
|
The session is cached after first login so you won't be asked again.
|
|
376
376
|
|
|
377
377
|
{DIM}Without credentials:{RESET}
|
|
378
378
|
SuperBrain can still save and analyse {BOLD}YouTube videos{RESET} and {BOLD}Websites{RESET}
|
|
379
379
|
without any Instagram account. However, Instagram posts will be limited:
|
|
380
|
-
|
|
381
|
-
|
|
380
|
+
• Only {BOLD}public posts{RESET} that are accessible without login may work.
|
|
381
|
+
• You {BOLD}cannot process multiple Instagram posts back-to-back{RESET} —
|
|
382
382
|
Instagram enforces a rate-limit cool-down between unauthenticated
|
|
383
383
|
requests. You may need to wait several minutes between saves.
|
|
384
384
|
Adding credentials removes these restrictions entirely.
|
|
@@ -391,7 +391,7 @@ def setup_api_keys():
|
|
|
391
391
|
# Write .api_keys
|
|
392
392
|
API_KEYS.parent.mkdir(parents=True, exist_ok=True)
|
|
393
393
|
lines = [
|
|
394
|
-
"# SuperBrain API Keys
|
|
394
|
+
"# SuperBrain API Keys — DO NOT COMMIT THIS FILE\n",
|
|
395
395
|
f"GEMINI_API_KEY={gemini}\n",
|
|
396
396
|
f"GROQ_API_KEY={groq_k}\n",
|
|
397
397
|
f"OPENROUTER_API_KEY={openr}\n",
|
|
@@ -402,32 +402,32 @@ def setup_api_keys():
|
|
|
402
402
|
API_KEYS.write_text("".join(lines))
|
|
403
403
|
ok(f"Keys saved to {API_KEYS}")
|
|
404
404
|
|
|
405
|
-
#
|
|
406
|
-
# Step 4
|
|
407
|
-
#
|
|
405
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
406
|
+
# Step 4 — Ollama / Offline Model
|
|
407
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
408
408
|
OLLAMA_MODEL = "qwen3-vl:4b" # vision-language model, fits ~6 GB VRAM / ~8 GB RAM
|
|
409
409
|
|
|
410
410
|
def setup_ollama():
|
|
411
|
-
h1("Step 4 of 7
|
|
411
|
+
h1("Step 4 of 7 — Offline AI Model (Ollama)")
|
|
412
412
|
|
|
413
413
|
keys = _load_saved_api_keys()
|
|
414
414
|
has_cloud_key = any(keys.get(k) for k in ("GEMINI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY"))
|
|
415
415
|
|
|
416
416
|
print(f"""
|
|
417
|
-
Ollama runs AI models {BOLD}locally on your machine{RESET}
|
|
417
|
+
Ollama runs AI models {BOLD}locally on your machine{RESET} — no internet or API
|
|
418
418
|
key required. SuperBrain uses it as a last-resort fallback if all
|
|
419
419
|
cloud providers fail or run out of quota.
|
|
420
420
|
|
|
421
421
|
Recommended model: {BOLD}{OLLAMA_MODEL}{RESET} (~3 GB download, needs ~8 GB RAM)
|
|
422
|
-
|
|
422
|
+
→ Vision-language model: understands both text AND images.
|
|
423
423
|
Other options: llama3.2:3b (2 GB / 4 GB RAM), gemma2:2b (1.5 GB / 4 GB RAM)
|
|
424
424
|
""")
|
|
425
425
|
|
|
426
426
|
if has_cloud_key:
|
|
427
|
-
info("Cloud API key(s) detected
|
|
427
|
+
info("Cloud API key(s) detected — Ollama is optional and skipped by default.")
|
|
428
428
|
|
|
429
429
|
if not ask_yn("Set up Ollama offline model?", default=not has_cloud_key):
|
|
430
|
-
warn("Skipping Ollama. Cloud providers only
|
|
430
|
+
warn("Skipping Ollama. Cloud providers only — make sure you have API keys.")
|
|
431
431
|
return
|
|
432
432
|
|
|
433
433
|
# Check if ollama binary is available
|
|
@@ -436,8 +436,8 @@ def setup_ollama():
|
|
|
436
436
|
{YELLOW}Ollama is not installed.{RESET}
|
|
437
437
|
|
|
438
438
|
Install it first:
|
|
439
|
-
Linux / macOS
|
|
440
|
-
Windows
|
|
439
|
+
Linux / macOS → {CYAN}curl -fsSL https://ollama.com/install.sh | sh{RESET}
|
|
440
|
+
Windows → Download from {CYAN}https://ollama.com/download{RESET}
|
|
441
441
|
|
|
442
442
|
After installing, re-run {BOLD}python start.py{RESET} to continue.
|
|
443
443
|
""")
|
|
@@ -460,7 +460,7 @@ def setup_ollama():
|
|
|
460
460
|
custom = ask(f"Model to pull", default=OLLAMA_MODEL)
|
|
461
461
|
model = custom or OLLAMA_MODEL
|
|
462
462
|
|
|
463
|
-
h2(f"Pulling {model}
|
|
463
|
+
h2(f"Pulling {model} — this downloads ~3 GB, grab a coffee ☕")
|
|
464
464
|
nl()
|
|
465
465
|
try:
|
|
466
466
|
_ollama_pull_with_progress(model)
|
|
@@ -476,7 +476,7 @@ def _ollama_pull_with_progress(model: str):
|
|
|
476
476
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
477
477
|
text=True, bufsize=1)
|
|
478
478
|
|
|
479
|
-
# digest
|
|
479
|
+
# digest → (total_bytes, completed_bytes, short_label)
|
|
480
480
|
layers: dict[str, tuple[int, int, str]] = {}
|
|
481
481
|
last_status = ""
|
|
482
482
|
active_digest = ""
|
|
@@ -487,7 +487,7 @@ def _ollama_pull_with_progress(model: str):
|
|
|
487
487
|
if not raw:
|
|
488
488
|
continue
|
|
489
489
|
|
|
490
|
-
# Ollama outputs plain-text lines (not JSON) when not a TTY
|
|
490
|
+
# Ollama outputs plain-text lines (not JSON) when not a TTY — accept both
|
|
491
491
|
try:
|
|
492
492
|
data = _json.loads(raw)
|
|
493
493
|
except _json.JSONDecodeError:
|
|
@@ -496,7 +496,7 @@ def _ollama_pull_with_progress(model: str):
|
|
|
496
496
|
sys.stdout.write("\n"); render_line = False
|
|
497
497
|
if raw != last_status:
|
|
498
498
|
last_status = raw
|
|
499
|
-
print(f" {CYAN}
|
|
499
|
+
print(f" {CYAN}→{RESET} {raw}")
|
|
500
500
|
continue
|
|
501
501
|
|
|
502
502
|
status = data.get("status", "")
|
|
@@ -520,9 +520,9 @@ def _ollama_pull_with_progress(model: str):
|
|
|
520
520
|
done_statuses = ("verifying sha256 digest", "writing manifest",
|
|
521
521
|
"removing any unused layers", "success")
|
|
522
522
|
if any(s in status.lower() for s in done_statuses):
|
|
523
|
-
print(f" {GREEN}
|
|
523
|
+
print(f" {GREEN}✓{RESET} {status}")
|
|
524
524
|
else:
|
|
525
|
-
print(f" {CYAN}
|
|
525
|
+
print(f" {CYAN}→{RESET} {status}")
|
|
526
526
|
|
|
527
527
|
if render_line:
|
|
528
528
|
sys.stdout.write("\n"); render_line = False
|
|
@@ -533,19 +533,19 @@ def _ollama_pull_with_progress(model: str):
|
|
|
533
533
|
|
|
534
534
|
ok(f"Model {model} ready")
|
|
535
535
|
|
|
536
|
-
#
|
|
537
|
-
# Step 5
|
|
538
|
-
#
|
|
536
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
537
|
+
# Step 5 — Whisper / Offline Transcription
|
|
538
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
539
539
|
WHISPER_MODELS = {
|
|
540
540
|
"tiny": (" ~74 MB", "fastest, lower accuracy"),
|
|
541
|
-
"base": ("~142 MB", "good balance
|
|
541
|
+
"base": ("~142 MB", "good balance ⭐ recommended"),
|
|
542
542
|
"small": ("~461 MB", "higher accuracy"),
|
|
543
543
|
"medium": ("~1.5 GB", "high accuracy, slower"),
|
|
544
544
|
"large": ("~2.9 GB", "best accuracy, needs 10 GB RAM"),
|
|
545
545
|
}
|
|
546
546
|
|
|
547
547
|
def setup_whisper():
|
|
548
|
-
h1("Step 5 of 7
|
|
548
|
+
h1("Step 5 of 7 — Offline Audio Transcription (Whisper)")
|
|
549
549
|
|
|
550
550
|
keys = _load_saved_api_keys()
|
|
551
551
|
has_groq_key = bool(keys.get("GROQ_API_KEY") or os.getenv("GROQ_API_KEY"))
|
|
@@ -553,14 +553,14 @@ def setup_whisper():
|
|
|
553
553
|
print(f"""
|
|
554
554
|
OpenAI Whisper transcribes audio and video {BOLD}entirely on your machine{RESET}.
|
|
555
555
|
SuperBrain uses it to extract speech from Instagram Reels, YouTube
|
|
556
|
-
videos, and any other saved media
|
|
556
|
+
videos, and any other saved media — no API key needed.
|
|
557
557
|
|
|
558
558
|
Whisper requires {BOLD}ffmpeg{RESET} to be installed on your system.
|
|
559
559
|
It also pre-downloads a speech model the first time it runs.
|
|
560
560
|
""")
|
|
561
561
|
|
|
562
562
|
if has_groq_key:
|
|
563
|
-
info("Groq API key detected
|
|
563
|
+
info("Groq API key detected — cloud Whisper is available, so local Whisper is optional.")
|
|
564
564
|
if not ask_yn("Also install local Whisper fallback?", default=False):
|
|
565
565
|
warn("Skipping local Whisper. Groq Whisper will be used when Groq is available.")
|
|
566
566
|
return
|
|
@@ -569,30 +569,30 @@ def setup_whisper():
|
|
|
569
569
|
warn("Skipping Whisper setup. Audio transcription will rely on cloud providers if available.")
|
|
570
570
|
return
|
|
571
571
|
|
|
572
|
-
#
|
|
572
|
+
# ── ffmpeg check ──────────────────────────────────────────────────────────
|
|
573
573
|
if shutil.which("ffmpeg"):
|
|
574
574
|
ok("ffmpeg is installed")
|
|
575
575
|
else:
|
|
576
|
-
warn("ffmpeg is NOT installed
|
|
576
|
+
warn("ffmpeg is NOT installed — Whisper cannot run without it.")
|
|
577
577
|
print(f"""
|
|
578
578
|
Install ffmpeg:
|
|
579
|
-
Linux / WSL
|
|
580
|
-
macOS
|
|
581
|
-
Windows
|
|
579
|
+
Linux / WSL → {CYAN}sudo apt install ffmpeg{RESET}
|
|
580
|
+
macOS → {CYAN}brew install ffmpeg{RESET}
|
|
581
|
+
Windows → {CYAN}winget install ffmpeg{RESET}
|
|
582
582
|
or download from {CYAN}https://ffmpeg.org/download.html{RESET}
|
|
583
583
|
|
|
584
584
|
After installing ffmpeg, re-run {BOLD}python start.py --reset{RESET} or just
|
|
585
|
-
restart
|
|
585
|
+
restart — Whisper will work automatically once ffmpeg is present.
|
|
586
586
|
""")
|
|
587
587
|
if not ask_yn("Continue setup anyway?", default=True):
|
|
588
588
|
sys.exit(0)
|
|
589
589
|
|
|
590
|
-
#
|
|
590
|
+
# ── Whisper package check / install ──────────────────────────────────────
|
|
591
591
|
try:
|
|
592
592
|
result = run_q([str(VENV_PYTHON), "-c", "import whisper; print(whisper.__version__)"])
|
|
593
593
|
ok(f"openai-whisper installed (version {result.stdout.strip()})")
|
|
594
594
|
except Exception:
|
|
595
|
-
warn("openai-whisper not found
|
|
595
|
+
warn("openai-whisper not found — installing now …")
|
|
596
596
|
nl()
|
|
597
597
|
try:
|
|
598
598
|
cmd = [str(VENV_PIP), "install", "--progress-bar", "off", "openai-whisper>=20231117"]
|
|
@@ -603,13 +603,13 @@ def setup_whisper():
|
|
|
603
603
|
if not line:
|
|
604
604
|
continue
|
|
605
605
|
if line.startswith("Collecting "):
|
|
606
|
-
print(f" {CYAN}
|
|
606
|
+
print(f" {CYAN}↓{RESET} {BOLD}{line.split()[1]}{RESET}")
|
|
607
607
|
elif "Downloading" in line and (".whl" in line or ".tar.gz" in line):
|
|
608
608
|
parts = line.strip().split()
|
|
609
609
|
if len(parts) >= 2:
|
|
610
|
-
print(f" {DIM}
|
|
610
|
+
print(f" {DIM}↓ {parts[1]} {' '.join(parts[2:]).strip('()')}{RESET}")
|
|
611
611
|
elif line.startswith("Successfully installed"):
|
|
612
|
-
print(f" {GREEN}
|
|
612
|
+
print(f" {GREEN}✓ {line}{RESET}")
|
|
613
613
|
elif "error" in line.lower() or "ERROR" in line:
|
|
614
614
|
print(f" {RED}{line}{RESET}")
|
|
615
615
|
proc.wait()
|
|
@@ -617,7 +617,7 @@ def setup_whisper():
|
|
|
617
617
|
result = run_q([str(VENV_PYTHON), "-c", "import whisper; print(whisper.__version__)"])
|
|
618
618
|
ok(f"openai-whisper installed (version {result.stdout.strip()})")
|
|
619
619
|
else:
|
|
620
|
-
err("openai-whisper install failed
|
|
620
|
+
err("openai-whisper install failed — offline transcription will not work.")
|
|
621
621
|
if not ask_yn("Continue setup anyway?", default=True):
|
|
622
622
|
sys.exit(0)
|
|
623
623
|
return
|
|
@@ -627,50 +627,50 @@ def setup_whisper():
|
|
|
627
627
|
sys.exit(0)
|
|
628
628
|
return
|
|
629
629
|
|
|
630
|
-
#
|
|
630
|
+
# ── Model pre-download ────────────────────────────────────────────────────
|
|
631
631
|
nl()
|
|
632
632
|
print(f" {BOLD}Whisper model pre-download{RESET}")
|
|
633
633
|
print(f" Pre-downloading a model now avoids a delay on first use.\n")
|
|
634
634
|
|
|
635
635
|
rows = ""
|
|
636
636
|
for name, (size, note) in WHISPER_MODELS.items():
|
|
637
|
-
star = f" {YELLOW}
|
|
637
|
+
star = f" {YELLOW}← default if skipped{RESET}" if name == "base" else ""
|
|
638
638
|
rows += f" {BOLD}{name:<8}{RESET} {size} {DIM}{note}{RESET}{star}\n"
|
|
639
639
|
print(rows)
|
|
640
640
|
|
|
641
641
|
choice = ask("Model to pre-download", default="base")
|
|
642
642
|
model = choice.strip().lower() if choice else "base"
|
|
643
643
|
if model not in WHISPER_MODELS:
|
|
644
|
-
warn(f"Unknown model '{model}'
|
|
644
|
+
warn(f"Unknown model '{model}' — defaulting to 'base'.")
|
|
645
645
|
model = "base"
|
|
646
646
|
|
|
647
|
-
#
|
|
647
|
+
# ── Save model choice to config ─────────────────────────────────────────
|
|
648
648
|
whisper_cfg = BASE_DIR / "config" / "whisper_model.txt"
|
|
649
649
|
(BASE_DIR / "config").mkdir(exist_ok=True)
|
|
650
650
|
whisper_cfg.write_text(model)
|
|
651
651
|
ok(f"Whisper model set to '{model}' (saved to config/whisper_model.txt)")
|
|
652
652
|
|
|
653
|
-
h2(f"Pre-downloading Whisper '{model}' model
|
|
653
|
+
h2(f"Pre-downloading Whisper '{model}' model …")
|
|
654
654
|
print(f" {DIM}(Whisper's own progress bar will appear below){RESET}\n")
|
|
655
655
|
try:
|
|
656
656
|
# Don't capture: let tqdm's download progress bars stream to the terminal
|
|
657
657
|
run([str(VENV_PYTHON), "-c",
|
|
658
|
-
f"import whisper; print('Loading model
|
|
658
|
+
f"import whisper; print('Loading model …'); whisper.load_model('{model}'); print('Done.')"])
|
|
659
659
|
nl()
|
|
660
660
|
ok(f"Whisper '{model}' model downloaded and cached")
|
|
661
661
|
except subprocess.CalledProcessError:
|
|
662
|
-
err(f"Pre-download failed
|
|
662
|
+
err(f"Pre-download failed — Whisper will download '{model}' automatically on first use.")
|
|
663
663
|
|
|
664
|
-
#
|
|
665
|
-
# Step 6
|
|
666
|
-
#
|
|
664
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
665
|
+
# Step 6 — Remote Access / Port Forwarding
|
|
666
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
667
667
|
NGROK_ENABLED = BASE_DIR / "config" / "ngrok_enabled.txt"
|
|
668
668
|
NGROK_TOKEN = BASE_DIR / "config" / "ngrok_token.txt"
|
|
669
669
|
LOCALTUNNEL_LOG = BASE_DIR / "config" / "localtunnel.log"
|
|
670
670
|
LOCALTUNNEL_LOG = BASE_DIR / "config" / "localtunnel.log"
|
|
671
671
|
|
|
672
672
|
def setup_remote_access():
|
|
673
|
-
h1("Step 6 of 7
|
|
673
|
+
h1("Step 6 of 7 — Remote Access (ngrok / localtunnel)")
|
|
674
674
|
|
|
675
675
|
print(f"""
|
|
676
676
|
The SuperBrain backend runs locally. Your phone needs to reach it over the internet.
|
|
@@ -703,11 +703,11 @@ def setup_remote_access():
|
|
|
703
703
|
else:
|
|
704
704
|
warn("No ngrok token provided. ngrok may disconnect. To fix, re-run setup.")
|
|
705
705
|
|
|
706
|
-
#
|
|
707
|
-
# Step 6
|
|
708
|
-
#
|
|
706
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
707
|
+
# Step 6 — Access Token & Database
|
|
708
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
709
709
|
def setup_token_and_db():
|
|
710
|
-
h1("Step 7 of 7
|
|
710
|
+
h1("Step 7 of 7 — Access Token & Database")
|
|
711
711
|
|
|
712
712
|
# Token
|
|
713
713
|
if TOKEN_FILE.exists():
|
|
@@ -726,16 +726,31 @@ def setup_token_and_db():
|
|
|
726
726
|
TOKEN_FILE.write_text(new_token)
|
|
727
727
|
ok(f"Access Token saved: {BOLD}{GREEN}{new_token}{RESET}")
|
|
728
728
|
nl()
|
|
729
|
-
print(f" {YELLOW}Copy this token into the mobile app
|
|
729
|
+
print(f" {YELLOW}Copy this token into the mobile app → Settings → Access Token.{RESET}")
|
|
730
730
|
|
|
731
731
|
# DB is auto-created on first backend start; just let the user know
|
|
732
732
|
nl()
|
|
733
733
|
info("The SQLite database (superbrain.db) will be created automatically")
|
|
734
734
|
info("the first time the backend starts.")
|
|
735
735
|
|
|
736
|
-
#
|
|
736
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
737
737
|
# Launch Backend
|
|
738
|
-
#
|
|
738
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
739
|
+
def _start_ngrok(port: int) -> str | None:
|
|
740
|
+
try:
|
|
741
|
+
import pyngrok
|
|
742
|
+
from pyngrok import ngrok
|
|
743
|
+
|
|
744
|
+
token = NGROK_TOKEN.read_text().strip() if NGROK_TOKEN.exists() else None
|
|
745
|
+
if token:
|
|
746
|
+
ngrok.set_auth_token(token)
|
|
747
|
+
|
|
748
|
+
tunnel = ngrok.connect(port, bind_tls=True)
|
|
749
|
+
return tunnel.public_url
|
|
750
|
+
except Exception as e:
|
|
751
|
+
warn(f"Failed to start ngrok: {e}")
|
|
752
|
+
return None
|
|
753
|
+
|
|
739
754
|
def _find_localtunnel_url_from_log() -> str | None:
|
|
740
755
|
try:
|
|
741
756
|
import re
|
|
@@ -755,8 +770,10 @@ def _stop_localtunnel_processes():
|
|
|
755
770
|
"| Where-Object { $_.CommandLine -match 'localtunnel|\\.loca\\.lt' } "
|
|
756
771
|
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
|
757
772
|
)
|
|
773
|
+
import subprocess
|
|
758
774
|
subprocess.run(["powershell", "-NoProfile", "-Command", script], check=False)
|
|
759
775
|
else:
|
|
776
|
+
import subprocess
|
|
760
777
|
subprocess.run(["pkill", "-f", "localtunnel"], check=False)
|
|
761
778
|
except Exception:
|
|
762
779
|
pass
|
|
@@ -818,8 +835,10 @@ def _stop_localtunnel_processes():
|
|
|
818
835
|
"| Where-Object { $_.CommandLine -match 'localtunnel|\\.loca\\.lt' } "
|
|
819
836
|
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
|
820
837
|
)
|
|
838
|
+
import subprocess
|
|
821
839
|
subprocess.run(["powershell", "-NoProfile", "-Command", script], check=False)
|
|
822
840
|
else:
|
|
841
|
+
import subprocess
|
|
823
842
|
subprocess.run(["pkill", "-f", "localtunnel"], check=False)
|
|
824
843
|
except Exception:
|
|
825
844
|
pass
|
|
@@ -862,21 +881,6 @@ def _start_localtunnel(port: int, timeout: int = 25) -> str | None:
|
|
|
862
881
|
return url
|
|
863
882
|
return None
|
|
864
883
|
|
|
865
|
-
def _start_ngrok(port: int) -> str | None:
|
|
866
|
-
try:
|
|
867
|
-
import pyngrok
|
|
868
|
-
from pyngrok import ngrok
|
|
869
|
-
|
|
870
|
-
token = NGROK_TOKEN.read_text().strip() if NGROK_TOKEN.exists() else None
|
|
871
|
-
if token:
|
|
872
|
-
ngrok.set_auth_token(token)
|
|
873
|
-
|
|
874
|
-
tunnel = ngrok.connect(port, bind_tls=True)
|
|
875
|
-
return tunnel.public_url
|
|
876
|
-
except Exception as e:
|
|
877
|
-
warn(f"Failed to start ngrok: {e}")
|
|
878
|
-
return None
|
|
879
|
-
|
|
880
884
|
def _get_windows_pids_on_port(port: int) -> list[int]:
|
|
881
885
|
"""Return listener PIDs on Windows using Get-NetTCPConnection when available."""
|
|
882
886
|
pids: set[int] = set()
|
|
@@ -907,7 +911,7 @@ def _check_port(port: int) -> int | None:
|
|
|
907
911
|
if s.connect_ex(("127.0.0.1", port)) != 0:
|
|
908
912
|
return None # port is free
|
|
909
913
|
|
|
910
|
-
# Port is busy
|
|
914
|
+
# Port is busy — try to find the PID
|
|
911
915
|
try:
|
|
912
916
|
if IS_WINDOWS:
|
|
913
917
|
out = run_q(["netstat", "-ano"]).stdout
|
|
@@ -1064,7 +1068,7 @@ def _display_connect_qr(url: str, token: str):
|
|
|
1064
1068
|
for _ in range(quiet):
|
|
1065
1069
|
padded.append(list(empty_row))
|
|
1066
1070
|
|
|
1067
|
-
print(f"
|
|
1071
|
+
print(f" ┌{'─' * (padded_cols + 4)}┐")
|
|
1068
1072
|
|
|
1069
1073
|
# Center the title within the inner frame
|
|
1070
1074
|
inner_width = padded_cols + 4
|
|
@@ -1073,8 +1077,8 @@ def _display_connect_qr(url: str, token: str):
|
|
|
1073
1077
|
if len(title) > inner_width - 2:
|
|
1074
1078
|
title = title[:inner_width - 2]
|
|
1075
1079
|
|
|
1076
|
-
print(f"
|
|
1077
|
-
print(f"
|
|
1080
|
+
print(f" │{title.center(inner_width)}│")
|
|
1081
|
+
print(f" ├{'─' * inner_width}┤")
|
|
1078
1082
|
|
|
1079
1083
|
for y in range(0, padded_rows, 2):
|
|
1080
1084
|
line_chars = []
|
|
@@ -1083,17 +1087,17 @@ def _display_connect_qr(url: str, token: str):
|
|
|
1083
1087
|
bottom = padded[y + 1][x] if y + 1 < padded_rows else 0
|
|
1084
1088
|
|
|
1085
1089
|
if top and bottom:
|
|
1086
|
-
line_chars.append("
|
|
1090
|
+
line_chars.append("█")
|
|
1087
1091
|
elif top and not bottom:
|
|
1088
|
-
line_chars.append("
|
|
1092
|
+
line_chars.append("▀")
|
|
1089
1093
|
elif not top and bottom:
|
|
1090
|
-
line_chars.append("
|
|
1094
|
+
line_chars.append("▄")
|
|
1091
1095
|
else:
|
|
1092
1096
|
line_chars.append(" ")
|
|
1093
1097
|
|
|
1094
|
-
print(f"
|
|
1098
|
+
print(f" │ {''.join(line_chars)} │")
|
|
1095
1099
|
|
|
1096
|
-
print(f"
|
|
1100
|
+
print(f" └{'─' * (padded_cols + 4)}┘")
|
|
1097
1101
|
''')
|
|
1098
1102
|
|
|
1099
1103
|
interpreters = []
|
|
@@ -1146,7 +1150,7 @@ def launch_backend():
|
|
|
1146
1150
|
# Ensure upload endpoints won't crash FastAPI at import time.
|
|
1147
1151
|
ensure_runtime_dependencies()
|
|
1148
1152
|
|
|
1149
|
-
#
|
|
1153
|
+
# ── Port conflict check ───────────────────────────────────────────────────
|
|
1150
1154
|
PORT = 5000
|
|
1151
1155
|
pid = _check_port(PORT)
|
|
1152
1156
|
if pid is not None:
|
|
@@ -1159,7 +1163,7 @@ def launch_backend():
|
|
|
1159
1163
|
print(f" This is usually a previous SuperBrain server that wasn't stopped.")
|
|
1160
1164
|
print(f" Options:")
|
|
1161
1165
|
print(f" {BOLD}1{RESET} Kill the existing process and start fresh {DIM}(recommended){RESET}")
|
|
1162
|
-
print(f" {BOLD}2{RESET} Exit
|
|
1166
|
+
print(f" {BOLD}2{RESET} Exit — I'll stop it manually then re-run start.py")
|
|
1163
1167
|
nl()
|
|
1164
1168
|
choice = input(f" {BOLD}Choose [1/2]{RESET}: ").strip()
|
|
1165
1169
|
|
|
@@ -1180,7 +1184,7 @@ def launch_backend():
|
|
|
1180
1184
|
if IS_WINDOWS:
|
|
1181
1185
|
killed = _kill_pid_windows(pid)
|
|
1182
1186
|
if not killed:
|
|
1183
|
-
warn(f"PID {pid} is no longer active. Trying current listeners on port {PORT}
|
|
1187
|
+
warn(f"PID {pid} is no longer active. Trying current listeners on port {PORT} …")
|
|
1184
1188
|
else:
|
|
1185
1189
|
os.kill(pid, _sig.SIGTERM)
|
|
1186
1190
|
time.sleep(1)
|
|
@@ -1208,7 +1212,7 @@ def launch_backend():
|
|
|
1208
1212
|
info(f"Run: lsof -ti TCP:{PORT} -sTCP:LISTEN | xargs kill -9")
|
|
1209
1213
|
sys.exit(1)
|
|
1210
1214
|
else:
|
|
1211
|
-
# Unknown PID
|
|
1215
|
+
# Unknown PID — try to kill all listeners we can find
|
|
1212
1216
|
extra_pids = _find_pids_on_port(PORT)
|
|
1213
1217
|
if not extra_pids:
|
|
1214
1218
|
err("Cannot determine PID automatically.")
|
|
@@ -1241,7 +1245,7 @@ def launch_backend():
|
|
|
1241
1245
|
info(f"Try manually: kill -9 {pid}")
|
|
1242
1246
|
sys.exit(1)
|
|
1243
1247
|
|
|
1244
|
-
token = TOKEN_FILE.read_text().strip() if TOKEN_FILE.exists() else "
|
|
1248
|
+
token = TOKEN_FILE.read_text().strip() if TOKEN_FILE.exists() else "—"
|
|
1245
1249
|
local_ip = _detect_local_ip()
|
|
1246
1250
|
|
|
1247
1251
|
# tunnel startup
|
|
@@ -1258,7 +1262,7 @@ def launch_backend():
|
|
|
1258
1262
|
public_url = _start_ngrok(PORT)
|
|
1259
1263
|
if public_url:
|
|
1260
1264
|
tunnel_type = "ngrok"
|
|
1261
|
-
ok(f"ngrok active
|
|
1265
|
+
ok(f"ngrok active → {GREEN}{BOLD}{public_url}{RESET}")
|
|
1262
1266
|
else:
|
|
1263
1267
|
warn("ngrok failed. Falling back to localtunnel...")
|
|
1264
1268
|
|
|
@@ -1266,67 +1270,67 @@ def launch_backend():
|
|
|
1266
1270
|
public_url = _start_localtunnel(PORT)
|
|
1267
1271
|
if public_url:
|
|
1268
1272
|
tunnel_type = "localtunnel"
|
|
1269
|
-
ok(f"localtunnel active
|
|
1273
|
+
ok(f"localtunnel active → {GREEN}{BOLD}{public_url}{RESET}")
|
|
1270
1274
|
|
|
1271
1275
|
tunnel_line = ""
|
|
1272
1276
|
tunnel_hint = ""
|
|
1273
1277
|
|
|
1274
1278
|
if public_url:
|
|
1275
|
-
tunnel_line = f" Public URL
|
|
1276
|
-
tunnel_hint = f"
|
|
1279
|
+
tunnel_line = f" Public URL → {GREEN}{BOLD}{public_url}{RESET} {DIM}({tunnel_type}){RESET}"
|
|
1280
|
+
tunnel_hint = f" · public → {GREEN}{public_url}{RESET}"
|
|
1277
1281
|
elif NGROK_ENABLED.exists():
|
|
1278
|
-
tunnel_line = f" Public URL
|
|
1279
|
-
tunnel_hint = f"
|
|
1282
|
+
tunnel_line = f" Public URL → {YELLOW}(failed to start ngrok and localtunnel){RESET}"
|
|
1283
|
+
tunnel_hint = f" · public → run manually: {DIM}ngrok http {PORT}{RESET}"
|
|
1280
1284
|
else:
|
|
1281
|
-
tunnel_line = f" Public URL
|
|
1282
|
-
tunnel_hint = f"
|
|
1285
|
+
tunnel_line = f" Public URL → {YELLOW}(failed to start localtunnel){RESET}"
|
|
1286
|
+
tunnel_hint = f" · public → configure ngrok via {DIM}python start.py --reset{RESET} or ensure node/npx is installed"
|
|
1283
1287
|
|
|
1284
|
-
#
|
|
1288
|
+
# ── Generate and display QR code ──────────────────────────────────────────
|
|
1285
1289
|
qr_url = public_url if public_url else f"http://{local_ip}:{PORT}"
|
|
1286
1290
|
_display_connect_qr(qr_url, token)
|
|
1287
1291
|
|
|
1288
1292
|
print(f"""
|
|
1289
1293
|
{GREEN}{BOLD}Backend is starting up!{RESET}
|
|
1290
1294
|
|
|
1291
|
-
Local URL
|
|
1292
|
-
Network URL
|
|
1293
|
-
{(tunnel_line + chr(10)) if tunnel_line else ''} API docs
|
|
1294
|
-
Access Token
|
|
1295
|
+
Local URL → {CYAN}http://127.0.0.1:{PORT}{RESET}
|
|
1296
|
+
Network URL → {CYAN}http://{local_ip}:{PORT}{RESET}
|
|
1297
|
+
{(tunnel_line + chr(10)) if tunnel_line else ''} API docs → {CYAN}http://127.0.0.1:{PORT}/docs{RESET}
|
|
1298
|
+
Access Token → {BOLD}{MAGENTA}{token}{RESET}
|
|
1295
1299
|
|
|
1296
1300
|
{DIM}Keep this terminal open. Press Ctrl+C to stop the server.{RESET}
|
|
1297
1301
|
|
|
1298
1302
|
{YELLOW}Mobile app setup:{RESET}
|
|
1299
|
-
{BOLD}Option A
|
|
1300
|
-
1. Open the app
|
|
1303
|
+
{BOLD}Option A — Scan QR code (easiest):{RESET}
|
|
1304
|
+
1. Open the app → Settings → tap the {BOLD}QR icon{RESET} 📷
|
|
1301
1305
|
2. Scan the QR code shown above
|
|
1302
|
-
3. Done
|
|
1306
|
+
3. Done — auto-connected!
|
|
1303
1307
|
|
|
1304
|
-
{BOLD}Option B
|
|
1308
|
+
{BOLD}Option B — Manual setup:{RESET}
|
|
1305
1309
|
1. Build / install the SuperBrain APK on your Android device.
|
|
1306
|
-
2. Open the app
|
|
1310
|
+
2. Open the app → tap the ⚙ settings icon.
|
|
1307
1311
|
3. Set {BOLD}Server URL{RESET} to:
|
|
1308
|
-
|
|
1312
|
+
· Same WiFi → http://{local_ip}:{PORT}
|
|
1309
1313
|
{tunnel_hint}
|
|
1310
|
-
|
|
1314
|
+
· Port fwd → http://<your-public-ip>:{PORT}
|
|
1311
1315
|
4. Set {BOLD}Access Token{RESET} to: {BOLD}{MAGENTA}{token}{RESET}
|
|
1312
|
-
5. Tap {BOLD}Save{RESET}
|
|
1316
|
+
5. Tap {BOLD}Save{RESET} → Connected!
|
|
1313
1317
|
|
|
1314
1318
|
{YELLOW}Data Management:{RESET}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1319
|
+
• {BOLD}Export:{RESET} In app Settings → Data Import/Export → choose format (JSON/ZIP)
|
|
1320
|
+
• {BOLD}Import:{RESET} Upload backup file in app → Data Import/Export → select file
|
|
1321
|
+
• {BOLD}Reset:{RESET} Run {BOLD}python reset.py{RESET} for safe data cleanup options
|
|
1318
1322
|
|
|
1319
1323
|
{DIM}Security Note: Keep token.txt private. Anyone with this token can use your API.{RESET}
|
|
1320
|
-
{DIM}The app securely stores your Access Token locally
|
|
1324
|
+
{DIM}The app securely stores your Access Token locally — it's never transmitted anywhere but your server.{RESET}
|
|
1321
1325
|
""")
|
|
1322
1326
|
|
|
1323
1327
|
os.chdir(BASE_DIR)
|
|
1324
1328
|
os.execv(str(VENV_PYTHON), [str(VENV_PYTHON), "-m", "uvicorn", "api:app",
|
|
1325
1329
|
"--host", "0.0.0.0", "--port", str(PORT), "--reload"])
|
|
1326
1330
|
|
|
1327
|
-
#
|
|
1331
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1328
1332
|
# Main
|
|
1329
|
-
#
|
|
1333
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1330
1334
|
|
|
1331
1335
|
def launch_backend_status():
|
|
1332
1336
|
h1("SuperBrain Status")
|
|
@@ -1337,7 +1341,6 @@ def launch_backend_status():
|
|
|
1337
1341
|
|
|
1338
1342
|
# Fetch ngrok status via API
|
|
1339
1343
|
url = "NOT_FOUND"
|
|
1340
|
-
tunnel_type = "tunnel"
|
|
1341
1344
|
try:
|
|
1342
1345
|
import urllib.request, json
|
|
1343
1346
|
req = urllib.request.urlopen("http://127.0.0.1:4040/api/tunnels", timeout=2)
|
|
@@ -1345,20 +1348,12 @@ def launch_backend_status():
|
|
|
1345
1348
|
for tunnel in data.get("tunnels", []):
|
|
1346
1349
|
if tunnel.get("proto") == "https":
|
|
1347
1350
|
url = tunnel.get("public_url")
|
|
1348
|
-
tunnel_type = "ngrok"
|
|
1349
1351
|
break
|
|
1350
1352
|
except Exception:
|
|
1351
1353
|
pass
|
|
1352
1354
|
|
|
1353
1355
|
if url == "NOT_FOUND":
|
|
1354
|
-
|
|
1355
|
-
url_lt = _find_localtunnel_url_from_log()
|
|
1356
|
-
if url_lt:
|
|
1357
|
-
url = url_lt
|
|
1358
|
-
tunnel_type = "localtunnel"
|
|
1359
|
-
|
|
1360
|
-
if url == "NOT_FOUND":
|
|
1361
|
-
warn("Could not find a running ngrok or localtunnel URL. Is the server running?")
|
|
1356
|
+
warn("Could not find a running ngrok URL. Is the server running?")
|
|
1362
1357
|
nl()
|
|
1363
1358
|
print(" Wait 5 seconds, or run 'superbrain-server' to start the server.")
|
|
1364
1359
|
return
|
|
@@ -1370,10 +1365,11 @@ def launch_backend_status():
|
|
|
1370
1365
|
network_url = f"http://{local_ip}:5000"
|
|
1371
1366
|
|
|
1372
1367
|
nl()
|
|
1373
|
-
print(f" Local URL
|
|
1374
|
-
print(f" Network URL
|
|
1375
|
-
print(f" Public URL
|
|
1376
|
-
print(f" API docs
|
|
1368
|
+
print(f" Local URL \u2192 {CYAN}{local_url}{RESET}")
|
|
1369
|
+
print(f" Network URL \u2192 {CYAN}{network_url}{RESET}")
|
|
1370
|
+
print(f" Public URL \u2192 {CYAN}{url}{RESET} (localtunnel)")
|
|
1371
|
+
print(f" API docs \u2192 {CYAN}{local_url}/docs{RESET}")
|
|
1372
|
+
print(f" Access Token \u2192 {BOLD}{MAGENTA}{token}{RESET}")
|
|
1377
1373
|
nl()
|
|
1378
1374
|
|
|
1379
1375
|
def main():
|
|
@@ -1396,8 +1392,8 @@ def main():
|
|
|
1396
1392
|
reset_mode = "--reset" in sys.argv
|
|
1397
1393
|
|
|
1398
1394
|
if SETUP_DONE.exists() and not reset_mode:
|
|
1399
|
-
# Already configured
|
|
1400
|
-
print(f" {GREEN}Setup already complete.{RESET} Starting backend
|
|
1395
|
+
# Already configured — just launch
|
|
1396
|
+
print(f" {GREEN}Setup already complete.{RESET} Starting backend …")
|
|
1401
1397
|
print(f" {DIM}Run python start.py --reset to redo the setup wizard.{RESET}")
|
|
1402
1398
|
launch_backend()
|
|
1403
1399
|
return
|
|
@@ -1405,18 +1401,18 @@ def main():
|
|
|
1405
1401
|
print(f"""
|
|
1406
1402
|
Welcome to SuperBrain! This wizard will guide you through:
|
|
1407
1403
|
|
|
1408
|
-
1
|
|
1409
|
-
2
|
|
1410
|
-
3
|
|
1411
|
-
4
|
|
1412
|
-
5
|
|
1413
|
-
6
|
|
1414
|
-
7
|
|
1404
|
+
1 · Create Python virtual environment
|
|
1405
|
+
2 · Install all required packages
|
|
1406
|
+
3 · Configure AI provider keys + Instagram credentials
|
|
1407
|
+
4 · Set up an offline AI model via Ollama (qwen3-vl:4b)
|
|
1408
|
+
5 · Set up offline audio transcription (Whisper + ffmpeg)
|
|
1409
|
+
6 · Configure remote access (localtunnel or port forwarding)
|
|
1410
|
+
7 · Generate Access Token & initialise database
|
|
1415
1411
|
|
|
1416
1412
|
Press {BOLD}Enter{RESET} to accept defaults shown in [{DIM}brackets{RESET}].
|
|
1417
1413
|
You can re-run this wizard any time with: {BOLD}python start.py --reset{RESET}
|
|
1418
1414
|
""")
|
|
1419
|
-
input(f" Press {BOLD}Enter{RESET} to begin
|
|
1415
|
+
input(f" Press {BOLD}Enter{RESET} to begin … ")
|
|
1420
1416
|
|
|
1421
1417
|
try:
|
|
1422
1418
|
setup_venv()
|
|
@@ -1435,9 +1431,9 @@ def main():
|
|
|
1435
1431
|
SETUP_DONE.write_text("ok")
|
|
1436
1432
|
|
|
1437
1433
|
nl()
|
|
1438
|
-
print(f" {GREEN}{BOLD}{'
|
|
1439
|
-
print(f" {GREEN}{BOLD}
|
|
1440
|
-
print(f" {GREEN}{BOLD}{'
|
|
1434
|
+
print(f" {GREEN}{BOLD}{'═'*60}{RESET}")
|
|
1435
|
+
print(f" {GREEN}{BOLD} ✓ Setup complete!{RESET}")
|
|
1436
|
+
print(f" {GREEN}{BOLD}{'═'*60}{RESET}")
|
|
1441
1437
|
nl()
|
|
1442
1438
|
|
|
1443
1439
|
if ask_yn("Start the backend now?", default=True):
|
|
@@ -1447,4 +1443,3 @@ def main():
|
|
|
1447
1443
|
|
|
1448
1444
|
if __name__ == "__main__":
|
|
1449
1445
|
main()
|
|
1450
|
-
|