From 2d8a9554cfb4d4a69a4328790f2098481a40f5d0 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 13 Jun 2026 21:32:36 +0200 Subject: [PATCH] Add Lumi AI web search tool --- plugins/lumi_ai/README.md | 2 + plugins/lumi_ai/backend/ai_provider.js | 7 +- plugins/lumi_ai/backend/tool_installer.js | 3 + plugins/lumi_ai/backend/tool_loader.js | 14 +- plugins/lumi_ai/backend/tool_manager.js | 14 +- plugins/lumi_ai/backend/tool_router.js | 24 +- plugins/lumi_ai/backend/tool_settings.js | 160 +++++++++ plugins/lumi_ai/index.js | 23 +- plugins/lumi_ai/plugin.json | 2 +- plugins/lumi_ai/public/settings.css | 12 + plugins/lumi_ai/public/tool-manager.js | 148 +++++++- plugins/lumi_ai/tests/verify-tools.js | 62 +++- plugins/lumi_ai/views/tool-modal.ejs | 19 + .../backend/provider_adapter.js | 248 +++++++++++++ .../backend/result_formatter.js | 122 +++++++ .../lumi_ai_web_search/backend/search_tool.js | 184 ++++++++++ .../lumi_ai_web_search/backend/settings.js | 87 +++++ .../lumi_ai_web_search/backend/url_policy.js | 132 +++++++ plugins/lumi_ai_web_search/data/.gitkeep | 1 + plugins/lumi_ai_web_search/index.js | 64 ++++ .../public/settings-modal.js | 4 + plugins/lumi_ai_web_search/readme.md | 91 +++++ plugins/lumi_ai_web_search/tests/verify.js | 328 ++++++++++++++++++ plugins/lumi_ai_web_search/tool_info.json | 232 +++++++++++++ .../views/settings-modal.ejs | 4 + 25 files changed, 1966 insertions(+), 21 deletions(-) create mode 100644 plugins/lumi_ai/backend/tool_settings.js create mode 100644 plugins/lumi_ai_web_search/backend/provider_adapter.js create mode 100644 plugins/lumi_ai_web_search/backend/result_formatter.js create mode 100644 plugins/lumi_ai_web_search/backend/search_tool.js create mode 100644 plugins/lumi_ai_web_search/backend/settings.js create mode 100644 plugins/lumi_ai_web_search/backend/url_policy.js create mode 100644 plugins/lumi_ai_web_search/data/.gitkeep create mode 100644 plugins/lumi_ai_web_search/index.js create mode 100644 plugins/lumi_ai_web_search/public/settings-modal.js create mode 100644 plugins/lumi_ai_web_search/readme.md create mode 100644 plugins/lumi_ai_web_search/tests/verify.js create mode 100644 plugins/lumi_ai_web_search/tool_info.json create mode 100644 plugins/lumi_ai_web_search/views/settings-modal.ejs diff --git a/plugins/lumi_ai/README.md b/plugins/lumi_ai/README.md index 60b640d..0258566 100644 --- a/plugins/lumi_ai/README.md +++ b/plugins/lumi_ai/README.md @@ -33,6 +33,8 @@ The loader exposes no generic shell, SQL, filesystem, network, or code-execution Enable installs remote tools atomically and registers valid definitions. Disable unregisters them while retaining files. Update preserves `data/` and `config/` by default and rolls back to the previous directory if validation or swapping fails. Delete uses the shared three-second destructive confirmation and removes only the selected `plugins/lumi_ai_*` directory. +Tools may declare a `settings_schema` in `tool_info.json`. The manager renders an admin-only Settings modal, validates and stores values under that tool's `data/settings.json`, redacts secret fields on reads, and reloads enabled tools after a save so availability and behavior update immediately. + ## Improvement Center The Improvement Center at `/plugins/lumi_ai/improvement_center` stores end-user response feedback, supports moderator verification with an administrator-managed trusted reviewer list, and reserves approval, editing, deletion, promotion, eval runs, and exports for administrators. diff --git a/plugins/lumi_ai/backend/ai_provider.js b/plugins/lumi_ai/backend/ai_provider.js index 210386d..bf8608d 100644 --- a/plugins/lumi_ai/backend/ai_provider.js +++ b/plugins/lumi_ai/backend/ai_provider.js @@ -204,14 +204,15 @@ class AiProvider { 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 }); + const prepared = this.tools.prepare({ tool: toolCall.tool, args: toolCall.arguments, user, role, sessionId, context: originContext }); + if (prepared.execute) toolResult = await this.tools.execute({ checked: prepared.checked, user, requestId, context: originContext }); confirmation = prepared.confirmation; } const out = { success: true, text: confirmation ? `Please confirm: ${confirmation.display_name}.` - : toolResult ? `Action completed: ${JSON.stringify(toolResult)}` : text, + : toolResult?.user_message ? toolResult.user_message + : toolResult ? `Action completed: ${JSON.stringify(toolResult)}` : text, links: [], raw_response: cfg.logging.log_responses || includeRaw ? result : null, tool_call: toolCall, diff --git a/plugins/lumi_ai/backend/tool_installer.js b/plugins/lumi_ai/backend/tool_installer.js index 21bafc9..791287a 100644 --- a/plugins/lumi_ai/backend/tool_installer.js +++ b/plugins/lumi_ai/backend/tool_installer.js @@ -117,6 +117,9 @@ function validateToolDirectory(directory, folderName = path.basename(directory)) if (!Array.isArray(metadata[field])) throw new Error(`${field} must be an array.`); } if (!["string", "object"].includes(typeof metadata.permissions)) throw new Error("permissions must be a string, array, or object."); + if (metadata.settings_schema != null && (!metadata.settings_schema || typeof metadata.settings_schema !== "object" || Array.isArray(metadata.settings_schema))) { + throw new Error("settings_schema must be an object."); + } validateRelativeEntrypoints(metadata.entrypoints); return normalizeMetadata(metadata); } diff --git a/plugins/lumi_ai/backend/tool_loader.js b/plugins/lumi_ai/backend/tool_loader.js index 4cc9db1..cf8fb27 100644 --- a/plugins/lumi_ai/backend/tool_loader.js +++ b/plugins/lumi_ai/backend/tool_loader.js @@ -45,7 +45,7 @@ class ToolLoader { const module = require(backend); const register = module?.register || module?.init; if (typeof register !== "function") throw new Error("Backend entrypoint must export register() or init()."); - const result = await register({ + const context = { metadata: Object.freeze({ ...local.metadata }), registerTool: (definition) => { const unregister = registerManagedTool(this.registry, local.metadata, definition); @@ -58,7 +58,17 @@ class ToolLoader { config: path.join(local.dir, "config") }), assetUrl: (relative = "") => `/plugins/lumi_ai/tools/${toolId}/assets/${String(relative).replace(/^\/+/, "")}` - }); + }; + if (typeof module.checkAvailability === "function") { + const availability = await module.checkAvailability(context); + if (availability?.available === false) { + this.loaded.set(toolId, { cleanup: null, registered, metadata: local.metadata, dir: local.dir }); + this.setStatus(toolId, "unavailable", String(availability.message || "Tool configuration is incomplete."), dependencies); + if (options.persist !== false) this.setEnabled(toolId, true); + return { loaded: false, unavailable: true, message: availability.message, dependencies }; + } + } + const result = await register(context); cleanup = typeof result === "function" ? result : typeof result?.stop === "function" ? () => result.stop() : null; } catch (error) { this.registry.unregisterOwner(toolId); diff --git a/plugins/lumi_ai/backend/tool_manager.js b/plugins/lumi_ai/backend/tool_manager.js index 70e3c7b..7bf695f 100644 --- a/plugins/lumi_ai/backend/tool_manager.js +++ b/plugins/lumi_ai/backend/tool_manager.js @@ -7,6 +7,7 @@ class ToolManager { this.repoClient = options.repoClient; this.installer = options.installer; this.loader = options.loader; + this.settings = options.settings; } async list({ force = false } = {}) { @@ -53,7 +54,8 @@ class ToolManager { runtime_message: runtime.message, dependency_status: dependencies, primary_type: metadata?.tool_type || "general", - primary_scope: displayScope(metadata?.scope) + primary_scope: displayScope(metadata?.scope), + has_settings: installed && Boolean(metadata?.settings_schema && Object.keys(metadata.settings_schema).length) }; }) }; @@ -116,6 +118,16 @@ class ToolManager { return { markdown: await this.repoClient.readReadme(toolId), source: "remote" }; } + settingsFor(toolId) { + return this.settings.describe(toolId); + } + + async saveSettings(toolId, values) { + const saved = this.settings.save(toolId, values); + if (this.loader.isEnabled(toolId)) await this.loader.enable(toolId, { persist: false }); + return saved; + } + async loadEnabled() { return this.loader.loadEnabled(); } diff --git a/plugins/lumi_ai/backend/tool_router.js b/plugins/lumi_ai/backend/tool_router.js index ea89698..0e0888c 100644 --- a/plugins/lumi_ai/backend/tool_router.js +++ b/plugins/lumi_ai/backend/tool_router.js @@ -25,27 +25,35 @@ class ToolRegistry { 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; } + for(const [key,spec] of Object.entries(schema)){ + const descriptor=typeof spec==="string"?{type:spec,required:true}:spec||{}; + const value=args?.[key]; + if(value==null && descriptor.required===false)continue; + if(descriptor.type==="integer" && !Number.isInteger(Number(value))) throw new Error(`${key} must be an integer.`); + if(descriptor.type==="string" && typeof value!=="string") throw new Error(`${key} must be a string.`); + if(Array.isArray(descriptor.enum) && !descriptor.enum.includes(value)) throw new Error(`${key} is invalid.`); + clean[key]=descriptor.type==="integer"?Number(value):value; + } return {def,args:clean}; } - prepare({tool,args,user,role,sessionId}){ + prepare({tool,args,user,role,sessionId,context=null}){ const checked=this.validate(tool,args,role); - const allowed=checked.def.permission_check({user,arguments:checked.args,required_permission:checked.def.required_permission}); + const allowed=checked.def.permission_check({user,arguments:checked.args,required_permission:checked.def.required_permission,context}); 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}); + if(!checked.def.confirmation_required) return {execute:true,checked,context}; + const id=crypto.randomUUID(); this.confirmations.set(id,{id,userId:user.id,sessionId,expiresAt:Date.now()+120000,context,...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}); + async execute({checked,user,requestId,context=null}){ + const result=await checked.def.workflow_handler({arguments:checked.args,user,ctx:context,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 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 +}; diff --git a/plugins/lumi_ai/index.js b/plugins/lumi_ai/index.js index 960e5d2..5dab022 100644 --- a/plugins/lumi_ai/index.js +++ b/plugins/lumi_ai/index.js @@ -33,6 +33,7 @@ const { ToolRepoClient } = require("./backend/tool_repo_client"); const { ToolInstaller } = require("./backend/tool_installer"); const { ToolLoader } = require("./backend/tool_loader"); const { ToolManager } = require("./backend/tool_manager"); +const { ToolSettings } = require("./backend/tool_settings"); const storage = require("./backend/storage"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils"); @@ -76,10 +77,12 @@ module.exports = { lumiAiVersion: require("./plugin.json").version, lumiVersion: require("../../package.json").version }); + const toolSettings = new ToolSettings({ installer: toolInstaller }); const toolManager = new ToolManager({ repoClient: toolRepoClient, installer: toolInstaller, - loader: toolLoader + loader: toolLoader, + settings: toolSettings }); const contextProviders = new Map(); const frontendVisibility = new Map(); @@ -1019,6 +1022,24 @@ module.exports = { } }); + router.get("/api/tools/:id/settings", (req, res) => { + if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); + try { + return res.json(toolManager.settingsFor(req.params.id)); + } catch (error) { + return res.status(404).json({ error: error.message }); + } + }); + + router.post("/api/tools/:id/settings", express.json({ limit: "64kb" }), async (req, res) => { + if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." }); + try { + return res.json(await toolManager.saveSettings(req.params.id, req.body?.values || {})); + } catch (error) { + return res.status(400).json({ error: error.message }); + } + }); + router.get("/tools/:id/assets/*", (req, res) => { const permission = canUseAssistant({ user: req.session.user, diff --git a/plugins/lumi_ai/plugin.json b/plugins/lumi_ai/plugin.json index 49a1eb4..35a8817 100644 --- a/plugins/lumi_ai/plugin.json +++ b/plugins/lumi_ai/plugin.json @@ -1,7 +1,7 @@ { "id": "lumi_ai", "name": "Lumi AI", - "version": "0.7.0", + "version": "0.7.1", "description": "Managed local AI provider and scoped WebUI assistant for Lumi.", "main": "index.js" } diff --git a/plugins/lumi_ai/public/settings.css b/plugins/lumi_ai/public/settings.css index acb7eec..977a3b5 100644 --- a/plugins/lumi_ai/public/settings.css +++ b/plugins/lumi_ai/public/settings.css @@ -81,6 +81,7 @@ .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; } .ai-tools-modal { z-index: 110; } .ai-tool-readme-modal { z-index: 120; } +.ai-tool-settings-modal { z-index: 125; } .ai-tools-dialog { width: min(1180px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; } .ai-tools-dialog .modal-header p { margin: 4px 0 0; color: var(--ink-soft); } .ai-tools-source { margin: 10px 0; color: var(--ink-soft); font-size: 12px; overflow-wrap: anywhere; } @@ -105,6 +106,16 @@ .ai-tool-readme pre { max-height: 420px; padding: 12px; overflow: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-3); white-space: pre; } .ai-tool-readme code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } .ai-tool-readme a { color: var(--sea); font-weight: 700; } +.ai-tool-settings-dialog { width: min(760px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; } +.ai-tool-settings-dialog .modal-header p { margin: 4px 0 0; color: var(--ink-soft); } +.ai-tool-settings-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.ai-tool-setting { display: grid; gap: 6px; min-width: 0; } +.ai-tool-setting.wide { grid-column: 1 / -1; } +.ai-tool-setting label { font-weight: 800; } +.ai-tool-setting .hint { margin: 0; } +.ai-tool-setting textarea { min-height: 90px; resize: vertical; } +.ai-tool-setting .check-grid { display: flex; flex-wrap: wrap; gap: 8px 14px; } +.ai-tool-setting .check-grid label { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; } @media (max-width: 800px) { .ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; } .ai-stat-grid { grid-template-columns: 1fr 1fr; } @@ -120,4 +131,5 @@ .ai-tool-summary { grid-template-columns: 1fr; } .ai-tool-actions { justify-content: flex-start; } .ai-tool-details { grid-template-columns: 1fr; } + .ai-tool-settings-fields { grid-template-columns: 1fr; } } diff --git a/plugins/lumi_ai/public/tool-manager.js b/plugins/lumi_ai/public/tool-manager.js index ad1c153..8217a88 100644 --- a/plugins/lumi_ai/public/tool-manager.js +++ b/plugins/lumi_ai/public/tool-manager.js @@ -7,9 +7,16 @@ const readmeModal = document.querySelector("[data-ai-tool-readme-modal]"); const readmeTitle = readmeModal?.querySelector("[data-ai-tool-readme-title]"); const readmeBody = readmeModal?.querySelector("[data-ai-tool-readme]"); - if (!openButton || !modal || !list || !source || !readmeModal || !readmeTitle || !readmeBody) return; + const settingsModal = document.querySelector("[data-ai-tool-settings-modal]"); + const settingsTitle = settingsModal?.querySelector("[data-ai-tool-settings-title]"); + const settingsForm = settingsModal?.querySelector("[data-ai-tool-settings-form]"); + const settingsFields = settingsModal?.querySelector("[data-ai-tool-settings-fields]"); + const settingsSave = settingsModal?.querySelector("[data-ai-tool-settings-save]"); + if (!openButton || !modal || !list || !source || !readmeModal || !readmeTitle || !readmeBody || + !settingsModal || !settingsTitle || !settingsForm || !settingsFields || !settingsSave) return; let loading = false; + let activeSettingsTool = null; const setOpen = (target, open) => { target.classList.toggle("is-open", open); @@ -84,6 +91,10 @@ }); const inspect = button("Inspect", "subtle"); inspect.addEventListener("click", () => inspectReadme(tool)); + const settings = button("Settings", "subtle"); + settings.disabled = !tool.has_settings; + settings.title = tool.has_settings ? "" : "This tool does not expose configurable settings."; + settings.addEventListener("click", () => openSettings(tool)); const enable = button(tool.enabled ? "Disable" : "Enable", tool.enabled ? "subtle" : ""); enable.disabled = tool.installed && !tool.local_valid; enable.addEventListener("click", () => runAction(tool, tool.enabled ? "disable" : "enable", enable)); @@ -91,7 +102,7 @@ update.disabled = !tool.update_enabled; update.title = !tool.installed ? "Install the tool before updating." : tool.remote_missing ? "This tool is missing remotely." : ""; update.addEventListener("click", () => runAction(tool, "update", update)); - actions.append(expand, inspect, enable, update); + actions.append(expand, inspect, settings, enable, update); if (tool.installed) actions.append(deleteForm(tool)); summary.append(identity, versions, scope, actions); @@ -169,6 +180,136 @@ } }; + const openSettings = async (tool) => { + activeSettingsTool = tool; + settingsTitle.textContent = `${tool.display_name || tool.tool_id} settings`; + settingsFields.replaceChildren(message("Loading settings...")); + setOpen(settingsModal, true); + try { + const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(tool.tool_id)}/settings`, { + cache: "no-store", + headers: { Accept: "application/json" } + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "Unable to load tool settings."); + renderSettings(payload); + } catch (error) { + settingsFields.replaceChildren(message(error.message, true)); + } + }; + + const renderSettings = (payload) => { + settingsFields.replaceChildren(); + for (const [key, field] of Object.entries(payload.schema || {})) { + const wrapper = document.createElement("div"); + wrapper.className = `ai-tool-setting${["string_list", "multi_select"].includes(field.type) ? " wide" : ""}`; + const label = document.createElement("label"); + label.textContent = field.label || key; + const control = settingsControl(key, field, payload.values?.[key], payload.configured_secrets?.[key]); + label.htmlFor = control.id || ""; + wrapper.append(label, control); + if (field.description) { + const hint = document.createElement("p"); + hint.className = "hint"; + hint.textContent = field.description; + wrapper.append(hint); + } + settingsFields.append(wrapper); + } + }; + + const settingsControl = (key, field, value, configuredSecret) => { + if (field.type === "boolean") { + const input = document.createElement("input"); + input.type = "checkbox"; + input.name = key; + input.id = `ai-tool-setting-${key}`; + input.checked = value === true; + return input; + } + if (field.type === "multi_select") { + const group = document.createElement("div"); + group.className = "check-grid"; + group.dataset.settingName = key; + for (const option of field.options || []) { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = option; + input.checked = Array.isArray(value) && value.includes(option); + label.append(input, document.createTextNode(option)); + group.append(label); + } + return group; + } + if (field.type === "string_list") { + const textarea = document.createElement("textarea"); + textarea.name = key; + textarea.id = `ai-tool-setting-${key}`; + textarea.rows = field.rows || 3; + textarea.value = Array.isArray(value) ? value.join("\n") : ""; + return textarea; + } + if (field.type === "enum") { + const select = document.createElement("select"); + select.name = key; + select.id = `ai-tool-setting-${key}`; + for (const option of field.options || []) { + const item = document.createElement("option"); + item.value = option; + item.textContent = option; + item.selected = option === value; + select.append(item); + } + return select; + } + const input = document.createElement("input"); + input.name = key; + input.id = `ai-tool-setting-${key}`; + input.type = field.secret ? "password" : ["integer", "number"].includes(field.type) ? "number" : "text"; + if (field.minimum != null) input.min = field.minimum; + if (field.maximum != null) input.max = field.maximum; + if (field.type === "number") input.step = "any"; + input.value = field.secret ? "" : value ?? ""; + if (field.secret && configuredSecret) input.placeholder = "Configured; leave blank to keep"; + return input; + }; + + settingsForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!activeSettingsTool) return; + const values = {}; + for (const [key, field] of Object.entries(activeSettingsTool.settings_schema || {})) { + if (field.type === "multi_select") { + const group = [...settingsFields.querySelectorAll("[data-setting-name]")] + .find((element) => element.dataset.settingName === key); + values[key] = [...(group?.querySelectorAll("input:checked") || [])].map((input) => input.value); + } else { + const input = [...settingsFields.querySelectorAll("[name]")] + .find((element) => element.name === key); + values[key] = field.type === "boolean" ? Boolean(input?.checked) : input?.value; + } + } + settingsSave.disabled = true; + settingsSave.textContent = "Saving..."; + try { + const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(activeSettingsTool.tool_id)}/settings`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ values }) + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "Unable to save tool settings."); + setOpen(settingsModal, false); + await loadTools(false); + } catch (error) { + window.alert(error.message); + } finally { + settingsSave.disabled = false; + settingsSave.textContent = "Save settings"; + } + }); + const renderMarkdown = (container, markdown) => { container.replaceChildren(); const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n"); @@ -309,7 +450,8 @@ refresh?.addEventListener("click", () => loadTools(true)); modal.querySelectorAll("[data-ai-tools-close]").forEach((control) => control.addEventListener("click", () => setOpen(modal, false))); readmeModal.querySelectorAll("[data-ai-tool-readme-close]").forEach((control) => control.addEventListener("click", () => setOpen(readmeModal, false))); - for (const target of [modal, readmeModal]) { + settingsModal.querySelectorAll("[data-ai-tool-settings-close]").forEach((control) => control.addEventListener("click", () => setOpen(settingsModal, false))); + for (const target of [modal, readmeModal, settingsModal]) { target.addEventListener("click", (event) => { if (event.target === target) setOpen(target, false); }); diff --git a/plugins/lumi_ai/tests/verify-tools.js b/plugins/lumi_ai/tests/verify-tools.js index b39ff92..d3096d4 100644 --- a/plugins/lumi_ai/tests/verify-tools.js +++ b/plugins/lumi_ai/tests/verify-tools.js @@ -8,6 +8,7 @@ const { ToolRepoClient, CACHE_TTL_MS } = require("../backend/tool_repo_client"); const { ToolInstaller, validateToolDirectory } = require("../backend/tool_installer"); const { ToolLoader } = require("../backend/tool_loader"); const { ToolManager } = require("../backend/tool_manager"); +const { ToolSettings } = require("../backend/tool_settings"); const { isDestructivePath, issueConfirmation, consumeConfirmation } = require("../../../src/services/destructive-confirm"); async function run() { @@ -57,10 +58,11 @@ async function run() { installer, settings, stateFile, - lumiAiVersion: "0.7.0", + lumiAiVersion: "0.7.1", lumiVersion: "0.1.0" }); - const manager = new ToolManager({ repoClient, installer, loader }); + const toolSettings = new ToolSettings({ installer }); + const manager = new ToolManager({ repoClient, installer, loader, settings: toolSettings }); let listing = await manager.list(); assert.equal(listing.tools.length, 1); @@ -111,6 +113,35 @@ async function run() { assert(optionalResult.dependencies.optional.some((entry) => entry.includes("module-that-does-not-exist"))); assert.match(loader.status("lumi_ai_optional").message, /limitations/); + createTool( + path.join(pluginsDir, "lumi_ai_configurable"), + { + ...metadata("lumi_ai_configurable", "1.0.0"), + settings_schema: { + enabled: { type: "boolean", default: false, label: "Enabled" }, + limit: { type: "integer", default: 5, minimum: 1, maximum: 10, label: "Limit" }, + api_key: { type: "string", default: "", secret: true, label: "API key" } + } + }, + backendSource("ok", "lumi_ai_configurable") + ); + listing = await manager.list(); + assert.equal(listing.tools.find((tool) => tool.tool_id === "lumi_ai_configurable").has_settings, true); + assert.deepEqual(manager.settingsFor("lumi_ai_configurable").values, { enabled: false, limit: 5, api_key: "" }); + let savedSettings = await manager.saveSettings("lumi_ai_configurable", { + enabled: true, + limit: 99, + api_key: "secret-value" + }); + assert.deepEqual(savedSettings.values, { enabled: true, limit: 10, api_key: "" }); + assert.equal(savedSettings.configured_secrets.api_key, true); + savedSettings = await manager.saveSettings("lumi_ai_configurable", { + enabled: false, + limit: 4, + api_key: "" + }); + assert.equal(toolSettings.readRaw("lumi_ai_configurable").api_key, "secret-value"); + createTool( path.join(pluginsDir, "lumi_ai_cross_dependency"), { ...metadata("lumi_ai_cross_dependency", "1.0.0"), required_plugins: ["lumi_ai_weather"] }, @@ -146,6 +177,31 @@ async function run() { }), /Permission denied/); assert.equal(strictRegistry.unregisterOwner("lumi_ai_strict"), 1); + const contextRegistry = new ToolRegistry(() => {}); + registerManagedTool(contextRegistry, metadata("lumi_ai_context", "1.0.0"), { + ...definition("lumi_ai_context.lookup"), + schema: { + query: { type: "string", required: true }, + freshness: { type: "string", required: false } + }, + workflow_handler: async ({ arguments: args, ctx }) => ({ query: args.query, origin: ctx.origin }) + }); + const preparedContext = contextRegistry.prepare({ + tool: "lumi_ai_context.lookup", + args: { query: "current information" }, + user: { id: "ordinary-user" }, + role: "user", + sessionId: "session", + context: { origin: "discord" } + }); + const contextResult = await contextRegistry.execute({ + checked: preparedContext.checked, + user: { id: "ordinary-user" }, + requestId: "request", + context: preparedContext.context + }); + assert.deepEqual(contextResult, { query: "current information", origin: "discord" }); + const unrelated = path.join(pluginsDir, "ordinary-plugin"); fs.mkdirSync(unrelated); assert.equal(isDestructivePath("/plugins/lumi_ai/tools/lumi_ai_weather/delete"), true); @@ -170,7 +226,9 @@ async function run() { assert(settingsTemplate.indexOf("data-ai-tools-open") < settingsTemplate.indexOf("Improvement Center")); assert(modalTemplate.includes("data-ai-tools-list")); assert(modalTemplate.includes("data-ai-tool-readme-modal")); + assert(modalTemplate.includes("data-ai-tool-settings-modal")); assert(clientScript.includes('button("Update"')); + assert(clientScript.includes('button("Settings"')); assert(clientScript.includes("update.disabled = !tool.update_enabled")); assert(clientScript.includes('form.dataset.confirmMode = "modal"')); assert(pluginLoader.includes('entry.name, "tool_info.json"')); diff --git a/plugins/lumi_ai/views/tool-modal.ejs b/plugins/lumi_ai/views/tool-modal.ejs index 2ca9558..409bea3 100644 --- a/plugins/lumi_ai/views/tool-modal.ejs +++ b/plugins/lumi_ai/views/tool-modal.ejs @@ -18,6 +18,25 @@ + +