From 34e78d69c3e3cdd2e9c79a0d67292da78b8f1e58 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Thu, 11 Jun 2026 06:35:43 +0200 Subject: [PATCH] Add Lumi AI, birthday plugin, and persistent updates --- .gitignore | 7 +- codex-guidelines | 2 + plugins/birthday/README.md | 43 + plugins/birthday/cmds.json | 15 + plugins/birthday/index.js | 964 ++++++++++++++++++ plugins/birthday/plugin.json | 7 + plugins/birthday/views/birthday-admin.ejs | 187 ++++ plugins/birthday/views/profile-birthday.ejs | 45 + plugins/lumi_ai/README.md | 97 ++ plugins/lumi_ai/backend/ai_provider.js | 54 + plugins/lumi_ai/backend/config_manager.js | 68 ++ plugins/lumi_ai/backend/diagnostics.js | 58 ++ plugins/lumi_ai/backend/downloader.js | 103 ++ plugins/lumi_ai/backend/error_codes.js | 74 ++ plugins/lumi_ai/backend/hardware.js | 47 + plugins/lumi_ai/backend/metrics.js | 55 + plugins/lumi_ai/backend/paths.js | 20 + plugins/lumi_ai/backend/permissions.js | 10 + plugins/lumi_ai/backend/prompt_builder.js | 21 + plugins/lumi_ai/backend/queue_manager.js | 24 + plugins/lumi_ai/backend/runtime_manager.js | 290 ++++++ plugins/lumi_ai/backend/tool_router.js | 43 + plugins/lumi_ai/cmds.json | 1 + plugins/lumi_ai/data/cache/.gitkeep | 1 + plugins/lumi_ai/data/config/.gitkeep | 1 + plugins/lumi_ai/data/diagnostics/.gitkeep | 1 + plugins/lumi_ai/data/logs/.gitkeep | 1 + plugins/lumi_ai/data/metrics/.gitkeep | 1 + plugins/lumi_ai/data/models/.gitkeep | 1 + plugins/lumi_ai/data/rag/.gitkeep | 1 + plugins/lumi_ai/data/runtime/.gitkeep | 1 + plugins/lumi_ai/data/tmp/.gitkeep | 1 + plugins/lumi_ai/index.js | 376 +++++++ plugins/lumi_ai/models_manifest.json | 207 ++++ plugins/lumi_ai/plugin.json | 7 + plugins/lumi_ai/public/assistant.css | 29 + plugins/lumi_ai/public/assistant.js | 122 +++ plugins/lumi_ai/public/settings.css | 42 + plugins/lumi_ai/public/settings.js | 71 ++ plugins/lumi_ai/runtime_manifest.json | 36 + plugins/lumi_ai/templates/role_admin.txt | 1 + plugins/lumi_ai/templates/role_mod.txt | 1 + plugins/lumi_ai/templates/role_user.txt | 1 + plugins/lumi_ai/templates/system.txt | 5 + plugins/lumi_ai/tests/verify.js | 157 +++ plugins/lumi_ai/views/assistant-panel.ejs | 25 + plugins/lumi_ai/views/settings.ejs | 219 ++++ .../verify-plugin-update-preserves-data.js | 32 + src/main.js | 20 +- src/services/plugins.js | 20 +- src/services/update-manager.js | 51 +- src/web/public/styles.css | 9 + src/web/server.js | 22 + src/web/views/partials/layout-top.ejs | 17 + ...mi-core-webhook-framework-patch-v0.1.0.zip | Bin 44438 -> 0 bytes .../lumi-plugin-throne_wishlist-v0.1.0.zip | Bin 17915 -> 0 bytes 56 files changed, 3701 insertions(+), 13 deletions(-) create mode 100644 plugins/birthday/README.md create mode 100644 plugins/birthday/cmds.json create mode 100644 plugins/birthday/index.js create mode 100644 plugins/birthday/plugin.json create mode 100644 plugins/birthday/views/birthday-admin.ejs create mode 100644 plugins/birthday/views/profile-birthday.ejs create mode 100644 plugins/lumi_ai/README.md create mode 100644 plugins/lumi_ai/backend/ai_provider.js create mode 100644 plugins/lumi_ai/backend/config_manager.js create mode 100644 plugins/lumi_ai/backend/diagnostics.js create mode 100644 plugins/lumi_ai/backend/downloader.js create mode 100644 plugins/lumi_ai/backend/error_codes.js create mode 100644 plugins/lumi_ai/backend/hardware.js create mode 100644 plugins/lumi_ai/backend/metrics.js create mode 100644 plugins/lumi_ai/backend/paths.js create mode 100644 plugins/lumi_ai/backend/permissions.js create mode 100644 plugins/lumi_ai/backend/prompt_builder.js create mode 100644 plugins/lumi_ai/backend/queue_manager.js create mode 100644 plugins/lumi_ai/backend/runtime_manager.js create mode 100644 plugins/lumi_ai/backend/tool_router.js create mode 100644 plugins/lumi_ai/cmds.json create mode 100644 plugins/lumi_ai/data/cache/.gitkeep create mode 100644 plugins/lumi_ai/data/config/.gitkeep create mode 100644 plugins/lumi_ai/data/diagnostics/.gitkeep create mode 100644 plugins/lumi_ai/data/logs/.gitkeep create mode 100644 plugins/lumi_ai/data/metrics/.gitkeep create mode 100644 plugins/lumi_ai/data/models/.gitkeep create mode 100644 plugins/lumi_ai/data/rag/.gitkeep create mode 100644 plugins/lumi_ai/data/runtime/.gitkeep create mode 100644 plugins/lumi_ai/data/tmp/.gitkeep create mode 100644 plugins/lumi_ai/index.js create mode 100644 plugins/lumi_ai/models_manifest.json create mode 100644 plugins/lumi_ai/plugin.json create mode 100644 plugins/lumi_ai/public/assistant.css create mode 100644 plugins/lumi_ai/public/assistant.js create mode 100644 plugins/lumi_ai/public/settings.css create mode 100644 plugins/lumi_ai/public/settings.js create mode 100644 plugins/lumi_ai/runtime_manifest.json create mode 100644 plugins/lumi_ai/templates/role_admin.txt create mode 100644 plugins/lumi_ai/templates/role_mod.txt create mode 100644 plugins/lumi_ai/templates/role_user.txt create mode 100644 plugins/lumi_ai/templates/system.txt create mode 100644 plugins/lumi_ai/tests/verify.js create mode 100644 plugins/lumi_ai/views/assistant-panel.ejs create mode 100644 plugins/lumi_ai/views/settings.ejs create mode 100644 scripts/verify-plugin-update-preserves-data.js delete mode 100644 updates/lumi-core-webhook-framework-patch-v0.1.0.zip delete mode 100644 updates/lumi-plugin-throne_wishlist-v0.1.0.zip diff --git a/.gitignore b/.gitignore index 87bed15..30bf97c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ -data/ -updates/ +/data/ +/updates/ +plugins/*/data/** +!plugins/*/data/**/ +!plugins/*/data/**/.gitkeep .env .env.* !.env.example diff --git a/codex-guidelines b/codex-guidelines index 190077e..41d7968 100644 --- a/codex-guidelines +++ b/codex-guidelines @@ -112,6 +112,8 @@ Plugins (important) - Plugins should avoid core edits unless explicitly requested - Plugins receive `webhooks` for raw-body inbound endpoint registration and outbound webhook sending. See `docs/webhooks.md`. +- `web.addAssistantPanel({ id, view, stylesheet?, script?, role?, isVisible?, locals? })` + contributes a role-filtered sidebar pill/global panel above the user footer. Current notable plugins - echonomy-framework: diff --git a/plugins/birthday/README.md b/plugins/birthday/README.md new file mode 100644 index 0000000..6c73a21 --- /dev/null +++ b/plugins/birthday/README.md @@ -0,0 +1,43 @@ +# Birthday Plugin + +Standalone Lumi plugin for birthday profile settings, chat commands, Discord birthday announcements, and optional echonomy birthday gifts. + +## Install + +Install `updates/lumi-plugin-birthday-v0.1.0.zip` through Admin -> Plugins. The ZIP is built from the contents of this folder, so `plugin.json` is at the ZIP root. + +## Commands + +- `!birthday` shows help and the caller's stored birthday when available. +- `!birthday set YYYY/MM/DD` stores a full birthday. +- `!birthday set MM/DD` stores a birthday without a year. +- `!birthday unset` removes the caller's birthday. +- `!birthday ` looks up a Lumi user by internal username, linked display name, provider user ID, or Discord mention. +- `!birthday claim` claims the configured gift when manual gift mode is enabled. + +The `bday` alias is also registered. The command supports Discord, Twitch, and YouTube command contexts. + +## WebUI + +- Admin/mod page: `/plugins/birthday` +- Owner profile section: rendered on `/profile` through `web.addProfileSection` +- Visitor birthday card: `/plugins/birthday/u/:username` + +Lumi currently exposes an owner profile section hook, but this repository does not expose a separate public visitor profile hook for plugins. Because of that, visitor display is implemented as the plugin route above instead of being injected into a core public profile page. + +## Storage + +Birthdays are stored in plugin-owned tables: + +- `birthday_profiles` +- `birthday_deliveries` + +Plugin settings are stored in `plugin_settings` with `plugin_id = 'birthday'`. + +## Notes + +- Accepted date formats are `YYYY/MM/DD` and `MM/DD`. +- Dash-separated dates and `DD/MM` dates are rejected. +- Default privacy is `limited`. +- If echonomy is not loaded, birthday announcements still work and gifts are skipped. +- Automatic gifts are delivered once per birthday occurrence. Manual gifts are claimed once per birthday occurrence. diff --git a/plugins/birthday/cmds.json b/plugins/birthday/cmds.json new file mode 100644 index 0000000..61e01a0 --- /dev/null +++ b/plugins/birthday/cmds.json @@ -0,0 +1,15 @@ +{ + "pluginId": "birthday", + "commands": [ + { + "id": "birthday", + "name": "Birthday", + "trigger": "birthday", + "aliases": ["bday"], + "description": "Set, unset, or look up birthdays.", + "usage": "birthday set YYYY/MM/DD | birthday set MM/DD | birthday unset | birthday ", + "platforms": ["discord", "twitch", "youtube"], + "origin": "plugin:birthday" + } + ] +} diff --git a/plugins/birthday/index.js b/plugins/birthday/index.js new file mode 100644 index 0000000..37c62a3 --- /dev/null +++ b/plugins/birthday/index.js @@ -0,0 +1,964 @@ +const crypto = require("crypto"); +const path = require("path"); + +const PLUGIN_ID = "birthday"; +const TIMER_KEY = Symbol.for("lumi.birthday.interval"); +const ALLOWED_PRIVACY = new Set(["public", "limited", "private"]); +const ALLOWED_PLACEHOLDERS = new Set([ + "username", + "display_name", + "pronoun", + "pronoun_subject", + "pronoun_object", + "pronoun_possessive", + "birthday", + "birthday_day", + "birthday_day_text", + "birthday_month", + "time_until_birthday", + "days_until_birthday", + "months_until_birthday", + "age_before", + "age_after", + "birthday_weekday", + "gift_amount", + "gift_amount_text", + "currency_name" +]); + +const DEFAULT_FULL = [ + "Happy birthday, {display_name}! {display_name} is turning {age_after} today.", + "Everyone wish {display_name} a happy birthday! From {age_before} to {age_after} - hope today is amazing." +]; + +const DEFAULT_PARTIAL = [ + "Happy birthday, {display_name}! Hope today is full of good vibes and cake.", + "Everyone wish {display_name} a happy birthday today!" +]; + +const DEFAULTS = { + enabled: "1", + announcement_channel_id: "", + timezone: "UTC", + leap_day_policy: "feb28", + gift_mode: "automatic", + gift_amount: "0", + birthday_check_interval_minutes: "60", + response_templates: JSON.stringify({ + fullYear: DEFAULT_FULL.map((text) => newTemplate(text)), + partialYear: DEFAULT_PARTIAL.map((text) => newTemplate(text)) + }) +}; + +module.exports = { + id: PLUGIN_ID, + init({ web, db, settings, commandRouter, discordClient }) { + ensureTables(db); + ensureDefaults(db); + registerCommands({ db, settings, commandRouter }); + + const router = web.createRouter(); + + router.get("/", async (req, res) => { + const user = req.session.user || null; + if (!canModerate(user)) { + return renderDenied(res); + } + const config = getConfig(db); + const diagnostics = await buildDiagnostics(discordClient, config); + res.render(path.join(__dirname, "views", "birthday-admin.ejs"), { + title: "Birthdays", + config, + diagnostics, + textChannels: getTextChannels(discordClient), + isAdmin: Boolean(user?.isAdmin), + allowedPlaceholders: Array.from(ALLOWED_PLACEHOLDERS).sort(), + previewTemplate, + formatDateTime + }); + }); + + router.post("/settings", async (req, res) => { + const user = req.session.user || null; + if (!canModerate(user)) { + return renderDenied(res); + } + const config = getConfig(db); + const next = { + enabled: req.body.enabled === "on" ? "1" : "0", + announcement_channel_id: (req.body.announcement_channel_id || "").trim(), + timezone: (req.body.timezone || "UTC").trim(), + leap_day_policy: req.body.leap_day_policy === "mar1" ? "mar1" : "feb28", + gift_mode: req.body.gift_mode === "manual" ? "manual" : "automatic", + birthday_check_interval_minutes: String(clampInt(req.body.birthday_check_interval_minutes, 5, 1440, 60)), + gift_amount: config.gift_amount, + response_templates: JSON.stringify(config.response_templates) + }; + if (!isValidTimezone(next.timezone)) { + req.session.flash = { type: "error", message: "Timezone must be a valid IANA timezone." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + if (next.announcement_channel_id) { + const channel = await validateTextChannel(discordClient, next.announcement_channel_id); + if (!channel.valid) { + req.session.flash = { type: "error", message: channel.message }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + } + if (user?.isAdmin) { + next.gift_amount = String(Math.max(0, parseInt(req.body.gift_amount || "0", 10) || 0)); + } else if ((req.body.gift_amount || "").trim() !== String(config.gift_amount)) { + req.session.flash = { type: "error", message: "Only admins can change the birthday gift amount." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + saveConfig(db, next); + restartScheduler({ db, discordClient }); + req.session.flash = { type: "success", message: "Birthday settings saved." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/templates/create", (req, res) => { + if (!canModerate(req.session.user)) { + return renderDenied(res); + } + const pool = normalizePool(req.body.pool); + const text = (req.body.text || "").trim(); + const error = validateTemplate(text); + if (error) { + req.session.flash = { type: "error", message: error }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const config = getConfig(db); + config.response_templates[pool].push(newTemplate(text)); + saveConfig(db, serializeConfig(config)); + req.session.flash = { type: "success", message: "Template added." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/templates/:id/update", (req, res) => { + if (!canModerate(req.session.user)) { + return renderDenied(res); + } + const pool = normalizePool(req.body.pool); + const text = (req.body.text || "").trim(); + const error = validateTemplate(text); + if (error) { + req.session.flash = { type: "error", message: error }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const config = getConfig(db); + const template = config.response_templates[pool].find((item) => item.id === req.params.id); + if (!template) { + req.session.flash = { type: "error", message: "Template not found." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + template.text = text; + template.enabled = req.body.enabled === "on"; + template.updatedAt = Date.now(); + saveConfig(db, serializeConfig(config)); + req.session.flash = { type: "success", message: "Template updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/templates/:id/duplicate", (req, res) => { + if (!canModerate(req.session.user)) { + return renderDenied(res); + } + const pool = normalizePool(req.body.pool); + const config = getConfig(db); + const source = config.response_templates[pool].find((item) => item.id === req.params.id); + if (source) { + config.response_templates[pool].push(newTemplate(source.text)); + saveConfig(db, serializeConfig(config)); + } + req.session.flash = { type: "success", message: "Template duplicated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/templates/:id/remove", (req, res) => { + if (!canModerate(req.session.user)) { + return renderDenied(res); + } + const pool = normalizePool(req.body.pool); + const config = getConfig(db); + config.response_templates[pool] = config.response_templates[pool].filter((item) => item.id !== req.params.id); + saveConfig(db, serializeConfig(config)); + req.session.flash = { type: "success", message: "Template removed." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/profile", (req, res) => { + const user = req.session.user || null; + if (!user?.id) { + return renderDenied(res); + } + const parsed = parseBirthdayInput(req.body.birthday || ""); + if (!parsed.ok) { + req.session.flash = { type: "error", message: parsed.message }; + return res.redirect("/profile"); + } + const privacy = normalizePrivacy(req.body.privacy); + upsertBirthday(db, user.id, { ...parsed, privacy }); + req.session.flash = { + type: "success", + message: `Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.` + }; + res.redirect("/profile"); + }); + + router.post("/profile/unset", (req, res) => { + const user = req.session.user || null; + if (!user?.id) { + return renderDenied(res); + } + deleteBirthday(db, user.id); + req.session.flash = { type: "success", message: "Birthday removed." }; + res.redirect("/profile"); + }); + + router.get("/u/:username", (req, res) => { + const viewer = req.session.user || null; + const target = findUser(db, req.params.username); + if (!target) { + return res.status(404).render("error", { + title: "Birthday not found", + message: "No matching Lumi user was found." + }); + } + const birthday = getBirthday(db, target.id); + const canView = birthday && canViewBirthday({ viewer, ownerId: target.id, privacy: birthday.privacy, commandContext: false }); + res.render(path.join(__dirname, "views", "profile-birthday.ejs"), { + title: `${target.internal_username} Birthday`, + target, + birthday, + canView, + viewer, + formatBirthday, + formatBirthdayDateOnly, + publicUrl: `/plugins/${PLUGIN_ID}/u/${encodeURIComponent(target.internal_username)}` + }); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Birthdays", + role: "mod", + section: "plugins" + }); + ensureSidebarNavItem(settings); + + if (typeof web.addProfileSection === "function") { + web.addProfileSection({ + id: PLUGIN_ID, + label: "Birthday", + view: path.join(__dirname, "views", "profile-birthday.ejs"), + order: 45, + locals: { + profileSection: true, + getBirthday: (userId) => getBirthday(db, userId), + formatBirthday, + formatBirthdayDateOnly + } + }); + } + + restartScheduler({ db, discordClient }); + } +}; + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS birthday_profiles ( + user_id TEXT PRIMARY KEY REFERENCES user_profiles(id) ON DELETE CASCADE, + year INTEGER NULL, + month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), + day INTEGER NOT NULL CHECK(day BETWEEN 1 AND 31), + privacy TEXT NOT NULL DEFAULT 'limited' CHECK(privacy IN ('public','limited','private')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS birthday_deliveries ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + delivery_key TEXT NOT NULL, + delivery_type TEXT NOT NULL CHECK(delivery_type IN ('announcement','gift')), + status TEXT NOT NULL CHECK(status IN ('sent','skipped','failed','claimed')), + details TEXT NULL, + created_at INTEGER NOT NULL, + UNIQUE(user_id, delivery_key, delivery_type) + ); + + CREATE INDEX IF NOT EXISTS birthday_profiles_month_day_idx ON birthday_profiles(month, day); + CREATE INDEX IF NOT EXISTS birthday_deliveries_key_idx ON birthday_deliveries(delivery_key, delivery_type); + `); +} + +function ensureDefaults(db) { + const now = Date.now(); + for (const [key, value] of Object.entries(DEFAULTS)) { + db.prepare( + "INSERT OR IGNORE INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?)" + ).run(PLUGIN_ID, key, value, now); + } +} + +function getConfig(db) { + const rows = db.prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?").all(PLUGIN_ID); + const raw = { ...DEFAULTS }; + for (const row of rows) { + raw[row.key] = row.value; + } + let templates; + try { + templates = JSON.parse(raw.response_templates); + } catch { + templates = {}; + } + return { + enabled: raw.enabled === "1", + announcement_channel_id: raw.announcement_channel_id || "", + timezone: isValidTimezone(raw.timezone) ? raw.timezone : "UTC", + leap_day_policy: raw.leap_day_policy === "mar1" ? "mar1" : "feb28", + gift_mode: raw.gift_mode === "manual" ? "manual" : "automatic", + gift_amount: Math.max(0, parseInt(raw.gift_amount || "0", 10) || 0), + birthday_check_interval_minutes: clampInt(raw.birthday_check_interval_minutes, 5, 1440, 60), + response_templates: normalizeTemplates(templates) + }; +} + +function serializeConfig(config) { + return { + enabled: config.enabled ? "1" : "0", + announcement_channel_id: config.announcement_channel_id || "", + timezone: config.timezone || "UTC", + leap_day_policy: config.leap_day_policy === "mar1" ? "mar1" : "feb28", + gift_mode: config.gift_mode === "manual" ? "manual" : "automatic", + gift_amount: String(Math.max(0, parseInt(config.gift_amount || "0", 10) || 0)), + birthday_check_interval_minutes: String(clampInt(config.birthday_check_interval_minutes, 5, 1440, 60)), + response_templates: JSON.stringify(normalizeTemplates(config.response_templates)) + }; +} + +function saveConfig(db, values) { + const now = Date.now(); + for (const [key, value] of Object.entries(values)) { + db.prepare( + "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(PLUGIN_ID, key, String(value), now); + } +} + +function normalizeTemplates(value) { + return { + fullYear: normalizeTemplateList(value?.fullYear, DEFAULT_FULL), + partialYear: normalizeTemplateList(value?.partialYear, DEFAULT_PARTIAL) + }; +} + +function normalizeTemplateList(items, defaults) { + const source = Array.isArray(items) && items.length ? items : defaults.map((text) => newTemplate(text)); + return source + .map((item) => ({ + id: item?.id || crypto.randomUUID(), + text: (typeof item === "string" ? item : item?.text || "").toString(), + enabled: item?.enabled !== false, + createdAt: Number(item?.createdAt) || Date.now(), + updatedAt: Number(item?.updatedAt) || Date.now() + })) + .filter((item) => item.text.trim()); +} + +function newTemplate(text) { + const now = Date.now(); + return { id: crypto.randomUUID(), text, enabled: true, createdAt: now, updatedAt: now }; +} + +function registerCommands({ db, settings, commandRouter }) { + if (!commandRouter) { + return; + } + commandRouter.registerCommands(PLUGIN_ID, [ + { + id: "birthday", + triggers: ["birthday", "bday"], + platforms: ["discord", "twitch", "youtube"], + handler: (ctx) => handleBirthdayCommand({ ctx, db, settings }) + } + ]); +} + +async function handleBirthdayCommand({ ctx, db, settings }) { + const prefix = settings?.getSetting ? settings.getSetting("command_prefix", "!") : "!"; + const sub = (ctx.args[0] || "").toLowerCase(); + if (!sub) { + const own = getBirthday(db, ctx.user.id); + const suffix = own ? ` Your birthday is ${formatBirthday(own)} (${own.privacy}).` : ""; + await ctx.reply(`Usage: ${prefix}birthday set YYYY/MM/DD, ${prefix}birthday set MM/DD, ${prefix}birthday unset, ${prefix}birthday , or ${prefix}birthday claim.${suffix}`); + return true; + } + if (sub === "set") { + const input = ctx.args[1] || ""; + if (!input) { + await ctx.reply("Use YYYY/MM/DD for a full birthday or MM/DD if you do not want to store the year. Year is optional but recommended for age-based messages."); + return true; + } + const parsed = parseBirthdayInput(input); + if (!parsed.ok) { + await ctx.reply(parsed.message); + return true; + } + const existing = getBirthday(db, ctx.user.id); + const privacy = existing?.privacy || "limited"; + upsertBirthday(db, ctx.user.id, { ...parsed, privacy }); + await ctx.reply(`Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.`); + return true; + } + if (sub === "unset") { + deleteBirthday(db, ctx.user.id); + await ctx.reply("Birthday removed."); + return true; + } + if (sub === "claim") { + await ctx.reply(await claimGift(db, ctx.user.id)); + return true; + } + const targetText = ctx.argsText.trim(); + const target = findUser(db, targetText); + if (!target) { + await ctx.reply("No matching Lumi user was found."); + return true; + } + const birthday = getBirthday(db, target.id); + if (!birthday) { + await ctx.reply("No birthday on file for that user."); + return true; + } + if (!canViewBirthday({ viewer: { id: ctx.user.id }, ownerId: target.id, privacy: birthday.privacy, commandContext: true })) { + await ctx.reply("That birthday is private."); + return true; + } + await ctx.reply(`${target.internal_username}'s birthday is ${formatBirthdayDateOnly(birthday)}.`); + return true; +} + +function parseBirthdayInput(input) { + const text = (input || "").trim(); + const full = text.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/); + const partial = text.match(/^(\d{1,2})\/(\d{1,2})$/); + const nowYear = new Date().getUTCFullYear(); + if (full) { + const year = parseInt(full[1], 10); + const month = parseInt(full[2], 10); + const day = parseInt(full[3], 10); + if (year < 1900 || year > nowYear) { + return { ok: false, message: `Year must be between 1900 and ${nowYear}.` }; + } + if (!isValidDate(year, month, day)) { + return { ok: false, message: "Birthday must be a real calendar date in YYYY/MM/DD format." }; + } + return { ok: true, year, month, day }; + } + if (partial) { + const month = parseInt(partial[1], 10); + const day = parseInt(partial[2], 10); + if (!isValidMonthDay(month, day)) { + return { ok: false, message: "Birthday must be a real calendar date in MM/DD format." }; + } + return { ok: true, year: null, month, day }; + } + return { ok: false, message: "Use slash-delimited YYYY/MM/DD or MM/DD. DD/MM and dash-separated dates are not accepted." }; +} + +function isValidDate(year, month, day) { + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} + +function isValidMonthDay(month, day) { + return month === 2 && day === 29 ? true : isValidDate(2000, month, day); +} + +function upsertBirthday(db, userId, birthday) { + const now = Date.now(); + db.prepare( + "INSERT INTO birthday_profiles (user_id, year, month, day, privacy, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET year = excluded.year, month = excluded.month, day = excluded.day, privacy = excluded.privacy, updated_at = excluded.updated_at" + ).run(userId, birthday.year || null, birthday.month, birthday.day, normalizePrivacy(birthday.privacy), now, now); +} + +function deleteBirthday(db, userId) { + db.prepare("DELETE FROM birthday_profiles WHERE user_id = ?").run(userId); +} + +function getBirthday(db, userId) { + return userId ? db.prepare("SELECT * FROM birthday_profiles WHERE user_id = ?").get(userId) : null; +} + +function findUser(db, text) { + const raw = (text || "").trim(); + if (!raw) { + return null; + } + const mention = raw.match(/^<@!?(\d+)>$/); + if (mention) { + const row = db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE i.provider = 'discord' AND i.provider_user_id = ?").get(mention[1]); + if (row) return row; + } + const cleaned = raw.replace(/^@/, ""); + return db.prepare("SELECT id, internal_username FROM user_profiles WHERE lower(internal_username) = lower(?) LIMIT 1").get(cleaned) || + db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE lower(i.display_name) = lower(?) OR i.provider_user_id = ? LIMIT 1").get(cleaned, cleaned); +} + +function canViewBirthday({ viewer, ownerId, privacy, commandContext }) { + if (viewer?.id && viewer.id === ownerId) { + return true; + } + if (viewer?.isAdmin || viewer?.isMod) { + return true; + } + if (privacy === "public") { + return true; + } + if (privacy === "limited") { + return commandContext ? Boolean(viewer?.id) : Boolean(viewer?.id); + } + return false; +} + +function normalizePrivacy(value) { + return ALLOWED_PRIVACY.has(value) ? value : "limited"; +} + +function restartScheduler({ db, discordClient }) { + if (global[TIMER_KEY]) { + clearInterval(global[TIMER_KEY]); + global[TIMER_KEY] = null; + } + const config = getConfig(db); + if (!config.enabled) { + return; + } + checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); + global[TIMER_KEY] = setInterval(() => { + checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); + }, config.birthday_check_interval_minutes * 60 * 1000); +} + +async function checkBirthdays({ db, discordClient }) { + const config = getConfig(db); + if (!config.enabled) { + return; + } + const today = getZonedDateParts(config.timezone); + const effectiveDates = getEffectiveDates(today, config.leap_day_policy); + const channelResult = config.announcement_channel_id + ? await validateTextChannel(discordClient, config.announcement_channel_id) + : { valid: false, message: "No birthday channel configured." }; + const birthdays = db.prepare( + "SELECT b.*, p.internal_username FROM birthday_profiles b JOIN user_profiles p ON p.id = b.user_id WHERE " + + effectiveDates.map(() => "(b.month = ? AND b.day = ?)").join(" OR ") + ).all(...effectiveDates.flatMap((date) => [date.month, date.day])); + for (const birthday of birthdays) { + const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`; + const announceReserved = reserveDelivery(db, birthday.user_id, deliveryKey, "announcement", channelResult.valid ? "sent" : "skipped", channelResult.message); + if (!announceReserved) { + continue; + } + let gift = null; + if (config.gift_mode === "automatic") { + gift = grantGiftOnce(db, birthday.user_id, deliveryKey, "automatic"); + } + if (!channelResult.valid || !channelResult.channel) { + continue; + } + const message = buildBirthdayMessage({ db, config, birthday, today, gift }); + await channelResult.channel.send({ content: message, allowedMentions: { parse: [] } }); + } +} + +function reserveDelivery(db, userId, deliveryKey, type, status, details) { + try { + db.prepare( + "INSERT INTO birthday_deliveries (id, user_id, delivery_key, delivery_type, status, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), userId, deliveryKey, type, status, details || null, Date.now()); + return true; + } catch { + return false; + } +} + +function grantGiftOnce(db, userId, deliveryKey, mode) { + const config = getConfig(db); + const amount = config.gift_amount; + const framework = global.lumiFrameworks?.echonomy; + if (!amount || !framework || typeof framework.addBalance !== "function") { + return null; + } + if (!reserveDelivery(db, userId, deliveryKey, "gift", mode === "manual" ? "claimed" : "sent", "Gift reserved.")) { + return null; + } + try { + framework.addBalance({ + userId, + amount, + note: "Birthday Gift", + meta: { source: "birthday", deliveryKey, mode }, + allowFrozen: false + }); + return { amount, currencyName: getCurrencyName(framework) }; + } catch (error) { + db.prepare("UPDATE birthday_deliveries SET status = 'failed', details = ? WHERE user_id = ? AND delivery_key = ? AND delivery_type = 'gift'") + .run(error?.message || String(error), userId, deliveryKey); + return null; + } +} + +async function claimGift(db, userId) { + const config = getConfig(db); + if (config.gift_mode !== "manual") { + return "Birthday gifts are automatic right now."; + } + if (!config.gift_amount) { + return "Birthday gifts are not configured right now."; + } + const birthday = getBirthday(db, userId); + if (!birthday) { + return "You do not have a birthday on file."; + } + const today = getZonedDateParts(config.timezone); + if (!isBirthdayToday(birthday, today, config.leap_day_policy)) { + return "Birthday gifts can only be claimed on your birthday."; + } + const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`; + const gift = grantGiftOnce(db, userId, deliveryKey, "manual"); + return gift ? `Birthday gift claimed: ${gift.amount}.` : "Birthday gift is unavailable or was already claimed."; +} + +function buildBirthdayMessage({ db, config, birthday, today, gift }) { + const profile = db.prepare("SELECT internal_username FROM user_profiles WHERE id = ?").get(birthday.user_id); + const tokens = buildTokens({ db, profile, birthday, today, gift }); + const pool = birthday.year ? config.response_templates.fullYear : config.response_templates.partialYear; + const eligible = pool.filter((item) => item.enabled && templateUsable(item.text, tokens)); + const template = eligible.length ? eligible[Math.floor(Math.random() * eligible.length)].text : "Happy birthday, {display_name}!"; + return renderTemplate(template, tokens); +} + +function buildTokens({ db, profile, birthday, today, gift }) { + const upcoming = getUpcomingBirthday(birthday, today); + const pronouns = getPronouns(db, birthday.user_id); + const ageAfter = birthday.year ? upcoming.year - birthday.year : null; + const daysUntil = daysBetween(today, upcoming); + const currencyName = gift?.currencyName || "coins"; + return { + username: profile?.internal_username || "Lumi user", + display_name: profile?.internal_username || "Lumi user", + pronoun: pronouns.value, + pronoun_subject: pronouns.subject, + pronoun_object: pronouns.object, + pronoun_possessive: pronouns.possessive, + birthday: formatBirthdayDateOnly(birthday), + birthday_day: String(birthday.day), + birthday_day_text: ordinal(birthday.day), + birthday_month: monthName(birthday.month), + time_until_birthday: daysUntil === 0 ? "today" : `${daysUntil} day(s)`, + days_until_birthday: String(daysUntil), + months_until_birthday: String(Math.floor(daysUntil / 30)), + age_before: ageAfter === null ? null : String(ageAfter - 1), + age_after: ageAfter === null ? null : String(ageAfter), + birthday_weekday: weekdayName(upcoming.year, upcoming.month, upcoming.day), + gift_amount: gift ? String(gift.amount) : null, + gift_amount_text: gift ? `${gift.amount} ${currencyName}` : null, + currency_name: gift ? currencyName : null + }; +} + +function templateUsable(text, tokens) { + for (const match of text.matchAll(/\{([^{}]+)\}/g)) { + const key = match[1].trim(); + if (!Object.prototype.hasOwnProperty.call(tokens, key) || tokens[key] === null || tokens[key] === undefined) { + return false; + } + } + return true; +} + +function renderTemplate(text, tokens) { + return text.replace(/\{([^{}]+)\}/g, (full, key) => { + const value = tokens[key.trim()]; + return value === null || value === undefined ? full : String(value); + }); +} + +function validateTemplate(text) { + if (!text) { + return "Template text is required."; + } + if (text.length > 1800) { + return "Template text is too long."; + } + for (const match of text.matchAll(/\{([^{}]+)\}/g)) { + const key = match[1].trim(); + if (!ALLOWED_PLACEHOLDERS.has(key)) { + return `Unknown placeholder: {${key}}.`; + } + } + return null; +} + +function previewTemplate(text) { + return renderTemplate(text, { + username: "lumi_user", + display_name: "Lumi User", + pronoun: "they/them", + pronoun_subject: "they", + pronoun_object: "them", + pronoun_possessive: "their", + birthday: "April 17", + birthday_day: "17", + birthday_day_text: "17th", + birthday_month: "April", + time_until_birthday: "2 months, 5 days", + days_until_birthday: "66", + months_until_birthday: "2", + age_before: "30", + age_after: "31", + birthday_weekday: "Friday", + gift_amount: "100", + gift_amount_text: "100 coins", + currency_name: "coins" + }); +} + +function getPronouns(db, userId) { + try { + const row = db.prepare("SELECT pronoun_set, subject_pronoun FROM welcome_message_pronouns WHERE user_id = ?").get(userId); + return pronounParts(row?.pronoun_set || row?.subject_pronoun); + } catch { + return pronounParts(null); + } +} + +function pronounParts(value) { + const raw = (value || "they/them").toString().toLowerCase(); + if (raw.startsWith("he/")) return { value: raw, subject: "he", object: "him", possessive: "his" }; + if (raw.startsWith("she/")) return { value: raw, subject: "she", object: "her", possessive: "her" }; + if (raw.startsWith("it/")) return { value: raw, subject: "it", object: "it", possessive: "its" }; + return { value: raw || "they/them", subject: "they", object: "them", possessive: "their" }; +} + +function getZonedDateParts(timezone) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(new Date()); + const get = (type) => parseInt(parts.find((part) => part.type === type)?.value, 10); + return { year: get("year"), month: get("month"), day: get("day") }; +} + +function getEffectiveDates(today, leapPolicy) { + const dates = [{ month: today.month, day: today.day }]; + if (!isLeapYear(today.year)) { + if (leapPolicy === "mar1" && today.month === 3 && today.day === 1) { + dates.push({ month: 2, day: 29 }); + } + if (leapPolicy !== "mar1" && today.month === 2 && today.day === 28) { + dates.push({ month: 2, day: 29 }); + } + } + return dates; +} + +function isBirthdayToday(birthday, today, leapPolicy) { + return getEffectiveDates(today, leapPolicy).some((date) => date.month === birthday.month && date.day === birthday.day); +} + +function getUpcomingBirthday(birthday, today) { + let year = today.year; + let month = birthday.month; + let day = birthday.day; + if (month === 2 && day === 29 && !isLeapYear(year)) { + day = 28; + } + if (month < today.month || (month === today.month && day < today.day)) { + year += 1; + if (birthday.month === 2 && birthday.day === 29 && !isLeapYear(year)) { + month = 2; + day = 28; + } + } + return { year, month, day }; +} + +function daysBetween(from, to) { + const start = Date.UTC(from.year, from.month - 1, from.day); + const end = Date.UTC(to.year, to.month - 1, to.day); + return Math.max(0, Math.round((end - start) / 86400000)); +} + +function formatBirthday(birthday) { + const base = `${monthName(birthday.month)} ${birthday.day}`; + return birthday.year ? `${base}, ${birthday.year}` : `${base} (year not set)`; +} + +function formatBirthdayDateOnly(birthday) { + return `${monthName(birthday.month)} ${birthday.day}`; +} + +function monthName(month) { + return new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(new Date(Date.UTC(2000, month - 1, 1))); +} + +function weekdayName(year, month, day) { + return new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: "UTC" }).format(new Date(Date.UTC(year, month - 1, day))); +} + +function ordinal(day) { + const mod10 = day % 10; + const mod100 = day % 100; + if (mod10 === 1 && mod100 !== 11) return `${day}st`; + if (mod10 === 2 && mod100 !== 12) return `${day}nd`; + if (mod10 === 3 && mod100 !== 13) return `${day}rd`; + return `${day}th`; +} + +function isLeapYear(year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function isValidTimezone(timezone) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date()); + return true; + } catch { + return false; + } +} + +function normalizePool(pool) { + return pool === "partialYear" ? "partialYear" : "fullYear"; +} + +function clampInt(value, min, max, fallback) { + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, parsed)); +} + +function pad2(value) { + return String(value).padStart(2, "0"); +} + +function getCurrencyName(framework) { + try { + const config = typeof framework.getConfig === "function" ? framework.getConfig() : null; + return config?.currency?.plural || config?.currency?.name || "coins"; + } catch { + return "coins"; + } +} + +function canModerate(user) { + return Boolean(user?.isAdmin || user?.isMod); +} + +function ensureSidebarNavItem(settings) { + if (!settings?.getSetting || !settings?.setSetting) { + return; + } + const navId = "plugins_birthday"; + 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; + } + for (const section of structure.sections) { + if (Array.isArray(section.items)) { + section.items = section.items.filter((item) => item !== navId); + } + } + let pluginsSection = structure.sections.find((section) => section.id === "plugins"); + if (!pluginsSection) { + pluginsSection = { + id: "plugins", + label: "Plugins", + icon: "blocks", + items: [] + }; + structure.sections.push(pluginsSection); + } + pluginsSection.items = Array.isArray(pluginsSection.items) ? pluginsSection.items : []; + pluginsSection.items.push(navId); + settings.setSetting("nav_structure", structure); +} + +function renderDenied(res) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); +} + +async function buildDiagnostics(discordClient, config) { + const channel = config.announcement_channel_id + ? await validateTextChannel(discordClient, config.announcement_channel_id) + : { valid: false, message: "No announcement channel configured." }; + return { + discordAvailable: Boolean(discordClient), + discordReady: Boolean(discordClient?.readyAt), + channel, + echonomyAvailable: Boolean(global.lumiFrameworks?.echonomy?.addBalance), + currentDate: getZonedDateParts(config.timezone) + }; +} + +function getTextChannels(discordClient) { + const channels = []; + if (!discordClient?.guilds?.cache) { + return channels; + } + for (const guild of discordClient.guilds.cache.values()) { + guild.channels?.cache?.forEach((channel) => { + if (isRegularTextChannel(channel)) { + channels.push({ id: channel.id, label: `${guild.name} - ${channel.name}` }); + } + }); + } + return channels.sort((a, b) => a.label.localeCompare(b.label)); +} + +async function validateTextChannel(discordClient, channelId) { + if (!discordClient) { + return { valid: false, message: "Discord client is unavailable." }; + } + const channel = discordClient.channels?.cache?.get(channelId) || + (typeof discordClient.channels?.fetch === "function" ? await discordClient.channels.fetch(channelId).catch(() => null) : null); + if (!channel) { + return { valid: false, message: "Configured channel was not found." }; + } + if (!isRegularTextChannel(channel)) { + return { valid: false, message: "Birthday channel must be a regular text channel." }; + } + return { valid: true, message: "Channel is valid.", channel }; +} + +function isRegularTextChannel(channel) { + if (!channel || channel.isThread?.()) return false; + return channel.type === "GUILD_TEXT" || channel.type === 0; +} + +function formatDateTime(value) { + return value ? new Date(value).toLocaleString() : ""; +} diff --git a/plugins/birthday/plugin.json b/plugins/birthday/plugin.json new file mode 100644 index 0000000..9ed3280 --- /dev/null +++ b/plugins/birthday/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "birthday", + "name": "Birthday", + "version": "0.1.0", + "description": "Birthday profiles, announcements, lookup commands, and optional birthday currency gifts.", + "main": "index.js" +} diff --git a/plugins/birthday/views/birthday-admin.ejs b/plugins/birthday/views/birthday-admin.ejs new file mode 100644 index 0000000..f148900 --- /dev/null +++ b/plugins/birthday/views/birthday-admin.ejs @@ -0,0 +1,187 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + +
+
+
+

