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, estimateAllocation } = require("./backend/hardware"); const metrics = require("./backend/metrics"); const { roleOf } = require("./backend/permissions"); const { canUseAssistant } = require("./backend/assistant_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 { evaluateAssistantAvailability } = require("./backend/assistant_availability"); const { buildVisibilityDiagnostics } = require("./backend/assistant_visibility"); const repoIndexer = require("./backend/repo_indexer"); const { HARD_RULES, normalizeScope } = require("./backend/scope_manager"); const { AiAccessControl } = require("./backend/access_control"); const { AiRateLimiter, mergeLimits } = require("./backend/rate_limits"); const { buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("./backend/commands"); const { AssistantPanelDiagnostics } = require("./backend/assistant_panel_diagnostics"); const { formatAssistantResponse } = require("./backend/response_formatter"); const storage = require("./backend/storage"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils"); 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, commandRouter, db, plugin }) { ensureDataDirs(); if (!repoIndexer.loadIndex()) { try { repoIndexer.refreshIndex(); } catch (error) { console.warn("Lumi AI repository index initialization failed", error.message); } } let config = getConfig(); const getModel = (id) => modelManifest.models.find((model) => model.id === id); const downloads = new DownloadManager((entry) => metrics.record(entry)); const accessControl = new AiAccessControl((entry) => metrics.record(entry)); const rateLimiter = new AiRateLimiter(() => config, (entry) => metrics.record(entry)); const panelTemplatePath = path.join(__dirname, "views", "assistant-panel.ejs"); const panelDiagnostics = new AssistantPanelDiagnostics(panelTemplatePath); panelDiagnostics.templateCheck( ["endpoint", "user"], { endpoint: `/plugins/${PLUGIN_ID}`, user: { id: "diagnostic-user" } } ); const queue = new RequestQueue(() => config); const tools = new ToolRegistry((entry) => metrics.record(entry)); const contextProviders = new Map(); const frontendVisibility = 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, lookupRepo: (message) => repoIndexer.lookupSupport(message), getRepoContext: (message, role, allowModeratorCodeHelp) => repoIndexer.supportContext(message, repoIndexer.loadIndex(), 8, role, allowModeratorCodeHelp) }); 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, runtimeManifest); const runtimeTarget = getRuntimeTarget(hardware); const selectedModel = getModel(config.selected_model_id); const runtimeStatus = await runtime.health(); const gpuAllocation = estimateAllocation({ model: selectedModel, contextSize: config.context_size, gpu: hardware.gpu, backend: runtimeStatus.runtime_backend, intentPercent: config.gpu_allocation_intent_percent, managedUsageMb: runtimeStatus.state === "running" ? runtimeStatus.estimated_gpu_memory_mb : 0 }); const assistantAvailability = evaluateAssistantAvailability({ user: req.session.user, config, model: selectedModel, runtimeHealth: runtimeStatus, providerAvailable: Boolean(provider) }); const visibilityDiagnostics = buildVisibilityDiagnostics({ user: req.session.user, config, model: selectedModel, runtimeHealth: runtimeStatus, providerAvailable: Boolean(provider), frontend: frontendVisibility.get(req.session.user.id) }); const usage = storage.storageUsage(modelManifest.models, config.selected_model_id); const metricsPage = metrics.historyPage(req.query.metrics_page, 25); const accessPage = paginateRows(accessControl.list(), req.query.access_page, 25); const logPage = storage.listLogsPage(req.query.logs_page, 25); const runtimeFolderSize = usage.categories.runtime; const selectedModelPath = selectedModel ? storage.modelPath(selectedModel) : null; const modelFileSize = selectedModelPath && fs.existsSync(selectedModelPath) ? fs.statSync(selectedModelPath).size : 0; const sizeDiagnostics = [ sanityCheckSize("Runtime folder", runtimeFolderSize, 5 * 1024 ** 3), sanityCheckSize("Runtime archive", runtimeTarget?.size || 0, 2 * 1024 ** 3), sanityCheckSize("Selected model", selectedModel?.size || 0, 100 * 1024 ** 3), sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3), sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3) ].filter((check) => !check.valid); for (const diagnostic of sizeDiagnostics) console.warn(`Lumi AI size diagnostic: ${diagnostic.message}`); 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)), installed_size: fs.existsSync(resolveData("models", model.filename)) ? fs.statSync(resolveData("models", model.filename)).size : 0, compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb })), runtimeTarget, runtimeManifest, runtimeStatus, gpuAllocation, assistantAvailability, visibilityDiagnostics, panelDiagnostics: panelDiagnostics.snapshot(), activeAiRestrictions: accessPage.entries, accessPage, recentRateLimitDenials: rateLimiter.recentDenials(), hardRules: HARD_RULES, assistantReason: describeAssistantReason(assistantAvailability.reason_code), repoIndexStatus: repoIndexer.indexStatus(), runtimeState: getRuntimeState(), latestDiagnostic: getLatestDiagnostic(), runtimeFolderSize, modelFileSize, storageUsage: usage, sizeDiagnostics, hardware, metrics: metrics.report(), history: metricsPage.entries, metricsPage, logFiles: logPage.entries, logPage, formatBytes, formatDate, formatDuration }); }); router.post("/settings", async (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."); const contextSize = boundedInt(req.body.context_size, 512, 131072, 4096); const previousConfig = config; config = saveConfig({ ...config, enabled: req.body.enabled === "on", selected_model_id: model.id, context_size: contextSize, internal_generation_char_budget: boundedInt(req.body.internal_generation_char_budget, 2000, 64000, 16000), threads: boundedInt(req.body.threads, 0, 256, 0), gpu_allocation_intent_percent: boundedInt(req.body.gpu_allocation_intent_percent, 0, 100, 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_enabled: req.body.assistant_enabled === "on", assistant_debug_logging: req.body.assistant_debug_logging === "on", assistant_visibility: { admins: req.body.visibility_admins === "on", mods: req.body.visibility_mods === "on", users: req.body.visibility_users === "on" }, commands: { enabled: req.body.command_enabled === "on", triggers: parseCommandTriggers(req.body.command_triggers), platforms: { discord: req.body.command_platform_discord === "on", twitch: req.body.command_platform_twitch === "on", youtube: req.body.command_platform_youtube === "on", kick: req.body.command_platform_kick === "on", other: req.body.command_platform_other === "on" }, roles: { admins: req.body.command_role_admins === "on", mods: req.body.command_role_mods === "on", users: req.body.command_role_users === "on" }, unavailable_message: cleanText(req.body.command_unavailable_message, 500), denied_message: cleanText(req.body.command_denied_message, 500) }, rate_limits: mergeLimits({ roles: { admin: limitFromBody(req.body, "limit_role_admin", 30, 60), mod: limitFromBody(req.body, "limit_role_mod", 12, 60), user: limitFromBody(req.body, "limit_role_user", 4, 60) }, platforms: { webui: limitFromBody(req.body, "limit_platform_webui", 60, 60), discord: limitFromBody(req.body, "limit_platform_discord", 30, 60), twitch: limitFromBody(req.body, "limit_platform_twitch", 20, 60), youtube: limitFromBody(req.body, "limit_platform_youtube", 20, 60), kick: limitFromBody(req.body, "limit_platform_kick", 20, 60), other: limitFromBody(req.body, "limit_platform_other", 20, 60) }, per_user: limitFromBody(req.body, "limit_user", 2, 30), per_channel: limitFromBody(req.body, "limit_channel", 12, 60), queue_when_limited: req.body.queue_when_limited === "on" }), support_scope: normalizeScope({ allowed_topics: cleanText(req.body.allowed_topics, 3000), allowed_support_domains: cleanText(req.body.allowed_support_domains, 3000), answer_style: cleanText(req.body.answer_style, 2000), linking_behavior: cleanText(req.body.linking_behavior, 2000), repo_lookup_enabled: req.body.repo_lookup_enabled === "on", allow_deterministic_help_shortcuts: req.body.allow_deterministic_help_shortcuts === "on", allow_moderator_code_help: req.body.allow_moderator_code_help === "on", clarification_behavior: cleanText(req.body.clarification_behavior, 2000), max_answer_length: boundedInt(req.body.maximum_answer_length, 100, 4000, 4000), role_overrides: { admin: cleanText(req.body.scope_admin, 3000), mod: cleanText(req.body.scope_mod, 3000), user: cleanText(req.body.scope_user, 3000) } }), instructions: { out_of_scope_response: cleanText(req.body.out_of_scope_response, 1000), 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" } }); registerAssistantCommands({ commandRouter, provider, runtime, getConfig: () => config, accessControl, rateLimiter, metrics, getBaseUrl: () => configuredBaseUrl(settings) }); writeCommandsManifest(plugin?.dir || __dirname, config); const runtimeSettingsChanged = [ "selected_model_id", "context_size", "threads", "gpu_allocation_intent_percent" ].some((key) => previousConfig[key] !== config[key]); if (runtimeSettingsChanged && runtime.status().state === "running") { try { await runtime.restart(); } catch (error) { return flash(req, res, "error", `Settings saved, but runtime restart failed: ${error.message}`); } } return flash(req, res, "success", "Lumi AI settings saved."); }); router.post("/download/runtime", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); const hardware = detectHardware(modelManifest.models, runtimeManifest); const target = getRuntimeTarget(hardware); 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, runtimeMetadata: { backend: target.backend || "cpu", version: runtimeManifest.version, target: target.filename } }); return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} 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."); if (model.id === config.selected_model_id && runtime.status().state === "running") { return flash(req, res, "error", "Stop the runtime before replacing the selected 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) => { const permission = canUseAssistant({ user: req.session.user, config, origin: "webui", platform: "webui", requestedSurface: "webui_chat" }); if (!permission.allowed) return res.status(403).json({ error: "Access denied.", reason: permission.reason }); 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.get("/api/gpu-capacity", (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); const model = getModel(req.query.model_id || config.selected_model_id); if (!model) return res.status(400).json({ error: "Unknown model." }); const contextSize = boundedInt(req.query.context_size, 512, 131072, config.context_size); const hardware = detectHardware(modelManifest.models, runtimeManifest); const installedBackend = runtime.runtimeMetadata().backend; res.json(estimateAllocation({ model, contextSize, gpu: hardware.gpu, backend: installedBackend, intentPercent: req.query.intent_percent ?? config.gpu_allocation_intent_percent, managedUsageMb: runtime.activeAcceleration?.estimated_gpu_memory_mb || 0 })); }); router.get("/api/assistant-diagnostics", async (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); const availability = evaluateAssistantAvailability({ user: req.session.user, config, model: getModel(config.selected_model_id), runtimeHealth: await runtime.health(), providerAvailable: Boolean(provider) }); res.json({ ...availability, reason: describeAssistantReason(availability.reason_code) }); }); const visibilityDebugHandler = async (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); const runtimeHealth = await runtime.health(); const diagnostics = buildVisibilityDiagnostics({ user: req.session.user, config, model: getModel(config.selected_model_id), runtimeHealth, providerAvailable: Boolean(provider), frontend: frontendVisibility.get(req.session.user.id) }); res.set("Cache-Control", "no-store"); return res.json({ ...diagnostics, reason: describeAssistantReason(diagnostics.reason_code), frontend_reported: frontendVisibility.has(req.session.user.id), ...panelDiagnostics.snapshot() }); }; const visibilityReportHandler = (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); frontendVisibility.set(req.session.user.id, { assistant_slot_found: Boolean(req.body.assistant_slot_found), frontend_loader_loaded: Boolean(req.body.frontend_loader_loaded), panel_html_returned: Boolean(req.body.panel_html_returned), mount_successful: Boolean(req.body.mount_successful), reported_at: new Date().toISOString() }); panelDiagnostics.frontend(req.body); return res.json({ success: true }); }; router.get("/api/assistant/visibility-debug", visibilityDebugHandler); router.post("/api/assistant/visibility-debug", visibilityReportHandler); if (typeof web.addRoute === "function") { web.addRoute("get", "/api/lumi-ai/assistant/visibility-debug", visibilityDebugHandler); web.addRoute("post", "/api/lumi-ai/assistant/visibility-debug", visibilityReportHandler); } router.post("/repo-index/refresh", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); try { const index = req.body.source === "public" ? repoIndexer.refreshPublicIndex() : repoIndexer.refreshIndex(); return flash(req, res, "success", `Repository support index refreshed with ${index.routes.length} routes.`); } catch (error) { return flash(req, res, "error", `Repository index refresh failed: ${error.message}`); } }); router.post("/access-control", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); try { accessControl.set(cleanText(req.body.user_id, 200), { action: req.body.action, timeoutUntil: req.body.timeout_until, reason: cleanText(req.body.reason, 500), silent: req.body.silent === "on", actorId: req.session.user.id }); return flash(req, res, "success", "AI user access updated."); } catch (error) { return flash(req, res, "error", error.message); } }); router.get("/api/users/search", (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); const query = cleanText(req.query.q, 120); if (query.length < 2) return res.json({ users: [] }); return res.json({ users: searchKnownUsers(db, query) }); }); router.post("/models/:id/delete", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); try { const result = storage.deleteModel(getModel(req.params.id), { selectedModelId: config.selected_model_id, runtimeRunning: runtime.status().state === "running", confirmed: req.body.confirm === "yes" }); return flash(req, res, "success", result.deleted ? `Model deleted. Recovered ${formatBytes(result.bytes_recovered)}.` : "Model was not installed."); } catch (error) { return flash(req, res, "error", error.message); } }); router.post("/models/:id/verify", async (req, res) => { if (!req.session.user?.isAdmin) return denied(res); const result = await runtime.verifyModel(req.params.id); return flash(req, res, result.success ? "success" : "error", result.success ? "Model verification passed." : result.message); }); router.post("/storage/cleanup", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); try { const categories = Array.isArray(req.body.categories) ? req.body.categories : req.body.categories ? [req.body.categories] : []; const activeDownload = [...downloads.jobs.values()].some((job) => !["complete", "error"].includes(job.state)); if (activeDownload && categories.some((category) => ["tmp", "runtime_archives"].includes(category))) { throw new Error("Wait for active downloads before cleaning temporary files or runtime archives."); } const result = storage.cleanupStorage(categories, { models: modelManifest.models, selectedModelId: config.selected_model_id, runtimeRunning: runtime.status().state === "running", activeLogPath: runtime.activeLogPath }); return flash(req, res, "success", `Storage cleanup recovered ${formatBytes(result.recovered_bytes)}.`); } catch (error) { return flash(req, res, "error", error.message); } }); router.get("/logs/:name", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); try { const log = storage.readLogTail(req.params.name); return res.render(path.join(__dirname, "views", "log-viewer.ejs"), { title: `Lumi AI log - ${log.name}`, log, formatBytes, formatDate }); } catch (error) { return res.status(404).render("error", { title: "Log unavailable", message: error.message }); } }); router.get("/logs/:name/download", (req, res) => { if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); try { return res.download(storage.resolveLog(req.params.name)); } catch (error) { return res.status(404).json({ error: error.message }); } }); router.post("/logs/:name/delete", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); try { const result = storage.deleteLog(req.params.name, runtime.activeLogPath); return flash(req, res, "success", `Log deleted. Recovered ${formatBytes(result.bytes_recovered)}.`); } catch (error) { return flash(req, res, "error", error.message); } }); 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) => { const permission = canUseAssistant({ user: req.session.user, config, origin: "webui", platform: "webui", requestedSurface: "webui_chat" }); if (!permission.allowed) { return res.status(403).json({ error: "Lumi AI is unavailable for this account.", reason: permission.reason, permission: permission.debug_details }); } const message = cleanText(req.body.message, 6000); if (!message) return res.status(400).json({ error: "Message is required." }); const originContext = webOriginContext(req); const access = authorizeAiRequest({ userId: req.session.user.id, context: originContext, accessControl, rateLimiter }); if (!access.allowed) return res.status(429).json({ error: access.message, reason: access.reason, retry_after_seconds: access.retry_after_seconds }); try { const result = await provider.generate({ message, user: req.session.user, sessionId: req.sessionID, originContext, allowDeterministicShortcut: config.support_scope.allow_deterministic_help_shortcuts, history: normalizeConversationHistory(req.body.history) }); const delivered = finalizeAssistantResult(result, { role: roleOf(req.session.user), config, baseUrl: originContext.base_url, maxLength: originContext.max_message_length, requestMessage: message }); metrics.record({ kind: "delivery", status: "success", scope: "webui_chat", route_used: result.route_used || "llm", role: roleOf(req.session.user), user_id: req.session.user.id, internal_generated_length: result.internal_generated_length || String(result.text || "").length, final_reply_length: delivered.original_final_length, original_final_length: delivered.original_final_length, delivered_length: delivered.delivered_length }); res.json(delivered); } 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) => { const permission = canUseAssistant({ user: req.session.user, config, origin: "webui", platform: "webui", requestedSurface: "webui_chat" }); if (!permission.allowed) return res.status(403).json({ error: "Access denied.", reason: permission.reason }); 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) => { const permission = canUseAssistant({ user: req.session.user, config, origin: "webui", platform: "webui", requestedSurface: "webui_chat" }); if (!permission.allowed) return res.status(403).json({ error: "Access denied.", reason: permission.reason }); 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" }); let removeAssistantPanel = () => {}; if (typeof web.addAssistantPanel === "function") { removeAssistantPanel = web.addAssistantPanel({ id: PLUGIN_ID, version: require("./plugin.json").version, canAccess: (user) => canUseAssistant({ user, config, origin: "webui", platform: "webui", requestedSurface: "webui_panel" }), getAvailability: async (user) => evaluateAssistantAvailability({ user, config, model: getModel(config.selected_model_id), runtimeHealth: await runtime.health(), providerAvailable: Boolean(provider), origin: "webui", platform: "webui", requestedSurface: "webui_panel" }), view: path.join(__dirname, "views", "assistant-panel.ejs"), requiredLocals: ["endpoint", "user"], onRenderDiagnostic: (report) => { panelDiagnostics.update(report); if (report.panel_html_error) panelDiagnostics.log("render_failure", report); }, stylesheet: `/plugins/${PLUGIN_ID}/assets/assistant.css`, script: `/plugins/${PLUGIN_ID}/assets/assistant.js`, getDebug: () => ({ enabled: Boolean(config.assistant_debug_logging), ...panelDiagnostics.snapshot() }), locals: { endpoint: `/plugins/${PLUGIN_ID}` } }); } else { console.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible."); } ensureSidebarNavItem(settings); registerAssistantCommands({ commandRouter, provider, runtime, getConfig: () => config, accessControl, rateLimiter, metrics, getBaseUrl: () => configuredBaseUrl(settings) }); writeCommandsManifest(plugin?.dir || __dirname, config); const state = getRuntimeState(); if (shouldAutoResume(config, state)) { setImmediate(() => runtime.start({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error))); } return async () => { removeAssistantPanel(); commandRouter?.clearCommands?.(PLUGIN_ID); 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(hardware = detectHardware(modelManifest.models, runtimeManifest)) { const selection = hardware.runtime_selection; return selection?.target ? { ...selection.target, backend: selection.backend } : 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 formatDuration(ms) { if (!ms) return "0 ms"; return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; } function formatDate(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); } function describeAssistantReason(code) { return ({ anonymous: "User is not logged in.", feature_disabled: "AI Assistant is disabled.", role_forbidden: "The current role is not enabled for the assistant.", model_not_selected: "No model is selected.", model_missing: "The selected model is not installed.", runtime_missing: "The managed runtime is not installed.", runtime_unusable: "The runtime self-test has not passed.", runtime_stopped: "The runtime is stopped.", runtime_starting: "The runtime is starting but not ready.", runtime_error: "The runtime is in an error state.", runtime_unhealthy: "The runtime health check failed.", assistant_disabled: "The sidebar assistant is disabled.", provider_unavailable: "The AI provider is unavailable." })[code] || (code ? `Assistant unavailable: ${code}.` : "Assistant is available."); } 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); } function registerAssistantCommands({ commandRouter, provider, runtime, getConfig, accessControl, rateLimiter, metrics: commandMetrics = metrics, getBaseUrl = () => "" }) { if (!commandRouter) return; const config = getConfig(); if (!config.commands.enabled) { commandRouter.registerCommands(PLUGIN_ID, []); return; } const platforms = Object.entries(config.commands.platforms) .filter(([, enabled]) => enabled) .map(([platform]) => platform); commandRouter.registerCommands(PLUGIN_ID, [{ id: "lumi_ai:assistant", triggers: config.commands.triggers, platforms, handler: async (ctx) => { const current = getConfig(); const originContext = buildOriginContext(ctx, ctx.trigger); originContext.base_url = getBaseUrl(); const started = Date.now(); const user = { id: originContext.user_id, username: originContext.username, isAdmin: originContext.is_admin, isMod: originContext.is_mod }; const permission = canUseAssistant({ user, config: current, origin: originContext.origin, platform: originContext.platform, requestedSurface: "command", roleHint: originContext.role, roleSource: "command_origin" }); if (!permission.allowed) { commandMetrics.record({ kind: "request", status: "refused", scope: "platform_command", route_used: "denial", role: permission.normalized_role, user_id: originContext.user_id, platform: originContext.platform, reason: permission.reason, duration_ms: Date.now() - started }); if (permission.reason === "command_disabled" || permission.reason === "platform_forbidden") return false; await ctx.reply(formatPlatformReply(current.commands.denied_message, [], originContext)); return true; } if (!ctx.argsText.trim()) { await ctx.reply(`Usage: !${ctx.trigger} `); return true; } const access = authorizeAiRequest({ userId: originContext.user_id, context: originContext, accessControl, rateLimiter }); if (!access.allowed) { commandMetrics.record({ kind: "request", status: "refused", scope: "platform_command", route_used: "denial", role: permission.normalized_role, user_id: originContext.user_id, platform: originContext.platform, reason: access.reason, duration_ms: Date.now() - started }); if (!access.silent) { const message = access.reason === "rate_limited" ? `Lumi Assistant is rate limited. Try again in ${access.retry_after_seconds}s.` : current.commands.denied_message; await ctx.reply(formatPlatformReply(message, [], originContext)); } return true; } const health = await runtime.health(); if (!current.enabled || health.state !== "running" || !health.healthy) { commandMetrics.record({ kind: "request", status: "failed", scope: "platform_command", route_used: "unavailable_fallback", role: permission.normalized_role, user_id: originContext.user_id, platform: originContext.platform, duration_ms: Date.now() - started }); await ctx.reply(formatPlatformReply(current.commands.unavailable_message, [], originContext)); return true; } try { const result = await provider.generate({ message: ctx.argsText.trim(), user, sessionId: `command:${originContext.platform}:${originContext.message_id || Date.now()}`, scope: "platform_command", originContext, allowDeterministicShortcut: current.support_scope?.allow_deterministic_help_shortcuts === true }); const delivered = finalizeAssistantResult(result, { role: permission.normalized_role, config: current, baseUrl: originContext.base_url, maxLength: originContext.max_message_length, requestMessage: ctx.argsText.trim() }); const reply = formatPlatformReplyDetails(delivered.text, delivered.links, originContext); await ctx.reply(reply.text); commandMetrics.record({ kind: "command_audit", status: "success", scope: "platform_command", route_used: result.route_used || "llm", role: permission.normalized_role, user_id: originContext.user_id, platform: originContext.platform, duration_ms: Date.now() - started, original_final_length: delivered.original_final_length, final_reply_length: reply.original_final_length, delivered_length: reply.delivered_length, internal_generated_length: result.internal_generated_length || String(result.text || "").length }); } catch (error) { commandMetrics.record({ kind: "request", status: "failed", scope: "platform_command", route_used: "unavailable_fallback", role: permission.normalized_role, user_id: originContext.user_id, platform: originContext.platform, message: error.message, duration_ms: Date.now() - started }); await ctx.reply(formatPlatformReply(current.commands.unavailable_message, [], originContext)); } return true; } }]); } function authorizeAiRequest({ userId, context, accessControl, rateLimiter }) { const access = accessControl.check(userId, context); if (!access.allowed) return access; const rate = rateLimiter.check(context); if (!rate.allowed) { return { ...rate, message: `Lumi Assistant is rate limited. Try again in ${rate.retry_after_seconds}s.` }; } return { allowed: true }; } function webOriginContext(req) { const user = req.session.user; const role = roleOf(user); return { origin: "webui", platform: "webui", channel_id: null, channel_name: null, server_id: null, user_id: user.id, username: user.username, display_name: user.username, role, is_admin: role === "admin", is_mod: role === "mod", message_id: null, reply_mode: "panel", format_capabilities: { markdown: false, html: true }, max_message_length: 8000, base_url: `${req.protocol}://${req.get("host")}`, source_plugin: "lumi_ai", source_command: "webui_assistant", permission_context: { identified_user: true, webui_actions_allowed: true } }; } function finalizeAssistantResult(result, { role, config, baseUrl = "", maxLength = null, requestMessage = "" }) { const formatted = formatAssistantResponse({ text: normalizeCustomCommandReply(result.text, requestMessage), links: result.links, baseUrl, verifiedRoutes: repoIndexer.verifiedRoutePaths(), role, allowModeratorCodeHelp: config.support_scope?.allow_moderator_code_help === true, maxLength: maxLength || config.support_scope?.max_answer_length || 4000 }); const output = { ...result, ...formatted }; if (role !== "admin") { delete output.source; output.raw_response = null; } return output; } function normalizeCustomCommandReply(text, requestMessage) { const request = String(requestMessage || ""); const output = String(text || ""); if (!/\b(custom|advanced)\s+(?:javascript|js|python|command)|\bcustom command\b/i.test(request)) return output; if (/```(?:javascript|python)\s*\n[\s\S]*?```/i.test(output)) return output; const language = /\bpython\b/i.test(request) || /^\s*def\s+run\s*\(/m.test(output) ? "python" : "javascript"; const codePattern = language === "python" ? /(^|\n)(def\s+run\s*\([\s\S]+)$/m : /(^|\n)((?:async\s+)?function\s+run\s*\([\s\S]+)$/m; const match = output.match(codePattern); if (!match) return output; const start = match.index + match[1].length; const explanation = output.slice(0, start).trim(); const code = output.slice(start).trim(); return `${explanation ? `${explanation}\n\n` : ""}\`\`\`${language}\n${code}\n\`\`\``; } function configuredBaseUrl(settings) { for (const key of ["discord_redirect_uri", "twitch_redirect_uri", "youtube_redirect_uri"]) { const value = settings?.getSetting?.(key, ""); if (!value) continue; try { return new URL(value).origin; } catch {} } return ""; } function paginateRows(rows, pageValue, pageSize = 25) { const size = Math.max(1, Number.parseInt(pageSize, 10) || 25); const pages = Math.max(1, Math.ceil(rows.length / size)); const page = Math.min(pages, Math.max(1, Number.parseInt(pageValue, 10) || 1)); const start = (page - 1) * size; return { entries: rows.slice(start, start + size), page, pages, page_size: size, total: rows.length }; } function normalizeConversationHistory(history) { if (!Array.isArray(history)) return []; return history.slice(-12).map((entry) => ({ role: ["user", "assistant"].includes(entry?.role) ? entry.role : null, content: cleanText(entry?.content, 4000) })).filter((entry) => entry.role && entry.content); } function searchKnownUsers(database, query) { const value = String(query || "").trim().replace(/[%_]/g, ""); if (value.length < 2) return []; const pattern = `%${value}%`; const rows = database.prepare( "SELECT DISTINCT p.id, p.internal_username, i.provider, i.provider_user_id, i.display_name " + "FROM user_profiles p LEFT JOIN user_identities i ON i.user_id = p.id " + "WHERE p.id LIKE ? OR p.internal_username LIKE ? OR i.provider_user_id LIKE ? OR i.display_name LIKE ? " + "ORDER BY p.internal_username LIMIT 30" ).all(pattern, pattern, pattern, pattern); return [...rows.reduce((map, row) => { const user = map.get(row.id) || { id: row.id, username: row.internal_username, identities: [] }; if (row.provider) { user.identities.push({ provider: row.provider, provider_user_id: row.provider_user_id, display_name: row.display_name }); } map.set(row.id, user); return map; }, new Map()).values()]; } function parseCommandTriggers(value) { const triggers = String(value || "assistant,lumi").split(/[,\s]+/) .map((entry) => entry.trim().replace(/^!+/, "").toLowerCase()) .filter((entry) => /^[a-z0-9_-]+$/.test(entry)); return [...new Set(triggers.length ? triggers : ["assistant", "lumi"])]; } function limitFromBody(body, prefix, fallbackRequests, fallbackWindow) { return { requests: boundedInt(body[`${prefix}_requests`], 0, 10000, fallbackRequests), window_seconds: boundedInt(body[`${prefix}_window`], 1, 86400, fallbackWindow) }; } function writeCommandsManifest(pluginDir, config) { const triggers = config.commands.triggers || ["assistant", "lumi"]; const primary = triggers[0] || "assistant"; const manifest = { pluginId: PLUGIN_ID, pluginName: "Lumi AI", platformKeys: { discord: "command_platform_discord", twitch: "command_platform_twitch", youtube: "command_platform_youtube", kick: "command_platform_kick" }, commands: [{ id: "assistant", trigger: primary, usage: `${primary} `, name: "Lumi Assistant", description: "Ask Lumi Assistant a scoped Lumi, bot, community, or WebUI support question.", level: "public", platforms: Object.entries(config.commands.platforms).filter(([, enabled]) => enabled).map(([key]) => key), aliases: triggers.slice(1) }] }; fs.writeFileSync(path.join(pluginDir, "cmds.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); } module.exports.shouldAutoResume = shouldAutoResume; module.exports.registerAssistantCommands = registerAssistantCommands; module.exports.authorizeAiRequest = authorizeAiRequest; module.exports.searchKnownUsers = searchKnownUsers; module.exports.finalizeAssistantResult = finalizeAssistantResult;