diff --git a/ai-server/index.mjs b/ai-server/index.mjs index 726c577..e8a5a7a 100644 --- a/ai-server/index.mjs +++ b/ai-server/index.mjs @@ -1,6 +1,7 @@ import http from "node:http" import fs from "node:fs" import path from "node:path" +import { spawn } from "node:child_process" import { fileURLToPath } from "node:url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -34,6 +35,10 @@ const DATABASE_URL = process.env.DATABASE_URL const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl") const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md") +// ── Setup state ────────────────────────────────────────────────── +let pullProcess = null +let pullProgress = { status: "idle", progress: 0, message: "" } + // ── Job loading ───────────────────────────────────────────────── function loadJobs() { try { @@ -297,6 +302,140 @@ const server = http.createServer(async (req, res) => { return sendJSON(res, 200, results) } + // ── Setup endpoints ───────────────────────────────────────── + + // GET /setup/status — check environment + if (req.method === "GET" && pathname === "/setup/status") { + const envExists = fs.existsSync(path.join(ROOT, ".env.local")) + + // Ollama check + let ollamaRunning = false + try { + await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) }) + ollamaRunning = true + } catch {} + + // Model check + let modelAvailable = false + if (ollamaRunning) { + try { + const r = await fetch(`${OLLAMA_URL}/api/show`, { + method: "POST", body: JSON.stringify({ name: MODEL }), + signal: AbortSignal.timeout(5000), + }) + modelAvailable = r.ok + } catch {} + } + + // Profile auto-detect + let profilePath = process.env.FX_PROFILE || "" + let profileDetected = !!profilePath + if (!profileDetected) { + try { + const r = await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) }) + if (r.ok) { + const diag = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json() + if (diag.path) { profilePath = diag.path; profileDetected = true } + } + } catch {} + } + + // Login check + let facebookLoggedIn = false + if (profileDetected) { + try { + const r = await fetch("http://127.0.0.1:3008/setup/check-login", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile_path: profilePath }), + signal: AbortSignal.timeout(15000), + }) + if (r.ok) { const d = await r.json(); facebookLoggedIn = d.logged_in === true } + } catch {} + } + + const firstRun = !envExists || !ollamaRunning || !profileDetected || !modelAvailable + + return sendJSON(res, 200, { + first_run: firstRun, + env_exists: envExists, + ollama_running: ollamaRunning, + model_available: modelAvailable, + model_name: MODEL, + profile_detected: profileDetected, + profile_path: profilePath || null, + facebook_logged_in: facebookLoggedIn, + }) + } + + // POST /setup/profile — save profile path to .env.local + if (req.method === "POST" && pathname === "/setup/profile") { + const body = await parseBody(req) + const profilePath = (body.path || "").trim() + if (!profilePath) return sendJSON(res, 400, { error: "Path required" }) + if (!fs.existsSync(profilePath)) return sendJSON(res, 400, { error: "Path does not exist" }) + + const envPath = path.join(ROOT, ".env.local") + let content = "" + try { content = fs.readFileSync(envPath, "utf-8") } catch {} + const lines = content.split("\n") + let found = false + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith("FX_PROFILE=")) { + lines[i] = `FX_PROFILE=${profilePath}` + found = true + break + } + } + if (!found) lines.push(`FX_PROFILE=${profilePath}`) + fs.writeFileSync(envPath, lines.join("\n"), "utf-8") + process.env.FX_PROFILE = profilePath + return sendJSON(res, 200, { success: true, path: profilePath }) + } + + // POST /setup/check-login — verify Facebook login in the given profile + if (req.method === "POST" && pathname === "/setup/check-login") { + const body = await parseBody(req) + const profilePath = body.profile_path || process.env.FX_PROFILE || "" + if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" }) + try { + const r = await fetch("http://127.0.0.1:3008/setup/check-login", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile_path: profilePath }), + signal: AbortSignal.timeout(20000), + }) + if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) } + } catch {} + return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" }) + } + + // POST /setup/ollama/pull — start pulling the model + if (req.method === "POST" && pathname === "/setup/ollama/pull") { + if (pullProcess) return sendJSON(res, 200, { status: "already_running" }) + pullProgress = { status: "downloading", progress: 0, message: "Starting..." } + const isWin = process.platform === "win32" + const cmd = isWin ? "ollama.exe" : "ollama" + pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] }) + + pullProcess.stdout.on("data", (data) => { + const text = data.toString() + pullProgress.message = text.trim() + // Extract percentage from patterns like "pulling xxxx... 45%" + const m = text.match(/(\d+)%/) + if (m) pullProgress.progress = parseInt(m[1], 10) + }) + pullProcess.on("close", (code) => { + pullProcess = null + pullProgress.status = code === 0 ? "done" : "failed" + if (code === 0) pullProgress.progress = 100 + }) + return sendJSON(res, 200, { status: "started" }) + } + + // GET /setup/ollama/pull/progress + if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") { + return sendJSON(res, 200, pullProgress) + } + // GET /ai/jobs if (req.method === "GET" && pathname === "/ai/jobs") { return sendJSON(res, 200, { jobs: loadedJobs }) diff --git a/browser-use-service/main.py b/browser-use-service/main.py index 6ad51c9..cddb226 100644 --- a/browser-use-service/main.py +++ b/browser-use-service/main.py @@ -1105,6 +1105,36 @@ Return a JSON array like ["yes","no","yes"] matching the order above.""" async def health(): return {"status": "ok"} +@app.get("/setup/profile") +async def setup_profile(): + """Auto-detect Firefox profile path.""" + path = FX_PROFILE or find_firefox_profile() + return {"path": path, "detected": bool(path)} + +@app.post("/setup/check-login") +async def setup_check_login(body: dict): + """Check if Facebook is logged in using the given profile.""" + profile_path = (body.get("profile_path") or "").strip() or FX_PROFILE or find_firefox_profile() + if not profile_path: + return {"logged_in": False, "reason": "no_profile", "error": "No Firefox profile found"} + try: + profile_dir = copy_firefox_profile(profile_path) + async with async_playwright() as pw: + context = await pw.firefox.launch_persistent_context( + user_data_dir=profile_dir, headless=True, + firefox_user_prefs={"dom.webdriver.enabled": False}, + ) + page = context.pages[0] if context.pages else await context.new_page() + await page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=30000) + await page.wait_for_timeout(3000) + logged_in = "/login" not in page.url.lower() + await context.close() + shutil.rmtree(profile_dir, ignore_errors=True) + return {"logged_in": logged_in, "reason": None if logged_in else "login_page"} + except Exception as e: + logger.error("Setup check-login failed: %s", e) + return {"logged_in": False, "reason": "error", "error": str(e)} + @app.post("/agent/run") async def agent_run(task: str = Body(..., embed=True)): """Run a browser-use Agent with ChatOllama (free/local).""" diff --git a/splash.html b/splash.html index 367dfce..83696eb 100644 --- a/splash.html +++ b/splash.html @@ -293,6 +293,125 @@ .retry-btn.active { display: inline-block; } + + /* ── Setup Wizard ──────────────────────────────────────────── */ + .setup-wizard { + display: none; + position: fixed; + inset: 0; + background: #0a0a1a; + z-index: 200; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + overflow-y: auto; + } + .setup-wizard.active { display: flex; } + + .setup-step { + display: none; + flex-direction: column; + align-items: center; + max-width: 520px; + width: 100%; + animation: fadeIn 0.3s ease-out; + } + .setup-step.active { display: flex; } + @keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } + + .setup-steps { + display: flex; + gap: 8px; + margin-bottom: 32px; + } + .setup-dot { + width: 10px; height: 10px; + border-radius: 50%; + background: rgba(255,255,255,0.15); + transition: all 0.3s; + } + .setup-dot.done { background: #22d3ee; box-shadow: 0 0 6px rgba(34,211,238,0.5); } + .setup-dot.current { background: #6366f1; box-shadow: 0 0 6px rgba(99,102,241,0.5); } + + .setup-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; } + .setup-subtitle { font-size: 14px; opacity: 0.5; margin-bottom: 28px; text-align: center; } + + .setup-card { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; + padding: 20px; + width: 100%; + margin-bottom: 20px; + } + + .check-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); + } + .check-row:last-child { border-bottom: none; } + .check-icon { font-size: 18px; min-width: 24px; text-align: center; } + .check-label { flex: 1; font-size: 14px; } + .check-value { font-size: 12px; opacity: 0.5; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + .setup-input { + width: 100%; + padding: 10px 14px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + color: #fff; + font-size: 14px; + font-family: inherit; + outline: none; + transition: border-color 0.2s; + } + .setup-input:focus { border-color: #6366f1; } + + .setup-btn { + padding: 10px 28px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + border: none; + border-radius: 8px; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + } + .setup-btn:hover { opacity: 0.85; } + .setup-btn:disabled { opacity: 0.4; cursor: not-allowed; } + .setup-btn.outline { + background: transparent; + border: 1px solid rgba(255,255,255,0.15); + } + .setup-btn.outline:hover { background: rgba(255,255,255,0.06); } + + .setup-btns { display: flex; gap: 12px; margin-top: 8px; } + + .progress-bar { + width: 100%; + height: 8px; + background: rgba(255,255,255,0.08); + border-radius: 4px; + overflow: hidden; + margin: 12px 0; + } + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #6366f1, #22d3ee); + border-radius: 4px; + transition: width 0.5s ease; + width: 0%; + } + + .status-ok { color: #22d3ee; } + .status-err { color: #ef4444; } + .status-warn { color: #f59e0b; } @@ -354,100 +473,363 @@
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
Welcome to CoastIT CRM
+
Let's check your environment before we start
+
+
Ollamachecking...
+
AI Modelchecking...
+
Browser Profilechecking...
+
Facebook Loginchecking...
+
+ +
+ + +
+
Browser Profile
+
We need a Firefox or Chrome profile with Facebook logged in
+
+
+ + Auto-detected + scanning... +
+
Or enter the path manually:
+ +
+
+
+ + +
+
+ + +
+
Facebook Login
+
Make sure you're logged into Facebook in your browser
+
+
🔍
+
Click "Check Login" to verify
+ +
+
+ + +
+
+ + +
+
AI Model
+
We need to pull the AI model for the scraper to work
+
+
dolphin-llama3:8b
+
+
Not downloaded yet
+
+
+ + +
+
+ + +
+
🎉
+
All set!
+
Your environment is configured. We'll now start all services.
+
+
All checks passed
+
+ +
+
+ \ No newline at end of file