Birthdays

+

Birthday announcements, profile display, and optional echonomy gifts.

+
+
+
+ + + + + + + +
Discord client<%= diagnostics.discordAvailable ? (diagnostics.discordReady ? "Ready" : "Available") : "Unavailable" %>
Announcement channel<%= diagnostics.channel.message %>
Echonomy<%= diagnostics.echonomyAvailable ? "Available" : "Unavailable" %>
Current plugin date<%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %>
+
+
+ +
+

Delivery

+
+
+ + +
+
+ + +
+
+ + +
+
+ + /> + <% if (!isAdmin) { %>

Only admins can change this value.

<% } %> +
+
+ + +
+
+ + +
+
+ + <% if (textChannels.length) { %> + + <% } else { %> + + <% } %> +

Only regular guild text channels are accepted.

+
+
+ +
+
+
+ +
+

Allowed Placeholders

+

+ <% allowedPlaceholders.forEach((name) => { %> + {<%= name %>} + <% }) %> +

+
+ +
+

Full-year birthday messages

+ <% const fullYearMessages = config.response_templates.fullYear; %> + <% if (!fullYearMessages.length) { %> +

No templates configured.

+ <% } %> + <% fullYearMessages.forEach((message) => { %> +
+ +
+ + +

Preview: <%= previewTemplate(message.text) %>

+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ <% }) %> +
+ +
+ + +
+
+ +
+
+
+ +
+

