Add Lumi AI web search tool
This commit is contained in:
parent
9a3091e410
commit
2d8a9554cf
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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<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});
|
||||
return this.execute({checked:{def:pending.def,args:pending.args},user,requestId:id,context:pending.context});
|
||||
}
|
||||
cancel(id,userId){ const p=this.confirmations.get(id); if(p?.userId===userId){this.confirmations.delete(id);return true;} return false; }
|
||||
}
|
||||
|
||||
160
plugins/lumi_ai/backend/tool_settings.js
Normal file
160
plugins/lumi_ai/backend/tool_settings.js
Normal file
@ -0,0 +1,160 @@
|
||||
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
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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"'));
|
||||
|
||||
@ -18,6 +18,25 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop ai-tool-settings-modal" data-ai-tool-settings-modal aria-hidden="true">
|
||||
<section class="modal ai-tool-settings-dialog" role="dialog" aria-modal="true" aria-labelledby="ai-tool-settings-title">
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<h2 id="ai-tool-settings-title" data-ai-tool-settings-title>Tool settings</h2>
|
||||
<p>These settings apply to WebUI and platform-triggered tool calls.</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-ai-tool-settings-close aria-label="Close tool settings">×</button>
|
||||
</header>
|
||||
<form data-ai-tool-settings-form>
|
||||
<div class="ai-tool-settings-fields" data-ai-tool-settings-fields></div>
|
||||
<div class="modal-actions">
|
||||
<button class="button subtle" type="button" data-ai-tool-settings-close>Cancel</button>
|
||||
<button class="button" type="submit" data-ai-tool-settings-save>Save settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop ai-tool-readme-modal" data-ai-tool-readme-modal aria-hidden="true">
|
||||
<section class="modal ai-tool-readme-dialog" role="dialog" aria-modal="true" aria-labelledby="ai-tool-readme-title">
|
||||
<header class="modal-header">
|
||||
|
||||
248
plugins/lumi_ai_web_search/backend/provider_adapter.js
Normal file
248
plugins/lumi_ai_web_search/backend/provider_adapter.js
Normal file
@ -0,0 +1,248 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const net = require("net");
|
||||
const {
|
||||
defaultResolveHost,
|
||||
evaluateNetworkTarget,
|
||||
evaluateUrl,
|
||||
isPrivateAddress
|
||||
} = require("./url_policy");
|
||||
|
||||
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_PAGE_BYTES = 512 * 1024;
|
||||
|
||||
class SearchProvider {
|
||||
constructor(options = {}) {
|
||||
this.fetch = options.fetch || null;
|
||||
this.resolveHost = options.resolveHost;
|
||||
}
|
||||
|
||||
async search(query, options) {
|
||||
const endpoint = buildEndpoint(query, options);
|
||||
const response = await this.request(endpoint, options, true, MAX_RESPONSE_BYTES);
|
||||
const payload = JSON.parse(response.body.toString("utf8"));
|
||||
return normalizeProviderResults(payload, options.provider_adapter);
|
||||
}
|
||||
|
||||
async fetchPage(url, options) {
|
||||
const response = await this.request(url, options, false, MAX_PAGE_BYTES);
|
||||
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||
if (!contentType.includes("text/html") && !contentType.includes("text/plain")) {
|
||||
throw new Error("Page content type is not supported.");
|
||||
}
|
||||
return {
|
||||
url: response.url,
|
||||
text: extractPageText(response.body.toString("utf8")).slice(0, 6000)
|
||||
};
|
||||
}
|
||||
|
||||
async request(initialUrl, options, providerRequest, maxBytes) {
|
||||
let current = initialUrl;
|
||||
const providerOrigin = providerRequest ? new URL(initialUrl).origin : null;
|
||||
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
||||
const policy = providerRequest
|
||||
? await evaluateNetworkTarget(current, { resolveHost: this.resolveHost })
|
||||
: await evaluateUrl(current, {
|
||||
mode: options.policy_mode,
|
||||
rules: options.url_rules,
|
||||
resolveHost: this.resolveHost
|
||||
});
|
||||
if (!policy.allowed) throw blockedError(policy.reason);
|
||||
if (providerRequest && new URL(policy.url).origin !== providerOrigin) {
|
||||
throw blockedError("cross_origin_provider_redirect");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.search_timeout_ms);
|
||||
timer.unref?.();
|
||||
try {
|
||||
const headers = {
|
||||
Accept: providerRequest ? "application/json" : "text/html,text/plain;q=0.9",
|
||||
"User-Agent": "Lumi-AI-Web-Search/1.0"
|
||||
};
|
||||
if (providerRequest && options.provider_api_key) {
|
||||
headers[options.provider_api_key_header] = [
|
||||
options.provider_api_key_prefix,
|
||||
options.provider_api_key
|
||||
].filter(Boolean).join(" ");
|
||||
}
|
||||
const response = this.fetch
|
||||
? await this.fetch(policy.url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: controller.signal
|
||||
})
|
||||
: await safeHttpRequest(policy.url, {
|
||||
headers,
|
||||
timeoutMs: options.search_timeout_ms,
|
||||
maxBytes,
|
||||
resolveHost: this.resolveHost
|
||||
});
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error("Provider redirect did not include a location.");
|
||||
current = new URL(location, policy.url).href;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Search provider request failed (${response.status}).`);
|
||||
return {
|
||||
url: policy.url,
|
||||
headers: response.headers,
|
||||
body: response.body || await readBounded(response, maxBytes)
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
throw new Error("Search request exceeded the redirect limit.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeHttpRequest(value, options = {}) {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
const resolveHost = options.resolveHost || defaultResolveHost;
|
||||
const addresses = await resolveHost(hostname);
|
||||
const address = addresses.find((entry) => !isPrivateAddress(entry));
|
||||
if (!address) throw blockedError("private_network");
|
||||
const transport = url.protocol === "https:" ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = transport.request({
|
||||
protocol: url.protocol,
|
||||
hostname,
|
||||
port: url.port || undefined,
|
||||
method: "GET",
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers: options.headers,
|
||||
servername: url.protocol === "https:" && !net.isIP(hostname) ? hostname : undefined,
|
||||
lookup: (_hostname, _options, callback) => callback(null, address, net.isIP(address))
|
||||
}, (response) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
response.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > options.maxBytes) {
|
||||
request.destroy(new Error("Provider response is too large."));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on("end", () => resolve({
|
||||
ok: response.statusCode >= 200 && response.statusCode < 300,
|
||||
status: response.statusCode,
|
||||
headers: { get: (name) => response.headers[String(name).toLowerCase()] || null },
|
||||
body: Buffer.concat(chunks)
|
||||
}));
|
||||
});
|
||||
request.setTimeout(options.timeoutMs, () => {
|
||||
const error = new Error("Search provider timed out.");
|
||||
error.name = "AbortError";
|
||||
request.destroy(error);
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function buildEndpoint(query, settings) {
|
||||
if (!settings.provider_endpoint) throw new Error("Search provider endpoint is not configured.");
|
||||
const endpoint = settings.provider_endpoint.includes("{query}")
|
||||
? settings.provider_endpoint.replaceAll("{query}", encodeURIComponent(query))
|
||||
: settings.provider_endpoint;
|
||||
const url = new URL(endpoint);
|
||||
if (!settings.provider_endpoint.includes("{query}")) {
|
||||
url.searchParams.set(settings.provider_query_parameter || "q", query);
|
||||
}
|
||||
url.searchParams.set("format", "json");
|
||||
url.searchParams.set("safesearch", safeSearchValue(settings.safe_search));
|
||||
url.searchParams.set("count", String(settings.max_results));
|
||||
if (settings.freshness) url.searchParams.set("time_range", String(settings.freshness).slice(0, 32));
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function normalizeProviderResults(payload, adapter) {
|
||||
const rows = adapter === "searxng_json"
|
||||
? payload?.results
|
||||
: payload?.results || payload?.items || payload?.web?.results?.value;
|
||||
if (!Array.isArray(rows)) throw new Error("Search provider response does not contain a supported result list.");
|
||||
return rows.map((row, index) => ({
|
||||
title: sanitizeText(row.title || row.name || "Untitled result", 240),
|
||||
url: String(row.url || row.link || ""),
|
||||
snippet: sanitizeText(row.content || row.snippet || row.description || "", 800),
|
||||
source_type: sanitizeText(row.source_type || row.category || row.engine || "", 80) || null,
|
||||
date: normalizeDate(row.publishedDate || row.published_date || row.date),
|
||||
relevance_score: finiteScore(row.score, index)
|
||||
})).filter((row) => row.url);
|
||||
}
|
||||
|
||||
async function readBounded(response, maxBytes) {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) throw new Error("Provider response is too large.");
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length > maxBytes) throw new Error("Provider response is too large.");
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function extractPageText(value) {
|
||||
return sanitizeText(
|
||||
String(value)
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " "),
|
||||
12000
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeText(value, maximum) {
|
||||
return decodeEntities(String(value || "").replace(/<[^>]+>/g, " "))
|
||||
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, maximum);
|
||||
}
|
||||
|
||||
function decodeEntities(value) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", "\"")
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function finiteScore(value, index) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : Math.max(0, 1 - index * 0.1);
|
||||
}
|
||||
|
||||
function safeSearchValue(level) {
|
||||
if (level === "off") return "0";
|
||||
if (level === "moderate") return "1";
|
||||
return "2";
|
||||
}
|
||||
|
||||
function blockedError(reason) {
|
||||
const error = new Error(`URL blocked by policy: ${reason}.`);
|
||||
error.code = "URL_BLOCKED";
|
||||
error.blockedReason = reason;
|
||||
return error;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_PAGE_BYTES,
|
||||
MAX_RESPONSE_BYTES,
|
||||
SearchProvider,
|
||||
blockedError,
|
||||
buildEndpoint,
|
||||
extractPageText,
|
||||
normalizeProviderResults,
|
||||
readBounded,
|
||||
sanitizeText,
|
||||
safeHttpRequest
|
||||
};
|
||||
122
plugins/lumi_ai_web_search/backend/result_formatter.js
Normal file
122
plugins/lumi_ai_web_search/backend/result_formatter.js
Normal file
@ -0,0 +1,122 @@
|
||||
const { sanitizeText } = require("./provider_adapter");
|
||||
|
||||
const REASONS = new Set([
|
||||
"fact_lookup",
|
||||
"resource_lookup",
|
||||
"troubleshooting",
|
||||
"documentation_lookup",
|
||||
"news_or_recent",
|
||||
"general_lookup"
|
||||
]);
|
||||
|
||||
function formatResults(rows, options = {}) {
|
||||
const reason = REASONS.has(options.reason) ? options.reason : "general_lookup";
|
||||
const origin = normalizeOrigin(options.origin);
|
||||
const budget = outputBudget(options.settings, origin);
|
||||
const sourceLimit = origin === "twitch" ? 2 : origin === "discord" ? 4 : options.settings.max_results;
|
||||
const snippetLimit = reason === "fact_lookup" ? 180
|
||||
: ["resource_lookup", "troubleshooting", "documentation_lookup"].includes(reason) ? 300
|
||||
: 240;
|
||||
const ordered = prioritize(rows, reason).slice(0, sourceLimit);
|
||||
const results = [];
|
||||
let used = 0;
|
||||
for (const row of ordered) {
|
||||
const normalized = {
|
||||
title: sanitizeText(row.title, 180),
|
||||
url: options.settings.show_source_links ? row.url : null,
|
||||
domain: safeDomain(row.url),
|
||||
snippet: sanitizeText(row.page_excerpt || row.snippet, snippetLimit),
|
||||
source_type: row.source_type || inferSourceType(row.url),
|
||||
date: row.date || null,
|
||||
relevance_score: Number.isFinite(Number(row.relevance_score)) ? Number(row.relevance_score) : null
|
||||
};
|
||||
const cost = normalized.title.length + normalized.snippet.length + (normalized.url?.length || 0) + 30;
|
||||
if (results.length && used + cost > budget) break;
|
||||
results.push(normalized);
|
||||
used += cost;
|
||||
}
|
||||
const condensedText = buildCondensedText(results, reason, origin, budget);
|
||||
return {
|
||||
reason,
|
||||
origin,
|
||||
output_budget_chars: budget,
|
||||
truncated: ordered.length > results.length || condensedText.truncated,
|
||||
condensed_text: condensedText.text,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
function buildCondensedText(results, reason, origin, budget) {
|
||||
const lines = results.map((result, index) => {
|
||||
const date = result.date ? ` (${result.date.slice(0, 10)})` : "";
|
||||
const source = result.url ? ` ${result.url}` : ` [${result.domain}]`;
|
||||
if (reason === "fact_lookup") return `${index + 1}. ${result.snippet || result.title}${date}${source}`;
|
||||
return `${index + 1}. ${result.title}${date}: ${result.snippet}${source}`;
|
||||
});
|
||||
let text = lines.join(origin === "twitch" ? " | " : "\n");
|
||||
let truncated = false;
|
||||
if (text.length > budget) {
|
||||
text = `${text.slice(0, Math.max(0, budget - 18)).trimEnd()}...`;
|
||||
truncated = true;
|
||||
}
|
||||
return { text, truncated };
|
||||
}
|
||||
|
||||
function prioritize(rows, reason) {
|
||||
const values = [...rows];
|
||||
if (["documentation_lookup", "troubleshooting"].includes(reason)) {
|
||||
values.sort((left, right) =>
|
||||
authorityScore(right) - authorityScore(left) ||
|
||||
Number(right.relevance_score || 0) - Number(left.relevance_score || 0)
|
||||
);
|
||||
} else if (reason === "news_or_recent") {
|
||||
values.sort((left, right) =>
|
||||
dateValue(right.date) - dateValue(left.date) ||
|
||||
Number(right.relevance_score || 0) - Number(left.relevance_score || 0)
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function outputBudget(settings, origin) {
|
||||
return Number(settings[`${origin}_output_chars`]) ||
|
||||
Number(settings.other_output_chars) ||
|
||||
500;
|
||||
}
|
||||
|
||||
function normalizeOrigin(value) {
|
||||
const origin = String(value || "other").toLowerCase();
|
||||
return ["webui", "discord", "twitch", "youtube", "kick"].includes(origin) ? origin : "other";
|
||||
}
|
||||
|
||||
function safeDomain(value) {
|
||||
try { return new URL(value).hostname; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
function inferSourceType(value) {
|
||||
const domain = safeDomain(value);
|
||||
if (/^(docs|developer|support)\./i.test(domain) || /\.(gov|edu)$/i.test(domain)) return "authoritative";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function authorityScore(row) {
|
||||
return ["official", "authoritative", "documentation"].includes(String(row.source_type || "").toLowerCase()) ||
|
||||
inferSourceType(row.url) === "authoritative" ? 1 : 0;
|
||||
}
|
||||
|
||||
function dateValue(value) {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REASONS,
|
||||
buildCondensedText,
|
||||
formatResults,
|
||||
inferSourceType,
|
||||
normalizeOrigin,
|
||||
outputBudget,
|
||||
prioritize,
|
||||
safeDomain
|
||||
};
|
||||
184
plugins/lumi_ai_web_search/backend/search_tool.js
Normal file
184
plugins/lumi_ai_web_search/backend/search_tool.js
Normal file
@ -0,0 +1,184 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { SearchProvider } = require("./provider_adapter");
|
||||
const { formatResults, normalizeOrigin } = require("./result_formatter");
|
||||
const { readSettings } = require("./settings");
|
||||
const { evaluateUrl } = require("./url_policy");
|
||||
|
||||
class WebSearchTool {
|
||||
constructor(options = {}) {
|
||||
this.dataDir = options.dataDir;
|
||||
this.provider = options.provider || new SearchProvider(options);
|
||||
this.now = options.now || Date.now;
|
||||
this.cache = new Map();
|
||||
this.rateLimits = new Map();
|
||||
}
|
||||
|
||||
async run(input = {}) {
|
||||
const started = this.now();
|
||||
const settings = readSettings(this.dataDir);
|
||||
const query = String(input.query || "").trim().slice(0, 500);
|
||||
const reason = String(input.reason || "general_lookup");
|
||||
const origin = trustedOrigin(input.ctx, input.origin);
|
||||
const actor = String(input.user?.id || input.user?.username || "unknown").slice(0, 120);
|
||||
const server = String(input.ctx?.server_id || input.ctx?.channel_id || "direct").slice(0, 120);
|
||||
const auditBase = { query, reason, origin, actor, server };
|
||||
if (!settings.enabled) return this.finish(blockedResult(query, reason, "tool_disabled", settings, started, this.now()), auditBase);
|
||||
if (!settings.allowed_origins.includes(origin)) {
|
||||
return this.finish(blockedResult(query, reason, "origin_not_allowed", settings, started, this.now()), auditBase);
|
||||
}
|
||||
if (!query) return this.finish(blockedResult(query, reason, "query_required", settings, started, this.now()), auditBase);
|
||||
if (!this.consumeRateLimit(`${origin}:${server}:${actor}`, settings.requests_per_minute)) {
|
||||
return this.finish(blockedResult(query, reason, "rate_limited", settings, started, this.now()), auditBase);
|
||||
}
|
||||
const cacheKey = JSON.stringify([
|
||||
query.toLowerCase(), reason, input.freshness || "", settings.provider_endpoint,
|
||||
settings.policy_mode, settings.url_rules, settings.safe_search, settings.max_results,
|
||||
origin, settings[`${origin}_output_chars`], settings.show_source_links,
|
||||
input.requested_depth || "search", settings.allow_full_page_fetch
|
||||
]);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > this.now()) {
|
||||
return this.finish({ ...cached.value, cache_hit: true, timing_ms: this.now() - started }, auditBase);
|
||||
}
|
||||
try {
|
||||
const discovered = await this.provider.search(query, {
|
||||
...settings,
|
||||
freshness: input.freshness
|
||||
});
|
||||
const allowed = [];
|
||||
for (const row of discovered) {
|
||||
if (allowed.length >= settings.max_results) break;
|
||||
const policy = await evaluateUrl(row.url, {
|
||||
mode: settings.policy_mode,
|
||||
rules: settings.url_rules,
|
||||
resolveHost: this.provider.resolveHost
|
||||
});
|
||||
if (!policy.allowed) continue;
|
||||
const normalized = { ...row, url: policy.url };
|
||||
if (input.requested_depth === "full_page" && settings.allow_full_page_fetch) {
|
||||
try {
|
||||
const page = await this.provider.fetchPage(policy.url, settings);
|
||||
normalized.url = page.url;
|
||||
normalized.page_excerpt = page.text;
|
||||
} catch (error) {
|
||||
if (error.code === "URL_BLOCKED") continue;
|
||||
}
|
||||
}
|
||||
allowed.push(normalized);
|
||||
}
|
||||
const formatted = formatResults(allowed, { reason, origin, settings });
|
||||
const value = {
|
||||
query,
|
||||
reason: formatted.reason,
|
||||
status: allowed.length ? "ok" : "no_results",
|
||||
blocked_reason: null,
|
||||
result_count: formatted.results.length,
|
||||
results: formatted.results,
|
||||
condensed_text: formatted.condensed_text,
|
||||
output_budget_chars: formatted.output_budget_chars,
|
||||
truncated: formatted.truncated,
|
||||
timing_ms: this.now() - started,
|
||||
cache_hit: false,
|
||||
policy_mode: settings.policy_mode
|
||||
};
|
||||
if (settings.cache_ttl_seconds > 0 && allowed.length) {
|
||||
this.cache.set(cacheKey, {
|
||||
expiresAt: this.now() + settings.cache_ttl_seconds * 1000,
|
||||
value
|
||||
});
|
||||
}
|
||||
return this.finish(value, auditBase);
|
||||
} catch (error) {
|
||||
return this.finish({
|
||||
query,
|
||||
reason,
|
||||
status: error.code === "URL_BLOCKED" ? "blocked" : "unavailable",
|
||||
blocked_reason: error.blockedReason || null,
|
||||
error: cleanError(error),
|
||||
result_count: 0,
|
||||
results: [],
|
||||
condensed_text: "",
|
||||
timing_ms: this.now() - started,
|
||||
cache_hit: false,
|
||||
policy_mode: settings.policy_mode
|
||||
}, auditBase);
|
||||
}
|
||||
}
|
||||
|
||||
consumeRateLimit(key, maximum) {
|
||||
const cutoff = this.now() - 60000;
|
||||
const recent = (this.rateLimits.get(key) || []).filter((timestamp) => timestamp > cutoff);
|
||||
if (recent.length >= maximum) {
|
||||
this.rateLimits.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(this.now());
|
||||
this.rateLimits.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
|
||||
finish(result, base) {
|
||||
result.user_message ||= userMessage(result);
|
||||
this.audit({
|
||||
...base,
|
||||
status: result.status,
|
||||
allowed: result.status === "ok" || result.status === "no_results",
|
||||
blocked_reason: result.blocked_reason || null,
|
||||
result_count: result.result_count,
|
||||
timing_ms: result.timing_ms,
|
||||
cache_hit: result.cache_hit
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
audit(entry) {
|
||||
fs.mkdirSync(this.dataDir, { recursive: true });
|
||||
fs.appendFileSync(path.join(this.dataDir, "audit.jsonl"), `${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
...entry
|
||||
})}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function trustedOrigin(ctx, fallback) {
|
||||
return normalizeOrigin(ctx?.origin || ctx?.platform || fallback || "other");
|
||||
}
|
||||
|
||||
function blockedResult(query, reason, blockedReason, settings, started, now = Date.now()) {
|
||||
return {
|
||||
query,
|
||||
reason,
|
||||
status: "blocked",
|
||||
blocked_reason: blockedReason,
|
||||
result_count: 0,
|
||||
results: [],
|
||||
condensed_text: "",
|
||||
timing_ms: Math.max(0, now - started),
|
||||
cache_hit: false,
|
||||
policy_mode: settings.policy_mode
|
||||
};
|
||||
}
|
||||
|
||||
function cleanError(error) {
|
||||
if (error?.name === "AbortError") return "Search provider timed out.";
|
||||
return "Search provider is unavailable.";
|
||||
}
|
||||
|
||||
function userMessage(result) {
|
||||
if (result.status === "ok") return result.condensed_text || "Web search completed without a usable summary.";
|
||||
if (result.status === "no_results") return "No permitted web results were found.";
|
||||
if (result.status === "unavailable") return "Web search is currently unavailable.";
|
||||
if (result.blocked_reason === "rate_limited") return "Web search is temporarily rate-limited.";
|
||||
if (result.blocked_reason === "origin_not_allowed") return "Web search is not enabled for this platform.";
|
||||
if (result.blocked_reason === "tool_disabled") return "Web search is disabled.";
|
||||
return "Web search was blocked by the configured safety policy.";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WebSearchTool,
|
||||
blockedResult,
|
||||
cleanError,
|
||||
trustedOrigin,
|
||||
userMessage
|
||||
};
|
||||
87
plugins/lumi_ai_web_search/backend/settings.js
Normal file
87
plugins/lumi_ai_web_search/backend/settings.js
Normal file
@ -0,0 +1,87 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const metadata = require("../tool_info.json");
|
||||
|
||||
function defaults() {
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata.settings_schema).map(([key, field]) => [key, structuredClone(field.default)])
|
||||
);
|
||||
}
|
||||
|
||||
function readSettings(dataDir) {
|
||||
const fallback = defaults();
|
||||
try {
|
||||
const stored = JSON.parse(fs.readFileSync(settingsPath(dataDir), "utf8"));
|
||||
return normalizeSettings({ ...fallback, ...stored });
|
||||
} catch {
|
||||
return normalizeSettings(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(dataDir, value) {
|
||||
const normalized = normalizeSettings({ ...defaults(), ...value });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const file = settingsPath(dataDir);
|
||||
const temporary = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
|
||||
try { fs.chmodSync(temporary, 0o600); } catch {}
|
||||
fs.renameSync(temporary, file);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSettings(value) {
|
||||
const mode = value.policy_mode === "blacklist" ? "blacklist" : "whitelist";
|
||||
const adapter = value.provider_adapter === "generic_json" ? "generic_json" : "searxng_json";
|
||||
const safeSearch = ["off", "moderate", "strict"].includes(value.safe_search) ? value.safe_search : "strict";
|
||||
return {
|
||||
enabled: value.enabled === true,
|
||||
policy_mode: mode,
|
||||
url_rules: stringList(value.url_rules, 200),
|
||||
max_results: integer(value.max_results, 1, 10, 5),
|
||||
search_timeout_ms: integer(value.search_timeout_ms, 1000, 30000, 8000),
|
||||
cache_ttl_seconds: integer(value.cache_ttl_seconds, 0, 3600, 300),
|
||||
safe_search: safeSearch,
|
||||
allowed_origins: stringList(value.allowed_origins, 6)
|
||||
.filter((origin) => ["webui", "discord", "twitch", "youtube", "kick", "other"].includes(origin)),
|
||||
webui_output_chars: integer(value.webui_output_chars, 300, 12000, 4000),
|
||||
discord_output_chars: integer(value.discord_output_chars, 200, 4000, 1200),
|
||||
twitch_output_chars: integer(value.twitch_output_chars, 120, 1000, 350),
|
||||
youtube_output_chars: integer(value.youtube_output_chars, 120, 1500, 500),
|
||||
kick_output_chars: integer(value.kick_output_chars, 120, 1000, 350),
|
||||
other_output_chars: integer(value.other_output_chars, 120, 2000, 500),
|
||||
provider_adapter: adapter,
|
||||
provider_endpoint: String(value.provider_endpoint || "").trim(),
|
||||
provider_api_key: String(value.provider_api_key || "").trim(),
|
||||
provider_api_key_header: value.provider_api_key_header === "Authorization" ? "Authorization" : "X-API-Key",
|
||||
provider_api_key_prefix: String(value.provider_api_key_prefix || "").trim().slice(0, 32),
|
||||
provider_query_parameter: /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(String(value.provider_query_parameter || ""))
|
||||
? String(value.provider_query_parameter)
|
||||
: "q",
|
||||
show_source_links: value.show_source_links !== false,
|
||||
allow_full_page_fetch: value.allow_full_page_fetch === true,
|
||||
requests_per_minute: integer(value.requests_per_minute, 1, 60, 6)
|
||||
};
|
||||
}
|
||||
|
||||
function settingsPath(dataDir) {
|
||||
return path.join(dataDir, "settings.json");
|
||||
}
|
||||
|
||||
function integer(value, minimum, maximum, fallback) {
|
||||
const number = Number.parseInt(value, 10);
|
||||
return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback;
|
||||
}
|
||||
|
||||
function stringList(value, limit) {
|
||||
const rows = Array.isArray(value) ? value : String(value || "").split(/\r?\n|,/);
|
||||
return [...new Set(rows.map((entry) => String(entry).trim()).filter(Boolean))].slice(0, limit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
defaults,
|
||||
normalizeSettings,
|
||||
readSettings,
|
||||
settingsPath,
|
||||
writeSettings
|
||||
};
|
||||
132
plugins/lumi_ai_web_search/backend/url_policy.js
Normal file
132
plugins/lumi_ai_web_search/backend/url_policy.js
Normal file
@ -0,0 +1,132 @@
|
||||
const dns = require("dns");
|
||||
const net = require("net");
|
||||
|
||||
const METADATA_HOSTS = new Set([
|
||||
"metadata.google.internal",
|
||||
"metadata.aws.internal",
|
||||
"instance-data.ec2.internal"
|
||||
]);
|
||||
|
||||
async function evaluateUrl(value, options = {}) {
|
||||
let url;
|
||||
try { url = new URL(String(value)); }
|
||||
catch { return denied("invalid_url"); }
|
||||
if (!["http:", "https:"].includes(url.protocol)) return denied("unsafe_protocol");
|
||||
if (url.username || url.password) return denied("credentials_in_url");
|
||||
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
if (isLocalHostname(hostname)) return denied("local_or_metadata_host");
|
||||
if (net.isIP(hostname) && isPrivateAddress(hostname)) return denied("private_network");
|
||||
const resolveHost = options.resolveHost || defaultResolveHost;
|
||||
try {
|
||||
const addresses = await resolveHost(hostname);
|
||||
if (!addresses.length || addresses.some(isPrivateAddress)) return denied("private_network");
|
||||
} catch {
|
||||
return denied("dns_resolution_failed");
|
||||
}
|
||||
const mode = options.mode === "blacklist" ? "blacklist" : "whitelist";
|
||||
const rules = Array.isArray(options.rules) ? options.rules : [];
|
||||
const matched = rules.some((rule) => matchesRule(url, rule));
|
||||
if (mode === "whitelist" && !matched) return denied("not_whitelisted");
|
||||
if (mode === "blacklist" && matched) return denied("blacklisted");
|
||||
return { allowed: true, url: canonicalUrl(url), reason: null };
|
||||
}
|
||||
|
||||
async function evaluateNetworkTarget(value, options = {}) {
|
||||
return evaluateUrl(value, { ...options, mode: "blacklist", rules: [] });
|
||||
}
|
||||
|
||||
function matchesRule(url, ruleValue) {
|
||||
const raw = String(ruleValue || "").trim();
|
||||
if (!raw) return false;
|
||||
const rule = raw.replace(/^GET\s+/i, "").replace(/#.*$/, "");
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const hostPath = `${hostname}${url.pathname}${url.search}`;
|
||||
const full = canonicalUrl(url);
|
||||
if (!rule.includes("*")) {
|
||||
try {
|
||||
const hasScheme = rule.includes("://");
|
||||
const parsed = new URL(hasScheme ? rule : `https://${rule}`);
|
||||
const ruleHost = parsed.hostname.toLowerCase();
|
||||
const hostMatches = hostname === ruleHost || hostname.endsWith(`.${ruleHost}`);
|
||||
if (!hostMatches) return false;
|
||||
if (hasScheme && (url.protocol !== parsed.protocol || url.port !== parsed.port)) return false;
|
||||
const rulePath = parsed.pathname === "/" && !rule.includes("/") ? "/" : parsed.pathname;
|
||||
if (rulePath !== "/" && !url.pathname.startsWith(rulePath)) return false;
|
||||
return !parsed.search || url.search === parsed.search;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const expression = wildcardExpression(rule.toLowerCase());
|
||||
return expression.test(full.toLowerCase()) ||
|
||||
expression.test(hostPath.toLowerCase()) ||
|
||||
expression.test(hostname);
|
||||
}
|
||||
|
||||
function wildcardExpression(value) {
|
||||
const escaped = value.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
function canonicalUrl(url) {
|
||||
const copy = new URL(url.href);
|
||||
copy.hash = "";
|
||||
return copy.href;
|
||||
}
|
||||
|
||||
function isLocalHostname(hostname) {
|
||||
return hostname === "localhost" ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".local") ||
|
||||
METADATA_HOSTS.has(hostname);
|
||||
}
|
||||
|
||||
function isPrivateAddress(address) {
|
||||
const version = net.isIP(address);
|
||||
if (version === 4) {
|
||||
const [a, b, c] = address.split(".").map(Number);
|
||||
return a === 0 || a === 10 || a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
a >= 224;
|
||||
}
|
||||
if (version === 6) {
|
||||
const normalized = address.toLowerCase();
|
||||
if (normalized === "::" || normalized === "::1") return true;
|
||||
if (normalized.startsWith("fc") || normalized.startsWith("fd") ||
|
||||
/^fe[89ab]/.test(normalized) || normalized.startsWith("ff")) return true;
|
||||
if (normalized.startsWith("::ffff:")) return true;
|
||||
if (normalized.startsWith("2001:db8:")) return true;
|
||||
const first = Number.parseInt(normalized.split(":")[0], 16);
|
||||
return !Number.isFinite(first) || first < 0x2000 || first > 0x3fff;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function defaultResolveHost(hostname) {
|
||||
if (net.isIP(hostname)) return [hostname];
|
||||
const rows = await dns.promises.lookup(hostname, { all: true, verbatim: true });
|
||||
return rows.map((row) => row.address);
|
||||
}
|
||||
|
||||
function denied(reason) {
|
||||
return { allowed: false, url: null, reason };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canonicalUrl,
|
||||
defaultResolveHost,
|
||||
evaluateNetworkTarget,
|
||||
evaluateUrl,
|
||||
isLocalHostname,
|
||||
isPrivateAddress,
|
||||
matchesRule,
|
||||
wildcardExpression
|
||||
};
|
||||
1
plugins/lumi_ai_web_search/data/.gitkeep
Normal file
1
plugins/lumi_ai_web_search/data/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
64
plugins/lumi_ai_web_search/index.js
Normal file
64
plugins/lumi_ai_web_search/index.js
Normal file
@ -0,0 +1,64 @@
|
||||
const net = require("net");
|
||||
const { WebSearchTool } = require("./backend/search_tool");
|
||||
const { normalizeOrigin } = require("./backend/result_formatter");
|
||||
const { readSettings } = require("./backend/settings");
|
||||
const { isLocalHostname, isPrivateAddress } = require("./backend/url_policy");
|
||||
|
||||
module.exports.checkAvailability = ({ paths }) => {
|
||||
const settings = readSettings(paths.data);
|
||||
if (!settings.enabled) {
|
||||
return { available: false, message: "Web search is disabled in tool settings." };
|
||||
}
|
||||
if (!settings.provider_endpoint) {
|
||||
return { available: false, message: "Configure a search provider endpoint in Tool Settings." };
|
||||
}
|
||||
try {
|
||||
const endpoint = new URL(settings.provider_endpoint);
|
||||
const hostname = endpoint.hostname.replace(/^\[|\]$/g, "");
|
||||
if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password ||
|
||||
isLocalHostname(hostname) || (net.isIP(hostname) && isPrivateAddress(hostname))) {
|
||||
return { available: false, message: "Search provider endpoint is blocked by network safety rules." };
|
||||
}
|
||||
} catch {
|
||||
return { available: false, message: "Search provider endpoint is invalid." };
|
||||
}
|
||||
return { available: true };
|
||||
};
|
||||
|
||||
module.exports.register = ({ registerTool, paths }) => {
|
||||
const search = new WebSearchTool({ dataDir: paths.data });
|
||||
registerTool({
|
||||
tool_id: "lumi_ai_web_search.search",
|
||||
display_name: "Search the web",
|
||||
description: "Search current public web information only when verified local Lumi context is insufficient or current external information is explicitly needed. Returns normalized, policy-filtered results for final answer formatting.",
|
||||
required_role: "user",
|
||||
required_permission: "lumi_ai_web_search.search",
|
||||
audit_category: "web_search",
|
||||
confirmation_required: false,
|
||||
risk_level: "low",
|
||||
schema: {
|
||||
query: { type: "string", required: true },
|
||||
reason: {
|
||||
type: "string",
|
||||
required: true,
|
||||
enum: [
|
||||
"fact_lookup",
|
||||
"resource_lookup",
|
||||
"troubleshooting",
|
||||
"documentation_lookup",
|
||||
"news_or_recent",
|
||||
"general_lookup"
|
||||
]
|
||||
},
|
||||
requested_depth: { type: "string", required: false, enum: ["search", "full_page"] },
|
||||
freshness: { type: "string", required: false }
|
||||
},
|
||||
permission_check: ({ user, context }) => {
|
||||
const settings = readSettings(paths.data);
|
||||
const origin = normalizeOrigin(context?.origin || context?.platform || "other");
|
||||
return Boolean(user?.id) && settings.enabled && settings.allowed_origins.includes(origin);
|
||||
},
|
||||
workflow_handler: ({ arguments: args, user, ctx }) =>
|
||||
search.run({ ...args, user, ctx })
|
||||
});
|
||||
};
|
||||
4
plugins/lumi_ai_web_search/public/settings-modal.js
Normal file
4
plugins/lumi_ai_web_search/public/settings-modal.js
Normal file
@ -0,0 +1,4 @@
|
||||
window.LumiAiToolSettings = window.LumiAiToolSettings || {};
|
||||
window.LumiAiToolSettings.lumi_ai_web_search = Object.freeze({
|
||||
policyExamples: ["docs.example.com", "*.example.com/docs/*", "https://example.com/resources/"]
|
||||
});
|
||||
91
plugins/lumi_ai_web_search/readme.md
Normal file
91
plugins/lumi_ai_web_search/readme.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Lumi AI Web Search
|
||||
|
||||
`lumi_ai_web_search` is an AI tool plugin for controlled current-information lookup. It is loaded only by Lumi AI's tool manager and is not an ordinary core plugin.
|
||||
|
||||
## Installation and enablement
|
||||
|
||||
1. Install this directory as `plugins/lumi_ai_web_search/`.
|
||||
2. Install Lumi AI `0.7.1` or newer.
|
||||
3. Open **Plugins -> Lumi AI -> Tools**.
|
||||
4. Select **Settings** for Lumi AI Web Search.
|
||||
5. Configure the provider and URL policy, turn on **Web search enabled**, and save.
|
||||
6. Select **Enable** in the Tools list.
|
||||
|
||||
The tool is not registered with the assistant while disabled. If its internal enabled setting or provider endpoint is missing, Lumi AI marks it unavailable without preventing Lumi from starting.
|
||||
|
||||
## Provider
|
||||
|
||||
The initial adapters accept JSON from a configured public endpoint:
|
||||
|
||||
- `searxng_json` reads the SearxNG `results` array.
|
||||
- `generic_json` reads `results`, `items`, or `web.results.value`.
|
||||
|
||||
The configured query parameter defaults to `q`. The adapter adds `format=json`, safe-search level, and result count. API keys are stored in `data/settings.json` with restricted file permissions where supported. The settings API never returns the key and a blank save keeps the existing secret.
|
||||
|
||||
Provider requests use a strict timeout, a 2 MiB response limit, and at most three redirects. No page JavaScript is executed.
|
||||
|
||||
## URL policy
|
||||
|
||||
The default is an empty whitelist, so no result URL is usable until an administrator adds explicit rules. Rules support:
|
||||
|
||||
- Domain: `docs.example.com`
|
||||
- Domain and subdomains: `example.com`
|
||||
- Subdomain wildcard: `*.example.com`
|
||||
- Path prefix: `example.com/docs`
|
||||
- Full wildcard pattern: `https://*.example.com/resources/*`
|
||||
|
||||
Whitelist mode permits only matching result, page, and redirect URLs. Blacklist mode permits public URLs except matching rules.
|
||||
|
||||
Independent hard network rules always block:
|
||||
|
||||
- `localhost`, `.localhost`, `.local`, and known metadata hostnames
|
||||
- Private, loopback, carrier-grade NAT, link-local, multicast, and reserved IP ranges
|
||||
- DNS names resolving to private or otherwise unsafe addresses
|
||||
- URL credentials
|
||||
- Non-HTTP/HTTPS protocols
|
||||
|
||||
The same checks run before each page fetch and after every redirect. Administrator rules cannot override these blocks.
|
||||
|
||||
## Tool behavior
|
||||
|
||||
The registered tool ID is `lumi_ai_web_search.search`. It accepts:
|
||||
|
||||
- `query`
|
||||
- `reason`: `fact_lookup`, `resource_lookup`, `troubleshooting`, `documentation_lookup`, `news_or_recent`, or `general_lookup`
|
||||
- Optional `requested_depth`: `search` or `full_page`
|
||||
- Optional `freshness`
|
||||
|
||||
The assistant should use this tool only for current or external information that is not available in verified local Lumi context.
|
||||
|
||||
Results are sanitized and returned as structured data rather than raw provider JSON. Each result contains a title, permitted URL or no URL when links are disabled, domain, condensed snippet, source type, date, and relevance score. Documentation and troubleshooting searches prioritize authoritative sources; recent searches prioritize dated sources.
|
||||
|
||||
Optional full-page mode extracts bounded visible text only when the administrator enables it. It does not automate a browser, submit forms, execute scripts, or follow unrestricted links.
|
||||
|
||||
## Origin limits and rate limits
|
||||
|
||||
Allowed origins and output budgets are independently configurable for WebUI, Discord, Twitch, YouTube, Kick, and other sources. Trusted runtime context determines the origin; a model-provided origin cannot elevate access.
|
||||
|
||||
Twitch is limited to compact output and at most two source references. Discord permits moderate detail. WebUI permits richer summaries and more results. The tool also applies a per-actor, per-origin, per-server/channel rolling request limit.
|
||||
|
||||
## Auditing and storage
|
||||
|
||||
All writable data remains under this plugin:
|
||||
|
||||
- `data/settings.json`: normalized settings and provider secret
|
||||
- `data/audit.jsonl`: query, reason, actor, origin, server/channel, policy outcome, result count, cache status, and timing
|
||||
|
||||
Provider credentials are not written to audit records or returned in tool results. Updates preserve `data/` by default.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The plugin has no shell, SQL, arbitrary filesystem, browser automation, or code-execution feature. Network access is limited to the configured public search provider and policy-approved public result pages. Lumi AI's backend role and permission checks remain authoritative.
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node plugins/lumi_ai_web_search/tests/verify.js
|
||||
```
|
||||
|
||||
The suite covers whitelist/blacklist matching, hard private-network blocks, redirect checks, reason-aware formatting, origin budgets, provider failures, settings effects, registration availability, and audits.
|
||||
328
plugins/lumi_ai_web_search/tests/verify.js
Normal file
328
plugins/lumi_ai_web_search/tests/verify.js
Normal file
@ -0,0 +1,328 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { SearchProvider } = require("../backend/provider_adapter");
|
||||
const { formatResults } = require("../backend/result_formatter");
|
||||
const { WebSearchTool } = require("../backend/search_tool");
|
||||
const { readSettings, writeSettings } = require("../backend/settings");
|
||||
const { evaluateUrl, matchesRule } = require("../backend/url_policy");
|
||||
const { ToolRegistry } = require("../../lumi_ai/backend/tool_router");
|
||||
const { ToolInstaller } = require("../../lumi_ai/backend/tool_installer");
|
||||
const { ToolLoader } = require("../../lumi_ai/backend/tool_loader");
|
||||
const plugin = require("../index");
|
||||
|
||||
const PUBLIC_DNS = async () => ["93.184.216.34"];
|
||||
|
||||
async function run() {
|
||||
await verifyPolicy();
|
||||
await verifyRedirectPolicy();
|
||||
verifyFormatting();
|
||||
await verifySearchFlow();
|
||||
await verifyLoaderLifecycle();
|
||||
verifyRegistrationAvailability();
|
||||
verifyStaticFiles();
|
||||
console.log("Lumi AI Web Search verification passed.");
|
||||
}
|
||||
|
||||
async function verifyLoaderLifecycle() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-web-loader-"));
|
||||
const pluginsDir = path.join(root, "plugins");
|
||||
const toolDir = path.join(pluginsDir, "lumi_ai_web_search");
|
||||
copyDirectory(path.resolve(__dirname, ".."), toolDir, new Set(["data"]));
|
||||
fs.mkdirSync(path.join(toolDir, "data"), { recursive: true });
|
||||
const installer = new ToolInstaller({
|
||||
pluginsDir,
|
||||
stagingRoot: path.join(root, "staging"),
|
||||
repoClient: {}
|
||||
});
|
||||
const registry = new ToolRegistry(() => {});
|
||||
const loader = new ToolLoader({
|
||||
registry,
|
||||
installer,
|
||||
settings: { getSetting: (_key, fallback) => fallback },
|
||||
stateFile: path.join(root, "enabled.json"),
|
||||
lumiAiVersion: "0.7.1",
|
||||
lumiVersion: "0.1.0"
|
||||
});
|
||||
const unavailable = await loader.enable("lumi_ai_web_search");
|
||||
assert.equal(unavailable.unavailable, true);
|
||||
assert.equal(registry.tools.has("lumi_ai_web_search.search"), false);
|
||||
writeSettings(path.join(toolDir, "data"), providerSettings());
|
||||
const enabled = await loader.enable("lumi_ai_web_search");
|
||||
assert.equal(enabled.loaded, true);
|
||||
assert.equal(registry.tools.has("lumi_ai_web_search.search"), true);
|
||||
await loader.disable("lumi_ai_web_search");
|
||||
assert.equal(registry.tools.has("lumi_ai_web_search.search"), false);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function verifyPolicy() {
|
||||
let result = await evaluateUrl("https://docs.example.com/guide", {
|
||||
mode: "whitelist",
|
||||
rules: ["*.example.com/*"],
|
||||
resolveHost: PUBLIC_DNS
|
||||
});
|
||||
assert.equal(result.allowed, true);
|
||||
result = await evaluateUrl("https://unrelated.test/guide", {
|
||||
mode: "whitelist",
|
||||
rules: ["*.example.com/*"],
|
||||
resolveHost: PUBLIC_DNS
|
||||
});
|
||||
assert.equal(result.allowed, false);
|
||||
assert.equal(result.reason, "not_whitelisted");
|
||||
|
||||
result = await evaluateUrl("https://ads.example.com/tracker", {
|
||||
mode: "blacklist",
|
||||
rules: ["*.example.com/tracker*"],
|
||||
resolveHost: PUBLIC_DNS
|
||||
});
|
||||
assert.equal(result.allowed, false);
|
||||
result = await evaluateUrl("https://docs.example.org/", {
|
||||
mode: "blacklist",
|
||||
rules: ["*.example.com/tracker*"],
|
||||
resolveHost: PUBLIC_DNS
|
||||
});
|
||||
assert.equal(result.allowed, true);
|
||||
|
||||
for (const target of [
|
||||
"http://127.0.0.1/",
|
||||
"http://10.1.2.3/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost/",
|
||||
"file:///etc/passwd"
|
||||
]) {
|
||||
result = await evaluateUrl(target, {
|
||||
mode: "blacklist",
|
||||
rules: [],
|
||||
resolveHost: PUBLIC_DNS
|
||||
});
|
||||
assert.equal(result.allowed, false, target);
|
||||
}
|
||||
result = await evaluateUrl("https://dns-rebind.example/", {
|
||||
mode: "blacklist",
|
||||
rules: [],
|
||||
resolveHost: async () => ["10.0.0.8"]
|
||||
});
|
||||
assert.equal(result.allowed, false);
|
||||
assert.equal(matchesRule(new URL("https://docs.example.com/guide/start"), "example.com/guide"), true);
|
||||
assert.equal(matchesRule(new URL("https://example.com/"), "http://example.com/"), false);
|
||||
}
|
||||
|
||||
async function verifyRedirectPolicy() {
|
||||
const provider = new SearchProvider({
|
||||
resolveHost: PUBLIC_DNS,
|
||||
fetch: async () => response({
|
||||
status: 302,
|
||||
headers: { location: "http://127.0.0.1/private" }
|
||||
})
|
||||
});
|
||||
await assert.rejects(
|
||||
() => provider.search("test", providerSettings()),
|
||||
/blocked by policy/i
|
||||
);
|
||||
let calls = 0;
|
||||
const crossOrigin = new SearchProvider({
|
||||
resolveHost: PUBLIC_DNS,
|
||||
fetch: async () => {
|
||||
calls += 1;
|
||||
return response({
|
||||
status: 302,
|
||||
headers: { location: "https://other-provider.example/search" }
|
||||
});
|
||||
}
|
||||
});
|
||||
await assert.rejects(
|
||||
() => crossOrigin.search("test", { ...providerSettings(), provider_api_key: "secret" }),
|
||||
/cross_origin_provider_redirect/i
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
|
||||
function verifyFormatting() {
|
||||
const rows = [
|
||||
result("Official docs", "https://docs.example.com/guide", "A detailed official answer for the requested subject.", "documentation"),
|
||||
result("Community post", "https://community.example.com/post", "A secondary explanation with useful context.", "web"),
|
||||
result("Recent update", "https://news.example.com/update", "A recently published update.", "news", "2026-06-12")
|
||||
];
|
||||
const settings = {
|
||||
max_results: 5,
|
||||
show_source_links: true,
|
||||
twitch_output_chars: 180,
|
||||
discord_output_chars: 700,
|
||||
webui_output_chars: 3000,
|
||||
other_output_chars: 500
|
||||
};
|
||||
const fact = formatResults(rows, { reason: "fact_lookup", origin: "twitch", settings });
|
||||
const resource = formatResults(rows, { reason: "resource_lookup", origin: "discord", settings });
|
||||
const webui = formatResults(rows, { reason: "documentation_lookup", origin: "webui", settings });
|
||||
assert(fact.condensed_text.length <= 180);
|
||||
assert(fact.results.length <= 2);
|
||||
assert(resource.condensed_text.length <= 700);
|
||||
assert(webui.condensed_text.length > fact.condensed_text.length);
|
||||
assert.equal(webui.results[0].source_type, "documentation");
|
||||
}
|
||||
|
||||
async function verifySearchFlow() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-web-search-"));
|
||||
const settings = {
|
||||
...providerSettings(),
|
||||
enabled: true,
|
||||
policy_mode: "whitelist",
|
||||
url_rules: ["*.example.com/*"],
|
||||
allowed_origins: ["webui", "discord", "twitch"],
|
||||
cache_ttl_seconds: 60
|
||||
};
|
||||
writeSettings(root, settings);
|
||||
let calls = 0;
|
||||
const provider = {
|
||||
resolveHost: PUBLIC_DNS,
|
||||
async search() {
|
||||
calls += 1;
|
||||
return [
|
||||
result("<b>Verified fact</b>", "https://docs.example.com/fact", "The <em>answer</em> is current.", "documentation"),
|
||||
result("Blocked local", "http://127.0.0.1/private", "Must never be returned.", "web")
|
||||
];
|
||||
},
|
||||
async fetchPage() {
|
||||
return { url: "https://docs.example.com/fact", text: "Expanded public page text." };
|
||||
}
|
||||
};
|
||||
const tool = new WebSearchTool({ dataDir: root, provider });
|
||||
const first = await tool.run({
|
||||
query: "current fact",
|
||||
reason: "fact_lookup",
|
||||
user: { id: "user-1" },
|
||||
ctx: { origin: "webui", server_id: "server-1" }
|
||||
});
|
||||
assert.equal(first.status, "ok");
|
||||
assert.equal(first.result_count, 1);
|
||||
assert.equal(first.results[0].title, "Verified fact");
|
||||
assert.equal(first.results.some((entry) => entry.url?.includes("127.0.0.1")), false);
|
||||
const cached = await tool.run({
|
||||
query: "current fact",
|
||||
reason: "fact_lookup",
|
||||
user: { id: "user-1" },
|
||||
ctx: { origin: "webui", server_id: "server-1" }
|
||||
});
|
||||
assert.equal(cached.cache_hit, true);
|
||||
assert.equal(calls, 1);
|
||||
|
||||
const twitch = await tool.run({
|
||||
query: "current fact twitch",
|
||||
reason: "resource_lookup",
|
||||
user: { id: "user-1" },
|
||||
ctx: { origin: "twitch", channel_id: "channel-1" }
|
||||
});
|
||||
assert(twitch.condensed_text.length <= readSettings(root).twitch_output_chars);
|
||||
assert.equal(calls, 2);
|
||||
|
||||
writeSettings(root, { ...readSettings(root), allowed_origins: ["webui"] });
|
||||
const blockedOrigin = await tool.run({
|
||||
query: "current fact",
|
||||
reason: "fact_lookup",
|
||||
origin: "webui",
|
||||
user: { id: "user-1" },
|
||||
ctx: { origin: "discord" }
|
||||
});
|
||||
assert.equal(blockedOrigin.status, "blocked");
|
||||
assert.equal(blockedOrigin.blocked_reason, "origin_not_allowed");
|
||||
|
||||
const failing = new WebSearchTool({
|
||||
dataDir: root,
|
||||
provider: {
|
||||
resolveHost: PUBLIC_DNS,
|
||||
async search() { throw new Error("provider secret https://provider.example/api?token=secret"); }
|
||||
}
|
||||
});
|
||||
const failed = await failing.run({
|
||||
query: "failure",
|
||||
reason: "general_lookup",
|
||||
user: { id: "user-2" },
|
||||
ctx: { origin: "webui" }
|
||||
});
|
||||
assert.equal(failed.status, "unavailable");
|
||||
assert.equal(failed.error.includes("provider.example"), false);
|
||||
|
||||
const audit = fs.readFileSync(path.join(root, "audit.jsonl"), "utf8")
|
||||
.trim().split(/\r?\n/).map(JSON.parse);
|
||||
assert(audit.some((entry) =>
|
||||
entry.query === "current fact" &&
|
||||
entry.actor === "user-1" &&
|
||||
entry.origin === "webui" &&
|
||||
typeof entry.timing_ms === "number"
|
||||
));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function verifyRegistrationAvailability() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-web-register-"));
|
||||
assert.equal(plugin.checkAvailability({ paths: { data: root } }).available, false);
|
||||
writeSettings(root, { ...providerSettings(), enabled: true });
|
||||
assert.equal(plugin.checkAvailability({ paths: { data: root } }).available, true);
|
||||
const definitions = [];
|
||||
plugin.register({
|
||||
paths: { data: root },
|
||||
registerTool: (definition) => definitions.push(definition)
|
||||
});
|
||||
assert.equal(definitions.length, 1);
|
||||
assert.equal(definitions[0].tool_id, "lumi_ai_web_search.search");
|
||||
assert.equal(definitions[0].permission_check({
|
||||
user: { id: "user" },
|
||||
context: { origin: "webui" }
|
||||
}), true);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function verifyStaticFiles() {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const metadata = JSON.parse(fs.readFileSync(path.join(root, "tool_info.json"), "utf8"));
|
||||
assert.equal(metadata.tool_id, "lumi_ai_web_search");
|
||||
assert.equal(metadata.settings_schema.policy_mode.default, "whitelist");
|
||||
assert(fs.existsSync(path.join(root, "readme.md")));
|
||||
assert(fs.readFileSync(path.join(root, "views", "settings-modal.ejs"), "utf8").includes("settings_schema"));
|
||||
}
|
||||
|
||||
function providerSettings() {
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(require("../tool_info.json").settings_schema).map(([key, field]) => [key, structuredClone(field.default)])
|
||||
),
|
||||
provider_endpoint: "https://search.example.net/search",
|
||||
enabled: true,
|
||||
allowed_origins: ["webui", "discord", "twitch"],
|
||||
url_rules: ["*.example.com/*"]
|
||||
};
|
||||
}
|
||||
|
||||
function result(title, url, snippet, sourceType, date = null) {
|
||||
return { title, url, snippet, source_type: sourceType, date, relevance_score: 0.9 };
|
||||
}
|
||||
|
||||
function response({ status = 200, headers = {}, body = "" }) {
|
||||
const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: {
|
||||
get(name) { return normalized[String(name).toLowerCase()] || null; }
|
||||
},
|
||||
async arrayBuffer() { return Buffer.from(body); }
|
||||
};
|
||||
}
|
||||
|
||||
function copyDirectory(source, destination, ignored = new Set()) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||
if (ignored.has(entry.name)) continue;
|
||||
const from = path.join(source, entry.name);
|
||||
const to = path.join(destination, entry.name);
|
||||
if (entry.isDirectory()) copyDirectory(from, to);
|
||||
else if (entry.isFile()) fs.copyFileSync(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
232
plugins/lumi_ai_web_search/tool_info.json
Normal file
232
plugins/lumi_ai_web_search/tool_info.json
Normal file
@ -0,0 +1,232 @@
|
||||
{
|
||||
"tool_id": "lumi_ai_web_search",
|
||||
"name": "lumi_ai_web_search",
|
||||
"display_name": "Lumi AI Web Search",
|
||||
"version": "1.0.0",
|
||||
"description": "Controlled current-information search for Lumi Assistant with URL policy, origin budgets, and source normalization.",
|
||||
"scope": {
|
||||
"label": "Assistant web lookup",
|
||||
"required_role": "user"
|
||||
},
|
||||
"permissions": {
|
||||
"required_role": "user",
|
||||
"permission": "lumi_ai_web_search.search"
|
||||
},
|
||||
"capabilities": [
|
||||
"Current web search through an administrator-configured JSON search provider",
|
||||
"Whitelist or blacklist URL policy with wildcard rules",
|
||||
"Optional bounded page excerpt fetching",
|
||||
"Context-aware condensed results for WebUI and chat platforms"
|
||||
],
|
||||
"limitations": [
|
||||
"Requires an administrator-configured search provider endpoint",
|
||||
"Does not provide browser automation or execute page scripts",
|
||||
"Private, local, link-local, metadata, and non-HTTP targets are always blocked",
|
||||
"Search quality and freshness depend on the configured provider"
|
||||
],
|
||||
"tool_type": "web_search",
|
||||
"owning_plugin": "lumi_ai",
|
||||
"entrypoints": {
|
||||
"backend": "index.js"
|
||||
},
|
||||
"frontend_assets": [
|
||||
"public"
|
||||
],
|
||||
"views": [
|
||||
"views/settings-modal.ejs"
|
||||
],
|
||||
"dependencies": [],
|
||||
"minimum_lumi_version": "0.1.0",
|
||||
"minimum_lumi_ai_version": "0.7.1",
|
||||
"required_plugins": [
|
||||
"core",
|
||||
"lumi_ai"
|
||||
],
|
||||
"required_platforms": [],
|
||||
"risk_level": "low",
|
||||
"confirmation_required": false,
|
||||
"data_paths": [
|
||||
"data/settings.json",
|
||||
"data/audit.jsonl"
|
||||
],
|
||||
"preserve_on_update": [
|
||||
"data"
|
||||
],
|
||||
"update_notes": "Initial controlled web-search provider with policy enforcement and per-origin output budgets.",
|
||||
"author": "Lumi",
|
||||
"homepage": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi",
|
||||
"repository_path": "plugins/lumi_ai_web_search",
|
||||
"settings_schema": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Web search enabled",
|
||||
"description": "The tool remains unavailable to Lumi Assistant until this and the parent Tools enable state are both on.",
|
||||
"default": false
|
||||
},
|
||||
"policy_mode": {
|
||||
"type": "enum",
|
||||
"label": "Search policy",
|
||||
"description": "Whitelist permits only matching result/page URLs. Blacklist permits URLs except matching rules.",
|
||||
"options": [
|
||||
"whitelist",
|
||||
"blacklist"
|
||||
],
|
||||
"default": "whitelist"
|
||||
},
|
||||
"url_rules": {
|
||||
"type": "string_list",
|
||||
"label": "URL policy rules",
|
||||
"description": "One domain, URL, path prefix, or * wildcard pattern per line. Example: docs.example.com or *.example.com/docs/*.",
|
||||
"default": [],
|
||||
"rows": 6
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"label": "Maximum results",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 10
|
||||
},
|
||||
"search_timeout_ms": {
|
||||
"type": "integer",
|
||||
"label": "Search timeout (ms)",
|
||||
"default": 8000,
|
||||
"minimum": 1000,
|
||||
"maximum": 30000
|
||||
},
|
||||
"cache_ttl_seconds": {
|
||||
"type": "integer",
|
||||
"label": "Result cache TTL (seconds)",
|
||||
"default": 300,
|
||||
"minimum": 0,
|
||||
"maximum": 3600
|
||||
},
|
||||
"safe_search": {
|
||||
"type": "enum",
|
||||
"label": "Safe search",
|
||||
"options": [
|
||||
"off",
|
||||
"moderate",
|
||||
"strict"
|
||||
],
|
||||
"default": "strict"
|
||||
},
|
||||
"allowed_origins": {
|
||||
"type": "multi_select",
|
||||
"label": "Allowed origins",
|
||||
"options": [
|
||||
"webui",
|
||||
"discord",
|
||||
"twitch",
|
||||
"youtube",
|
||||
"kick",
|
||||
"other"
|
||||
],
|
||||
"default": [
|
||||
"webui"
|
||||
]
|
||||
},
|
||||
"webui_output_chars": {
|
||||
"type": "integer",
|
||||
"label": "WebUI output budget",
|
||||
"default": 4000,
|
||||
"minimum": 300,
|
||||
"maximum": 12000
|
||||
},
|
||||
"discord_output_chars": {
|
||||
"type": "integer",
|
||||
"label": "Discord output budget",
|
||||
"default": 1200,
|
||||
"minimum": 200,
|
||||
"maximum": 4000
|
||||
},
|
||||
"twitch_output_chars": {
|
||||
"type": "integer",
|
||||
"label": "Twitch output budget",
|
||||
"default": 350,
|
||||
"minimum": 120,
|
||||
"maximum": 1000
|
||||
},
|
||||
"youtube_output_chars": {
|
||||
"type": "integer",
|
||||
"label": "YouTube output budget",
|
||||
"default": 500,
|
||||
"minimum": 120,
|
||||
"maximum": 1500
|
||||
},
|
||||
"kick_output_chars": {
|
||||
"type": "integer",
|
||||
"label": "Kick output budget",
|
||||
"default": 350,
|
||||
"minimum": 120,
|
||||
"maximum": 1000
|
||||
},
|
||||
"other_output_chars": {
|
||||
"type": "integer",
|
||||
"label": "Other output budget",
|
||||
"default": 500,
|
||||
"minimum": 120,
|
||||
"maximum": 2000
|
||||
},
|
||||
"provider_adapter": {
|
||||
"type": "enum",
|
||||
"label": "Provider adapter",
|
||||
"options": [
|
||||
"searxng_json",
|
||||
"generic_json"
|
||||
],
|
||||
"default": "searxng_json"
|
||||
},
|
||||
"provider_endpoint": {
|
||||
"type": "string",
|
||||
"label": "Provider endpoint",
|
||||
"description": "HTTPS is recommended. The endpoint must be publicly routable and return JSON.",
|
||||
"default": ""
|
||||
},
|
||||
"provider_api_key": {
|
||||
"type": "string",
|
||||
"label": "Provider API key",
|
||||
"description": "Stored in the plugin data directory and never returned by the settings API.",
|
||||
"default": "",
|
||||
"secret": true
|
||||
},
|
||||
"provider_api_key_header": {
|
||||
"type": "enum",
|
||||
"label": "API key header",
|
||||
"options": [
|
||||
"X-API-Key",
|
||||
"Authorization"
|
||||
],
|
||||
"default": "X-API-Key"
|
||||
},
|
||||
"provider_api_key_prefix": {
|
||||
"type": "string",
|
||||
"label": "API key prefix",
|
||||
"description": "For example: Bearer",
|
||||
"default": ""
|
||||
},
|
||||
"provider_query_parameter": {
|
||||
"type": "string",
|
||||
"label": "Query parameter",
|
||||
"default": "q"
|
||||
},
|
||||
"show_source_links": {
|
||||
"type": "boolean",
|
||||
"label": "Show source links",
|
||||
"default": true
|
||||
},
|
||||
"allow_full_page_fetch": {
|
||||
"type": "boolean",
|
||||
"label": "Allow page excerpts",
|
||||
"description": "Allows bounded text extraction after search discovery. URL policy and redirect checks still apply.",
|
||||
"default": false
|
||||
},
|
||||
"requests_per_minute": {
|
||||
"type": "integer",
|
||||
"label": "Requests per actor/minute",
|
||||
"default": 6,
|
||||
"minimum": 1,
|
||||
"maximum": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
4
plugins/lumi_ai_web_search/views/settings-modal.ejs
Normal file
4
plugins/lumi_ai_web_search/views/settings-modal.ejs
Normal file
@ -0,0 +1,4 @@
|
||||
<p class="hint">
|
||||
Lumi AI renders this tool's settings from <code>tool_info.json</code> <code>settings_schema</code>. Provider secrets are write-only,
|
||||
and policy changes apply to WebUI and platform tool calls after saving.
|
||||
</p>
|
||||
Loading…
Reference in New Issue
Block a user