Lumi/plugins/lumi_ai/backend/tool_settings.js
2026-06-13 21:32:36 +02:00

161 lines
6.0 KiB
JavaScript

const fs = require("fs");
const path = require("path");
class ToolSettings {
constructor(options = {}) {
this.installer = options.installer;
}
describe(toolId) {
const local = this.installer.local(toolId);
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
const schema = normalizeSchema(local.metadata.settings_schema);
if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings.");
const values = this.readValues(local.dir, schema);
return {
tool_id: toolId,
display_name: local.metadata.display_name,
schema,
values: redactSecrets(values, schema),
configured_secrets: Object.fromEntries(
Object.entries(schema)
.filter(([, field]) => field.secret === true)
.map(([key]) => [key, Boolean(values[key])])
)
};
}
save(toolId, input) {
const local = this.installer.local(toolId);
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
const schema = normalizeSchema(local.metadata.settings_schema);
if (!Object.keys(schema).length) throw new Error("This AI tool does not expose configurable settings.");
const current = this.readValues(local.dir, schema);
const next = {};
for (const [key, field] of Object.entries(schema)) {
const incoming = input?.[key];
if (field.secret === true && (incoming == null || String(incoming) === "")) {
next[key] = current[key] ?? field.default;
} else {
next[key] = normalizeValue(incoming, field, key);
}
}
const file = settingsFile(local.dir);
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
try { fs.chmodSync(temporary, 0o600); } catch {}
fs.renameSync(temporary, file);
return this.describe(toolId);
}
readRaw(toolId) {
const local = this.installer.local(toolId);
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
const schema = normalizeSchema(local.metadata.settings_schema);
return this.readValues(local.dir, schema);
}
readValues(toolDir, schema) {
let stored = {};
try { stored = JSON.parse(fs.readFileSync(settingsFile(toolDir), "utf8")); }
catch {}
return Object.fromEntries(
Object.entries(schema).map(([key, field]) => {
try {
return [key, normalizeValue(stored[key] ?? field.default, field, key)];
} catch {
return [key, normalizeValue(field.default, field, key)];
}
})
);
}
}
function normalizeSchema(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).map(([key, field]) => {
if (!/^[a-z][a-z0-9_]*$/i.test(key) || !field || typeof field !== "object" || Array.isArray(field)) {
throw new Error("settings_schema contains an invalid field.");
}
const type = String(field.type || "string");
if (!["string", "integer", "number", "boolean", "enum", "string_list", "multi_select"].includes(type)) {
throw new Error(`Unsupported settings type for ${key}.`);
}
const options = Array.isArray(field.options) ? field.options.map(String) : [];
if (["enum", "multi_select"].includes(type) && !options.length) {
throw new Error(`${key} must define options.`);
}
return [key, {
type,
label: String(field.label || key.replaceAll("_", " ")),
description: String(field.description || ""),
default: field.default ?? defaultValue(type, options),
options,
minimum: finiteOrNull(field.minimum),
maximum: finiteOrNull(field.maximum),
secret: field.secret === true,
required: field.required === true,
rows: Math.max(2, Math.min(12, Number.parseInt(field.rows, 10) || 3))
}];
}));
}
function normalizeValue(value, field, key = "setting") {
if (field.type === "boolean") return value === true || value === "true" || value === "1" || value === "on";
if (field.type === "integer" || field.type === "number") {
const number = field.type === "integer" ? Number.parseInt(value, 10) : Number(value);
if (!Number.isFinite(number)) throw new Error(`${field.label || key} must be a number.`);
return clamp(number, field.minimum, field.maximum);
}
if (field.type === "string_list") {
const rows = Array.isArray(value) ? value : String(value || "").split(/\r?\n|,/);
return [...new Set(rows.map((entry) => String(entry).trim()).filter(Boolean))].slice(0, 200);
}
if (field.type === "multi_select") {
const selected = Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)];
return [...new Set(selected)].filter((entry) => field.options.includes(entry));
}
const text = String(value ?? "").trim();
if (field.required && !text) throw new Error(`${field.label || key} is required.`);
if (field.type === "enum" && !field.options.includes(text)) throw new Error(`${field.label || key} is invalid.`);
return text;
}
function redactSecrets(values, schema) {
return Object.fromEntries(Object.entries(values).map(([key, value]) => [
key,
schema[key]?.secret === true ? "" : value
]));
}
function settingsFile(toolDir) {
return path.join(toolDir, "data", "settings.json");
}
function defaultValue(type, options) {
if (type === "boolean") return false;
if (type === "integer" || type === "number") return 0;
if (type === "string_list" || type === "multi_select") return [];
if (type === "enum") return options[0] || "";
return "";
}
function finiteOrNull(value) {
if (value == null || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function clamp(value, minimum, maximum) {
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
}
module.exports = {
ToolSettings,
normalizeSchema,
normalizeValue,
redactSecrets,
settingsFile
};