377 lines
17 KiB
JavaScript
377 lines
17 KiB
JavaScript
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;
|