diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md index 5a657c3..b9b432b 100644 --- a/docs/lumi-ui.md +++ b/docs/lumi-ui.md @@ -81,10 +81,17 @@ presets plus bounded base-size, heading-scale, and control-density ranges. The server accepts only six-digit hex colors, supported font presets, bounded metric values, and readable text/button/input contrast. -The live preview updates colors, role colors, metrics, and typography before -save. The editor also shows contrast warnings for the current preview mode, -offers a reset-to-base action for inherited custom themes, and provides an -optional desktop pop-out preview window that stays synchronized with the editor. +Draft values are isolated to preview roots. The compact preview and pop-out +preview update colors, role colors, metrics, spacing, and typography before +save, but the editor shell and live site keep the active saved theme until the +admin saves/applies the custom theme. The compact preview is hidden on narrow +phone layouts; use the Preview action to open the synchronized pop-out instead. + +The compact preview includes representative headings, pills, cards, buttons, +state buttons, inputs, toggles, alerts/statuses, badges, tables, modal samples, +dirty-state markers, and spacing samples. The pop-out uses a faithful Lumi page +shell with safe sample data so admins can test draft themes against dashboard, +settings, logs, AI, and component-like layouts without exposing real admin data. Missing or invalid stored values are replaced from the custom theme's built-in base. Existing installations with modified legacy `theme_light_*`, @@ -112,32 +119,52 @@ password field blank keeps the existing password. Lumi AI's main Selected model dropdown lists only installed/downloaded models. If the configured model is missing, the settings page shows a warning and saving -requires selecting an installed model. Main context size is a preset dropdown: -Small (2048), Medium (4096), Large (8192), and Extended (16384). Unsupported -freeform context values are rejected server-side. +requires selecting an installed model. Main context, gate context, and output +token budgets use shared presets from Tiny (256) through Extra extended +(32768). Unsupported freeform values are rejected server-side. AI feedback supports `feedback_kind` values `strict_correction` and -`instruction_based`. Feedback tags include `wrong_tool_usage` for cases where -the model called the wrong tool or failed to call an expected tool. Review, -edit, and implementation views show both the kind and tag so admins can tell -direct answer corrections from broader tool-calling or instruction guidance. +`instruction_based`; instruction-based feedback is the default because most +reviews are guidance for future replies rather than exact replacement answers. +Feedback tags include `wrong_tool_usage` for cases where the model called the +wrong tool or failed to call an expected tool. Review, edit, and implementation +views show both the kind and tag so admins can tell direct answer corrections +from broader tool-calling or instruction guidance. + +Model/runtime downloads and the combined Start/Restart runtime control use the +Lumi state button behavior. Enhanced browsers start downloads and runtime +actions through fetch, update button state/progress in place, and avoid hard +page refreshes; the underlying POST routes remain available for non-JavaScript +fallbacks. ## Homepage Content -Admins can define homepage external link buttons in `homepage_link_buttons` from -Admin > Settings. Each entry may include `enabled`, `label`, `description`, -`url`, `icon_url`, `permission` (`public`, `user`, `mod`, `admin`), and -`sort_order`. Links open in a new tab with `rel="noopener noreferrer"` and are -filtered server-side by permission. +Admins configure homepage external link buttons from Admin > Settings with the +Homepage content builder. It writes the existing `homepage_link_buttons` JSON +setting behind the scenes. Each entry may include `enabled`, `label`, +`description`, `url`, `icon_url`, `permission` (`public`, `user`, `mod`, +`admin`), and `sort_order`. Links open in a new tab with +`rel="noopener noreferrer"` and are filtered server-side by permission. -Admins can define priority-based hero entries in `homepage_hero_entries`. -Supported types are `twitch_stream`, `youtube_video`, `youtube_channel`, -`discord_server_overview`, `static_image`, `custom_embed`, `custom_link`, and -`none`. The homepage renders the first enabled, available entry the current user -can access. Hero entries support priority/order, permission, source/embed/image -URLs, video IDs, availability mode, autoplay mode metadata, and duration fields. -Slow external availability checks are intentionally avoided; entries fail -closed if required local configuration is missing. +Admins configure priority-based hero entries with the same builder; it writes +the existing `homepage_hero_entries` JSON setting behind the scenes. The +homepage renders the first enabled, available entry the current user can access. +Hero entries support type, priority/order, permission, source/embed/image URLs, +video IDs, availability mode, autoplay mode metadata, and duration fields. Slow +external availability checks are intentionally avoided; entries fail closed if +required local configuration is missing. + +## Admin Dashboard And Logs + +The admin dashboard polls `GET /api/admin/dashboard-metrics` for process +uptime, memory, plugin counts, content counts, and recent log severity totals. +The dashboard renders lightweight SVG graphs using Lumi tokens and does not add +a frontend framework dependency. + +The logs page keeps server-side range/severity/limit filters and adds a labeled +responsive filter bar with search, reset, refresh, and download actions. Search +filters the loaded entries client-side; changing range, severity, or limit +reloads the same `/admin/logs` route with query parameters. ## Visual references diff --git a/plugins/lumi_ai/backend/feedback.js b/plugins/lumi_ai/backend/feedback.js index 755f3cd..7cf22e4 100644 --- a/plugins/lumi_ai/backend/feedback.js +++ b/plugins/lumi_ai/backend/feedback.js @@ -16,7 +16,7 @@ const FEEDBACK_TAGS = Object.freeze([ "wrong_tool_usage" ]); -const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]); +const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]); class FeedbackStore { constructor(options = {}) { @@ -39,7 +39,7 @@ class FeedbackStore { feedback_tag: tag, feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind) ? input.feedback_kind - : "strict_correction", + : "instruction_based", optional_correction: clean(input.optional_correction, 16000), status: "pending", submitted_by: String(actor?.id || "anonymous"), @@ -76,7 +76,7 @@ class FeedbackStore { return this.mutate(id, (entry) => ({ ...entry, feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag, - feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "strict_correction", + feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "instruction_based", optional_correction: clean(values.optional_correction, 16000), review_notes: clean(values.review_notes, 4000), reviewed_by: String(actor.id), diff --git a/plugins/lumi_ai/index.js b/plugins/lumi_ai/index.js index 3b93515..90d4f24 100644 --- a/plugins/lumi_ai/index.js +++ b/plugins/lumi_ai/index.js @@ -39,12 +39,18 @@ const storage = require("./backend/storage"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils"); const PLUGIN_ID = "lumi_ai"; -const CONTEXT_OPTIONS = Object.freeze([ - { label: "Small (2048)", value: 2048, description: "Good for short replies and low memory usage." }, +const TOKEN_PRESETS = Object.freeze([ + { label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." }, + { label: "Very small (512)", value: 512, description: "Short replies and low memory usage." }, + { label: "Small (1024)", value: 1024, description: "Compact answers and lightweight request gates." }, + { label: "Short (2048)", value: 2048, description: "Short conversations and normal commands." }, { label: "Medium (4096)", value: 4096, description: "Balanced default for normal assistant use." }, - { label: "Large (8192)", value: 8192, description: "Better for longer conversations and documents." }, - { label: "Extended (16384)", value: 16384, description: "Useful for long context when the selected model supports it." } + { label: "Large (8192)", value: 8192, description: "Longer conversations and documents." }, + { label: "Extended (16384)", value: 16384, description: "Long context when the selected model supports it." }, + { label: "Extra extended (32768)", value: 32768, description: "Highest supported local preset for large-context models." } ]); +const CONTEXT_OPTIONS = TOKEN_PRESETS; +const GATE_CONTEXT_OPTIONS = TOKEN_PRESETS.filter((option) => option.value >= 512 && option.value <= 4096); const modelManifest = require("./models_manifest.json"); const runtimeManifest = require("./runtime_manifest.json"); @@ -322,6 +328,8 @@ module.exports = { installedModels, selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id), contextOptions: CONTEXT_OPTIONS, + tokenPresets: TOKEN_PRESETS, + gateContextOptions: GATE_CONTEXT_OPTIONS, runtimeTarget, runtimeManifest, runtimeStatus, @@ -371,8 +379,23 @@ module.exports = { return flash(req, res, "error", "Choose a supported AI context size."); } const contextSize = requestedContext; + const tokenValues = TOKEN_PRESETS.map((option) => option.value); + const gateContextValues = GATE_CONTEXT_OPTIONS.map((option) => option.value); + const requestedGateContext = Number(req.body.gate_context_size); + if (!gateContextValues.includes(requestedGateContext)) { + return flash(req, res, "error", "Choose a supported gate context size."); + } + const presetToken = (field, fallback, label) => { + const value = Number(req.body[field]); + if (!tokenValues.includes(value)) { + throw new Error(`Choose a supported preset for ${label}.`); + } + return value || fallback; + }; const previousConfig = config; - config = saveConfig({ + let nextConfig; + try { + nextConfig = { ...config, enabled: req.body.enabled === "on", selected_model_id: model.id, @@ -385,13 +408,13 @@ module.exports = { request_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000), ui_soft_timeout_ms: boundedInt(req.body.ui_soft_timeout_ms, 5000, 300000, 45000), hard_generation_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000), - max_output_tokens: boundedInt(req.body.max_output_tokens, 64, 32768, 2048), + max_output_tokens: presetToken("max_output_tokens", 2048, "API/test output tokens"), output_budgets: { - navigation_help: boundedInt(req.body.output_budget_navigation_help, 64, 32768, 256), - simple_answer: boundedInt(req.body.output_budget_simple_answer, 64, 32768, 512), - code_custom_command: boundedInt(req.body.output_budget_code_custom_command, 64, 32768, 896), - admin_debug: boundedInt(req.body.output_budget_admin_debug, 64, 32768, 1280), - explicit_long: boundedInt(req.body.output_budget_explicit_long, 64, 32768, 2048) + navigation_help: presetToken("output_budget_navigation_help", 256, "navigation/help tokens"), + simple_answer: presetToken("output_budget_simple_answer", 512, "simple answer tokens"), + code_custom_command: presetToken("output_budget_code_custom_command", 1024, "code/custom command tokens"), + admin_debug: presetToken("output_budget_admin_debug", 2048, "admin debug tokens"), + explicit_long: presetToken("output_budget_explicit_long", 4096, "explicit long-answer tokens") }, batch_size: boundedInt(req.body.batch_size, 32, 4096, 512), ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128), @@ -443,7 +466,7 @@ module.exports = { gate: { ...config.gate, model_id: getModel(req.body.gate_model_id)?.id || config.gate.model_id, - context_size: boundedInt(req.body.gate_context_size, 512, 4096, 1024), + context_size: requestedGateContext, threads: boundedInt(req.body.gate_threads, 1, 16, 2), timeout_ms: boundedInt(req.body.gate_timeout_ms, 1000, 5000, 3000), high_confidence_threshold: boundedNumber(req.body.gate_high_confidence_threshold, 0.5, 0.99, 0.88), @@ -489,7 +512,11 @@ module.exports = { trusted_moderator_reviewers: parseIdList(req.body.trusted_moderator_reviewers), corrections_enabled: req.body.corrections_enabled === "on" } - }); + }; + } catch (error) { + return flash(req, res, "error", error.message); + } + config = saveConfig(nextConfig); registerAssistantCommands({ commandRouter, provider, @@ -525,11 +552,15 @@ module.exports = { router.post("/download/runtime", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); + const wantsJson = req.accepts(["json", "html"]) === "json"; const hardware = detectHardware(modelManifest.models, runtimeManifest); const target = getRuntimeTarget(hardware); - if (!target) return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform."); + if (!target) { + if (wantsJson) return res.status(400).json({ error: "No managed llama.cpp runtime is available for this platform." }); + return flash(req, res, "error", "No managed llama.cpp runtime is available for this platform."); + } try { - downloads.start({ + const job = downloads.start({ id: "runtime", ...target, kind: "runtime", @@ -540,29 +571,37 @@ module.exports = { target: target.filename } }); + if (wantsJson) return res.json({ success: true, job }); return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`); } catch (error) { + if (wantsJson) return res.status(400).json({ error: error.message }); return flash(req, res, "error", error.message); } }); router.post("/download/model/:id", (req, res) => { if (!req.session.user?.isAdmin) return denied(res); + const wantsJson = req.accepts(["json", "html"]) === "json"; const model = getModel(req.params.id); - if (!model) return flash(req, res, "error", "Unknown model."); + if (!model) { + if (wantsJson) return res.status(404).json({ error: "Unknown model." }); + return flash(req, res, "error", "Unknown model."); + } if ( (model.id === config.selected_model_id && runtime.status().state === "running") || (model.id === config.gate.model_id && gateRuntime.status().state === "running") ) { + if (wantsJson) return res.status(400).json({ error: "Stop the AI runtimes before replacing an active model." }); return flash(req, res, "error", "Stop the AI runtimes before replacing an active model."); } const hardware = detectHardware(modelManifest.models); const incompatible = model.ram_gb * 1024 > hardware.total_ram_mb || model.size / 1048576 > hardware.free_disk_mb; if (incompatible && req.body.override_compatibility !== "on") { + if (wantsJson) return res.status(400).json({ error: "This model exceeds detected RAM or free disk. Check override to download anyway." }); return flash(req, res, "error", "This model exceeds detected RAM or free disk. Check override to download anyway."); } try { - downloads.start({ + const job = downloads.start({ id: `model:${model.id}`, url: `https://huggingface.co/${model.repo}/resolve/${model.revision}/${model.filename}`, filename: model.filename, @@ -570,8 +609,10 @@ module.exports = { size: model.size, kind: "model" }); + if (wantsJson) return res.json({ success: true, job }); return flash(req, res, "success", `${model.label} download started.`); } catch (error) { + if (wantsJson) return res.status(400).json({ error: error.message }); return flash(req, res, "error", error.message); } }); diff --git a/plugins/lumi_ai/public/assistant.js b/plugins/lumi_ai/public/assistant.js index 2219241..f24e2b2 100644 --- a/plugins/lumi_ai/public/assistant.js +++ b/plugins/lumi_ai/public/assistant.js @@ -300,8 +300,8 @@ const kind = document.createElement("select"); kind.setAttribute("aria-label", "Feedback type"); for (const [value, label] of [ - ["strict_correction", "Strict correction"], - ["instruction_based", "Instruction-based guidance"] + ["instruction_based", "Instruction-based guidance"], + ["strict_correction", "Strict correction"] ]) { const option = document.createElement("option"); option.value = value; diff --git a/plugins/lumi_ai/public/settings.js b/plugins/lumi_ai/public/settings.js index 01e520b..6e41732 100644 --- a/plugins/lumi_ai/public/settings.js +++ b/plugins/lumi_ai/public/settings.js @@ -7,27 +7,66 @@ const testToolsNotice = document.querySelector("[data-ai-test-tools-notice]"); const gpuControl = document.querySelector("[data-gpu-control]"); const accessForm = document.querySelector("[data-ai-access-form]"); + const runtimePrimary = document.querySelector("[data-runtime-primary]"); if (actions) { + const syncPrimary = (nextState) => { + if (!runtimePrimary || !window.LumiStateButton) return; + const normalized = String(nextState || state?.textContent || "").toLowerCase(); + window.LumiStateButton.setState(runtimePrimary, normalized === "running" ? "running" : "idle"); + }; actions.addEventListener("click", async (event) => { - const button = event.target.closest("[data-runtime-action]"); + const primaryButton = event.target.closest("[data-runtime-primary]"); + const button = primaryButton || event.target.closest("[data-runtime-action]"); if (!button) return; + const action = primaryButton + ? (String(state?.textContent || "").trim().toLowerCase() === "running" ? "restart" : "start") + : button.dataset.runtimeAction; + if (!action) return; button.disabled = true; + if (primaryButton && window.LumiStateButton) { + window.LumiStateButton.setState(button, action === "restart" ? "restarting" : "starting", { busy: true }); + } try { - const response = await fetch(`/plugins/lumi_ai/runtime/${button.dataset.runtimeAction}`, { method: "POST" }); + const response = await fetch(`/plugins/lumi_ai/runtime/${action}`, { method: "POST" }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Runtime action failed."); if (data.state) state.textContent = data.state; - if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(button.dataset.runtimeAction)) { + syncPrimary(data.state); + if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(action)) { const labels = { "self-test": "Runtime self-test passed.", "verify-runtime": "Runtime installation verified.", "verify-model": "Model verification passed.", "verify-gate-model": "Gate model verification passed." }; - window.alert(labels[button.dataset.runtimeAction]); + window.alert(labels[action]); } } catch (error) { + if (primaryButton && window.LumiStateButton) window.LumiStateButton.error(button); window.alert(error.message); } finally { button.disabled = false; } }); } + document.querySelectorAll("[data-ai-download-form]").forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const button = form.querySelector("[data-ai-download-button]"); + window.LumiStateButton?.setState(button, "loading", { busy: true }); + try { + const response = await fetch(form.action, { + method: "POST", + headers: { "Accept": "application/json" }, + body: new FormData(form) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Download failed to start."); + pollDownloads(); + } catch (error) { + window.LumiStateButton?.error(button); + if (downloadStatus) { + downloadStatus.hidden = false; + downloadStatus.textContent = error.message; + } + } + }); + }); const pollDownloads = async () => { if (!downloadStatus) return; try { @@ -41,9 +80,19 @@ const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0; return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`; }).join(" | "); + jobs.forEach((job) => { + const button = document.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`); + if (!button || !window.LumiStateButton) return; + if (job.state === "complete") window.LumiStateButton.setState(button, "success"); + else if (job.state === "error") window.LumiStateButton.setState(button, "error"); + else window.LumiStateButton.setState(button, "loading", { busy: true }); + const label = button.querySelector('[data-state-view="loading"] span:last-child'); + if (label && job.total) label.textContent = `Downloading ${Math.floor(job.downloaded / job.total * 100)}%`; + }); if (active.length) window.setTimeout(pollDownloads, 1000); } catch {} }; + pollDownloads(); if (testForm && testOutput) { const updateTestToolsNotice = () => { if (!testToolsNotice) return; diff --git a/plugins/lumi_ai/views/improvement-center.ejs b/plugins/lumi_ai/views/improvement-center.ejs index bbe0e39..769528a 100644 --- a/plugins/lumi_ai/views/improvement-center.ejs +++ b/plugins/lumi_ai/views/improvement-center.ejs @@ -93,7 +93,7 @@