This commit is contained in:
2026-07-12 17:26:59 -04:00
parent 60663b8d4a
commit ba1bef8ff9
25 changed files with 158880 additions and 1529 deletions
+411
View File
@@ -0,0 +1,411 @@
'use strict';
const conf = require('../../conf');
const Database = require('../storage/database');
// In-memory cache: key -> { value, type, category }
let _cache = null;
// Bot settings cache: botName -> { key -> { value, type } }
let _botCache = null;
// Registry of all known settings with their types, categories, labels, defaults
const SETTINGS_REGISTRY = [
// ---- MC / Server ----
{ key: 'mc.host', type: 'string', category: 'server', label: 'Server Host', description: 'Minecraft server address' },
// ---- AI category ----
{ key: 'ai.provider', type: 'string', category: 'ai', label: 'AI Provider', description: 'LLM provider (ollama or gemini)' },
{ key: 'ai.model', type: 'string', category: 'ai', label: 'Model', description: 'Model name to use' },
{ key: 'ai.baseUrl', type: 'string', category: 'ai', label: 'Ollama Base URL', description: 'Ollama server URL (only used if provider is ollama)' },
{ key: 'ai.key', type: 'secret', category: 'ai', label: 'API Key', description: 'Gemini API key (only used if provider is gemini)' },
{ key: 'ai.temperature', type: 'number', category: 'ai', label: 'Temperature', description: 'LLM temperature (0-2)' },
{ key: 'ai.topP', type: 'number', category: 'ai', label: 'Top P', description: 'Nucleus sampling parameter' },
{ key: 'ai.topK', type: 'number', category: 'ai', label: 'Top K', description: 'Top-K sampling parameter' },
{ key: 'ai.interval', type: 'number', category: 'ai', label: 'Poll Interval', description: 'Seconds between AI poll cycles' },
{ key: 'ai.timeout', type: 'number', category: 'ai', label: 'Request Timeout', description: 'LLM request timeout in ms' },
{ key: 'ai.promptName', type: 'string', category: 'ai', label: 'Active Prompt', description: 'Active prompt personality name' },
{ key: 'ai.enableNativeTools', type: 'boolean', category: 'ai', label: 'Native Tools', description: 'Enable native function calling' },
{ key: 'ai.faceBot', type: 'string', category: 'ai', label: 'Face Bot', description: 'Bot that runs the AI coordinator' },
{ key: 'ai.storageBot', type: 'string', category: 'ai', label: 'Storage Bot', description: 'Bot that handles storage operations' },
{ key: 'ai.prompCustom', type: 'string', category: 'ai', label: 'Custom Prompt Text', description: 'Injected text when prompt name is "custom"' },
{ key: 'ai.prompts', type: 'json', category: 'ai', label: 'Prompt Templates', description: 'All prompt templates: {"name": "template...", ...}' },
// ---- Storage category ----
{ key: 'storage.dbPath', type: 'string', category: 'storage', label: 'Database Path', description: 'Path to SQLite database file' },
{ key: 'storage.scanRadius', type: 'number', category: 'storage', label: 'Scan Radius', description: 'Block radius for chest scanning' },
{ key: 'storage.homePos', type: 'json', category: 'storage', label: 'Home Position', description: 'Bot home position {x, y, z} or null' },
{ key: 'storage.craftingTablePos', type: 'json', category: 'storage', label: 'Crafting Table Pos', description: 'Crafting table position {x, y, z} or null' },
{ key: 'storage.inboxShulkerName', type: 'string', category: 'storage', label: 'Inbox Shulker Name', description: 'Name tag for inbox shulker boxes' },
{ key: 'storage.outboxShulkerName', type: 'string', category: 'storage', label: 'Outbox Shulker Name', description: 'Name tag for outbox shulker boxes' },
{ key: 'storage.newShulkersName', type: 'string', category: 'storage', label: 'New Shulkers Name', description: 'Name tag for empty/new shulker boxes' },
{ key: 'storage.hotbarItems', type: 'json', category: 'storage', label: 'Hotbar Items', description: 'Array of items to keep in hotbar' },
{ key: 'storage.hotbarRestockInterval', type: 'number',category: 'storage', label: 'Restock Interval', description: 'ms between hotbar restock checks' },
{ key: 'storage.categories', type: 'json', category: 'storage', label: 'Item Categories', description: 'Item name classification lists' },
{ key: 'storage.defaultPlayers', type: 'json', category: 'storage', label: 'Default Players', description: 'Default player role assignments' },
{ key: 'storage.webPort', type: 'number', category: 'storage', label: 'Web UI Port', description: 'Port for the web dashboard' },
{ key: 'storage.webHost', type: 'string', category: 'storage', label: 'Web UI Host', description: 'Bind address for the web dashboard' },
// ---- Farm supply ----
{ key: 'farmSupply.enabled', type: 'boolean', category: 'farm', label: 'Farm Supply Enabled', description: 'Enable farm supply plugin' },
{ key: 'farmSupply.storageBotName', type: 'string', category: 'farm', label: 'Storage Bot Name', description: 'Name of bot handling storage trades' },
// ---- Web auth (OIDC / SSO) ----
{ key: 'auth.enabled', type: 'boolean', category: 'auth', label: 'Auth Enabled', description: 'Require SSO login for the web dashboard' },
{ key: 'auth.authorizationEndpoint', type: 'string', category: 'auth', label: 'Authorize Endpoint', description: 'SSO OAuth authorize URL' },
{ key: 'auth.tokenEndpoint', type: 'string', category: 'auth', label: 'Token Endpoint', description: 'SSO OAuth token URL' },
{ key: 'auth.userinfoEndpoint', type: 'string', category: 'auth', label: 'Userinfo Endpoint', description: 'SSO OIDC userinfo URL' },
{ key: 'auth.clientId', type: 'string', category: 'auth', label: 'Client ID', description: 'OAuth client ID registered on the SSO' },
{ key: 'auth.clientSecret', type: 'secret', category: 'auth', label: 'Client Secret', description: 'OAuth client secret' },
{ key: 'auth.redirectUri', type: 'string', category: 'auth', label: 'Redirect URI', description: 'Absolute callback URL — must match the SSO client registration' },
{ key: 'auth.scopes', type: 'json', category: 'auth', label: 'Scopes', description: 'OAuth scopes to request' },
{ key: 'auth.usernameClaim', type: 'string', category: 'auth', label: 'Username Claim', description: 'Userinfo claim used as the username' },
{ key: 'auth.groupsClaim', type: 'string', category: 'auth', label: 'Groups Claim', description: 'Userinfo claim carrying group membership' },
{ key: 'auth.allowedUsers', type: 'json', category: 'auth', label: 'Allowed Users', description: 'Usernames allowed to log in (empty = any SSO user)' },
{ key: 'auth.allowedGroups', type: 'json', category: 'auth', label: 'Allowed Groups', description: 'SSO groups allowed to log in (empty = any SSO user)' },
{ key: 'auth.tokenTTL', type: 'number', category: 'auth', label: 'Session TTL (s)', description: 'Seconds a login session stays valid' },
// ---- Invites ----
{ key: 'invite.seedSites', type: 'json', category: 'invites', label: 'Invite Sites', description: 'Array of invite site configurations' },
// ---- Plugins ----
{ key: 'plugings', type: 'json', category: 'general', label: 'Default Plugins', description: 'Default plugin configurations for all bots' },
// ---- Player list ----
{ key: 'playerListDir', type: 'string', category: 'general', label: 'Player List Dir', description: 'Directory for player list output' },
];
// Registry of per-bot settings
const BOT_SETTINGS_REGISTRY = [
{ key: 'username', type: 'string', label: 'Username/Email', description: 'Microsoft/Mojang auth email' },
{ key: 'password', type: 'secret', label: 'Password', description: 'Account password' },
{ key: 'auth', type: 'string', label: 'Auth Method', description: 'microsoft or mojang' },
{ key: 'autoConnect', type: 'boolean', label: 'Auto Connect', description: 'Connect on startup' },
{ key: 'autoReConnect', type: 'boolean', label: 'Auto Reconnect', description: 'Reconnect after disconnect' },
{ key: 'onDemand', type: 'boolean', label: 'On Demand', description: 'Only connect when needed, auto-disconnect when idle' },
{ key: 'idleTimeout', type: 'number', label: 'Idle Timeout', description: 'ms before on-demand bot disconnects' },
{ key: 'commands', type: 'json', label: 'Commands', description: 'Array of command module names to load' },
{ key: 'plugins', type: 'json', label: 'Plugins', description: 'Plugin configurations: {"PluginName": {...options...}}' },
{ key: 'hasAi', type: 'boolean', label: 'Has AI', description: 'Load the AI plugin for this bot' },
];
// Build a map from config key path to config file value
function _getConfigDefault(key) {
const parts = key.split('.');
let node = conf;
for (const p of parts) {
if (node == null) return undefined;
node = node[p];
}
return node;
}
function _serializeDefault(value, type) {
if (value === undefined || value === null) return '';
switch (type) {
case 'number': return String(value);
case 'boolean': return String(value);
case 'json': return JSON.stringify(value);
case 'secret': return String(value);
default: return String(value);
}
}
// Serialize a native value to the string stored in the DB/cache.
function _serialize(value, type) {
switch (type) {
case 'number': {
const n = Number(value);
if (isNaN(n)) throw new Error(`Expected number, got: ${value}`);
return String(n);
}
case 'boolean': return (value === true || value === 'true' || value === '1') ? 'true' : 'false';
case 'json': return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value);
case 'secret': return String(value);
default: return String(value);
}
}
// Extract serialized defaults from a bot config object.
function _botConfigToDefaults(botConfig) {
return {
username: { value: botConfig.username || '', type: 'string' },
password: { value: botConfig.password || '', type: 'secret' },
auth: { value: botConfig.auth || 'microsoft', type: 'string' },
autoConnect: { value: String(botConfig.autoConnect ?? true), type: 'boolean' },
autoReConnect: { value: String(botConfig.autoReConnect ?? true), type: 'boolean' },
onDemand: { value: String(botConfig.onDemand || false), type: 'boolean' },
idleTimeout: { value: String(botConfig.idleTimeout || 30000), type: 'number' },
commands: { value: JSON.stringify(botConfig.commands || ['default']), type: 'json' },
plugins: { value: JSON.stringify(botConfig.plugins || {}), type: 'json' },
hasAi: { value: String(botConfig.hasAi || false), type: 'boolean' },
};
}
function _coerceForConsumer(value, type) {
if (value === undefined || value === null || value === '') {
switch (type) {
case 'number': return 0;
case 'boolean': return false;
case 'json': return null;
case 'secret': return '';
default: return '';
}
}
switch (type) {
case 'number': {
const n = Number(value);
return isNaN(n) ? 0 : n;
}
case 'boolean': return value === 'true' || value === true;
case 'json': {
try { return JSON.parse(value); }
catch (e) { return null; }
}
case 'secret': return String(value);
default: return String(value);
}
}
function _getTypeForKey(key) {
const entry = SETTINGS_REGISTRY.find(e => e.key === key);
return entry ? entry.type : 'string';
}
/**
* Populate the in-memory cache from DB (with config file fallback).
* Must be called after Database is initialized.
*/
async function initialize() {
if (_cache) return;
// Build cache from registry defaults first
_cache = {};
for (const entry of SETTINGS_REGISTRY) {
const configDefault = _getConfigDefault(entry.key);
_cache[entry.key] = {
value: _serializeDefault(configDefault, entry.type),
type: entry.type,
category: entry.category,
};
}
// Overlay DB values if DB is available
if (Database && Database.db) {
try {
const rows = await Database.getAllSettings();
for (const row of rows) {
if (_cache[row.key]) {
_cache[row.key].value = row.value;
_cache[row.key].fromDb = true;
}
}
} catch (err) {
console.warn('SettingsManager: failed to load from DB, using config defaults:', err.message);
}
}
// Initialize bot settings cache
await _initBotCache();
console.log(`SettingsManager: initialized with ${Object.keys(_cache).length} global settings`);
}
async function _initBotCache() {
_botCache = {};
// Load from config first as defaults
const bots = conf.mc?.bots || {};
for (const [botName, botConfig] of Object.entries(bots)) {
_botCache[botName] = _botConfigToDefaults(botConfig);
}
// Overlay DB values
if (Database && Database.db) {
for (const botName of Object.keys(_botCache)) {
try {
const rows = await Database.getAllBotSettings(botName);
for (const row of rows) {
if (_botCache[botName] && _botCache[botName][row.key] !== undefined) {
_botCache[botName][row.key].value = row.value;
_botCache[botName][row.key].fromDb = true;
}
}
} catch (e) { /* bot settings not in DB yet */ }
}
}
}
/** Synchronous get — reads from cache. Returns coerced native type. */
function get(key) {
if (!_cache) {
const raw = _getConfigDefault(key);
return _coerceForConsumer(raw, _getTypeForKey(key));
}
const entry = _cache[key];
if (!entry) return undefined;
return _coerceForConsumer(entry.value, entry.type);
}
/** Synchronous getAll — returns { [key]: nativeValue } */
function getAll() {
const result = {};
if (!_cache) return result;
for (const [key, entry] of Object.entries(_cache)) {
result[key] = _coerceForConsumer(entry.value, entry.type);
}
return result;
}
/** Synchronous getAllByCategory */
function getAllByCategory(category) {
const result = {};
if (!_cache) return result;
for (const [key, entry] of Object.entries(_cache)) {
if (entry.category === category) {
result[key] = _coerceForConsumer(entry.value, entry.type);
}
}
return result;
}
/** Async set — writes to DB and updates cache */
async function set(key, value) {
if (!_cache) throw new Error('SettingsManager not initialized');
const entry = _cache[key];
if (!entry) throw new Error(`Unknown setting: ${key}`);
const serialized = _serialize(value, entry.type);
if (Database && Database.db) {
await Database.setSetting(key, serialized);
}
entry.value = serialized;
entry.fromDb = true;
// Notify AiManager of the change (if it's an ai.* setting)
try {
const { getInstance } = require('../ai/manager');
const manager = getInstance();
if (manager && key.startsWith('ai.')) {
manager.onSettingChanged(key, _coerceForConsumer(serialized, entry.type));
}
} catch (e) { /* ignore */ }
return _coerceForConsumer(serialized, entry.type);
}
/**
* Return all settings under a key prefix as a flat object with short keys.
* e.g. getSection('storage') → { dbPath: './storage/storage.db', scanRadius: 30, ... }
* Falls back to conf when cache is not yet initialized.
* Skips keys whose cached value is empty (unset), preserving caller's defaults.
*/
function getSection(prefix) {
const strip = prefix + '.';
if (!_cache) {
// Not yet initialized — read directly from conf
const parts = prefix.split('.');
let node = conf;
for (const p of parts) { if (node == null) return {}; node = node[p]; }
return (typeof node === 'object' && node !== null && !Array.isArray(node)) ? { ...node } : {};
}
const result = {};
for (const [key, entry] of Object.entries(_cache)) {
if (!key.startsWith(strip)) continue;
if (entry.value === '') continue; // unset — let caller's default win
result[key.slice(strip.length)] = _coerceForConsumer(entry.value, entry.type);
}
return result;
}
/** Reload cache from DB */
async function reload() {
_cache = null;
_botCache = null;
await initialize();
}
function getRegistry() {
return SETTINGS_REGISTRY;
}
// ========================================
// Bot Settings
// ========================================
function getBotSettingsRegistry() {
return BOT_SETTINGS_REGISTRY;
}
/** Get all bot names known to the system */
function getBotNames() {
if (!_botCache) return Object.keys(conf.mc?.bots || {});
return Object.keys(_botCache);
}
/** Get all settings for a specific bot. Returns { key: nativeValue, ... } with metadata. */
function getBotSettings(botName) {
const result = { _meta: { name: botName } };
if (!_botCache || !_botCache[botName]) {
const botConfig = conf.mc?.bots?.[botName];
if (!botConfig) return null;
const defaults = _botConfigToDefaults(botConfig);
for (const [key, entry] of Object.entries(defaults)) {
result[key] = _coerceForConsumer(entry.value, entry.type);
}
return result;
}
for (const [key, cacheEntry] of Object.entries(_botCache[botName])) {
result[key] = _coerceForConsumer(cacheEntry.value, cacheEntry.type);
}
return result;
}
/** Set a single bot setting. Writes to DB and updates cache. */
async function setBotSetting(botName, key, value) {
if (!_botCache) throw new Error('SettingsManager not initialized');
// Ensure bot exists in cache
if (!_botCache[botName]) {
const botConfig = conf.mc?.bots?.[botName];
if (!botConfig) throw new Error(`Unknown bot: ${botName}`);
_botCache[botName] = _botConfigToDefaults(botConfig);
}
const cacheEntry = _botCache[botName][key];
if (!cacheEntry) throw new Error(`Unknown bot setting: ${key}`);
const serialized = _serialize(value, cacheEntry.type);
if (Database && Database.db) {
await Database.setBotSetting(botName, key, serialized, cacheEntry.type);
}
cacheEntry.value = serialized;
cacheEntry.fromDb = true;
// Apply to live bot instance if connected
try {
const { CJbot } = require('../../model/minecraft');
const bot = CJbot.bots[botName];
if (bot) {
const nativeVal = _coerceForConsumer(serialized, cacheEntry.type);
switch (key) {
case 'autoConnect': bot.autoConnect = nativeVal; break;
case 'autoReConnect': bot.autoReConnect = nativeVal; break;
case 'onDemand': bot.onDemand = nativeVal; break;
case 'idleTimeout': bot._idleTimeout = nativeVal; break;
case 'plugins': bot.pluginsWanted = nativeVal || {}; break;
}
}
} catch (e) { /* ignore */ }
return _coerceForConsumer(serialized, cacheEntry.type);
}
module.exports = {
initialize, get, getAll, getAllByCategory, getSection, set, reload, getRegistry,
getBotNames, getBotSettings, setBotSetting, getBotSettingsRegistry,
};
+639
View File
@@ -0,0 +1,639 @@
'use strict';
const express = require('express');
const settings = require('./manager');
const Database = require('../storage/database');
function createRouter() {
const router = express.Router();
function dbAvailable() {
return Database && Database.db;
}
// ---- Global settings ----
router.get('/api/settings', async (req, res) => {
try {
const all = settings.getAll();
const registry = settings.getRegistry();
const result = registry.map(r => ({
...r,
value: all[r.key],
}));
res.json({ settings: result });
} catch (error) {
console.error('API Error /api/settings:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/settings/:category', async (req, res) => {
try {
const values = settings.getAllByCategory(req.params.category);
const registry = settings.getRegistry().filter(r => r.category === req.params.category);
const result = registry.map(r => ({
...r,
value: values[r.key],
}));
res.json({ settings: result });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.put('/api/settings/:key', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const { key } = req.params;
const { value } = req.body;
if (value === undefined) {
return res.status(400).json({ error: 'Missing value' });
}
const newValue = await settings.set(key, value);
res.json({ key, value: newValue });
} catch (error) {
console.error('API Error /api/settings/:key:', error);
res.status(500).json({ error: error.message });
}
});
// ---- Bot settings ----
router.get('/api/bot-settings', async (req, res) => {
try {
const names = settings.getBotNames();
const botReg = settings.getBotSettingsRegistry();
const bots = names.map(name => {
const s = settings.getBotSettings(name);
const flat = { name };
// Add metadata from registry for each key
for (const br of botReg) {
flat[br.key] = { value: s ? s[br.key] : null, type: br.type, label: br.label, description: br.description };
}
return flat;
});
res.json({ bots, registry: botReg });
} catch (error) {
console.error('API Error /api/bot-settings:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/bot-settings/:botName', async (req, res) => {
try {
const s = settings.getBotSettings(req.params.botName);
if (!s) return res.status(404).json({ error: `Unknown bot: ${req.params.botName}` });
const botReg = settings.getBotSettingsRegistry();
const result = { name: req.params.botName };
for (const br of botReg) {
result[br.key] = { value: s[br.key], type: br.type, label: br.label, description: br.description };
}
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.put('/api/bot-settings/:botName/:key', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const { botName, key } = req.params;
const { value } = req.body;
if (value === undefined) return res.status(400).json({ error: 'Missing value' });
const newValue = await settings.setBotSetting(botName, key, value);
res.json({ botName, key, value: newValue });
} catch (error) {
console.error('API Error /api/bot-settings/:botName/:key:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'settings',
tabLabel: 'Settings',
tabOrder: 35,
html: `
<div id="settingsArea">
<div class="settings-layout">
<div class="settings-sidebar" id="settingsSidebar"></div>
<div class="settings-main" id="settingsMain">
<div class="settings-empty">Select a category to view settings</div>
</div>
</div>
</div>
`,
css: `
.settings-layout{display:flex;gap:16px;min-height:400px}
.settings-sidebar{width:180px;flex-shrink:0;display:flex;flex-direction:column;gap:4px}
.settings-sidebar-btn{background:transparent;border:1px solid #374151;color:#9ca3af;padding:10px 14px;border-radius:6px;cursor:pointer;text-align:left;font-size:.9em;transition:all .2s}
.settings-sidebar-btn:hover{border-color:#60a5fa;color:#e5e7eb}
.settings-sidebar-btn.active{background:#1e40af;border-color:#60a5fa;color:#fff}
.settings-main{flex:1;min-width:0}
.settings-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
.settings-grid{display:flex;flex-direction:column;gap:12px}
.settings-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:14px 16px;transition:border-color .2s}
.settings-card:hover{border-color:#60a5fa}
.settings-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
.settings-card-label{font-weight:600;color:#e5e7eb;font-size:.95em}
.settings-card-desc{color:#6b7280;font-size:.8em;margin-bottom:10px}
.settings-card-key{color:#4b5563;font-size:.75em;font-family:monospace}
.settings-card-body{display:flex;gap:8px;align-items:center}
.settings-card-body input[type="text"],
.settings-card-body input[type="number"],
.settings-card-body input[type="password"]{flex:1;padding:8px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.9em}
.settings-card-body input:focus{outline:none;border-color:#60a5fa}
.settings-card-body textarea{flex:1;padding:8px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.85em;min-height:80px;resize:vertical;font-family:monospace}
.settings-card-body textarea:focus{outline:none;border-color:#60a5fa}
.settings-card-body input[type="checkbox"]{width:18px;height:18px;accent-color:#60a5fa}
.settings-card-body select{flex:1;padding:8px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.9em}
.settings-card-body select:focus{outline:none;border-color:#60a5fa}
.settings-value-display{flex:1;padding:8px 10px;color:#9ca3af;font-size:.9em;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.settings-btn-save{padding:8px 16px;border-radius:6px;border:1px solid #059669;background:#065f46;color:#6ee7b7;cursor:pointer;font-size:.85em;white-space:nowrap;transition:all .2s}
.settings-btn-save:hover{background:#059669;color:#fff}
.settings-btn-save.saved{background:#059669;color:#fff}
.settings-toast{position:fixed;bottom:20px;right:20px;background:#059669;color:#fff;padding:12px 20px;border-radius:8px;font-size:.9em;z-index:9999;opacity:0;transform:translateY(10px);transition:all .3s}
.settings-toast.show{opacity:1;transform:translateY(0)}
.settings-toast.error{background:#dc2626}
.prompt-editor{margin-top:12px;border:1px solid #374151;border-radius:8px;overflow:hidden}
.prompt-editor-layout{display:flex;min-height:300px}
.prompt-editor-sidebar{width:180px;flex-shrink:0;background:#0f1729;border-right:1px solid #374151;display:flex;flex-direction:column}
.prompt-editor-sidebar-header{padding:10px 12px;border-bottom:1px solid #374151;display:flex;justify-content:space-between;align-items:center}
.prompt-editor-sidebar-title{color:#9ca3af;font-size:.75em;text-transform:uppercase;letter-spacing:.5px}
.prompt-editor-sidebar-list{flex:1;overflow-y:auto;padding:4px}
.prompt-editor-prompt-item{padding:8px 10px;border-radius:4px;cursor:pointer;color:#9ca3af;font-size:.85em;transition:all .15s;display:flex;justify-content:space-between;align-items:center}
.prompt-editor-prompt-item:hover{background:#1e293b;color:#e5e7eb}
.prompt-editor-prompt-item.active{background:#1e40af;color:#fff}
.prompt-editor-prompt-item .prompt-delete-x{opacity:0;color:#ef4444;font-weight:bold;font-size:1.1em;padding:0 4px;transition:opacity .15s}
.prompt-editor-prompt-item:hover .prompt-delete-x{opacity:.7}
.prompt-editor-prompt-item .prompt-delete-x:hover{opacity:1}
.prompt-editor-content{flex:1;display:flex;flex-direction:column;padding:12px}
.prompt-editor-content-label{color:#9ca3af;font-size:.8em;margin-bottom:6px}
.prompt-editor-content textarea{flex:1;min-height:250px;padding:10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.85em;font-family:monospace;resize:vertical;line-height:1.5}
.prompt-editor-content textarea:focus{outline:none;border-color:#60a5fa}
.prompt-editor-actions{display:flex;gap:8px;margin-top:8px;align-items:center}
.prompt-editor-vars{font-size:.75em;color:#6b7280;margin-top:6px}
.prompt-editor-vars code{color:#93c5fd;font-size:.85em}
.prompt-editor-add-btn{padding:4px 8px;border-radius:4px;border:1px solid #374151;background:transparent;color:#9ca3af;cursor:pointer;font-size:.8em;transition:all .15s}
.prompt-editor-add-btn:hover{border-color:#60a5fa;color:#e5e7eb}
.bot-list{display:flex;flex-direction:column;gap:8px}
.bot-card{background:#111827;border:1px solid #374151;border-radius:8px;overflow:hidden;transition:border-color .2s}
.bot-card.expanded{border-color:#60a5fa}
.bot-card-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;cursor:pointer;user-select:none;transition:background .15s}
.bot-card-header:hover{background:#1e293b}
.bot-card-name{font-weight:600;color:#e5e7eb;font-size:1em}
.bot-card-summary{font-size:.8em;color:#6b7280;display:flex;gap:12px;flex-wrap:wrap}
.bot-card-summary span{white-space:nowrap}
.bot-card-summary .on{color:#6ee7b7}
.bot-card-summary .off{color:#ef4444}
.bot-card-arrow{color:#6b7280;transition:transform .2s;font-size:1.2em}
.bot-card.expanded .bot-card-arrow{transform:rotate(180deg)}
.bot-card-body{display:none;padding:0 16px 14px;border-top:1px solid #1f2937}
.bot-card.expanded .bot-card-body{display:block}
.bot-field{margin-top:10px}
.bot-field-label{font-size:.8em;color:#9ca3af;margin-bottom:4px}
.bot-field-row{display:flex;gap:8px;align-items:center}
.bot-field-row input[type="text"],
.bot-field-row input[type="number"],
.bot-field-row input[type="password"]{flex:1;padding:6px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.85em}
.bot-field-row input:focus{outline:none;border-color:#60a5fa}
.bot-field-row textarea{flex:1;padding:6px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.8em;font-family:monospace;min-height:50px;resize:vertical}
.bot-field-row textarea:focus{outline:none;border-color:#60a5fa}
.bot-field-row input[type="checkbox"]{width:16px;height:16px;accent-color:#60a5fa}
.bot-field-row .mini-save{padding:6px 12px;border-radius:6px;border:1px solid #059669;background:#065f46;color:#6ee7b7;cursor:pointer;font-size:.8em;white-space:nowrap;transition:all .2s}
.bot-field-row .mini-save:hover{background:#059669;color:#fff}
.bot-field-row .mini-save.saved{background:#059669;color:#fff}
`,
onTabActive: 'onSettingsTabActive',
js: `
let settingsData = [];
let activeCategory = null;
let botData = [];
let botRegistry = [];
function onSettingsTabActive() {
loadSettingsCategories();
}
async function loadSettingsCategories() {
try {
const r = await fetch('/api/settings');
if (!r.ok) throw new Error('Failed to load settings');
const d = await r.json();
settingsData = d.settings || [];
// Build category sidebar
const cats = {};
settingsData.forEach(s => {
if (!cats[s.category]) cats[s.category] = [];
cats[s.category].push(s);
});
// Ensure bots category exists
cats['bots'] = cats['bots'] || [];
const catOrder = ['ai', 'storage', 'server', 'farm', 'invites', 'general', 'bots'];
let sidebarHtml = '';
const catNames = Object.keys(cats);
catNames.sort((a, b) => {
const ia = catOrder.indexOf(a), ib = catOrder.indexOf(b);
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.localeCompare(b);
});
catNames.forEach(cat => {
const count = cats[cat].length;
const label = cat === 'bots' ? 'Bots' : cat.charAt(0).toUpperCase()+cat.slice(1);
const activeClass = activeCategory === cat ? ' active' : (activeCategory === null && cat === catNames[0] ? ' active' : '');
sidebarHtml += '<button class="settings-sidebar-btn'+activeClass+'" onclick="switchSettingsCategory(\\''+escHtml(cat)+'\\')">'+escHtml(label)+(count > 0 ? ' <span style="color:#6b7280;font-size:.8em">('+count+')</span>' : '')+'</button>';
});
document.getElementById('settingsSidebar').innerHTML = sidebarHtml;
if (!activeCategory) {
activeCategory = catNames[0] || null;
}
if (activeCategory === 'bots') {
await loadBotSettings();
} else {
renderSettings(activeCategory);
}
} catch(e) {
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">Failed to load settings: '+escHtml(e.message)+'</div>';
}
}
async function switchSettingsCategory(cat) {
activeCategory = cat;
const btns = document.querySelectorAll('.settings-sidebar-btn');
btns.forEach(b => {
b.classList.remove('active');
if (b.textContent.trim().startsWith(cat === 'bots' ? 'Bots' : cat.charAt(0).toUpperCase()+cat.slice(1))) b.classList.add('active');
});
if (cat === 'bots') {
await loadBotSettings();
} else {
renderSettings(cat);
}
}
async function loadBotSettings() {
try {
const r = await fetch('/api/bot-settings');
if (!r.ok) throw new Error('Failed to load bot settings');
const d = await r.json();
botData = d.bots || [];
botRegistry = d.registry || [];
renderBotSettings();
} catch(e) {
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">Failed to load bot settings: '+escHtml(e.message)+'</div>';
}
}
function renderBotSettings() {
if (!botData.length) {
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">No bots configured</div>';
return;
}
let html = '<div class="bot-list">';
botData.forEach(bot => {
const autoConnect = bot.autoConnect?.value;
const onDemand = bot.onDemand?.value;
const isReady = bot.isReady ? ' (online)' : '';
let summaryParts = [];
if (autoConnect) summaryParts.push('<span class="on">auto-connect</span>');
else summaryParts.push('<span class="off">no auto-connect</span>');
if (onDemand) summaryParts.push('<span class="on">on-demand</span>');
if (bot.commands?.value && Array.isArray(bot.commands.value)) {
summaryParts.push('<span>cmds: '+escHtml(bot.commands.value.join(','))+'</span>');
}
html += '<div class="bot-card" id="botCard_'+escHtml(bot.name)+'">'+
'<div class="bot-card-header" onclick="toggleBotCard(\\''+escHtml(bot.name)+'\\')">'+
'<div>'+
'<div class="bot-card-name">'+escHtml(bot.name)+isReady+'</div>'+
'<div class="bot-card-summary">'+summaryParts.join('')+'</div>'+
'</div>'+
'<div class="bot-card-arrow">&#9660;</div>'+
'</div>'+
'<div class="bot-card-body">';
botRegistry.forEach(br => {
const field = bot[br.key];
if (!field) return;
let val = field.value;
if (br.type === 'boolean') val = val === true || val === 'true';
const displayVal = br.type === 'secret' ? (val ? '***' : '') : val;
const isSecret = br.type === 'secret';
let inputHtml;
if (br.type === 'boolean') {
inputHtml = '<input type="checkbox" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'"'+(val ? ' checked' : '')+' onchange="saveBotSetting(\\''+escHtml(bot.name)+'\\',\\''+escHtml(br.key)+'\\',this.checked)">';
} else if (br.type === 'json') {
const jsonStr = val ? JSON.stringify(val, null, 2) : '';
inputHtml = '<textarea id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" rows="3">'+escHtml(jsonStr)+'</textarea>';
} else if (isSecret) {
inputHtml = '<input type="password" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" value="'+escHtml(String(val || ''))+'" placeholder="(unchanged)">';
} else if (br.type === 'number') {
inputHtml = '<input type="number" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" value="'+escHtml(String(val ?? ''))+'">';
} else {
inputHtml = '<input type="text" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" value="'+escHtml(String(val ?? ''))+'">';
}
html += '<div class="bot-field">'+
'<div class="bot-field-label">'+escHtml(br.label)+' <code style="color:#4b5563">'+escHtml(br.key)+'</code> '+escHtml(br.description ? '- '+br.description : '')+'</div>'+
'<div class="bot-field-row">'+inputHtml;
if (br.type !== 'boolean') {
html += '<button class="mini-save" id="btn_bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" onclick="saveBotSetting(\\''+escHtml(bot.name)+'\\',\\''+escHtml(br.key)+'\\',document.getElementById(\\'bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'\\').value)">Save</button>';
}
html += '</div></div>';
});
html += '</div></div>';
});
html += '</div>';
document.getElementById('settingsMain').innerHTML = html;
}
function toggleBotCard(name) {
const card = document.getElementById('botCard_'+name);
if (!card) return;
card.classList.toggle('expanded');
}
async function saveBotSetting(botName, key, value) {
const btn = document.getElementById('btn_bot_'+botName+'_'+key);
try {
const r = await fetch('/api/bot-settings/'+encodeURIComponent(botName)+'/'+encodeURIComponent(key), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: value })
});
if (!r.ok) {
const err = await r.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error || 'Failed to save');
}
// Update local data
const bot = botData.find(b => b.name === botName);
if (bot && bot[key]) {
bot[key].value = (key === 'password' && value) ? value : value;
}
if (btn) {
btn.classList.add('saved');
btn.textContent = 'Saved!';
setTimeout(() => { btn.classList.remove('saved'); btn.textContent = 'Save'; }, 2000);
}
// If password field, clear it after save
if (key === 'password') {
const inp = document.getElementById('bot_'+botName+'_'+key);
if (inp) inp.value = '';
}
showToast('Saved '+botName+'.'+key);
} catch(e) {
showToast('Error: '+e.message, true);
if (btn) { btn.style.borderColor = '#dc2626'; btn.textContent = 'Error';
setTimeout(() => { btn.style.borderColor = '#059669'; btn.textContent = 'Save'; }, 3000); }
}
}
function renderSettings(category) {
if (!category) {
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">Select a category</div>';
return;
}
const items = settingsData.filter(s => s.category === category);
if (items.length === 0) {
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">No settings in this category</div>';
return;
}
let html = '<div class="settings-grid">';
items.forEach(s => {
const key = escHtml(s.key);
const label = escHtml(s.label || s.key);
const desc = escHtml(s.description || '');
const isSecret = s.type === 'secret';
let inputHtml = '';
if (s.key === 'ai.prompts') {
inputHtml = buildPromptEditor(s);
} else if (s.key === 'ai.promptName') {
const prompts = getPromptsMap();
const names = Object.keys(prompts);
if (names.length === 0) names.push('asshole');
inputHtml = '<select id="inp_'+key+'" onchange="saveSetting(\\''+key+'\\', this.value)">'+
names.map(n => '<option value="'+escHtml(n)+'"'+(String(s.value) === n ? ' selected' : '')+'>'+escHtml(n)+'</option>').join('')+
'</select>';
} else if (s.key === 'ai.prompCustom') {
inputHtml = '<textarea id="inp_'+key+'" rows="4" style="width:100%">'+escHtml(String(s.value ?? ''))+'</textarea>';
} else if (s.type === 'boolean') {
const checked = s.value === true ? ' checked' : '';
inputHtml = '<input type="checkbox" id="inp_'+key+'"'+checked+' onchange="saveSetting(\\''+key+'\\', this.checked)">';
} else if (isSecret) {
inputHtml = '<input type="password" id="inp_'+key+'" value="'+escHtml(String(s.value ?? ''))+'" placeholder="(unchanged)">';
} else if (s.type === 'json') {
const jsonStr = s.value ? JSON.stringify(s.value, null, 2) : '';
inputHtml = '<textarea id="inp_'+key+'" rows="4">'+escHtml(jsonStr)+'</textarea>';
} else if (s.type === 'number') {
inputHtml = '<input type="number" id="inp_'+key+'" value="'+escHtml(String(s.value ?? ''))+'" step="any">';
} else {
inputHtml = '<input type="text" id="inp_'+key+'" value="'+escHtml(String(s.value ?? ''))+'">';
}
html += '<div class="settings-card">'+
'<div class="settings-card-header">'+
'<div><div class="settings-card-label">'+label+'</div><div class="settings-card-key">'+key+'</div></div>'+
'</div>'+
'<div class="settings-card-desc">'+desc+'</div>'+
'<div class="settings-card-body">'+inputHtml;
if (s.key === 'ai.prompts') {
// Prompt editor handles its own save
} else if (s.key === 'ai.promptName' || s.key === 'ai.prompCustom' || s.type !== 'boolean') {
html += '<button class="settings-btn-save" onclick="saveSetting(\\''+key+'\\', document.getElementById(\\'inp_'+key+'\\').value)">Save</button>';
}
html += '</div></div>';
});
html += '</div>';
document.getElementById('settingsMain').innerHTML = html;
const promptsItem = items.find(s => s.key === 'ai.prompts');
if (promptsItem) initPromptEditor(promptsItem);
}
async function saveSetting(key, value) {
const btn = event && event.target ? event.target : null;
try {
const r = await fetch('/api/settings/'+encodeURIComponent(key), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: value })
});
if (!r.ok) {
const err = await r.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error || 'Failed to save');
}
const d = await r.json();
const item = settingsData.find(s => s.key === key);
if (item) item.value = d.value;
if (btn) {
btn.classList.add('saved');
btn.textContent = 'Saved!';
setTimeout(() => { btn.classList.remove('saved'); btn.textContent = 'Save'; }, 2000);
}
// Clear password field after save
const isSecret = settingsData.find(s => s.key === key)?.type === 'secret';
if (isSecret) {
const inp = document.getElementById('inp_'+key);
if (inp) inp.value = '';
}
showToast('Saved '+key);
} catch(e) {
showToast('Error: '+e.message, true);
if (btn) {
btn.style.borderColor = '#dc2626'; btn.style.background = '#7f1d1d'; btn.style.color = '#fca5a5'; btn.textContent = 'Error';
setTimeout(() => { btn.style.borderColor = '#059669'; btn.style.background = '#065f46'; btn.style.color = '#6ee7b7'; btn.textContent = 'Save'; }, 3000);
}
}
}
let selectedPromptName = null;
function getPromptsMap() {
const item = settingsData.find(s => s.key === 'ai.prompts');
if (item && item.value && typeof item.value === 'object' && !Array.isArray(item.value)) {
return item.value;
}
return {};
}
function buildPromptEditor(s) {
return '<div class="prompt-editor">'+
'<div class="prompt-editor-layout">'+
'<div class="prompt-editor-sidebar">'+
'<div class="prompt-editor-sidebar-header">'+
'<span class="prompt-editor-sidebar-title">Prompts</span>'+
'<button class="prompt-editor-add-btn" onclick="addNewPrompt()">+ Add</button>'+
'</div>'+
'<div class="prompt-editor-sidebar-list" id="promptEditorList"></div>'+
'</div>'+
'<div class="prompt-editor-content">'+
'<span class="prompt-editor-content-label">Edit template for: <strong id="promptEditorActiveName">none</strong></span>'+
'<textarea id="promptEditorTextarea" placeholder="Select a prompt from the sidebar or add a new one..."></textarea>'+
'<div class="prompt-editor-actions">'+
'<button class="settings-btn-save" onclick="savePromptTemplate()">Save Template</button>'+
'<span style="font-size:0.75em;color:#6b7280" id="promptEditorSaved"></span>'+
'</div>'+
'<div class="prompt-editor-vars">Template variables: <code>\${name}</code> <code>\${interval}</code> <code>\${currentPlayers}</code> <code>\${toolsDocs}</code> <code>\${memoryContext}</code> <code>\${timeInfo}</code> <code>\${custom}</code></div>'+
'</div>'+
'</div>'+
'</div>';
}
function initPromptEditor(s) {
const prompts = getPromptsMap();
renderPromptList(prompts);
const names = Object.keys(prompts);
if (names.length > 0) {
selectPromptToEdit(names[0]);
}
}
function renderPromptList(prompts) {
const listEl = document.getElementById('promptEditorList');
if (!listEl) return;
const names = Object.keys(prompts);
listEl.innerHTML = names.map(name =>
'<div class="prompt-editor-prompt-item'+(name === selectedPromptName ? ' active' : '')+'" onclick="selectPromptToEdit(\\''+escHtml(name)+'\\')">'+
'<span>'+escHtml(name)+'</span>'+
'<span class="prompt-delete-x" onclick="event.stopPropagation();deletePrompt(\\''+escHtml(name)+'\\')">&times;</span>'+
'</div>'
).join('');
}
function selectPromptToEdit(name) {
selectedPromptName = name;
const prompts = getPromptsMap();
document.getElementById('promptEditorActiveName').textContent = name;
document.getElementById('promptEditorTextarea').value = prompts[name] || '';
document.getElementById('promptEditorSaved').textContent = '';
renderPromptList(prompts);
}
async function savePromptTemplate() {
if (!selectedPromptName) return;
const textarea = document.getElementById('promptEditorTextarea');
const template = textarea.value;
const prompts = getPromptsMap();
prompts[selectedPromptName] = template;
await saveSetting('ai.prompts', prompts);
document.getElementById('promptEditorSaved').textContent = 'Saved!';
setTimeout(() => { document.getElementById('promptEditorSaved').textContent = ''; }, 2000);
}
async function addNewPrompt() {
const name = prompt('New prompt name:');
if (!name || !name.trim()) return;
const trimmed = name.trim();
const prompts = getPromptsMap();
if (prompts[trimmed]) {
alert('Prompt "'+trimmed+'" already exists.');
return;
}
prompts[trimmed] = '';
await saveSetting('ai.prompts', prompts);
selectedPromptName = trimmed;
renderPromptList(prompts);
selectPromptToEdit(trimmed);
loadSettingsCategories();
}
async function deletePrompt(name) {
const prompts = getPromptsMap();
if (Object.keys(prompts).length <= 1) {
alert('Cannot delete the last prompt.');
return;
}
if (!confirm('Delete prompt "'+name+'"?')) return;
delete prompts[name];
await saveSetting('ai.prompts', prompts);
if (selectedPromptName === name) {
const remaining = Object.keys(prompts);
selectedPromptName = remaining.length > 0 ? remaining[0] : null;
}
if (selectedPromptName) {
selectPromptToEdit(selectedPromptName);
}
renderPromptList(prompts);
loadSettingsCategories();
}
function showToast(msg, isError) {
let toast = document.getElementById('settingsToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'settingsToast';
toast.className = 'settings-toast';
document.body.appendChild(toast);
}
toast.textContent = msg;
toast.className = 'settings-toast' + (isError ? ' error' : '');
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => { toast.classList.remove('show'); }, 3000);
}
`,
};
module.exports = { createRouter, webUI };