1144 lines
49 KiB
JavaScript
1144 lines
49 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, 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} <question>`);
|
|
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} <question>`,
|
|
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;
|