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.
|
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
|
## 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.
|
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 confirmation = null;
|
||||||
let toolResult = null;
|
let toolResult = null;
|
||||||
if (toolCall) {
|
if (toolCall) {
|
||||||
const prepared = this.tools.prepare({ tool: toolCall.tool, args: toolCall.arguments, user, role, sessionId });
|
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 });
|
if (prepared.execute) toolResult = await this.tools.execute({ checked: prepared.checked, user, requestId, context: originContext });
|
||||||
confirmation = prepared.confirmation;
|
confirmation = prepared.confirmation;
|
||||||
}
|
}
|
||||||
const out = {
|
const out = {
|
||||||
success: true,
|
success: true,
|
||||||
text: confirmation ? `Please confirm: ${confirmation.display_name}.`
|
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: [],
|
links: [],
|
||||||
raw_response: cfg.logging.log_responses || includeRaw ? result : null,
|
raw_response: cfg.logging.log_responses || includeRaw ? result : null,
|
||||||
tool_call: toolCall,
|
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 (!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 (!["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);
|
validateRelativeEntrypoints(metadata.entrypoints);
|
||||||
return normalizeMetadata(metadata);
|
return normalizeMetadata(metadata);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class ToolLoader {
|
|||||||
const module = require(backend);
|
const module = require(backend);
|
||||||
const register = module?.register || module?.init;
|
const register = module?.register || module?.init;
|
||||||
if (typeof register !== "function") throw new Error("Backend entrypoint must export register() or 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 }),
|
metadata: Object.freeze({ ...local.metadata }),
|
||||||
registerTool: (definition) => {
|
registerTool: (definition) => {
|
||||||
const unregister = registerManagedTool(this.registry, local.metadata, definition);
|
const unregister = registerManagedTool(this.registry, local.metadata, definition);
|
||||||
@ -58,7 +58,17 @@ class ToolLoader {
|
|||||||
config: path.join(local.dir, "config")
|
config: path.join(local.dir, "config")
|
||||||
}),
|
}),
|
||||||
assetUrl: (relative = "") => `/plugins/lumi_ai/tools/${toolId}/assets/${String(relative).replace(/^\/+/, "")}`
|
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;
|
cleanup = typeof result === "function" ? result : typeof result?.stop === "function" ? () => result.stop() : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.registry.unregisterOwner(toolId);
|
this.registry.unregisterOwner(toolId);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ class ToolManager {
|
|||||||
this.repoClient = options.repoClient;
|
this.repoClient = options.repoClient;
|
||||||
this.installer = options.installer;
|
this.installer = options.installer;
|
||||||
this.loader = options.loader;
|
this.loader = options.loader;
|
||||||
|
this.settings = options.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
async list({ force = false } = {}) {
|
async list({ force = false } = {}) {
|
||||||
@ -53,7 +54,8 @@ class ToolManager {
|
|||||||
runtime_message: runtime.message,
|
runtime_message: runtime.message,
|
||||||
dependency_status: dependencies,
|
dependency_status: dependencies,
|
||||||
primary_type: metadata?.tool_type || "general",
|
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" };
|
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() {
|
async loadEnabled() {
|
||||||
return this.loader.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.");
|
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.");
|
if(!roleAllows(role,def.required_role)) throw new Error("Permission denied for this tool.");
|
||||||
const schema=def.schema||{}; const clean={};
|
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};
|
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 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 && 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(!allowed)throw new Error("The requesting user does not have permission for this action.");
|
||||||
if(!checked.def.confirmation_required) return {execute:true,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,...checked});
|
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}};
|
return {execute:false,confirmation:{id,display_name:checked.def.display_name,arguments:checked.args,expires_at:Date.now()+120000}};
|
||||||
}
|
}
|
||||||
async execute({checked,user,requestId}){
|
async execute({checked,user,requestId,context=null}){
|
||||||
const result=await checked.def.workflow_handler({arguments:checked.args,user,initiated_via_ai:true,ai_request_id:requestId});
|
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});
|
this.audit({kind:"tool",status:"success",user_id:user.id,tool_requested:checked.def.tool_id,tool_executed:true});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
async confirm({id,user,sessionId}){
|
async confirm({id,user,sessionId}){
|
||||||
const pending=this.confirmations.get(id); this.confirmations.delete(id);
|
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.");
|
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; }
|
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 { ToolInstaller } = require("./backend/tool_installer");
|
||||||
const { ToolLoader } = require("./backend/tool_loader");
|
const { ToolLoader } = require("./backend/tool_loader");
|
||||||
const { ToolManager } = require("./backend/tool_manager");
|
const { ToolManager } = require("./backend/tool_manager");
|
||||||
|
const { ToolSettings } = require("./backend/tool_settings");
|
||||||
const storage = require("./backend/storage");
|
const storage = require("./backend/storage");
|
||||||
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
||||||
|
|
||||||
@ -76,10 +77,12 @@ module.exports = {
|
|||||||
lumiAiVersion: require("./plugin.json").version,
|
lumiAiVersion: require("./plugin.json").version,
|
||||||
lumiVersion: require("../../package.json").version
|
lumiVersion: require("../../package.json").version
|
||||||
});
|
});
|
||||||
|
const toolSettings = new ToolSettings({ installer: toolInstaller });
|
||||||
const toolManager = new ToolManager({
|
const toolManager = new ToolManager({
|
||||||
repoClient: toolRepoClient,
|
repoClient: toolRepoClient,
|
||||||
installer: toolInstaller,
|
installer: toolInstaller,
|
||||||
loader: toolLoader
|
loader: toolLoader,
|
||||||
|
settings: toolSettings
|
||||||
});
|
});
|
||||||
const contextProviders = new Map();
|
const contextProviders = new Map();
|
||||||
const frontendVisibility = 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) => {
|
router.get("/tools/:id/assets/*", (req, res) => {
|
||||||
const permission = canUseAssistant({
|
const permission = canUseAssistant({
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_ai",
|
"id": "lumi_ai",
|
||||||
"name": "Lumi AI",
|
"name": "Lumi AI",
|
||||||
"version": "0.7.0",
|
"version": "0.7.1",
|
||||||
"description": "Managed local AI provider and scoped WebUI assistant for Lumi.",
|
"description": "Managed local AI provider and scoped WebUI assistant for Lumi.",
|
||||||
"main": "index.js"
|
"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-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-tools-modal { z-index: 110; }
|
||||||
.ai-tool-readme-modal { z-index: 120; }
|
.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 { 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-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; }
|
.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 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 code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
.ai-tool-readme a { color: var(--sea); font-weight: 700; }
|
.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) {
|
@media (max-width: 800px) {
|
||||||
.ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; }
|
.ai-titlebar, .ai-section-heading { align-items: flex-start; flex-direction: column; }
|
||||||
.ai-stat-grid { grid-template-columns: 1fr 1fr; }
|
.ai-stat-grid { grid-template-columns: 1fr 1fr; }
|
||||||
@ -120,4 +131,5 @@
|
|||||||
.ai-tool-summary { grid-template-columns: 1fr; }
|
.ai-tool-summary { grid-template-columns: 1fr; }
|
||||||
.ai-tool-actions { justify-content: flex-start; }
|
.ai-tool-actions { justify-content: flex-start; }
|
||||||
.ai-tool-details { grid-template-columns: 1fr; }
|
.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 readmeModal = document.querySelector("[data-ai-tool-readme-modal]");
|
||||||
const readmeTitle = readmeModal?.querySelector("[data-ai-tool-readme-title]");
|
const readmeTitle = readmeModal?.querySelector("[data-ai-tool-readme-title]");
|
||||||
const readmeBody = readmeModal?.querySelector("[data-ai-tool-readme]");
|
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 loading = false;
|
||||||
|
let activeSettingsTool = null;
|
||||||
|
|
||||||
const setOpen = (target, open) => {
|
const setOpen = (target, open) => {
|
||||||
target.classList.toggle("is-open", open);
|
target.classList.toggle("is-open", open);
|
||||||
@ -84,6 +91,10 @@
|
|||||||
});
|
});
|
||||||
const inspect = button("Inspect", "subtle");
|
const inspect = button("Inspect", "subtle");
|
||||||
inspect.addEventListener("click", () => inspectReadme(tool));
|
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" : "");
|
const enable = button(tool.enabled ? "Disable" : "Enable", tool.enabled ? "subtle" : "");
|
||||||
enable.disabled = tool.installed && !tool.local_valid;
|
enable.disabled = tool.installed && !tool.local_valid;
|
||||||
enable.addEventListener("click", () => runAction(tool, tool.enabled ? "disable" : "enable", enable));
|
enable.addEventListener("click", () => runAction(tool, tool.enabled ? "disable" : "enable", enable));
|
||||||
@ -91,7 +102,7 @@
|
|||||||
update.disabled = !tool.update_enabled;
|
update.disabled = !tool.update_enabled;
|
||||||
update.title = !tool.installed ? "Install the tool before updating." : tool.remote_missing ? "This tool is missing remotely." : "";
|
update.title = !tool.installed ? "Install the tool before updating." : tool.remote_missing ? "This tool is missing remotely." : "";
|
||||||
update.addEventListener("click", () => runAction(tool, "update", update));
|
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));
|
if (tool.installed) actions.append(deleteForm(tool));
|
||||||
|
|
||||||
summary.append(identity, versions, scope, actions);
|
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) => {
|
const renderMarkdown = (container, markdown) => {
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
|
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
|
||||||
@ -309,7 +450,8 @@
|
|||||||
refresh?.addEventListener("click", () => loadTools(true));
|
refresh?.addEventListener("click", () => loadTools(true));
|
||||||
modal.querySelectorAll("[data-ai-tools-close]").forEach((control) => control.addEventListener("click", () => setOpen(modal, false)));
|
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)));
|
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) => {
|
target.addEventListener("click", (event) => {
|
||||||
if (event.target === target) setOpen(target, false);
|
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 { ToolInstaller, validateToolDirectory } = require("../backend/tool_installer");
|
||||||
const { ToolLoader } = require("../backend/tool_loader");
|
const { ToolLoader } = require("../backend/tool_loader");
|
||||||
const { ToolManager } = require("../backend/tool_manager");
|
const { ToolManager } = require("../backend/tool_manager");
|
||||||
|
const { ToolSettings } = require("../backend/tool_settings");
|
||||||
const { isDestructivePath, issueConfirmation, consumeConfirmation } = require("../../../src/services/destructive-confirm");
|
const { isDestructivePath, issueConfirmation, consumeConfirmation } = require("../../../src/services/destructive-confirm");
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
@ -57,10 +58,11 @@ async function run() {
|
|||||||
installer,
|
installer,
|
||||||
settings,
|
settings,
|
||||||
stateFile,
|
stateFile,
|
||||||
lumiAiVersion: "0.7.0",
|
lumiAiVersion: "0.7.1",
|
||||||
lumiVersion: "0.1.0"
|
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();
|
let listing = await manager.list();
|
||||||
assert.equal(listing.tools.length, 1);
|
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(optionalResult.dependencies.optional.some((entry) => entry.includes("module-that-does-not-exist")));
|
||||||
assert.match(loader.status("lumi_ai_optional").message, /limitations/);
|
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(
|
createTool(
|
||||||
path.join(pluginsDir, "lumi_ai_cross_dependency"),
|
path.join(pluginsDir, "lumi_ai_cross_dependency"),
|
||||||
{ ...metadata("lumi_ai_cross_dependency", "1.0.0"), required_plugins: ["lumi_ai_weather"] },
|
{ ...metadata("lumi_ai_cross_dependency", "1.0.0"), required_plugins: ["lumi_ai_weather"] },
|
||||||
@ -146,6 +177,31 @@ async function run() {
|
|||||||
}), /Permission denied/);
|
}), /Permission denied/);
|
||||||
assert.equal(strictRegistry.unregisterOwner("lumi_ai_strict"), 1);
|
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");
|
const unrelated = path.join(pluginsDir, "ordinary-plugin");
|
||||||
fs.mkdirSync(unrelated);
|
fs.mkdirSync(unrelated);
|
||||||
assert.equal(isDestructivePath("/plugins/lumi_ai/tools/lumi_ai_weather/delete"), true);
|
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(settingsTemplate.indexOf("data-ai-tools-open") < settingsTemplate.indexOf("Improvement Center"));
|
||||||
assert(modalTemplate.includes("data-ai-tools-list"));
|
assert(modalTemplate.includes("data-ai-tools-list"));
|
||||||
assert(modalTemplate.includes("data-ai-tool-readme-modal"));
|
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("Update"'));
|
||||||
|
assert(clientScript.includes('button("Settings"'));
|
||||||
assert(clientScript.includes("update.disabled = !tool.update_enabled"));
|
assert(clientScript.includes("update.disabled = !tool.update_enabled"));
|
||||||
assert(clientScript.includes('form.dataset.confirmMode = "modal"'));
|
assert(clientScript.includes('form.dataset.confirmMode = "modal"'));
|
||||||
assert(pluginLoader.includes('entry.name, "tool_info.json"'));
|
assert(pluginLoader.includes('entry.name, "tool_info.json"'));
|
||||||
|
|||||||
@ -18,6 +18,25 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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">
|
<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">
|
<section class="modal ai-tool-readme-dialog" role="dialog" aria-modal="true" aria-labelledby="ai-tool-readme-title">
|
||||||
<header class="modal-header">
|
<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