fable
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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">▼</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)+'\\')">×</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 };
|
||||
Reference in New Issue
Block a user