const fs = require("fs"); const path = require("path"); const express = require("express"); const { ensureDataDirs, resolveData } = require("./backend/paths"); const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager"); const { detectHardware } = require("./backend/hardware"); const metrics = require("./backend/metrics"); const { canUse, roleOf } = require("./backend/permissions"); const { RequestQueue } = require("./backend/queue_manager"); const { ToolRegistry } = require("./backend/tool_router"); const { DownloadManager } = require("./backend/downloader"); const { RuntimeManager } = require("./backend/runtime_manager"); const { AiProvider } = require("./backend/ai_provider"); const { getLatestDiagnostic, createDiagnosticsBundle } = require("./backend/diagnostics"); const PLUGIN_ID = "lumi_ai"; const modelManifest = require("./models_manifest.json"); const runtimeManifest = require("./runtime_manifest.json"); module.exports = { id: PLUGIN_ID, init({ web, settings }) { ensureDataDirs(); let config = getConfig(); const getModel = (id) => modelManifest.models.find((model) => model.id === id); const downloads = new DownloadManager((entry) => metrics.record(entry)); const queue = new RequestQueue(() => config); const tools = new ToolRegistry((entry) => metrics.record(entry)); const contextProviders = new Map(); const getSafeContext = (role) => [...contextProviders.values()].flatMap((fn) => { try { return normalizeContext(fn({ role })); } catch { return []; } }); const runtime = new RuntimeManager({ getConfig: () => config, getModel, runtimeManifest, onCrash: (message) => metrics.record({ kind: "runtime", status: "failed", runtime_crash: true, message }), onDiagnostic: (entry) => metrics.record(entry) }); const provider = new AiProvider({ getConfig: () => config, runtime, queue, tools, metrics, getContext: getSafeContext }); const api = { health: () => runtime.health(), capabilities: () => ({ provider: "local_llama_cpp", enabled: config.enabled, model_id: config.selected_model_id, roles: config.assistant_visibility, tools: tools.list("admin").map((tool) => tool.tool_id) }), metrics_summary: () => metrics.report(), generate: (input) => provider.generate(input), classify: (input) => provider.classify(input), summarize: (input) => provider.summarize(input), route_tool: async ({ message, allowed_tools = [], ...input }) => { const result = await provider.generate({ message, ...input, scope: "route_tool" }); if (result.tool_call && !allowed_tools.includes(result.tool_call.tool)) { return { ...result, success: false, tool_call: null, refusal_reason: "tool_not_allowed" }; } return result; }, registerTool: (definition) => tools.register(definition), registerContext: (id, factory) => { if (!id || typeof factory !== "function") throw new Error("Invalid AI context provider."); contextProviders.set(id, factory); }, unregisterContext: (id) => contextProviders.delete(id) }; global.lumiFrameworks = global.lumiFrameworks || {}; global.lumiFrameworks.ai = api; global.lumiFrameworks.lumi_ai = api; const router = web.createRouter(); router.use("/assets", express.static(path.join(__dirname, "public"))); router.get("/", async (req, res) => { if (!req.session.user?.isAdmin) return denied(res); const hardware = detectHardware(modelManifest.models); const runtimeTarget = getRuntimeTarget(); res.render(path.join(__dirname, "views", "settings.ejs"), { title: "Lumi AI", config, models: modelManifest.models.map((model) => ({ ...model, downloaded: fs.existsSync(resolveData("models", model.filename)), compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb })), runtimeTarget, runtimeManifest, runtimeStatus: await runtime.health(), runtimeState: getRuntimeState(), latestDiagnostic: getLatestDiagnostic(), runtimeFolderSize: folderSize(resolveData("runtime")), modelFileSize: modelFileSize(getModel(config.selected_model_id)), hardware, metrics: metrics.report(), history: metrics.history(25), logFiles: listLogFiles(), formatBytes, formatDuration }); }); router.post("/settings", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); const model = getModel(req.body.selected_model_id); if (!model) return flash(req, res, "error", "Unknown model."); config = saveConfig({ ...config, enabled: req.body.enabled === "on", selected_model_id: model.id, context_size: boundedInt(req.body.context_size, 512, 131072, 4096), threads: boundedInt(req.body.threads, 0, 256, 0), concurrency: boundedInt(req.body.concurrency, 1, 8, 1), max_queue_length: boundedInt(req.body.max_queue_length, 1, 100, 8), request_timeout_ms: boundedInt(req.body.request_timeout_ms, 5000, 600000, 120000), per_user_requests_per_minute: boundedInt(req.body.per_user_requests_per_minute, 1, 120, 6), admin_bypass_rate_limit: req.body.admin_bypass_rate_limit === "on", assistant_visibility: { admins: req.body.visibility_admins === "on", mods: req.body.visibility_mods === "on", users: req.body.visibility_users === "on" }, instructions: { identity: cleanText(req.body.identity, 1000), style: cleanText(req.body.style, 1000), allowed_topics: cleanText(req.body.allowed_topics, 2000), out_of_scope_response: cleanText(req.body.out_of_scope_response, 1000), maximum_answer_length: boundedInt(req.body.maximum_answer_length, 100, 4000, 700), roleplay_intensity: boundedInt(req.body.roleplay_intensity, 0, 10, 0), community_tone: cleanText(req.body.community_tone, 2000), admin_custom: cleanText(req.body.admin_custom, 6000) }, logging: { log_prompts: req.body.log_prompts === "on", log_responses: req.body.log_responses === "on", log_tool_calls: req.body.log_tool_calls === "on", log_metrics: req.body.log_metrics === "on", log_internal_audit: req.body.log_internal_audit === "on" } }); return flash(req, res, "success", "Lumi AI settings saved."); }); router.post("/download/runtime", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); const target = getRuntimeTarget(); if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform."); try { downloads.start({ id: "runtime", ...target, kind: "runtime", archive: true }); return flash(req, res, "success", "Runtime download started."); } catch (error) { return flash(req, res, "error", error.message); } }); router.post("/download/model/:id", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); const model = getModel(req.params.id); if (!model) return flash(req, res, "error", "Unknown model."); const hardware = detectHardware(modelManifest.models); const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb; if (incompatible && req.body.override_compatibility !== "on") { return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway."); } try { downloads.start({ id: `model:${model.id}`, url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`, filename: model.filename, sha256: model.sha256, size: model.size, kind: "model" }); return flash(req, res, "success", `${model.label} download started.`); } catch (error) { return flash(req, res, "error", error.message); } }); router.get("/api/status", async (req, res) => { if (!canUse(req.session.user, config) && !req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); res.json({ runtime: await runtime.health(), queue_length: queue.length, enabled: config.enabled, model_id: config.selected_model_id }); }); router.get("/api/downloads", (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); res.json(Object.fromEntries(downloads.jobs)); }); router.post("/runtime/:action", async (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); try { const action = req.params.action; if (!["start", "stop", "restart", "self-test", "verify-runtime", "verify-model"].includes(action)) throw new Error("Unknown runtime action."); const result = action === "self-test" ? await runtime.selfTest() : action === "verify-runtime" ? runtime.verifyRuntimeInstallation() : action === "verify-model" ? await runtime.verifyModel() : action === "stop" ? await runtime.stop({ manual: true, reason: "admin_stop" }) : action === "restart" ? await runtime.restart() : await runtime.start(); if (result?.success === false) return res.status(400).json({ error: result.message, diagnostic: result }); res.json(result); } catch (error) { res.status(400).json({ error: error.message }); } }); router.get("/diagnostics/download", (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); const file = createDiagnosticsBundle({ config, runtimeState: getRuntimeState(), manifest: { runtime: runtimeManifest, model: getModel(config.selected_model_id) }, metrics: metrics.report() }); return res.download(file); }); router.post("/assistant/message", async (req, res) => { if (!config.enabled || !canUse(req.session.user, config)) return res.status(403).json({ error: "Lumi AI is unavailable for this account." }); const message = cleanText(req.body.message, 6000); if (!message) return res.status(400).json({ error: "Message is required." }); try { res.json(await provider.generate({ message, user: req.session.user, sessionId: req.sessionID })); } catch (error) { metrics.record({ kind: "request", status: "failed", user_id: req.session.user.id, role: roleOf(req.session.user), message: error.message }); res.status(error.code === "QUEUE_FULL" || error.code === "RATE_LIMIT" ? 429 : 503).json({ error: error.message }); } }); router.post("/assistant/test", async (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); const message = cleanText(req.body.message, 6000); if (!message) return res.status(400).json({ error: "Message is required." }); const simulatedRole = ["admin", "mod", "user"].includes(req.body.role) ? req.body.role : "admin"; const simulatedUser = { id: req.session.user.id, username: req.session.user.username, isAdmin: simulatedRole === "admin", isMod: simulatedRole === "mod" }; try { const result = await provider.test({ message, user: simulatedUser, includeRaw: Boolean(req.body.show_raw_output) }); if (!req.body.show_raw_prompt) delete result.raw_prompt; res.json(result); } catch (error) { res.status(503).json({ error: error.message }); } }); router.post("/assistant/confirm", async (req, res) => { if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." }); try { res.json({ success: true, result: await tools.confirm({ id: req.body.id, user: req.session.user, sessionId: req.sessionID }) }); } catch (error) { res.status(400).json({ error: error.message }); } }); router.post("/assistant/cancel", (req, res) => { if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." }); const cancelled = tools.cancel(req.body.id, req.session.user.id); metrics.record({ kind: "tool", status: cancelled ? "cancelled" : "failed", user_id: req.session.user.id }); res.json({ success: cancelled }); }); web.mount(`/plugins/${PLUGIN_ID}`, router, { label: "Lumi AI", role: "admin", section: "plugins" }); if (typeof web.addAssistantPanel === "function") { web.addAssistantPanel({ id: PLUGIN_ID, role: "user", isVisible: (user) => config.enabled && canUse(user, config), view: path.join(__dirname, "views", "assistant-panel.ejs"), stylesheet: `/plugins/${PLUGIN_ID}/assets/assistant.css`, script: `/plugins/${PLUGIN_ID}/assets/assistant.js`, locals: { endpoint: `/plugins/${PLUGIN_ID}` } }); } else { console.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible."); } ensureSidebarNavItem(settings); const state = getRuntimeState(); if (shouldAutoResume(config, state)) { setImmediate(() => runtime.start({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error))); } return async () => { await runtime.stop({ manual: false, reason: "bot_shutdown" }); if (global.lumiFrameworks?.ai === api) delete global.lumiFrameworks.ai; if (global.lumiFrameworks?.lumi_ai === api) delete global.lumiFrameworks.lumi_ai; }; } }; function getRuntimeTarget() { return runtimeManifest.targets[`${process.platform}-${process.arch}`] || null; } function normalizeContext(value) { if (Array.isArray(value)) return value.filter((item) => typeof item === "string"); return typeof value === "string" ? [value] : []; } function boundedInt(value, min, max, fallback) { const number = Number.parseInt(value, 10); return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; } function cleanText(value, max) { return String(value || "").trim().slice(0, max); } function flash(req, res, type, message) { req.session.flash = { type, message }; return res.redirect(`/plugins/${PLUGIN_ID}`); } function denied(res) { return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." }); } function formatBytes(bytes) { if (!bytes) return "0 B"; const units = ["B", "MB", "GB", "TB"]; const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`; } function formatDuration(ms) { if (!ms) return "0 ms"; return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; } function listLogFiles() { const dir = resolveData("logs"); return fs.readdirSync(dir).filter((name) => name.endsWith(".log")).sort().reverse().slice(0, 10).map((name) => ({ name, size: fs.statSync(path.join(dir, name)).size })); } function folderSize(dir) { if (!fs.existsSync(dir)) return 0; return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => { const target = path.join(dir, entry.name); return total + (entry.isDirectory() ? folderSize(target) : entry.isFile() ? fs.statSync(target).size : 0); }, 0); } function modelFileSize(model) { if (!model) return 0; const file = resolveData("models", model.filename); return fs.existsSync(file) ? fs.statSync(file).size : 0; } function ensureSidebarNavItem(settings) { if (!settings?.getSetting || !settings?.setSetting) return; const raw = settings.getSetting("nav_structure", null); if (!raw) return; let structure = raw; if (typeof structure === "string") { try { structure = JSON.parse(structure); } catch { return; } } if (!structure?.enabled || !Array.isArray(structure.sections)) return; const navId = "plugins_lumi_ai"; for (const section of structure.sections) { if (Array.isArray(section.items)) section.items = section.items.filter((item) => item !== navId); } let plugins = structure.sections.find((section) => section.id === "plugins"); if (!plugins) { plugins = { id: "plugins", label: "Plugins", icon: "blocks", items: [] }; structure.sections.push(plugins); } plugins.items = Array.isArray(plugins.items) ? plugins.items : []; plugins.items.push(navId); settings.setSetting("nav_structure", structure); } function shouldAutoResume(config, state) { return Boolean(config.enabled && state.desired_state === "running" && !state.last_manual_stop && !state.last_crashed); } module.exports.shouldAutoResume = shouldAutoResume;