mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
Add Node.js AI server, fix dev scripts, ignore rust-ai/target
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import http from "node:http"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = path.resolve(__dirname, "..")
|
||||
|
||||
// ── 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 || "sam860/dolphin3-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")
|
||||
|
||||
// ── 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 handleChat(userMessage, userId, userRole) {
|
||||
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 /health
|
||||
if (req.method === "GET" && pathname === "/health") {
|
||||
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
||||
}
|
||||
|
||||
// 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 body = await parseBody(req)
|
||||
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()
|
||||
Reference in New Issue
Block a user