Lumi/plugins/lumi_ai/index.js
2026-06-11 06:35:43 +02:00

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;