Partial-year birthday messages

+ <% const partialYearMessages = config.response_templates.partialYear; %> + <% if (!partialYearMessages.length) { %> +

No templates configured.

+ <% } %> + <% partialYearMessages.forEach((message) => { %> +
+ +
+ + +

Preview: <%= previewTemplate(message.text) %>

+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ <% }) %> +
+ +
+ + +
+
+ +
+
+
+ +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/birthday/views/profile-birthday.ejs b/plugins/birthday/views/profile-birthday.ejs new file mode 100644 index 0000000..39e1f62 --- /dev/null +++ b/plugins/birthday/views/profile-birthday.ejs @@ -0,0 +1,45 @@ +<% if (typeof profileSection !== "undefined" && profileSection) { %> + <% const birthday = getBirthday(user.id); %> +
+
+ + +

Use YYYY/MM/DD or MM/DD. Year is optional.

+
+
+ + +
+ <% if (birthday) { %> +

Stored birthday: <%= formatBirthday(birthday) %>.

+ <% } %> +
+ +
+
+ <% if (birthday) { %> +
+ +
+ <% } %> +<% } else { %> + <%- include("../../../src/web/views/partials/layout-top", { title }) %> +
+
+
+

<%= target.internal_username %>

+

Birthday profile

+
+
+ <% if (canView && birthday) { %> +

Birthday: <%= formatBirthdayDateOnly(birthday) %>

+ <% } else { %> +

This user's birthday is not visible to you.

+ <% } %> +
+ <%- include("../../../src/web/views/partials/layout-bottom") %> +<% } %> diff --git a/plugins/lumi_ai/README.md b/plugins/lumi_ai/README.md new file mode 100644 index 0000000..74743dd --- /dev/null +++ b/plugins/lumi_ai/README.md @@ -0,0 +1,97 @@ +# Lumi AI + +`lumi_ai` is a standalone Lumi plugin that manages a local `llama.cpp` inference process and adds a scoped AI Assistant to the WebUI. + +## Install and configure + +1. Place this directory at `plugins/lumi_ai/`. +2. Restart Lumi. +3. Open **Plugins -> Lumi AI** in the sidebar. +4. Download the managed runtime and a compatible model. +5. Select the model, configure visibility and instructions, then save. +6. Start the runtime and enable AI. + +The settings page is always registered as an admin-only item in the `Plugins` sidebar section. The assistant pill is injected separately above the profile footer and follows the configured admin, moderator, and user visibility controls. + +## Storage + +Every writable path is confined to `plugins/lumi_ai/data/`: + +- `config/`: settings and runtime state +- `models/`: verified GGUF models +- `runtime/`: extracted `llama.cpp` runtime +- `logs/`: runtime logs +- `metrics/`: usage and audit records +- `rag/`, `cache/`, `tmp/`: plugin-local working data + +Downloads are written to `data/tmp/`, verified against a pinned SHA-256 digest, and only then moved or extracted into their final plugin-local directory. + +## Runtime and downloads + +Models use pinned Hugging Face repository commits. The runtime uses a pinned official `ggml-org/llama.cpp` GitHub release because the llama.cpp project does not publish authoritative multi-platform runtime archives on Hugging Face. This is the only download-source exception; the archive URL, version, size, and SHA-256 are pinned in `runtime_manifest.json`. + +The runtime binds only to `127.0.0.1` on an ephemeral port. It is never exposed on `0.0.0.0`. + +Before loading a model, Lumi AI runs `llama-server --help` as a smoke test. Failed launches and exits are decoded into plugin-local diagnostics, including Windows NTSTATUS values such as `0xC0000005 / STATUS_ACCESS_VIOLATION`. The admin page provides remediation steps, raw stdout/stderr tails, model verification, and a redacted diagnostics bundle. + +The test console no longer exposes a user-editable scope label. Clearly unrelated requests are rejected deterministically, while ambiguous requests are passed to the scoped Lumi system prompt instead of being rejected by a fixed keyword list. + +## Plugin API + +Other Lumi plugins can use: + +```js +const ai = global.lumiFrameworks?.ai; +const health = await ai.health(); +const result = await ai.generate({ + message: "Summarize this Lumi event.", + user: requestingUser, + sessionId: requestSessionId, + scope: "my_plugin" +}); +``` + +Available functions: + +- `generate` +- `classify` +- `summarize` +- `route_tool` +- `health` +- `capabilities` +- `metrics_summary` +- `registerContext` +- `unregisterContext` +- `registerTool` + +AI tools must provide an owning plugin, a synchronous permission check, a fixed argument schema, and an established workflow handler. Model output cannot execute SQL, shell commands, file operations, or arbitrary URLs. + +## Tool registration + +```js +ai.registerTool({ + tool_id: "example.action", + display_name: "Example action", + description: "Runs an existing plugin workflow.", + owning_plugin: "example", + required_role: "user", + required_permission: "example.action.self", + permission_check: ({ user, arguments: args }) => canRunWorkflow(user, args), + schema: { target: "string", amount: "integer" }, + confirmation_required: true, + risk_level: "sensitive", + audit_category: "example", + workflow_handler: ({ arguments: args, user, initiated_via_ai, ai_request_id }) => + existingWorkflow({ ...args, actor: user, initiated_via_ai, ai_request_id }) +}); +``` + +## Verification + +Run: + +```powershell +node plugins/lumi_ai/tests/verify.js +``` + +The verification covers path confinement, traversal rejection, assistant role access, tool schema and permission checks, user/session confirmation ownership, expiry, action attribution, audit recording, queue limits, refusal behavior, and runtime resume persistence. diff --git a/plugins/lumi_ai/backend/ai_provider.js b/plugins/lumi_ai/backend/ai_provider.js new file mode 100644 index 0000000..dac3d1c --- /dev/null +++ b/plugins/lumi_ai/backend/ai_provider.js @@ -0,0 +1,54 @@ +const crypto=require("crypto");const {buildPrompt}=require("./prompt_builder");const {roleOf}=require("./permissions");const {parseToolCall}=require("./tool_router"); +const ROUTE_HELP=[ + {terms:["twitch","configuration"],text:"Twitch configuration is available in [Settings -> Twitch wizard](/admin/twitch-wizard)."}, + {terms:["discord","configuration"],text:"Discord configuration is available in [Settings -> Discord wizard](/admin/discord-wizard)."}, + {terms:["youtube","configuration"],text:"YouTube configuration is available in [Settings -> YouTube wizard](/admin/youtube-wizard)."}, + {terms:["plugins"],text:"Plugin management is available in [Admin -> Plugins](/admin/plugins)."} +]; +const CLEARLY_UNRELATED_PATTERNS=[ + /\b(capital|population|president|prime minister)\s+of\b/i, + /\b(weather|forecast)\s+(in|for|at)\b/i, + /\b(stock price|exchange rate|sports score|lottery)\b/i, + /\b(write|compose)\s+(a\s+)?(poem|story|song|essay)\b/i, + /\b(recipe|cook|bake)\b/i, + /\b(homework|calculus|algebra|chemistry|physics)\b/i +]; +class AiProvider{ + constructor({getConfig,runtime,queue,tools,metrics,getContext}){Object.assign(this,{getConfig,runtime,queue,tools,metrics,getContext});} + async generate({message,user,sessionId,scope="assistant",max_tokens,includeRaw=false}){ + const requestId=crypto.randomUUID(),role=roleOf(user),started=Date.now(); + if(isClearlyOutOfScope(message)){this.metrics.record({kind:"request",status:"refused",request_id:requestId,user_id:user.id,role,scope,duration_ms:Date.now()-started});return{success:false,text:this.getConfig().instructions.out_of_scope_response,refusal_reason:"out_of_scope",request_id:requestId};} + const direct=ROUTE_HELP.find(row=>row.terms.every(t=>message.toLowerCase().includes(t)));if(direct){this.metrics.record({kind:"request",status:"success",request_id:requestId,user_id:user.id,role,scope,duration_ms:Date.now()-started});return{success:true,text:direct.text,model_id:"lumi-route-help",duration_ms:Date.now()-started,queue_wait_ms:0,request_id:requestId};} + return this.queue.run(user.id,role,async(queueWait)=>{ + const cfg=this.getConfig(),prompt=buildPrompt({config:cfg,role,message,contextBlocks:this.getContext(role),tools:this.tools.list(role)}); + const result=await this.runtime.infer([{role:"system",content:prompt},{role:"user",content:message}],max_tokens||300); + const text=result.choices?.[0]?.message?.content||"";const toolCall=parseToolCall(text);let confirmation=null; + let toolResult=null; + if(toolCall){const prepared=this.tools.prepare({tool:toolCall.tool,args:toolCall.arguments,user,role,sessionId});if(prepared.execute)toolResult=await this.tools.execute({checked:prepared.checked,user,requestId});confirmation=prepared.confirmation;} + const out={success:true,text:confirmation?`Please confirm: ${confirmation.display_name}.`:toolResult?`Action completed: ${JSON.stringify(toolResult)}`:text,raw_response:cfg.logging.log_responses||includeRaw?result:null,tool_call:toolCall,tool_result:toolResult,confirmation,model_id:cfg.selected_model_id,duration_ms:Date.now()-started,queue_wait_ms:queueWait,finish_reason:result.choices?.[0]?.finish_reason||null,request_id:requestId}; + this.metrics.record({kind:"request",status:"success",request_id:requestId,user_id:user.id,role,scope,model:cfg.selected_model_id,duration_ms:out.duration_ms,queue_wait_ms:queueWait,tool_requested:toolCall?.tool||null,tool_executed:false});return out; + }); + } + async classify({message,labels,user}){const result=await this.generate({message:`Classify this Lumi-related request into exactly one label: ${labels.join(", ")}. Request: ${message}`,user,scope:"classify",max_tokens:40});return{...result,label:labels.find(l=>result.text.toLowerCase().includes(l.toLowerCase()))||null};} + async summarize({text,max_length=500,user}){return this.generate({message:`Summarize this Lumi-related content in at most ${max_length} characters:\n${text}`,user,scope:"summarize",max_tokens:Math.ceil(max_length/3)});} + async test({message,user,max_tokens=300,includeRaw=false}){ + const requestId=crypto.randomUUID(),role=roleOf(user),started=Date.now(); + return this.queue.run(user.id,role,async(queueWait)=>{ + const cfg=this.getConfig(); + const prompt=[ + "You are running an administrator-requested local model diagnostic.", + "Answer the exact user message directly and concisely.", + "Do not call tools, perform actions, claim access to Lumi data, or follow requests to execute code, files, SQL, shell commands, or URLs.", + `Maximum answer length: ${cfg.instructions.maximum_answer_length || 700} characters.` + ].join("\n"); + const result=await this.runtime.infer([{role:"system",content:prompt},{role:"user",content:message}],max_tokens); + const text=result.choices?.[0]?.message?.content||""; + const output={success:true,text,raw_response:includeRaw?result:null,raw_prompt:prompt,tool_call:null,tool_result:null,confirmation:null,model_id:cfg.selected_model_id,duration_ms:Date.now()-started,queue_wait_ms:queueWait,finish_reason:result.choices?.[0]?.finish_reason||null,request_id:requestId}; + this.metrics.record({kind:"request",status:"success",request_id:requestId,user_id:user.id,role,scope:"model_test",model:cfg.selected_model_id,duration_ms:output.duration_ms,queue_wait_ms:queueWait}); + return output; + }); + } +} +function isClearlyOutOfScope(message){const value=(message||"").trim();return value.length>0&&CLEARLY_UNRELATED_PATTERNS.some(pattern=>pattern.test(value));} +function isInScope(message){return !isClearlyOutOfScope(message);} +module.exports={AiProvider,isInScope,isClearlyOutOfScope}; diff --git a/plugins/lumi_ai/backend/config_manager.js b/plugins/lumi_ai/backend/config_manager.js new file mode 100644 index 0000000..b3e6256 --- /dev/null +++ b/plugins/lumi_ai/backend/config_manager.js @@ -0,0 +1,68 @@ +const fs = require("fs"); +const { resolveData, ensureDataDirs } = require("./paths"); + +const DEFAULT_CONFIG = { + enabled: false, + selected_model_id: "qwen3-1.7b-q4", + context_size: 4096, + threads: 0, + concurrency: 1, + max_queue_length: 8, + request_timeout_ms: 120000, + per_user_requests_per_minute: 6, + admin_bypass_rate_limit: false, + assistant_visibility: { admins: true, mods: false, users: false }, + instructions: { + identity: "You are Lumi Assistant, a concise assistant for this Lumi bot and community.", + style: "Be brief, factual, and provide internal WebUI links when known.", + allowed_topics: "Lumi, its WebUI, plugins, community systems, streams, and videos.", + out_of_scope_response: "I am sorry, but that is outside my scope.", + maximum_answer_length: 700, + roleplay_intensity: 0, + community_tone: "", + admin_custom: "" + }, + logging: { + log_prompts: false, + log_responses: false, + log_tool_calls: true, + log_metrics: true, + log_internal_audit: true + } +}; + +function readJson(name, fallback) { + ensureDataDirs(); + const file = resolveData("config", name); + if (!fs.existsSync(file)) { + writeJson(name, fallback); + return structuredClone(fallback); + } + try { return { ...structuredClone(fallback), ...JSON.parse(fs.readFileSync(file, "utf8")) }; } + catch { return structuredClone(fallback); } +} +function writeJson(name, value) { + const file = resolveData("config", name); + const tmp = `${file}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); + fs.renameSync(tmp, file); +} +function getConfig() { return readJson("ai_config.json", DEFAULT_CONFIG); } +function saveConfig(value) { + const merged = { ...DEFAULT_CONFIG, ...value }; + merged.assistant_visibility = { ...DEFAULT_CONFIG.assistant_visibility, ...(value.assistant_visibility || {}) }; + merged.instructions = { ...DEFAULT_CONFIG.instructions, ...(value.instructions || {}) }; + merged.logging = { ...DEFAULT_CONFIG.logging, ...(value.logging || {}) }; + writeJson("ai_config.json", merged); + return merged; +} +function getRuntimeState() { + return readJson("runtime_state.json", { + desired_state: "stopped", last_known_state: "stopped", last_stop_reason: "never_started", + last_manual_stop: true, last_crashed: false, last_exit_code: null, + last_diagnostic_category: null, selected_model_id: null, updated_at: new Date().toISOString() + }); +} +function saveRuntimeState(value) { writeJson("runtime_state.json", { ...value, updated_at: new Date().toISOString() }); } + +module.exports = { DEFAULT_CONFIG, getConfig, saveConfig, getRuntimeState, saveRuntimeState, readJson, writeJson }; diff --git a/plugins/lumi_ai/backend/diagnostics.js b/plugins/lumi_ai/backend/diagnostics.js new file mode 100644 index 0000000..884243f --- /dev/null +++ b/plugins/lumi_ai/backend/diagnostics.js @@ -0,0 +1,58 @@ +const fs = require("fs"); +const path = require("path"); +const AdmZip = require("adm-zip"); +const { resolveData, ensureDataDirs } = require("./paths"); + +function redact(value) { + if (Array.isArray(value)) return value.map(redact); + if (!value || typeof value !== "object") return value; + const output = {}; + for (const [key, item] of Object.entries(value)) { + output[key] = /token|secret|password|cookie|authorization|session/i.test(key) ? "[REDACTED]" : redact(item); + } + return output; +} +function createDiagnostic(input) { + return redact({ + timestamp: new Date().toISOString(), + severity: "error", + can_retry: true, + requires_admin_action: true, + should_auto_resume: false, + platform: process.platform, + ...input + }); +} +function persistDiagnostic(input) { + ensureDataDirs(); + const diagnostic = createDiagnostic(input); + fs.writeFileSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`); + fs.appendFileSync(resolveData("diagnostics", "runtime_diagnostics.jsonl"), `${JSON.stringify(diagnostic)}\n`); + return diagnostic; +} +function getLatestDiagnostic() { + try { return JSON.parse(fs.readFileSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), "utf8")); } + catch { return null; } +} +function createDiagnosticsBundle({ config, runtimeState, manifest, metrics }) { + ensureDataDirs(); + const destination = resolveData("diagnostics", `lumi-ai-diagnostics-${Date.now()}.zip`); + const zip = new AdmZip(); + const addJson = (name, value) => zip.addFile(name, Buffer.from(`${JSON.stringify(redact(value), null, 2)}\n`)); + addJson("config.json", config); + addJson("runtime_state.json", runtimeState); + addJson("latest_runtime_diagnostic.json", getLatestDiagnostic()); + addJson("runtime_manifest.json", manifest); + addJson("metrics_summary.json", metrics); + for (const name of ["runtime-selftest.log"]) { + const file = resolveData("logs", name); + if (fs.existsSync(file)) zip.addLocalFile(file, "logs"); + } + const logs = fs.readdirSync(resolveData("logs")).filter((name) => name.startsWith("runtime-")).sort().slice(-2); + for (const name of logs) zip.addLocalFile(resolveData("logs", name), "logs"); + zip.writeZip(destination); + return destination; +} +function tail(value, length = 4000) { return String(value || "").slice(-length); } + +module.exports = { redact, createDiagnostic, persistDiagnostic, getLatestDiagnostic, createDiagnosticsBundle, tail }; diff --git a/plugins/lumi_ai/backend/downloader.js b/plugins/lumi_ai/backend/downloader.js new file mode 100644 index 0000000..187e387 --- /dev/null +++ b/plugins/lumi_ai/backend/downloader.js @@ -0,0 +1,103 @@ +const fs = require("fs"); +const crypto = require("crypto"); +const path = require("path"); +const { spawn } = require("child_process"); +const AdmZip = require("adm-zip"); +const { resolveData } = require("./paths"); + +class DownloadManager { + constructor(onEvent){ this.jobs=new Map(); this.onEvent=onEvent; } + status(id){ return this.jobs.get(id)||null; } + start({id,url,filename,sha256,kind,archive=false,size=0}){ + if(this.jobs.get(id)?.state==="downloading") throw new Error("Download already running."); + if(size&&freeDiskBytes(){const classified=classifyError(error);job.state="error";job.error=classified.message;job.error_category=classified.category;this.onEvent?.({kind:"download",status:"failed",download_id:id,error:job.error,category:classified.category});}); + return job; + } + async download({job,url,filename,sha256,kind,archive}){ + job.state="downloading"; + const tmp=resolveData("tmp",`${filename}.part`), finalDir=resolveData(kind==="model"?"models":"runtime"); + const existing=fs.existsSync(tmp)?fs.statSync(tmp).size:0; + const headers=existing?{Range:`bytes=${existing}-`}:{}; + const response=await fetch(url,{headers}); if(!response.ok && response.status!==206) throw new Error(`source unavailable (${response.status})`); + const resumed=existing>0&&response.status===206; + const total=Number(response.headers.get("content-length")||0)+(resumed?existing:0); job.total=total; job.downloaded=resumed?existing:0; + const stream=fs.createWriteStream(tmp,{flags:resumed?"a":"w"}); + for await(const chunk of response.body){ if(!stream.write(chunk)) await new Promise(r=>stream.once("drain",r)); job.downloaded+=chunk.length; } + await new Promise((resolve,reject)=>stream.end(error=>error?reject(error):resolve())); + job.state="verifying"; const actual=await hashFile(tmp); if(actual!==sha256.toLowerCase()){fs.unlinkSync(tmp);throw new Error("hash mismatch");} + if(archive){ + job.state="extracting"; + const staging=resolveData("tmp",`runtime-extract-${Date.now()}`); + fs.mkdirSync(staging,{recursive:true}); + try{ + await extractArchive(tmp,staging,filename); + await makeRuntimeExecutable(staging); + const executable=findRuntimeExecutable(staging); + if(!executable)throw new Error("runtime executable missing after extraction"); + for(const entry of fs.readdirSync(finalDir))fs.rmSync(path.join(finalDir,entry),{recursive:true,force:true}); + for(const entry of fs.readdirSync(staging))fs.renameSync(path.join(staging,entry),path.join(finalDir,entry)); + fs.unlinkSync(tmp); + job.executable=findRuntimeExecutable(finalDir); + }finally{ + fs.rmSync(staging,{recursive:true,force:true}); + } + } + else { const final=path.join(finalDir,filename); if(fs.existsSync(final))fs.unlinkSync(final); fs.renameSync(tmp,final); } + job.state="complete";job.finished_at=Date.now();job.sha256=actual;this.onEvent?.({kind:"download",status:"success",download_id:job.id,sha256:actual,duration_ms:job.finished_at-job.started_at}); + } +} +async function makeRuntimeExecutable(dir){ + if(process.platform==="win32")return; + for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ + const target=path.join(dir,entry.name); + if(entry.isDirectory())await makeRuntimeExecutable(target); + else if(entry.name==="llama-server")fs.chmodSync(target,0o755); + } +} +function findRuntimeExecutable(dir){ + const name=process.platform==="win32"?"llama-server.exe":"llama-server"; + for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ + const target=path.join(dir,entry.name); + if(entry.isFile()&&entry.name===name)return target; + if(entry.isDirectory()){const found=findRuntimeExecutable(target);if(found)return found;} + } + return null; +} +async function hashFile(file){const hash=crypto.createHash("sha256");for await(const chunk of fs.createReadStream(file))hash.update(chunk);return hash.digest("hex");} +async function extractArchive(file,dest,name){ + if(name.endsWith(".zip")){ + const zip=new AdmZip(file); + for(const entry of zip.getEntries())validateArchivePath(entry.entryName); + zip.extractAllTo(dest,true); + return; + } + const entries=await capture("tar",["-tzf",file]); + if(entries.code!==0)throw new Error(`archive corrupt (${entries.code})`); + for(const entry of entries.stdout.split(/\r?\n/).filter(Boolean))validateArchivePath(entry); + await new Promise((resolve,reject)=>{const child=spawn("tar",["-xzf",file,"-C",dest],{windowsHide:true,shell:false});child.on("exit",c=>c===0?resolve():reject(new Error(`archive extraction failed (${c})`)));child.on("error",reject);}); +} +function validateArchivePath(entry){ + const normalized=path.posix.normalize(String(entry).replace(/\\/g,"/")); + if(path.posix.isAbsolute(normalized)||normalized===".."||normalized.startsWith("../"))throw new Error("archive path traversal"); +} +function capture(command,args){return new Promise((resolve,reject)=>{const child=spawn(command,args,{windowsHide:true,shell:false});let stdout="",stderr="";child.stdout.on("data",c=>stdout+=c);child.stderr.on("data",c=>stderr+=c);child.on("error",reject);child.on("exit",code=>resolve({code,stdout,stderr}));});} +function classifyError(error){ + const message=error?.message||String(error); + if(/ENOSPC|not enough disk/i.test(message))return{category:"disk_full",message:"Not enough disk space."}; + if(/EACCES|EPERM|permission denied/i.test(message))return{category:"permission_denied",message:"Permission denied."}; + if(/hash mismatch/i.test(message))return{category:"hash_mismatch",message:"Downloaded file failed SHA-256 verification."}; + if(/archive path traversal/i.test(message))return{category:"archive_path_traversal",message:"Archive contains an unsafe path."}; + if(/archive corrupt|extraction failed/i.test(message))return{category:"archive_corrupt",message}; + if(/\(404\)/.test(message))return{category:"http_404",message:"Download source was not found (404)."}; + if(/\(403\)/.test(message))return{category:"http_403",message:"Download source denied access (403)."}; + if(/\(429\)/.test(message))return{category:"http_429",message:"Download source rate limit reached (429)."}; + if(/\(5\d\d\)/.test(message))return{category:"server_error",message}; + if(/timeout|abort/i.test(message))return{category:"timeout",message:"Download timed out."}; + if(/fetch|network|ENOTFOUND|EAI_AGAIN/i.test(message))return{category:"network_unavailable",message}; + if(/runtime executable missing/i.test(message))return{category:"install_validation_failed",message}; + return{category:"download_failed",message}; +} +function freeDiskBytes(){try{const stat=fs.statfsSync(resolveData("tmp"));return Number(stat.bavail)*Number(stat.bsize);}catch{return Number.MAX_SAFE_INTEGER;}} +module.exports={DownloadManager,hashFile,validateArchivePath,classifyError}; diff --git a/plugins/lumi_ai/backend/error_codes.js b/plugins/lumi_ai/backend/error_codes.js new file mode 100644 index 0000000..7424c9f --- /dev/null +++ b/plugins/lumi_ai/backend/error_codes.js @@ -0,0 +1,74 @@ +const WINDOWS_STATUS = { + 0xC0000005: ["STATUS_ACCESS_VIOLATION", "runtime_crash", ["Reinstall the runtime.", "Install or update Microsoft Visual C++ Redistributable x64.", "Unblock downloaded runtime files.", "Try a local disk path or alternate runtime build."]], + 0xC000001D: ["STATUS_ILLEGAL_INSTRUCTION", "cpu_incompatible", ["Use a baseline or more compatible CPU runtime build.", "Verify CPU AVX/AVX2 support."]], + 0xC0000135: ["STATUS_DLL_NOT_FOUND", "missing_dependency", ["Reinstall the runtime.", "Verify sibling DLL files.", "Install or update Microsoft Visual C++ Redistributable x64."]], + 0xC0000139: ["STATUS_ENTRYPOINT_NOT_FOUND", "dependency_mismatch", ["Delete and reinstall the complete runtime folder.", "Do not mix DLLs from different releases."]], + 0xC000007B: ["STATUS_INVALID_IMAGE_FORMAT", "architecture_mismatch", ["Download the runtime matching the detected OS and architecture.", "Reinstall and verify the archive hash."]], + 0xC0000142: ["STATUS_DLL_INIT_FAILED", "dependency_initialization_failed", ["Unblock runtime files.", "Install or update Microsoft Visual C++ Redistributable x64.", "Review security software restrictions."]], + 0xC0000409: ["STATUS_STACK_BUFFER_OVERRUN", "runtime_crash", ["Reinstall the runtime.", "Try an alternate runtime build.", "Inspect the captured runtime logs."]], + 0xC0000374: ["STATUS_HEAP_CORRUPTION", "runtime_crash", ["Reinstall the runtime.", "Try an alternate build.", "Reduce context size if failure occurs after model load."]] +}; +const WINDOWS_LAUNCH = { + 2: ["ERROR_FILE_NOT_FOUND", "executable_missing"], + 3: ["ERROR_PATH_NOT_FOUND", "path_missing"], + 5: ["ERROR_ACCESS_DENIED", "permission_denied"], + 126: ["ERROR_MOD_NOT_FOUND", "missing_dependency"], + 127: ["ERROR_PROC_NOT_FOUND", "dependency_mismatch"], + 193: ["ERROR_BAD_EXE_FORMAT", "architecture_mismatch"], + 206: ["ERROR_FILENAME_EXCED_RANGE", "path_too_long"], + 740: ["ERROR_ELEVATION_REQUIRED", "permission_denied"], + 1114: ["ERROR_DLL_INIT_FAILED", "dependency_initialization_failed"], + 1455: ["ERROR_COMMITMENT_LIMIT", "insufficient_memory"] +}; +const POSIX = { + 11: ["SIGSEGV", "runtime_crash"], + 4: ["SIGILL", "cpu_incompatible"], + 6: ["SIGABRT", "runtime_abort"], + 9: ["SIGKILL", "killed_or_oom"], + 15: ["SIGTERM", "terminated"] +}; + +function normalizeExitCode(code, signal, platform = process.platform) { + if (platform === "win32" && Number.isInteger(code)) { + const unsigned = code >>> 0; + const signed = unsigned | 0; + const known = WINDOWS_STATUS[unsigned]; + return { + raw_exit_code: code, + signed_exit_code: signed, + unsigned_exit_code: unsigned, + hex_exit_code: `0x${unsigned.toString(16).toUpperCase().padStart(8, "0")}`, + code: known?.[0] || "WINDOWS_PROCESS_EXIT", + category: known?.[1] || "runtime_exit", + remediation_steps: known?.[2] || ["Inspect runtime stdout and stderr.", "Reinstall or try an alternate runtime build."] + }; + } + const signalNumber = typeof signal === "string" ? require("os").constants.signals[signal] : null; + const number = signalNumber || (Number.isInteger(code) && code >= 128 ? code - 128 : Number.isInteger(code) && code < 0 ? -code : null); + const known = number ? POSIX[number] : null; + return { + raw_exit_code: code, + signed_exit_code: code, + unsigned_exit_code: Number.isInteger(code) ? code >>> 0 : null, + hex_exit_code: Number.isInteger(code) ? `0x${(code >>> 0).toString(16).toUpperCase().padStart(8, "0")}` : null, + code: known?.[0] || signal || "PROCESS_EXIT", + category: known?.[1] || "runtime_exit", + remediation_steps: known ? ["Inspect runtime logs.", "Verify runtime compatibility and model settings."] : ["Inspect runtime stdout and stderr."] + }; +} + +function classifyLaunchError(error, platform = process.platform) { + const numeric = Number(error?.errno); + const known = platform === "win32" ? WINDOWS_LAUNCH[numeric] : null; + return { + raw_exit_code: numeric || null, + signed_exit_code: numeric || null, + unsigned_exit_code: Number.isInteger(numeric) ? numeric >>> 0 : null, + hex_exit_code: Number.isInteger(numeric) ? `0x${(numeric >>> 0).toString(16).toUpperCase().padStart(8, "0")}` : null, + code: known?.[0] || error?.code || "PROCESS_LAUNCH_FAILED", + category: known?.[1] || (/EACCES|EPERM/.test(error?.code) ? "permission_denied" : /ENOENT/.test(error?.code) ? "executable_missing" : "launch_failed"), + remediation_steps: ["Verify the executable and working directory.", "Reinstall the runtime.", "Check file permissions and security software."] + }; +} + +module.exports = { normalizeExitCode, classifyLaunchError }; diff --git a/plugins/lumi_ai/backend/hardware.js b/plugins/lumi_ai/backend/hardware.js new file mode 100644 index 0000000..e48bfc6 --- /dev/null +++ b/plugins/lumi_ai/backend/hardware.js @@ -0,0 +1,47 @@ +const os = require("os"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const { PLUGIN_DATA, PLUGIN_ROOT } = require("./paths"); + +function detectHardware(models) { + const freeDisk = getFreeDisk(); + const totalRamMb = Math.floor(os.totalmem() / 1048576); + const availableRamMb = Math.floor(os.freemem() / 1048576); + const gpu = detectGpu(); + const writable = testWritable(); + const recommendation = [...models] + .filter((model) => model.ram_gb * 1024 <= totalRamMb && model.size / 1048576 <= freeDisk) + .sort((a, b) => b.ram_gb - a.ram_gb)[0]?.tier || "tiny"; + return { + platform: os.platform(), architecture: os.arch(), cpu_threads: os.cpus().length, + total_ram_mb: totalRamMb, available_ram_mb: availableRamMb, free_disk_mb: freeDisk, + gpu, subprocess_allowed: true, plugin_writable: writable, recommended_tier: recommendation, + plugin_path: PLUGIN_ROOT, path_length: PLUGIN_ROOT.length, + long_path_warning: os.platform()==="win32" && PLUGIN_ROOT.length > 220, + network_path_warning: os.platform()==="win32" && PLUGIN_ROOT.startsWith("\\\\") + }; +} +function getFreeDisk() { + try { + if (typeof fs.statfsSync === "function") { + const stat = fs.statfsSync(PLUGIN_DATA); + return Math.floor((Number(stat.bavail) * Number(stat.bsize)) / 1048576); + } + } catch {} + return 0; +} +function detectGpu() { + try { + const result = spawnSync("nvidia-smi", ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], { encoding: "utf8", timeout: 3000 }); + if (result.status === 0 && result.stdout.trim()) { + const [name, vram] = result.stdout.trim().split(",").map((v) => v.trim()); + return { present: true, name, vram_mb: Number(vram) || null }; + } + } catch {} + return { present: false, name: null, vram_mb: null }; +} +function testWritable() { + try { const file = require("path").join(PLUGIN_DATA, ".write-test"); fs.writeFileSync(file, "ok"); fs.unlinkSync(file); return true; } + catch { return false; } +} +module.exports = { detectHardware }; diff --git a/plugins/lumi_ai/backend/metrics.js b/plugins/lumi_ai/backend/metrics.js new file mode 100644 index 0000000..8837fe9 --- /dev/null +++ b/plugins/lumi_ai/backend/metrics.js @@ -0,0 +1,55 @@ +const fs = require("fs"); +const { resolveData } = require("./paths"); + +const historyFile = () => resolveData("metrics", "history.jsonl"); +const stateFile = () => resolveData("metrics", "summary.json"); +function getSummary() { + try { return JSON.parse(fs.readFileSync(stateFile(), "utf8")); } + catch { return { total_requests:0, successful:0, failed:0, refusals:0, tool_suggestions:0, tool_executions:0, tool_denials:0, confirmation_cancellations:0, timeout_count:0, runtime_crash_count:0, runtime_self_test_total:0, runtime_self_test_failed_total:0, runtime_start_attempt_total:0, runtime_start_failed_total:0, verified_downloads:0, failed_downloads:0, requests_by_role:{}, requests_by_scope:{}, runtime_exit_code_counts:{}, durations:[], queue_wait_total_ms:0 }; } +} +function record(entry) { + const summary = getSummary(); + summary.requests_by_role ||= {}; + summary.requests_by_scope ||= {}; + if (entry.kind === "request") { + summary.total_requests += 1; + if (entry.status === "success") summary.successful += 1; + if (entry.status === "failed") summary.failed += 1; + if (entry.status === "refused") summary.refusals += 1; + if (entry.role) summary.requests_by_role[entry.role] = (summary.requests_by_role[entry.role] || 0) + 1; + if (entry.scope) summary.requests_by_scope[entry.scope] = (summary.requests_by_scope[entry.scope] || 0) + 1; + } + if (entry.tool_requested) summary.tool_suggestions += 1; + if (entry.tool_executed) summary.tool_executions += 1; + if (entry.kind === "tool" && entry.status === "failed") summary.tool_denials += 1; + if (entry.kind === "tool" && entry.status === "cancelled") summary.confirmation_cancellations += 1; + if (entry.timeout) summary.timeout_count += 1; + if (entry.runtime_crash) summary.runtime_crash_count += 1; + if (entry.kind === "runtime_self_test") { + summary.runtime_self_test_total += 1; + if (entry.status === "failed") summary.runtime_self_test_failed_total += 1; + } + if (entry.kind === "runtime_start") { + if (entry.status === "attempt") summary.runtime_start_attempt_total += 1; + if (entry.status === "failed") summary.runtime_start_failed_total += 1; + } + if (entry.code) { + summary.runtime_exit_code_counts ||= {}; + summary.runtime_exit_code_counts[entry.code] = (summary.runtime_exit_code_counts[entry.code] || 0) + 1; + } + if (entry.kind === "download" && entry.status === "success") summary.verified_downloads += 1; + if (entry.kind === "download" && entry.status === "failed") summary.failed_downloads += 1; + if (entry.duration_ms != null) summary.durations.push(entry.duration_ms); + summary.durations = summary.durations.slice(-500); + if (entry.queue_wait_ms) summary.queue_wait_total_ms += entry.queue_wait_ms; + fs.writeFileSync(stateFile(), JSON.stringify(summary, null, 2)); + fs.appendFileSync(historyFile(), `${JSON.stringify({ timestamp:new Date().toISOString(), ...entry })}\n`); +} +function report() { + const s = getSummary(); const sorted=[...s.durations].sort((a,b)=>a-b); + return { ...s, average_response_ms: sorted.length ? Math.round(sorted.reduce((a,b)=>a+b,0)/sorted.length) : 0, median_response_ms: sorted.length ? sorted[Math.floor(sorted.length/2)] : 0 }; +} +function history(limit=100) { + try { return fs.readFileSync(historyFile(),"utf8").trim().split(/\r?\n/).filter(Boolean).slice(-limit).reverse().map(JSON.parse); } catch { return []; } +} +module.exports = { record, report, history }; diff --git a/plugins/lumi_ai/backend/paths.js b/plugins/lumi_ai/backend/paths.js new file mode 100644 index 0000000..17c0778 --- /dev/null +++ b/plugins/lumi_ai/backend/paths.js @@ -0,0 +1,20 @@ +const path = require("path"); +const fs = require("fs"); + +const PLUGIN_ROOT = path.resolve(__dirname, ".."); +const PLUGIN_DATA = path.join(PLUGIN_ROOT, "data"); +const DIRS = ["config", "models", "runtime", "logs", "metrics", "rag", "cache", "tmp", "diagnostics"]; + +function ensureDataDirs() { + for (const dir of DIRS) fs.mkdirSync(path.join(PLUGIN_DATA, dir), { recursive: true }); +} + +function resolveData(...parts) { + const target = path.resolve(PLUGIN_DATA, ...parts); + if (target !== PLUGIN_DATA && !target.startsWith(`${PLUGIN_DATA}${path.sep}`)) { + throw new Error("Path escapes Lumi AI plugin storage."); + } + return target; +} + +module.exports = { PLUGIN_ROOT, PLUGIN_DATA, ensureDataDirs, resolveData }; diff --git a/plugins/lumi_ai/backend/permissions.js b/plugins/lumi_ai/backend/permissions.js new file mode 100644 index 0000000..b5c8c75 --- /dev/null +++ b/plugins/lumi_ai/backend/permissions.js @@ -0,0 +1,10 @@ +function roleOf(user) { return user?.isAdmin ? "admin" : user?.isMod ? "mod" : user?.id ? "user" : "anonymous"; } +function canUse(user, config) { + const role = roleOf(user); + if (role === "anonymous") return false; + return role === "admin" ? config.assistant_visibility.admins : role === "mod" ? config.assistant_visibility.mods : config.assistant_visibility.users; +} +function roleAllows(actual, required) { + const rank={anonymous:0,user:1,mod:2,admin:3}; return rank[actual] >= rank[required || "user"]; +} +module.exports = { roleOf, canUse, roleAllows }; diff --git a/plugins/lumi_ai/backend/prompt_builder.js b/plugins/lumi_ai/backend/prompt_builder.js new file mode 100644 index 0000000..404c17d --- /dev/null +++ b/plugins/lumi_ai/backend/prompt_builder.js @@ -0,0 +1,21 @@ +const fs = require("fs"); +const path = require("path"); +const { PLUGIN_ROOT } = require("./paths"); + +function readTemplate(name){ return fs.readFileSync(path.join(PLUGIN_ROOT,"templates",name),"utf8").trim(); } +function buildPrompt({ config, role, message, contextBlocks=[], tools=[] }) { + const sections=[ + readTemplate("system.txt"), + config.instructions.identity, + `ALLOWED TOPICS:\n${config.instructions.allowed_topics}`, + `REQUESTING ROLE: ${role}\n${readTemplate(`role_${role}.txt`)}`, + `RESPONSE STYLE:\n${config.instructions.style}\nMaximum answer length: ${config.instructions.maximum_answer_length} characters.\nRoleplay intensity: ${config.instructions.roleplay_intensity || 0}/10.`, + config.instructions.community_tone ? `COMMUNITY TONE:\n${config.instructions.community_tone}` : "", + `ADMIN CUSTOM INSTRUCTIONS (cannot override hard rules):\n${config.instructions.admin_custom || "(none)"}`, + `SAFE LUMI CONTEXT:\n${contextBlocks.join("\n\n") || "(none)"}`, + `ALLOWED TOOLS:\n${tools.map(t=>JSON.stringify({tool_id:t.tool_id,description:t.description,schema:t.schema})).join("\n") || "(none)"}`, + `USER MESSAGE:\n${message}` + ]; + return sections.filter(Boolean).join("\n\n---\n\n"); +} +module.exports = { buildPrompt }; diff --git a/plugins/lumi_ai/backend/queue_manager.js b/plugins/lumi_ai/backend/queue_manager.js new file mode 100644 index 0000000..78e9d24 --- /dev/null +++ b/plugins/lumi_ai/backend/queue_manager.js @@ -0,0 +1,24 @@ +class RequestQueue { + constructor(getConfig) { this.getConfig=getConfig; this.active=0; this.pending=[]; this.rate=new Map(); } + get length(){ return this.pending.length; } + async run(userId, role, fn) { + const cfg=this.getConfig(); this.checkRate(userId,role,cfg); + if(this.pending.length >= cfg.max_queue_length) throw Object.assign(new Error("AI is busy right now. Try again in a moment."),{code:"QUEUE_FULL"}); + const queuedAt=Date.now(); + return new Promise((resolve,reject)=>{ this.pending.push({fn,resolve,reject,queuedAt}); this.drain(); }); + } + checkRate(userId,role,cfg) { + if(role==="admin" && cfg.admin_bypass_rate_limit) return; + const now=Date.now(), key=`${role}:${userId}`, rows=(this.rate.get(key)||[]).filter(t=>now-t<60000); + if(rows.length >= cfg.per_user_requests_per_minute) throw Object.assign(new Error("AI rate limit reached. Try again shortly."),{code:"RATE_LIMIT"}); + rows.push(now); this.rate.set(key,rows); + } + drain(){ + const limit=Math.max(1,Number(this.getConfig().concurrency)||1); + while(this.activejob.fn(Date.now()-job.queuedAt)).then(job.resolve,job.reject).finally(()=>{this.active--;this.drain();}); + } + } +} +module.exports = { RequestQueue }; diff --git a/plugins/lumi_ai/backend/runtime_manager.js b/plugins/lumi_ai/backend/runtime_manager.js new file mode 100644 index 0000000..50fd4b9 --- /dev/null +++ b/plugins/lumi_ai/backend/runtime_manager.js @@ -0,0 +1,290 @@ +const fs = require("fs"); +const path = require("path"); +const net = require("net"); +const os = require("os"); +const crypto = require("crypto"); +const { spawn } = require("child_process"); +const { resolveData } = require("./paths"); +const { getRuntimeState, saveRuntimeState } = require("./config_manager"); +const { normalizeExitCode, classifyLaunchError } = require("./error_codes"); +const { persistDiagnostic, getLatestDiagnostic, tail } = require("./diagnostics"); + +class RuntimeManager { + constructor({ getConfig, getModel, runtimeManifest, onCrash, onDiagnostic }) { + Object.assign(this, { getConfig, getModel, runtimeManifest, onCrash, onDiagnostic }); + this.child = null; + this.port = null; + this.startedAt = null; + this.lastError = null; + this.lastSelfTest = null; + } + findBinary() { + return findRecursive(resolveData("runtime"), process.platform === "win32" ? "llama-server.exe" : "llama-server"); + } + modelPath() { + const model = this.getModel(this.getConfig().selected_model_id); + return model ? resolveData("models", model.filename) : null; + } + status() { + const binary = this.findBinary(); + const model = this.modelPath(); + return { + state: this.child && !this.child.killed ? "running" : this.lastError ? "error" : "stopped", + runtime_installed: Boolean(binary), + runtime_usable: this.lastSelfTest?.success ?? null, + model_downloaded: Boolean(model && fs.existsSync(model)), + port: this.port, + pid: this.child?.pid || null, + uptime_ms: this.startedAt ? Date.now() - this.startedAt : 0, + last_error: this.lastError, + last_self_test: this.lastSelfTest, + executable_path: binary, + working_directory: binary ? path.dirname(binary) : null, + model_path: model, + latest_diagnostic: getLatestDiagnostic() + }; + } + async selfTest() { + const binary = this.findBinary(); + if (!binary) return this.failDiagnostic("executable_missing", "RUNTIME_MISSING", "Runtime executable was not found.", { remediation_steps: ["Download or reinstall the managed runtime."] }); + const installation = this.verifyRuntimeInstallation(); + if (!installation.success) return this.failDiagnostic(installation.category, "INSTALL_VALIDATION_FAILED", installation.message, installation); + const result = await runCaptured(binary, ["--help"], path.dirname(binary), 10000); + fs.writeFileSync(resolveData("logs", "runtime-selftest.log"), `${result.stdout}\n${result.stderr}`.trim()); + if (result.error) { + const decoded = classifyLaunchError(result.error); + return this.failDiagnostic(decoded.category, decoded.code, result.error.message, { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: ["--help"], stdout_tail: tail(result.stdout), stderr_tail: tail(result.stderr) }); + } + if (result.timedOut) return this.failDiagnostic("self_test_timeout", "SELF_TEST_TIMEOUT", "Runtime self-test exceeded 10 seconds.", { executable_path: binary, working_directory: path.dirname(binary), command_args: ["--help"], stdout_tail: tail(result.stdout), stderr_tail: tail(result.stderr) }); + if (result.code !== 0 || !/llama|usage|server|options/i.test(`${result.stdout}\n${result.stderr}`)) { + const decoded = normalizeExitCode(result.code, result.signal); + return this.failDiagnostic(decoded.category, decoded.code, "Runtime self-test failed.", { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: ["--help"], stdout_tail: tail(result.stdout), stderr_tail: tail(result.stderr) }); + } + this.lastSelfTest = { success: true, timestamp: new Date().toISOString(), executable_path: binary, code: result.code }; + this.lastError = null; + this.onDiagnostic?.({ kind: "runtime_self_test", status: "success" }); + return this.lastSelfTest; + } + verifyRuntimeInstallation() { + const binary = this.findBinary(); + if (!binary) return { success: false, category: "executable_missing", message: "Runtime executable was not found." }; + const runtimeDir = resolveData("runtime"); + const size = folderSize(runtimeDir); + if (size < 1024 * 1024) return { success: false, category: "incomplete_extraction", message: "Extracted runtime folder is unexpectedly small.", executable_path: binary, runtime_folder_size: size }; + if (process.platform !== "win32") { + try { fs.accessSync(binary, fs.constants.X_OK); } catch { return { success: false, category: "permission_denied", message: "Runtime executable bit is not set.", executable_path: binary, runtime_folder_size: size }; } + } + if (process.platform === "win32") { + const dlls = findFiles(runtimeDir, (name) => name.toLowerCase().endsWith(".dll")); + if (!dlls.length) return { success: false, category: "missing_dependency", message: "No runtime DLL files were found after extraction.", executable_path: binary, runtime_folder_size: size }; + return { success: true, executable_path: binary, runtime_folder_size: size, dll_count: dlls.length }; + } + return { success: true, executable_path: binary, runtime_folder_size: size }; + } + async verifyModel() { + const model = this.getModel(this.getConfig().selected_model_id); + const file = this.modelPath(); + if (!model || !file || !fs.existsSync(file)) return { success: false, category: "model_missing", message: "Selected model file is missing." }; + const stat = fs.statSync(file); + if (stat.size !== model.size) return { success: false, category: "model_size_mismatch", message: `Expected ${model.size} bytes, found ${stat.size}.` }; + const header = Buffer.alloc(4); + const descriptor = fs.openSync(file, "r"); + try { fs.readSync(descriptor, header, 0, 4, 0); } finally { fs.closeSync(descriptor); } + if (header.toString("ascii") !== "GGUF") return { success: false, category: "model_invalid", message: "Selected file does not have a GGUF header." }; + const sha256 = await hashFile(file); + if (sha256 !== model.sha256) return { success: false, category: "model_hash_mismatch", message: "Selected model SHA-256 does not match the manifest.", sha256 }; + return { success: true, file, size: stat.size, sha256 }; + } + async start({ resume = false } = {}) { + if (this.child && !this.child.killed) return this.status(); + this.onDiagnostic?.({ kind: "runtime_start", status: "attempt" }); + const selfTest = await this.selfTest(); + if (!selfTest.success) { + this.onDiagnostic?.({ kind: "runtime_start", status: "failed", category: selfTest.category }); + throw new Error(selfTest.message || "Runtime self-test failed."); + } + const modelValidation = await this.verifyModel(); + if (!modelValidation.success) { + const diagnostic = this.failDiagnostic(modelValidation.category, "MODEL_VALIDATION_FAILED", modelValidation.message, { model_path: this.modelPath() }); + saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "error", last_stop_reason: modelValidation.category, last_manual_stop: false, last_crashed: false, last_diagnostic_category: modelValidation.category }); + throw new Error(diagnostic.message); + } + const binary = this.findBinary(); + const model = this.modelPath(); + this.port = await freePort(); + const cfg = this.getConfig(); + const threads = Number(cfg.threads) > 0 ? Number(cfg.threads) : os.cpus().length; + const args = ["--host", "127.0.0.1", "--port", String(this.port), "-m", model, "-c", String(cfg.context_size || 4096), "-t", String(threads)]; + const logPath = resolveData("logs", `runtime-${Date.now()}.log`); + const log = fs.openSync(logPath, "a"); + const child = spawn(binary, args, { cwd: path.dirname(binary), stdio: ["ignore", log, log], windowsHide: true, shell: false }); + fs.closeSync(log); + this.child = child; + this.startedAt = Date.now(); + this.lastError = null; + child.once("error", (error) => { + child.__spawnFailed = true; + const decoded = classifyLaunchError(error); + this.failDiagnostic(decoded.category, decoded.code, error.message, { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: args, model_path: model }); + if (this.child === child) this.child = null; + this.persistCrash(decoded.category, error.message, decoded.signed_exit_code); + }); + child.once("exit", (code, signal) => { + const expected = child.__manualStop || child.__spawnFailed; + if (this.child === child) this.child = null; + if (!expected) { + const decoded = normalizeExitCode(code, signal); + const diagnostic = this.failDiagnostic(decoded.category, decoded.code, `Runtime exited before or after health readiness.`, { ...decoded, executable_path: binary, working_directory: path.dirname(binary), command_args: args, model_path: model }); + this.persistCrash(decoded.category, diagnostic.message, decoded.signed_exit_code); + } + }); + saveRuntimeState({ ...getRuntimeState(), desired_state: "running", last_known_state: "starting", last_crashed: false, last_manual_stop: false, last_stop_reason: resume ? "resuming" : "starting", selected_model_id: cfg.selected_model_id }); + try { + await waitHealth(this, 45000); + saveRuntimeState({ ...getRuntimeState(), desired_state: "running", last_known_state: "running", last_crashed: false, last_manual_stop: false, last_stop_reason: resume ? "resumed" : "started", selected_model_id: cfg.selected_model_id }); + this.onDiagnostic?.({ kind: "runtime_start", status: "success", model_load_ms: Date.now() - this.startedAt }); + return this.status(); + } catch (error) { + if (this.child) await this.stop({ manual: false, reason: "health_timeout" }); + const existing = getLatestDiagnostic(); + const preserveProcessExit = error.category === "process_exited_before_health" && existing?.raw_exit_code != null; + if (!preserveProcessExit) { + this.failDiagnostic(error.category || "health_timeout", "RUNTIME_HEALTH_FAILED", error.message, { executable_path: binary, working_directory: path.dirname(binary), command_args: args, model_path: model }); + saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "error", last_stop_reason: error.category || "health_timeout", last_manual_stop: false, last_crashed: false, last_diagnostic_category: error.category || "health_timeout" }); + } else { + error.message = `${existing.code}: ${existing.message}`; + } + this.onDiagnostic?.({ kind: "runtime_start", status: "failed", category: error.category || "health_timeout" }); + throw error; + } + } + failDiagnostic(category, code, message, extra = {}) { + this.lastError = message; + this.lastSelfTest = category.startsWith("self_test") || code === "RUNTIME_MISSING" || extra.command_args?.[0] === "--help" ? { success: false, category, code, message } : this.lastSelfTest; + const diagnostic = persistDiagnostic({ category, code, message, ...extra }); + if (extra.command_args?.[0] === "--help" || code === "RUNTIME_MISSING" || category === "self_test_timeout") { + saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "error", last_stop_reason: "self_test_failed", last_manual_stop: false, last_crashed: false, last_diagnostic_category: category, last_exit_code: extra.signed_exit_code ?? null }); + this.onDiagnostic?.({ kind: "runtime_self_test", status: "failed", category, code }); + } + this.onDiagnostic?.({ kind: "runtime_diagnostic", status: "failed", category, code }); + return { success: false, ...diagnostic }; + } + persistCrash(category, message, exitCode) { + saveRuntimeState({ ...getRuntimeState(), desired_state: "stopped", last_known_state: "crashed", last_crashed: true, last_stop_reason: "runtime_crash", last_manual_stop: false, last_exit_code: exitCode ?? null, last_diagnostic_category: category }); + this.onCrash?.(message); + } + async stop({ manual = true, reason = "manual_stop" } = {}) { + const wasRunning = Boolean(this.child && !this.child.killed); + if (this.child) { + const child = this.child; + child.__manualStop = true; + child.kill(); + await waitExit(child, 10000); + if (this.child === child && !child.killed) child.kill("SIGKILL"); + } + this.child = null; + this.startedAt = null; + const resumeAfterShutdown = !manual && reason === "bot_shutdown" && wasRunning; + saveRuntimeState({ ...getRuntimeState(), desired_state: resumeAfterShutdown ? "running" : "stopped", last_known_state: "stopped", last_stop_reason: reason, last_manual_stop: manual, last_crashed: false }); + return this.status(); + } + async restart() { await this.stop({ manual: false, reason: "restart" }); return this.start(); } + async health() { + const status = this.status(); + if (status.state !== "running") return { ...status, healthy: false }; + try { + const response = await fetch(`http://127.0.0.1:${this.port}/health`, { signal: AbortSignal.timeout(2000) }); + if (!response.ok) return { ...status, healthy: false, health_status: "http_error", health_http_status: response.status }; + try { + const body = await response.json(); + return { ...status, healthy: true, health_status: "ready", health_response: body }; + } catch { + return { ...status, healthy: false, health_status: "invalid_json" }; + } + } catch (error) { + return { ...status, healthy: false, health_status: error.name === "TimeoutError" ? "connection_timeout" : "connection_refused" }; + } + } + async infer(messages, maxTokens = 300) { + if (!this.port) throw new Error("Runtime is offline."); + const response = await fetch(`http://127.0.0.1:${this.port}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "local", messages, max_tokens: maxTokens, temperature: 0.2 }), signal: AbortSignal.timeout(this.getConfig().request_timeout_ms || 120000) }); + if (!response.ok) throw new Error(`Inference failed (${response.status})`); + return response.json(); + } +} + +function findRecursive(dir, name) { + if (!fs.existsSync(dir)) return null; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const target = path.join(dir, entry.name); + if (entry.isFile() && entry.name === name) return target; + if (entry.isDirectory()) { const found = findRecursive(target, name); if (found) return found; } + } + return null; +} +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); + server.on("error", reject); + }); +} +function runCaptured(executable, args, cwd, timeoutMs) { + return new Promise((resolve) => { + const child = spawn(executable, args, { cwd, windowsHide: true, shell: false }); + let stdout = "", stderr = "", settled = false, timedOut = false, timer; + const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ stdout, stderr, timedOut, ...result }); }; + child.stdout.on("data", (chunk) => { stdout = tail(stdout + chunk, 12000); }); + child.stderr.on("data", (chunk) => { stderr = tail(stderr + chunk, 12000); }); + child.once("error", (error) => finish({ error })); + child.once("exit", (code, signal) => finish({ code, signal })); + timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs); + }); +} +async function waitHealth(manager, timeout) { + const end = Date.now() + timeout; + let lastCategory = "connection_refused"; + while (Date.now() < end) { + if (!manager.child) throw Object.assign(new Error("Runtime process exited before health became ready."), { category: "process_exited_before_health" }); + try { + const response = await fetch(`http://127.0.0.1:${manager.port}/health`, { signal: AbortSignal.timeout(2000) }); + if (!response.ok) lastCategory = "http_error"; + else { + try { await response.json(); return; } + catch { lastCategory = "invalid_json"; } + } + } catch (error) { + lastCategory = error.name === "TimeoutError" ? "connection_timeout" : "connection_refused"; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw Object.assign(new Error(`Runtime process remained alive but health did not become ready within 45 seconds (${lastCategory}).`), { category: lastCategory === "connection_refused" ? "model_load_timeout" : lastCategory }); +} +function waitExit(child, timeout) { + return new Promise((resolve) => { + if (child.exitCode != null) return resolve(); + const timer = setTimeout(resolve, timeout); + child.once("exit", () => { clearTimeout(timer); resolve(); }); + }); +} +async function hashFile(file) { + const hash = crypto.createHash("sha256"); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + return hash.digest("hex"); +} +function folderSize(dir) { + if (!fs.existsSync(dir)) return 0; + return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => { + const target = path.join(dir, entry.name); + return total + (entry.isDirectory() ? folderSize(target) : entry.isFile() ? fs.statSync(target).size : 0); + }, 0); +} +function findFiles(dir, predicate) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(dir, entry.name); + return entry.isDirectory() ? findFiles(target, predicate) : entry.isFile() && predicate(entry.name) ? [target] : []; + }); +} + +module.exports = { RuntimeManager, runCaptured }; diff --git a/plugins/lumi_ai/backend/tool_router.js b/plugins/lumi_ai/backend/tool_router.js new file mode 100644 index 0000000..2f39992 --- /dev/null +++ b/plugins/lumi_ai/backend/tool_router.js @@ -0,0 +1,43 @@ +const crypto = require("crypto"); +const { roleAllows } = require("./permissions"); + +class ToolRegistry { + constructor(audit){ this.tools=new Map(); this.confirmations=new Map(); this.audit=audit; } + register(def){ + if(!def?.tool_id || !def.display_name || !def.description || !def.owning_plugin || !def.required_permission || !def.audit_category || typeof def.workflow_handler!=="function" || typeof def.permission_check!=="function" || !def.schema) throw new Error("Invalid AI tool definition."); + this.tools.set(def.tool_id,{required_role:"user",confirmation_required:true,risk_level:"sensitive",...def}); + } + list(role){ return [...this.tools.values()].filter(t=>roleAllows(role,t.required_role)).map(({workflow_handler,permission_check,...t})=>t); } + validate(tool,args,role){ + const def=this.tools.get(tool); if(!def) throw new Error("Tool is not registered."); + if(!roleAllows(role,def.required_role)) throw new Error("Permission denied for this tool."); + const schema=def.schema||{}; const clean={}; + for(const [key,type] of Object.entries(schema)){ const value=args?.[key]; if(type==="integer" && !Number.isInteger(Number(value))) throw new Error(`${key} must be an integer.`); if(type==="string" && typeof value!=="string") throw new Error(`${key} must be a string.`); clean[key]=type==="integer"?Number(value):value; } + return {def,args:clean}; + } + prepare({tool,args,user,role,sessionId}){ + const checked=this.validate(tool,args,role); + const allowed=checked.def.permission_check({user,arguments:checked.args,required_permission:checked.def.required_permission}); + if(allowed && typeof allowed.then==="function")throw new Error("AI tool permission checks must be synchronous."); + if(!allowed)throw new Error("The requesting user does not have permission for this action."); + if(!checked.def.confirmation_required) return {execute:true,checked}; + const id=crypto.randomUUID(); this.confirmations.set(id,{id,userId:user.id,sessionId,expiresAt:Date.now()+120000,...checked}); + return {execute:false,confirmation:{id,display_name:checked.def.display_name,arguments:checked.args,expires_at:Date.now()+120000}}; + } + async execute({checked,user,requestId}){ + const result=await checked.def.workflow_handler({arguments:checked.args,user,initiated_via_ai:true,ai_request_id:requestId}); + this.audit({kind:"tool",status:"success",user_id:user.id,tool_requested:checked.def.tool_id,tool_executed:true}); + return result; + } + async confirm({id,user,sessionId}){ + const pending=this.confirmations.get(id); this.confirmations.delete(id); + if(!pending || pending.expiresAt modelManifest.models.find((model) => model.id === id); + const downloads = new DownloadManager((entry) => metrics.record(entry)); + const queue = new RequestQueue(() => config); + const tools = new ToolRegistry((entry) => metrics.record(entry)); + const contextProviders = new Map(); + const getSafeContext = (role) => [...contextProviders.values()].flatMap((fn) => { + try { return normalizeContext(fn({ role })); } catch { return []; } + }); + const runtime = new RuntimeManager({ + getConfig: () => config, + getModel, + runtimeManifest, + onCrash: (message) => metrics.record({ kind: "runtime", status: "failed", runtime_crash: true, message }), + onDiagnostic: (entry) => metrics.record(entry) + }); + const provider = new AiProvider({ + getConfig: () => config, + runtime, + queue, + tools, + metrics, + getContext: getSafeContext + }); + + const api = { + health: () => runtime.health(), + capabilities: () => ({ + provider: "local_llama_cpp", + enabled: config.enabled, + model_id: config.selected_model_id, + roles: config.assistant_visibility, + tools: tools.list("admin").map((tool) => tool.tool_id) + }), + metrics_summary: () => metrics.report(), + generate: (input) => provider.generate(input), + classify: (input) => provider.classify(input), + summarize: (input) => provider.summarize(input), + route_tool: async ({ message, allowed_tools = [], ...input }) => { + const result = await provider.generate({ message, ...input, scope: "route_tool" }); + if (result.tool_call && !allowed_tools.includes(result.tool_call.tool)) { + return { ...result, success: false, tool_call: null, refusal_reason: "tool_not_allowed" }; + } + return result; + }, + registerTool: (definition) => tools.register(definition), + registerContext: (id, factory) => { + if (!id || typeof factory !== "function") throw new Error("Invalid AI context provider."); + contextProviders.set(id, factory); + }, + unregisterContext: (id) => contextProviders.delete(id) + }; + global.lumiFrameworks = global.lumiFrameworks || {}; + global.lumiFrameworks.ai = api; + global.lumiFrameworks.lumi_ai = api; + + const router = web.createRouter(); + router.use("/assets", express.static(path.join(__dirname, "public"))); + + router.get("/", async (req, res) => { + if (!req.session.user?.isAdmin) return denied(res); + const hardware = detectHardware(modelManifest.models); + const runtimeTarget = getRuntimeTarget(); + res.render(path.join(__dirname, "views", "settings.ejs"), { + title: "Lumi AI", + config, + models: modelManifest.models.map((model) => ({ + ...model, + downloaded: fs.existsSync(resolveData("models", model.filename)), + compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb + })), + runtimeTarget, + runtimeManifest, + runtimeStatus: await runtime.health(), + runtimeState: getRuntimeState(), + latestDiagnostic: getLatestDiagnostic(), + runtimeFolderSize: folderSize(resolveData("runtime")), + modelFileSize: modelFileSize(getModel(config.selected_model_id)), + hardware, + metrics: metrics.report(), + history: metrics.history(25), + logFiles: listLogFiles(), + formatBytes, + formatDuration + }); + }); + + router.post("/settings", (req, res) => { + if (!req.session.user?.isAdmin) return denied(res); + const model = getModel(req.body.selected_model_id); + if (!model) return flash(req, res, "error", "Unknown model."); + config = saveConfig({ + ...config, + enabled: req.body.enabled === "on", + selected_model_id: model.id, + context_size: boundedInt(req.body.context_size, 512, 131072, 4096), + threads: boundedInt(req.body.threads, 0, 256, 0), + concurrency: boundedInt(req.body.concurrency, 1, 8, 1), + max_queue_length: boundedInt(req.body.max_queue_length, 1, 100, 8), + request_timeout_ms: boundedInt(req.body.request_timeout_ms, 5000, 600000, 120000), + per_user_requests_per_minute: boundedInt(req.body.per_user_requests_per_minute, 1, 120, 6), + admin_bypass_rate_limit: req.body.admin_bypass_rate_limit === "on", + assistant_visibility: { + admins: req.body.visibility_admins === "on", + mods: req.body.visibility_mods === "on", + users: req.body.visibility_users === "on" + }, + instructions: { + identity: cleanText(req.body.identity, 1000), + style: cleanText(req.body.style, 1000), + allowed_topics: cleanText(req.body.allowed_topics, 2000), + out_of_scope_response: cleanText(req.body.out_of_scope_response, 1000), + maximum_answer_length: boundedInt(req.body.maximum_answer_length, 100, 4000, 700), + roleplay_intensity: boundedInt(req.body.roleplay_intensity, 0, 10, 0), + community_tone: cleanText(req.body.community_tone, 2000), + admin_custom: cleanText(req.body.admin_custom, 6000) + }, + logging: { + log_prompts: req.body.log_prompts === "on", + log_responses: req.body.log_responses === "on", + log_tool_calls: req.body.log_tool_calls === "on", + log_metrics: req.body.log_metrics === "on", + log_internal_audit: req.body.log_internal_audit === "on" + } + }); + return flash(req, res, "success", "Lumi AI settings saved."); + }); + + router.post("/download/runtime", (req, res) => { + if (!req.session.user?.isAdmin) return denied(res); + const target = getRuntimeTarget(); + if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform."); + try { + downloads.start({ id: "runtime", ...target, kind: "runtime", archive: true }); + return flash(req, res, "success", "Runtime download started."); + } catch (error) { + return flash(req, res, "error", error.message); + } + }); + + router.post("/download/model/:id", (req, res) => { + if (!req.session.user?.isAdmin) return denied(res); + const model = getModel(req.params.id); + if (!model) return flash(req, res, "error", "Unknown model."); + const hardware = detectHardware(modelManifest.models); + const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb; + if (incompatible && req.body.override_compatibility !== "on") { + return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway."); + } + try { + downloads.start({ + id: `model:${model.id}`, + url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`, + filename: model.filename, + sha256: model.sha256, + size: model.size, + kind: "model" + }); + return flash(req, res, "success", `${model.label} download started.`); + } catch (error) { + return flash(req, res, "error", error.message); + } + }); + + router.get("/api/status", async (req, res) => { + if (!canUse(req.session.user, config) && !req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); + res.json({ runtime: await runtime.health(), queue_length: queue.length, enabled: config.enabled, model_id: config.selected_model_id }); + }); + router.get("/api/downloads", (req, res) => { + if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); + res.json(Object.fromEntries(downloads.jobs)); + }); + router.post("/runtime/:action", async (req, res) => { + if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); + try { + const action = req.params.action; + if (!["start", "stop", "restart", "self-test", "verify-runtime", "verify-model"].includes(action)) throw new Error("Unknown runtime action."); + const result = action === "self-test" ? await runtime.selfTest() + : action === "verify-runtime" ? runtime.verifyRuntimeInstallation() + : action === "verify-model" ? await runtime.verifyModel() + : action === "stop" + ? await runtime.stop({ manual: true, reason: "admin_stop" }) + : action === "restart" ? await runtime.restart() : await runtime.start(); + if (result?.success === false) return res.status(400).json({ error: result.message, diagnostic: result }); + res.json(result); + } catch (error) { + res.status(400).json({ error: error.message }); + } + }); + router.get("/diagnostics/download", (req, res) => { + if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); + const file = createDiagnosticsBundle({ + config, + runtimeState: getRuntimeState(), + manifest: { runtime: runtimeManifest, model: getModel(config.selected_model_id) }, + metrics: metrics.report() + }); + return res.download(file); + }); + router.post("/assistant/message", async (req, res) => { + if (!config.enabled || !canUse(req.session.user, config)) return res.status(403).json({ error: "Lumi AI is unavailable for this account." }); + const message = cleanText(req.body.message, 6000); + if (!message) return res.status(400).json({ error: "Message is required." }); + try { + res.json(await provider.generate({ message, user: req.session.user, sessionId: req.sessionID })); + } catch (error) { + metrics.record({ kind: "request", status: "failed", user_id: req.session.user.id, role: roleOf(req.session.user), message: error.message }); + res.status(error.code === "QUEUE_FULL" || error.code === "RATE_LIMIT" ? 429 : 503).json({ error: error.message }); + } + }); + router.post("/assistant/test", async (req, res) => { + if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); + const message = cleanText(req.body.message, 6000); + if (!message) return res.status(400).json({ error: "Message is required." }); + const simulatedRole = ["admin", "mod", "user"].includes(req.body.role) ? req.body.role : "admin"; + const simulatedUser = { + id: req.session.user.id, + username: req.session.user.username, + isAdmin: simulatedRole === "admin", + isMod: simulatedRole === "mod" + }; + try { + const result = await provider.test({ + message, + user: simulatedUser, + includeRaw: Boolean(req.body.show_raw_output) + }); + if (!req.body.show_raw_prompt) delete result.raw_prompt; + res.json(result); + } catch (error) { + res.status(503).json({ error: error.message }); + } + }); + router.post("/assistant/confirm", async (req, res) => { + if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." }); + try { res.json({ success: true, result: await tools.confirm({ id: req.body.id, user: req.session.user, sessionId: req.sessionID }) }); } + catch (error) { res.status(400).json({ error: error.message }); } + }); + router.post("/assistant/cancel", (req, res) => { + if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." }); + const cancelled = tools.cancel(req.body.id, req.session.user.id); + metrics.record({ kind: "tool", status: cancelled ? "cancelled" : "failed", user_id: req.session.user.id }); + res.json({ success: cancelled }); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Lumi AI", + role: "admin", + section: "plugins" + }); + if (typeof web.addAssistantPanel === "function") { + web.addAssistantPanel({ + id: PLUGIN_ID, + role: "user", + isVisible: (user) => config.enabled && canUse(user, config), + view: path.join(__dirname, "views", "assistant-panel.ejs"), + stylesheet: `/plugins/${PLUGIN_ID}/assets/assistant.css`, + script: `/plugins/${PLUGIN_ID}/assets/assistant.js`, + locals: { endpoint: `/plugins/${PLUGIN_ID}` } + }); + } else { + console.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible."); + } + ensureSidebarNavItem(settings); + + const state = getRuntimeState(); + if (shouldAutoResume(config, state)) { + setImmediate(() => runtime.start({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error))); + } + + return async () => { + await runtime.stop({ manual: false, reason: "bot_shutdown" }); + if (global.lumiFrameworks?.ai === api) delete global.lumiFrameworks.ai; + if (global.lumiFrameworks?.lumi_ai === api) delete global.lumiFrameworks.lumi_ai; + }; + } +}; + +function getRuntimeTarget() { + return runtimeManifest.targets[`${process.platform}-${process.arch}`] || null; +} +function normalizeContext(value) { + if (Array.isArray(value)) return value.filter((item) => typeof item === "string"); + return typeof value === "string" ? [value] : []; +} +function boundedInt(value, min, max, fallback) { + const number = Number.parseInt(value, 10); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} +function cleanText(value, max) { + return String(value || "").trim().slice(0, max); +} +function flash(req, res, type, message) { + req.session.flash = { type, message }; + return res.redirect(`/plugins/${PLUGIN_ID}`); +} +function denied(res) { + return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." }); +} +function formatBytes(bytes) { + if (!bytes) return "0 B"; + const units = ["B", "MB", "GB", "TB"]; + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`; +} +function formatDuration(ms) { + if (!ms) return "0 ms"; + return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; +} +function listLogFiles() { + const dir = resolveData("logs"); + return fs.readdirSync(dir).filter((name) => name.endsWith(".log")).sort().reverse().slice(0, 10).map((name) => ({ name, size: fs.statSync(path.join(dir, name)).size })); +} +function folderSize(dir) { + if (!fs.existsSync(dir)) return 0; + return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => { + const target = path.join(dir, entry.name); + return total + (entry.isDirectory() ? folderSize(target) : entry.isFile() ? fs.statSync(target).size : 0); + }, 0); +} +function modelFileSize(model) { + if (!model) return 0; + const file = resolveData("models", model.filename); + return fs.existsSync(file) ? fs.statSync(file).size : 0; +} +function ensureSidebarNavItem(settings) { + if (!settings?.getSetting || !settings?.setSetting) return; + const raw = settings.getSetting("nav_structure", null); + if (!raw) return; + let structure = raw; + if (typeof structure === "string") { + try { structure = JSON.parse(structure); } catch { return; } + } + if (!structure?.enabled || !Array.isArray(structure.sections)) return; + const navId = "plugins_lumi_ai"; + for (const section of structure.sections) { + if (Array.isArray(section.items)) section.items = section.items.filter((item) => item !== navId); + } + let plugins = structure.sections.find((section) => section.id === "plugins"); + if (!plugins) { + plugins = { id: "plugins", label: "Plugins", icon: "blocks", items: [] }; + structure.sections.push(plugins); + } + plugins.items = Array.isArray(plugins.items) ? plugins.items : []; + plugins.items.push(navId); + settings.setSetting("nav_structure", structure); +} + +function shouldAutoResume(config, state) { + return Boolean(config.enabled && state.desired_state === "running" && !state.last_manual_stop && !state.last_crashed); +} + +module.exports.shouldAutoResume = shouldAutoResume; diff --git a/plugins/lumi_ai/models_manifest.json b/plugins/lumi_ai/models_manifest.json new file mode 100644 index 0000000..d92bc78 --- /dev/null +++ b/plugins/lumi_ai/models_manifest.json @@ -0,0 +1,207 @@ +{ + "models": [ + { + "id": "smollm2-360m-q8", + "label": "Tiny - SmolLM2 360M Q8", + "display_name": "SmolLM2 360M Instruct", + "model_family": "SmolLM2", + "parameter_count": "360M", + "repo_id": "HuggingFaceTB/SmolLM2-360M-Instruct-GGUF", + "license": "apache-2.0", + "format": "GGUF", + "quantization": "Q8_0", + "min_ram_mb": 1024, + "recommended_ram_mb": 2048, + "min_disk_mb": 400, + "recommended_threads": 2, + "default_context": 2048, + "max_context": 8192, + "supports_chat": true, + "supports_json": false, + "supports_tool_routing": false, + "recommended_use": "Basic intent routing and very short responses.", + "warnings": "Not recommended for complex assistant behavior.", + "repo": "HuggingFaceTB/SmolLM2-360M-Instruct-GGUF", + "revision": "593b5a2e04c8f3e4ee880263f93e0bd2901ad47f", + "filename": "smollm2-360m-instruct-q8_0.gguf", + "size": 386404992, + "sha256": "48ab3034d0dd401fbc721eb1df3217902fee7dab9078992d66431f09b7750201", + "ram_gb": 2, + "tier": "tiny" + }, + { + "id": "qwen3-0.6b-q4", + "label": "Small - Qwen3 0.6B Q4_K_M", + "display_name": "Qwen3 0.6B Instruct", + "model_family": "Qwen3", + "parameter_count": "0.6B", + "repo_id": "bartowski/Qwen_Qwen3-0.6B-GGUF", + "license": "apache-2.0", + "format": "GGUF", + "quantization": "Q4_K_M", + "min_ram_mb": 1536, + "recommended_ram_mb": 3072, + "min_disk_mb": 500, + "recommended_threads": 4, + "default_context": 4096, + "max_context": 32768, + "supports_chat": true, + "supports_json": true, + "supports_tool_routing": false, + "recommended_use": "Basic scoped assistant for weak servers.", + "warnings": "Limited reasoning and tool-routing reliability.", + "repo": "bartowski/Qwen_Qwen3-0.6B-GGUF", + "revision": "60b85c0e3d8fe0f6474f406922a26d12aca4550d", + "filename": "Qwen_Qwen3-0.6B-Q4_K_M.gguf", + "size": 484220320, + "sha256": "9acfc1e001311f34b4252001b626f2e466d592a42065f66571bff3790d4e1b14", + "ram_gb": 3, + "tier": "small" + }, + { + "id": "qwen3-1.7b-q4", + "label": "Medium - Qwen3 1.7B Q4_K_M", + "display_name": "Qwen3 1.7B Instruct", + "model_family": "Qwen3", + "parameter_count": "1.7B", + "repo_id": "bartowski/Qwen_Qwen3-1.7B-GGUF", + "license": "apache-2.0", + "format": "GGUF", + "quantization": "Q4_K_M", + "min_ram_mb": 3072, + "recommended_ram_mb": 5120, + "min_disk_mb": 1300, + "recommended_threads": 6, + "default_context": 4096, + "max_context": 32768, + "supports_chat": true, + "supports_json": true, + "supports_tool_routing": true, + "recommended_use": "Recommended minimum for useful bot assistant behavior.", + "warnings": "CPU response speed depends heavily on host memory bandwidth.", + "repo": "bartowski/Qwen_Qwen3-1.7B-GGUF", + "revision": "dcb19155b962dbb6389f4691a982043a8e651022", + "filename": "Qwen_Qwen3-1.7B-Q4_K_M.gguf", + "size": 1282439584, + "sha256": "72c5c3cb38fa32d5256e2fe30d03e7a64c6c79e668ad84057e3bd66e250b24fb", + "ram_gb": 5, + "tier": "medium" + }, + { + "id": "qwen3-4b-q4", + "label": "Large - Qwen3 4B Q4_K_M", + "display_name": "Qwen3 4B Instruct", + "model_family": "Qwen3", + "parameter_count": "4B", + "repo_id": "bartowski/Qwen_Qwen3-4B-GGUF", + "license": "apache-2.0", + "format": "GGUF", + "quantization": "Q4_K_M", + "min_ram_mb": 5120, + "recommended_ram_mb": 8192, + "min_disk_mb": 2500, + "recommended_threads": 8, + "default_context": 4096, + "max_context": 32768, + "supports_chat": true, + "supports_json": true, + "supports_tool_routing": true, + "recommended_use": "Better style following, tool routing, and reasoning.", + "warnings": "May be slow on CPU-only systems.", + "repo": "bartowski/Qwen_Qwen3-4B-GGUF", + "revision": "cb76885dc66d50759b207c5a48c4e78dfa00c638", + "filename": "Qwen_Qwen3-4B-Q4_K_M.gguf", + "size": 2497280960, + "sha256": "fbe1d5edd4ce802ae3ae7c7e4ab7d09789d697fdac1fc7929f8df4ca3c41bae3", + "ram_gb": 8, + "tier": "large" + }, + { + "id": "qwen3-8b-q4", + "label": "General - Qwen3 8B Q4_K_M", + "display_name": "Qwen3 8B Instruct", + "model_family": "Qwen3", + "parameter_count": "8B", + "repo_id": "bartowski/Qwen_Qwen3-8B-GGUF", + "license": "apache-2.0", + "format": "GGUF", + "quantization": "Q4_K_M", + "min_ram_mb": 8192, + "recommended_ram_mb": 12288, + "min_disk_mb": 5100, + "recommended_threads": 10, + "default_context": 4096, + "max_context": 32768, + "supports_chat": true, + "supports_json": true, + "supports_tool_routing": true, + "recommended_use": "More capable general assistant.", + "warnings": "Requires decent RAM and patience on CPU.", + "repo": "bartowski/Qwen_Qwen3-8B-GGUF", + "revision": "0b69f75b7472688e6808490aa2b85efdb81b5ce7", + "filename": "Qwen_Qwen3-8B-Q4_K_M.gguf", + "size": 5027784224, + "sha256": "54fffa050078e984116639c83dfb64b5aa6d4cd474e018b076777c632bbccccd", + "ram_gb": 12, + "tier": "general" + }, + { + "id": "qwen3-14b-q4", + "label": "GPU - Qwen3 14B Q4_K_M", + "display_name": "Qwen3 14B Instruct", + "model_family": "Qwen3", + "parameter_count": "14B", + "repo_id": "bartowski/Qwen_Qwen3-14B-GGUF", + "license": "apache-2.0", + "format": "GGUF", + "quantization": "Q4_K_M", + "min_ram_mb": 14336, + "recommended_ram_mb": 20480, + "min_disk_mb": 9000, + "recommended_threads": 12, + "default_context": 4096, + "max_context": 32768, + "supports_chat": true, + "supports_json": true, + "supports_tool_routing": true, + "recommended_use": "Serious local assistant tier.", + "warnings": "GPU strongly recommended.", + "repo": "bartowski/Qwen_Qwen3-14B-GGUF", + "revision": "bd080f768a6401c2d5a7fa53a2e50cd8218a9ce2", + "filename": "Qwen_Qwen3-14B-Q4_K_M.gguf", + "size": 9001753632, + "sha256": "915913e22399475dbe6c968ac014d9f1fbe08975e489279aede9d5c7b2c98eb6", + "ram_gb": 20, + "tier": "gpu" + }, + { + "id": "qwen3-30b-a3b-q4", + "label": "GPU XL - Qwen3 30B-A3B Q4_K_M", + "display_name": "Qwen3 30B-A3B Instruct", + "model_family": "Qwen3 MoE", + "parameter_count": "30B total / 3B active", + "repo_id": "bartowski/Qwen_Qwen3-30B-A3B-GGUF", + "license": "apache-2.0", + "format": "GGUF", + "quantization": "Q4_K_M", + "min_ram_mb": 24576, + "recommended_ram_mb": 32768, + "min_disk_mb": 18700, + "recommended_threads": 16, + "default_context": 4096, + "max_context": 32768, + "supports_chat": true, + "supports_json": true, + "supports_tool_routing": true, + "recommended_use": "Experimental high-end assistant tier.", + "warnings": "Requires strong hardware and substantial disk space.", + "repo": "bartowski/Qwen_Qwen3-30B-A3B-GGUF", + "revision": "46f17e079cba70b04390bef39b57d2783e9fd015", + "filename": "Qwen_Qwen3-30B-A3B-Q4_K_M.gguf", + "size": 18632184480, + "sha256": "a015794bfb1d69cb03dbb86b185fb2b9b339f757df5f8f9dd9ebdab8f6ed5d32", + "ram_gb": 32, + "tier": "gpu_xl" + } + ] +} diff --git a/plugins/lumi_ai/plugin.json b/plugins/lumi_ai/plugin.json new file mode 100644 index 0000000..22a5c95 --- /dev/null +++ b/plugins/lumi_ai/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "lumi_ai", + "name": "Lumi AI", + "version": "0.2.1", + "description": "Managed local AI provider and scoped WebUI assistant for Lumi.", + "main": "index.js" +} diff --git a/plugins/lumi_ai/public/assistant.css b/plugins/lumi_ai/public/assistant.css new file mode 100644 index 0000000..9b31ebe --- /dev/null +++ b/plugins/lumi_ai/public/assistant.css @@ -0,0 +1,29 @@ +.lumi-ai-shell { width: 100%; min-width: 0; } +.lumi-ai-pill { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); cursor: pointer; } +.lumi-ai-pill:hover { border-color: var(--sea); background: var(--surface-3); } +.lumi-ai-mark { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 6px; background: var(--sea); color: white; font-size: 10px; font-weight: 800; } +.lumi-ai-pill-label { flex: 1; text-align: left; font-weight: 700; } +.lumi-ai-state { width: 8px; height: 8px; border-radius: 50%; background: #8b949e; box-shadow: 0 0 0 3px color-mix(in srgb, #8b949e 18%, transparent); } +.lumi-ai-state.ready { background: #2ea043; box-shadow: 0 0 0 3px color-mix(in srgb, #2ea043 18%, transparent); } +.lumi-ai-state.error { background: #d73a49; box-shadow: 0 0 0 3px color-mix(in srgb, #d73a49 18%, transparent); } +.lumi-ai-panel { position: fixed; z-index: 80; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; top: var(--lumi-ai-top, calc(100vh - 16.666vh - 14px)); height: max(180px, 16.666vh); max-height: calc(100vh - 16px); display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.5s ease-in-out, height 0.5s ease-in-out, opacity 0.5s ease-in-out; } +.lumi-ai-panel.open { opacity: 1; transform: translateY(0); pointer-events: auto; } +.lumi-ai-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--surface-2); } +.lumi-ai-header div { display: flex; align-items: baseline; gap: 10px; } +.lumi-ai-header span { color: var(--ink-soft); font-size: 12px; } +.lumi-ai-close { border: 0; background: transparent; color: var(--ink-soft); font-size: 22px; cursor: pointer; } +.lumi-ai-messages { min-height: 0; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } +.lumi-ai-message { max-width: min(760px, 86%); padding: 8px 10px; border-radius: 7px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.4; } +.lumi-ai-message.assistant { align-self: flex-start; background: var(--surface-2); border: 1px solid var(--border); } +.lumi-ai-message.user { align-self: flex-end; background: var(--sea); color: white; } +.lumi-ai-message.error { border-color: var(--rose); color: var(--rose); } +.lumi-ai-confirm { display: flex; gap: 8px; margin-top: 8px; } +.lumi-ai-confirm button { padding: 5px 9px; border-radius: 5px; border: 1px solid var(--border); cursor: pointer; } +.lumi-ai-compose { display: grid; grid-template-columns: 1fr 40px; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); } +.lumi-ai-compose textarea { width: 100%; min-height: 40px; max-height: 96px; resize: vertical; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--ink); padding: 8px; } +.lumi-ai-compose button { display: grid; place-items: center; border: 0; border-radius: 6px; background: var(--sea); color: white; cursor: pointer; } +.lumi-ai-compose svg { width: 19px; height: 19px; } +@media (max-width: 800px) { .lumi-ai-panel { left: 10px; right: 10px; } } +body.sidebar-collapsed .lumi-ai-pill { justify-content: center; padding: 8px; } +body.sidebar-collapsed .lumi-ai-pill-label, +body.sidebar-collapsed .lumi-ai-state { display: none; } diff --git a/plugins/lumi_ai/public/assistant.js b/plugins/lumi_ai/public/assistant.js new file mode 100644 index 0000000..1240223 --- /dev/null +++ b/plugins/lumi_ai/public/assistant.js @@ -0,0 +1,122 @@ +(() => { + const root = document.querySelector("[data-lumi-ai]"); + if (!root) return; + const endpoint = root.dataset.endpoint; + const panel = root.querySelector("[data-lumi-ai-panel]"); + const toggle = root.querySelector("[data-lumi-ai-toggle]"); + const close = root.querySelector("[data-lumi-ai-close]"); + const state = root.querySelector("[data-lumi-ai-state]"); + const status = root.querySelector("[data-lumi-ai-status]"); + const messages = root.querySelector("[data-lumi-ai-messages]"); + const form = root.querySelector("[data-lumi-ai-form]"); + const input = form.querySelector("textarea"); + + const setOpen = (open) => { + if (open) positionPanel(); + panel.classList.toggle("open", open); + panel.setAttribute("aria-hidden", String(!open)); + toggle.setAttribute("aria-expanded", String(open)); + if (open) input.focus(); + }; + const positionPanel = () => { + const viewportHeight = window.innerHeight; + const desiredHeight = Math.max(180, viewportHeight / 6); + const footerRect = document.querySelector(".site-footer")?.getBoundingClientRect(); + const bottomLimit = footerRect && footerRect.top < viewportHeight && footerRect.bottom > 0 + ? Math.max(8, footerRect.top - 8) + : viewportHeight - 8; + const anchor = toggle.getBoundingClientRect(); + let top = anchor.bottom + 8; + if (top + desiredHeight > bottomLimit) { + const overflow = top + desiredHeight - bottomLimit; + top -= overflow / 2; + if (top + desiredHeight > bottomLimit) top = bottomLimit - desiredHeight; + } + top = Math.max(8, Math.min(top, viewportHeight - desiredHeight - 8)); + panel.style.setProperty("--lumi-ai-top", `${top}px`); + panel.style.height = `${Math.min(desiredHeight, viewportHeight - top - 8)}px`; + }; + const addMessage = (text, type, confirmation) => { + const item = document.createElement("div"); + item.className = `lumi-ai-message ${type}`; + item.textContent = text; + if (confirmation) { + const actions = document.createElement("div"); + actions.className = "lumi-ai-confirm"; + for (const [label, route] of [["Confirm", "confirm"], ["Cancel", "cancel"]]) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.addEventListener("click", async () => { + button.disabled = true; + try { + const response = await fetch(`${endpoint}/assistant/${route}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: confirmation.id }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Action failed."); + addMessage(route === "confirm" ? "Confirmed and completed." : "Cancelled.", "assistant"); + actions.remove(); + } catch (error) { addMessage(error.message, "assistant error"); } + }); + actions.append(button); + } + item.append(actions); + } + messages.append(item); + messages.scrollTop = messages.scrollHeight; + }; + const refreshStatus = async () => { + try { + const response = await fetch(`${endpoint}/api/status`); + const data = await response.json(); + const ready = response.ok && data.enabled && data.runtime?.healthy; + state.className = `lumi-ai-state ${ready ? "ready" : "error"}`; + if (ready) status.textContent = `${data.model_id} ready`; + else if (!data.enabled) status.textContent = "Disabled by administrator"; + else if (!data.runtime?.runtime_installed) status.textContent = "Runtime not installed"; + else if (!data.runtime?.model_downloaded) status.textContent = "Selected model missing"; + else if (data.runtime?.state === "error") status.textContent = "Runtime error"; + else status.textContent = "Runtime stopped"; + } catch { + state.className = "lumi-ai-state error"; + status.textContent = "Status unavailable"; + } + }; + toggle.addEventListener("click", () => setOpen(!panel.classList.contains("open"))); + close.addEventListener("click", () => setOpen(false)); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const message = input.value.trim(); + if (!message) return; + addMessage(message, "user"); + input.value = ""; + input.disabled = true; + try { + const response = await fetch(`${endpoint}/assistant/message`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Request failed."); + addMessage(data.text, "assistant", data.confirmation); + } catch (error) { + addMessage(error.message, "assistant error"); + } finally { + input.disabled = false; + input.focus(); + } + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + form.requestSubmit(); + } + }); + window.addEventListener("resize", () => { + if (panel.classList.contains("open")) positionPanel(); + }); + refreshStatus(); + window.setInterval(refreshStatus, 15000); +})(); diff --git a/plugins/lumi_ai/public/settings.css b/plugins/lumi_ai/public/settings.css new file mode 100644 index 0000000..826905d --- /dev/null +++ b/plugins/lumi_ai/public/settings.css @@ -0,0 +1,42 @@ +.ai-titlebar, .ai-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.ai-titlebar { margin-bottom: 14px; } +.ai-titlebar h1, .ai-section-heading h2 { margin: 0; } +.ai-titlebar p, .ai-section-heading p { margin: 4px 0 0; color: var(--ink-soft); } +.ai-runtime-badge { display: flex; align-items: center; gap: 8px; font-weight: 700; } +.ai-runtime-badge span { width: 9px; height: 9px; border-radius: 50%; background: #d73a49; } +.ai-runtime-badge.ready span { background: #2ea043; } +.ai-tabs { position: sticky; top: 0; z-index: 4; display: flex; gap: 4px; overflow-x: auto; padding: 8px 0; background: var(--bg-1); border-bottom: 1px solid var(--border); } +.ai-tabs a { padding: 7px 10px; border-radius: 5px; color: var(--ink-soft); text-decoration: none; font-weight: 700; white-space: nowrap; } +.ai-tabs a:hover { background: var(--surface-2); color: var(--ink); } +.ai-band { padding: 24px 0; border-bottom: 1px solid var(--border); scroll-margin-top: 60px; } +.ai-stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; margin-top: 16px; overflow: hidden; border: 1px solid var(--border); border-radius: 7px; background: var(--border); } +.ai-stat-grid div { min-width: 0; padding: 14px; background: var(--card); } +.ai-stat-grid span { display: block; color: var(--ink-soft); font-size: 12px; } +.ai-stat-grid strong { display: block; margin-top: 4px; overflow-wrap: anywhere; } +.ai-stat-grid.compact div { padding: 10px 12px; } +.ai-model-list { margin-top: 14px; border-top: 1px solid var(--border); } +.ai-model-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border); } +.ai-model-main span { display: block; margin-top: 3px; color: var(--ink-soft); font-size: 12px; overflow-wrap: anywhere; } +.ai-tag { padding: 4px 7px; border: 1px solid var(--border); border-radius: 5px; color: var(--ink-soft); font-size: 12px; } +.ai-tag.installed { border-color: #2ea043; color: #2ea043; } +.ai-tag.warning { border-color: var(--sun); color: var(--sun); } +.ai-inline-form, .ai-inline-form label, .ai-actions { display: flex; align-items: center; gap: 8px; } +.ai-runtime-grid { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr); gap: 28px; margin-top: 16px; } +.ai-diagnostic { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; } +.ai-diagnostic span { color: var(--ink-soft); } +.ai-diagnostic strong { overflow-wrap: anywhere; } +.ai-download-status { margin-top: 12px; padding: 9px; border: 1px solid var(--border); border-radius: 6px; } +.ai-form { margin-top: 16px; } +.ai-fieldset { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0; padding: 12px; border: 1px solid var(--border); border-radius: 7px; } +.ai-fieldset legend { padding: 0 5px; font-weight: 700; } +.ai-fieldset label { display: flex; align-items: center; gap: 6px; } +.ai-test-output { max-height: 420px; overflow: auto; margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; } +.ai-remediation { margin: 14px 0; padding-left: 24px; } +.ai-raw-diagnostic pre { max-height: 420px; overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); white-space: pre-wrap; overflow-wrap: anywhere; } +@media (max-width: 800px) { + .ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; } + .ai-stat-grid { grid-template-columns: 1fr 1fr; } + .ai-model-row { grid-template-columns: 1fr auto; } + .ai-inline-form { grid-column: 1 / -1; } + .ai-runtime-grid { grid-template-columns: 1fr; } +} diff --git a/plugins/lumi_ai/public/settings.js b/plugins/lumi_ai/public/settings.js new file mode 100644 index 0000000..4fc3ddd --- /dev/null +++ b/plugins/lumi_ai/public/settings.js @@ -0,0 +1,71 @@ +(() => { + const actions = document.querySelector("[data-ai-runtime-actions]"); + const state = document.querySelector("[data-runtime-state]"); + const downloadStatus = document.querySelector("[data-download-status]"); + const testForm = document.querySelector("[data-ai-test-form]"); + const testOutput = document.querySelector("[data-ai-test-output]"); + if (actions) { + actions.addEventListener("click", async (event) => { + const button = event.target.closest("[data-runtime-action]"); + if (!button) return; + button.disabled = true; + try { + const response = await fetch(`/plugins/lumi_ai/runtime/${button.dataset.runtimeAction}`, { method: "POST" }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Runtime action failed."); + if (data.state) state.textContent = data.state; + if (["self-test", "verify-runtime", "verify-model"].includes(button.dataset.runtimeAction)) { + const labels = { "self-test": "Runtime self-test passed.", "verify-runtime": "Runtime installation verified.", "verify-model": "Model verification passed." }; + window.alert(labels[button.dataset.runtimeAction]); + } + } catch (error) { + window.alert(error.message); + } finally { + button.disabled = false; + } + }); + } + const pollDownloads = async () => { + if (!downloadStatus) return; + try { + const response = await fetch("/plugins/lumi_ai/api/downloads"); + if (!response.ok) return; + const jobs = Object.values(await response.json()); + const active = jobs.filter((job) => !["complete", "error"].includes(job.state)); + if (!jobs.length) return; + downloadStatus.hidden = false; + downloadStatus.textContent = jobs.map((job) => { + const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0; + return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`; + }).join(" | "); + if (active.length) window.setTimeout(pollDownloads, 1000); + } catch {} + }; + if (testForm && testOutput) { + testForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(testForm); + testOutput.hidden = false; + testOutput.textContent = "Running..."; + try { + const response = await fetch("/plugins/lumi_ai/assistant/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + role: form.get("role"), + message: form.get("message"), + show_raw_prompt: form.get("show_raw_prompt") === "on", + show_raw_output: form.get("show_raw_output") === "on" + }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Test failed."); + if (form.get("show_raw_output") !== "on") delete data.raw_response; + testOutput.textContent = JSON.stringify(data, null, 2); + } catch (error) { + testOutput.textContent = error.message; + } + }); + } + pollDownloads(); +})(); diff --git a/plugins/lumi_ai/runtime_manifest.json b/plugins/lumi_ai/runtime_manifest.json new file mode 100644 index 0000000..9182642 --- /dev/null +++ b/plugins/lumi_ai/runtime_manifest.json @@ -0,0 +1,36 @@ +{ + "version": "b9592", + "source": "https://github.com/ggml-org/llama.cpp/releases/tag/b9592", + "targets": { + "win32-x64": { + "filename": "llama-b9592-bin-win-cpu-x64.zip", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-cpu-x64.zip", + "sha256": "2b3d4e167be290bf6266d405746da52813c19a58fe02dc88a97ab75c4c021428", + "size": 16722005 + }, + "linux-x64": { + "filename": "llama-b9592-bin-ubuntu-x64.tar.gz", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-ubuntu-x64.tar.gz", + "sha256": "ce07450c3463473721843772fbbe4ea6c1691e097e4991e93239a1dda0dfa440", + "size": 15408227 + }, + "linux-arm64": { + "filename": "llama-b9592-bin-ubuntu-arm64.tar.gz", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-ubuntu-arm64.tar.gz", + "sha256": "345ca9cac08f237496adcf476b72c1e28053a0636971513d57de513a67df3754", + "size": 12424701 + }, + "darwin-arm64": { + "filename": "llama-b9592-bin-macos-arm64.tar.gz", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-macos-arm64.tar.gz", + "sha256": "e395d9f746bc1b04e3e019295e76a5158de3ecc837a2f08b7fe6e76ec5b42729", + "size": 10548003 + }, + "darwin-x64": { + "filename": "llama-b9592-bin-macos-x64.tar.gz", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-macos-x64.tar.gz", + "sha256": "6b5ba1d89560f51d68d5845ca7a76b5093b1f9b4908882229135e9c186262121", + "size": 10790342 + } + } +} diff --git a/plugins/lumi_ai/templates/role_admin.txt b/plugins/lumi_ai/templates/role_admin.txt new file mode 100644 index 0000000..06c7b1b --- /dev/null +++ b/plugins/lumi_ai/templates/role_admin.txt @@ -0,0 +1 @@ +The user is a Lumi administrator. Administrative explanations are allowed. Destructive or sensitive actions still require a registered workflow and explicit confirmation. diff --git a/plugins/lumi_ai/templates/role_mod.txt b/plugins/lumi_ai/templates/role_mod.txt new file mode 100644 index 0000000..66b3610 --- /dev/null +++ b/plugins/lumi_ai/templates/role_mod.txt @@ -0,0 +1 @@ +The user is a Lumi moderator. Do not expose administrator-only settings, secrets, logs, or tools. diff --git a/plugins/lumi_ai/templates/role_user.txt b/plugins/lumi_ai/templates/role_user.txt new file mode 100644 index 0000000..bd81dd6 --- /dev/null +++ b/plugins/lumi_ai/templates/role_user.txt @@ -0,0 +1 @@ +The user is a regular Lumi user. Only provide public or self-service information and tools. diff --git a/plugins/lumi_ai/templates/system.txt b/plugins/lumi_ai/templates/system.txt new file mode 100644 index 0000000..8c999c3 --- /dev/null +++ b/plugins/lumi_ai/templates/system.txt @@ -0,0 +1,5 @@ +Operate only within Lumi, its WebUI, installed plugins, community systems, streams, and videos. +Never claim an action succeeded unless a registered tool returned success. +Never invent settings, routes, user data, balances, sanctions, commands, or plugin capabilities. +For an unavailable fact or capability, say what is unavailable and direct the user to the relevant Lumi page. +Tool calls must be a single JSON object: {"type":"tool_call","tool":"tool_id","arguments":{}}. diff --git a/plugins/lumi_ai/tests/verify.js b/plugins/lumi_ai/tests/verify.js new file mode 100644 index 0000000..2da2c3f --- /dev/null +++ b/plugins/lumi_ai/tests/verify.js @@ -0,0 +1,157 @@ +const assert = require("assert"); +const fs = require("fs"); +const { ensureDataDirs, PLUGIN_ROOT, PLUGIN_DATA, resolveData } = require("../backend/paths"); +const { canUse } = require("../backend/permissions"); +const { ToolRegistry } = require("../backend/tool_router"); +const { RequestQueue } = require("../backend/queue_manager"); +const { RuntimeManager, runCaptured } = require("../backend/runtime_manager"); +const { getRuntimeState } = require("../backend/config_manager"); +const { AiProvider } = require("../backend/ai_provider"); +const { shouldAutoResume } = require("../index"); +const { normalizeExitCode, classifyLaunchError } = require("../backend/error_codes"); +const { redact } = require("../backend/diagnostics"); +const { validateArchivePath, classifyError } = require("../backend/downloader"); +const { EventEmitter } = require("events"); + +async function run() { + ensureDataDirs(); + assert(PLUGIN_DATA.startsWith(PLUGIN_ROOT)); + assert(resolveData("models", "model.gguf").startsWith(PLUGIN_ROOT)); + assert.throws(() => resolveData("..", "..", "outside"), /escapes/); + assert.throws(() => validateArchivePath("../../outside.exe"), /traversal/); + assert.doesNotThrow(() => validateArchivePath("bin/llama-server.exe")); + assert.equal(classifyError(new Error("source unavailable (404)")).category, "http_404"); + assert.equal(classifyError(new Error("hash mismatch")).category, "hash_mismatch"); + + const accessViolation = normalizeExitCode(-1073741819, null, "win32"); + assert.equal(accessViolation.unsigned_exit_code, 3221225477); + assert.equal(accessViolation.hex_exit_code, "0xC0000005"); + assert.equal(accessViolation.code, "STATUS_ACCESS_VIOLATION"); + assert.equal(normalizeExitCode(139, null, "linux").code, "SIGSEGV"); + assert.equal(classifyLaunchError({ code: "EACCES" }, "linux").category, "permission_denied"); + assert.deepEqual(redact({ token: "secret", nested: { password: "secret", value: "ok" } }), { token: "[REDACTED]", nested: { password: "[REDACTED]", value: "ok" } }); + const captured = await runCaptured(process.execPath, ["-e", "console.log('llama server usage')"], process.cwd(), 3000); + assert.equal(captured.code, 0); + assert.match(captured.stdout, /llama server usage/); + + const config = { assistant_visibility: { admins: true, mods: false, users: true } }; + assert.equal(canUse({ id: "a", isAdmin: true }, config), true); + assert.equal(canUse({ id: "m", isMod: true }, config), false); + assert.equal(canUse({ id: "u" }, config), true); + assert.equal(canUse(null, config), false); + + const audit = []; + const calls = []; + const registry = new ToolRegistry((entry) => audit.push(entry)); + registry.register({ + tool_id: "test.action", + display_name: "Test action", + description: "Runs a test workflow.", + owning_plugin: "test", + required_role: "user", + required_permission: "test.self", + permission_check: ({ user }) => user.id === "user-1", + schema: { amount: "integer", recipient: "string" }, + confirmation_required: true, + risk_level: "sensitive", + audit_category: "test", + workflow_handler: async (input) => { calls.push(input); return { ok: true }; } + }); + assert.throws(() => registry.prepare({ tool: "test.action", args: { amount: "bad", recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" }), /integer/); + assert.throws(() => registry.prepare({ tool: "test.action", args: { amount: 1, recipient: "x" }, user: { id: "other" }, role: "user", sessionId: "s1" }), /permission/); + const prepared = registry.prepare({ tool: "test.action", args: { amount: 2, recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" }); + await assert.rejects(() => registry.confirm({ id: prepared.confirmation.id, user: { id: "user-1" }, sessionId: "wrong" }), /invalid or expired/); + const expiring = registry.prepare({ tool: "test.action", args: { amount: 2, recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" }); + registry.confirmations.get(expiring.confirmation.id).expiresAt = Date.now() - 1; + await assert.rejects(() => registry.confirm({ id: expiring.confirmation.id, user: { id: "user-1" }, sessionId: "s1" }), /invalid or expired/); + const valid = registry.prepare({ tool: "test.action", args: { amount: 3, recipient: "x" }, user: { id: "user-1" }, role: "user", sessionId: "s1" }); + await registry.confirm({ id: valid.confirmation.id, user: { id: "user-1" }, sessionId: "s1" }); + assert.equal(calls[0].user.id, "user-1"); + assert.equal(calls[0].initiated_via_ai, true); + assert.equal(audit[0].tool_executed, true); + + const queueConfig = { concurrency: 1, max_queue_length: 1, per_user_requests_per_minute: 20 }; + const queue = new RequestQueue(() => queueConfig); + let release; + const blocked = new Promise((resolve) => { release = resolve; }); + const first = queue.run("u1", "user", () => blocked); + const second = queue.run("u2", "user", async () => "second"); + await assert.rejects(() => queue.run("u3", "user", async () => "third"), /busy/); + release("first"); + assert.equal(await first, "first"); + assert.equal(await second, "second"); + + const fakeMetrics = { record() {} }; + const provider = new AiProvider({ + getConfig: () => ({ instructions: { out_of_scope_response: "OUT" } }), + runtime: { infer: async () => { throw new Error("must not run"); } }, + queue, + tools: registry, + metrics: fakeMetrics, + getContext: () => [] + }); + const refused = await provider.generate({ message: "What is the capital of France?", user: { id: "u1" }, sessionId: "s1" }); + assert.equal(refused.refusal_reason, "out_of_scope"); + const routed = await provider.generate({ message: "Where can I find Twitch configuration?", user: { id: "u1" }, sessionId: "s1" }); + assert.equal(routed.success, true); + assert.match(routed.text, /twitch-wizard/); + const ambiguousProvider = new AiProvider({ + getConfig: () => ({ selected_model_id: "test", request_timeout_ms: 1000, logging: {}, instructions: { identity: "Lumi", style: "Brief", allowed_topics: "Lumi", maximum_answer_length: 700, out_of_scope_response: "OUT" } }), + runtime: { infer: async () => ({ choices: [{ message: { content: "Open the relevant Lumi settings page." }, finish_reason: "stop" }] }) }, + queue, + tools: registry, + metrics: fakeMetrics, + getContext: () => [] + }); + const ambiguous = await ambiguousProvider.generate({ message: "How do I change this option?", user: { id: "u1" }, sessionId: "s1" }); + assert.equal(ambiguous.success, true); + let diagnosticMessages; + const testProvider = new AiProvider({ + getConfig: () => ({ selected_model_id: "test", instructions: { maximum_answer_length: 700 } }), + runtime: { infer: async (messages) => { diagnosticMessages = messages; return { choices: [{ message: { content: "There are 3 Rs." }, finish_reason: "stop" }] }; } }, + queue, + tools: registry, + metrics: fakeMetrics, + getContext: () => [] + }); + const diagnosticTest = await testProvider.test({ message: 'How many "R"s are in Strawberry?', user: { id: "u1" }, includeRaw: true }); + assert.equal(diagnosticMessages[1].content, 'How many "R"s are in Strawberry?'); + assert.equal(diagnosticTest.text, "There are 3 Rs."); + assert.match(diagnosticTest.raw_prompt, /local model diagnostic/); + + const statePath = resolveData("config", "runtime_state.json"); + const originalState = fs.readFileSync(statePath, "utf8"); + try { + const runtime = new RuntimeManager({ getConfig: () => ({}), getModel: () => null, runtimeManifest: {} }); + runtime.child = fakeChild(); + await runtime.stop({ manual: false, reason: "bot_shutdown" }); + assert.equal(getRuntimeState().desired_state, "running"); + assert.equal(shouldAutoResume({ enabled: true }, getRuntimeState()), true); + runtime.child = fakeChild(); + await runtime.stop({ manual: true, reason: "admin_stop" }); + assert.equal(getRuntimeState().desired_state, "stopped"); + assert.equal(shouldAutoResume({ enabled: true }, getRuntimeState()), false); + assert.equal(shouldAutoResume({ enabled: true }, { desired_state: "running", last_manual_stop: false, last_crashed: true }), false); + } finally { + fs.writeFileSync(statePath, originalState); + } + + console.log("Lumi AI verification passed."); +} + +function fakeChild() { + const child = new EventEmitter(); + child.killed = false; + child.exitCode = null; + child.kill = function kill() { + this.killed = true; + this.exitCode = 0; + this.emit("exit", 0, null); + }; + return child; +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/plugins/lumi_ai/views/assistant-panel.ejs b/plugins/lumi_ai/views/assistant-panel.ejs new file mode 100644 index 0000000..b938bf5 --- /dev/null +++ b/plugins/lumi_ai/views/assistant-panel.ejs @@ -0,0 +1,25 @@ +
+ + +
diff --git a/plugins/lumi_ai/views/settings.ejs b/plugins/lumi_ai/views/settings.ejs new file mode 100644 index 0000000..00e24b5 --- /dev/null +++ b/plugins/lumi_ai/views/settings.ejs @@ -0,0 +1,219 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +
+
+

