Add Lumi AI, birthday plugin, and persistent updates
This commit is contained in:
parent
5588819df4
commit
34e78d69c3
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,6 +1,9 @@
|
||||
node_modules/
|
||||
data/
|
||||
updates/
|
||||
/data/
|
||||
/updates/
|
||||
plugins/*/data/**
|
||||
!plugins/*/data/**/
|
||||
!plugins/*/data/**/.gitkeep
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@ -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:
|
||||
|
||||
43
plugins/birthday/README.md
Normal file
43
plugins/birthday/README.md
Normal file
@ -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 <user>` 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.
|
||||
15
plugins/birthday/cmds.json
Normal file
15
plugins/birthday/cmds.json
Normal file
@ -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 <user>",
|
||||
"platforms": ["discord", "twitch", "youtube"],
|
||||
"origin": "plugin:birthday"
|
||||
}
|
||||
]
|
||||
}
|
||||
964
plugins/birthday/index.js
Normal file
964
plugins/birthday/index.js
Normal file
@ -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 <user>, 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() : "";
|
||||
}
|
||||
7
plugins/birthday/plugin.json
Normal file
7
plugins/birthday/plugin.json
Normal file
@ -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"
|
||||
}
|
||||
187
plugins/birthday/views/birthday-admin.ejs
Normal file
187
plugins/birthday/views/birthday-admin.ejs
Normal file
@ -0,0 +1,187 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h1>Birthdays</h1>
|
||||
<p class="command-subtitle">Birthday announcements, profile display, and optional echonomy gifts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr><th>Discord client</th><td><%= diagnostics.discordAvailable ? (diagnostics.discordReady ? "Ready" : "Available") : "Unavailable" %></td></tr>
|
||||
<tr><th>Announcement channel</th><td><%= diagnostics.channel.message %></td></tr>
|
||||
<tr><th>Echonomy</th><td><%= diagnostics.echonomyAvailable ? "Available" : "Unavailable" %></td></tr>
|
||||
<tr><th>Current plugin date</th><td><%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Delivery</h2>
|
||||
<form method="post" action="/plugins/birthday/settings" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Announcements</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="enabled" <%= config.enabled ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= config.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Timezone</label>
|
||||
<input name="timezone" value="<%= config.timezone %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Gift mode</label>
|
||||
<select name="gift_mode">
|
||||
<option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>automatic</option>
|
||||
<option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Gift amount</label>
|
||||
<input name="gift_amount" type="number" min="0" step="1" value="<%= config.gift_amount %>" <%= isAdmin ? "" : "readonly" %> />
|
||||
<% if (!isAdmin) { %><p class="hint">Only admins can change this value.</p><% } %>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Check interval minutes</label>
|
||||
<input name="birthday_check_interval_minutes" type="number" min="5" max="1440" step="1" value="<%= config.birthday_check_interval_minutes %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Feb 29 handling</label>
|
||||
<select name="leap_day_policy">
|
||||
<option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>feb28</option>
|
||||
<option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>mar1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Announcement channel</label>
|
||||
<% if (textChannels.length) { %>
|
||||
<select name="announcement_channel_id">
|
||||
<option value="">Select a Discord text channel</option>
|
||||
<% textChannels.forEach((channel) => { %>
|
||||
<option value="<%= channel.id %>" <%= channel.id === config.announcement_channel_id ? "selected" : "" %>><%= channel.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<% } else { %>
|
||||
<input name="announcement_channel_id" value="<%= config.announcement_channel_id %>" placeholder="Discord text channel ID" />
|
||||
<% } %>
|
||||
<p class="hint">Only regular guild text channels are accepted.</p>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Allowed Placeholders</h2>
|
||||
<p>
|
||||
<% allowedPlaceholders.forEach((name) => { %>
|
||||
<code>{<%= name %>}</code>
|
||||
<% }) %>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Full-year birthday messages</h2>
|
||||
<% const fullYearMessages = config.response_templates.fullYear; %>
|
||||
<% if (!fullYearMessages.length) { %>
|
||||
<p>No templates configured.</p>
|
||||
<% } %>
|
||||
<% fullYearMessages.forEach((message) => { %>
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/update" class="form-grid">
|
||||
<input type="hidden" name="pool" value="fullYear" />
|
||||
<div class="field full">
|
||||
<label>Template</label>
|
||||
<textarea name="text" rows="3"><%= message.text %></textarea>
|
||||
<p class="hint">Preview: <%= previewTemplate(message.text) %></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Enabled</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="enabled" <%= message.enabled ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= message.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field profile-actions">
|
||||
<button type="submit" class="button subtle">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="profile-actions">
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/duplicate">
|
||||
<input type="hidden" name="pool" value="fullYear" />
|
||||
<button type="submit" class="button subtle">Duplicate</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove">
|
||||
<input type="hidden" name="pool" value="fullYear" />
|
||||
<button type="submit" class="button subtle">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
<% }) %>
|
||||
<form method="post" action="/plugins/birthday/templates/create" class="form-grid">
|
||||
<input type="hidden" name="pool" value="fullYear" />
|
||||
<div class="field full">
|
||||
<label>New template</label>
|
||||
<textarea name="text" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Add template</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Partial-year birthday messages</h2>
|
||||
<% const partialYearMessages = config.response_templates.partialYear; %>
|
||||
<% if (!partialYearMessages.length) { %>
|
||||
<p>No templates configured.</p>
|
||||
<% } %>
|
||||
<% partialYearMessages.forEach((message) => { %>
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/update" class="form-grid">
|
||||
<input type="hidden" name="pool" value="partialYear" />
|
||||
<div class="field full">
|
||||
<label>Template</label>
|
||||
<textarea name="text" rows="3"><%= message.text %></textarea>
|
||||
<p class="hint">Preview: <%= previewTemplate(message.text) %></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Enabled</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="enabled" <%= message.enabled ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= message.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field profile-actions">
|
||||
<button type="submit" class="button subtle">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="profile-actions">
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/duplicate">
|
||||
<input type="hidden" name="pool" value="partialYear" />
|
||||
<button type="submit" class="button subtle">Duplicate</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove">
|
||||
<input type="hidden" name="pool" value="partialYear" />
|
||||
<button type="submit" class="button subtle">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
<% }) %>
|
||||
<form method="post" action="/plugins/birthday/templates/create" class="form-grid">
|
||||
<input type="hidden" name="pool" value="partialYear" />
|
||||
<div class="field full">
|
||||
<label>New template</label>
|
||||
<textarea name="text" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Add template</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
45
plugins/birthday/views/profile-birthday.ejs
Normal file
45
plugins/birthday/views/profile-birthday.ejs
Normal file
@ -0,0 +1,45 @@
|
||||
<% if (typeof profileSection !== "undefined" && profileSection) { %>
|
||||
<% const birthday = getBirthday(user.id); %>
|
||||
<form method="post" action="/plugins/birthday/profile" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Birthday</label>
|
||||
<input name="birthday" value="<%= birthday ? (birthday.year ? birthday.year + '/' : '') + String(birthday.month).padStart(2, '0') + '/' + String(birthday.day).padStart(2, '0') : '' %>" placeholder="YYYY/MM/DD or MM/DD" />
|
||||
<p class="hint">Use YYYY/MM/DD or MM/DD. Year is optional.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Privacy</label>
|
||||
<select name="privacy">
|
||||
<% ["public", "limited", "private"].forEach((option) => { %>
|
||||
<option value="<%= option %>" <%= (birthday?.privacy || "limited") === option ? "selected" : "" %>><%= option %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<% if (birthday) { %>
|
||||
<p class="hint full">Stored birthday: <%= formatBirthday(birthday) %>.</p>
|
||||
<% } %>
|
||||
<div class="field full profile-actions">
|
||||
<button type="submit" class="button subtle">Save birthday</button>
|
||||
</div>
|
||||
</form>
|
||||
<% if (birthday) { %>
|
||||
<form method="post" action="/plugins/birthday/profile/unset">
|
||||
<button type="submit" class="button subtle">Remove birthday</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h1><%= target.internal_username %></h1>
|
||||
<p class="command-subtitle">Birthday profile</p>
|
||||
</div>
|
||||
</div>
|
||||
<% if (canView && birthday) { %>
|
||||
<p><strong>Birthday:</strong> <%= formatBirthdayDateOnly(birthday) %></p>
|
||||
<% } else { %>
|
||||
<p>This user's birthday is not visible to you.</p>
|
||||
<% } %>
|
||||
</section>
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
<% } %>
|
||||
97
plugins/lumi_ai/README.md
Normal file
97
plugins/lumi_ai/README.md
Normal file
@ -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.
|
||||
54
plugins/lumi_ai/backend/ai_provider.js
Normal file
54
plugins/lumi_ai/backend/ai_provider.js
Normal file
@ -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};
|
||||
68
plugins/lumi_ai/backend/config_manager.js
Normal file
68
plugins/lumi_ai/backend/config_manager.js
Normal file
@ -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 };
|
||||
58
plugins/lumi_ai/backend/diagnostics.js
Normal file
58
plugins/lumi_ai/backend/diagnostics.js
Normal file
@ -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 };
|
||||
103
plugins/lumi_ai/backend/downloader.js
Normal file
103
plugins/lumi_ai/backend/downloader.js
Normal file
@ -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()<size*1.2)throw new Error("not enough disk space");
|
||||
const job={id,state:"queued",downloaded:0,total:0,error:null,started_at:Date.now()};this.jobs.set(id,job);
|
||||
this.download({job,url,filename,sha256,kind,archive}).catch(error=>{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};
|
||||
74
plugins/lumi_ai/backend/error_codes.js
Normal file
74
plugins/lumi_ai/backend/error_codes.js
Normal file
@ -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 };
|
||||
47
plugins/lumi_ai/backend/hardware.js
Normal file
47
plugins/lumi_ai/backend/hardware.js
Normal file
@ -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 };
|
||||
55
plugins/lumi_ai/backend/metrics.js
Normal file
55
plugins/lumi_ai/backend/metrics.js
Normal file
@ -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 };
|
||||
20
plugins/lumi_ai/backend/paths.js
Normal file
20
plugins/lumi_ai/backend/paths.js
Normal file
@ -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 };
|
||||
10
plugins/lumi_ai/backend/permissions.js
Normal file
10
plugins/lumi_ai/backend/permissions.js
Normal file
@ -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 };
|
||||
21
plugins/lumi_ai/backend/prompt_builder.js
Normal file
21
plugins/lumi_ai/backend/prompt_builder.js
Normal file
@ -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 };
|
||||
24
plugins/lumi_ai/backend/queue_manager.js
Normal file
24
plugins/lumi_ai/backend/queue_manager.js
Normal file
@ -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.active<limit && this.pending.length){
|
||||
const job=this.pending.shift(); this.active++;
|
||||
Promise.resolve().then(()=>job.fn(Date.now()-job.queuedAt)).then(job.resolve,job.reject).finally(()=>{this.active--;this.drain();});
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { RequestQueue };
|
||||
290
plugins/lumi_ai/backend/runtime_manager.js
Normal file
290
plugins/lumi_ai/backend/runtime_manager.js
Normal file
@ -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 };
|
||||
43
plugins/lumi_ai/backend/tool_router.js
Normal file
43
plugins/lumi_ai/backend/tool_router.js
Normal file
@ -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<Date.now() || pending.userId!==user.id || pending.sessionId!==sessionId) throw new Error("Confirmation is invalid or expired.");
|
||||
return this.execute({checked:{def:pending.def,args:pending.args},user,requestId:id});
|
||||
}
|
||||
cancel(id,userId){ const p=this.confirmations.get(id); if(p?.userId===userId){this.confirmations.delete(id);return true;} return false; }
|
||||
}
|
||||
function parseToolCall(text){
|
||||
const match=(text||"").match(/\{[\s\S]*"type"\s*:\s*"tool_call"[\s\S]*\}/); if(!match)return null;
|
||||
try{const value=JSON.parse(match[0]);return value.type==="tool_call"?value:null;}catch{return null;}
|
||||
}
|
||||
module.exports = { ToolRegistry, parseToolCall };
|
||||
1
plugins/lumi_ai/cmds.json
Normal file
1
plugins/lumi_ai/cmds.json
Normal file
@ -0,0 +1 @@
|
||||
{"pluginId":"lumi_ai","commands":[]}
|
||||
1
plugins/lumi_ai/data/cache/.gitkeep
vendored
Normal file
1
plugins/lumi_ai/data/cache/.gitkeep
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/config/.gitkeep
Normal file
1
plugins/lumi_ai/data/config/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/diagnostics/.gitkeep
Normal file
1
plugins/lumi_ai/data/diagnostics/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/logs/.gitkeep
Normal file
1
plugins/lumi_ai/data/logs/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/metrics/.gitkeep
Normal file
1
plugins/lumi_ai/data/metrics/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/models/.gitkeep
Normal file
1
plugins/lumi_ai/data/models/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/rag/.gitkeep
Normal file
1
plugins/lumi_ai/data/rag/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/runtime/.gitkeep
Normal file
1
plugins/lumi_ai/data/runtime/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
plugins/lumi_ai/data/tmp/.gitkeep
Normal file
1
plugins/lumi_ai/data/tmp/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
376
plugins/lumi_ai/index.js
Normal file
376
plugins/lumi_ai/index.js
Normal file
@ -0,0 +1,376 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const express = require("express");
|
||||
const { ensureDataDirs, resolveData } = require("./backend/paths");
|
||||
const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager");
|
||||
const { detectHardware } = require("./backend/hardware");
|
||||
const metrics = require("./backend/metrics");
|
||||
const { canUse, roleOf } = require("./backend/permissions");
|
||||
const { RequestQueue } = require("./backend/queue_manager");
|
||||
const { ToolRegistry } = require("./backend/tool_router");
|
||||
const { DownloadManager } = require("./backend/downloader");
|
||||
const { RuntimeManager } = require("./backend/runtime_manager");
|
||||
const { AiProvider } = require("./backend/ai_provider");
|
||||
const { getLatestDiagnostic, createDiagnosticsBundle } = require("./backend/diagnostics");
|
||||
|
||||
const PLUGIN_ID = "lumi_ai";
|
||||
const modelManifest = require("./models_manifest.json");
|
||||
const runtimeManifest = require("./runtime_manifest.json");
|
||||
|
||||
module.exports = {
|
||||
id: PLUGIN_ID,
|
||||
init({ web, settings }) {
|
||||
ensureDataDirs();
|
||||
let config = getConfig();
|
||||
const getModel = (id) => modelManifest.models.find((model) => model.id === id);
|
||||
const downloads = new DownloadManager((entry) => metrics.record(entry));
|
||||
const queue = new RequestQueue(() => config);
|
||||
const tools = new ToolRegistry((entry) => metrics.record(entry));
|
||||
const contextProviders = new Map();
|
||||
const getSafeContext = (role) => [...contextProviders.values()].flatMap((fn) => {
|
||||
try { return normalizeContext(fn({ role })); } catch { return []; }
|
||||
});
|
||||
const runtime = new RuntimeManager({
|
||||
getConfig: () => config,
|
||||
getModel,
|
||||
runtimeManifest,
|
||||
onCrash: (message) => metrics.record({ kind: "runtime", status: "failed", runtime_crash: true, message }),
|
||||
onDiagnostic: (entry) => metrics.record(entry)
|
||||
});
|
||||
const provider = new AiProvider({
|
||||
getConfig: () => config,
|
||||
runtime,
|
||||
queue,
|
||||
tools,
|
||||
metrics,
|
||||
getContext: getSafeContext
|
||||
});
|
||||
|
||||
const api = {
|
||||
health: () => runtime.health(),
|
||||
capabilities: () => ({
|
||||
provider: "local_llama_cpp",
|
||||
enabled: config.enabled,
|
||||
model_id: config.selected_model_id,
|
||||
roles: config.assistant_visibility,
|
||||
tools: tools.list("admin").map((tool) => tool.tool_id)
|
||||
}),
|
||||
metrics_summary: () => metrics.report(),
|
||||
generate: (input) => provider.generate(input),
|
||||
classify: (input) => provider.classify(input),
|
||||
summarize: (input) => provider.summarize(input),
|
||||
route_tool: async ({ message, allowed_tools = [], ...input }) => {
|
||||
const result = await provider.generate({ message, ...input, scope: "route_tool" });
|
||||
if (result.tool_call && !allowed_tools.includes(result.tool_call.tool)) {
|
||||
return { ...result, success: false, tool_call: null, refusal_reason: "tool_not_allowed" };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
registerTool: (definition) => tools.register(definition),
|
||||
registerContext: (id, factory) => {
|
||||
if (!id || typeof factory !== "function") throw new Error("Invalid AI context provider.");
|
||||
contextProviders.set(id, factory);
|
||||
},
|
||||
unregisterContext: (id) => contextProviders.delete(id)
|
||||
};
|
||||
global.lumiFrameworks = global.lumiFrameworks || {};
|
||||
global.lumiFrameworks.ai = api;
|
||||
global.lumiFrameworks.lumi_ai = api;
|
||||
|
||||
const router = web.createRouter();
|
||||
router.use("/assets", express.static(path.join(__dirname, "public")));
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const hardware = detectHardware(modelManifest.models);
|
||||
const runtimeTarget = getRuntimeTarget();
|
||||
res.render(path.join(__dirname, "views", "settings.ejs"), {
|
||||
title: "Lumi AI",
|
||||
config,
|
||||
models: modelManifest.models.map((model) => ({
|
||||
...model,
|
||||
downloaded: fs.existsSync(resolveData("models", model.filename)),
|
||||
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
|
||||
})),
|
||||
runtimeTarget,
|
||||
runtimeManifest,
|
||||
runtimeStatus: await runtime.health(),
|
||||
runtimeState: getRuntimeState(),
|
||||
latestDiagnostic: getLatestDiagnostic(),
|
||||
runtimeFolderSize: folderSize(resolveData("runtime")),
|
||||
modelFileSize: modelFileSize(getModel(config.selected_model_id)),
|
||||
hardware,
|
||||
metrics: metrics.report(),
|
||||
history: metrics.history(25),
|
||||
logFiles: listLogFiles(),
|
||||
formatBytes,
|
||||
formatDuration
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/settings", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const model = getModel(req.body.selected_model_id);
|
||||
if (!model) return flash(req, res, "error", "Unknown model.");
|
||||
config = saveConfig({
|
||||
...config,
|
||||
enabled: req.body.enabled === "on",
|
||||
selected_model_id: model.id,
|
||||
context_size: boundedInt(req.body.context_size, 512, 131072, 4096),
|
||||
threads: boundedInt(req.body.threads, 0, 256, 0),
|
||||
concurrency: boundedInt(req.body.concurrency, 1, 8, 1),
|
||||
max_queue_length: boundedInt(req.body.max_queue_length, 1, 100, 8),
|
||||
request_timeout_ms: boundedInt(req.body.request_timeout_ms, 5000, 600000, 120000),
|
||||
per_user_requests_per_minute: boundedInt(req.body.per_user_requests_per_minute, 1, 120, 6),
|
||||
admin_bypass_rate_limit: req.body.admin_bypass_rate_limit === "on",
|
||||
assistant_visibility: {
|
||||
admins: req.body.visibility_admins === "on",
|
||||
mods: req.body.visibility_mods === "on",
|
||||
users: req.body.visibility_users === "on"
|
||||
},
|
||||
instructions: {
|
||||
identity: cleanText(req.body.identity, 1000),
|
||||
style: cleanText(req.body.style, 1000),
|
||||
allowed_topics: cleanText(req.body.allowed_topics, 2000),
|
||||
out_of_scope_response: cleanText(req.body.out_of_scope_response, 1000),
|
||||
maximum_answer_length: boundedInt(req.body.maximum_answer_length, 100, 4000, 700),
|
||||
roleplay_intensity: boundedInt(req.body.roleplay_intensity, 0, 10, 0),
|
||||
community_tone: cleanText(req.body.community_tone, 2000),
|
||||
admin_custom: cleanText(req.body.admin_custom, 6000)
|
||||
},
|
||||
logging: {
|
||||
log_prompts: req.body.log_prompts === "on",
|
||||
log_responses: req.body.log_responses === "on",
|
||||
log_tool_calls: req.body.log_tool_calls === "on",
|
||||
log_metrics: req.body.log_metrics === "on",
|
||||
log_internal_audit: req.body.log_internal_audit === "on"
|
||||
}
|
||||
});
|
||||
return flash(req, res, "success", "Lumi AI settings saved.");
|
||||
});
|
||||
|
||||
router.post("/download/runtime", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const target = getRuntimeTarget();
|
||||
if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform.");
|
||||
try {
|
||||
downloads.start({ id: "runtime", ...target, kind: "runtime", archive: true });
|
||||
return flash(req, res, "success", "Runtime download started.");
|
||||
} catch (error) {
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/download/model/:id", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const model = getModel(req.params.id);
|
||||
if (!model) return flash(req, res, "error", "Unknown model.");
|
||||
const hardware = detectHardware(modelManifest.models);
|
||||
const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb;
|
||||
if (incompatible && req.body.override_compatibility !== "on") {
|
||||
return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway.");
|
||||
}
|
||||
try {
|
||||
downloads.start({
|
||||
id: `model:${model.id}`,
|
||||
url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`,
|
||||
filename: model.filename,
|
||||
sha256: model.sha256,
|
||||
size: model.size,
|
||||
kind: "model"
|
||||
});
|
||||
return flash(req, res, "success", `${model.label} download started.`);
|
||||
} catch (error) {
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/status", async (req, res) => {
|
||||
if (!canUse(req.session.user, config) && !req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
||||
res.json({ runtime: await runtime.health(), queue_length: queue.length, enabled: config.enabled, model_id: config.selected_model_id });
|
||||
});
|
||||
router.get("/api/downloads", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
||||
res.json(Object.fromEntries(downloads.jobs));
|
||||
});
|
||||
router.post("/runtime/:action", async (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
||||
try {
|
||||
const action = req.params.action;
|
||||
if (!["start", "stop", "restart", "self-test", "verify-runtime", "verify-model"].includes(action)) throw new Error("Unknown runtime action.");
|
||||
const result = action === "self-test" ? await runtime.selfTest()
|
||||
: action === "verify-runtime" ? runtime.verifyRuntimeInstallation()
|
||||
: action === "verify-model" ? await runtime.verifyModel()
|
||||
: action === "stop"
|
||||
? await runtime.stop({ manual: true, reason: "admin_stop" })
|
||||
: action === "restart" ? await runtime.restart() : await runtime.start();
|
||||
if (result?.success === false) return res.status(400).json({ error: result.message, diagnostic: result });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
router.get("/diagnostics/download", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
||||
const file = createDiagnosticsBundle({
|
||||
config,
|
||||
runtimeState: getRuntimeState(),
|
||||
manifest: { runtime: runtimeManifest, model: getModel(config.selected_model_id) },
|
||||
metrics: metrics.report()
|
||||
});
|
||||
return res.download(file);
|
||||
});
|
||||
router.post("/assistant/message", async (req, res) => {
|
||||
if (!config.enabled || !canUse(req.session.user, config)) return res.status(403).json({ error: "Lumi AI is unavailable for this account." });
|
||||
const message = cleanText(req.body.message, 6000);
|
||||
if (!message) return res.status(400).json({ error: "Message is required." });
|
||||
try {
|
||||
res.json(await provider.generate({ message, user: req.session.user, sessionId: req.sessionID }));
|
||||
} catch (error) {
|
||||
metrics.record({ kind: "request", status: "failed", user_id: req.session.user.id, role: roleOf(req.session.user), message: error.message });
|
||||
res.status(error.code === "QUEUE_FULL" || error.code === "RATE_LIMIT" ? 429 : 503).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
router.post("/assistant/test", async (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
||||
const message = cleanText(req.body.message, 6000);
|
||||
if (!message) return res.status(400).json({ error: "Message is required." });
|
||||
const simulatedRole = ["admin", "mod", "user"].includes(req.body.role) ? req.body.role : "admin";
|
||||
const simulatedUser = {
|
||||
id: req.session.user.id,
|
||||
username: req.session.user.username,
|
||||
isAdmin: simulatedRole === "admin",
|
||||
isMod: simulatedRole === "mod"
|
||||
};
|
||||
try {
|
||||
const result = await provider.test({
|
||||
message,
|
||||
user: simulatedUser,
|
||||
includeRaw: Boolean(req.body.show_raw_output)
|
||||
});
|
||||
if (!req.body.show_raw_prompt) delete result.raw_prompt;
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(503).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
router.post("/assistant/confirm", async (req, res) => {
|
||||
if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." });
|
||||
try { res.json({ success: true, result: await tools.confirm({ id: req.body.id, user: req.session.user, sessionId: req.sessionID }) }); }
|
||||
catch (error) { res.status(400).json({ error: error.message }); }
|
||||
});
|
||||
router.post("/assistant/cancel", (req, res) => {
|
||||
if (!canUse(req.session.user, config)) return res.status(403).json({ error: "Access denied." });
|
||||
const cancelled = tools.cancel(req.body.id, req.session.user.id);
|
||||
metrics.record({ kind: "tool", status: cancelled ? "cancelled" : "failed", user_id: req.session.user.id });
|
||||
res.json({ success: cancelled });
|
||||
});
|
||||
|
||||
web.mount(`/plugins/${PLUGIN_ID}`, router, {
|
||||
label: "Lumi AI",
|
||||
role: "admin",
|
||||
section: "plugins"
|
||||
});
|
||||
if (typeof web.addAssistantPanel === "function") {
|
||||
web.addAssistantPanel({
|
||||
id: PLUGIN_ID,
|
||||
role: "user",
|
||||
isVisible: (user) => config.enabled && canUse(user, config),
|
||||
view: path.join(__dirname, "views", "assistant-panel.ejs"),
|
||||
stylesheet: `/plugins/${PLUGIN_ID}/assets/assistant.css`,
|
||||
script: `/plugins/${PLUGIN_ID}/assets/assistant.js`,
|
||||
locals: { endpoint: `/plugins/${PLUGIN_ID}` }
|
||||
});
|
||||
} else {
|
||||
console.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible.");
|
||||
}
|
||||
ensureSidebarNavItem(settings);
|
||||
|
||||
const state = getRuntimeState();
|
||||
if (shouldAutoResume(config, state)) {
|
||||
setImmediate(() => runtime.start({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error)));
|
||||
}
|
||||
|
||||
return async () => {
|
||||
await runtime.stop({ manual: false, reason: "bot_shutdown" });
|
||||
if (global.lumiFrameworks?.ai === api) delete global.lumiFrameworks.ai;
|
||||
if (global.lumiFrameworks?.lumi_ai === api) delete global.lumiFrameworks.lumi_ai;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function getRuntimeTarget() {
|
||||
return runtimeManifest.targets[`${process.platform}-${process.arch}`] || null;
|
||||
}
|
||||
function normalizeContext(value) {
|
||||
if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
|
||||
return typeof value === "string" ? [value] : [];
|
||||
}
|
||||
function boundedInt(value, min, max, fallback) {
|
||||
const number = Number.parseInt(value, 10);
|
||||
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
||||
}
|
||||
function cleanText(value, max) {
|
||||
return String(value || "").trim().slice(0, max);
|
||||
}
|
||||
function flash(req, res, type, message) {
|
||||
req.session.flash = { type, message };
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
function denied(res) {
|
||||
return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." });
|
||||
}
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "MB", "GB", "TB"];
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
function formatDuration(ms) {
|
||||
if (!ms) return "0 ms";
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
function listLogFiles() {
|
||||
const dir = resolveData("logs");
|
||||
return fs.readdirSync(dir).filter((name) => name.endsWith(".log")).sort().reverse().slice(0, 10).map((name) => ({ name, size: fs.statSync(path.join(dir, name)).size }));
|
||||
}
|
||||
function folderSize(dir) {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => {
|
||||
const target = path.join(dir, entry.name);
|
||||
return total + (entry.isDirectory() ? folderSize(target) : entry.isFile() ? fs.statSync(target).size : 0);
|
||||
}, 0);
|
||||
}
|
||||
function modelFileSize(model) {
|
||||
if (!model) return 0;
|
||||
const file = resolveData("models", model.filename);
|
||||
return fs.existsSync(file) ? fs.statSync(file).size : 0;
|
||||
}
|
||||
function ensureSidebarNavItem(settings) {
|
||||
if (!settings?.getSetting || !settings?.setSetting) return;
|
||||
const raw = settings.getSetting("nav_structure", null);
|
||||
if (!raw) return;
|
||||
let structure = raw;
|
||||
if (typeof structure === "string") {
|
||||
try { structure = JSON.parse(structure); } catch { return; }
|
||||
}
|
||||
if (!structure?.enabled || !Array.isArray(structure.sections)) return;
|
||||
const navId = "plugins_lumi_ai";
|
||||
for (const section of structure.sections) {
|
||||
if (Array.isArray(section.items)) section.items = section.items.filter((item) => item !== navId);
|
||||
}
|
||||
let plugins = structure.sections.find((section) => section.id === "plugins");
|
||||
if (!plugins) {
|
||||
plugins = { id: "plugins", label: "Plugins", icon: "blocks", items: [] };
|
||||
structure.sections.push(plugins);
|
||||
}
|
||||
plugins.items = Array.isArray(plugins.items) ? plugins.items : [];
|
||||
plugins.items.push(navId);
|
||||
settings.setSetting("nav_structure", structure);
|
||||
}
|
||||
|
||||
function shouldAutoResume(config, state) {
|
||||
return Boolean(config.enabled && state.desired_state === "running" && !state.last_manual_stop && !state.last_crashed);
|
||||
}
|
||||
|
||||
module.exports.shouldAutoResume = shouldAutoResume;
|
||||
207
plugins/lumi_ai/models_manifest.json
Normal file
207
plugins/lumi_ai/models_manifest.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
plugins/lumi_ai/plugin.json
Normal file
7
plugins/lumi_ai/plugin.json
Normal file
@ -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"
|
||||
}
|
||||
29
plugins/lumi_ai/public/assistant.css
Normal file
29
plugins/lumi_ai/public/assistant.css
Normal file
@ -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; }
|
||||
122
plugins/lumi_ai/public/assistant.js
Normal file
122
plugins/lumi_ai/public/assistant.js
Normal file
@ -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);
|
||||
})();
|
||||
42
plugins/lumi_ai/public/settings.css
Normal file
42
plugins/lumi_ai/public/settings.css
Normal file
@ -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; }
|
||||
}
|
||||
71
plugins/lumi_ai/public/settings.js
Normal file
71
plugins/lumi_ai/public/settings.js
Normal file
@ -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();
|
||||
})();
|
||||
36
plugins/lumi_ai/runtime_manifest.json
Normal file
36
plugins/lumi_ai/runtime_manifest.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
1
plugins/lumi_ai/templates/role_admin.txt
Normal file
1
plugins/lumi_ai/templates/role_admin.txt
Normal file
@ -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.
|
||||
1
plugins/lumi_ai/templates/role_mod.txt
Normal file
1
plugins/lumi_ai/templates/role_mod.txt
Normal file
@ -0,0 +1 @@
|
||||
The user is a Lumi moderator. Do not expose administrator-only settings, secrets, logs, or tools.
|
||||
1
plugins/lumi_ai/templates/role_user.txt
Normal file
1
plugins/lumi_ai/templates/role_user.txt
Normal file
@ -0,0 +1 @@
|
||||
The user is a regular Lumi user. Only provide public or self-service information and tools.
|
||||
5
plugins/lumi_ai/templates/system.txt
Normal file
5
plugins/lumi_ai/templates/system.txt
Normal file
@ -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":{}}.
|
||||
157
plugins/lumi_ai/tests/verify.js
Normal file
157
plugins/lumi_ai/tests/verify.js
Normal file
@ -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;
|
||||
});
|
||||
25
plugins/lumi_ai/views/assistant-panel.ejs
Normal file
25
plugins/lumi_ai/views/assistant-panel.ejs
Normal file
@ -0,0 +1,25 @@
|
||||
<div class="lumi-ai-shell" data-lumi-ai data-endpoint="<%= endpoint %>">
|
||||
<button class="lumi-ai-pill" type="button" data-lumi-ai-toggle aria-expanded="false" aria-controls="lumi-ai-panel">
|
||||
<span class="lumi-ai-mark" aria-hidden="true">AI</span>
|
||||
<span class="lumi-ai-pill-label">AI Assistant</span>
|
||||
<span class="lumi-ai-state" data-lumi-ai-state title="Checking runtime"></span>
|
||||
</button>
|
||||
<section class="lumi-ai-panel" id="lumi-ai-panel" data-lumi-ai-panel aria-hidden="true" aria-label="Lumi AI Assistant">
|
||||
<header class="lumi-ai-header">
|
||||
<div>
|
||||
<strong>Lumi AI</strong>
|
||||
<span data-lumi-ai-status>Checking local runtime</span>
|
||||
</div>
|
||||
<button type="button" class="lumi-ai-close" data-lumi-ai-close aria-label="Close AI Assistant" title="Close">×</button>
|
||||
</header>
|
||||
<div class="lumi-ai-messages" data-lumi-ai-messages aria-live="polite">
|
||||
<div class="lumi-ai-message assistant">Ask about Lumi, plugins, settings, streams, or community systems.</div>
|
||||
</div>
|
||||
<form class="lumi-ai-compose" data-lumi-ai-form>
|
||||
<textarea name="message" rows="2" maxlength="6000" placeholder="Ask Lumi AI" aria-label="Message Lumi AI" required></textarea>
|
||||
<button type="submit" aria-label="Send message" title="Send">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4l17 8-17 8 3-8zM7 12h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
219
plugins/lumi_ai/views/settings.ejs
Normal file
219
plugins/lumi_ai/views/settings.ejs
Normal file
@ -0,0 +1,219 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" />
|
||||
|
||||
<section class="ai-titlebar">
|
||||
<div>
|
||||
<h1>Lumi AI</h1>
|
||||
<p>Managed local inference, assistant access, and guarded plugin tools.</p>
|
||||
</div>
|
||||
<div class="ai-runtime-badge <%= runtimeStatus.healthy ? 'ready' : 'offline' %>">
|
||||
<span></span><%= runtimeStatus.healthy ? "Runtime ready" : "Runtime offline" %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="ai-tabs" aria-label="Lumi AI settings">
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#models">Models</a>
|
||||
<a href="#runtime">Runtime</a>
|
||||
<a href="#assistant">Assistant</a>
|
||||
<a href="#metrics">Metrics</a>
|
||||
</nav>
|
||||
|
||||
<section class="ai-band" id="overview">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Overview</h2><p>Current installation and host capacity.</p></div>
|
||||
</div>
|
||||
<div class="ai-stat-grid">
|
||||
<div><span>Provider</span><strong>llama.cpp</strong></div>
|
||||
<div><span>Selected model</span><strong><%= models.find((model) => model.id === config.selected_model_id)?.label || config.selected_model_id %></strong></div>
|
||||
<div><span>RAM</span><strong><%= Math.round(hardware.total_ram_mb / 1024) %> GB</strong></div>
|
||||
<div><span>Free disk</span><strong><%= formatBytes(hardware.free_disk_mb * 1048576) %></strong></div>
|
||||
<div><span>CPU threads</span><strong><%= hardware.cpu_threads %></strong></div>
|
||||
<div><span>GPU</span><strong><%= hardware.gpu.present ? hardware.gpu.name : "Not detected" %></strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-band" id="models">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Models</h2><p>Pinned GGUF files downloaded directly from Hugging Face and verified by SHA-256.</p></div>
|
||||
</div>
|
||||
<div class="ai-model-list">
|
||||
<% models.forEach((model) => { %>
|
||||
<article class="ai-model-row">
|
||||
<div class="ai-model-main">
|
||||
<strong><%= model.label %></strong>
|
||||
<span><%= formatBytes(model.size) %> · <%= model.ram_gb %> GB recommended RAM · <%= model.repo %></span>
|
||||
</div>
|
||||
<span class="ai-tag <%= model.downloaded ? 'installed' : model.compatible ? '' : 'warning' %>">
|
||||
<%= model.downloaded ? "Installed" : model.compatible ? "Available" : "Exceeds host" %>
|
||||
</span>
|
||||
<% if (!model.downloaded) { %>
|
||||
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" class="ai-inline-form">
|
||||
<% if (!model.compatible) { %>
|
||||
<label title="Allow download despite detected capacity"><input type="checkbox" name="override_compatibility" /> Override</label>
|
||||
<% } %>
|
||||
<button class="button subtle" type="submit">Download</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</article>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-band" id="runtime">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Runtime</h2><p>Official llama.cpp release, bound to localhost and stored inside this plugin.</p></div>
|
||||
<div class="ai-actions" data-ai-runtime-actions>
|
||||
<button class="button" type="button" data-runtime-action="start">Start</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="self-test">Run self-test</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="verify-runtime">Verify runtime</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="verify-model">Verify model</button>
|
||||
<button class="button subtle" type="button" data-runtime-action="restart">Restart</button>
|
||||
<button class="button danger" type="button" data-runtime-action="stop">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-runtime-grid">
|
||||
<div class="ai-diagnostic">
|
||||
<span>Installed</span><strong><%= runtimeStatus.runtime_installed ? "Yes" : "No" %></strong>
|
||||
<span>Process</span><strong data-runtime-state><%= runtimeStatus.state %></strong>
|
||||
<span>Health</span><strong><%= runtimeStatus.healthy ? "Healthy" : "Unavailable" %></strong>
|
||||
<span>PID</span><strong><%= runtimeStatus.pid || "None" %></strong>
|
||||
<span>Last stop</span><strong><%= runtimeState.last_stop_reason %></strong>
|
||||
<span>Platform</span><strong><%= hardware.platform %>-<%= hardware.architecture %></strong>
|
||||
<span>Self-test</span><strong><%= runtimeStatus.last_self_test?.success ? "Passed" : runtimeStatus.last_self_test ? "Failed" : "Not run" %></strong>
|
||||
<span>Runtime folder</span><strong><%= formatBytes(runtimeFolderSize) %></strong>
|
||||
<span>Runtime archive</span><strong><%= runtimeTarget ? formatBytes(runtimeTarget.size) : "Unavailable" %></strong>
|
||||
<span>Model installed</span><strong><%= formatBytes(modelFileSize) %></strong>
|
||||
<span>Model download</span><strong><%= formatBytes(models.find((model) => model.id === config.selected_model_id)?.size || 0) %></strong>
|
||||
</div>
|
||||
<div>
|
||||
<% if (runtimeTarget) { %>
|
||||
<p><strong>Managed release <%= runtimeManifest?.version || "b9592" %></strong></p>
|
||||
<p class="hint"><%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %></p>
|
||||
<form method="post" action="/plugins/lumi_ai/download/runtime">
|
||||
<button class="button subtle" type="submit"><%= runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" %></button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<div class="callout">No managed runtime build is available for this OS and architecture.</div>
|
||||
<% } %>
|
||||
<% if (runtimeStatus.last_error) { %><div class="callout danger"><%= runtimeStatus.last_error %></div><% } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-download-status" data-download-status hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="ai-band" id="runtime-diagnostics">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Runtime diagnostics</h2><p>Latest plugin-local runtime failure and remediation details.</p></div>
|
||||
<a class="button subtle" href="/plugins/lumi_ai/diagnostics/download">Download diagnostics</a>
|
||||
</div>
|
||||
<% if (latestDiagnostic) { %>
|
||||
<div class="callout danger">
|
||||
<strong><%= latestDiagnostic.code %>: <%= latestDiagnostic.message %></strong>
|
||||
<p><%= latestDiagnostic.category %> / <%= latestDiagnostic.severity %></p>
|
||||
</div>
|
||||
<% if (latestDiagnostic.remediation_steps?.length) { %>
|
||||
<ol class="ai-remediation"><% latestDiagnostic.remediation_steps.forEach((step) => { %><li><%= step %></li><% }) %></ol>
|
||||
<% } %>
|
||||
<details class="ai-raw-diagnostic">
|
||||
<summary>Raw diagnostic details</summary>
|
||||
<pre><%= JSON.stringify(latestDiagnostic, null, 2) %></pre>
|
||||
</details>
|
||||
<% } else { %>
|
||||
<p class="hint">No runtime diagnostic has been recorded.</p>
|
||||
<% } %>
|
||||
<% if (hardware.network_path_warning) { %>
|
||||
<div class="callout">The plugin path may be a mapped or network-like location. A local disk path is more reliable for native runtime DLL loading.</div>
|
||||
<% } %>
|
||||
<% if (hardware.long_path_warning) { %><div class="callout">The plugin path is unusually long for Windows native loading. Consider a shorter local installation path.</div><% } %>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/plugins/lumi_ai/settings">
|
||||
<section class="ai-band" id="assistant">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Assistant</h2><p>Configuration remains admin-only. Visibility controls only the sidebar assistant.</p></div>
|
||||
<button class="button" type="submit">Save settings</button>
|
||||
</div>
|
||||
<div class="form-grid ai-form">
|
||||
<div class="field">
|
||||
<label>AI enabled</label>
|
||||
<label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= config.enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Available</span></label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="selected-model">Selected model</label>
|
||||
<select id="selected-model" name="selected_model_id"><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
|
||||
</div>
|
||||
<div class="field"><label>Context size</label><input type="number" name="context_size" min="512" max="131072" value="<%= config.context_size %>" /></div>
|
||||
<div class="field"><label>CPU threads (0 = auto)</label><input type="number" name="threads" min="0" max="256" value="<%= config.threads %>" /></div>
|
||||
<div class="field"><label>Concurrent requests</label><input type="number" name="concurrency" min="1" max="8" value="<%= config.concurrency %>" /></div>
|
||||
<div class="field"><label>Maximum queue</label><input type="number" name="max_queue_length" min="1" max="100" value="<%= config.max_queue_length %>" /></div>
|
||||
<div class="field"><label>Timeout (ms)</label><input type="number" name="request_timeout_ms" min="5000" max="600000" value="<%= config.request_timeout_ms %>" /></div>
|
||||
<div class="field"><label>Requests per user/minute</label><input type="number" name="per_user_requests_per_minute" min="1" max="120" value="<%= config.per_user_requests_per_minute %>" /></div>
|
||||
<div class="field"><label>Admin rate-limit bypass</label><label class="switch"><input class="switch-input" type="checkbox" name="admin_bypass_rate_limit" <%= config.admin_bypass_rate_limit ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Bypass</span></label></div>
|
||||
<fieldset class="field full ai-fieldset">
|
||||
<legend>Sidebar visibility</legend>
|
||||
<label><input type="checkbox" name="visibility_admins" <%= config.assistant_visibility.admins ? "checked" : "" %> /> Administrators</label>
|
||||
<label><input type="checkbox" name="visibility_mods" <%= config.assistant_visibility.mods ? "checked" : "" %> /> Moderators</label>
|
||||
<label><input type="checkbox" name="visibility_users" <%= config.assistant_visibility.users ? "checked" : "" %> /> Users</label>
|
||||
</fieldset>
|
||||
<div class="field full"><label>Identity</label><textarea name="identity" rows="2"><%= config.instructions.identity %></textarea></div>
|
||||
<div class="field full"><label>Response style</label><textarea name="style" rows="2"><%= config.instructions.style %></textarea></div>
|
||||
<div class="field full"><label>Allowed topics</label><textarea name="allowed_topics" rows="2"><%= config.instructions.allowed_topics %></textarea></div>
|
||||
<div class="field full"><label>Out-of-scope response</label><textarea name="out_of_scope_response" rows="2"><%= config.instructions.out_of_scope_response %></textarea></div>
|
||||
<div class="field"><label>Maximum answer length</label><input type="number" name="maximum_answer_length" min="100" max="4000" value="<%= config.instructions.maximum_answer_length %>" /></div>
|
||||
<div class="field"><label>Roleplay intensity (0-10)</label><input type="number" name="roleplay_intensity" min="0" max="10" value="<%= config.instructions.roleplay_intensity || 0 %>" /></div>
|
||||
<div class="field full"><label>Community tone</label><textarea name="community_tone" rows="2"><%= config.instructions.community_tone %></textarea></div>
|
||||
<div class="field full"><label>Admin custom instructions</label><textarea name="admin_custom" rows="4"><%= config.instructions.admin_custom %></textarea><span class="hint">Hard scope, role, tool, and confirmation rules cannot be overridden.</span></div>
|
||||
<fieldset class="field full ai-fieldset">
|
||||
<legend>Logging</legend>
|
||||
<% [["log_prompts","Prompts"],["log_responses","Responses"],["log_tool_calls","Tool calls"],["log_metrics","Metrics"],["log_internal_audit","Internal audit"]].forEach(([key,label]) => { %>
|
||||
<label><input type="checkbox" name="<%= key %>" <%= config.logging[key] ? "checked" : "" %> /> <%= label %></label>
|
||||
<% }) %>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<section class="ai-band" id="test-console">
|
||||
<div class="ai-section-heading"><div><h2>Test console</h2><p>Run a request as a simulated role without changing the logged-in actor.</p></div></div>
|
||||
<form class="form-grid ai-form" data-ai-test-form>
|
||||
<div class="field"><label>Simulated role</label><select name="role"><option value="admin">Admin</option><option value="mod">Moderator</option><option value="user">User</option></select></div>
|
||||
<div class="field full"><label>Message</label><textarea name="message" rows="3" required>Where can I find Twitch configuration?</textarea></div>
|
||||
<div class="field full ai-fieldset">
|
||||
<label><input type="checkbox" name="show_raw_prompt" /> Show assembled prompt</label>
|
||||
<label><input type="checkbox" name="show_raw_output" /> Show raw model response</label>
|
||||
</div>
|
||||
<div class="field full"><button class="button" type="submit">Run test</button></div>
|
||||
</form>
|
||||
<pre class="ai-test-output" data-ai-test-output hidden></pre>
|
||||
</section>
|
||||
|
||||
<section class="ai-band" id="metrics">
|
||||
<div class="ai-section-heading"><div><h2>Metrics</h2><p>Plugin-local operational counters and recent requests.</p></div></div>
|
||||
<div class="ai-stat-grid compact">
|
||||
<div><span>Requests</span><strong><%= metrics.total_requests %></strong></div>
|
||||
<div><span>Successful</span><strong><%= metrics.successful %></strong></div>
|
||||
<div><span>Failed</span><strong><%= metrics.failed %></strong></div>
|
||||
<div><span>Refused</span><strong><%= metrics.refusals %></strong></div>
|
||||
<div><span>Average</span><strong><%= formatDuration(metrics.average_response_ms) %></strong></div>
|
||||
<div><span>Median</span><strong><%= formatDuration(metrics.median_response_ms) %></strong></div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table"><thead><tr><th>Time</th><th>Kind</th><th>Status</th><th>Role</th><th>Duration</th></tr></thead><tbody>
|
||||
<% history.forEach((entry) => { %><tr><td><%= entry.timestamp %></td><td><%= entry.kind %></td><td><%= entry.status %></td><td><%= entry.role || "-" %></td><td><%= formatDuration(entry.duration_ms) %></td></tr><% }) %>
|
||||
<% if (!history.length) { %><tr><td colspan="5">No requests recorded.</td></tr><% } %>
|
||||
</tbody></table>
|
||||
</div>
|
||||
<% if (logFiles.length) { %><p class="hint">Runtime logs: <%= logFiles.map((file) => `${file.name} (${formatBytes(file.size)})`).join(", ") %></p><% } %>
|
||||
</section>
|
||||
|
||||
<section class="ai-band">
|
||||
<div class="ai-section-heading"><div><h2>Privacy and troubleshooting</h2><p>Local inference remains on this host.</p></div></div>
|
||||
<div class="callout">
|
||||
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.
|
||||
</div>
|
||||
<p class="hint">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 <code>plugins/lumi_ai/data/logs/</code>.</p>
|
||||
</section>
|
||||
|
||||
<script src="/plugins/lumi_ai/assets/settings.js?v=<%= assetVersion %>" defer></script>
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
32
scripts/verify-plugin-update-preserves-data.js
Normal file
32
scripts/verify-plugin-update-preserves-data.js
Normal file
@ -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 });
|
||||
}
|
||||
20
src/main.js
20
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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<% const optionalAssistantPanels =
|
||||
typeof assistantPanels !== "undefined" && Array.isArray(assistantPanels)
|
||||
? assistantPanels
|
||||
: []; %>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= title %> - <%= siteTitle %></title>
|
||||
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
|
||||
<% optionalAssistantPanels.forEach((panel) => { if (panel.stylesheet) { %>
|
||||
<link rel="stylesheet" href="<%= panel.stylesheet %>?v=<%= assetVersion %>" />
|
||||
<% } }) %>
|
||||
<% if (theme) { %>
|
||||
<style>
|
||||
:root {
|
||||
@ -92,6 +99,13 @@
|
||||
</details>
|
||||
<% }) %>
|
||||
</nav>
|
||||
<% if (optionalAssistantPanels.length) { %>
|
||||
<div class="sidebar-assistant-panels">
|
||||
<% optionalAssistantPanels.forEach((panel) => { %>
|
||||
<%- include(panel.view, panel.locals || {}) %>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="sidebar-footer">
|
||||
<% if (user) { %>
|
||||
<a href="/profile" class="user-chip user-chip-link" title="View profile">
|
||||
@ -138,4 +152,7 @@
|
||||
<% if (flash) { %>
|
||||
<div class="flash <%= flash.type %>"><%= flash.message %></div>
|
||||
<% } %>
|
||||
<% optionalAssistantPanels.forEach((panel) => { if (panel.script) { %>
|
||||
<script src="<%= panel.script %>?v=<%= assetVersion %>" defer></script>
|
||||
<% } }) %>
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user