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)) const ROOT = path.resolve(__dirname, "..") // ── Load .env.local ────────────────────────────────────────────── try { const envPath = path.join(ROOT, ".env.local") const envContent = fs.readFileSync(envPath, "utf-8") for (const line of envContent.split("\n")) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith("#")) continue const eqIdx = trimmed.indexOf("=") if (eqIdx === -1) continue const k = trimmed.substring(0, eqIdx).trim() let v = trimmed.substring(eqIdx + 1).trim() if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1) if (!process.env[k]) process.env[k] = v } console.log("Loaded .env.local") } catch { // .env.local may not exist, ignore } // ── Config from env ───────────────────────────────────────────── const PORT = parseInt(process.env.AI_PORT || "3001", 10) const HOST = process.env.AI_HOST || "0.0.0.0" const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434" const MODEL = process.env.AI_MODEL || "llama3.2:3b" 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 { const content = fs.readFileSync(JOBS_PATH, "utf-8") const jobs = content .split("\n") .map((l) => l.trim()) .filter(Boolean) .map((l) => { try { return JSON.parse(l) } catch { return null } }) .filter(Boolean) console.log(`Loaded ${jobs.length} job categories`) return jobs } catch (e) { console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message) return [] } } // ── ai.md management ──────────────────────────────────────────── function readInstructions() { try { return fs.readFileSync(AI_MD_PATH, "utf-8") } catch { return "" } } function writeInstructions(content) { fs.writeFileSync(AI_MD_PATH, content, "utf-8") return content } function appendToImprovementLog(entry) { const current = readInstructions() const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16) const logEntry = `\n- ${timestamp} — ${entry}` const logSection = "\n## Improvement Log" const logIndex = current.indexOf(logSection) if (logIndex !== -1) { const afterLog = current.substring(logIndex + logSection.length) const nextSectionIndex = afterLog.search(/\n## /) if (nextSectionIndex !== -1) { const insertAt = logIndex + logSection.length + nextSectionIndex const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt) writeInstructions(updated) return updated } writeInstructions(current + logEntry + "\n") return current + logEntry + "\n" } const updated = current + `\n${logSection}\n${logEntry}\n` writeInstructions(updated) return updated } // ── Chat handler ──────────────────────────────────────────────── async function scrapeFacebook() { const profilePath = process.env.FX_PROFILE || "" const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}` const logPath = "C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-scrape-debug.log" try { const body = await new Promise((resolve, reject) => { const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => { let data = "" res.on("data", (c) => data += c) res.on("end", () => resolve(data)) res.on("error", reject) }) req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) }) req.on("error", reject) req.end() }) const data = JSON.parse(body) return data } catch (e) { return null } } function formatLeads(leads) { if (!leads || leads.length === 0) return "No leads found from the latest scrape." let output = `**${leads.length} leads found:**\n\n` for (let i = 0; i < leads.length; i++) { const l = leads[i] output += `${i + 1}. ${l.title || "No title"}\n` if (l.author) output += ` Author: ${l.author}\n` if (l.date) output += ` Date: ${l.date}\n` if (l.url) output += ` URL: ${l.url}\n` output += "\n" } return output.trim() } async function handleChat(userMessage, userId, userRole) { const lowerMsg = userMessage.toLowerCase() const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"] if (triggerWords.some(w => lowerMsg.includes(w))) { const result = await scrapeFacebook() if (result && result.success) { return formatLeads(result.leads) } return "Scraper returned no results or encountered an error. Try again later." } const jobs = loadedJobs const instructions = readInstructions() const jobList = jobs .map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`) .join("\n") const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance. Available job categories to target: ${jobList} Current instructions: ${instructions} Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.` const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: MODEL, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userMessage }, ], stream: false, options: { temperature: 0.7, num_predict: 1024 }, }), }) if (!ollamaRes.ok) { const text = await ollamaRes.text() throw new Error(`Ollama error (${ollamaRes.status}): ${text}`) } const data = await ollamaRes.json() const responseText = data.message?.content || "" // Try to persist to DB (best-effort) try { if (pgPool && userId) { await pgPool.query( "INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)", [crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText] ) } } catch { // table might not exist, ignore } return responseText } // ── PG pool (lazy init) ──────────────────────────────────────── let pgPool = null async function initPg() { if (!DATABASE_URL) return try { const { default: pg } = await import("pg") pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 }) await pgPool.query("SELECT 1") console.log("Connected to PostgreSQL") } catch (e) { console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message) } } // ── Request router ───────────────────────────────────────────── const loadedJobs = loadJobs() function sendJSON(res, status, data) { res.writeHead(status, { "Content-Type": "application/json" }) res.end(JSON.stringify(data)) } function parseBody(req) { return new Promise((resolve, reject) => { let body = "" req.on("data", (chunk) => (body += chunk)) req.on("end", () => { try { resolve(JSON.parse(body)) } catch { reject(new Error("Invalid JSON")) } }) req.on("error", reject) }) } function parseURL(req) { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`) return { pathname: url.pathname, searchParams: url.searchParams } } const server = http.createServer(async (req, res) => { // CORS res.setHeader("Access-Control-Allow-Origin", "*") res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") res.setHeader("Access-Control-Allow-Headers", "Content-Type") if (req.method === "OPTIONS") { res.writeHead(204) res.end() return } const { pathname } = parseURL(req) try { // GET /splash — loading screen if (req.method === "GET" && pathname === "/splash") { const splashPath = path.join(ROOT, "splash.html") if (fs.existsSync(splashPath)) { const html = fs.readFileSync(splashPath, "utf-8") res.writeHead(200, { "Content-Type": "text/html" }) res.end(html) return } sendJSON(res, 200, { status: "ok" }) return } // GET /health if (req.method === "GET" && pathname === "/health") { return sendJSON(res, 200, { status: "ok", model: MODEL }) } // GET /status — combined health of all services if (req.method === "GET" && pathname === "/status") { const { default: http } = await import("http") const results = { ai: true } // Check scraper try { await new Promise((resolve, reject) => { const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() }) r.on("error", reject) }) results.scraper = true } catch { results.scraper = false } // Check frontend try { await new Promise((resolve, reject) => { const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() }) r.on("error", reject) }) results.frontend = true } catch { results.frontend = false } 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 }) } // GET /ai/instructions if (req.method === "GET" && pathname === "/ai/instructions") { const instructions = readInstructions() return sendJSON(res, 200, { success: true, instructions }) } // POST /ai/instructions if (req.method === "POST" && pathname === "/ai/instructions") { const body = await parseBody(req) if (body.content) { writeInstructions(body.content) } else if (body.entry) { appendToImprovementLog(body.entry) } return sendJSON(res, 200, { success: true, instructions: readInstructions(), }) } // POST /ai/chat if (req.method === "POST" && pathname === "/ai/chat") { const startTime = Date.now() const chunks = [] req.on("data", c => chunks.push(c)) req.on("end", () => { const rawBody = Buffer.concat(chunks).toString() try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt", `${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {} try { const body = JSON.parse(rawBody) // Continue processing processRequest(req, res, body, startTime) } catch { sendJSON(res, 400, { error: "Invalid JSON" }) } }) return } // Separate handler async function processRequest(req, res, body, startTime) { const { message, user_id, user_role } = body if (!message) { return sendJSON(res, 400, { error: "message is required" }) } const validRoles = ["sales", "admin", "super_admin"] if (user_role && !validRoles.includes(user_role)) { return sendJSON(res, 403, { error: "Forbidden" }) } const response = await handleChat(message, user_id || "", user_role || "sales") return sendJSON(res, 200, { response }) } // 404 sendJSON(res, 404, { error: "Not found" }) } catch (err) { console.error("Request error:", err) sendJSON(res, 500, { error: err.message }) } }) // ── Start ─────────────────────────────────────────────────────── server.listen(PORT, HOST, () => { console.log(`CRM AI server listening on http://${HOST}:${PORT}`) console.log(` Model: ${MODEL}`) console.log(` Ollama: ${OLLAMA_URL}`) }) initPg()