Lumi AI

+

Managed local inference, assistant access, and guarded plugin tools.

+
+
+ <%= runtimeStatus.healthy ? "Runtime ready" : "Runtime offline" %> +
+
+ + + +
+
+

Overview

Current installation and host capacity.

+
+
+
Providerllama.cpp
+
Selected model<%= models.find((model) => model.id === config.selected_model_id)?.label || config.selected_model_id %>
+
RAM<%= Math.round(hardware.total_ram_mb / 1024) %> GB
+
Free disk<%= formatBytes(hardware.free_disk_mb * 1048576) %>
+
CPU threads<%= hardware.cpu_threads %>
+
GPU<%= hardware.gpu.present ? hardware.gpu.name : "Not detected" %>
+
+
+ +
+
+

Models

Pinned GGUF files downloaded directly from Hugging Face and verified by SHA-256.

+
+
+ <% models.forEach((model) => { %> +
+
+ <%= model.label %> + <%= formatBytes(model.size) %> · <%= model.ram_gb %> GB recommended RAM · <%= model.repo %> +
+ + <%= model.downloaded ? "Installed" : model.compatible ? "Available" : "Exceeds host" %> + + <% if (!model.downloaded) { %> +
+ <% if (!model.compatible) { %> + + <% } %> + +
+ <% } %> +
+ <% }) %> +
+
+ +
+
+

