ui: add streamed interactions and homepage controls
This commit is contained in:
parent
0512fa5931
commit
0d4431924a
@ -16,6 +16,11 @@ from visual tokens and reusable components.
|
||||
- `src/web/public/lumi-state-button.js` and
|
||||
`src/web/views/partials/state-button.ejs`: reusable multi-state button
|
||||
behavior for submit/loading/success actions.
|
||||
- `src/web/public/lumi-interactions.js`: progressive interaction layer for
|
||||
server-sent events, no-auto-refresh notices, dirty settings save bars,
|
||||
expandable settings containers, refresh prompts, and soft navigation.
|
||||
- `src/services/web-events.js`: small role-aware Server-Sent Events bus exposed
|
||||
at `GET /api/events` for authenticated users.
|
||||
- `src/web/public/styles.css`: legacy and feature-specific styles that still use
|
||||
the shared tokens. New general-purpose styling belongs in the Lumi UI files.
|
||||
- `src/web/views/partials/page-header.ejs`: standard page title and description.
|
||||
@ -29,6 +34,35 @@ Use `lumi-stack`, `lumi-cluster`, `lumi-split`, `lumi-grid`, `page-header`,
|
||||
and `status-indicator` before adding one-off layout rules. Preserve existing IDs,
|
||||
field names, data attributes, and JavaScript hooks when restyling a page.
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
Pages should not self-refresh for state or progress changes. Core connection
|
||||
recovery now displays a notice instead of calling `window.location.reload()`.
|
||||
Server-originated events use `GET /api/events` with explicit event names such as
|
||||
`server:status`, `server:warning`, `ai:model_status`, and
|
||||
`data:new_available`. Admin-only events must be published with `{ role: "admin" }`.
|
||||
|
||||
List/data updates should announce that new data exists and show a refresh prompt.
|
||||
The shared refresh prompt uses a 3-second cooldown before another refresh can be
|
||||
requested. It does not replace list contents automatically.
|
||||
|
||||
Forms that represent page settings should add `data-lumi-settings-form`.
|
||||
Action-only forms must not use that attribute. The shared dirty-state layer
|
||||
tracks original values, marks changed fields with theme-aware unsaved styling,
|
||||
shows a top Save changes bar, warns before accidental navigation, and clears
|
||||
markers only after successful saves.
|
||||
|
||||
Expandable settings rows use `data-lumi-expandable-settings` on a `<details>`
|
||||
container. Preview text can be wired with `data-placeholder-preview="#field-id"`;
|
||||
known placeholders such as `{gifter_username}`, `{item_name}`,
|
||||
`{creator_username}`, and `{amount_display}` render with plausible sample values
|
||||
without changing the saved template.
|
||||
|
||||
Soft navigation progressively enhances same-origin links by replacing
|
||||
`main.content`, updating history, and fading content in place. If a fetch fails,
|
||||
JavaScript is unavailable, or unsaved settings are present, navigation falls back
|
||||
to normal browser behavior.
|
||||
|
||||
## Themes
|
||||
|
||||
Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High
|
||||
@ -74,6 +108,37 @@ Admins can change the localhost username and password from **Admin > Settings**
|
||||
when the settings page itself is accessed through localhost. Leaving the
|
||||
password field blank keeps the existing password.
|
||||
|
||||
## Lumi AI Settings And Feedback
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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 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.
|
||||
|
||||
## Visual references
|
||||
|
||||
- [Home, desktop](screenshots/lumi-home-desktop.png)
|
||||
|
||||
@ -36,6 +36,8 @@ class CorrectionStore {
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
source_feedback_id: feedback.id,
|
||||
feedback_kind: feedback.feedback_kind || "strict_correction",
|
||||
feedback_tag: feedback.feedback_tag || "",
|
||||
prompt: feedback.user_message,
|
||||
corrected_answer: answer,
|
||||
rejected_answer: feedback.assistant_answer,
|
||||
@ -166,6 +168,9 @@ class CorrectionStore {
|
||||
.filter((entry) => ["correction", "route_alias", "predefined_answer"].includes(entry.target))
|
||||
.map((entry) => [
|
||||
`Reviewed correction for a similar request (minimum role: ${entry.min_role}):`,
|
||||
entry.feedback_kind === "instruction_based" || entry.feedback_tag === "wrong_tool_usage"
|
||||
? `Revision guidance: ${entry.feedback_tag === "wrong_tool_usage" ? "tool-calling behavior" : "instruction"}`
|
||||
: "",
|
||||
`Request: ${entry.prompt}`,
|
||||
`Approved answer: ${entry.corrected_answer}`,
|
||||
entry.expected_link ? `Verified link: ${entry.expected_link}` : ""
|
||||
|
||||
@ -12,9 +12,12 @@ const FEEDBACK_TAGS = Object.freeze([
|
||||
"unsafe",
|
||||
"should_clarify",
|
||||
"bad_code",
|
||||
"wrong_scope"
|
||||
"wrong_scope",
|
||||
"wrong_tool_usage"
|
||||
]);
|
||||
|
||||
const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]);
|
||||
|
||||
class FeedbackStore {
|
||||
constructor(options = {}) {
|
||||
this.file = options.file || resolveData("feedback", "reviews.json");
|
||||
@ -34,6 +37,9 @@ class FeedbackStore {
|
||||
model: clean(input.model, 200),
|
||||
timestamp: validDate(input.timestamp) || new Date().toISOString(),
|
||||
feedback_tag: tag,
|
||||
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
|
||||
? input.feedback_kind
|
||||
: "strict_correction",
|
||||
optional_correction: clean(input.optional_correction, 16000),
|
||||
status: "pending",
|
||||
submitted_by: String(actor?.id || "anonymous"),
|
||||
@ -70,6 +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",
|
||||
optional_correction: clean(values.optional_correction, 16000),
|
||||
review_notes: clean(values.review_notes, 4000),
|
||||
reviewed_by: String(actor.id),
|
||||
@ -196,6 +203,7 @@ function validDate(value) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FEEDBACK_KINDS,
|
||||
FEEDBACK_TAGS,
|
||||
FeedbackStore,
|
||||
improvementAccess,
|
||||
|
||||
@ -25,7 +25,7 @@ const { AiRateLimiter, mergeLimits } = require("./backend/rate_limits");
|
||||
const { buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("./backend/commands");
|
||||
const { AssistantPanelDiagnostics } = require("./backend/assistant_panel_diagnostics");
|
||||
const { formatAssistantResponse } = require("./backend/response_formatter");
|
||||
const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
|
||||
const { FeedbackStore, FEEDBACK_KINDS, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
|
||||
const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections");
|
||||
const { EvalStore } = require("./backend/evals");
|
||||
const { TrainingExporter } = require("./backend/training_export");
|
||||
@ -39,6 +39,12 @@ 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." },
|
||||
{ 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." }
|
||||
]);
|
||||
const modelManifest = require("./models_manifest.json");
|
||||
const runtimeManifest = require("./runtime_manifest.json");
|
||||
|
||||
@ -155,6 +161,10 @@ module.exports = {
|
||||
if (config.enabled) {
|
||||
ensureGateRuntime().catch((error) => {
|
||||
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_start_failed", message: error.message });
|
||||
web.emitEvent?.("ai:model_status", {
|
||||
status: "gate_start_failed",
|
||||
message: `Lumi AI gate runtime failed to start: ${error.message}`
|
||||
}, { role: "admin" });
|
||||
});
|
||||
}
|
||||
const main = await ensureMainRuntime(options);
|
||||
@ -171,6 +181,10 @@ module.exports = {
|
||||
try { await ensureGateRuntime(); }
|
||||
catch (error) {
|
||||
metrics.record({ kind: "gate_runtime", status: "failed", reason_code: "gate_restart_failed", message: error.message });
|
||||
web.emitEvent?.("ai:model_status", {
|
||||
status: "gate_restart_failed",
|
||||
message: `Lumi AI gate runtime restart failed: ${error.message}`
|
||||
}, { role: "admin" });
|
||||
}
|
||||
}
|
||||
return { ...main, gate: await gateRuntime.health() };
|
||||
@ -184,7 +198,12 @@ module.exports = {
|
||||
) return;
|
||||
gateRecoveryPending = true;
|
||||
try { await ensureGateRuntime(); }
|
||||
catch {}
|
||||
catch (error) {
|
||||
web.emitEvent?.("ai:model_status", {
|
||||
status: "gate_recovery_failed",
|
||||
message: `Lumi AI gate recovery failed: ${error.message}`
|
||||
}, { role: "admin" });
|
||||
}
|
||||
finally { gateRecoveryPending = false; }
|
||||
}, 30000);
|
||||
gateMonitor.unref?.();
|
||||
@ -287,17 +306,22 @@ module.exports = {
|
||||
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
|
||||
].filter((check) => !check.valid);
|
||||
for (const diagnostic of sizeDiagnostics) console.warn(`Lumi AI size diagnostic: ${diagnostic.message}`);
|
||||
const models = modelManifest.models.map((model) => ({
|
||||
...model,
|
||||
downloaded: fs.existsSync(resolveData("models", model.filename)),
|
||||
installed_size: fs.existsSync(resolveData("models", model.filename))
|
||||
? fs.statSync(resolveData("models", model.filename)).size
|
||||
: 0,
|
||||
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
|
||||
}));
|
||||
const installedModels = models.filter((model) => model.downloaded);
|
||||
res.render(path.join(__dirname, "views", "settings.ejs"), {
|
||||
title: "Lumi AI",
|
||||
config,
|
||||
models: modelManifest.models.map((model) => ({
|
||||
...model,
|
||||
downloaded: fs.existsSync(resolveData("models", model.filename)),
|
||||
installed_size: fs.existsSync(resolveData("models", model.filename))
|
||||
? fs.statSync(resolveData("models", model.filename)).size
|
||||
: 0,
|
||||
compatible: model.ram_gb * 1024 <= hardware.total_ram_mb && model.size / 1048576 <= hardware.free_disk_mb
|
||||
})),
|
||||
models,
|
||||
installedModels,
|
||||
selectedModelInstalled: installedModels.some((model) => model.id === config.selected_model_id),
|
||||
contextOptions: CONTEXT_OPTIONS,
|
||||
runtimeTarget,
|
||||
runtimeManifest,
|
||||
runtimeStatus,
|
||||
@ -338,7 +362,15 @@ module.exports = {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const model = getModel(req.body.selected_model_id);
|
||||
if (!model) return flash(req, res, "error", "Unknown model.");
|
||||
const contextSize = boundedInt(req.body.context_size, 512, 131072, 4096);
|
||||
if (!fs.existsSync(resolveData("models", model.filename))) {
|
||||
return flash(req, res, "error", "Selected model must be installed before it can be saved.");
|
||||
}
|
||||
const contextValues = CONTEXT_OPTIONS.map((option) => option.value);
|
||||
const requestedContext = Number(req.body.context_size);
|
||||
if (!contextValues.includes(requestedContext)) {
|
||||
return flash(req, res, "error", "Choose a supported AI context size.");
|
||||
}
|
||||
const contextSize = requestedContext;
|
||||
const previousConfig = config;
|
||||
config = saveConfig({
|
||||
...config,
|
||||
@ -891,6 +923,7 @@ module.exports = {
|
||||
model: req.body.model,
|
||||
timestamp: req.body.timestamp,
|
||||
feedback_tag: req.body.feedback_tag,
|
||||
feedback_kind: req.body.feedback_kind,
|
||||
optional_correction: req.body.optional_correction
|
||||
}, req.session.user);
|
||||
return res.status(201).json({ success: true, id: entry.id });
|
||||
@ -1148,6 +1181,7 @@ module.exports = {
|
||||
config,
|
||||
access,
|
||||
feedbackTags: FEEDBACK_TAGS,
|
||||
feedbackKinds: FEEDBACK_KINDS,
|
||||
promotionTargets: PROMOTION_TARGETS,
|
||||
reviews: feedbackStore.list({
|
||||
page: req.query.review_page,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
.lumi-ai-state.ready { background: #2ea043; box-shadow: 0 0 0 3px color-mix(in srgb, #2ea043 18%, transparent); }
|
||||
.lumi-ai-state.warming { background: #d29922; box-shadow: 0 0 0 3px color-mix(in srgb, #d29922 18%, transparent); }
|
||||
.lumi-ai-state.error { background: #d73a49; box-shadow: 0 0 0 3px color-mix(in srgb, #d73a49 18%, transparent); }
|
||||
.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; top: var(--lumi-ai-top, calc(100vh - 16.666vh - 14px)); height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 16px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; }
|
||||
.lumi-ai-panel { position: fixed; z-index: 1; left: calc(var(--sidebar-width, 260px) + 14px); right: 14px; bottom: 14px; height: max(180px, 16.666vh); min-height: 180px; max-height: calc(100vh - 96px); display: grid; grid-template-rows: 8px auto 1fr auto auto; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 18px 55px rgba(0,0,0,.22); opacity: 0; transform: translateY(100%); pointer-events: none; transition: transform 0.25s ease-in-out, opacity 0.25s ease-in-out; }
|
||||
.lumi-ai-panel.open { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||
.lumi-ai-resize-handle { position: relative; cursor: ns-resize; background: var(--surface-2); touch-action: none; }
|
||||
.lumi-ai-resize-handle::after { content: ""; position: absolute; top: 3px; left: 50%; width: 42px; height: 2px; transform: translateX(-50%); border-radius: 2px; background: var(--border); }
|
||||
|
||||
@ -65,15 +65,8 @@
|
||||
};
|
||||
|
||||
const positionPanel = (height = panelHeight()) => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const footerRect = document.querySelector(".site-footer")?.getBoundingClientRect();
|
||||
const bottomLimit = footerRect && footerRect.top < viewportHeight && footerRect.bottom > 0
|
||||
? Math.max(MIN_HEIGHT + 8, footerRect.top - 8)
|
||||
: viewportHeight - 8;
|
||||
const maximum = Math.max(MIN_HEIGHT, bottomLimit - 8);
|
||||
const maximum = Math.max(MIN_HEIGHT, window.innerHeight - 96);
|
||||
const clampedHeight = Math.min(maximum, Math.max(MIN_HEIGHT, height));
|
||||
const top = Math.max(8, bottomLimit - clampedHeight);
|
||||
panel.style.setProperty("--lumi-ai-top", `${top}px`);
|
||||
panel.style.height = `${clampedHeight}px`;
|
||||
return clampedHeight;
|
||||
};
|
||||
@ -296,21 +289,33 @@
|
||||
"unsafe",
|
||||
"should_clarify",
|
||||
"bad_code",
|
||||
"wrong_scope"
|
||||
"wrong_scope",
|
||||
"wrong_tool_usage"
|
||||
]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = tag;
|
||||
option.textContent = tag.replaceAll("_", " ");
|
||||
select.append(option);
|
||||
}
|
||||
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"]
|
||||
]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
kind.append(option);
|
||||
}
|
||||
const correction = document.createElement("input");
|
||||
correction.maxLength = 16000;
|
||||
correction.placeholder = "Optional correction";
|
||||
correction.setAttribute("aria-label", "Optional correction");
|
||||
correction.placeholder = "Correction or instruction for future replies";
|
||||
correction.setAttribute("aria-label", "Correction or instruction");
|
||||
const submitFeedback = document.createElement("button");
|
||||
submitFeedback.type = "submit";
|
||||
submitFeedback.textContent = "Send feedback";
|
||||
controls.append(select, correction, submitFeedback);
|
||||
controls.append(select, kind, correction, submitFeedback);
|
||||
controls.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submitFeedback.disabled = true;
|
||||
@ -321,6 +326,7 @@
|
||||
body: JSON.stringify({
|
||||
...context,
|
||||
feedback_tag: select.value,
|
||||
feedback_kind: kind.value,
|
||||
optional_correction: correction.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
@ -162,6 +162,7 @@
|
||||
workload.addEventListener("change", refreshCapacity);
|
||||
model.addEventListener("change", refreshCapacity);
|
||||
context.addEventListener("input", scheduleCapacity);
|
||||
context.addEventListener("change", refreshCapacity);
|
||||
refreshCapacity();
|
||||
}
|
||||
if (accessForm) {
|
||||
|
||||
@ -43,14 +43,14 @@
|
||||
<% reviews.entries.forEach((review) => { %>
|
||||
<article class="improvement-card">
|
||||
<header>
|
||||
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.status %></span></div>
|
||||
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div>
|
||||
<span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %></span>
|
||||
</header>
|
||||
<div class="improvement-pair">
|
||||
<div><span>User message</span><pre><%= review.user_message %></pre></div>
|
||||
<div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></div>
|
||||
</div>
|
||||
<% if (review.optional_correction) { %><div class="improvement-correction"><strong>Suggested correction</strong><pre><%= review.optional_correction %></pre></div><% } %>
|
||||
<% if (review.optional_correction) { %><div class="improvement-correction"><strong><%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %></strong><pre><%= review.optional_correction %></pre></div><% } %>
|
||||
<% if (review.review_notes) { %><p class="hint"><strong>Review notes:</strong> <%= review.review_notes %></p><% } %>
|
||||
<div class="improvement-actions">
|
||||
<% if (access.can_flag) { %>
|
||||
@ -93,7 +93,8 @@
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form">
|
||||
<input type="hidden" name="action" value="edit" />
|
||||
<div class="field"><label>Feedback tag</label><select name="feedback_tag"><% feedbackTags.forEach((tag) => { %><option value="<%= tag %>" <%= tag === review.feedback_tag ? "selected" : "" %>><%= tag %></option><% }) %></select></div>
|
||||
<div class="field full"><label>Optional correction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></div>
|
||||
<div class="field"><label>Feedback type</label><select name="feedback_kind"><% feedbackKinds.forEach((kind) => { %><option value="<%= kind %>" <%= kind === (review.feedback_kind || "strict_correction") ? "selected" : "" %>><%= kind.replaceAll("_", " ") %></option><% }) %></select></div>
|
||||
<div class="field full"><label>Correction or instruction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></div>
|
||||
<div class="field full"><label>Review notes</label><textarea name="review_notes" rows="3"><%= review.review_notes %></textarea></div>
|
||||
<div class="field full improvement-actions"><button class="button" type="submit">Save review</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>
|
||||
</form>
|
||||
@ -105,7 +106,7 @@
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/implement" class="form-grid ai-form">
|
||||
<div class="field"><label>Promotion target</label><select name="target"><% promotionTargets.forEach((target) => { %><option value="<%= target %>"><%= target.replaceAll("_", " ") %></option><% }) %></select></div>
|
||||
<div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= role %></option><% }) %></select></div>
|
||||
<div class="field full"><label>Corrected / expected answer</label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
|
||||
<div class="field full"><label><%= review.feedback_kind === "instruction_based" ? "Instruction to apply" : "Corrected / expected answer" %></label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
|
||||
<div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div>
|
||||
<div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div>
|
||||
<div class="field"><label>Route alias</label><input name="route_alias" /></div>
|
||||
|
||||
@ -202,7 +202,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/plugins/lumi_ai/settings">
|
||||
<form method="post" action="/plugins/lumi_ai/settings" data-lumi-settings-form>
|
||||
<section class="ai-band" id="assistant">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Assistant</h2><p>Configuration remains admin-only. Visibility controls only the sidebar assistant.</p></div>
|
||||
@ -222,9 +222,24 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="selected-model">Selected model</label>
|
||||
<select id="selected-model" name="selected_model_id" data-gpu-model><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
|
||||
<% if (!selectedModelInstalled) { %>
|
||||
<div class="callout danger">The currently selected model is not installed. Choose an installed model before saving.</div>
|
||||
<% } %>
|
||||
<select id="selected-model" name="selected_model_id" data-gpu-model required>
|
||||
<% if (!installedModels.length) { %>
|
||||
<option value="">No installed models available</option>
|
||||
<% } %>
|
||||
<% installedModels.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Context size</label>
|
||||
<select name="context_size" data-gpu-context>
|
||||
<% contextOptions.forEach((option) => { %>
|
||||
<option value="<%= option.value %>" <%= Number(config.context_size) === option.value ? "selected" : "" %>><%= option.label %> - <%= option.description %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Context size</label><input type="number" name="context_size" min="512" max="131072" value="<%= config.context_size %>" data-gpu-context /></div>
|
||||
<div class="field"><label>CPU threads (0 = auto)</label><input type="number" name="threads" min="0" max="256" value="<%= config.threads %>" /></div>
|
||||
<div class="field full ai-gpu-control" data-gpu-control data-endpoint="/plugins/lumi_ai/api/gpu-capacity">
|
||||
<div class="ai-gpu-label">
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
.diagnostic-grid > div,
|
||||
.destination-panel,
|
||||
.template-panel {
|
||||
.template-panel:not(.lumi-expandable-settings) {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
padding: 14px;
|
||||
@ -99,7 +99,7 @@
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.template-panel {
|
||||
.template-panel:not(.lumi-expandable-settings) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@ -164,10 +164,22 @@
|
||||
<p>No active platform templates are available.</p>
|
||||
<% } %>
|
||||
<% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %>
|
||||
<form method="post" action="/plugins/throne_wishlist/templates" class="template-panel">
|
||||
<details class="template-panel lumi-expandable-settings" data-lumi-expandable-settings>
|
||||
<summary>
|
||||
<span>
|
||||
<strong><%= status?.label || platform %></strong>
|
||||
<span class="lumi-preview-line" data-placeholder-preview="#template-<%= eventType %>-<%= platform %>" data-fallback="<%= template?.template || "" %>"></span>
|
||||
</span>
|
||||
<label class="switch" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="switch-input" disabled <%= template?.enabled ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</summary>
|
||||
<form method="post" action="/plugins/throne_wishlist/templates" class="lumi-expandable-body form-grid" data-lumi-settings-form>
|
||||
<input type="hidden" name="event_type" value="<%= eventType %>" />
|
||||
<input type="hidden" name="platform" value="<%= platform %>" />
|
||||
<div class="template-heading">
|
||||
<div class="template-heading field full">
|
||||
<strong><%= status?.label || platform %></strong>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="enabled" <%= template?.enabled ? "checked" : "" %> />
|
||||
@ -175,10 +187,13 @@
|
||||
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
|
||||
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
|
||||
<div class="field full">
|
||||
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
|
||||
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
|
||||
</div>
|
||||
<button type="submit" class="button subtle">Save <%= status?.label || platform %></button>
|
||||
</form>
|
||||
</details>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -101,6 +101,37 @@ function verifyThemeService() {
|
||||
assert(rendered.includes("data-lumi-state-button"));
|
||||
assert(rendered.includes("Built-in · read-only"));
|
||||
|
||||
const homeView = path.join(root, "src", "web", "views", "home.ejs");
|
||||
const homeRendered = ejs.render(fs.readFileSync(homeView, "utf8"), {
|
||||
title: "Home",
|
||||
siteTitle: "Lumi Bot",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
botAvatar: null,
|
||||
navSections: [],
|
||||
user: { username: "Admin" },
|
||||
userAvatar: null,
|
||||
userInitial: "A",
|
||||
platformLogins: [],
|
||||
flash: null,
|
||||
softError: null,
|
||||
homepageLinks: [{
|
||||
label: "Twitch",
|
||||
description: "Watch live",
|
||||
url: "https://example.com",
|
||||
fallback_icon: "T"
|
||||
}],
|
||||
homepageHero: {
|
||||
type: "static_image",
|
||||
title: "Featured",
|
||||
description: "Featured content",
|
||||
image_url: "https://example.com/hero.png",
|
||||
source_url: "https://example.com"
|
||||
}
|
||||
}, { filename: homeView });
|
||||
assert(homeRendered.includes("homepage-link-button"));
|
||||
assert(homeRendered.includes("homepage-dynamic-hero"));
|
||||
|
||||
const loginView = path.join(root, "src", "web", "views", "localhost-login.ejs");
|
||||
const loginRendered = ejs.render(fs.readFileSync(loginView, "utf8"), {
|
||||
title: "Localhost Login",
|
||||
|
||||
63
src/services/web-events.js
Normal file
63
src/services/web-events.js
Normal file
@ -0,0 +1,63 @@
|
||||
const clients = new Map();
|
||||
|
||||
function subscribe(req, res) {
|
||||
const id = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
||||
const user = req.session?.user || null;
|
||||
const client = { id, res, user, connectedAt: Date.now() };
|
||||
clients.set(id, client);
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
});
|
||||
send(client, "server:status", {
|
||||
status: "connected",
|
||||
message: "Lumi event stream connected.",
|
||||
connected_at: client.connectedAt
|
||||
});
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
send(client, "server:status", { status: "heartbeat", at: Date.now() });
|
||||
}, 25000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(keepAlive);
|
||||
clients.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
function publish(event, payload = {}, options = {}) {
|
||||
let delivered = 0;
|
||||
for (const client of clients.values()) {
|
||||
if (!canReceive(client.user, options)) continue;
|
||||
send(client, event, payload);
|
||||
delivered += 1;
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
function send(client, event, payload) {
|
||||
try {
|
||||
client.res.write(`event: ${event}\n`);
|
||||
client.res.write(`data: ${JSON.stringify({ ...payload, event, at: Date.now() })}\n\n`);
|
||||
} catch {
|
||||
clients.delete(client.id);
|
||||
}
|
||||
}
|
||||
|
||||
function canReceive(user, options = {}) {
|
||||
const role = options.role || "public";
|
||||
if (role === "public") return true;
|
||||
if (!user) return false;
|
||||
if (role === "user") return true;
|
||||
if (role === "mod") return Boolean(user.isMod || user.isAdmin);
|
||||
if (role === "admin") return Boolean(user.isAdmin);
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publishWebEvent: publish,
|
||||
subscribeWebEvents: subscribe
|
||||
};
|
||||
@ -489,8 +489,9 @@
|
||||
const response = await fetch(healthEndpoint, { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
if (connectionLost) {
|
||||
window.location.reload();
|
||||
return;
|
||||
window.LumiInteractions?.showEventNotice?.({
|
||||
message: "Connection restored. Refresh manually if you need newer page data."
|
||||
}, "info");
|
||||
}
|
||||
connectionLost = false;
|
||||
} else {
|
||||
|
||||
@ -90,6 +90,65 @@ pre {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.homepage-dynamic-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(18rem, 1.1fr);
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.homepage-hero-media {
|
||||
width: 100%;
|
||||
min-height: 18rem;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.homepage-link-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.homepage-link-button {
|
||||
flex: 1 1 14rem;
|
||||
min-height: var(--lumi-control-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
color: var(--lumi-text);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.homepage-link-button:hover {
|
||||
border-color: color-mix(in srgb, var(--lumi-primary) 38%, var(--lumi-border));
|
||||
background: var(--lumi-surface-raised);
|
||||
}
|
||||
|
||||
.homepage-link-button img,
|
||||
.homepage-link-button > span:first-child {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.homepage-link-button small {
|
||||
display: block;
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.card,
|
||||
.panel,
|
||||
.lumi-panel {
|
||||
@ -425,6 +484,126 @@ input[type="color"] {
|
||||
background: color-mix(in srgb, var(--lumi-info) 9%, var(--lumi-surface));
|
||||
}
|
||||
|
||||
.is-unsaved {
|
||||
border-color: var(--lumi-color-unsaved-border) !important;
|
||||
background: var(--lumi-color-unsaved-bg) !important;
|
||||
color: var(--lumi-color-unsaved-text);
|
||||
box-shadow: 0 0 0 2px var(--lumi-color-unsaved-ring);
|
||||
}
|
||||
|
||||
.lumi-savebar {
|
||||
position: fixed;
|
||||
top: var(--lumi-space-3);
|
||||
left: 50%;
|
||||
z-index: 80;
|
||||
width: min(42rem, calc(100vw - 2rem));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-savebar-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-savebar-bg);
|
||||
box-shadow: var(--lumi-savebar-shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
transform: translate(-50%, 0);
|
||||
transition: opacity var(--lumi-transition), transform var(--lumi-transition);
|
||||
}
|
||||
|
||||
.lumi-savebar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lumi-savebar.is-hidden-by-scroll {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -110%);
|
||||
}
|
||||
|
||||
.lumi-savebar.has-error {
|
||||
border-color: var(--lumi-danger);
|
||||
}
|
||||
|
||||
.lumi-event-notices {
|
||||
position: fixed;
|
||||
right: var(--lumi-space-4);
|
||||
bottom: var(--lumi-space-4);
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.lumi-event-notice,
|
||||
.lumi-refresh-prompt {
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
}
|
||||
|
||||
.lumi-event-notice.warning {
|
||||
border-color: color-mix(in srgb, var(--lumi-warning) 50%, var(--lumi-border));
|
||||
}
|
||||
|
||||
.lumi-event-notice.danger {
|
||||
border-color: color-mix(in srgb, var(--lumi-danger) 55%, var(--lumi-border));
|
||||
}
|
||||
|
||||
.lumi-refresh-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.lumi-expandable-settings {
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.lumi-expandable-settings + .lumi-expandable-settings {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.lumi-expandable-settings summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--lumi-space-3);
|
||||
align-items: center;
|
||||
padding: var(--lumi-space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lumi-expandable-settings[open] summary {
|
||||
border-bottom: 1px solid var(--lumi-border);
|
||||
}
|
||||
|
||||
.lumi-expandable-body {
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.lumi-preview-line {
|
||||
color: var(--lumi-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content.is-soft-loading {
|
||||
opacity: 0.55;
|
||||
transition: opacity 90ms ease;
|
||||
}
|
||||
|
||||
.content.is-soft-loaded {
|
||||
animation: lumi-soft-in 140ms ease;
|
||||
}
|
||||
|
||||
.list li {
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
@ -550,6 +729,17 @@ details > summary {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lumi-soft-in {
|
||||
from {
|
||||
opacity: 0.45;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
h1 {
|
||||
font-size: calc(clamp(1.45rem, 8vw, 2rem) * var(--lumi-heading-scale));
|
||||
@ -586,6 +776,14 @@ details > summary {
|
||||
top: -5rem;
|
||||
}
|
||||
|
||||
.homepage-dynamic-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.homepage-hero-media {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button.button,
|
||||
input[type="submit"].button,
|
||||
@ -602,6 +800,17 @@ details > summary {
|
||||
padding: 0.55rem 0.8rem;
|
||||
}
|
||||
|
||||
.lumi-savebar,
|
||||
.lumi-refresh-prompt {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lumi-event-notices {
|
||||
right: var(--lumi-space-3);
|
||||
bottom: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.table-tools,
|
||||
.table-controls,
|
||||
.log-controls {
|
||||
|
||||
299
src/web/public/lumi-interactions.js
Normal file
299
src/web/public/lumi-interactions.js
Normal file
@ -0,0 +1,299 @@
|
||||
(() => {
|
||||
const initializedForms = new WeakSet();
|
||||
const initializedExpandables = new WeakSet();
|
||||
const REFRESH_COOLDOWN_MS = 3000;
|
||||
let saveBar = null;
|
||||
let stream = null;
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
const init = (root = document) => {
|
||||
initSettingsDirty(root);
|
||||
initExpandables(root);
|
||||
initSoftNavigation(root);
|
||||
};
|
||||
|
||||
const ensureSaveBar = () => {
|
||||
if (saveBar) return saveBar;
|
||||
saveBar = document.createElement("div");
|
||||
saveBar.className = "lumi-savebar";
|
||||
saveBar.hidden = true;
|
||||
saveBar.innerHTML = `
|
||||
<div>
|
||||
<strong data-savebar-count>Unsaved changes</strong>
|
||||
<span class="hint" data-savebar-status>Review and save changed settings on this page.</span>
|
||||
</div>
|
||||
<button type="button" class="button" data-savebar-submit>Save changes</button>
|
||||
`;
|
||||
document.body.append(saveBar);
|
||||
saveBar.querySelector("[data-savebar-submit]").addEventListener("click", saveDirtyForms);
|
||||
window.addEventListener("scroll", updateSaveBarScroll, { passive: true });
|
||||
return saveBar;
|
||||
};
|
||||
|
||||
const formFields = (form) => Array.from(form.elements).filter((field) =>
|
||||
field.name && !field.disabled && !["submit", "button", "reset", "file"].includes(field.type)
|
||||
);
|
||||
|
||||
const fieldValue = (field) => {
|
||||
if (field.type === "checkbox") return field.checked ? "on" : "";
|
||||
if (field.type === "radio") return field.checked ? field.value : "";
|
||||
return field.value;
|
||||
};
|
||||
|
||||
const snapshotForm = (form) => {
|
||||
const snapshot = new Map();
|
||||
for (const field of formFields(form)) {
|
||||
if (field.type === "radio") {
|
||||
if (!snapshot.has(field.name)) snapshot.set(field.name, "");
|
||||
if (field.checked) snapshot.set(field.name, field.value);
|
||||
} else {
|
||||
snapshot.set(field.name, fieldValue(field));
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const isFieldDirty = (field, snapshot) => {
|
||||
const original = snapshot.get(field.name) || "";
|
||||
if (field.type === "radio") {
|
||||
return field.checked && field.value !== original;
|
||||
}
|
||||
return fieldValue(field) !== original;
|
||||
};
|
||||
|
||||
const updateDirtyState = () => {
|
||||
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form]"));
|
||||
let dirtyCount = 0;
|
||||
for (const form of forms) {
|
||||
const snapshot = form._lumiSnapshot || snapshotForm(form);
|
||||
let formDirty = false;
|
||||
for (const field of formFields(form)) {
|
||||
const dirty = isFieldDirty(field, snapshot);
|
||||
const container = field.closest(".field, .theme-color-control, .theme-range-control, .theme-select-control, fieldset");
|
||||
container?.classList.toggle("is-unsaved", dirty);
|
||||
formDirty = formDirty || dirty;
|
||||
if (dirty) dirtyCount += 1;
|
||||
}
|
||||
form.classList.toggle("has-unsaved-settings", formDirty);
|
||||
}
|
||||
const bar = ensureSaveBar();
|
||||
bar.hidden = dirtyCount === 0;
|
||||
bar.classList.toggle("is-visible", dirtyCount > 0);
|
||||
bar.querySelector("[data-savebar-count]").textContent =
|
||||
dirtyCount === 1 ? "1 unsaved setting" : `${dirtyCount} unsaved settings`;
|
||||
if (dirtyCount === 0) bar.querySelector("[data-savebar-status]").textContent = "Saved.";
|
||||
updateSaveBarScroll();
|
||||
};
|
||||
|
||||
function initSettingsDirty(root) {
|
||||
root.querySelectorAll?.("form[data-lumi-settings-form]").forEach((form) => {
|
||||
if (initializedForms.has(form)) return;
|
||||
initializedForms.add(form);
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
form.addEventListener("input", updateDirtyState);
|
||||
form.addEventListener("change", updateDirtyState);
|
||||
form.addEventListener("submit", () => {
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
window.setTimeout(updateDirtyState, 0);
|
||||
});
|
||||
});
|
||||
if (document.querySelector("form[data-lumi-settings-form]")) {
|
||||
ensureSaveBar();
|
||||
updateDirtyState();
|
||||
window.addEventListener("beforeunload", warnDirtyNavigation);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDirtyForms() {
|
||||
const bar = ensureSaveBar();
|
||||
const button = bar.querySelector("[data-savebar-submit]");
|
||||
const status = bar.querySelector("[data-savebar-status]");
|
||||
const forms = Array.from(document.querySelectorAll("form[data-lumi-settings-form].has-unsaved-settings"));
|
||||
if (!forms.length) return;
|
||||
button.disabled = true;
|
||||
status.textContent = "Saving...";
|
||||
try {
|
||||
for (const form of forms) {
|
||||
const response = await fetch(form.action || window.location.href, {
|
||||
method: form.method || "POST",
|
||||
body: new FormData(form),
|
||||
headers: { Accept: "text/html,application/json" },
|
||||
redirect: "follow"
|
||||
});
|
||||
if (!response.ok) throw new Error(`Save failed for ${form.action || "settings form"}.`);
|
||||
form._lumiSnapshot = snapshotForm(form);
|
||||
}
|
||||
status.textContent = "Saved.";
|
||||
updateDirtyState();
|
||||
} catch (error) {
|
||||
status.textContent = error.message || "Save failed.";
|
||||
bar.classList.add("has-error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
window.setTimeout(() => bar.classList.remove("has-error"), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
function warnDirtyNavigation(event) {
|
||||
if (!document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
|
||||
function updateSaveBarScroll() {
|
||||
if (!saveBar || saveBar.hidden) return;
|
||||
const canScroll = document.documentElement.scrollHeight > window.innerHeight + 12;
|
||||
const next = window.scrollY;
|
||||
saveBar.classList.toggle("is-hidden-by-scroll", canScroll && next > lastScrollY + 4);
|
||||
lastScrollY = next;
|
||||
}
|
||||
|
||||
function initExpandables(root) {
|
||||
root.querySelectorAll?.("[data-lumi-expandable-settings]").forEach((item) => {
|
||||
if (initializedExpandables.has(item)) return;
|
||||
initializedExpandables.add(item);
|
||||
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
|
||||
item.addEventListener("input", () => {
|
||||
item.querySelectorAll("[data-placeholder-preview]").forEach(updatePlaceholderPreview);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updatePlaceholderPreview(target) {
|
||||
const source = target.closest("[data-lumi-expandable-settings]")?.querySelector(target.dataset.placeholderPreview);
|
||||
const text = source?.value || target.dataset.fallback || "";
|
||||
const replacements = {
|
||||
gifter_username: "SomeUser123",
|
||||
item_name: "Cool Item",
|
||||
creator_username: "CreatorName",
|
||||
amount_display: "$12.34",
|
||||
username: "SomeUser123",
|
||||
platform: "Twitch"
|
||||
};
|
||||
target.textContent = text.replace(/\{([^{}]+)\}/g, (full, key) => replacements[key] || full);
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!window.EventSource || stream) return;
|
||||
stream = new EventSource("/api/events");
|
||||
stream.addEventListener("server:warning", (event) => showEventNotice(readEvent(event), "warning"));
|
||||
stream.addEventListener("server:status", (event) => {
|
||||
const data = readEvent(event);
|
||||
if (data.status === "connected") document.body.dataset.eventStream = "connected";
|
||||
});
|
||||
stream.addEventListener("ai:model_status", (event) => showEventNotice(readEvent(event), "danger"));
|
||||
stream.addEventListener("data:new_available", (event) => showRefreshPrompt(readEvent(event)));
|
||||
stream.onerror = () => {
|
||||
document.body.dataset.eventStream = "disconnected";
|
||||
};
|
||||
}
|
||||
|
||||
function readEvent(event) {
|
||||
try { return JSON.parse(event.data || "{}"); } catch { return {}; }
|
||||
}
|
||||
|
||||
function noticeRoot() {
|
||||
let root = document.querySelector("[data-lumi-event-notices]");
|
||||
if (!root) {
|
||||
root = document.createElement("div");
|
||||
root.className = "lumi-event-notices";
|
||||
root.dataset.lumiEventNotices = "";
|
||||
document.body.append(root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function showEventNotice(data, tone = "info") {
|
||||
const item = document.createElement("div");
|
||||
item.className = `lumi-event-notice ${tone}`;
|
||||
item.setAttribute("role", tone === "danger" ? "alert" : "status");
|
||||
item.textContent = data.message || data.status || "Lumi status changed.";
|
||||
noticeRoot().append(item);
|
||||
window.setTimeout(() => item.remove(), 9000);
|
||||
}
|
||||
|
||||
function showRefreshPrompt(data) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "lumi-refresh-prompt";
|
||||
item.setAttribute("role", "status");
|
||||
const label = document.createElement("span");
|
||||
label.textContent = data.message || "New data is available.";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button subtle";
|
||||
button.textContent = "Refresh";
|
||||
button.addEventListener("click", () => {
|
||||
button.disabled = true;
|
||||
window.setTimeout(() => { button.disabled = false; }, REFRESH_COOLDOWN_MS);
|
||||
if (data.url) window.location.assign(data.url);
|
||||
else window.location.reload();
|
||||
});
|
||||
item.append(label, button);
|
||||
noticeRoot().append(item);
|
||||
}
|
||||
|
||||
function initSoftNavigation(root) {
|
||||
root.querySelectorAll?.("a[href]").forEach((link) => {
|
||||
if (link.dataset.softNavBound || link.target || link.hasAttribute("download")) return;
|
||||
const url = new URL(link.href, window.location.href);
|
||||
if (url.origin !== window.location.origin || url.pathname.startsWith("/auth/")) return;
|
||||
link.dataset.softNavBound = "true";
|
||||
link.addEventListener("click", (event) => {
|
||||
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const main = document.querySelector("main.content");
|
||||
if (!main || document.querySelector("form[data-lumi-settings-form].has-unsaved-settings")) return;
|
||||
event.preventDefault();
|
||||
softNavigate(url.href);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function softNavigate(url, push = true) {
|
||||
const main = document.querySelector("main.content");
|
||||
if (!main) return window.location.assign(url);
|
||||
main.classList.add("is-soft-loading");
|
||||
try {
|
||||
const response = await fetch(url, { headers: { "X-Lumi-Soft-Navigation": "1" } });
|
||||
if (!response.ok) throw new Error("Navigation failed.");
|
||||
const html = await response.text();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const nextMain = doc.querySelector("main.content");
|
||||
if (!nextMain) throw new Error("Navigation target did not contain page content.");
|
||||
document.title = doc.title || document.title;
|
||||
main.replaceChildren(...Array.from(nextMain.childNodes));
|
||||
main.classList.remove("is-soft-loading");
|
||||
main.classList.add("is-soft-loaded");
|
||||
window.setTimeout(() => main.classList.remove("is-soft-loaded"), 180);
|
||||
updateActiveNavigation(new URL(url, window.location.href).pathname);
|
||||
if (push) history.pushState({}, "", url);
|
||||
init(main);
|
||||
executePageScripts(main);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
} catch {
|
||||
window.location.assign(url);
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveNavigation(pathname) {
|
||||
document.querySelectorAll(".nav-link").forEach((link) => {
|
||||
const url = new URL(link.href, window.location.href);
|
||||
link.classList.toggle("active", url.pathname === pathname || (url.pathname !== "/" && pathname.startsWith(`${url.pathname}/`)));
|
||||
});
|
||||
}
|
||||
|
||||
function executePageScripts(root) {
|
||||
root.querySelectorAll("script").forEach((script) => {
|
||||
const next = document.createElement("script");
|
||||
for (const attr of script.attributes) next.setAttribute(attr.name, attr.value);
|
||||
next.textContent = script.textContent;
|
||||
script.replaceWith(next);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", () => softNavigate(window.location.href, false));
|
||||
window.LumiInteractions = { init, connectEvents, showEventNotice, showRefreshPrompt };
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init(document);
|
||||
connectEvents();
|
||||
});
|
||||
})();
|
||||
@ -28,6 +28,13 @@
|
||||
--lumi-button-text: #ffffff;
|
||||
--lumi-button-hover: color-mix(in srgb, var(--lumi-button-bg) 86%, black);
|
||||
--lumi-focus: color-mix(in srgb, var(--lumi-primary) 72%, white);
|
||||
--lumi-color-unsaved-bg: color-mix(in srgb, var(--lumi-warning) 13%, var(--lumi-surface));
|
||||
--lumi-color-unsaved-border: color-mix(in srgb, var(--lumi-warning) 45%, var(--lumi-border));
|
||||
--lumi-color-unsaved-text: var(--lumi-text);
|
||||
--lumi-color-unsaved-ring: color-mix(in srgb, var(--lumi-warning) 26%, transparent);
|
||||
--lumi-savebar-bg: color-mix(in srgb, var(--lumi-surface) 94%, transparent);
|
||||
--lumi-savebar-border: var(--lumi-color-unsaved-border);
|
||||
--lumi-savebar-shadow: var(--lumi-shadow-md);
|
||||
|
||||
--lumi-space-scale: 1;
|
||||
--lumi-space-1: calc(0.25rem * var(--lumi-space-scale));
|
||||
|
||||
@ -93,6 +93,10 @@ const {
|
||||
consumeConfirmation,
|
||||
normalizeAction
|
||||
} = require("../services/destructive-confirm");
|
||||
const {
|
||||
publishWebEvent,
|
||||
subscribeWebEvents
|
||||
} = require("../services/web-events");
|
||||
|
||||
function ensureSessionSecret() {
|
||||
let secret = getSetting("session_secret");
|
||||
@ -1514,6 +1518,113 @@ function getThemeSettings() {
|
||||
return getActiveTheme();
|
||||
}
|
||||
|
||||
function parseJsonSetting(key, fallback) {
|
||||
const value = getSetting(key, fallback);
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function safeExternalUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ""));
|
||||
return ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function permissionAllows(user, permission = "public") {
|
||||
const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public";
|
||||
return role === "public" ? true : hasAccess(user, role);
|
||||
}
|
||||
|
||||
function fallbackIconForUrl(url) {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^www\./, "");
|
||||
return host.slice(0, 1).toUpperCase();
|
||||
} catch {
|
||||
return "↗";
|
||||
}
|
||||
}
|
||||
|
||||
function homepageLinksForUser(user) {
|
||||
return parseJsonSetting("homepage_link_buttons", [])
|
||||
.filter((item) => item && item.enabled !== false)
|
||||
.filter((item) => permissionAllows(user, item.permission))
|
||||
.map((item, index) => {
|
||||
const url = safeExternalUrl(item.url);
|
||||
if (!url) return null;
|
||||
return {
|
||||
id: String(item.id || `link-${index}`),
|
||||
label: String(item.label || item.description || "External link").slice(0, 80),
|
||||
description: String(item.description || item.label || "Open link").slice(0, 160),
|
||||
url,
|
||||
icon_url: safeExternalUrl(item.icon_url || item.fetched_favicon_url),
|
||||
fallback_icon: fallbackIconForUrl(url),
|
||||
permission: item.permission || "public",
|
||||
sort_order: Number(item.sort_order) || index
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}
|
||||
|
||||
function homepageHeroForUser(user) {
|
||||
const entries = parseJsonSetting("homepage_hero_entries", [])
|
||||
.filter((item) => item && item.enabled !== false)
|
||||
.filter((item) => permissionAllows(user, item.permission))
|
||||
.sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0));
|
||||
for (const item of entries) {
|
||||
const hero = normalizeHomepageHero(item);
|
||||
if (hero?.available) return hero;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHomepageHero(item) {
|
||||
const type = String(item.type || "none");
|
||||
if (type === "none") {
|
||||
return item.fallback_behavior === "message"
|
||||
? { type, available: true, title: item.title || "No featured content", description: item.description || "" }
|
||||
: null;
|
||||
}
|
||||
const sourceUrl = safeExternalUrl(item.source_url);
|
||||
const embedUrl = safeExternalUrl(item.embed_url);
|
||||
const imageUrl = safeExternalUrl(item.image_url);
|
||||
const title = String(item.title || "Featured content").slice(0, 120);
|
||||
const description = String(item.description || "").slice(0, 500);
|
||||
if (type === "static_image" && imageUrl) return { type, available: true, title, description, image_url: imageUrl, source_url: sourceUrl };
|
||||
if (type === "custom_link" && sourceUrl) return { type, available: true, title, description, source_url: sourceUrl };
|
||||
if (type === "custom_embed" && embedUrl) return { type, available: true, title, description, embed_url: embedUrl };
|
||||
if (type === "youtube_video") {
|
||||
const videoId = item.video_id || youtubeVideoId(sourceUrl);
|
||||
if (videoId) return { type, available: true, title, description, embed_url: `https://www.youtube-nocookie.com/embed/${videoId}`, source_url: sourceUrl };
|
||||
}
|
||||
if (["youtube_channel", "twitch_stream", "discord_server_overview"].includes(type) && (embedUrl || sourceUrl)) {
|
||||
if (type === "twitch_stream" && item.availability_mode === "live_only" && item.live_now !== true) return null;
|
||||
return { type, available: true, title, description, embed_url: embedUrl, source_url: sourceUrl };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function youtubeVideoId(value) {
|
||||
try {
|
||||
const url = new URL(value || "");
|
||||
if (url.hostname.includes("youtu.be")) return url.pathname.slice(1);
|
||||
return url.searchParams.get("v") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getDiscordSettings() {
|
||||
return {
|
||||
discord_client_id: getSetting("discord_client_id", ""),
|
||||
@ -1942,10 +2053,12 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
assistantPanels.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
emitEvent: publishWebEvent
|
||||
};
|
||||
|
||||
app.use(requireConfigured);
|
||||
app.get("/api/events", requireAuth, subscribeWebEvents);
|
||||
app.post("/api/destructive-confirmations", requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(issueConfirmation(req, req.body.action));
|
||||
@ -2072,7 +2185,9 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.render("home", {
|
||||
title: "Home"
|
||||
title: "Home",
|
||||
homepageLinks: homepageLinksForUser(req.session.user),
|
||||
homepageHero: homepageHeroForUser(req.session.user)
|
||||
});
|
||||
});
|
||||
|
||||
@ -3601,6 +3716,22 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
setSetting("localhost_login_password", localhostPassword);
|
||||
}
|
||||
}
|
||||
for (const field of ["homepage_link_buttons", "homepage_hero_entries"]) {
|
||||
if (req.body[field] === undefined) continue;
|
||||
const raw = String(req.body[field] || "").trim();
|
||||
if (!raw) {
|
||||
setSetting(field, []);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) throw new Error("Expected an array.");
|
||||
setSetting(field, parsed);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", `${field.replaceAll("_", " ")} JSON is invalid: ${error.message}`);
|
||||
return res.redirect("/admin/settings");
|
||||
}
|
||||
}
|
||||
const platformStatus = getPlatformStatus();
|
||||
const nextPlatformValues = new Map();
|
||||
for (const platform of platformStatus) {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
pageTitle: "Settings",
|
||||
description: "Manage core behavior, updates, and platform integrations."
|
||||
}) %>
|
||||
<form method="post" action="/admin/settings" class="form-grid">
|
||||
<form method="post" action="/admin/settings" class="form-grid" data-lumi-settings-form>
|
||||
<div class="field">
|
||||
<label>Site title</label>
|
||||
<input name="site_title" value="<%= settings.site_title || '' %>" />
|
||||
@ -113,6 +113,21 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="field full">
|
||||
<h2>Homepage content</h2>
|
||||
<p class="hint">Configure public homepage link buttons and the priority-based dynamic hero. Use JSON arrays; invalid JSON is rejected without saving.</p>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Homepage link buttons JSON</label>
|
||||
<textarea name="homepage_link_buttons" rows="8" spellcheck="false"><%= JSON.stringify(settings.homepage_link_buttons || [], null, 2) %></textarea>
|
||||
<p class="hint">Fields: enabled, label, description, url, icon_url, permission public/user/mod/admin, sort_order.</p>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Homepage hero entries JSON</label>
|
||||
<textarea name="homepage_hero_entries" rows="10" spellcheck="false"><%= JSON.stringify(settings.homepage_hero_entries || [], null, 2) %></textarea>
|
||||
<p class="hint">Fields: enabled, type, title, description, priority, permission, source_url, image_url, embed_url, video_id, availability_mode, autoplay_mode, duration_seconds.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@ -7,6 +7,35 @@
|
||||
<a class="button subtle" href="/leaderboards">Leaderboards</a>
|
||||
</div>
|
||||
</section>
|
||||
<% if (homepageHero) { %>
|
||||
<section class="homepage-dynamic-hero card" aria-label="Featured community content">
|
||||
<div>
|
||||
<span class="eyebrow"><%= homepageHero.type.replaceAll("_", " ") %></span>
|
||||
<h2><%= homepageHero.title %></h2>
|
||||
<% if (homepageHero.description) { %><p><%= homepageHero.description %></p><% } %>
|
||||
<% if (homepageHero.source_url) { %><a class="button subtle" href="<%= homepageHero.source_url %>" target="_blank" rel="noopener noreferrer">Open featured content</a><% } %>
|
||||
</div>
|
||||
<% if (homepageHero.image_url) { %>
|
||||
<img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" />
|
||||
<% } else if (homepageHero.embed_url) { %>
|
||||
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
<% if ((homepageLinks || []).length) { %>
|
||||
<section class="homepage-link-strip" aria-label="Community links">
|
||||
<% homepageLinks.forEach((link) => { %>
|
||||
<a class="homepage-link-button" href="<%= link.url %>" target="_blank" rel="noopener noreferrer">
|
||||
<% if (link.icon_url) { %>
|
||||
<img src="<%= link.icon_url %>" alt="" loading="lazy" />
|
||||
<% } else { %>
|
||||
<span aria-hidden="true"><%= link.fallback_icon %></span>
|
||||
<% } %>
|
||||
<span><strong><%= link.label %></strong><small><%= link.description %></small></span>
|
||||
</a>
|
||||
<% }) %>
|
||||
</section>
|
||||
<% } %>
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<h2>Bot control</h2>
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
</div>
|
||||
<script src="/assistant-panels.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/lumi-interactions.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/app.js?v=<%= assetVersion %>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user