1971 lines
84 KiB
JavaScript
1971 lines
84 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, performanceTuningHints } = 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, GateRuntimeManager, combinedResourceEstimate } = require("./backend/runtime_manager");
|
|
const { AiProvider } = require("./backend/ai_provider");
|
|
const { GateProvider } = require("./backend/gate_provider");
|
|
const { SafeAnswerCache } = require("./backend/cache");
|
|
const { AssistantRequestJobs } = require("./backend/request_jobs");
|
|
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 { FeedbackStore, FEEDBACK_KINDS, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
|
|
const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections");
|
|
const { EvalStore } = require("./backend/evals");
|
|
const { TrainingExporter } = require("./backend/training_export");
|
|
const { ToolRepoClient } = require("./backend/tool_repo_client");
|
|
const { ToolInstaller } = require("./backend/tool_installer");
|
|
const { ToolLoader } = require("./backend/tool_loader");
|
|
const { ToolManager } = require("./backend/tool_manager");
|
|
const { ToolSettings } = require("./backend/tool_settings");
|
|
const { buildAllowedToolsSection } = require("./backend/prompt_builder");
|
|
const storage = require("./backend/storage");
|
|
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
|
|
|
const PLUGIN_ID = "lumi_ai";
|
|
const TOKEN_PRESETS = Object.freeze([
|
|
{ label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." },
|
|
{ label: "Very small (512)", value: 512, description: "Short replies and low memory usage." },
|
|
{ label: "Small (1024)", value: 1024, description: "Compact answers and lightweight request gates." },
|
|
{ label: "Short (2048)", value: 2048, description: "Short conversations and normal commands." },
|
|
{ label: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." },
|
|
{ label: "Large (8192)", value: 8192, description: "Longer conversations and documents." },
|
|
{ label: "Extended (16384)", value: 16384, description: "Long context when the selected model supports it." },
|
|
{ label: "Extra extended (32768)", value: 32768, description: "Highest supported local preset for large-context models." }
|
|
]);
|
|
const CONTEXT_OPTIONS = TOKEN_PRESETS;
|
|
const GATE_CONTEXT_OPTIONS = TOKEN_PRESETS.filter((option) => option.value >= 512 && option.value <= 4096);
|
|
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 feedbackStore = new FeedbackStore();
|
|
const correctionStore = new CorrectionStore({
|
|
getConfig: () => config,
|
|
verifyLink: isVerifiedImprovementLink
|
|
});
|
|
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 requestJobs = new AssistantRequestJobs();
|
|
const tools = new ToolRegistry((entry) => metrics.record(entry));
|
|
const toolRepoClient = new ToolRepoClient({ settings });
|
|
const toolInstaller = new ToolInstaller({ repoClient: toolRepoClient });
|
|
const toolLoader = new ToolLoader({
|
|
registry: tools,
|
|
installer: toolInstaller,
|
|
settings,
|
|
lumiAiVersion: require("./plugin.json").version,
|
|
lumiVersion: require("../../package.json").version
|
|
});
|
|
const toolSettings = new ToolSettings({ installer: toolInstaller });
|
|
const toolManager = new ToolManager({
|
|
repoClient: toolRepoClient,
|
|
installer: toolInstaller,
|
|
loader: toolLoader,
|
|
settings: toolSettings
|
|
});
|
|
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 gateRuntime = new GateRuntimeManager({
|
|
getConfig: () => config,
|
|
getModel,
|
|
onDiagnostic: (entry) => metrics.record(entry)
|
|
});
|
|
const gate = new GateProvider({
|
|
getConfig: () => config,
|
|
runtime: gateRuntime,
|
|
lookupRepo: (message) => repoIndexer.lookupSupport(message),
|
|
lookupCorrection: (context) => correctionStore.findPredefined({
|
|
message: context.message,
|
|
role: context.role,
|
|
origin: context.origin || context.platform,
|
|
platform: context.platform
|
|
}),
|
|
cache: new SafeAnswerCache(() => config),
|
|
metrics
|
|
});
|
|
let mainStartPromise = null;
|
|
let gateStartPromise = null;
|
|
const ensureMainRuntime = async (options = {}) => {
|
|
const health = await runtime.health();
|
|
if (health.healthy) return health;
|
|
if (!mainStartPromise) {
|
|
mainStartPromise = runtime.start(options).finally(() => { mainStartPromise = null; });
|
|
}
|
|
return mainStartPromise;
|
|
};
|
|
const ensureGateRuntime = async () => {
|
|
if (gateRuntime.status().state === "running") return gateRuntime.health();
|
|
if (!gateStartPromise) {
|
|
gateStartPromise = gateRuntime.start().finally(() => { gateStartPromise = null; });
|
|
}
|
|
return gateStartPromise;
|
|
};
|
|
const provider = new AiProvider({
|
|
getConfig: () => config,
|
|
runtime,
|
|
gate,
|
|
queue,
|
|
tools,
|
|
metrics,
|
|
getContext: getSafeContext,
|
|
lookupRepo: (message) => repoIndexer.lookupSupport(message),
|
|
getRepoContext: (message, role, allowModeratorCodeHelp) =>
|
|
repoIndexer.supportContext(message, repoIndexer.loadIndex(), 8, role, allowModeratorCodeHelp),
|
|
getCorrections: (context) => correctionStore.context(context),
|
|
ensureRuntime: ensureMainRuntime
|
|
});
|
|
const evalStore = new EvalStore({ provider });
|
|
const trainingExporter = new TrainingExporter({
|
|
feedback: feedbackStore,
|
|
corrections: correctionStore
|
|
});
|
|
const startRuntimes = async (options = {}) => {
|
|
if (config.enabled) {
|
|
ensureGateRuntime().catch((error) => {
|
|
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_start_failed", message: error.message });
|
|
web.emitEvent?.("ai:model_status", {
|
|
status: "gate_start_failed",
|
|
message: `Lumi AI gate runtime failed to start: ${error.message}`
|
|
}, { role: "admin" });
|
|
});
|
|
}
|
|
const main = await ensureMainRuntime(options);
|
|
return { ...main, gate: await gateRuntime.health() };
|
|
};
|
|
const stopRuntimes = async (options = {}) => {
|
|
await gateRuntime.stop();
|
|
return runtime.stop(options);
|
|
};
|
|
const restartRuntimes = async () => {
|
|
await gateRuntime.stop();
|
|
const main = await runtime.restart();
|
|
if (config.enabled) {
|
|
try { await ensureGateRuntime(); }
|
|
catch (error) {
|
|
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_restart_failed", message: error.message });
|
|
web.emitEvent?.("ai:model_status", {
|
|
status: "gate_restart_failed",
|
|
message: `Lumi AI gate runtime restart failed: ${error.message}`
|
|
}, { role: "admin" });
|
|
}
|
|
}
|
|
return { ...main, gate: await gateRuntime.health() };
|
|
};
|
|
let gateRecoveryPending = false;
|
|
const gateMonitor = setInterval(async () => {
|
|
if (
|
|
gateRecoveryPending ||
|
|
!config.enabled ||
|
|
gateRuntime.status().state === "running"
|
|
) return;
|
|
gateRecoveryPending = true;
|
|
try { await ensureGateRuntime(); }
|
|
catch (error) {
|
|
web.emitEvent?.("ai:model_status", {
|
|
status: "gate_recovery_failed",
|
|
message: `Lumi AI gate recovery failed: ${error.message}`
|
|
}, { role: "admin" });
|
|
}
|
|
finally { gateRecoveryPending = false; }
|
|
}, 30000);
|
|
gateMonitor.unref?.();
|
|
|
|
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),
|
|
unregisterToolOwner: (owner) => tools.unregisterOwner(owner),
|
|
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 gateStatus = await gateRuntime.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 resourceEstimate = combinedResourceEstimate({
|
|
main: runtimeStatus,
|
|
gate: gateStatus,
|
|
hardware
|
|
});
|
|
const recentGeneration = metrics.history(100).find((entry) =>
|
|
entry.kind === "request" && Number(entry.generation_tps) > 0
|
|
);
|
|
const tuningHints = performanceTuningHints({
|
|
model: selectedModel,
|
|
config,
|
|
gpu: hardware.gpu,
|
|
allocation: gpuAllocation,
|
|
generationTps: Number(recentGeneration?.generation_tps) || 0
|
|
});
|
|
const metricsPage = metrics.historyPage(req.query.metrics_page, 25);
|
|
const slowRequestsPage = metrics.slowRequestsPage(req.query.slow_page, 15);
|
|
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}`);
|
|
const 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
|
|
}));
|
|
const installedModels = models.filter((model) => model.downloaded);
|
|
res.render(path.join(__dirname, "views", "settings.ejs"), {
|
|
title: "Lumi AI",
|
|
config,
|
|
models,
|
|
installedModels,
|
|
selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id),
|
|
contextOptions: CONTEXT_OPTIONS,
|
|
tokenPresets: TOKEN_PRESETS,
|
|
gateContextOptions: GATE_CONTEXT_OPTIONS,
|
|
runtimeTarget,
|
|
runtimeManifest,
|
|
runtimeStatus,
|
|
gateStatus,
|
|
resourceEstimate,
|
|
tuningHints,
|
|
jobDiagnostics: requestJobs.diagnostics(),
|
|
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,
|
|
slowRequestsPage,
|
|
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.");
|
|
if (!fs.existsSync(resolveData("models", model.filename))) {
|
|
return flash(req, res, "error", "Selected model must be installed before it can be saved.");
|
|
}
|
|
const contextValues = CONTEXT_OPTIONS.map((option) => option.value);
|
|
const requestedContext = Number(req.body.context_size);
|
|
if (!contextValues.includes(requestedContext)) {
|
|
return flash(req, res, "error", "Choose a supported AI context size.");
|
|
}
|
|
const contextSize = requestedContext;
|
|
const tokenValues = TOKEN_PRESETS.map((option) => option.value);
|
|
const gateContextValues = GATE_CONTEXT_OPTIONS.map((option) => option.value);
|
|
const requestedGateContext = Number(req.body.gate_context_size);
|
|
if (!gateContextValues.includes(requestedGateContext)) {
|
|
return flash(req, res, "error", "Choose a supported gate context size.");
|
|
}
|
|
const presetToken = (field, fallback, label) => {
|
|
const value = Number(req.body[field]);
|
|
if (!tokenValues.includes(value)) {
|
|
throw new Error(`Choose a supported preset for ${label}.`);
|
|
}
|
|
return value || fallback;
|
|
};
|
|
const previousConfig = config;
|
|
let nextConfig;
|
|
try {
|
|
nextConfig = {
|
|
...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.hard_generation_timeout_ms, 30000, 3600000, 600000),
|
|
ui_soft_timeout_ms: boundedInt(req.body.ui_soft_timeout_ms, 5000, 300000, 45000),
|
|
hard_generation_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000),
|
|
max_output_tokens: presetToken("max_output_tokens", 2048, "API/test output tokens"),
|
|
output_budgets: {
|
|
navigation_help: presetToken("output_budget_navigation_help", 256, "navigation/help tokens"),
|
|
simple_answer: presetToken("output_budget_simple_answer", 512, "simple answer tokens"),
|
|
code_custom_command: presetToken("output_budget_code_custom_command", 1024, "code/custom command tokens"),
|
|
admin_debug: presetToken("output_budget_admin_debug", 2048, "admin debug tokens"),
|
|
explicit_long: presetToken("output_budget_explicit_long", 4096, "explicit long-answer tokens")
|
|
},
|
|
batch_size: boundedInt(req.body.batch_size, 32, 4096, 512),
|
|
ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128),
|
|
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"
|
|
}),
|
|
gate: {
|
|
...config.gate,
|
|
model_id: getModel(req.body.gate_model_id)?.id || config.gate.model_id,
|
|
context_size: requestedGateContext,
|
|
threads: boundedInt(req.body.gate_threads, 1, 16, 2),
|
|
timeout_ms: boundedInt(req.body.gate_timeout_ms, 1000, 5000, 3000),
|
|
high_confidence_threshold: boundedNumber(req.body.gate_high_confidence_threshold, 0.5, 0.99, 0.88),
|
|
main_llm_threshold: boundedNumber(req.body.gate_main_llm_threshold, 0.1, 0.95, 0.72),
|
|
predefined_enabled: req.body.gate_predefined_enabled === "on",
|
|
cache_ttl_seconds: boundedInt(req.body.gate_cache_ttl_seconds, 30, 604800, 3600),
|
|
repeat_force_window_seconds: boundedInt(req.body.gate_repeat_force_window_seconds, 0, 3600, 90),
|
|
similarity_threshold: boundedNumber(req.body.gate_similarity_threshold, 0.5, 1, 0.86),
|
|
force_prefix: cleanText(req.body.gate_force_prefix, 40)
|
|
},
|
|
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"
|
|
},
|
|
improvement: {
|
|
...config.improvement,
|
|
allow_moderators_to_review_responses: req.body.allow_moderators_to_review_responses === "on",
|
|
trusted_moderator_reviewers: parseIdList(req.body.trusted_moderator_reviewers),
|
|
corrections_enabled: req.body.corrections_enabled === "on"
|
|
}
|
|
};
|
|
} catch (error) {
|
|
return flash(req, res, "error", error.message);
|
|
}
|
|
config = saveConfig(nextConfig);
|
|
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",
|
|
"batch_size",
|
|
"ubatch_size",
|
|
"gpu_allocation_intent_percent"
|
|
].some((key) => previousConfig[key] !== config[key]);
|
|
const gateSettingsChanged = JSON.stringify(previousConfig.gate) !== JSON.stringify(config.gate);
|
|
const enabledChanged = previousConfig.enabled !== config.enabled;
|
|
if ((runtimeSettingsChanged || gateSettingsChanged || enabledChanged) && runtime.status().state === "running") {
|
|
try {
|
|
await restartRuntimes();
|
|
} catch (error) {
|
|
return flash(req, res, "error", `Settings saved, but runtime restart failed: ${error.message}`);
|
|
}
|
|
} else if (!config.enabled && gateRuntime.status().state === "running") {
|
|
await gateRuntime.stop();
|
|
}
|
|
return flash(req, res, "success", "Lumi AI settings saved.");
|
|
});
|
|
|
|
router.post("/download/runtime", (req, res) => {
|
|
if (!req.session.user?.isAdmin) return denied(res);
|
|
const wantsJson = req.accepts(["json", "html"]) === "json";
|
|
const hardware = detectHardware(modelManifest.models, runtimeManifest);
|
|
const target = getRuntimeTarget(hardware);
|
|
if (!target) {
|
|
if (wantsJson) return res.status(400).json({ error: "No managed llama.cpp runtime is available for this platform." });
|
|
return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform.");
|
|
}
|
|
try {
|
|
const job = downloads.start({
|
|
id: "runtime",
|
|
...target,
|
|
kind: "runtime",
|
|
archive: true,
|
|
runtimeMetadata: {
|
|
backend: target.backend || "cpu",
|
|
version: runtimeManifest.version,
|
|
target: target.filename
|
|
}
|
|
});
|
|
if (wantsJson) return res.json({ success: true, job });
|
|
return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`);
|
|
} catch (error) {
|
|
if (wantsJson) return res.status(400).json({ error: error.message });
|
|
return flash(req, res, "error", error.message);
|
|
}
|
|
});
|
|
|
|
router.post("/download/model/:id", (req, res) => {
|
|
if (!req.session.user?.isAdmin) return denied(res);
|
|
const wantsJson = req.accepts(["json", "html"]) === "json";
|
|
const model = getModel(req.params.id);
|
|
if (!model) {
|
|
if (wantsJson) return res.status(404).json({ error: "Unknown model." });
|
|
return flash(req, res, "error", "Unknown model.");
|
|
}
|
|
if (
|
|
(model.id === config.selected_model_id && runtime.status().state === "running") ||
|
|
(model.id === config.gate.model_id && gateRuntime.status().state === "running")
|
|
) {
|
|
if (wantsJson) return res.status(400).json({ error: "Stop the AI runtimes before replacing an active model." });
|
|
return flash(req, res, "error", "Stop the AI runtimes before replacing an active 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") {
|
|
if (wantsJson) return res.status(400).json({ error: "This model exceeds detected RAM or free disk. Check override to download anyway." });
|
|
return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway.");
|
|
}
|
|
try {
|
|
const job = 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"
|
|
});
|
|
if (wantsJson) return res.json({ success: true, job });
|
|
return flash(req, res, "success", `${model.label} download started.`);
|
|
} catch (error) {
|
|
if (wantsJson) return res.status(400).json({ error: error.message });
|
|
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(),
|
|
gate: await gateRuntime.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,
|
|
gateModelId: config.gate.model_id,
|
|
runtimeRunning: runtime.status().state === "running",
|
|
gateRuntimeRunning: gateRuntime.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,
|
|
gateModelId: config.gate.model_id,
|
|
runtimeRunning: runtime.status().state === "running" || gateRuntime.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", "verify-gate-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 === "verify-gate-model" ? await gateRuntime.verifyModel()
|
|
: action === "stop"
|
|
? await stopRuntimes({ manual: true, reason: "admin_stop" })
|
|
: action === "restart" ? await restartRuntimes() : await startRuntimes();
|
|
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),
|
|
gate_model: getModel(config.gate.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 });
|
|
const requestUser = { ...req.session.user };
|
|
const requestRole = roleOf(requestUser);
|
|
const requestConfig = config;
|
|
const requestSessionId = req.sessionID;
|
|
const history = normalizeConversationHistory(req.body.history);
|
|
const requestStarted = Date.now();
|
|
const job = requestJobs.create({
|
|
userId: requestUser.id,
|
|
metadata: {
|
|
context_size: requestConfig.context_size,
|
|
batch_size: requestConfig.batch_size,
|
|
ubatch_size: requestConfig.ubatch_size,
|
|
threads: requestConfig.threads
|
|
},
|
|
execute: async (updateStage, signal) => {
|
|
try {
|
|
const result = await provider.generate({
|
|
message,
|
|
user: requestUser,
|
|
sessionId: requestSessionId,
|
|
originContext,
|
|
allowDeterministicShortcut: requestConfig.support_scope.allow_deterministic_help_shortcuts,
|
|
history,
|
|
signal,
|
|
onStage: updateStage
|
|
});
|
|
updateStage("formatting", { route: result.route_used, ...(result.diagnostics || {}) });
|
|
const delivered = finalizeAssistantResult(result, {
|
|
role: requestRole,
|
|
config: requestConfig,
|
|
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",
|
|
route_class: result.route_class,
|
|
max_output_tokens_used: result.max_output_tokens_used,
|
|
role: requestRole,
|
|
user_id: requestUser.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,
|
|
...(result.stage_timings || {})
|
|
});
|
|
return {
|
|
...delivered,
|
|
diagnostics: result.diagnostics || null,
|
|
feedback_context: {
|
|
user_message: message,
|
|
assistant_answer: delivered.text,
|
|
route_used: result.route_used || "main_llm",
|
|
role: requestRole,
|
|
origin: originContext.origin,
|
|
platform: originContext.platform,
|
|
model: result.model_id || requestConfig.selected_model_id,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
};
|
|
} catch (error) {
|
|
metrics.record({
|
|
kind: "request",
|
|
status: "failed",
|
|
user_id: requestUser.id,
|
|
role: requestRole,
|
|
message: error.message,
|
|
timeout: error.name === "TimeoutError" || error.code === "HARD_GENERATION_TIMEOUT",
|
|
cancelled: error.code === "REQUEST_CANCELLED",
|
|
total_ms: Date.now() - requestStarted,
|
|
duration_ms: Date.now() - requestStarted
|
|
});
|
|
if (error.code === "QUEUE_FULL" && !error.retry_after_seconds) error.retry_after_seconds = 5;
|
|
throw error;
|
|
}
|
|
}
|
|
});
|
|
return res.status(202).json({
|
|
job_id: job.id,
|
|
state: job.state,
|
|
stage: job.stage,
|
|
status_url: `/plugins/${PLUGIN_ID}/assistant/jobs/${job.id}`,
|
|
cancel_url: `/plugins/${PLUGIN_ID}/assistant/jobs/${job.id}/cancel`,
|
|
soft_timeout_url: `/plugins/${PLUGIN_ID}/assistant/jobs/${job.id}/soft-timeout`,
|
|
ui_soft_timeout_ms: requestConfig.ui_soft_timeout_ms
|
|
});
|
|
});
|
|
router.post("/assistant/feedback", (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." });
|
|
try {
|
|
const entry = feedbackStore.capture({
|
|
user_message: req.body.user_message,
|
|
assistant_answer: req.body.assistant_answer,
|
|
route_used: req.body.route_used,
|
|
role: roleOf(req.session.user),
|
|
origin: "webui",
|
|
platform: "webui",
|
|
model: req.body.model,
|
|
timestamp: req.body.timestamp,
|
|
feedback_tag: req.body.feedback_tag,
|
|
feedback_kind: req.body.feedback_kind,
|
|
optional_correction: req.body.optional_correction
|
|
}, req.session.user);
|
|
return res.status(201).json({ success: true, id: entry.id });
|
|
} catch (error) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
router.get("/assistant/jobs/:id", (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." });
|
|
const job = requestJobs.get(req.params.id, req.session.user.id);
|
|
if (!job) return res.status(404).json({ error: "Assistant request was not found or expired." });
|
|
return res.json(job);
|
|
});
|
|
router.post("/assistant/jobs/:id/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." });
|
|
const job = requestJobs.cancel(req.params.id, req.session.user.id, {
|
|
admin: Boolean(req.session.user?.isAdmin)
|
|
});
|
|
if (!job) return res.status(404).json({ error: "Assistant request was not found or expired." });
|
|
metrics.record({
|
|
kind: "request_job",
|
|
status: "cancelled",
|
|
job_id: job.id,
|
|
user_id: req.session.user.id,
|
|
stage: job.stage,
|
|
elapsed_ms: job.elapsed_ms
|
|
});
|
|
return res.json(job);
|
|
});
|
|
router.post("/assistant/jobs/:id/soft-timeout", (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." });
|
|
const job = requestJobs.markSoftTimeout(req.params.id, req.session.user.id);
|
|
if (!job) return res.status(404).json({ error: "Assistant request was not found or expired." });
|
|
metrics.record({
|
|
kind: "request_job",
|
|
status: "soft_timeout",
|
|
job_id: job.id,
|
|
user_id: req.session.user.id,
|
|
stage: job.stage,
|
|
elapsed_ms: job.elapsed_ms,
|
|
still_running: job.still_running
|
|
});
|
|
return res.json(job);
|
|
});
|
|
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"
|
|
};
|
|
const allowTools = req.body.allow_tools === true;
|
|
const testOrigin = ["webui", "discord", "twitch", "youtube", "kick", "other"].includes(req.body.origin)
|
|
? req.body.origin
|
|
: "webui";
|
|
try {
|
|
const result = await provider.test({
|
|
message,
|
|
user: simulatedUser,
|
|
includeRaw: Boolean(req.body.show_raw_output),
|
|
allowTools,
|
|
originContext: diagnosticOriginContext(simulatedUser, testOrigin)
|
|
});
|
|
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 });
|
|
});
|
|
|
|
router.get("/api/tools", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
|
try {
|
|
return res.json(await toolManager.list({ force: req.query.refresh === "1" }));
|
|
} catch (error) {
|
|
return res.status(503).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get("/api/tools-diagnostics", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
|
const role = ["admin", "mod", "user"].includes(req.query.role) ? req.query.role : "admin";
|
|
const origin = ["webui", "discord", "twitch", "youtube", "kick", "other"].includes(req.query.origin)
|
|
? req.query.origin
|
|
: "webui";
|
|
const simulatedUser = {
|
|
id: req.session.user.id,
|
|
username: req.session.user.username,
|
|
isAdmin: role === "admin",
|
|
isMod: role === "mod"
|
|
};
|
|
try {
|
|
const diagnostics = await toolManager.diagnostics({
|
|
role,
|
|
user: simulatedUser,
|
|
context: diagnosticOriginContext(simulatedUser, origin)
|
|
});
|
|
return res.json({
|
|
...diagnostics,
|
|
prompt_preview: buildAllowedToolsSection(diagnostics.prompt_tools)
|
|
});
|
|
} catch (error) {
|
|
return res.status(503).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get("/api/tools/:id/readme", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
|
try {
|
|
return res.json(await toolManager.readme(req.params.id));
|
|
} catch (error) {
|
|
return res.status(404).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get("/api/tools/:id/settings", (req, res) => {
|
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
|
try {
|
|
return res.json(toolManager.settingsFor(req.params.id));
|
|
} catch (error) {
|
|
return res.status(404).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.post("/api/tools/:id/settings", express.json({ limit: "64kb" }), async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
|
try {
|
|
return res.json(await toolManager.saveSettings(req.params.id, req.body?.values || {}));
|
|
} catch (error) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.post("/api/tools/:id/settings/reset", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
|
try {
|
|
return res.json(await toolManager.resetSettings(req.params.id));
|
|
} catch (error) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get("/tools/:id/assets/*", (req, res) => {
|
|
if (!req.session.user?.isAdmin) {
|
|
const permission = canUseAssistant({
|
|
user: req.session.user,
|
|
config,
|
|
origin: "webui",
|
|
platform: "webui",
|
|
requestedSurface: "webui_chat"
|
|
});
|
|
if (!permission.allowed) return res.status(403).end();
|
|
}
|
|
const file = toolManager.resolveAsset(req.params.id, req.params[0], {
|
|
allowInstalled: req.session.user?.isAdmin === true
|
|
});
|
|
return file ? res.sendFile(file) : res.status(404).end();
|
|
});
|
|
|
|
router.post("/tools/:id/enable", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
|
try {
|
|
const result = await toolManager.enable(req.params.id);
|
|
return toolActionResponse(req, res, "AI tool enabled.", result);
|
|
} catch (error) {
|
|
return toolActionError(req, res, error);
|
|
}
|
|
});
|
|
|
|
router.post("/tools/:id/disable", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
|
try {
|
|
const result = await toolManager.disable(req.params.id);
|
|
return toolActionResponse(req, res, "AI tool disabled.", result);
|
|
} catch (error) {
|
|
return toolActionError(req, res, error);
|
|
}
|
|
});
|
|
|
|
router.post("/tools/:id/update", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
|
try {
|
|
const result = await toolManager.update(req.params.id);
|
|
return toolActionResponse(req, res, "AI tool updated.", result);
|
|
} catch (error) {
|
|
return toolActionError(req, res, error);
|
|
}
|
|
});
|
|
|
|
router.post("/tools/:id/delete", async (req, res) => {
|
|
if (!req.session.user?.isAdmin) return toolDenied(req, res);
|
|
try {
|
|
const result = await toolManager.delete(req.params.id);
|
|
return toolActionResponse(req, res, "AI tool deleted.", result);
|
|
} catch (error) {
|
|
return toolActionError(req, res, error);
|
|
}
|
|
});
|
|
|
|
router.get("/improvement_center", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.allowed) return deniedImprovement(res);
|
|
return res.render(path.join(__dirname, "views", "improvement-center.ejs"), {
|
|
title: "Lumi AI Improvement Center",
|
|
config,
|
|
access,
|
|
feedbackTags: FEEDBACK_TAGS,
|
|
feedbackKinds: FEEDBACK_KINDS,
|
|
promotionTargets: PROMOTION_TARGETS,
|
|
reviews: feedbackStore.list({
|
|
page: req.query.review_page,
|
|
pageSize: 15,
|
|
status: cleanText(req.query.status, 30)
|
|
}),
|
|
corrections: correctionStore.list({ page: req.query.correction_page, pageSize: 15 }),
|
|
evalCases: evalStore.list({ page: req.query.eval_page, pageSize: 15 }),
|
|
evalResults: evalStore.results(25),
|
|
formatDate
|
|
});
|
|
});
|
|
|
|
router.post("/improvement_center/settings", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.can_approve) return deniedImprovement(res);
|
|
config = saveConfig({
|
|
...config,
|
|
improvement: {
|
|
...config.improvement,
|
|
allow_moderators_to_review_responses: req.body.allow_moderators_to_review_responses === "on",
|
|
trusted_moderator_reviewers: parseIdList(req.body.trusted_moderator_reviewers),
|
|
corrections_enabled: req.body.corrections_enabled === "on"
|
|
}
|
|
});
|
|
ensureSidebarNavItem(settings);
|
|
return improvementFlash(req, res, "success", "Improvement Center settings saved.");
|
|
});
|
|
|
|
router.post("/improvement_center/reviews/:id", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.allowed) return deniedImprovement(res);
|
|
try {
|
|
const action = cleanText(req.body.action, 30);
|
|
if (action === "flag" && access.can_flag) {
|
|
feedbackStore.setStatus(req.params.id, "flagged", req.session.user, req.body.review_notes);
|
|
} else if (action === "verify" && access.can_verify) {
|
|
feedbackStore.verify(req.params.id, req.session.user, req.body.review_notes);
|
|
} else if (action === "approve" && access.can_approve) {
|
|
feedbackStore.setStatus(req.params.id, "approved", req.session.user, req.body.review_notes);
|
|
} else if (action === "reject" && access.can_approve) {
|
|
feedbackStore.setStatus(req.params.id, "rejected", req.session.user, req.body.review_notes);
|
|
} else if (action === "edit" && access.can_edit) {
|
|
feedbackStore.edit(req.params.id, req.body, req.session.user);
|
|
} else if (action === "export" && access.can_export) {
|
|
feedbackStore.markExportApproved(req.params.id, req.session.user);
|
|
} else if (action === "delete" && access.can_delete) {
|
|
feedbackStore.delete(req.params.id);
|
|
} else {
|
|
return deniedImprovement(res);
|
|
}
|
|
return improvementFlash(req, res, "success", `Review ${action} completed.`);
|
|
} catch (error) {
|
|
return improvementFlash(req, res, "error", error.message);
|
|
}
|
|
});
|
|
|
|
router.post("/improvement_center/reviews/:id/implement", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.can_implement) return deniedImprovement(res);
|
|
try {
|
|
const review = feedbackStore.get(req.params.id);
|
|
if (!review || review.status !== "approved") throw new Error("Approve the review before implementing it.");
|
|
const target = PROMOTION_TARGETS.includes(req.body.target) ? req.body.target : "correction";
|
|
if (target === "eval_case") {
|
|
evalStore.add({
|
|
prompt: review.user_message,
|
|
role: req.body.min_role || review.role,
|
|
origin: req.body.permission_origin || review.origin,
|
|
expected_behavior: req.body.corrected_answer || review.optional_correction,
|
|
forbidden_behavior: req.body.forbidden_behavior,
|
|
expected_link: req.body.expected_link,
|
|
notes: req.body.notes
|
|
}, req.session.user);
|
|
} else if (target === "training_export") {
|
|
feedbackStore.markExportApproved(review.id, req.session.user);
|
|
} else {
|
|
correctionStore.createFromFeedback(review, {
|
|
...req.body,
|
|
target,
|
|
explicitly_safe: req.body.explicitly_safe === "on",
|
|
enabled: req.body.enabled === "on"
|
|
}, req.session.user);
|
|
}
|
|
return improvementFlash(req, res, "success", "Approved feedback was promoted. Save Corrections before it becomes active.");
|
|
} catch (error) {
|
|
return improvementFlash(req, res, "error", error.message);
|
|
}
|
|
});
|
|
|
|
router.post("/improvement_center/corrections/save", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.can_implement) return deniedImprovement(res);
|
|
const result = correctionStore.saveCorrections(req.session.user);
|
|
return improvementFlash(req, res, "success", `Corrections saved. ${result.active} of ${result.total} are active.`);
|
|
});
|
|
|
|
router.post("/improvement_center/corrections/:id", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.allowed) return deniedImprovement(res);
|
|
try {
|
|
const action = cleanText(req.body.action, 30);
|
|
if (action === "verify" && access.can_verify) {
|
|
correctionStore.verify(req.params.id, req.session.user);
|
|
} else if (action === "edit" && access.can_edit) {
|
|
if (req.body.expected_link && !isVerifiedImprovementLink(req.body.expected_link)) {
|
|
throw new Error("Internal correction links must match a verified Lumi route.");
|
|
}
|
|
correctionStore.update(req.params.id, {
|
|
...req.body,
|
|
explicitly_safe: req.body.explicitly_safe === "on",
|
|
enabled: req.body.enabled === "on"
|
|
});
|
|
} else if (action === "toggle" && access.can_edit) {
|
|
correctionStore.setEnabled(req.params.id, req.body.enabled === "on");
|
|
} else if (action === "delete" && access.can_delete) {
|
|
correctionStore.delete(req.params.id);
|
|
} else {
|
|
return deniedImprovement(res);
|
|
}
|
|
return improvementFlash(req, res, "success", `Correction ${action} completed. Save Corrections to activate changes.`);
|
|
} catch (error) {
|
|
return improvementFlash(req, res, "error", error.message);
|
|
}
|
|
});
|
|
|
|
router.post("/improvement_center/evals", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.can_run_evals) return deniedImprovement(res);
|
|
try {
|
|
if (req.body.expected_link && !isVerifiedImprovementLink(req.body.expected_link)) {
|
|
throw new Error("Expected links must match a verified Lumi route.");
|
|
}
|
|
evalStore.add(req.body, req.session.user);
|
|
return improvementFlash(req, res, "success", "Eval case added.");
|
|
} catch (error) {
|
|
return improvementFlash(req, res, "error", error.message);
|
|
}
|
|
});
|
|
|
|
router.post("/improvement_center/evals/:id/delete", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.can_run_evals) return deniedImprovement(res);
|
|
evalStore.delete(req.params.id);
|
|
return improvementFlash(req, res, "success", "Eval case deleted.");
|
|
});
|
|
|
|
router.post("/improvement_center/evals/run", async (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.can_run_evals) return deniedImprovement(res);
|
|
try {
|
|
const results = await evalStore.runAll({ provider, actor: req.session.user });
|
|
return improvementFlash(req, res, "success", `Eval run completed with ${results.length} result(s).`);
|
|
} catch (error) {
|
|
return improvementFlash(req, res, "error", error.message);
|
|
}
|
|
});
|
|
|
|
router.post("/improvement_center/exports/:format", (req, res) => {
|
|
const access = improvementAccess(req.session.user, config);
|
|
if (!access.can_export) return deniedImprovement(res);
|
|
try {
|
|
const output = trainingExporter.export(req.params.format);
|
|
return res.download(output.file, output.filename);
|
|
} catch (error) {
|
|
return improvementFlash(req, res, "error", error.message);
|
|
}
|
|
});
|
|
|
|
web.mount(`/plugins/${PLUGIN_ID}`, router, {
|
|
label: "Lumi AI",
|
|
role: "admin",
|
|
section: "plugins"
|
|
});
|
|
web.addNavItem({
|
|
label: "AI Improvement Center",
|
|
path: `/plugins/${PLUGIN_ID}/improvement_center`,
|
|
role: "mod",
|
|
section: "moderation",
|
|
canAccess: (user) => improvementAccess(user, config).allowed
|
|
});
|
|
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);
|
|
setImmediate(() => toolManager.loadEnabled().catch((error) =>
|
|
console.error("Lumi AI tool loader failed", error)
|
|
));
|
|
|
|
if (config.enabled) {
|
|
setImmediate(() => ensureGateRuntime().catch((error) =>
|
|
console.error("Lumi AI gate runtime start failed", error)
|
|
));
|
|
}
|
|
const state = getRuntimeState();
|
|
if (shouldAutoResume(config, state)) {
|
|
setImmediate(() => startRuntimes({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error)));
|
|
}
|
|
|
|
return async () => {
|
|
clearInterval(gateMonitor);
|
|
removeAssistantPanel();
|
|
commandRouter?.clearCommands?.(PLUGIN_ID);
|
|
await toolManager.stopAll();
|
|
await stopRuntimes({ 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 boundedNumber(value, min, max, fallback) {
|
|
const number = Number(value);
|
|
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 toolActionResponse(req, res, message, result = {}) {
|
|
if (req.accepts(["json", "html"]) === "json") return res.json({ success: true, message, result });
|
|
req.session.flash = { type: "success", message };
|
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
|
}
|
|
function toolActionError(req, res, error) {
|
|
if (req.accepts(["json", "html"]) === "json") return res.status(400).json({ error: error.message });
|
|
req.session.flash = { type: "error", message: error.message };
|
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
|
}
|
|
function toolDenied(req, res) {
|
|
if (req.accepts(["json", "html"]) === "json") return res.status(403).json({ error: "Access denied." });
|
|
return denied(res);
|
|
}
|
|
function improvementFlash(req, res, type, message) {
|
|
req.session.flash = { type, message };
|
|
return res.redirect(`/plugins/${PLUGIN_ID}/improvement_center`);
|
|
}
|
|
function denied(res) {
|
|
return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." });
|
|
}
|
|
function deniedImprovement(res) {
|
|
return res.status(403).render("error", {
|
|
title: "Access denied",
|
|
message: "Improvement Center access is not enabled for this account."
|
|
});
|
|
}
|
|
function parseIdList(value) {
|
|
return [...new Set(String(value || "")
|
|
.split(/[\s,;]+/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean))]
|
|
.slice(0, 250);
|
|
}
|
|
function isVerifiedImprovementLink(value) {
|
|
const cleaned = cleanText(value, 2000).replace(/^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/i, "");
|
|
if (!cleaned) return true;
|
|
let pathname;
|
|
try {
|
|
const url = new URL(cleaned, "https://lumi.invalid");
|
|
if (!["http:", "https:"].includes(url.protocol)) return false;
|
|
pathname = url.pathname;
|
|
} catch {
|
|
return false;
|
|
}
|
|
return repoIndexer.verifiedRoutePaths().includes(pathname);
|
|
}
|
|
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";
|
|
const improvementNavId = "plugins_lumi_ai_improvement_center";
|
|
for (const section of structure.sections) {
|
|
if (Array.isArray(section.items)) {
|
|
section.items = section.items.filter((item) => ![navId, improvementNavId].includes(item));
|
|
}
|
|
}
|
|
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);
|
|
let moderation = structure.sections.find((section) => section.id === "moderation");
|
|
if (!moderation) {
|
|
moderation = { id: "moderation", label: "Mod", icon: "shield", items: [] };
|
|
structure.sections.push(moderation);
|
|
}
|
|
moderation.items = Array.isArray(moderation.items) ? moderation.items : [];
|
|
moderation.items.push(improvementNavId);
|
|
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 diagnosticOriginContext(user, origin = "webui") {
|
|
const role = roleOf(user);
|
|
const limits = { webui: 8000, discord: 1900, twitch: 450, youtube: 1800, kick: 450, other: 1000 };
|
|
return {
|
|
origin,
|
|
platform: origin,
|
|
channel_id: "diagnostic-channel",
|
|
channel_name: "Diagnostic channel",
|
|
server_id: "diagnostic-server",
|
|
user_id: user.id,
|
|
username: user.username,
|
|
display_name: user.username,
|
|
role,
|
|
is_admin: role === "admin",
|
|
is_mod: role === "mod",
|
|
message_id: "diagnostic-message",
|
|
reply_mode: origin === "webui" ? "panel" : "same_channel",
|
|
format_capabilities: {
|
|
markdown: origin === "discord",
|
|
html: origin === "webui"
|
|
},
|
|
max_message_length: limits[origin] || limits.other,
|
|
source_plugin: "lumi_ai",
|
|
source_command: "admin_diagnostic",
|
|
permission_context: {
|
|
identified_user: true,
|
|
webui_actions_allowed: origin === "webui"
|
|
}
|
|
};
|
|
}
|
|
|
|
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
|
|
});
|
|
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;
|