Runtime

Official llama.cpp release, bound to localhost and stored inside this plugin.

+
+ + + + + + +
+
+
+
+ Installed<%= runtimeStatus.runtime_installed ? "Yes" : "No" %> + Process<%= runtimeStatus.state %> + Health<%= runtimeStatus.healthy ? "Healthy" : "Unavailable" %> + PID<%= runtimeStatus.pid || "None" %> + Last stop<%= runtimeState.last_stop_reason %> + Platform<%= hardware.platform %>-<%= hardware.architecture %> + Self-test<%= runtimeStatus.last_self_test?.success ? "Passed" : runtimeStatus.last_self_test ? "Failed" : "Not run" %> + Runtime folder<%= formatBytes(runtimeFolderSize) %> + Runtime archive<%= runtimeTarget ? formatBytes(runtimeTarget.size) : "Unavailable" %> + Model installed<%= formatBytes(modelFileSize) %> + Model download<%= formatBytes(models.find((model) => model.id === config.selected_model_id)?.size || 0) %> +
+
+ <% if (runtimeTarget) { %> +

Managed release <%= runtimeManifest?.version || "b9592" %>

+

<%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %>

+
+ +
+ <% } else { %> +
No managed runtime build is available for this OS and architecture.
+ <% } %> + <% if (runtimeStatus.last_error) { %>
<%= runtimeStatus.last_error %>
<% } %> +
+
+ +
+ +
+
+

Runtime diagnostics

Latest plugin-local runtime failure and remediation details.

+ Download diagnostics +
+ <% if (latestDiagnostic) { %> +
+ <%= latestDiagnostic.code %>: <%= latestDiagnostic.message %> +

<%= latestDiagnostic.category %> / <%= latestDiagnostic.severity %>

+
+ <% if (latestDiagnostic.remediation_steps?.length) { %> +
    <% latestDiagnostic.remediation_steps.forEach((step) => { %>
  1. <%= step %>
  2. <% }) %>
+ <% } %> +
+ Raw diagnostic details +
<%= JSON.stringify(latestDiagnostic, null, 2) %>
+
+ <% } else { %> +

No runtime diagnostic has been recorded.

+ <% } %> + <% if (hardware.network_path_warning) { %> +
The plugin path may be a mapped or network-like location. A local disk path is more reliable for native runtime DLL loading.
+ <% } %> + <% if (hardware.long_path_warning) { %>
The plugin path is unusually long for Windows native loading. Consider a shorter local installation path.
<% } %> +
+ +
+
+
+

Assistant

Configuration remains admin-only. Visibility controls only the sidebar assistant.

+ +
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+ Sidebar visibility + + + +
+
+
+
+
+
+
+
+
Hard scope, role, tool, and confirmation rules cannot be overridden.
+
+ Logging + <% [["log_prompts","Prompts"],["log_responses","Responses"],["log_tool_calls","Tool calls"],["log_metrics","Metrics"],["log_internal_audit","Internal audit"]].forEach(([key,label]) => { %> + + <% }) %> +
+
+
+
+ +
+

Test console

Run a request as a simulated role without changing the logged-in actor.

+
+
+
+
+ + +
+
+
+ +
+ +
+

Metrics

Plugin-local operational counters and recent requests.

+
+
Requests<%= metrics.total_requests %>
+
Successful<%= metrics.successful %>
+
Failed<%= metrics.failed %>
+
Refused<%= metrics.refusals %>
+
Average<%= formatDuration(metrics.average_response_ms) %>
+
Median<%= formatDuration(metrics.median_response_ms) %>
+
+
+ + <% history.forEach((entry) => { %><% }) %> + <% if (!history.length) { %><% } %> +
TimeKindStatusRoleDuration
<%= entry.timestamp %><%= entry.kind %><%= entry.status %><%= entry.role || "-" %><%= formatDuration(entry.duration_ms) %>
No requests recorded.
+
+ <% if (logFiles.length) { %>

Runtime logs: <%= logFiles.map((file) => `${file.name} (${formatBytes(file.size)})`).join(", ") %>

<% } %> +
+ +
+

Privacy and troubleshooting

Local inference remains on this host.

+
+ Models are downloaded from pinned Hugging Face revisions. The managed runtime is downloaded from the official llama.cpp release and verified by SHA-256. No cloud inference is used. Prompt and response logging are off by default. +
+

If startup fails, confirm that the runtime and selected model show as installed, the plugin directory is writable, and enough RAM and disk are available. Runtime logs are stored under plugins/lumi_ai/data/logs/.

+
+ + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/scripts/verify-plugin-update-preserves-data.js b/scripts/verify-plugin-update-preserves-data.js new file mode 100644 index 0000000..a533285 --- /dev/null +++ b/scripts/verify-plugin-update-preserves-data.js @@ -0,0 +1,32 @@ +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { replacePluginDirectory } = require("../src/services/update-manager"); + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-update-test-")); +try { + const source = path.join(root, "source"); + const target = path.join(root, "target"); + fs.mkdirSync(path.join(source, "data", "config"), { recursive: true }); + fs.mkdirSync(path.join(target, "data", "models"), { recursive: true }); + fs.writeFileSync(path.join(source, "plugin.json"), '{"id":"test"}'); + fs.writeFileSync(path.join(source, "index.js"), "module.exports = 'new';"); + fs.writeFileSync(path.join(source, "data", "config", "default.json"), '{"default":true}'); + fs.writeFileSync(path.join(target, "index.js"), "module.exports = 'old';"); + fs.writeFileSync(path.join(target, "stale.js"), "stale"); + const model = path.join(target, "data", "models", "large.gguf"); + const descriptor = fs.openSync(model, "w"); + fs.ftruncateSync(descriptor, 3 * 1024 * 1024 * 1024); + fs.closeSync(descriptor); + + replacePluginDirectory(source, target, { preserveData: true }); + + assert.equal(fs.readFileSync(path.join(target, "index.js"), "utf8"), "module.exports = 'new';"); + assert.equal(fs.existsSync(path.join(target, "stale.js")), false); + assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024); + assert.equal(fs.existsSync(path.join(target, "data", "config", "default.json")), false); + console.log("Plugin update preservation verification passed."); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/src/main.js b/src/main.js index 6cc4b31..76afdc1 100644 --- a/src/main.js +++ b/src/main.js @@ -4,7 +4,12 @@ const { createWebServer } = require("./web/server"); const { startBot, stopBot } = require("./services/discord"); const { startTwitchBot, stopTwitchBot } = require("./services/twitch"); const { startYouTubeBot, stopYouTubeBot } = require("./services/youtube"); -const { loadEnabled } = require("./services/plugins"); +const pluginService = require("./services/plugins"); +const { loadEnabled } = pluginService; +const stopPlugins = + typeof pluginService.stopPlugins === "function" + ? pluginService.stopPlugins + : async () => {}; const { checkForUpdates, pullUpdates, requestRestart } = require("./services/updater"); const { createCommandRouter } = require("./services/command-router"); const { registerTopCommand } = require("./services/top"); @@ -86,12 +91,21 @@ async function main() { }, intervalMs); } - process.on("SIGINT", async () => { + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) { + return; + } + shuttingDown = true; + await stopPlugins(); await stopBot(); await stopTwitchBot(); await stopYouTubeBot(); process.exit(0); - }); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); } main(); diff --git a/src/services/plugins.js b/src/services/plugins.js index 32d02b8..74ffc5d 100644 --- a/src/services/plugins.js +++ b/src/services/plugins.js @@ -4,6 +4,7 @@ const { spawnSync } = require("child_process"); const { db } = require("./db"); const pluginsDir = path.join(__dirname, "..", "..", "plugins"); +const cleanupHandlers = []; function readJson(filePath) { const raw = fs.readFileSync(filePath, "utf8"); @@ -120,7 +121,7 @@ function loadEnabled({ try { const mod = require(mainPath); if (mod && typeof mod.init === "function") { - mod.init({ + const cleanup = mod.init({ app, discordClient, twitchClient, @@ -132,6 +133,11 @@ function loadEnabled({ plugin, commandRouter }); + if (typeof cleanup === "function") { + cleanupHandlers.push({ id: plugin.id, cleanup }); + } else if (cleanup && typeof cleanup.stop === "function") { + cleanupHandlers.push({ id: plugin.id, cleanup: () => cleanup.stop() }); + } } } catch (error) { console.error(`Plugin ${plugin.id} failed to load`, error); @@ -139,6 +145,17 @@ function loadEnabled({ } } +async function stopPlugins() { + const handlers = cleanupHandlers.splice(0).reverse(); + for (const handler of handlers) { + try { + await handler.cleanup(); + } catch (error) { + console.error(`Plugin ${handler.id} failed to stop`, error); + } + } +} + function installFromGit(url, targetFolder) { if (!fs.existsSync(pluginsDir)) { fs.mkdirSync(pluginsDir, { recursive: true }); @@ -225,6 +242,7 @@ module.exports = { setPluginEnabled, removePlugin, loadEnabled, + stopPlugins, installFromGit, updatePluginFromGit, createLocalPlugin diff --git a/src/services/update-manager.js b/src/services/update-manager.js index d98f751..27d83a4 100644 --- a/src/services/update-manager.js +++ b/src/services/update-manager.js @@ -70,7 +70,10 @@ async function createSnapshot({ type, pluginId }) { pluginExisted = fs.existsSync(pluginDir); if (pluginExisted) { pluginZip = path.join(snapshotPath, "plugin.zip"); - zipFolder(pluginDir, pluginZip, { base: pluginDir }); + zipFolder(pluginDir, pluginZip, { + base: pluginDir, + ignore: new Set(["node_modules", "data"]) + }); } } @@ -248,7 +251,7 @@ function zipFolder(source, destination, options) { } const zip = new AdmZip(); const base = options?.base || source; - addFolder(zip, source, base, new Set(["node_modules"])); + addFolder(zip, source, base, options?.ignore || new Set(["node_modules"])); zip.writeZip(destination); } @@ -330,13 +333,42 @@ function hasAnyFiles(rootPath) { return false; } -function applyPluginFiles(rootPath, pluginId) { +function resetPluginCode(targetDir) { + if (!fs.existsSync(targetDir)) { + return; + } + for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) { + if (entry.name === "data") { + continue; + } + fs.rmSync(path.join(targetDir, entry.name), { + recursive: true, + force: true + }); + } +} + +function applyPluginFiles(rootPath, pluginId, options = {}) { const pluginsDir = path.join(repoRoot, "plugins"); const targetDir = path.join(pluginsDir, pluginId); - fs.rmSync(targetDir, { recursive: true, force: true }); fs.mkdirSync(pluginsDir, { recursive: true }); + replacePluginDirectory(rootPath, targetDir, options); +} + +function replacePluginDirectory(rootPath, targetDir, options = {}) { + if (options.preserveData) { + resetPluginCode(targetDir); + } else { + fs.rmSync(targetDir, { recursive: true, force: true }); + } fs.mkdirSync(targetDir, { recursive: true }); - copyDirectory(rootPath, targetDir, new Set(["node_modules"])); + copyDirectory( + rootPath, + targetDir, + options.preserveData + ? new Set(["node_modules", "data"]) + : new Set(["node_modules"]) + ); } async function applyBotUpdate(zipPath, options = {}) { @@ -378,7 +410,9 @@ async function applyPluginUpdate(zipPath) { const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id }); try { - applyPluginFiles(rootPath, manifest.id); + applyPluginFiles(rootPath, manifest.id, { + preserveData: snapshot.pluginExisted + }); return finalizeSnapshot(snapshot); } catch (error) { discardSnapshot(snapshot); @@ -435,7 +469,7 @@ function restoreSnapshot(id) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); extractZip(pluginZip, tempDir); const rootPath = resolvePluginRoot(tempDir); - applyPluginFiles(rootPath, entry.pluginId); + applyPluginFiles(rootPath, entry.pluginId, { preserveData: true }); fs.rmSync(tempDir, { recursive: true, force: true }); } else { fs.rmSync(targetDir, { recursive: true, force: true }); @@ -450,6 +484,9 @@ function restoreSnapshot(id) { module.exports = { applyBotUpdate, applyPluginUpdate, + applyPluginFiles, + resetPluginCode, + replacePluginDirectory, listSnapshots, restoreSnapshot }; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e7e3f8c..e12111c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -209,6 +209,11 @@ body { gap: 10px; } +.sidebar-assistant-panels { + flex: 0 0 auto; + min-width: 0; +} + .user-chip { font-weight: 600; padding: 6px 10px; @@ -1504,6 +1509,10 @@ body.sidebar-collapsed .sidebar-footer { gap: 8px; } +body.sidebar-collapsed .sidebar-assistant-panels { + width: 100%; +} + body.sidebar-collapsed .user-chip { padding: 6px; border-radius: 12px; diff --git a/src/web/server.js b/src/web/server.js index a84c8f4..b6cfdc2 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1804,6 +1804,7 @@ function createWebServer({ loadPlugins, discordClient }) { }) ); app.use(express.urlencoded({ extended: false })); + app.use(express.json({ limit: "1mb" })); app.use(express.static(path.join(__dirname, "public"))); const uploadDir = path.join(__dirname, "..", "..", "data", "uploads"); @@ -1848,6 +1849,7 @@ function createWebServer({ loadPlugins, discordClient }) { const navItems = []; const profileSections = []; + const assistantPanels = []; const web = { createRouter: () => express.Router(), mount: (mountPath, router, navItem) => { @@ -1864,6 +1866,12 @@ function createWebServer({ loadPlugins, discordClient }) { return; } profileSections.push(section); + }, + addAssistantPanel: (panel) => { + if (!panel || !panel.id || !panel.view || !fs.existsSync(panel.view)) { + return; + } + assistantPanels.push(panel); } }; @@ -1887,6 +1895,20 @@ function createWebServer({ loadPlugins, discordClient }) { const twitchPlatform = platformStatus.find((platform) => platform.id === "twitch"); res.locals.twitchConfigured = Boolean(twitchPlatform?.configured); res.locals.currentPath = req.path; + res.locals.assistantPanels = assistantPanels + .filter((panel) => hasAccess(req.session.user, panel.role || "public")) + .filter((panel) => { + if (typeof panel.isVisible !== "function") { + return true; + } + try { + return panel.isVisible(req.session.user); + } catch (error) { + console.error(`Assistant panel ${panel.id} visibility check failed`, error); + return false; + } + }) + .map((panel) => ({ ...panel, locals: { ...(panel.locals || {}), user: req.session.user } })); res.locals.userAvatar = req.session.user ? getPreferredAvatar(req.session.user.id) : null; diff --git a/src/web/views/partials/layout-top.ejs b/src/web/views/partials/layout-top.ejs index e880081..bf78526 100644 --- a/src/web/views/partials/layout-top.ejs +++ b/src/web/views/partials/layout-top.ejs @@ -1,10 +1,17 @@ + <% const optionalAssistantPanels = + typeof assistantPanels !== "undefined" && Array.isArray(assistantPanels) + ? assistantPanels + : []; %> <%= title %> - <%= siteTitle %> + <% optionalAssistantPanels.forEach((panel) => { if (panel.stylesheet) { %> + + <% } }) %> <% if (theme) { %>