merge codex update system work

This commit is contained in:
Franz Rolfsvaag 2026-06-16 10:07:11 +02:00
commit 32443335c3
64 changed files with 7756 additions and 674 deletions

3
.gitignore vendored
View File

@ -16,3 +16,6 @@ npm-debug.log
security-audit-*.json
security-audit-*.md
taskfile.txt
codex-guidelines
Twitch.png
twitch-credentials-lumi.png

View File

@ -38,6 +38,19 @@ You can set these in `.env` or change role IDs in **Admin → Settings**.
Use **Admin → Plugins** to install, enable, update, or uninstall plugins.
You can also create a local plugin from the WebUI.
## Updates and recovery
Use **Admin → Updates** for version-aware core and plugin updates. Lumi reads
repo metadata from `main` by default, can explicitly target the newest
`experimental-*` branch, creates snapshots before updates, blocks unsafe major
jumps without compatibility bridge metadata, and keeps advanced ZIP updates
hidden behind manual reveal controls.
Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`,
`node run.js --safe-mode`, or `data/recovery/safe-mode.flag`. See
[`docs/updates.md`](docs/updates.md) and
[`docs/recovery-mode.md`](docs/recovery-mode.md).
## Twitch bot
Configure Twitch chat settings in **Admin → Settings**:
@ -53,7 +66,14 @@ Users have an internal UUID and username. Link Twitch accounts in **Profile** an
## Theming
Use **Admin → Theming** to adjust light and dark mode colors. The UI uses your OS theme preference.
Use **Admin → Theming** to select one of six read-only Lumi themes. Duplicate a
built-in or custom theme to edit colors, surfaces, controls, status colors,
focus states, radius, shadows, and spacing with a live light/dark preview.
Custom themes can be applied, renamed, duplicated, and deleted. Invalid or
incomplete values fall back safely to the selected built-in base theme.
Developer and modding conventions are documented in
[`docs/lumi-ui.md`](docs/lumi-ui.md).
## Notes

View File

@ -1,178 +0,0 @@
Project: Lumi Bot (Discord + Twitch + YouTube) — WebUI-first management
Purpose of this file
- Single source of truth for crossconversation context, conventions, and packaging.
- Update when project behavior changes (routes, APIs, packaging, DB schema).
- Refer back here before making changes.
Repository layout
- Core:
- src/main.js (entry)
- src/web/server.js (WebUI + routes + wizards)
- src/services (auth, platforms, users, plugins, update-manager, etc.)
- src/web/views (EJS pages, partials/layout)
- src/web/public (styles.css, app.js)
- Plugins: plugins/<plugin-id>/ (plugin.json + index.js + optional views/)
- Data: data/app.db (SQLite), snapshots, uploads (should be excluded from updates)
- Updates output: updates/ (all update zips go here)
Platform integration (current)
- Discord, Twitch, YouTube supported; modular via src/services/platforms.js
- Wizards in /setup/*:
- /setup/discord, /setup/twitch, /setup/youtube (cancelable)
- OAuth routes in /auth/*:
- /auth/discord, /auth/twitch, /auth/youtube
- Role mapping:
- Discord roles from settings discord_admin_role_id / discord_mod_role_id (supports comma-separated)
- Twitch: broadcaster/admin/mod via tags/badges
- YouTube: chat owner/moderator flags
WebUI key routes (core)
- / (home)
- /commands, /leaderboards, /stats, /profile
- /moderator (Mods List)
- /admin
- /admin/settings
- /admin/navigation
- /admin/theming
- /admin/privileges
- /admin/logs
- /admin/updates
- /admin/commands
- /admin/pages
- /admin/users
- /admin/plugins
WebUI profile hook (core)
- web.addProfileSection({ id, label, view?, content?, role?, order?, locals? })
- view: EJS include path; content: raw HTML string
- role defaults to "public" if omitted
- In profile template, sections render under "Personalized"
- Profile view receives: user, profile, accounts + section.locals
Update system (core)
- applyBotUpdate(zipPath, { mode: "full"|"patch" })
- Full update requires: package.json, safe-mode.js, src/main.js, src/web/server.js
- Patch mode accepts any files and overlays them (no deletes)
- applyPluginUpdate(zipPath) expects plugin.json in root + entry file
- Snapshots: data/snapshots (keeps last 20 successful)
- Safe Mode: safe-mode.js supports rollback
Update packaging standards
- Always place update zips in updates/
- Core full update (default):
- Zip from repo root EXCLUDING: .git, node_modules, data, plugins, updates
- Filename: updates/lumi-update-<topic>.zip
- Core patch update:
- Zip only changed files/folders
- Use Patch Mode in UI
- Filename: updates/lumi-update-<topic>-patch.zip (or similar)
- Plugin update:
- Zip contents of plugins/<plugin-id>/ (root = plugin folder)
- Filename: updates/lumi-plugin-<plugin-id>-vX.Y.Z.zip
- Preferred zip tool on Windows:
- tar -a -c -f <zip> -C <folder> .
Command framework (core)
- commandRouter.registerCommands(pluginId, [{ id, triggers, platforms, handler }])
- Platforms enumerated by services/platforms.js
- Plugins can expose cmds.json for admin command list ingestion
- Core dynamic command: !top <category> (categories pulled from leaderboards/providers via src/services/top.js)
Database schema (core)
- data/app.db (SQLite)
- user_profiles table includes:
- internal_username (unique, NOCASE)
- username_updated_at (added for 90day cooldown)
- user_identities: provider/user mapping
- plugin_settings: plugin key/value store
- plugins: plugin registry
Profile username cooldown (core)
- User can update once every 90 days
- Stored in user_profiles.username_updated_at
- /profile/username checks cooldown serverside
- UI: modal with disabled button + hint when on cooldown
Sidebar UX standards (core)
- User chip is clickable to /profile
- Collapsed sidebar:
- Icons centered, tooltips on items/sections
- Subitem icons remain visible (default or admin-uploaded)
- Consistent padding/width
- Enable/disable inputs should use green/red switch toggles (not plain checkboxes)
- /admin/navigation uses drag-and-drop layout with an Advanced JSON editor
Plugins (important)
- Plugin system loads from plugins/ directory
- Each plugin:
- plugin.json with id, name, version, main (index.js)
- index.js exports { id, init(...) }
- web.mount("/plugins/<id>", router, navItem) to add nav entry
- Plugins should avoid core edits unless explicitly requested
- Plugins receive `webhooks` for raw-body inbound endpoint registration and
outbound webhook sending. See `docs/webhooks.md`.
- `web.addAssistantPanel({ id, view, stylesheet?, script?, role?, getAvailability?(user), version?, locals? })`
registers a dynamically mounted sidebar pill/global panel above the user footer.
`getAvailability` may be async and must return `{ available, status?, reason_code? }`.
The core polls `/api/assistant-panels`, renders HTML only for available panels, and
returns a cleanup function that plugins should call when unloading.
Current notable plugins
- echonomy-framework:
- Currency framework only (no items/betting)
- Banking UI: /profile/banking (plugin)
- Response templates with random/weighted replies
- Community funds (renamable)
- Commands under root (default "coins")
- Activity rewards (discord/twitch messages, discord voice) are queued per user per hour and flushed as one hourly transaction note "Activity Reward" with metadata breakdown
- echonomy-games:
- Uses echonomy-framework currency
- Hot Potato / Coinflip / Mystery Box
- Per-platform enable + configurable triggers/aliases
- Admin UI at /plugins/echonomy-games
- Stats stored in echonomy_game_stats (plays, coins won/lost, last played)
- moderation:
- Global moderation actions, notes, sanctions
- Ban/timeout UI at /plugins/moderation
- TOs & Bans view at /plugins/moderation/tos-bans
- Login gating shows moderation status screen
- Evidence uploads stored in data/moderation/evidence (download via /plugins/moderation/evidence/:id)
- quotes:
- Quote storage/search with WebUI at /plugins/quotes
- quotes table fields include quoter_user_id (internal user id) + game_name
- Stats provider adds total quotes, top quoters, and top quoted games
Database schema (core)
- mod_role_periods table tracks mod/admin time for Mods List
- auto-vc:
- Auto VC creation based on lobby channels
- Game name detection uses Discord presence PLAYING/STREAMING/COMPETING only
- expression-interaction:
- Action commands with stats tracking
Important settings keys (core)
- discord: discord_client_id, discord_client_secret, discord_bot_token, discord_guild_id, discord_admin_role_id, discord_mod_role_id, discord_redirect_uri
- twitch: twitch_client_id, twitch_client_secret, twitch_bot_username, twitch_bot_oauth, twitch_channels, twitch_redirect_uri
- youtube: youtube_client_id, youtube_client_secret, youtube_bot_refresh_token, youtube_bot_channel_id, youtube_redirect_uri
- site: site_title, bot_avatar_url, command_prefix
- nav: nav_item_icons (map of nav item id -> filename in data/nav-icons), nav_structure (custom sidebar layout)
Known file locations
- Layout partials: src/web/views/partials/layout-top.ejs, layout-bottom.ejs
- Global CSS: src/web/public/styles.css
- Global JS: src/web/public/app.js
- Asset versioning: res.locals.assetVersion (cache-bust for styles/app)
- Nav icons: src/web/public/icons/nav (defaults), data/nav-icons (admin uploads)
Packaging sanity checks (before shipping)
- Plugin zips contain plugin.json at root
- Core zip contains package.json and src/main.js
- No data/ or node_modules included
- Update zip placed in updates/
TODOs / Open questions
- Align plugin profile sections with new core hook (replace direct injection if used)
- Standardize plugin response templates UI to avoid duplication
- Consider centralized search API for profile user lookup
- Add docs for web.addProfileSection usage (when stable)

222
docs/lumi-ui.md Normal file
View File

@ -0,0 +1,222 @@
# Lumi UI and theme system
Lumi UI is the project-native design layer used by every page rendered through the
shared EJS layout. It keeps route behavior and page-specific JavaScript separate
from visual tokens and reusable components.
## Files
- `src/web/public/lumi-tokens.css`: semantic colors, spacing, radii, shadows,
typography, compatibility aliases, and reduced-motion behavior.
- `src/web/public/lumi-layout.css`: application shell, sidebar, content
containers, responsive grids, stacks, clusters, and mobile navigation.
- `src/web/public/lumi-components.css`: buttons, forms, cards, tables, lists,
badges, alerts, tabs, modals, empty/loading/error states, stateful action
buttons, and tooltips.
- `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.
- `src/web/views/partials/theme-vars.ejs`: safe active-theme variables for shell
and standalone pages.
- `src/services/themes.js`: built-in themes, custom theme CRUD, validation,
migration, fallback handling, and active-theme selection.
Use `lumi-stack`, `lumi-cluster`, `lumi-split`, `lumi-grid`, `page-header`,
`button-group`, `card`, `panel`, `table-wrap`, `empty-state`, `loading-state`,
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.
Buttons should use `partials/state-button.ejs` for submit, loading, success, or
error states. Single-state and multi-state buttons share the same Lumi button
tokens. Hidden states stay measurable with `data-state-hidden`, so the button
width is based on the widest state and state changes do not shift surrounding
layout. Use `.input-action-row` for desktop file/input + action pairs such as
ZIP updates and navigation icon uploads; the row stacks on mobile.
Destructive POST forms should provide context through `data-confirm-title`,
`data-confirm-text`, and `data-confirm-label`. Dynamic JavaScript-only
destructive actions can call `window.LumiConfirm.destructive({ title, text,
label })` to reuse the same modal. The helper keeps vague default confirmation
copy out of normal admin flows and returns focus after cancel/confirm.
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.
Sidebar navigation sections behave as an accordion. Opening one `.nav-section`
closes the other expanded sections while preserving the active page highlight
and `aria-expanded` state.
## Themes
Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High
Contrast, Midnight, and Soft Aurora. Admins select them from **Admin > Theming**.
Built-in themes cannot be renamed, edited, or deleted.
Open a theme's **More actions** section and duplicate it to create an editable
custom theme. Custom themes can be previewed in light and dark mode, saved,
applied globally, renamed, duplicated, or deleted. Deleting the active custom
theme falls back to Lumi Default.
The compact editor exposes common colors first. Advanced controls cover
background glows, raised surfaces, links, buttons, inputs, focus rings, radius,
shadow strength, and spacing scale. Typography controls use constrained font
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.
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_*`,
`theme_dark_*`, or `theme_role_*` settings are migrated once into a custom
**Migrated Theme** and selected automatically. The legacy `/admin/theming` POST
route remains supported and writes into an editable custom theme.
Run `npm run verify:webui` to compile every EJS view and exercise built-in theme
validation plus custom duplicate, apply, edit validation, typography validation,
stateful theme actions, localhost login rendering, rename, and delete.
## Localhost Login
Development builds opened from `localhost`, `127.0.0.1`, or `::1` show a
**Localhost Login** option. It defaults to username `admin` and password `admin`
unless those settings have already been changed. The option is not inserted into
the login list, cannot be used, and does not satisfy setup requirements for
non-localhost requests.
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, 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`; 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 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_mode`, `icon_url`, `fetched_favicon_url`,
`permission` (`public`, `user`, `mod`, `admin`), and `sort_order`. Entries can
be added, duplicated, moved up/down, removed with contextual confirmation,
enabled/disabled, and previewed in place. Links open in a new tab with
`rel="noopener noreferrer"` and are filtered server-side by permission.
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, mutually exclusive autoplay modes, duration
fields, fallback behavior, and a live card preview. The builder shows
video-only controls only for stream/video types and image/embed/source fields
only when they apply. 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.
## Updates And Local-Only Files
Admin update controls use the same progressive action patterns as other Lumi
actions. `/admin/updates` is split into Core and Plugins expandable sections
with collapsed summaries, warning/danger badges, safe target versions, changelog
ranges, migration notes, recovery state, snapshot/revert availability, and live
progress from `GET /admin/updates/events`.
Core update success shows a five-second in-page notice before refresh/restart.
Plugin update success logs progress for the affected plugin row without forcing
a whole-page refresh. ZIP update controls remain available but are hidden under
advanced/manual reveal sections because ZIPs may bypass repository metadata and
compatibility checks.
See [Update system](updates.md) and [Recovery mode](recovery-mode.md) for the
semver policy, compatibility bridge behavior, snapshots, revert limits, recovery
markers, and manual safe-mode triggers.
The repository ignores local-only coordination and credential artifacts such as
`codex-guidelines`, `Twitch.png`, and `twitch-credentials-lumi.png`. Plugin
runtime data stays excluded from source control; runtime folders are recreated
by the plugin data-directory initializer.
## Visual references
- [Home, desktop](screenshots/lumi-home-desktop.png)
- [Home, 360px mobile](screenshots/lumi-home-mobile.png)
- [Custom theme editor, desktop](screenshots/lumi-theme-editor-desktop.png)
- [Custom theme editor, 360px mobile](screenshots/lumi-theme-editor-mobile.png)
The broad sidebar and content structure remains in place. Theme controls moved
from one long raw color form into the Theme Studio library and grouped custom
editor; no navigation destination or non-theme control was relocated.

54
docs/recovery-mode.md Normal file
View File

@ -0,0 +1,54 @@
# Lumi Recovery Mode
Failed updates should leave an administrator with a recovery path. Lumi writes a
recovery marker before update files are applied and keeps snapshots available for
manual revert.
## Recovery Marker
The marker lives at `data/recovery/update-marker.json` and records:
- target kind and id
- from/to versions
- source branch
- update method
- rollback safety
- snapshot id when available
- status and timestamps
Statuses include `pending`, `applying`, `verifying`, `completed`, `failed`, and
`stale`. A completed marker is cleared after a successful normal startup. If
Lumi starts and finds an incomplete marker, it marks it stale so the admin UI and
safe-mode UI can show the last attempted update.
## Manual Safe Mode
Safe mode can be started with any of these triggers:
```bash
LUMI_SAFE_MODE=1 npm run run
node run.js --safe-mode
```
Creating `data/recovery/safe-mode.flag` also makes the wrapper start
`safe-mode.js` instead of the full bot. Safe mode loads only the minimum services
needed for recovery: config/database, auth/session, static recovery UI,
snapshots/revert, plugin disable, and restart controls. Optional plugins,
platform clients, AI runtime, scheduled jobs, and non-essential integrations are
not loaded.
## Admin Recovery UI
The normal **Admin > Updates** page shows a recovery banner when a marker is
present. The standalone safe-mode page shows the last attempted target, versions,
method, source branch, snapshot id, timestamp, and error.
Admins can:
- revert a safe snapshot,
- disable a problematic plugin,
- clear a stale marker after verifying startup,
- retry normal startup.
Rollback is never automatic. Major-version rollback remains blocked unless the
snapshot is explicitly marked rollback safe.

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

81
docs/updates.md Normal file
View File

@ -0,0 +1,81 @@
# Lumi Updates
Lumi updates are managed from **Admin > Updates**. The page separates Core and
Plugins into expandable sections and uses repository metadata to choose a safe
target before any files are changed.
## Version Policy
Core and plugin versions use `major.minor.patch`.
- Patch: fixes only.
- Minor: non-breaking features or design changes.
- Major: potentially incompatible changes.
The UI tracks three values separately: current version, latest available
version, and safe target version. Same-major updates can target the latest
version. Major crossings are blocked unless metadata exposes a compatibility
bridge, usually `X.0.0`, or another version marked with
`migration_kind: "compatibility_bridge"` and compatible with the current
install.
If the latest version is unsafe, the UI shows the safe target as the actionable
version and lists why the absolute latest is not selected.
Updates to or from an unversioned install or target remain available so an admin
can recover directly from the repository. They are treated as manual repo
updates: the action button stays enabled, but the UI shows warnings and the
confirmation modal states that version ordering, changelog range, and rollback
safety cannot be verified from metadata.
## Metadata
The updater looks for these files on the source branch:
- `update-manifest.json` or `lumi.manifest.json`
- `CHANGELOG.md` or `changelog.json`
- `plugins/<plugin_id>/plugin.json`
- `plugins/<plugin_id>/CHANGELOG.md` or `changelog.json`
Useful metadata fields include `version`, `channel`, `compatible_from`,
`migration_kind`, `migration_notes`, `rollback_safe`, `requirements`, `size`,
and changelog entries. Missing changelog data is shown as a warning.
Stable checks read `main` by default. The newest `experimental-*` branch is used
only when explicitly selected on the updates page.
## Snapshots And Revert
Before a core, plugin, repo, or ZIP update, Lumi creates a snapshot under
`data/snapshots/` and records metadata in the snapshot index:
- target kind/id
- from/to versions
- source branch
- update method
- rollback safety
- recovery marker id
- major-crossing flag
Manual revert is available for core and individual plugins. Revert actions are
limited to the previous-version snapshot for that target. Major-version rollback
is blocked unless the snapshot or manifest explicitly marks rollback as safe.
## ZIP Fallback
Core and plugin ZIP updates remain available, but they are hidden under
**Show advanced ZIP update options**. ZIP updates create snapshots and recovery
markers. They may bypass repo metadata and compatibility checks unless the ZIP
contains valid manifest data, so use them as a manual fallback.
## Progress Events
Admin update actions publish Server-Sent Events through
`GET /admin/updates/events`. Event names include `update:checking`,
`update:metadata`, `update:snapshot`, `update:recovery_marker`,
`update:download`, `update:apply`, `update:verify`, `update:complete`,
`update:failed`, and `update:revert`.
Core update success returns a five-second in-page notice before refresh/restart.
Plugin update success updates progress for the affected plugin action without a
whole-page refresh.

View File

@ -5,7 +5,8 @@
"type": "commonjs",
"scripts": {
"start": "node src/main.js",
"run": "node run.js"
"run": "node run.js",
"verify:webui": "node scripts/verify-webui.js"
},
"engines": {
"node": ">=18"

View File

@ -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}` : ""

View File

@ -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(["instruction_based", "strict_correction"]);
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
: "instruction_based",
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 || "instruction_based",
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,

View File

@ -1 +0,0 @@

View File

@ -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,18 @@ const storage = require("./backend/storage");
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
const PLUGIN_ID = "lumi_ai";
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: "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");
@ -155,6 +167,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 +187,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 +204,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 +312,24 @@ 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,
tokenPresets: TOKEN_PRESETS,
gateContextOptions: GATE_CONTEXT_OPTIONS,
runtimeTarget,
runtimeManifest,
runtimeStatus,
@ -338,9 +370,32 @@ 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 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,
@ -353,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),
@ -411,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),
@ -457,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,
@ -493,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",
@ -508,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,
@ -538,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);
}
});
@ -891,6 +964,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 +1222,7 @@ module.exports = {
config,
access,
feedbackTags: FEEDBACK_TAGS,
feedbackKinds: FEEDBACK_KINDS,
promotionTargets: PROMOTION_TARGETS,
reviews: feedbackStore.list({
page: req.query.review_page,

View File

@ -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); }

View File

@ -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 [
["instruction_based", "Instruction-based guidance"],
["strict_correction", "Strict correction"]
]) {
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()
})
});

View File

@ -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;
@ -162,6 +211,7 @@
workload.addEventListener("change", refreshCapacity);
model.addEventListener("change", refreshCapacity);
context.addEventListener("input", scheduleCapacity);
context.addEventListener("change", refreshCapacity);
refreshCapacity();
}
if (accessForm) {

View File

@ -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 || "instruction_based") ? "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>

View File

@ -1,5 +1,17 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" />
<%
const renderPresetOptions = (options, current) => {
const value = Number(current);
const hasValue = options.some((option) => option.value === value);
let html = "";
if (!hasValue && Number.isFinite(value)) {
html += `<option value="${value}" selected disabled>Unsupported current value (${value})</option>`;
}
html += options.map((option) => `<option value="${option.value}" ${option.value === value ? "selected" : ""}>${option.label}</option>`).join("");
return html;
};
%>
<section class="ai-titlebar">
<div>
@ -70,19 +82,39 @@
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/verify">
<button class="button subtle" type="submit">Verify</button>
</form>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>">
<button class="button subtle" type="submit">Redownload</button>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
<%- include("../../../src/web/views/partials/state-button", {
type: "submit",
classes: "subtle",
attrs: `data-ai-download-button data-download-id="model:${model.id}"`,
states: [
{ id: "idle", text: "Redownload" },
{ id: "loading", text: "Downloading", spinner: true },
{ id: "success", text: "Downloaded" },
{ id: "error", text: "Retry" }
]
}) %>
</form>
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-form="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?">
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model">
<input type="hidden" name="confirm" value="yes" />
<button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button>
</form>
<% } else { %>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>">
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
<% if (!model.compatible) { %>
<label title="Allow download despite detected capacity"><input type="checkbox" name="override_compatibility" /> Override</label>
<% } %>
<button class="button subtle" type="submit">Download</button>
<%- include("../../../src/web/views/partials/state-button", {
type: "submit",
classes: "subtle",
attrs: `data-ai-download-button data-download-id="model:${model.id}"`,
states: [
{ id: "idle", text: "Download" },
{ id: "loading", text: "Downloading", spinner: true },
{ id: "success", text: "Downloaded" },
{ id: "error", text: "Retry" }
]
}) %>
</form>
<% } %>
</div>
@ -95,12 +127,25 @@
<div class="ai-section-heading">
<div><h2>Runtime</h2><p>Official llama.cpp release, bound to localhost and stored inside this plugin.</p></div>
<div class="ai-actions" data-ai-runtime-actions>
<button class="button" type="button" data-runtime-action="start">Start</button>
<%- include("../../../src/web/views/partials/state-button", {
type: "button",
attrs: "data-runtime-primary",
loadingState: "starting",
successState: "running",
errorState: "error",
defaultState: runtimeStatus.state === "running" ? "running" : "idle",
states: [
{ id: "idle", text: "Start" },
{ id: "starting", text: "Starting", spinner: true },
{ id: "running", text: "Restart" },
{ id: "restarting", text: "Restarting", spinner: true },
{ id: "error", text: "Retry" }
]
}) %>
<button class="button subtle" type="button" data-runtime-action="self-test">Run self-test</button>
<button class="button subtle" type="button" data-runtime-action="verify-runtime">Verify runtime</button>
<button class="button subtle" type="button" data-runtime-action="verify-model">Verify model</button>
<button class="button subtle" type="button" data-runtime-action="verify-gate-model">Verify gate model</button>
<button class="button subtle" type="button" data-runtime-action="restart">Restart</button>
<button class="button danger" type="button" data-runtime-action="stop">Stop</button>
</div>
</div>
@ -138,8 +183,18 @@
<% if (runtimeTarget) { %>
<p><strong>Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p>
<p class="hint"><%= runtimeTarget.filename %> &middot; <%= formatBytes(runtimeTarget.size) %></p>
<form method="post" action="/plugins/lumi_ai/download/runtime">
<button class="button subtle" type="submit"><%= runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" %></button>
<form method="post" action="/plugins/lumi_ai/download/runtime" data-ai-download-form data-download-id="runtime">
<%- include("../../../src/web/views/partials/state-button", {
type: "submit",
classes: "subtle",
attrs: "data-ai-download-button data-download-id=\"runtime\"",
states: [
{ id: "idle", text: runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" },
{ id: "loading", text: "Downloading", spinner: true },
{ id: "success", text: "Downloaded" },
{ id: "error", text: "Retry" }
]
}) %>
</form>
<% } else { %>
<div class="callout">No managed runtime build is available for this OS and architecture.</div>
@ -189,7 +244,7 @@
<div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div>
<% }) %>
</div>
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-form="Delete the selected plugin-local storage categories?">
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected">
<label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label>
<label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label>
<label><input type="checkbox" name="categories" value="logs" /> Old logs</label>
@ -202,7 +257,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 +277,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">
@ -250,12 +320,12 @@
<div class="field"><label>Maximum queue</label><input type="number" name="max_queue_length" min="1" max="100" value="<%= config.max_queue_length %>" /></div>
<div class="field"><label>UI soft timeout (ms)</label><input type="number" name="ui_soft_timeout_ms" min="5000" max="300000" value="<%= config.ui_soft_timeout_ms %>" /><span class="hint">Shows Continue waiting controls without stopping the job.</span></div>
<div class="field"><label>Hard generation timeout (ms)</label><input type="number" name="hard_generation_timeout_ms" min="30000" max="3600000" value="<%= config.hard_generation_timeout_ms %>" /></div>
<div class="field"><label>API/test output token fallback</label><input type="number" name="max_output_tokens" min="64" max="32768" value="<%= config.max_output_tokens %>" /><span class="hint">Normal assistant requests use the class budgets below.</span></div>
<div class="field"><label>Navigation/help tokens</label><input type="number" name="output_budget_navigation_help" min="64" max="32768" value="<%= config.output_budgets.navigation_help %>" /></div>
<div class="field"><label>Simple answer tokens</label><input type="number" name="output_budget_simple_answer" min="64" max="32768" value="<%= config.output_budgets.simple_answer %>" /></div>
<div class="field"><label>Code/custom command tokens</label><input type="number" name="output_budget_code_custom_command" min="64" max="32768" value="<%= config.output_budgets.code_custom_command %>" /></div>
<div class="field"><label>Admin debug tokens</label><input type="number" name="output_budget_admin_debug" min="64" max="32768" value="<%= config.output_budgets.admin_debug %>" /></div>
<div class="field"><label>Explicit long-answer tokens</label><input type="number" name="output_budget_explicit_long" min="64" max="32768" value="<%= config.output_budgets.explicit_long %>" /></div>
<div class="field"><label>API/test output token fallback</label><select name="max_output_tokens"><%- renderPresetOptions(tokenPresets, config.max_output_tokens) %></select><span class="hint">Normal assistant requests use the class budgets below.</span></div>
<div class="field"><label>Navigation/help tokens</label><select name="output_budget_navigation_help"><%- renderPresetOptions(tokenPresets, config.output_budgets.navigation_help) %></select></div>
<div class="field"><label>Simple answer tokens</label><select name="output_budget_simple_answer"><%- renderPresetOptions(tokenPresets, config.output_budgets.simple_answer) %></select></div>
<div class="field"><label>Code/custom command tokens</label><select name="output_budget_code_custom_command"><%- renderPresetOptions(tokenPresets, config.output_budgets.code_custom_command) %></select></div>
<div class="field"><label>Admin debug tokens</label><select name="output_budget_admin_debug"><%- renderPresetOptions(tokenPresets, config.output_budgets.admin_debug) %></select></div>
<div class="field"><label>Explicit long-answer tokens</label><select name="output_budget_explicit_long"><%- renderPresetOptions(tokenPresets, config.output_budgets.explicit_long) %></select></div>
<div class="field"><label>Batch size</label><input type="number" name="batch_size" min="32" max="4096" value="<%= config.batch_size %>" /></div>
<div class="field"><label>Micro-batch size</label><input type="number" name="ubatch_size" min="16" max="4096" value="<%= config.ubatch_size %>" /></div>
<div class="field"><label>Requests per user/minute</label><input type="number" name="per_user_requests_per_minute" min="1" max="120" value="<%= config.per_user_requests_per_minute %>" /></div>
@ -270,7 +340,7 @@
<select name="gate_model_id"><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.gate.model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
<span class="hint">Use the smallest downloaded model that can reliably return JSON classifications.</span>
</div>
<div class="field"><label>Gate context size</label><input type="number" name="gate_context_size" min="512" max="4096" value="<%= config.gate.context_size %>" /></div>
<div class="field"><label>Gate context size</label><select name="gate_context_size"><%- renderPresetOptions(gateContextOptions, config.gate.context_size) %></select></div>
<div class="field"><label>Gate CPU threads</label><input type="number" name="gate_threads" min="1" max="16" value="<%= config.gate.threads %>" /></div>
<div class="field"><label>Gate timeout (ms)</label><input type="number" name="gate_timeout_ms" min="1000" max="5000" step="250" value="<%= config.gate.timeout_ms %>" /><span class="hint">Timeout or errors immediately escalate to the main model.</span></div>
<div class="field"><label>High-confidence threshold</label><input type="number" name="gate_high_confidence_threshold" min="0.5" max="0.99" step="0.01" value="<%= config.gate.high_confidence_threshold %>" /></div>
@ -467,7 +537,7 @@
<input type="hidden" name="source" value="local" />
<button class="button subtle" type="submit">Refresh local</button>
</form>
<form method="post" action="/plugins/lumi_ai/repo-index/refresh" data-confirm-form="Download and index the approved public Lumi repository?">
<form method="post" action="/plugins/lumi_ai/repo-index/refresh" data-confirm-title="Refresh public repository index" data-confirm-text="Download and index the approved public Lumi repository?" data-confirm-label="Refresh public">
<input type="hidden" name="source" value="public" />
<button class="button subtle" type="submit">Refresh public</button>
</form>
@ -563,7 +633,7 @@
<td class="ai-table-actions">
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a>
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a>
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-form="Delete <%= file.name %>?"><button class="button danger" type="submit">Delete</button></form>
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form>
</td>
</tr>
<% }) %>

View File

@ -4,11 +4,15 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
<%- include("../../../src/web/views/partials/theme-vars", { theme }) %>
</head>
<body>
<div class="page" style="min-height: 100vh; display: flex; align-items: center; justify-content: center;">
<div class="card" style="max-width: 720px; width: 100%;">
<body data-theme-id="<%= theme ? theme.id : '' %>">
<main class="standalone-page">
<div class="card standalone-card">
<h1>Access restricted</h1>
<p class="hint">Your account is currently restricted by moderation.</p>
<div class="stat-grid">
@ -29,7 +33,7 @@
<span class="stat-value"><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %></span>
</div>
</div>
<div class="card" style="margin-top: 16px;">
<div class="card standalone-detail">
<h2>Summary</h2>
<p><%= sanction.reason_short %></p>
<h2>Details</h2>
@ -37,6 +41,6 @@
<p class="hint">Moderator: <%= sanction.created_by_name || 'Staff' %></p>
</div>
</div>
</div>
</main>
</body>
</html>

View File

@ -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;
}

View File

@ -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>

11
run.js
View File

@ -6,6 +6,7 @@ const safeModeEntry = path.join(__dirname, "safe-mode.js");
const maxRestarts = Number(process.env.MAX_RESTARTS || 25);
const restartDelayMs = Number(process.env.RESTART_DELAY_MS || 1500);
const restartCodes = new Set([10, 100]);
const safeModeFlag = path.join(__dirname, "data", "recovery", "safe-mode.flag");
let restarts = 0;
let safeModeStarted = false;
@ -62,4 +63,12 @@ function startChild() {
});
}
startChild();
if (
process.env.LUMI_SAFE_MODE === "1" ||
process.argv.includes("--safe-mode") ||
require("fs").existsSync(safeModeFlag)
) {
startSafeMode();
} else {
startChild();
}

View File

@ -14,6 +14,11 @@ const {
const { getRoleFlags, hasAccess } = require("./src/services/rbac");
const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager");
const { requestRestart } = require("./src/services/updater");
const {
safeModeStatus,
clearRecoveryMarker
} = require("./src/services/recovery-mode");
const { setPluginEnabled } = require("./src/services/plugins");
function ensureSessionSecret() {
let secret = getSetting("session_secret");
@ -45,8 +50,13 @@ function renderPage(title, content) {
main { padding: 24px 28px; max-width: 900px; margin: 0 auto; }
.card { background: #fff; border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 10px 25px rgba(20, 24, 30, 0.08); }
.button { background: #0f6a78; color: #fff; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-block; }
.button.subtle { background: #eef5f6; color: #173238; }
.button.danger { background: #c24b3b; }
.muted { color: #5a616a; }
.warning { border-left: 4px solid #d28a24; }
.danger-card { border-left: 4px solid #c24b3b; }
form { display: inline-block; margin: 4px 6px 4px 0; }
code { background: #f1ede7; padding: 2px 5px; border-radius: 5px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; }
</style>
@ -99,6 +109,47 @@ function buildSnapshotTable(snapshots) {
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildRecoveryPanel() {
const status = safeModeStatus();
const marker = status.marker;
if (!marker) {
return `<section class="card"><h2>Recovery state</h2><p class="muted">No recovery marker is present.</p></section>`;
}
const pluginActions = marker.target_kind === "plugin" && marker.target_id
? `<form method="post" action="/plugins/${encodeURIComponent(marker.target_id)}/disable"><button class="button danger" type="submit">Disable plugin</button></form>`
: "";
return `
<section class="card ${marker.status === "failed" || marker.status === "stale" ? "danger-card" : "warning"}">
<h2>Last update marker</h2>
<p class="muted">Status: <strong>${escapeHtml(marker.status)}</strong></p>
<table>
<tbody>
<tr><th>Target</th><td>${escapeHtml(marker.target_kind || "unknown")} ${escapeHtml(marker.target_id || "")}</td></tr>
<tr><th>Version</th><td>${escapeHtml(marker.from_version || "?")} -> ${escapeHtml(marker.to_version || "?")}</td></tr>
<tr><th>Method</th><td>${escapeHtml(marker.update_method || "?")}</td></tr>
<tr><th>Source branch</th><td>${escapeHtml(marker.source_branch || "?")}</td></tr>
<tr><th>Snapshot</th><td>${escapeHtml(marker.snapshot_id || marker.recovery_marker_id || "pending")}</td></tr>
<tr><th>Updated</th><td>${escapeHtml(marker.updated_at || marker.created_at || "?")}</td></tr>
<tr><th>Error</th><td>${escapeHtml(marker.error || "")}</td></tr>
</tbody>
</table>
<div>
${pluginActions}
<form method="post" action="/recovery/clear-marker"><button class="button danger" type="submit">Clear stale marker</button></form>
<form method="post" action="/recovery/retry-normal-startup"><button class="button subtle" type="submit">Retry normal startup</button></form>
</div>
</section>
`;
}
const app = express();
const sessionStore = new BetterSqlite3Store({ client: db });
app.use(
@ -141,7 +192,7 @@ app.get("/", (req, res) => {
res.send(
renderPage(
"Safe Mode",
`<section class="card"><h2>Rollback snapshots</h2><p class="muted">Use these snapshots to roll back failed updates. The server will restart after rollback.</p>${table}</section>`
`${buildRecoveryPanel()}<section class="card"><h2>Rollback snapshots</h2><p class="muted">Use these snapshots to roll back failed updates. Major-version rollback is blocked unless the snapshot is marked rollback safe. The server will restart after rollback.</p>${table}</section>`
)
);
});
@ -210,6 +261,30 @@ app.post("/rollback/:id", (req, res) => {
}
});
app.post("/plugins/:id/disable", (req, res) => {
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
}
setPluginEnabled(req.params.id, false);
res.redirect("/");
});
app.post("/recovery/clear-marker", (req, res) => {
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
}
clearRecoveryMarker();
res.redirect("/");
});
app.post("/recovery/retry-normal-startup", (req, res) => {
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
}
res.send(renderPage("Safe Mode", "<section class=\"card\"><h2>Retrying normal startup</h2><p class=\"muted\">Safe mode is exiting. The wrapper will restart Lumi normally.</p></section>"));
requestRestart();
});
const port = Number(process.env.SAFE_MODE_PORT || 3001);
app.listen(port, () => {
console.log(`Safe mode listening on http://localhost:${port}`);

181
scripts/verify-webui.js Normal file
View File

@ -0,0 +1,181 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const ejs = require("ejs");
const root = path.join(__dirname, "..");
function listFiles(directory, extension, output = []) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) listFiles(target, extension, output);
else if (target.endsWith(extension)) output.push(target);
}
return output;
}
function verifyViews() {
const viewRoots = [path.join(root, "src", "web", "views"), path.join(root, "plugins")];
const files = viewRoots.flatMap((directory) => listFiles(directory, ".ejs"));
for (const file of files) {
ejs.compile(fs.readFileSync(file, "utf8"), { filename: file });
}
return files.length;
}
function verifyThemeService() {
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-theme-test-"));
const serviceDir = path.join(sandbox, "src", "services");
let database = null;
fs.mkdirSync(serviceDir, { recursive: true });
for (const file of ["config.js", "db.js", "settings.js", "themes.js"]) {
fs.copyFileSync(
path.join(root, "src", "services", file),
path.join(serviceDir, file)
);
}
try {
database = require(path.join(serviceDir, "db.js"));
database.migrate();
require(path.join(serviceDir, "settings.js")).ensureDefaults();
const themes = require(path.join(serviceDir, "themes.js"));
assert.strictEqual(themes.BUILTIN_THEMES.length, 6);
for (const theme of themes.BUILTIN_THEMES) {
assert.deepStrictEqual(themes.validateThemeValues(theme), []);
}
const library = themes.listThemes();
assert(library.every((theme) => theme.builtin));
assert.throws(
() => themes.saveCustomTheme("builtin:lumi-default", library[0]),
/read-only/
);
const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme");
assert.strictEqual(copy.builtin, false);
assert.strictEqual(copy.typography.bodyFont, "lumi");
themes.setActiveTheme(copy.id);
assert.strictEqual(themes.getActiveTheme().id, copy.id);
const invalid = JSON.parse(JSON.stringify(copy));
invalid.light.text = "not-a-color";
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /hex color/);
invalid.light.text = copy.light.text;
invalid.typography.bodyFont = "external-css";
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /font preset/);
const renamed = themes.renameCustomTheme(copy.id, "Verified Theme");
assert.strictEqual(renamed.name, "Verified Theme");
const repaired = themes.normalizeThemeValues({
...renamed,
light: { ...renamed.light, text: "#ffffff", surface: "#ffffff" }
});
assert.notStrictEqual(repaired.light.text, repaired.light.surface);
const themeView = path.join(root, "src", "web", "views", "admin-theme.ejs");
const rendered = ejs.render(fs.readFileSync(themeView, "utf8"), {
title: "Theming",
siteTitle: "Lumi Bot",
assetVersion: "verify",
theme: renamed,
activeTheme: renamed,
themes: themes.listThemes(),
editingTheme: renamed,
editingBaseTheme: themes.getThemeById(renamed.baseThemeId),
fontStacks: themes.FONT_STACKS,
botAvatar: null,
navSections: [],
user: { username: "Admin" },
userAvatar: null,
userInitial: "A",
platformLogins: [],
flash: null,
softError: null
}, { filename: themeView });
assert(rendered.includes("data-theme-editor"));
assert(rendered.includes("data-theme-font"));
assert(rendered.includes("data-theme-popout"));
assert(rendered.includes("data-lumi-state-button"));
assert(rendered.includes("Built-in &middot; 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",
username: "admin",
siteTitle: "Lumi Bot",
assetVersion: "verify",
theme: renamed,
botAvatar: null,
navSections: [],
user: null,
userAvatar: null,
userInitial: "",
platformLogins: [{ id: "localhost", label: "Localhost Login", configured: true, loginPath: "/auth/localhost" }],
flash: null,
softError: null
}, { filename: loginView });
assert(loginRendered.includes("admin / admin"));
const statusView = path.join(root, "plugins", "moderation", "views", "status.ejs");
const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), {
title: "Access restricted",
assetVersion: "verify",
theme: renamed,
sanction: {
action_type: "timeout",
status: "active",
created_at: Date.now(),
expires_at: Date.now() + 60000,
reason_short: "Verification",
reason_detail: "Standalone themed view verification.",
created_by_name: "Admin"
}
}, { filename: statusView });
assert(statusRendered.includes("/lumi-components.css"));
assert(statusRendered.includes('data-theme-id="custom:'));
themes.deleteCustomTheme(copy.id);
assert.strictEqual(themes.getActiveTheme().id, themes.DEFAULT_THEME_ID);
} finally {
database?.db.close();
fs.rmSync(sandbox, { recursive: true, force: true });
}
}
const viewCount = verifyViews();
verifyThemeService();
console.log(`WebUI verification passed: ${viewCount} EJS views and theme CRUD.`);

View File

@ -15,11 +15,20 @@ const { createCommandRouter } = require("./services/command-router");
const { registerTopCommand } = require("./services/top");
const logger = require("./services/logger");
const { isPlatformEnabled } = require("./services/platforms");
const {
isSafeModeRequested,
markStartupVerification
} = require("./services/recovery-mode");
async function main() {
migrate();
ensureDefaults();
logger.hookConsole();
const safeModeRequested = isSafeModeRequested();
const startupMarker = markStartupVerification();
if (startupMarker?.status === "stale") {
console.warn("Recovery marker detected from incomplete update; start with LUMI_SAFE_MODE=1 for recovery tools.");
}
const settingsApi = { getSetting, setSetting };
const commandRouter = createCommandRouter({ settings: settingsApi });
@ -28,7 +37,7 @@ async function main() {
let twitchClient = null;
let youtubeClient = null;
if (isPlatformEnabled("discord")) {
if (!safeModeRequested && isPlatformEnabled("discord")) {
try {
discordClient = await startBot({ commandRouter });
} catch (error) {
@ -36,7 +45,7 @@ async function main() {
}
}
if (isPlatformEnabled("twitch")) {
if (!safeModeRequested && isPlatformEnabled("twitch")) {
try {
twitchClient = await startTwitchBot({ commandRouter });
} catch (error) {
@ -44,7 +53,7 @@ async function main() {
}
}
if (isPlatformEnabled("youtube")) {
if (!safeModeRequested && isPlatformEnabled("youtube")) {
try {
youtubeClient = await startYouTubeBot({ commandRouter });
} catch (error) {
@ -55,6 +64,7 @@ async function main() {
const app = createWebServer({
discordClient,
loadPlugins: (appInstance, web, webhooks) => {
if (safeModeRequested) return;
loadEnabled({
app: appInstance,
discordClient,
@ -75,7 +85,7 @@ async function main() {
const autoUpdateEnabled = getSetting("auto_update_enabled", false);
const intervalMinutes = getSetting("auto_update_interval_minutes", 60);
if (autoUpdateEnabled) {
if (!safeModeRequested && autoUpdateEnabled) {
const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000;
setInterval(() => {
try {

View File

@ -18,6 +18,15 @@ function migrate() {
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS custom_themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_theme_id TEXT NOT NULL,
values_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,

View File

@ -0,0 +1,132 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const repoRoot = path.join(__dirname, "..", "..");
const recoveryDir = path.join(repoRoot, "data", "recovery");
const markerPath = path.join(recoveryDir, "update-marker.json");
const safeModeFlagPath = path.join(recoveryDir, "safe-mode.flag");
function ensureRecoveryDir() {
fs.mkdirSync(recoveryDir, { recursive: true });
}
function readRecoveryMarker() {
try {
if (!fs.existsSync(markerPath)) return null;
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
return marker && typeof marker === "object" ? marker : null;
} catch {
return {
id: "unreadable",
status: "failed",
error: "Recovery marker could not be parsed.",
marker_path: markerPath
};
}
}
function writeRecoveryMarker(marker) {
ensureRecoveryDir();
const next = {
id: marker.id || crypto.randomUUID(),
status: marker.status || "pending",
updated_at: new Date().toISOString(),
...marker
};
fs.writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf8");
return next;
}
function createRecoveryMarker(details) {
return writeRecoveryMarker({
id: crypto.randomUUID(),
status: "pending",
created_at: new Date().toISOString(),
...details
});
}
function updateRecoveryMarker(values) {
const current = readRecoveryMarker() || {};
return writeRecoveryMarker({ ...current, ...values });
}
function markRecoveryMarkerFailed(error) {
return updateRecoveryMarker({
status: "failed",
error: error?.message || String(error || "Update failed.")
});
}
function markRecoveryMarkerComplete(values = {}) {
return updateRecoveryMarker({
...values,
status: "completed",
completed_at: new Date().toISOString()
});
}
function clearRecoveryMarker() {
try {
fs.rmSync(markerPath, { force: true });
} catch {
// ignore cleanup failures
}
}
function markStartupVerification() {
const marker = readRecoveryMarker();
if (!marker) return null;
if (marker.status === "completed") {
clearRecoveryMarker();
return { ...marker, cleared: true };
}
if (["pending", "applying", "verifying"].includes(marker.status)) {
return writeRecoveryMarker({
...marker,
status: "stale",
stale_at: new Date().toISOString(),
error: marker.error || "Previous update did not complete startup verification."
});
}
return marker;
}
function isSafeModeRequested(argv = process.argv, env = process.env) {
return env.LUMI_SAFE_MODE === "1" ||
env.SAFE_MODE === "1" ||
argv.includes("--safe-mode") ||
fs.existsSync(safeModeFlagPath);
}
function safeModeStatus() {
const marker = readRecoveryMarker();
const requested = isSafeModeRequested();
const incomplete = marker && ["pending", "applying", "verifying", "failed", "stale"].includes(marker.status);
return {
requested,
active: process.env.SAFE_MODE === "1" || process.env.LUMI_SAFE_MODE === "1",
marker,
has_incomplete_marker: Boolean(incomplete),
safe_mode_flag: fs.existsSync(safeModeFlagPath),
marker_path: markerPath,
flag_path: safeModeFlagPath
};
}
module.exports = {
recoveryDir,
markerPath,
safeModeFlagPath,
readRecoveryMarker,
writeRecoveryMarker,
createRecoveryMarker,
updateRecoveryMarker,
markRecoveryMarkerFailed,
markRecoveryMarkerComplete,
clearRecoveryMarker,
markStartupVerification,
isSafeModeRequested,
safeModeStatus
};

223
src/services/repo-update.js Normal file
View File

@ -0,0 +1,223 @@
const path = require("path");
const fs = require("fs");
const { spawnSync } = require("child_process");
const { syncPluginRegistry, setPluginEnabled } = require("./plugins");
const {
createSnapshot,
finalizeSnapshot,
discardSnapshot,
restoreSnapshot
} = require("./update-manager");
const { getUpdateStatus } = require("./update-index");
const {
createRecoveryMarker,
updateRecoveryMarker,
markRecoveryMarkerFailed,
markRecoveryMarkerComplete
} = require("./recovery-mode");
const repoRoot = path.join(__dirname, "..", "..");
const activeOperations = new Set();
function runGit(args) {
const result = spawnSync("git", args, {
cwd: repoRoot,
encoding: "utf8"
});
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
}
return result.stdout.trim();
}
function withOperation(key, fn) {
if (activeOperations.has(key)) {
throw new Error("An update is already running for this target.");
}
activeOperations.add(key);
return Promise.resolve()
.then(fn)
.finally(() => activeOperations.delete(key));
}
function emitProgress(publish, event, payload) {
if (typeof publish === "function") {
publish(event, payload, { role: "admin" });
}
}
function verifyCoreFiles() {
for (const file of ["package.json", "src/main.js", "src/web/server.js"]) {
if (!fs.existsSync(path.join(repoRoot, file))) {
throw new Error(`Core verification failed: ${file} is missing.`);
}
}
}
function verifyPluginFiles(pluginId) {
const manifest = path.join(repoRoot, "plugins", pluginId, "plugin.json");
if (!fs.existsSync(manifest)) {
throw new Error(`Plugin verification failed: ${pluginId}/plugin.json is missing.`);
}
JSON.parse(fs.readFileSync(manifest, "utf8"));
}
async function applyCoreUpdate({ source = "stable", publish } = {}) {
return withOperation("core", async () => {
const status = getUpdateStatus({ source });
const target = status.core;
if (target.blocked) throw new Error(target.blocked_reason || "Core update is blocked.");
if (!target.update_available) throw new Error("No safe core update target is available.");
const marker = createRecoveryMarker({
target_kind: "core",
target_id: "core",
from_version: target.current_version,
to_version: target.safe_target_version,
source_branch: target.source_branch,
update_method: "git",
rollback_safe: target.rollback_safe,
major_crossing: target.major_crossing
});
let snapshot = null;
try {
emitProgress(publish, "update:queued", { target: "core" });
emitProgress(publish, "update:checking", { target: "core" });
emitProgress(publish, "update:metadata", target);
updateRecoveryMarker({ status: "applying" });
snapshot = await createSnapshot({
type: "bot",
metadata: {
target_kind: "core",
target_id: "core",
from_version: target.current_version,
to_version: target.safe_target_version,
source_branch: target.source_branch,
update_method: "git",
rollback_safe: target.rollback_safe,
recovery_marker_id: marker.id,
major_crossing: target.major_crossing,
migration_notes: target.migration_notes,
danger_notes: target.dangers
}
});
emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshot.id });
emitProgress(publish, "update:recovery_marker", { target: "core", marker_id: marker.id });
emitProgress(publish, "update:download", { target: "core", branch: target.source_branch });
runGit(["fetch", status.remote, target.source_branch]);
emitProgress(publish, "update:apply", { target: "core" });
runGit(["pull", "--ff-only", status.remote, target.source_branch]);
updateRecoveryMarker({ status: "verifying" });
emitProgress(publish, "update:verify", { target: "core" });
verifyCoreFiles();
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id });
emitProgress(publish, "update:restart_required", { target: "core" });
emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id });
return { status: "complete", restart_required: true, snapshot: record, target };
} catch (error) {
if (snapshot) discardSnapshot(snapshot);
markRecoveryMarkerFailed(error);
emitProgress(publish, "update:failed", { target: "core", error: error.message });
throw error;
}
});
}
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) {
return withOperation(`plugin:${pluginId}`, async () => {
const status = getUpdateStatus({ source });
const target = status.plugins.find((plugin) => plugin.id === pluginId);
if (!target) throw new Error("Plugin is not installed.");
if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked.");
if (!target.update_available) throw new Error("No safe plugin update target is available.");
const marker = createRecoveryMarker({
target_kind: "plugin",
target_id: pluginId,
from_version: target.current_version,
to_version: target.safe_target_version,
source_branch: target.source_branch,
update_method: "git",
rollback_safe: target.rollback_safe,
major_crossing: target.major_crossing
});
let snapshot = null;
try {
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
emitProgress(publish, "update:metadata", target);
updateRecoveryMarker({ status: "applying" });
snapshot = await createSnapshot({
type: "plugin",
pluginId,
metadata: {
target_kind: "plugin",
target_id: pluginId,
from_version: target.current_version,
to_version: target.safe_target_version,
source_branch: target.source_branch,
update_method: "git",
rollback_safe: target.rollback_safe,
recovery_marker_id: marker.id,
major_crossing: target.major_crossing,
migration_notes: target.migration_notes,
danger_notes: target.dangers
}
});
emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshot.id });
emitProgress(publish, "update:download", { target: "plugin", plugin_id: pluginId, branch: target.source_branch });
runGit(["fetch", status.remote, target.source_branch]);
emitProgress(publish, "update:apply", { target: "plugin", plugin_id: pluginId });
runGit(["checkout", `${status.remote}/${target.source_branch}`, "--", `plugins/${pluginId}`]);
updateRecoveryMarker({ status: "verifying" });
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
verifyPluginFiles(pluginId);
syncPluginRegistry();
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id });
emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id });
return { status: "complete", restart_required: false, snapshot: record, target };
} catch (error) {
if (snapshot) discardSnapshot(snapshot);
markRecoveryMarkerFailed(error);
emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message });
throw error;
}
});
}
async function revertCoreSnapshot(snapshotId, { currentVersion, publish } = {}) {
return withOperation("core:revert", async () => {
emitProgress(publish, "update:revert", { target: "core", snapshot_id: snapshotId });
const entry = restoreSnapshot(snapshotId, {
expectedType: "bot",
currentVersion
});
return { status: "reverted", restart_required: true, snapshot: entry };
});
}
async function revertPluginSnapshot(pluginId, snapshotId, { currentVersion, publish } = {}) {
return withOperation(`plugin:${pluginId}:revert`, async () => {
emitProgress(publish, "update:revert", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotId });
const entry = restoreSnapshot(snapshotId, {
expectedType: "plugin",
expectedPluginId: pluginId,
currentVersion
});
syncPluginRegistry();
return { status: "reverted", restart_required: true, snapshot: entry };
});
}
function disablePluginForRecovery(pluginId, publish) {
setPluginEnabled(pluginId, false);
emitProgress(publish, "recovery:plugin_disabled", { plugin_id: pluginId });
return { status: "disabled", plugin_id: pluginId };
}
module.exports = {
applyCoreUpdate,
applyPluginUpdateFromRepo,
revertCoreSnapshot,
revertPluginSnapshot,
disablePluginForRecovery
};

View File

@ -86,6 +86,8 @@ function ensureDefaults() {
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
localhost_login_username: "admin",
localhost_login_password: "admin",
theme_light_bg_1: "#ffe5c4",
theme_light_bg_2: "#f4efe8",
theme_light_bg_3: "#e9f3f1",

750
src/services/themes.js Normal file
View File

@ -0,0 +1,750 @@
const crypto = require("crypto");
const { db } = require("./db");
const { getSetting, setSetting } = require("./settings");
const DEFAULT_THEME_ID = "builtin:lumi-default";
const THEME_SYSTEM_VERSION = 1;
const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const FONT_STACKS = Object.freeze({
lumi: {
label: "Lumi Sans",
stack: '"Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
system: {
label: "System UI",
stack: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
rounded: {
label: "Rounded",
stack: 'ui-rounded, "Nunito Sans", "Aptos", Inter, ui-sans-serif, system-ui, sans-serif'
},
editorial: {
label: "Editorial",
stack: 'Georgia, "Times New Roman", ui-serif, serif'
},
mono: {
label: "Mono",
stack: '"Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace'
}
});
const TYPOGRAPHY_FIELDS = ["bodyFont", "displayFont", "monoFont", "baseSize", "headingScale", "controlScale"];
const MODE_COLOR_FIELDS = [
"bg1",
"bg2",
"bg3",
"text",
"muted",
"accent",
"accentAlt",
"success",
"warning",
"danger",
"info",
"surface",
"surface2",
"surface3",
"border",
"link",
"buttonBg",
"buttonText",
"buttonHover",
"inputBg",
"inputBorder",
"inputText",
"focusRing"
];
const ROLE_COLOR_FIELDS = ["public", "mod", "admin"];
const DEFAULT_MODE_LIGHT = {
bg1: "#dff4f2",
bg2: "#f5f7f8",
bg3: "#fff1dc",
text: "#182026",
muted: "#5a6872",
accent: "#176b75",
accentAlt: "#e58b2b",
success: "#23845b",
warning: "#a96612",
danger: "#bd4d4d",
info: "#3479a8",
surface: "#ffffff",
surface2: "#f4f7f8",
surface3: "#edf2f3",
border: "#d8e0e3",
link: "#0d6470",
buttonBg: "#176b75",
buttonText: "#ffffff",
buttonHover: "#0f5660",
inputBg: "#ffffff",
inputBorder: "#c8d3d7",
inputText: "#182026",
focusRing: "#2f98a5"
};
const DEFAULT_MODE_DARK = {
bg1: "#102c31",
bg2: "#11171b",
bg3: "#261e18",
text: "#f2f6f7",
muted: "#aebbc1",
accent: "#63c4cf",
accentAlt: "#f0b45f",
success: "#59c894",
warning: "#e4b35d",
danger: "#ef7b78",
info: "#74b9e6",
surface: "#1a2227",
surface2: "#202b31",
surface3: "#27343b",
border: "#35434a",
link: "#7bd3dc",
buttonBg: "#4dafba",
buttonText: "#08191c",
buttonHover: "#68c8d2",
inputBg: "#151d21",
inputBorder: "#42535b",
inputText: "#f2f6f7",
focusRing: "#7bd3dc"
};
const DEFAULT_THEME_VALUES = {
light: DEFAULT_MODE_LIGHT,
dark: DEFAULT_MODE_DARK,
role: {
public: "#ffffff",
mod: "#23845b",
admin: "#bd4d6d"
},
metrics: {
radius: 14,
shadowStrength: 0.14,
spacingScale: 1
},
typography: {
bodyFont: "lumi",
displayFont: "system",
monoFont: "mono",
baseSize: 16,
headingScale: 1,
controlScale: 1,
bodyFontStack: FONT_STACKS.lumi.stack,
displayFontStack: FONT_STACKS.system.stack,
monoFontStack: FONT_STACKS.mono.stack
}
};
function mergeMode(base, override = {}) {
return Object.fromEntries(
MODE_COLOR_FIELDS.map((field) => [field, override[field] || base[field]])
);
}
function createBuiltin(id, name, description, overrides = {}) {
return Object.freeze({
id: `builtin:${id}`,
name,
description,
builtin: true,
readOnly: true,
baseThemeId: null,
light: mergeMode(DEFAULT_MODE_LIGHT, overrides.light),
dark: mergeMode(DEFAULT_MODE_DARK, overrides.dark),
role: { ...DEFAULT_THEME_VALUES.role, ...(overrides.role || {}) },
metrics: { ...DEFAULT_THEME_VALUES.metrics, ...(overrides.metrics || {}) },
typography: normalizeTypography(overrides.typography, DEFAULT_THEME_VALUES.typography)
});
}
const BUILTIN_THEMES = [
createBuiltin(
"lumi-default",
"Lumi Default",
"Balanced teal and warm accents with automatic light and dark modes."
),
createBuiltin("lumi-dark", "Lumi Dark", "A deep, low-glare theme for dark workspaces.", {
light: {
bg1: "#18242a",
bg2: "#11171b",
bg3: "#241d19",
text: "#f3f6f7",
muted: "#b4c0c5",
surface: "#1c252a",
surface2: "#222d33",
surface3: "#29363d",
border: "#3b4a52",
inputBg: "#141c20",
inputBorder: "#465860",
inputText: "#f3f6f7",
accent: "#67c6d0",
link: "#7bd3dc",
buttonBg: "#51b4bf",
buttonText: "#08191c",
buttonHover: "#71d0da",
focusRing: "#7bd3dc"
}
}),
createBuiltin("lumi-light", "Lumi Light", "A crisp, bright theme with restrained shadows.", {
dark: DEFAULT_MODE_LIGHT,
metrics: { shadowStrength: 0.08 }
}),
createBuiltin("high-contrast", "High Contrast", "Maximum clarity with strong focus and status colors.", {
light: {
bg1: "#ffffff",
bg2: "#ffffff",
bg3: "#f2f2f2",
text: "#000000",
muted: "#303030",
accent: "#004f5a",
accentAlt: "#8a4300",
success: "#006b3c",
warning: "#7a4700",
danger: "#a00000",
info: "#004b88",
surface: "#ffffff",
surface2: "#f5f5f5",
surface3: "#e8e8e8",
border: "#555555",
link: "#003f99",
buttonBg: "#003f49",
buttonText: "#ffffff",
buttonHover: "#002c33",
inputBg: "#ffffff",
inputBorder: "#333333",
inputText: "#000000",
focusRing: "#005fcc"
},
dark: {
bg1: "#000000",
bg2: "#000000",
bg3: "#101010",
text: "#ffffff",
muted: "#d6d6d6",
accent: "#67e8f9",
accentAlt: "#ffd166",
success: "#65e6a3",
warning: "#ffd166",
danger: "#ff8c8c",
info: "#8fd3ff",
surface: "#080808",
surface2: "#151515",
surface3: "#222222",
border: "#aaaaaa",
link: "#8fd3ff",
buttonBg: "#a5f3fc",
buttonText: "#000000",
buttonHover: "#ffffff",
inputBg: "#000000",
inputBorder: "#dddddd",
inputText: "#ffffff",
focusRing: "#ffffff"
},
metrics: { radius: 8, shadowStrength: 0, spacingScale: 1.05 }
}),
createBuiltin("midnight", "Midnight", "Cool blue surfaces with violet highlights.", {
light: {
bg1: "#dce8ff",
bg2: "#f4f6fb",
bg3: "#eee8ff",
accent: "#4457a6",
accentAlt: "#8258b7",
link: "#354a9b",
buttonBg: "#4457a6",
buttonHover: "#34448a",
focusRing: "#6f82d8"
},
dark: {
bg1: "#10182f",
bg2: "#0b1020",
bg3: "#211630",
surface: "#141c32",
surface2: "#19233d",
surface3: "#202c49",
border: "#334160",
accent: "#91a4ff",
accentAlt: "#c49aff",
link: "#aab8ff",
buttonBg: "#91a4ff",
buttonText: "#0b1020",
buttonHover: "#b0bcff",
focusRing: "#c49aff"
}
}),
createBuiltin("soft-aurora", "Soft Aurora", "A gentle mint, lavender, and coral palette.", {
light: {
bg1: "#dcf8ee",
bg2: "#f8f5fb",
bg3: "#ffe9e4",
accent: "#397f70",
accentAlt: "#986aa8",
link: "#306f63",
buttonBg: "#397f70",
buttonHover: "#2d665a",
focusRing: "#8a6fa8"
},
dark: {
bg1: "#17352f",
bg2: "#171a22",
bg3: "#38242e",
surface: "#20262d",
surface2: "#283038",
surface3: "#313b44",
border: "#43505a",
accent: "#82d7c1",
accentAlt: "#d4a7e1",
link: "#9ce7d4",
buttonBg: "#72c9b3",
buttonText: "#10231f",
buttonHover: "#96e3d0",
focusRing: "#d4a7e1"
},
metrics: { radius: 18, shadowStrength: 0.1, spacingScale: 1.05 }
})
];
const BUILTIN_MAP = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
function cloneTheme(theme) {
return JSON.parse(JSON.stringify(theme));
}
function getBuiltinTheme(id = DEFAULT_THEME_ID) {
return BUILTIN_MAP.get(id) || BUILTIN_MAP.get(DEFAULT_THEME_ID);
}
function customKey(id) {
return `custom:${id}`;
}
function customId(themeId) {
return String(themeId || "").startsWith("custom:")
? String(themeId).slice("custom:".length)
: null;
}
function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
const source = values && typeof values === "object" ? values : {};
const normalized = {
light: mergeMode(baseTheme.light, source.light),
dark: mergeMode(baseTheme.dark, source.dark),
role: { ...baseTheme.role, ...(source.role || {}) },
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) },
typography: normalizeTypography(source.typography, baseTheme.typography)
};
for (const mode of ["light", "dark"]) {
for (const field of MODE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(normalized[mode][field])) {
normalized[mode][field] = baseTheme[mode][field];
}
}
}
for (const field of ROLE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(normalized.role[field])) {
normalized.role[field] = baseTheme.role[field];
}
}
normalized.metrics.radius = clampNumber(
normalized.metrics.radius,
0,
32,
baseTheme.metrics.radius
);
normalized.metrics.shadowStrength = clampNumber(
normalized.metrics.shadowStrength,
0,
0.35,
baseTheme.metrics.shadowStrength
);
normalized.metrics.spacingScale = clampNumber(
normalized.metrics.spacingScale,
0.75,
1.35,
baseTheme.metrics.spacingScale
);
for (const mode of ["light", "dark"]) {
if (contrastRatio(normalized[mode].text, normalized[mode].surface) < 4.5) {
normalized[mode].text = baseTheme[mode].text;
normalized[mode].surface = baseTheme[mode].surface;
}
if (contrastRatio(normalized[mode].buttonText, normalized[mode].buttonBg) < 4.5) {
normalized[mode].buttonText = baseTheme[mode].buttonText;
normalized[mode].buttonBg = baseTheme[mode].buttonBg;
}
if (contrastRatio(normalized[mode].inputText, normalized[mode].inputBg) < 4.5) {
normalized[mode].inputText = baseTheme[mode].inputText;
normalized[mode].inputBg = baseTheme[mode].inputBg;
}
}
return normalized;
}
function normalizeFontKey(value, fallback) {
return Object.prototype.hasOwnProperty.call(FONT_STACKS, value) ? value : fallback;
}
function normalizeTypography(values = {}, fallback = DEFAULT_THEME_VALUES.typography) {
const bodyFont = normalizeFontKey(values.bodyFont, fallback.bodyFont);
const displayFont = normalizeFontKey(values.displayFont, fallback.displayFont);
const monoFont = normalizeFontKey(values.monoFont, fallback.monoFont);
return {
bodyFont,
displayFont,
monoFont,
baseSize: clampNumber(values.baseSize, 14, 19, fallback.baseSize),
headingScale: clampNumber(values.headingScale, 0.9, 1.2, fallback.headingScale),
controlScale: clampNumber(values.controlScale, 0.9, 1.12, fallback.controlScale),
bodyFontStack: FONT_STACKS[bodyFont].stack,
displayFontStack: FONT_STACKS[displayFont].stack,
monoFontStack: FONT_STACKS[monoFont].stack
};
}
function clampNumber(value, min, max, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function parseHex(value) {
const raw = String(value || "").slice(1);
return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16));
}
function relativeLuminance(value) {
const channels = parseHex(value).map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(left, right) {
const a = relativeLuminance(left);
const b = relativeLuminance(right);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function validateThemeValues(values) {
const errors = [];
for (const mode of ["light", "dark"]) {
for (const field of MODE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(String(values?.[mode]?.[field] || ""))) {
errors.push(`${mode}.${field} must be a six-digit hex color.`);
}
}
}
for (const field of ROLE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(String(values?.role?.[field] || ""))) {
errors.push(`role.${field} must be a six-digit hex color.`);
}
}
const metricRules = [
["radius", 0, 32],
["shadowStrength", 0, 0.35],
["spacingScale", 0.75, 1.35]
];
for (const [field, min, max] of metricRules) {
const value = Number(values?.metrics?.[field]);
if (!Number.isFinite(value) || value < min || value > max) {
errors.push(`metrics.${field} must be between ${min} and ${max}.`);
}
}
for (const field of ["bodyFont", "displayFont", "monoFont"]) {
if (!Object.prototype.hasOwnProperty.call(FONT_STACKS, values?.typography?.[field])) {
errors.push(`typography.${field} must be a supported font preset.`);
}
}
const typographyRules = [
["baseSize", 14, 19],
["headingScale", 0.9, 1.2],
["controlScale", 0.9, 1.12]
];
for (const [field, min, max] of typographyRules) {
const value = Number(values?.typography?.[field]);
if (!Number.isFinite(value) || value < min || value > max) {
errors.push(`typography.${field} must be between ${min} and ${max}.`);
}
}
if (!errors.length) {
for (const mode of ["light", "dark"]) {
if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) {
errors.push(`${mode} text and surface colors need at least 4.5:1 contrast.`);
}
if (contrastRatio(values[mode].buttonText, values[mode].buttonBg) < 4.5) {
errors.push(`${mode} button text and background need at least 4.5:1 contrast.`);
}
if (contrastRatio(values[mode].inputText, values[mode].inputBg) < 4.5) {
errors.push(`${mode} input text and background need at least 4.5:1 contrast.`);
}
}
}
return errors;
}
function legacyThemeValues() {
const legacy = {
light: {
bg1: getSetting("theme_light_bg_1", "#ffe5c4"),
bg2: getSetting("theme_light_bg_2", "#f4efe8"),
bg3: getSetting("theme_light_bg_3", "#e9f3f1"),
text: getSetting("theme_light_text", "#121518"),
muted: getSetting("theme_light_text_muted", "#2c3137"),
accent: getSetting("theme_light_accent", "#0f6a78"),
accentAlt: getSetting("theme_light_accent_alt", "#f4a340"),
danger: getSetting("theme_light_danger", "#d66d5c"),
surface: getSetting("theme_light_surface", "#ffffff"),
surface2: getSetting("theme_light_surface_2", "#fbf9f6"),
surface3: getSetting("theme_light_surface_3", "#f9f5ef"),
border: getSetting("theme_light_border", "#e3ddd6")
},
dark: {
bg1: getSetting("theme_dark_bg_1", "#1b1d1f"),
bg2: getSetting("theme_dark_bg_2", "#16181b"),
bg3: getSetting("theme_dark_bg_3", "#0f1113"),
text: getSetting("theme_dark_text", "#f2f0ec"),
muted: getSetting("theme_dark_text_muted", "#c5bfb7"),
accent: getSetting("theme_dark_accent", "#4fb6c2"),
accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"),
danger: getSetting("theme_dark_danger", "#e08173"),
surface: getSetting("theme_dark_surface", "#232629"),
surface2: getSetting("theme_dark_surface_2", "#2b2f33"),
surface3: getSetting("theme_dark_surface_3", "#30353a"),
border: getSetting("theme_dark_border", "#34393d")
},
role: {
public: getSetting("theme_role_public", "#ffffff"),
mod: getSetting("theme_role_mod", "#2cb678"),
admin: getSetting("theme_role_admin", "#e35678")
}
};
return normalizeThemeValues(legacy, getBuiltinTheme());
}
function legacyWasCustomized(values) {
const defaults = {
light: {
bg1: "#ffe5c4", bg2: "#f4efe8", bg3: "#e9f3f1", text: "#121518",
muted: "#2c3137", accent: "#0f6a78", accentAlt: "#f4a340",
danger: "#d66d5c", surface: "#ffffff", surface2: "#fbf9f6",
surface3: "#f9f5ef", border: "#e3ddd6"
},
dark: {
bg1: "#1b1d1f", bg2: "#16181b", bg3: "#0f1113", text: "#f2f0ec",
muted: "#c5bfb7", accent: "#4fb6c2", accentAlt: "#f1b765",
danger: "#e08173", surface: "#232629", surface2: "#2b2f33",
surface3: "#30353a", border: "#34393d"
},
role: { public: "#ffffff", mod: "#2cb678", admin: "#e35678" }
};
return ["light", "dark", "role"].some((group) =>
Object.entries(defaults[group]).some(([key, value]) => values[group][key] !== value)
);
}
function ensureThemeMigration() {
if (Number(getSetting("theme_system_version", 0)) >= THEME_SYSTEM_VERSION) return;
const legacy = legacyThemeValues();
setSetting("theme_system_version", THEME_SYSTEM_VERSION);
if (legacyWasCustomized(legacy)) {
const theme = insertCustomTheme("Migrated Theme", DEFAULT_THEME_ID, legacy);
setSetting("theme_active_id", theme.id);
} else {
setSetting("theme_active_id", DEFAULT_THEME_ID);
}
}
function rowToTheme(row) {
const base = getBuiltinTheme(row.base_theme_id);
let stored = {};
try {
stored = JSON.parse(row.values_json);
} catch {
stored = {};
}
const values = normalizeThemeValues(stored, base);
return {
id: customKey(row.id),
name: row.name,
description: `Custom theme based on ${base.name}.`,
builtin: false,
readOnly: false,
baseThemeId: base.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
...values
};
}
function getThemeById(themeId) {
ensureThemeMigration();
if (BUILTIN_MAP.has(themeId)) return cloneTheme(BUILTIN_MAP.get(themeId));
const id = customId(themeId);
if (!id) return null;
const row = db.prepare("SELECT * FROM custom_themes WHERE id = ?").get(id);
return row ? rowToTheme(row) : null;
}
function listThemes() {
ensureThemeMigration();
const custom = db
.prepare("SELECT * FROM custom_themes ORDER BY lower(name), created_at")
.all()
.map(rowToTheme);
return [...BUILTIN_THEMES.map(cloneTheme), ...custom];
}
function getActiveTheme() {
ensureThemeMigration();
const requested = getSetting("theme_active_id", DEFAULT_THEME_ID);
const theme = getThemeById(requested) || cloneTheme(getBuiltinTheme());
if (theme.id !== requested) setSetting("theme_active_id", theme.id);
return theme;
}
function setActiveTheme(themeId) {
const theme = getThemeById(themeId);
if (!theme) throw new Error("Theme not found.");
setSetting("theme_active_id", theme.id);
return theme;
}
function cleanName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
if (name.length < 2 || name.length > 60) {
throw new Error("Theme name must be between 2 and 60 characters.");
}
return name;
}
function insertCustomTheme(name, baseThemeId, values) {
const clean = cleanName(name);
const base = getBuiltinTheme(baseThemeId);
const normalized = normalizeThemeValues(values, base);
const errors = validateThemeValues(normalized);
if (errors.length) throw new Error(errors[0]);
const id = crypto.randomUUID();
const now = Date.now();
db.prepare(
"INSERT INTO custom_themes (id, name, base_theme_id, values_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
).run(id, clean, base.id, JSON.stringify(normalized), now, now);
return getThemeById(customKey(id));
}
function duplicateTheme(themeId, name) {
const source = getThemeById(themeId);
if (!source) throw new Error("Theme not found.");
return insertCustomTheme(
name || `${source.name} Copy`,
source.builtin ? source.id : source.baseThemeId,
source
);
}
function saveCustomTheme(themeId, values) {
const id = customId(themeId);
if (!id) throw new Error("Built-in themes are read-only.");
const current = getThemeById(themeId);
if (!current) throw new Error("Theme not found.");
const normalized = normalizeThemeValues(values, getBuiltinTheme(current.baseThemeId));
const errors = validateThemeValues(values);
if (errors.length) {
const error = new Error(errors[0]);
error.validationErrors = errors;
throw error;
}
db.prepare(
"UPDATE custom_themes SET values_json = ?, updated_at = ? WHERE id = ?"
).run(JSON.stringify(normalized), Date.now(), id);
return getThemeById(themeId);
}
function renameCustomTheme(themeId, name) {
const id = customId(themeId);
if (!id) throw new Error("Built-in themes cannot be renamed.");
const result = db
.prepare("UPDATE custom_themes SET name = ?, updated_at = ? WHERE id = ?")
.run(cleanName(name), Date.now(), id);
if (!result.changes) throw new Error("Theme not found.");
return getThemeById(themeId);
}
function deleteCustomTheme(themeId) {
const id = customId(themeId);
if (!id) throw new Error("Built-in themes cannot be deleted.");
const activeId = getSetting("theme_active_id", DEFAULT_THEME_ID);
const result = db.prepare("DELETE FROM custom_themes WHERE id = ?").run(id);
if (!result.changes) throw new Error("Theme not found.");
if (activeId === themeId) setSetting("theme_active_id", DEFAULT_THEME_ID);
}
function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
const values = { light: {}, dark: {}, role: {}, metrics: {}, typography: {} };
for (const mode of ["light", "dark"]) {
for (const field of MODE_COLOR_FIELDS) {
values[mode][field] = String(
body?.[`${mode}_${field}`] ?? fallbackTheme[mode][field]
).trim();
}
}
for (const field of ROLE_COLOR_FIELDS) {
values.role[field] = String(
body?.[`role_${field}`] ?? fallbackTheme.role[field]
).trim();
}
values.metrics.radius = Number(body?.metrics_radius ?? fallbackTheme.metrics.radius);
values.metrics.shadowStrength = Number(
body?.metrics_shadowStrength ?? fallbackTheme.metrics.shadowStrength
);
values.metrics.spacingScale = Number(
body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale
);
values.typography.bodyFont = String(
body?.typography_bodyFont ?? fallbackTheme.typography.bodyFont
);
values.typography.displayFont = String(
body?.typography_displayFont ?? fallbackTheme.typography.displayFont
);
values.typography.monoFont = String(
body?.typography_monoFont ?? fallbackTheme.typography.monoFont
);
values.typography.baseSize = Number(
body?.typography_baseSize ?? fallbackTheme.typography.baseSize
);
values.typography.headingScale = Number(
body?.typography_headingScale ?? fallbackTheme.typography.headingScale
);
values.typography.controlScale = Number(
body?.typography_controlScale ?? fallbackTheme.typography.controlScale
);
return values;
}
module.exports = {
BUILTIN_THEMES,
DEFAULT_THEME_ID,
FONT_STACKS,
MODE_COLOR_FIELDS,
ROLE_COLOR_FIELDS,
TYPOGRAPHY_FIELDS,
contrastRatio,
deleteCustomTheme,
duplicateTheme,
getActiveTheme,
getThemeById,
listThemes,
normalizeThemeValues,
renameCustomTheme,
saveCustomTheme,
setActiveTheme,
validateThemeValues,
valuesFromRequest
};

View File

@ -0,0 +1,290 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { getSetting } = require("./settings");
const { scanPluginDirectories, getPlugins } = require("./plugins");
const {
parseSemver,
compareSemver,
findSafeTarget,
collectChangelogRange,
normalizeVersions
} = require("./versioning");
const { listSnapshots } = require("./update-manager");
const { safeModeStatus } = require("./recovery-mode");
const repoRoot = path.join(__dirname, "..", "..");
function runGit(args, options = {}) {
const result = spawnSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
timeout: options.timeout || 20000
});
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
}
return result.stdout.trim();
}
function tryGit(args, fallback = "") {
try {
return runGit(args);
} catch {
return fallback;
}
}
function fetchRemote(remote) {
runGit(["fetch", "--prune", remote]);
}
function remoteRef(remote, branch) {
return `${remote}/${branch}`;
}
function readGitFile(ref, filePath) {
const output = tryGit(["show", `${ref}:${filePath}`], "");
return output || null;
}
function readJsonGitFile(ref, filePath) {
const raw = readGitFile(ref, filePath);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function readLocalJson(filePath) {
try {
return JSON.parse(fs.readFileSync(path.join(repoRoot, filePath), "utf8"));
} catch {
return null;
}
}
function resolveSourceBranch(remote, requested = "stable") {
if (requested === "experimental") {
const refs = tryGit([
"for-each-ref",
"--format=%(refname:short)|%(committerdate:iso8601)",
`refs/remotes/${remote}/experimental-*`
], "");
const branches = refs
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [ref, date] = line.split("|");
return { branch: ref.replace(`${remote}/`, ""), date };
})
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
if (branches[0]) return branches[0].branch;
}
if (requested && requested !== "stable" && requested !== "main") {
return String(requested).replace(/^origin\//, "");
}
return "main";
}
function parseMarkdownChangelog(raw) {
if (!raw) return [];
const entries = [];
const lines = raw.split(/\r?\n/);
let current = null;
for (const line of lines) {
const heading = line.match(/^#{1,3}\s+\[?v?(\d+\.\d+\.\d+)\]?(.+)?$/i);
if (heading) {
if (current) entries.push(current);
current = { version: heading[1], title: line.replace(/^#+\s+/, ""), changes: [] };
} else if (current && line.trim()) {
current.changes.push(line.trim().replace(/^[-*]\s*/, ""));
}
}
if (current) entries.push(current);
return entries;
}
function changelogEntries(ref, basePath = "") {
const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json"));
if (Array.isArray(json)) return json;
if (Array.isArray(json?.versions)) return json.versions;
const md = readGitFile(ref, path.posix.join(basePath, "CHANGELOG.md"));
return parseMarkdownChangelog(md);
}
function manifestVersions(manifest, fallbackVersion) {
const entries = [];
if (Array.isArray(manifest?.versions)) entries.push(...manifest.versions);
if (manifest?.version) entries.push({ ...manifest, version: manifest.version });
if (fallbackVersion) entries.push({ version: fallbackVersion });
return normalizeVersions(entries);
}
function manifestRawVersion(manifest, fallbackVersion) {
return String(manifest?.version || fallbackVersion || "").trim();
}
function coreManifest(ref) {
return readJsonGitFile(ref, "update-manifest.json") ||
readJsonGitFile(ref, "lumi.manifest.json") ||
readJsonGitFile(ref, "package.json") ||
{};
}
function localCoreVersion() {
return readLocalJson("package.json")?.version || "0.0.0";
}
function latestEntry(entries) {
return entries.length ? entries[entries.length - 1] : null;
}
function snapshotAvailability(kind, id = null) {
const snapshots = listSnapshots()
.filter((snap) => snap.type === kind || (kind === "core" && snap.type === "bot"))
.filter((snap) => !id || snap.pluginId === id)
.sort((a, b) => b.createdAt - a.createdAt);
const latest = snapshots[0] || null;
return {
available: Boolean(latest),
latest_snapshot_id: latest?.id || null,
rollback_safe: latest?.rollback_safe !== false,
latest
};
}
function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) {
const versions = manifestVersions(manifest, manifest?.version);
const rawVersion = manifestRawVersion(manifest);
const targetVersion = rawVersion || latestEntry(versions)?.version || "";
const currentIsVersioned = Boolean(parseSemver(currentVersion));
const targetIsVersioned = Boolean(parseSemver(targetVersion));
const unversionedUpdate = !currentIsVersioned || !targetIsVersioned;
const latest = latestEntry(versions);
const unversionedTarget = {
...(latest || manifest || {}),
version: targetVersion || "unversioned",
rollback_safe: false,
unversioned: true
};
const targetResult = unversionedUpdate
? {
target: unversionedTarget,
latest: latest || unversionedTarget,
blocked: false,
warning: "This update involves an unversioned source or target. It is available as a manual repo update, but version ordering, changelog range, and rollback safety cannot be verified."
}
: findSafeTarget(currentVersion, versions);
const target = targetResult.target;
const range = target && !unversionedUpdate
? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions)
: changelog;
const warnings = [];
const dangers = [];
if (!changelog.length) warnings.push("Changelog metadata is missing.");
if (targetResult.warning) warnings.push(targetResult.warning);
if (targetResult.blocked) dangers.push(targetResult.reason);
if (target?.rollback_safe === false) warnings.push("Target metadata marks rollback as unsafe after migration.");
return {
kind,
id: id || kind,
name: name || manifest?.name || id || "Lumi core",
current_version: currentVersion,
latest_available_version: latest?.version || rawVersion || currentVersion,
safe_target_version: target?.version || null,
update_available: Boolean(
target && (unversionedUpdate || compareSemver(target.version, currentVersion) > 0)
),
blocked: Boolean(targetResult.blocked),
blocked_reason: targetResult.reason || null,
source_branch: sourceBranch,
channel: manifest?.channel || channel || "stable",
version_description: target
? `${currentVersion} -> ${target.version}`
: targetResult.reason || "No safe update target available.",
changelog_range: range,
size_delta: target?.size || manifest?.size || null,
size_delta_label: target?.size || manifest?.size ? String(target.size || manifest.size) : "unknown",
warnings,
dangers,
unversioned_update: unversionedUpdate,
requires_manual_confirmation: unversionedUpdate,
requirements: target?.requirements || manifest?.requirements || [],
migration_notes: target?.migration_notes || manifest?.migration_notes || "",
rollback_safe: target?.rollback_safe !== false,
major_crossing: target && !unversionedUpdate ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false,
snapshot: snapshotAvailability(kind === "plugin" ? "plugin" : "bot", id),
raw_target: target || null
};
}
function remotePluginDirs(ref) {
const output = tryGit(["ls-tree", "-d", "--name-only", `${ref}:plugins`], "");
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((item) => item.replace(/^plugins\//, ""));
}
function getUpdateStatus(options = {}) {
const remote = options.remote || getSetting("git_remote", "origin");
const requestedSource = options.source || "stable";
fetchRemote(remote);
const sourceBranch = resolveSourceBranch(remote, requestedSource);
const ref = remoteRef(remote, sourceBranch);
const core = buildStatus({
kind: "core",
currentVersion: localCoreVersion(),
manifest: coreManifest(ref),
changelog: changelogEntries(ref),
sourceBranch,
channel: requestedSource === "experimental" ? "experimental" : "stable"
});
const installed = scanPluginDirectories();
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
const remoteDirs = new Set(remotePluginDirs(ref));
const plugins = installed.map((plugin) => {
const basePath = `plugins/${plugin.id}`;
const manifest = readJsonGitFile(ref, `${basePath}/plugin.json`) || {};
const changelog = changelogEntries(ref, basePath);
const dbPlugin = registry.get(plugin.id);
return buildStatus({
kind: "plugin",
id: plugin.id,
name: plugin.name,
currentVersion: dbPlugin?.version || plugin.version || "0.0.0",
manifest: remoteDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name },
changelog,
sourceBranch,
channel: requestedSource === "experimental" ? "experimental" : "stable"
});
});
return {
generated_at: new Date().toISOString(),
source_branch: sourceBranch,
requested_source: requestedSource,
remote,
core,
plugins,
plugins_summary: {
installed_plugins: installed.length,
total_plugins: remoteDirs.size || installed.length,
updatable_plugins: plugins.filter((plugin) => plugin.update_available).length,
blocked_plugins: plugins.filter((plugin) => plugin.blocked).length
},
recovery: safeModeStatus()
};
}
module.exports = {
getUpdateStatus,
resolveSourceBranch,
fetchRemote,
runGit,
readGitFile
};

View File

@ -9,6 +9,12 @@ try {
AdmZip = null;
}
const { db } = require("./db");
const {
createRecoveryMarker,
updateRecoveryMarker,
markRecoveryMarkerFailed,
markRecoveryMarkerComplete
} = require("./recovery-mode");
const repoRoot = path.join(__dirname, "..", "..");
const dataDir = path.join(repoRoot, "data");
@ -49,7 +55,7 @@ async function backupDatabase(targetPath) {
}
}
async function createSnapshot({ type, pluginId }) {
async function createSnapshot({ type, pluginId, metadata = {} }) {
ensureSnapshotsDir();
const id = `${Date.now()}-${crypto.randomUUID()}`;
const snapshotPath = path.join(snapshotsDir, id);
@ -77,7 +83,7 @@ async function createSnapshot({ type, pluginId }) {
}
}
return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath };
return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath, metadata };
}
function finalizeSnapshot(snapshot) {
@ -89,7 +95,18 @@ function finalizeSnapshot(snapshot) {
pluginExisted: snapshot.pluginExisted || false,
createdAt: Date.now(),
status: "available",
path: snapshot.snapshotPath
path: snapshot.snapshotPath,
target_kind: snapshot.metadata?.target_kind || snapshot.type,
target_id: snapshot.metadata?.target_id || snapshot.pluginId || null,
from_version: snapshot.metadata?.from_version || null,
to_version: snapshot.metadata?.to_version || null,
source_branch: snapshot.metadata?.source_branch || null,
update_method: snapshot.metadata?.update_method || null,
rollback_safe: snapshot.metadata?.rollback_safe !== false,
recovery_marker_id: snapshot.metadata?.recovery_marker_id || null,
major_crossing: Boolean(snapshot.metadata?.major_crossing),
migration_notes: snapshot.metadata?.migration_notes || "",
danger_notes: snapshot.metadata?.danger_notes || []
};
entries.push(record);
saveIndex(pruneEntries(entries));
@ -384,16 +401,38 @@ async function applyBotUpdate(zipPath, options = {}) {
verifyBotPackage(rootPath);
}
const snapshot = await createSnapshot({ type: "bot" });
const marker = createRecoveryMarker({
target_kind: "core",
target_id: "core",
from_version: options.metadata?.from_version || null,
to_version: options.metadata?.to_version || null,
source_branch: options.metadata?.source_branch || "manual_zip",
update_method: options.mode === "patch" ? "zip_patch" : "zip",
rollback_safe: options.metadata?.rollback_safe !== false,
major_crossing: Boolean(options.metadata?.major_crossing)
});
updateRecoveryMarker({ status: "applying" });
const snapshot = await createSnapshot({
type: "bot",
metadata: {
target_kind: "core",
update_method: options.mode === "patch" ? "zip_patch" : "zip",
...options.metadata,
recovery_marker_id: marker.id
}
});
try {
if (mode === "patch") {
applyCorePatch(rootPath);
} else {
applyCoreUpdate(rootPath);
}
return finalizeSnapshot(snapshot);
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id });
return record;
} catch (error) {
discardSnapshot(snapshot);
markRecoveryMarkerFailed(error);
throw error;
}
} finally {
@ -401,21 +440,48 @@ async function applyBotUpdate(zipPath, options = {}) {
}
}
async function applyPluginUpdate(zipPath) {
async function applyPluginUpdate(zipPath, options = {}) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-"));
try {
extractZip(zipPath, tempDir);
const rootPath = resolvePluginRoot(tempDir);
const manifest = verifyPluginPackage(rootPath);
if (options.expectedPluginId && manifest.id !== options.expectedPluginId) {
throw new Error(`Plugin ZIP id ${manifest.id} does not match ${options.expectedPluginId}.`);
}
const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id });
const marker = createRecoveryMarker({
target_kind: "plugin",
target_id: manifest.id,
from_version: options.metadata?.from_version || null,
to_version: manifest.version || options.metadata?.to_version || null,
source_branch: options.metadata?.source_branch || "manual_zip",
update_method: "zip",
rollback_safe: options.metadata?.rollback_safe !== false,
major_crossing: Boolean(options.metadata?.major_crossing)
});
updateRecoveryMarker({ status: "applying" });
const snapshot = await createSnapshot({
type: "plugin",
pluginId: manifest.id,
metadata: {
target_kind: "plugin",
target_id: manifest.id,
update_method: "zip",
...options.metadata,
recovery_marker_id: marker.id
}
});
try {
applyPluginFiles(rootPath, manifest.id, {
preserveData: snapshot.pluginExisted
});
return finalizeSnapshot(snapshot);
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id });
return record;
} catch (error) {
discardSnapshot(snapshot);
markRecoveryMarkerFailed(error);
throw error;
}
} finally {
@ -436,7 +502,7 @@ function restoreDatabase(snapshotPath) {
fs.rmSync(shm, { force: true });
}
function restoreSnapshot(id) {
function restoreSnapshot(id, options = {}) {
const entries = loadIndex();
const entry = entries.find((item) => item.id === id);
if (!entry) {
@ -445,6 +511,18 @@ function restoreSnapshot(id) {
if (entry.status !== "available") {
throw new Error("Snapshot is no longer available.");
}
if (options.expectedType && entry.type !== options.expectedType) {
throw new Error("Snapshot target type does not match this revert action.");
}
if (options.expectedPluginId && entry.pluginId !== options.expectedPluginId) {
throw new Error("Snapshot target plugin does not match this revert action.");
}
if (entry.major_crossing && entry.rollback_safe === false && !options.allowUnsafeMajorRollback) {
throw new Error("This snapshot crossed a major version and is not marked rollback safe.");
}
if (options.currentVersion && entry.to_version && entry.to_version !== options.currentVersion) {
throw new Error("Only the previous version snapshot can be reverted from this action.");
}
if (entry.type === "bot") {
const coreZip = path.join(entry.path, "core.zip");
@ -484,6 +562,9 @@ function restoreSnapshot(id) {
module.exports = {
applyBotUpdate,
applyPluginUpdate,
createSnapshot,
finalizeSnapshot,
discardSnapshot,
applyPluginFiles,
resetPluginCode,
replacePluginDirectory,

118
src/services/versioning.js Normal file
View File

@ -0,0 +1,118 @@
function parseSemver(value) {
const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
raw: `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`
};
}
function compareSemver(left, right) {
const a = parseSemver(left);
const b = parseSemver(right);
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
for (const key of ["major", "minor", "patch"]) {
if (a[key] > b[key]) return 1;
if (a[key] < b[key]) return -1;
}
return 0;
}
function isMajorJump(current, target) {
const from = parseSemver(current);
const to = parseSemver(target);
return Boolean(from && to && from.major !== to.major);
}
function versionInRange(version, from, to) {
return compareSemver(version, from) > 0 && compareSemver(version, to) <= 0;
}
function normalizeVersionEntry(entry, fallback = {}) {
if (typeof entry === "string") return { version: entry, ...fallback };
if (!entry || typeof entry !== "object") return null;
const version = entry.version || entry.name || entry.tag;
if (!parseSemver(version)) return null;
return { ...fallback, ...entry, version: parseSemver(version).raw };
}
function normalizeVersions(entries, fallback = {}) {
return (Array.isArray(entries) ? entries : [])
.map((entry) => normalizeVersionEntry(entry, fallback))
.filter(Boolean)
.sort((a, b) => compareSemver(a.version, b.version));
}
function compatibleFromAllows(entry, current) {
if (!entry?.compatible_from) return false;
return compareSemver(current, entry.compatible_from) >= 0;
}
function isBridgeTarget(entry, current) {
const from = parseSemver(current);
const to = parseSemver(entry?.version);
if (!from || !to || from.major === to.major) return false;
if (entry.compatibility_bridge === true || entry.migration_kind === "compatibility_bridge") {
return compatibleFromAllows(entry, current) || !entry.compatible_from;
}
if (to.major === from.major + 1 && to.minor === 0 && to.patch === 0) {
return compatibleFromAllows(entry, current) || entry.compatible_from === undefined;
}
return false;
}
function findSafeTarget(currentVersion, versionEntries) {
const current = parseSemver(currentVersion);
const versions = normalizeVersions(versionEntries);
if (!current) {
return {
target: null,
blocked: true,
reason: "Current version is not valid semver."
};
}
const newer = versions.filter((entry) => compareSemver(entry.version, current.raw) > 0);
if (!newer.length) {
return { target: null, blocked: false, reason: "Already up to date." };
}
const latest = newer[newer.length - 1];
const sameMajor = newer.filter((entry) => parseSemver(entry.version)?.major === current.major);
if (sameMajor.length) {
return { target: sameMajor[sameMajor.length - 1], latest, blocked: false };
}
const bridge = newer.find((entry) => isBridgeTarget(entry, current.raw));
if (bridge) {
return {
target: bridge,
latest,
blocked: false,
warning: `Latest ${latest.version} crosses a major boundary; targeting compatibility bridge ${bridge.version}.`
};
}
return {
target: null,
latest,
blocked: true,
reason: `Latest ${latest.version} crosses a major boundary and no compatible bridge target was found.`
};
}
function collectChangelogRange(currentVersion, targetVersion, changelogEntries) {
if (!targetVersion) return [];
return normalizeVersions(changelogEntries)
.filter((entry) => versionInRange(entry.version, currentVersion, targetVersion))
.sort((a, b) => compareSemver(b.version, a.version));
}
module.exports = {
parseSemver,
compareSemver,
isMajorJump,
normalizeVersions,
findSafeTarget,
collectChangelogRange
};

View 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
};

View File

@ -1,6 +1,11 @@
(() => {
const body = document.body;
const media = window.matchMedia("(max-width: 900px)");
const sidebarPreferenceKey = "lumi-sidebar-collapsed";
if (!media.matches && window.localStorage.getItem(sidebarPreferenceKey) === "true") {
body.classList.add("sidebar-collapsed");
}
document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => {
button.addEventListener("click", () => {
@ -8,10 +13,18 @@
body.classList.toggle("sidebar-open");
} else {
body.classList.toggle("sidebar-collapsed");
window.localStorage.setItem(
sidebarPreferenceKey,
body.classList.contains("sidebar-collapsed") ? "true" : "false"
);
}
});
});
document.querySelector("[data-sidebar-dismiss]")?.addEventListener("click", () => {
body.classList.remove("sidebar-open");
});
document.querySelectorAll(".nav-link").forEach((link) => {
link.addEventListener("click", () => {
if (body.classList.contains("sidebar-open")) {
@ -20,6 +33,30 @@
});
});
document.querySelectorAll(".nav-section").forEach((section) => {
const summary = section.querySelector("summary");
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
section.addEventListener("toggle", () => {
summary?.setAttribute("aria-expanded", section.open ? "true" : "false");
if (!section.open) return;
document.querySelectorAll(".nav-section[open]").forEach((other) => {
if (other !== section) {
other.open = false;
other.querySelector("summary")?.setAttribute("aria-expanded", "false");
}
});
});
});
media.addEventListener?.("change", () => {
body.classList.remove("sidebar-open");
if (media.matches) {
body.classList.remove("sidebar-collapsed");
} else if (window.localStorage.getItem(sidebarPreferenceKey) === "true") {
body.classList.add("sidebar-collapsed");
}
});
const editToggles = Array.from(
document.querySelectorAll("[data-edit-toggle]")
);
@ -467,8 +504,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 {
@ -499,19 +537,34 @@
const destructiveConfirm = destructiveModal?.querySelector("[data-destructive-confirm]");
const destructiveStates = new WeakMap();
let activeDestructive = null;
let activeCallbackConfirm = null;
const destructiveAction = (form) => {
const destructiveAction = (form, submitter = null) => {
try {
return new URL(form.action, window.location.origin).pathname;
const action = submitter?.formAction || form.action;
return new URL(action, window.location.origin).pathname;
} catch {
return "";
}
};
const isDestructiveForm = (form) => {
const actionCopy = (action) => {
const normalized = String(action || "").toLowerCase();
if (normalized.includes("/delete")) return { title: "Confirm deletion", label: "Delete" };
if (normalized.includes("/uninstall")) return { title: "Confirm uninstall", label: "Uninstall" };
if (normalized.includes("/cleanup")) return { title: "Confirm cleanup", label: "Clean selected" };
if (normalized.includes("/reset")) return { title: "Confirm reset", label: "Reset" };
if (normalized.includes("/remove")) return { title: "Confirm removal", label: "Remove" };
if (normalized.includes("/update")) return { title: "Confirm update", label: "Update" };
if (normalized.includes("/restart")) return { title: "Confirm restart", label: "Restart" };
return { title: "Confirm action", label: "Confirm" };
};
const isDestructiveForm = (form, submitter = null) => {
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
if (form.dataset.updateAction !== undefined) return false;
return String(form.method || "get").toLowerCase() === "post" &&
destructivePattern.test(destructiveAction(form));
destructivePattern.test(destructiveAction(form, submitter));
};
const resetDestructive = (form) => {
@ -529,6 +582,41 @@
if (form.dataset.syntheticConfirmation === "true") form.remove();
};
const resetCallbackConfirm = (result = false) => {
const active = activeCallbackConfirm;
if (!active) return;
activeCallbackConfirm = null;
destructiveModal?.classList.remove("is-open");
destructiveModal?.setAttribute("aria-hidden", "true");
destructiveConfirm?.removeEventListener("click", active.onConfirm);
active.resolve(result);
active.returnFocus?.focus?.();
};
window.LumiConfirm = {
destructive({ title = "Confirm action", text = "This action cannot be undone.", label = "Confirm", danger = true } = {}) {
if (!destructiveModal || !destructiveConfirm) {
return Promise.resolve(window.confirm(text));
}
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
if (activeCallbackConfirm) resetCallbackConfirm(false);
return new Promise((resolve) => {
const returnFocus = document.activeElement;
destructiveTitle.textContent = title;
destructiveDescription.textContent = text;
destructiveConfirm.disabled = false;
destructiveConfirm.textContent = label;
destructiveConfirm.classList.toggle("danger", danger);
destructiveModal.classList.add("is-open");
destructiveModal.setAttribute("aria-hidden", "false");
const onConfirm = () => resetCallbackConfirm(true);
activeCallbackConfirm = { resolve, returnFocus, onConfirm };
destructiveConfirm.addEventListener("click", onConfirm);
destructiveConfirm.focus();
});
}
};
const submitDestructive = (form, submitter, token) => {
let tokenField = form.querySelector('input[name="confirmation_token"]');
if (!tokenField) {
@ -544,12 +632,14 @@
form.requestSubmit(submitter?.form === form ? submitter : undefined);
};
const confirmLabel = (form, submitter = null) => form.dataset.confirmLabel || submitter?.dataset?.confirmLabel || actionCopy(destructiveAction(form, submitter)).label;
const startCountdown = ({ form, button, token, notBefore, expiresAt, submitter }) => {
const state = destructiveStates.get(form) || {};
const update = () => {
const remaining = Math.max(0, Math.ceil((notBefore - Date.now()) / 1000));
button.disabled = remaining > 0;
button.textContent = remaining > 0 ? `Confirm in ${remaining}` : "Confirm";
button.textContent = remaining > 0 ? `${confirmLabel(form, submitter)} in ${remaining}` : confirmLabel(form, submitter);
if (!remaining && state.timer) {
window.clearInterval(state.timer);
state.timer = null;
@ -566,19 +656,21 @@
const issueDestructiveConfirmation = async (form, submitter) => {
if (destructiveStates.has(form)) return;
const action = destructiveAction(form);
const action = destructiveAction(form, submitter);
const state = { confirmed: false, inline: null, timer: null, expiryTimer: null };
destructiveStates.set(form, state);
const message = form.dataset.confirmText || "This action cannot be undone.";
const copy = actionCopy(action);
const message = submitter?.dataset?.confirmText || form.dataset.confirmText || form.dataset.confirmForm || "This action cannot be undone.";
const mode = form.dataset.confirmMode || (highImpactPattern.test(action) ? "modal" : "inline");
let confirmButton;
if (mode === "modal" && destructiveModal && destructiveConfirm) {
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
activeDestructive = { form };
destructiveTitle.textContent = form.dataset.confirmTitle || "Confirm destructive action";
destructiveTitle.textContent = submitter?.dataset?.confirmTitle || form.dataset.confirmTitle || copy.title;
destructiveDescription.textContent = message;
destructiveConfirm.disabled = true;
destructiveConfirm.classList.add("danger");
destructiveConfirm.textContent = "Preparing...";
destructiveModal.classList.add("is-open");
destructiveModal.setAttribute("aria-hidden", "false");
@ -630,7 +722,7 @@
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form)) return;
if (!(form instanceof HTMLFormElement) || !isDestructiveForm(form, event.submitter)) return;
const state = destructiveStates.get(form);
if (state?.confirmed) {
state.confirmed = false;
@ -655,6 +747,7 @@
form.dataset.syntheticConfirmation = "true";
form.dataset.confirmTitle = button.dataset.confirmTitle || "Confirm destructive action";
form.dataset.confirmText = button.dataset.confirmText || "This action cannot be undone.";
form.dataset.confirmLabel = button.dataset.confirmLabel || "Confirm";
document.body.append(form);
issueDestructiveConfirmation(form, null);
}, true);
@ -662,19 +755,115 @@
document.querySelectorAll("[data-destructive-cancel]").forEach((button) => {
button.addEventListener("click", () => {
if (activeDestructive?.form) resetDestructive(activeDestructive.form);
else resetCallbackConfirm(false);
});
});
destructiveModal?.addEventListener("click", (event) => {
if (event.target === destructiveModal && activeDestructive?.form) {
resetDestructive(activeDestructive.form);
} else if (event.target === destructiveModal) {
resetCallbackConfirm(false);
}
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && activeDestructive?.form) {
resetDestructive(activeDestructive.form);
} else if (event.key === "Escape") {
resetCallbackConfirm(false);
}
});
const updateLog = document.querySelector("[data-update-progress-log]");
if (updateLog) {
const appendUpdateLog = (message, level = "info") => {
const row = document.createElement("div");
row.className = `update-progress-entry ${level}`;
row.textContent = message;
updateLog.prepend(row);
};
try {
const source = new EventSource("/admin/updates/events");
[
"update:queued",
"update:checking",
"update:metadata",
"update:snapshot",
"update:recovery_marker",
"update:download",
"update:apply",
"update:verify",
"update:restart_required",
"update:complete",
"update:failed",
"update:revert",
"recovery:plugin_disabled",
"recovery:retry_startup"
].forEach((eventName) => {
source.addEventListener(eventName, (event) => {
const payload = JSON.parse(event.data || "{}");
const target = payload.plugin_id ? `plugin ${payload.plugin_id}` : payload.target || "recovery";
appendUpdateLog(`${eventName.replace("update:", "").replace("recovery:", "recovery ")}: ${target}`, eventName.includes("failed") ? "danger" : "info");
});
});
} catch {
appendUpdateLog("Live update stream is unavailable.", "danger");
}
document.querySelectorAll("form[data-update-action]").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
const submitter = event.submitter || form.querySelector("button[type='submit']");
const confirmed = form.dataset.confirmMode === "modal"
? await window.LumiConfirm?.destructive?.({
title: form.dataset.confirmTitle || "Confirm update action",
text: form.dataset.confirmText || "This update action will change local files.",
label: form.dataset.confirmLabel || submitter?.textContent || "Confirm"
})
: true;
if (!confirmed) {
window.LumiStateButton?.reset?.(submitter);
return;
}
const originalText = submitter?.textContent;
const isStateButton = submitter?.matches?.("[data-lumi-state-button]");
if (isStateButton) {
window.LumiStateButton?.setState?.(submitter, "loading", { busy: true });
} else if (submitter) {
submitter.disabled = true;
submitter.textContent = "Working...";
}
appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`);
try {
const response = await fetch(form.action, {
method: form.method || "POST",
body: new FormData(form),
headers: { Accept: "application/json" }
});
const result = await response.json();
if (!response.ok || result.ok === false) throw new Error(result.error || "Update action failed.");
if (isStateButton) window.LumiStateButton?.success?.(submitter);
else if (submitter) submitter.textContent = "Done";
appendUpdateLog(result.message || "Update action completed.", "success");
if (result.refresh_after_ms) {
appendUpdateLog(`Lumi will refresh in ${Math.round(result.refresh_after_ms / 1000)} seconds.`, "success");
window.setTimeout(() => window.location.reload(), Number(result.refresh_after_ms));
}
} catch (error) {
if (isStateButton) window.LumiStateButton?.error?.(submitter);
else if (submitter) submitter.textContent = "Failed";
appendUpdateLog(error.message, "danger");
} finally {
if (!isStateButton && submitter) {
window.setTimeout(() => {
submitter.disabled = false;
submitter.textContent = originalText;
}, 2500);
}
}
});
});
}
document.querySelectorAll("[data-copy]").forEach((button) => {
button.addEventListener("click", async () => {
const text = button.getAttribute("data-copy") || "";

View File

@ -0,0 +1,82 @@
(() => {
const root = document.querySelector("[data-dashboard-metrics]");
if (!root) return;
const memoryChart = root.querySelector("[data-memory-chart]");
const logChart = root.querySelector("[data-log-chart]");
const status = root.querySelector("[data-metrics-status]");
const history = [];
const bytes = (value) => {
const mb = Number(value || 0) / 1048576;
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
};
const duration = (seconds) => {
const total = Number(seconds || 0);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
};
const setMetric = (name, value) => {
const target = root.querySelector(`[data-metric="${name}"]`);
if (target) target.textContent = value;
};
const line = (values) => {
const max = Math.max(...values, 1);
return values.map((value, index) => {
const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 280 + 10;
const y = 108 - (value / max) * 96;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
};
const drawMemory = () => {
if (!memoryChart) return;
const values = history.map((item) => item.memory.rss);
memoryChart.innerHTML = `<polyline points="${line(values)}" fill="none" stroke="var(--lumi-primary)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></polyline>`;
};
const drawLogs = (logs) => {
if (!logChart) return;
const entries = [["error", logs.error], ["warn", logs.warn], ["info", logs.info], ["debug", logs.debug]];
const max = Math.max(...entries.map(([, value]) => value), 1);
logChart.innerHTML = entries.map(([label, value], index) => {
const height = Math.max(4, (value / max) * 86);
const x = 24 + index * 68;
const y = 100 - height;
return `<rect x="${x}" y="${y}" width="38" height="${height}" rx="6" fill="var(--lumi-${label === "error" ? "danger" : label === "warn" ? "warning" : label === "info" ? "info" : "text-muted"})"></rect><text x="${x + 19}" y="116" text-anchor="middle" fill="var(--lumi-text-muted)" font-size="10">${label}</text>`;
}).join("");
};
const refresh = async () => {
try {
const response = await fetch("/api/admin/dashboard-metrics", { cache: "no-store" });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Metrics unavailable.");
history.push(data);
while (history.length > 24) history.shift();
setMetric("uptime", duration(data.uptime_seconds));
setMetric("rss", bytes(data.memory.rss));
setMetric("heap", `${bytes(data.memory.heap_used)} / ${bytes(data.memory.heap_total)}`);
setMetric("plugins", `${data.plugins.enabled} / ${data.plugins.total}`);
setMetric("users", data.counts.users);
setMetric("commands", data.counts.commands);
if (status) {
status.textContent = "Live";
status.className = "status-indicator status-success";
}
drawMemory();
drawLogs(data.logs);
} catch (error) {
if (status) {
status.textContent = error.message;
status.className = "status-indicator status-danger";
}
}
};
refresh();
window.setInterval(refresh, 10000);
})();

View File

@ -0,0 +1,368 @@
(() => {
const builders = document.querySelectorAll("[data-homepage-builder]");
if (!builders.length) return;
const permissions = ["public", "user", "mod", "admin"];
const heroTypes = [
["static_image", "Static image"],
["custom_embed", "Custom embed"],
["custom_link", "Custom link"],
["youtube_video", "YouTube video"],
["youtube_channel", "YouTube channel"],
["twitch_stream", "Twitch stream"],
["discord_server_overview", "Discord server overview"],
["none", "Fallback message"]
];
const availabilityModes = [
["always", "Always available"],
["live_only", "Only while live"],
["scheduled", "Scheduled/manual"]
];
const autoplayModes = [
["off", "No autoplay"],
["muted", "Autoplay muted"],
["sound", "Autoplay with sound"]
];
const heroTypeLabel = (value) => heroTypes.find(([id]) => id === value)?.[1] || value || "Hero";
const parseRows = (source) => {
try {
const parsed = JSON.parse(source.value || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const field = (label, input, options = {}) => {
const wrapper = document.createElement("label");
wrapper.className = "homepage-builder-field";
if (options.relevance) wrapper.dataset.relevance = options.relevance;
const span = document.createElement("span");
span.textContent = label;
wrapper.append(span, input);
return wrapper;
};
const textInput = (value = "", placeholder = "") => {
const input = document.createElement("input");
input.value = value || "";
input.placeholder = placeholder;
return input;
};
const numberInput = (value = 0, min = 0) => {
const input = document.createElement("input");
input.type = "number";
input.min = String(min);
input.value = Number.isFinite(Number(value)) ? String(value) : String(min);
return input;
};
const selectInput = (value, values) => {
const select = document.createElement("select");
values.forEach((item) => {
const [id, label] = Array.isArray(item) ? item : [item, item];
const option = document.createElement("option");
option.value = id;
option.textContent = label;
option.selected = id === value;
select.append(option);
});
return select;
};
const checkbox = (checked = true) => {
const input = document.createElement("input");
input.type = "checkbox";
input.checked = checked !== false;
return input;
};
const linkDefaults = () => ({
enabled: true,
label: "",
description: "",
url: "",
icon_mode: "favicon",
icon_url: "",
fetched_favicon_url: "",
permission: "public",
sort_order: 0
});
const heroDefaults = () => ({
enabled: true,
type: "static_image",
title: "",
description: "",
priority: 0,
permission: "public",
source_url: "",
image_url: "",
embed_url: "",
video_id: "",
availability_mode: "always",
autoplay_mode: "off",
duration_seconds: 0,
fallback_behavior: "message"
});
const firstLetter = (value) => {
try {
return new URL(value || "").hostname.replace(/^www\./, "").slice(0, 1).toUpperCase() || "L";
} catch {
return "L";
}
};
const confirmation = async (options) => {
if (window.LumiConfirm?.destructive) return window.LumiConfirm.destructive(options);
return window.confirm(options.text);
};
builders.forEach((builder) => {
const kind = builder.dataset.homepageBuilder;
const source = builder.querySelector(".homepage-json-source");
const list = builder.querySelector(`[data-homepage-list="${kind}"]`);
const addButton = document.querySelector(`[data-homepage-add="${kind}"]`);
if (!source || !list) return;
let rows = parseRows(source);
const sync = () => {
const next = Array.from(list.querySelectorAll("[data-homepage-row]")).map((row, index) => {
if (kind === "links") {
return {
enabled: row.querySelector("[data-field='enabled']").checked,
label: row.querySelector("[data-field='label']").value.trim(),
description: row.querySelector("[data-field='description']").value.trim(),
url: row.querySelector("[data-field='url']").value.trim(),
icon_mode: row.querySelector("[data-field='icon_mode']").value,
icon_url: row.querySelector("[data-field='icon_url']").value.trim(),
fetched_favicon_url: row.querySelector("[data-field='fetched_favicon_url']").value.trim(),
permission: row.querySelector("[data-field='permission']").value,
sort_order: Number(row.querySelector("[data-field='sort_order']").value) || index
};
}
return {
enabled: row.querySelector("[data-field='enabled']").checked,
type: row.querySelector("[data-field='type']").value,
title: row.querySelector("[data-field='title']").value.trim(),
description: row.querySelector("[data-field='description']").value.trim(),
priority: Number(row.querySelector("[data-field='priority']").value) || index,
permission: row.querySelector("[data-field='permission']").value,
source_url: row.querySelector("[data-field='source_url']").value.trim(),
image_url: row.querySelector("[data-field='image_url']").value.trim(),
embed_url: row.querySelector("[data-field='embed_url']").value.trim(),
video_id: row.querySelector("[data-field='video_id']").value.trim(),
availability_mode: row.querySelector("[data-field='availability_mode']").value,
autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value,
duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0,
fallback_behavior: row.querySelector("[data-field='fallback_behavior']").value
};
});
rows = next;
source.value = JSON.stringify(next, null, 2);
source.dispatchEvent(new Event("input", { bubbles: true }));
renderPreviews();
};
const addField = (row, labelText, element, name, options = {}) => {
element.dataset.field = name;
row.append(field(labelText, element, options));
return element;
};
const updateHeroRelevance = (row) => {
if (kind !== "heroes") return;
const type = row.querySelector("[data-field='type']")?.value || "static_image";
const videoLike = ["youtube_video", "youtube_channel", "twitch_stream"].includes(type);
const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
const source = ["custom_link", "static_image", "custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
const image = type === "static_image";
const fallback = type === "none";
row.querySelectorAll("[data-relevance]").forEach((item) => {
const relevance = item.dataset.relevance;
const visible =
relevance === "video" ? videoLike :
relevance === "embed" ? embedded :
relevance === "source" ? source :
relevance === "image" ? image :
relevance === "fallback" ? fallback :
true;
item.hidden = !visible;
});
};
const updateLinkRelevance = (row) => {
if (kind !== "links") return;
const mode = row.querySelector("[data-field='icon_mode']")?.value || "favicon";
row.querySelectorAll("[data-relevance]").forEach((item) => {
const relevance = item.dataset.relevance;
item.hidden = !(
(relevance === "manual-icon" && mode === "manual") ||
(relevance === "fetched-icon" && mode === "favicon")
);
});
};
const renderPreviews = () => {
list.querySelectorAll("[data-homepage-row]").forEach((row) => {
const preview = row.querySelector("[data-homepage-preview]");
if (!preview) return;
if (kind === "links") {
const label = row.querySelector("[data-field='label']").value.trim() || "Homepage link";
const description = row.querySelector("[data-field='description']").value.trim() || "Open external link";
const url = row.querySelector("[data-field='url']").value.trim();
const iconMode = row.querySelector("[data-field='icon_mode']").value;
const iconUrl =
iconMode === "manual" ? row.querySelector("[data-field='icon_url']").value.trim() :
iconMode === "favicon" ? row.querySelector("[data-field='fetched_favicon_url']").value.trim() :
"";
preview.innerHTML = `
<span class="homepage-preview-icon">${iconUrl ? `<img src="${escapeHtml(iconUrl)}" alt="">` : escapeHtml(firstLetter(url))}</span>
<span><strong>${escapeHtml(label)}</strong><small>${escapeHtml(description)}</small></span>
`;
return;
}
const type = row.querySelector("[data-field='type']").value;
const title = row.querySelector("[data-field='title']").value.trim() || heroTypeLabel(type);
const description = row.querySelector("[data-field='description']").value.trim() || "Hero preview";
preview.innerHTML = `
<span class="eyebrow">${escapeHtml(heroTypeLabel(type))}</span>
<strong>${escapeHtml(title)}</strong>
<small>${escapeHtml(description)}</small>
`;
});
};
const moveRow = (from, direction) => {
const to = from + direction;
if (to < 0 || to >= rows.length) return;
const [item] = rows.splice(from, 1);
rows.splice(to, 0, item);
rows.forEach((row, index) => {
if (kind === "links") row.sort_order = index;
else row.priority = index;
});
render();
};
const render = () => {
list.replaceChildren();
rows.forEach((item, index) => {
const row = document.createElement("article");
row.className = "homepage-builder-row";
row.dataset.homepageRow = "";
const header = document.createElement("div");
header.className = "homepage-builder-row-header";
const title = document.createElement("strong");
title.textContent = item.label || item.title || `${kind === "links" ? "Link" : "Hero"} ${index + 1}`;
const enabled = checkbox(item.enabled);
enabled.dataset.field = "enabled";
const enabledLabel = field("Enabled", enabled);
header.append(title, enabledLabel);
row.append(header);
if (kind === "links") {
addField(row, "Label", textInput(item.label, "Commands"), "label");
addField(row, "Description", textInput(item.description, "Open command list"), "description");
addField(row, "URL", textInput(item.url, "/commands"), "url");
const iconMode = addField(row, "Icon mode", selectInput(item.icon_mode || "favicon", [["favicon", "Fetched favicon/logo"], ["manual", "Manual icon URL"], ["letter", "Fallback letter"]]), "icon_mode");
addField(row, "Manual icon/logo URL", textInput(item.icon_url, "/assets/icon.svg"), "icon_url", { relevance: "manual-icon" });
addField(row, "Fetched favicon/logo preview", textInput(item.fetched_favicon_url, "https://example.com/favicon.ico"), "fetched_favicon_url", { relevance: "fetched-icon" });
addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission");
addField(row, "Sort order", numberInput(item.sort_order ?? index, 0), "sort_order");
iconMode.addEventListener("change", () => updateLinkRelevance(row));
} else {
const typeSelect = addField(row, "Type", selectInput(item.type || "static_image", heroTypes), "type");
addField(row, "Title", textInput(item.title, "Featured stream"), "title");
addField(row, "Description", textInput(item.description, "What's happening now"), "description");
addField(row, "Priority/order", numberInput(item.priority ?? index, 0), "priority");
addField(row, "Permission", selectInput(item.permission || "public", permissions), "permission");
addField(row, "Source URL or platform ID", textInput(item.source_url, "https://..."), "source_url", { relevance: "source" });
addField(row, "Image URL", textInput(item.image_url, "https://.../image.png"), "image_url", { relevance: "image" });
addField(row, "Embed URL", textInput(item.embed_url, "https://.../embed"), "embed_url", { relevance: "embed" });
addField(row, "Video ID", textInput(item.video_id, "Optional platform ID"), "video_id", { relevance: "video" });
addField(row, "Availability mode", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video" });
addField(row, "Autoplay mode", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" });
addField(row, "Duration timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { relevance: "video" });
addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" });
typeSelect.addEventListener("change", () => updateHeroRelevance(row));
}
const preview = document.createElement("div");
preview.className = kind === "links" ? "homepage-link-button homepage-builder-preview" : "homepage-builder-preview hero";
preview.dataset.homepagePreview = "";
row.append(preview);
const actions = document.createElement("div");
actions.className = "homepage-builder-actions";
const up = document.createElement("button");
up.type = "button";
up.className = "button subtle";
up.textContent = "Move up";
up.disabled = index === 0;
up.addEventListener("click", () => moveRow(index, -1));
const down = document.createElement("button");
down.type = "button";
down.className = "button subtle";
down.textContent = "Move down";
down.disabled = index === rows.length - 1;
down.addEventListener("click", () => moveRow(index, 1));
const duplicate = document.createElement("button");
duplicate.type = "button";
duplicate.className = "button subtle";
duplicate.textContent = "Duplicate";
duplicate.addEventListener("click", () => {
rows.splice(index + 1, 0, { ...rows[index] });
render();
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "button danger";
remove.textContent = "Remove";
remove.addEventListener("click", async () => {
const name = item.label || item.title || `${kind === "links" ? "link" : "hero"} ${index + 1}`;
const confirmed = await confirmation({
title: kind === "links" ? "Remove homepage link" : "Remove homepage hero",
text: `Remove ${name}? This only becomes permanent after you save settings.`,
label: "Remove"
});
if (!confirmed) return;
rows.splice(index, 1);
render();
});
actions.append(up, down, duplicate, remove);
row.append(actions);
row.addEventListener("input", sync);
row.addEventListener("change", () => {
updateLinkRelevance(row);
updateHeroRelevance(row);
sync();
});
list.append(row);
updateLinkRelevance(row);
updateHeroRelevance(row);
});
sync();
};
addButton?.addEventListener("click", () => {
rows.push(kind === "links" ? linkDefaults() : heroDefaults());
render();
});
render();
});
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
})();

File diff suppressed because it is too large Load Diff

View 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();
});
})();

View File

@ -0,0 +1,275 @@
html {
min-width: 320px;
background: var(--bg-2);
scroll-behavior: smooth;
}
body {
font-family: var(--lumi-font-body);
font-size: var(--lumi-font-size-base);
line-height: 1.55;
background:
radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem),
radial-gradient(circle at 100% 100%, var(--bg-3) 0, transparent 38rem),
var(--bg-2);
background-attachment: fixed;
}
.app-shell {
grid-template-columns: 17rem minmax(0, 1fr);
}
.sidebar {
width: 17rem;
gap: var(--lumi-space-4);
padding: var(--lumi-space-4);
background: color-mix(in srgb, var(--lumi-surface) 94%, transparent);
border-color: var(--lumi-border);
box-shadow: var(--lumi-shadow-sm);
backdrop-filter: blur(18px);
z-index: 30;
}
.sidebar-brand {
gap: var(--lumi-space-2);
}
.brand-link {
min-height: 3rem;
padding: var(--lumi-space-2);
}
.sidebar-nav {
gap: var(--lumi-space-2);
scrollbar-width: thin;
scrollbar-color: var(--lumi-border) transparent;
}
.nav-section {
padding: var(--lumi-space-2);
border-radius: var(--lumi-radius-md);
background: transparent;
}
.nav-section[open] {
background: var(--lumi-surface-subtle);
}
.nav-section summary {
min-height: 2.5rem;
padding: var(--lumi-space-2);
border-radius: var(--lumi-radius-sm);
}
.nav-section summary:hover {
background: var(--lumi-surface-raised);
}
.nav-links {
gap: var(--lumi-space-1);
padding: var(--lumi-space-2) 0 0 1.75rem;
}
.nav-link {
min-height: 2.5rem;
padding: var(--lumi-space-2) var(--lumi-space-3);
border: 1px solid transparent;
border-radius: var(--lumi-radius-sm);
}
.nav-link:hover {
border-color: var(--lumi-border);
}
.nav-link.active {
background: color-mix(in srgb, var(--lumi-primary) 14%, var(--lumi-surface));
border-color: color-mix(in srgb, var(--lumi-primary) 28%, var(--lumi-border));
color: var(--lumi-text);
}
.page {
min-width: 0;
}
.content {
width: 100%;
max-width: var(--lumi-content-max);
margin: 0 auto;
padding: clamp(1rem, 2.5vw, 2.5rem);
gap: var(--lumi-space-5);
}
.content > * {
min-width: 0;
}
.site-footer {
width: 100%;
max-width: var(--lumi-content-max);
margin: auto auto 0;
padding: var(--lumi-space-5) clamp(1rem, 2.5vw, 2.5rem);
font-size: 0.875rem;
}
.grid,
.lumi-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
gap: var(--lumi-space-4);
}
.lumi-stack {
display: flex;
flex-direction: column;
gap: var(--lumi-space-4);
}
.lumi-cluster,
.button-group,
.page-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--lumi-space-2);
}
.lumi-split {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--lumi-space-4);
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--lumi-space-4);
}
.page-header h1,
.section-header h1,
.section-header h2 {
margin-bottom: 0;
}
.sidebar-scrim {
position: fixed;
inset: 0;
display: none;
background: rgba(5, 10, 12, 0.52);
backdrop-filter: blur(2px);
z-index: 25;
}
.standalone-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: var(--lumi-space-4);
}
.standalone-card {
width: min(100%, 45rem);
}
.standalone-detail {
margin-top: var(--lumi-space-4);
}
@media (min-width: 901px) {
body.sidebar-collapsed .app-shell {
grid-template-columns: 5.5rem minmax(0, 1fr);
}
body.sidebar-collapsed .sidebar {
width: 5.5rem;
}
}
@media (max-width: 1100px) and (min-width: 901px) {
.app-shell {
grid-template-columns: 14rem minmax(0, 1fr);
}
.sidebar {
width: 14rem;
}
}
@media (max-width: 900px) {
body.sidebar-collapsed .app-shell,
.app-shell {
grid-template-columns: minmax(0, 1fr);
}
body.sidebar-collapsed .sidebar,
.sidebar {
width: min(19rem, 88vw);
padding-top: var(--lumi-space-3);
}
body.sidebar-open {
overflow: hidden;
}
body.sidebar-open .sidebar-scrim {
display: block;
}
.mobile-topbar {
min-height: 4rem;
padding: var(--lumi-space-3) var(--lumi-space-4);
background: color-mix(in srgb, var(--lumi-surface) 92%, transparent);
backdrop-filter: blur(18px);
}
.content {
padding: var(--lumi-space-4);
}
}
@media (max-width: 600px) {
body {
font-size: calc(var(--lumi-font-size-base) * 0.94);
}
.mobile-topbar {
min-height: 3.5rem;
padding: var(--lumi-space-2) var(--lumi-space-3);
}
.content {
gap: var(--lumi-space-3);
padding: max(0.75rem, env(safe-area-inset-top)) max(0.75rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
}
.site-footer {
padding: var(--lumi-space-5) var(--lumi-space-4);
}
.section-header,
.commands-header,
.stats-header,
.lumi-split {
align-items: stretch;
flex-direction: column;
}
.section-header > *,
.commands-header > *,
.stats-header > * {
width: 100%;
}
.button-group,
.page-actions {
align-items: stretch;
}
.button-group .button,
.page-actions .button {
flex: 1 1 auto;
}
}

View File

@ -0,0 +1,64 @@
(() => {
const buttons = new Set();
const getViews = (button) => Array.from(button.querySelectorAll("[data-state-view]"));
const setState = (button, state, options = {}) => {
if (!button) return;
const nextState = state || button.dataset.defaultState || "idle";
const busyState = button.dataset.loadingState || "loading";
const isBusy = nextState === busyState || options.busy === true;
button.dataset.state = nextState;
button.setAttribute("aria-busy", isBusy ? "true" : "false");
getViews(button).forEach((view) => {
const isVisible = view.dataset.stateView === nextState;
view.dataset.stateHidden = isVisible ? "false" : "true";
view.setAttribute("aria-hidden", isVisible ? "false" : "true");
});
if (button.dataset.disableWhileBusy === "true") {
button.disabled = isBusy;
}
};
const reset = (button) => {
setState(button, button?.dataset.defaultState || "idle");
};
const scheduleReset = (button) => {
const delay = Number(button?.dataset.resetDelay || 0);
if (delay > 0) window.setTimeout(() => reset(button), delay);
};
const initButton = (button) => {
if (buttons.has(button)) return;
buttons.add(button);
button.classList.add("lumi-state-btn");
setState(button, button.dataset.state || button.dataset.defaultState || "idle");
};
document.querySelectorAll("[data-lumi-state-button]").forEach(initButton);
document.addEventListener("submit", (event) => {
const submitter = event.submitter;
if (!submitter?.matches?.("[data-lumi-state-button]")) return;
initButton(submitter);
setState(submitter, submitter.dataset.loadingState || "loading", { busy: true });
}, true);
window.LumiStateButton = {
init: initButton,
setState,
reset,
success(button) {
setState(button, button?.dataset.successState || "success");
scheduleReset(button);
},
error(button) {
setState(button, button?.dataset.errorState || "error");
scheduleReset(button);
}
};
})();

View File

@ -0,0 +1,81 @@
:root {
color-scheme: light dark;
--lumi-font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
--lumi-font-display: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
--lumi-font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
--lumi-font-size-base: 16px;
--lumi-heading-scale: 1;
--lumi-control-scale: 1;
--lumi-text: var(--ink, #182026);
--lumi-text-muted: var(--ink-soft, #5a6872);
--lumi-primary: var(--sea, #176b75);
--lumi-accent: var(--sun, #e58b2b);
--lumi-danger: var(--rose, #bd4d4d);
--lumi-success: #23845b;
--lumi-warning: #a96612;
--lumi-info: #3479a8;
--lumi-link: var(--lumi-primary);
--lumi-surface: var(--card, #ffffff);
--lumi-surface-subtle: var(--surface-2, #f4f7f8);
--lumi-surface-raised: var(--surface-3, #edf2f3);
--lumi-border: var(--border, #d8e0e3);
--lumi-input-bg: var(--lumi-surface);
--lumi-input-border: var(--lumi-border);
--lumi-input-text: var(--lumi-text);
--lumi-button-bg: var(--lumi-primary);
--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));
--lumi-space-2: calc(0.5rem * var(--lumi-space-scale));
--lumi-space-3: calc(0.75rem * var(--lumi-space-scale));
--lumi-space-4: calc(1rem * var(--lumi-space-scale));
--lumi-space-5: calc(1.5rem * var(--lumi-space-scale));
--lumi-space-6: calc(2rem * var(--lumi-space-scale));
--lumi-space-7: calc(3rem * var(--lumi-space-scale));
--lumi-radius-sm: calc(var(--lumi-radius, 14px) * 0.58);
--lumi-radius-md: var(--lumi-radius, 14px);
--lumi-radius-lg: calc(var(--lumi-radius, 14px) * 1.42);
--lumi-radius-pill: 999px;
--lumi-shadow-sm: 0 1px 2px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 0.7));
--lumi-shadow-md: 0 12px 34px rgba(11, 20, 24, var(--lumi-shadow-strength, 0.14));
--lumi-shadow-lg: 0 22px 60px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 1.15));
--lumi-transition: 150ms ease;
--lumi-control-height: calc(2.75rem * var(--lumi-control-scale));
--lumi-content-max: 1600px;
/* Compatibility aliases for existing core and plugin styles. */
--text: var(--lumi-text);
--muted: var(--lumi-text-muted);
--primary: var(--lumi-primary);
--accent: var(--lumi-accent);
--danger: var(--lumi-danger);
--success: var(--lumi-success);
--warning: var(--lumi-warning);
--info: var(--lumi-info);
--panel: var(--lumi-surface);
--panel-2: var(--lumi-surface-subtle);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@ -0,0 +1,539 @@
.theme-library {
display: grid;
gap: var(--lumi-space-4);
}
.theme-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--lumi-space-4);
}
.theme-card {
display: flex;
flex-direction: column;
gap: var(--lumi-space-3);
padding: var(--lumi-space-4);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface);
box-shadow: var(--lumi-shadow-sm);
}
.theme-card.is-active {
border-color: var(--lumi-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-primary) 14%, transparent);
}
.theme-swatch {
height: 7rem;
display: grid;
grid-template-columns: 1fr 2fr;
grid-template-rows: 1fr 1fr;
gap: var(--lumi-space-2);
padding: var(--lumi-space-3);
border: 1px solid color-mix(in srgb, var(--swatch-primary) 25%, transparent);
border-radius: var(--lumi-radius-sm);
background: var(--swatch-bg);
}
.theme-swatch span {
border-radius: calc(var(--lumi-radius-sm) * 0.7);
background: var(--swatch-surface);
box-shadow: var(--lumi-shadow-sm);
}
.theme-swatch span:first-child {
grid-row: 1 / -1;
background: var(--swatch-primary);
}
.theme-swatch span:last-child {
width: 58%;
background: var(--swatch-accent);
}
.theme-card-copy {
flex: 1;
}
.theme-card-copy p {
margin: var(--lumi-space-2) 0 0;
color: var(--lumi-text-muted);
}
.theme-card-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--lumi-space-2);
}
.theme-card-title h3 {
margin-bottom: 0;
}
.theme-kind {
flex: 0 0 auto;
padding: 0.25rem 0.45rem;
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-pill);
color: var(--lumi-text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.theme-card-actions,
.theme-card-more-body {
display: flex;
flex-wrap: wrap;
gap: var(--lumi-space-2);
}
.theme-card-actions form {
display: inline-flex;
}
.theme-card-more {
border-top: 1px solid var(--lumi-border);
padding-top: var(--lumi-space-3);
}
.theme-card-more summary {
cursor: pointer;
color: var(--lumi-link);
font-weight: 700;
}
.theme-card-more-body {
align-items: flex-end;
margin-top: var(--lumi-space-3);
}
.compact-form {
flex: 1 1 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: var(--lumi-space-2);
}
.compact-form label {
display: grid;
gap: var(--lumi-space-1);
font-weight: 700;
}
.theme-editor-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 25rem);
align-items: start;
gap: var(--lumi-space-5);
scroll-margin-top: var(--lumi-space-4);
}
.theme-editor-main {
display: grid;
gap: var(--lumi-space-5);
}
.theme-edit-form {
display: grid;
gap: var(--lumi-space-5);
}
.theme-fieldset {
min-width: 0;
margin: 0;
padding: var(--lumi-space-4);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface-subtle);
}
.theme-fieldset legend {
padding: 0 var(--lumi-space-2);
font: 700 1.05rem/1 var(--lumi-font-display);
}
.theme-control-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--lumi-space-3);
}
.theme-color-control,
.theme-select-control,
.theme-range-control {
display: grid;
gap: var(--lumi-space-2);
font-weight: 700;
}
.theme-color-input {
min-height: var(--lumi-control-height);
display: flex;
align-items: center;
gap: var(--lumi-space-2);
padding: 0.35rem;
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-sm);
background: var(--lumi-surface);
}
.theme-color-input input {
width: 3.2rem;
height: 2.2rem;
padding: 0;
border: 0;
}
.theme-color-input output {
color: var(--lumi-text-muted);
font: 600 0.8rem/1 var(--lumi-font-mono);
}
.theme-range-control {
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-sm);
background: var(--lumi-surface);
}
.theme-range-control > span {
display: flex;
justify-content: space-between;
gap: var(--lumi-space-2);
}
.theme-range-control output {
color: var(--lumi-text-muted);
}
.theme-select-control {
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-sm);
background: var(--lumi-surface);
}
.theme-inline-actions {
display: flex;
flex-wrap: wrap;
gap: var(--lumi-space-2);
margin-top: var(--lumi-space-4);
}
.theme-validation-panel {
display: grid;
gap: var(--lumi-space-2);
}
.theme-validation-panel:empty {
display: none;
}
.theme-warning {
padding: var(--lumi-space-2) var(--lumi-space-3);
border: 1px solid color-mix(in srgb, var(--lumi-warning) 40%, var(--lumi-border));
border-radius: var(--lumi-radius-sm);
background: color-mix(in srgb, var(--lumi-warning) 11%, var(--lumi-surface));
color: var(--lumi-text);
font-size: 0.9rem;
}
.advanced-theme-controls {
margin-top: var(--lumi-space-4);
}
.advanced-theme-controls summary {
cursor: pointer;
color: var(--lumi-link);
font-weight: 700;
}
.advanced-theme-controls[open] summary {
margin-bottom: var(--lumi-space-3);
}
.theme-editor-actions {
position: sticky;
bottom: var(--lumi-space-3);
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--lumi-space-3);
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: color-mix(in srgb, var(--lumi-surface) 92%, transparent);
box-shadow: var(--lumi-shadow-md);
backdrop-filter: blur(16px);
}
.theme-preview {
position: sticky;
top: var(--lumi-space-4);
display: grid;
gap: var(--lumi-space-3);
}
.theme-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--lumi-space-2);
}
.theme-preview-header .eyebrow {
margin-bottom: 0;
}
.theme-popout-button {
min-height: 2.25rem;
padding: 0.45rem 0.7rem;
font-size: 0.8rem;
}
.theme-mobile-preview-action {
display: none;
}
.theme-preview-window {
min-height: 26rem;
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background:
radial-gradient(circle at 20% 10%, var(--bg-1), transparent 55%),
var(--bg-2);
}
.theme-preview-nav {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--lumi-space-3);
padding: var(--lumi-space-3);
border-right: 1px solid var(--lumi-border);
background: var(--lumi-surface);
}
.theme-preview-nav > span:not(.theme-preview-logo) {
width: 1.8rem;
height: 0.38rem;
border-radius: var(--lumi-radius-pill);
background: var(--lumi-border);
}
.theme-preview-nav > span.is-active {
width: 2.35rem;
background: var(--lumi-primary);
}
.theme-preview-logo {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border-radius: var(--lumi-radius-sm);
background: var(--lumi-primary);
color: var(--lumi-button-text);
font-weight: 800;
}
.theme-preview-content {
display: flex;
flex-direction: column;
gap: var(--lumi-space-3);
padding: var(--lumi-space-4);
}
.theme-preview-content h2 {
margin-bottom: 0;
font-size: calc(1.25rem * var(--lumi-heading-scale));
}
.theme-preview-content p {
margin: 0;
}
.theme-preview-pill {
width: max-content;
padding: calc(var(--lumi-space-1) * 0.85) var(--lumi-space-2);
border: 1px solid color-mix(in srgb, var(--lumi-primary) 30%, var(--lumi-border));
border-radius: var(--lumi-radius-pill);
background: color-mix(in srgb, var(--lumi-primary) 10%, var(--lumi-surface));
}
.theme-preview-sample-card {
margin-top: var(--lumi-space-2);
padding: var(--lumi-space-4);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface);
box-shadow: var(--lumi-shadow-md);
}
.theme-preview-sample-card p {
color: var(--lumi-text-muted);
}
.theme-preview-sample-card input {
margin-top: var(--lumi-space-3);
}
.theme-preview-sample-card .switch,
.theme-preview-badges,
.theme-preview-modal-sample,
.theme-spacing-sample {
margin-top: var(--lumi-space-3);
}
.theme-preview-statuses {
display: flex;
flex-wrap: wrap;
gap: var(--lumi-space-3);
margin-top: var(--lumi-space-3);
font-size: 0.8rem;
font-weight: 700;
}
.theme-preview-badges {
display: flex;
flex-wrap: wrap;
gap: var(--lumi-space-2);
}
.theme-preview-badges .badge,
.theme-preview-badges .pill {
padding: 0.25rem 0.55rem;
border: 1px solid var(--lumi-border);
background: var(--lumi-surface-subtle);
}
.theme-preview-dirty {
padding: var(--lumi-space-2);
border-radius: var(--lumi-radius-sm);
font-size: 0.85rem;
font-weight: 700;
}
.theme-preview-table {
margin-top: var(--lumi-space-3);
}
.theme-preview-table .table {
min-width: 16rem;
font-size: 0.78rem;
}
.theme-preview-table th,
.theme-preview-table td {
padding: 0.45rem;
}
.theme-preview-modal-sample {
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface-subtle);
box-shadow: var(--lumi-shadow-sm);
}
.theme-spacing-sample {
display: flex;
align-items: center;
gap: var(--lumi-space-2);
}
.theme-spacing-sample span {
width: var(--lumi-space-4);
height: var(--lumi-space-4);
border-radius: var(--lumi-radius-sm);
background: var(--lumi-primary);
}
.theme-spacing-sample span:nth-child(2) {
width: var(--lumi-space-5);
height: var(--lumi-space-5);
background: var(--lumi-accent);
}
.theme-spacing-sample span:nth-child(3) {
width: var(--lumi-space-6);
height: var(--lumi-space-6);
background: var(--lumi-info);
}
.is-selected {
border-color: var(--lumi-primary) !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important;
}
@media (max-width: 1100px) {
.theme-editor-shell {
grid-template-columns: 1fr;
}
.theme-preview {
position: static;
order: -1;
}
.theme-preview-window {
min-height: 20rem;
}
}
@media (max-width: 600px) {
.theme-library,
.theme-editor-main,
.theme-edit-form,
.theme-editor-shell {
gap: var(--lumi-space-3);
}
.theme-card-grid,
.theme-control-grid {
grid-template-columns: minmax(0, 1fr);
gap: var(--lumi-space-2);
}
.theme-card,
.theme-fieldset,
.theme-range-control,
.theme-select-control {
padding: var(--lumi-space-3);
}
.theme-swatch {
height: 5.5rem;
}
.theme-mobile-preview-action {
display: inline-flex;
}
.theme-preview {
display: none;
}
.compact-form {
grid-template-columns: 1fr;
}
.theme-editor-actions {
align-items: stretch;
flex-direction: column;
}
.theme-editor-actions .button-group,
.theme-editor-actions .button {
width: 100%;
}
}

View File

@ -0,0 +1,270 @@
(() => {
const editor = document.querySelector("[data-theme-editor]");
const form = editor?.querySelector("[data-theme-form]");
if (!editor || !form) return;
const previewRoots = () => Array.from(editor.querySelectorAll("[data-theme-preview-root]"));
const tokenVariables = {
bg1: "--bg-1",
bg2: "--bg-2",
bg3: "--bg-3",
text: "--ink",
muted: "--ink-soft",
accent: "--sea",
accentAlt: "--sun",
success: "--lumi-success",
warning: "--lumi-warning",
danger: "--rose",
info: "--lumi-info",
surface: "--card",
surface2: "--surface-2",
surface3: "--surface-3",
border: "--border",
link: "--lumi-link",
buttonBg: "--lumi-button-bg",
buttonText: "--lumi-button-text",
buttonHover: "--lumi-button-hover",
inputBg: "--lumi-input-bg",
inputBorder: "--lumi-input-border",
inputText: "--lumi-input-text",
focusRing: "--lumi-focus"
};
const metricVariables = {
radius: ["--lumi-radius", "px"],
shadowStrength: ["--lumi-shadow-strength", ""],
spacingScale: ["--lumi-space-scale", ""]
};
const typographyVariables = {
body: "--lumi-font-body",
display: "--lumi-font-display",
mono: "--lumi-font-mono",
baseSize: ["--lumi-font-size-base", "px"],
headingScale: ["--lumi-heading-scale", ""],
controlScale: ["--lumi-control-scale", ""]
};
let popout = null;
let previewMode = "light";
const updateOutputs = () => {
form.querySelectorAll('input[type="color"]').forEach((input) => {
const output = input.closest("label")?.querySelector("output");
if (output) output.value = input.value.toUpperCase();
});
form.querySelectorAll("[data-theme-metric]").forEach((input) => {
const output = input.closest("label")?.querySelector("[data-range-output]");
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
});
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
const output = input.closest("label")?.querySelector("[data-range-output]");
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
});
};
const buildPreviewVariables = () => {
const variables = [];
form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => {
const variable = tokenVariables[input.dataset.themeToken];
if (variable) variables.push([variable, input.value]);
});
form.querySelectorAll("[data-theme-role]").forEach((input) => {
variables.push([`--role-${input.dataset.themeRole}`, input.value]);
});
form.querySelectorAll("[data-theme-metric]").forEach((input) => {
const config = metricVariables[input.dataset.themeMetric];
if (config) variables.push([config[0], `${input.value}${config[1]}`]);
});
form.querySelectorAll("[data-theme-font]").forEach((select) => {
const variable = typographyVariables[select.dataset.themeFont];
const stack = select.selectedOptions[0]?.dataset.fontStack;
if (variable && stack) variables.push([variable, stack]);
});
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
const config = typographyVariables[input.dataset.themeTypography];
if (config) variables.push([config[0], `${input.value}${config[1]}`]);
});
return variables;
};
const applyVariables = (target, variables) => {
if (!target) return;
target.dataset.colorScheme = previewMode;
variables.forEach(([name, value]) => {
target.style.setProperty(name, value);
});
};
const applyPreview = () => {
const variables = buildPreviewVariables();
previewRoots().forEach((target) => applyVariables(target, variables));
updateOutputs();
updateWarnings();
syncPopout(variables);
};
const parseHex = (value) => {
const hex = String(value || "").replace("#", "");
return [0, 2, 4].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
};
const luminance = (value) => {
const channels = parseHex(value).map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
};
const contrastRatio = (left, right) => {
const a = luminance(left);
const b = luminance(right);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
};
const getModeValue = (token) =>
form.querySelector(`[data-theme-mode="${previewMode}"][data-theme-token="${token}"]`)?.value;
const updateWarnings = () => {
const panel = editor.querySelector("[data-theme-warnings]");
if (!panel) return;
const checks = [
["Text on surface", getModeValue("text"), getModeValue("surface")],
["Button text", getModeValue("buttonText"), getModeValue("buttonBg")],
["Input text", getModeValue("inputText"), getModeValue("inputBg")]
];
const warnings = checks
.map(([label, foreground, background]) => [label, contrastRatio(foreground, background)])
.filter(([, ratio]) => Number.isFinite(ratio) && ratio < 4.5);
panel.replaceChildren(
...warnings.map(([label, ratio]) => {
const item = document.createElement("div");
item.className = "theme-warning";
item.textContent = `${label} contrast is ${ratio.toFixed(2)}:1. Save will reject values below 4.5:1.`;
return item;
})
);
};
const syncPopout = (variables = buildPreviewVariables()) => {
if (!popout || popout.closed) return;
popout.document.documentElement.dataset.colorScheme = previewMode;
variables.forEach(([name, value]) => {
popout.document.documentElement.style.setProperty(name, value);
});
};
const previewShell = () => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumi Theme Preview</title>
<link rel="stylesheet" href="/lumi-tokens.css">
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/lumi-layout.css">
<link rel="stylesheet" href="/lumi-components.css">
<link rel="stylesheet" href="/theme-editor.css">
<style>
body{margin:0;background:radial-gradient(circle at 16% 8%,var(--bg-1),transparent 38%),var(--bg-2);font-family:var(--lumi-font-body);font-size:var(--lumi-font-size-base);color:var(--lumi-text)}
.preview-app{display:grid;grid-template-columns:15rem minmax(0,1fr);min-height:100vh}
.preview-sidebar{padding:var(--lumi-space-4);border-right:1px solid var(--lumi-border);background:var(--lumi-surface)}
.preview-brand{display:flex;align-items:center;gap:var(--lumi-space-2);font:800 1rem var(--lumi-font-display)}
.preview-logo{width:2.4rem;height:2.4rem;display:grid;place-items:center;border-radius:var(--lumi-radius-sm);background:var(--lumi-primary);color:var(--lumi-button-text)}
.preview-nav{display:grid;gap:var(--lumi-space-2);margin-top:var(--lumi-space-5)}
.preview-nav a{padding:var(--lumi-space-2);border-radius:var(--lumi-radius-sm);color:var(--lumi-text);text-decoration:none}
.preview-nav a.active,.preview-nav a:hover{background:var(--lumi-surface-subtle);box-shadow:var(--lumi-shadow-sm)}
.preview-page{display:grid;gap:var(--lumi-space-4);align-content:start;padding:var(--lumi-space-5)}
.preview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-4)}
.preview-metric{display:grid;gap:var(--lumi-space-1)}
.preview-modal-sample{position:relative;min-height:9rem;display:grid;place-items:center;border:1px dashed var(--lumi-border);border-radius:var(--lumi-radius-md);background:color-mix(in srgb,var(--lumi-surface-subtle) 80%,transparent)}
.preview-modal-card{width:min(20rem,90%);padding:var(--lumi-space-4);border-radius:var(--lumi-radius-md);background:var(--lumi-surface);box-shadow:var(--lumi-shadow-lg)}
@media (max-width:760px){.preview-app{grid-template-columns:1fr}.preview-sidebar{position:static;border-right:0;border-bottom:1px solid var(--lumi-border)}.preview-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="preview-app">
<aside class="preview-sidebar">
<div class="preview-brand"><span class="preview-logo">L</span><span>Lumi Bot</span></div>
<nav class="preview-nav" aria-label="Preview navigation">
<a class="active" href="#dashboard">Dashboard</a>
<a href="#settings">Settings</a>
<a href="#logs">Logs</a>
<a href="#ai">Lumi AI</a>
</nav>
</aside>
<main class="preview-page">
<section class="hero">
<span class="eyebrow">Faithful preview</span>
<h1>Community control center</h1>
<p>Draft theme values are isolated to this preview until you save and apply the theme.</p>
<div class="button-group"><button class="button">Primary action</button><button class="button subtle">Secondary</button></div>
</section>
<section class="preview-grid">
<article class="card preview-metric"><span class="hint">Messages</span><strong>12,480</strong><span class="status-success">Healthy stream</span></article>
<article class="card preview-metric"><span class="hint">Warnings</span><strong>3</strong><span class="status-warning">Needs review</span></article>
<article class="card">
<h2>Settings panel</h2>
<label>Channel title<input value="Cozy Carnage" readonly></label>
<label class="switch"><input class="switch-input" type="checkbox" checked><span class="switch-track"></span><span class="switch-text">Enabled toggle</span></label>
<div class="is-unsaved" style="padding:var(--lumi-space-2);border-radius:var(--lumi-radius-sm)">Unsaved marker sample</div>
</article>
<article class="card">
<h2>Logs table</h2>
<div class="table-wrap"><table class="table"><thead><tr><th>Level</th><th>Message</th></tr></thead><tbody><tr><td><span class="status-danger">error</span></td><td>Webhook retry failed</td></tr><tr><td><span class="status-success">info</span></td><td>Command synced</td></tr></tbody></table></div>
</article>
<article class="card">
<h2>State button</h2>
<button class="button lumi-state-btn"><span class="lumi-state-btn-content"><span data-state-view="idle">Download</span><span data-state-view="loading" data-state-hidden="true"><span class="lumi-state-btn-spinner"></span>Downloading 64%</span><span data-state-view="success" data-state-hidden="true">Downloaded</span></span></button>
<p><span class="badge">Badge</span> <span class="pill">Preview pill</span></p>
</article>
<article class="card preview-modal-sample">
<div class="preview-modal-card"><h3>Modal sample</h3><p class="hint">Confirmation panels inherit theme surface, shadow, radius, and button tokens.</p><button class="button danger">Delete sample</button></div>
</article>
</section>
</main>
</div>
</body>
</html>`;
form.addEventListener("input", applyPreview);
form.addEventListener("change", applyPreview);
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
button.addEventListener("click", () => {
previewMode = button.dataset.themePreviewMode;
editor.querySelectorAll("[data-theme-preview-mode]").forEach((item) => {
item.classList.toggle("is-selected", item === button);
});
applyPreview();
});
});
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
form.reset();
applyPreview();
window.LumiStateButton?.success(editor.querySelector("[data-theme-reset]"));
});
editor.querySelector("[data-theme-reset-to-base]")?.addEventListener("click", () => {
form.querySelectorAll("[data-base-value]").forEach((input) => {
input.value = input.dataset.baseValue;
});
applyPreview();
});
editor.querySelector("[data-theme-popout]")?.addEventListener("click", () => {
popout = window.open("", "lumi-theme-preview", "width=430,height=760,menubar=no,toolbar=no,location=no,status=no");
const status = editor.querySelector("[data-theme-popout-status]");
if (!popout) {
if (status) status.textContent = "Pop-out preview was blocked by the browser.";
return;
}
popout.document.open();
popout.document.write(previewShell());
popout.document.close();
if (status) status.textContent = "Pop-out preview is open and updates with this editor.";
syncPopout();
});
applyPreview();
})();

View File

@ -14,6 +14,18 @@ const BetterSqlite3Store = require("better-sqlite3-session-store")(session);
const { db } = require("../services/db");
const { getSetting, setSetting, getAllSettings } = require("../services/settings");
const {
deleteCustomTheme,
duplicateTheme,
FONT_STACKS,
getActiveTheme,
getThemeById,
listThemes,
renameCustomTheme,
saveCustomTheme,
setActiveTheme,
valuesFromRequest
} = require("../services/themes");
const { getRoleFlags, hasAccess } = require("../services/rbac");
const {
buildDiscordAuthUrl,
@ -71,6 +83,19 @@ const {
applyPluginUpdate,
listSnapshots
} = require("../services/update-manager");
const { getUpdateStatus } = require("../services/update-index");
const {
applyCoreUpdate,
applyPluginUpdateFromRepo,
revertCoreSnapshot,
revertPluginSnapshot,
disablePluginForRecovery
} = require("../services/repo-update");
const {
safeModeStatus,
clearRecoveryMarker,
updateRecoveryMarker
} = require("../services/recovery-mode");
const {
generateCommandPreview,
previewParts
@ -81,6 +106,10 @@ const {
consumeConfirmation,
normalizeAction
} = require("../services/destructive-confirm");
const {
publishWebEvent,
subscribeWebEvents
} = require("../services/web-events");
function ensureSessionSecret() {
let secret = getSetting("session_secret");
@ -101,7 +130,39 @@ function isConfigured() {
return platforms.some((platform) => platform.configured);
}
function getPrimaryLoginPlatform() {
function normalizeHostName(value) {
const raw = String(value || "").toLowerCase();
if (raw.startsWith("[")) {
return raw.slice(1, raw.indexOf("]"));
}
if (raw === "::1" || raw === "::ffff:127.0.0.1") {
return raw;
}
return raw.replace(/:\d+$/, "");
}
function isLocalhostLoginAvailable(req) {
if (!req) return false;
const host = normalizeHostName(req.hostname || req.get("host"));
return host === "localhost" || host === "::1" || host === "::ffff:127.0.0.1" || host === "127.0.0.1" || host.startsWith("127.");
}
function getLocalhostLoginPlatform(req) {
if (!isLocalhostLoginAvailable(req)) return null;
return {
id: "localhost",
label: "Localhost Login",
configured: true,
enabled: true,
supported: true,
supportsLogin: true,
loginPath: "/auth/localhost"
};
}
function getPrimaryLoginPlatform(req) {
const localhostPlatform = getLocalhostLoginPlatform(req);
if (localhostPlatform) return localhostPlatform;
const platforms = getPlatformStatus().filter(
(platform) =>
platform.supported && platform.enabled && platform.supportsLogin
@ -112,13 +173,13 @@ function getPrimaryLoginPlatform() {
return platforms.find((platform) => platform.configured) || platforms[0];
}
function getLoginRedirectPath() {
const platform = getPrimaryLoginPlatform();
function getLoginRedirectPath(req) {
const platform = getPrimaryLoginPlatform(req);
return platform?.loginPath || "/setup";
}
function requireConfigured(req, res, next) {
if (!isConfigured() && !req.path.startsWith("/setup")) {
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
return res.redirect("/setup");
}
next();
@ -126,7 +187,7 @@ function requireConfigured(req, res, next) {
function requireAuth(req, res, next) {
if (!req.session.user) {
return res.redirect(getLoginRedirectPath());
return res.redirect(getLoginRedirectPath(req));
}
next();
}
@ -170,7 +231,7 @@ function formatDuration(totalMs) {
function requireRole(role) {
return (req, res, next) => {
if (!req.session.user) {
return res.redirect(getLoginRedirectPath());
return res.redirect(getLoginRedirectPath(req));
}
if (!hasAccess(req.session.user, role)) {
return res.status(403).render("error", {
@ -1373,6 +1434,12 @@ function buildCustomPageSrcdoc(page, theme) {
` --bg-1: ${theme.light.bg1};`,
` --bg-2: ${theme.light.bg2};`,
` --bg-3: ${theme.light.bg3};`,
` --success: ${theme.light.success};`,
` --warning: ${theme.light.warning};`,
` --info: ${theme.light.info};`,
` --link: ${theme.light.link};`,
` --radius: ${theme.metrics.radius}px;`,
` --spacing-scale: ${theme.metrics.spacingScale};`,
"}",
"@media (prefers-color-scheme: dark) {",
" :root {",
@ -1388,6 +1455,10 @@ function buildCustomPageSrcdoc(page, theme) {
` --bg-1: ${theme.dark.bg1};`,
` --bg-2: ${theme.dark.bg2};`,
` --bg-3: ${theme.dark.bg3};`,
` --success: ${theme.dark.success};`,
` --warning: ${theme.dark.warning};`,
` --info: ${theme.dark.info};`,
` --link: ${theme.dark.link};`,
" }",
"}"
].join("\n")
@ -1457,41 +1528,120 @@ function setFlash(req, type, message) {
}
function getThemeSettings() {
return {
light: {
bg1: getSetting("theme_light_bg_1", "#ffe5c4"),
bg2: getSetting("theme_light_bg_2", "#f4efe8"),
bg3: getSetting("theme_light_bg_3", "#e9f3f1"),
text: getSetting("theme_light_text", "#121518"),
muted: getSetting("theme_light_text_muted", "#2c3137"),
accent: getSetting("theme_light_accent", "#0f6a78"),
accentAlt: getSetting("theme_light_accent_alt", "#f4a340"),
danger: getSetting("theme_light_danger", "#d66d5c"),
surface: getSetting("theme_light_surface", "#ffffff"),
surface2: getSetting("theme_light_surface_2", "#fbf9f6"),
surface3: getSetting("theme_light_surface_3", "#f9f5ef"),
border: getSetting("theme_light_border", "#e3ddd6")
},
dark: {
bg1: getSetting("theme_dark_bg_1", "#1b1d1f"),
bg2: getSetting("theme_dark_bg_2", "#16181b"),
bg3: getSetting("theme_dark_bg_3", "#0f1113"),
text: getSetting("theme_dark_text", "#f2f0ec"),
muted: getSetting("theme_dark_text_muted", "#c5bfb7"),
accent: getSetting("theme_dark_accent", "#4fb6c2"),
accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"),
danger: getSetting("theme_dark_danger", "#e08173"),
surface: getSetting("theme_dark_surface", "#232629"),
surface2: getSetting("theme_dark_surface_2", "#2b2f33"),
surface3: getSetting("theme_dark_surface_3", "#30353a"),
border: getSetting("theme_dark_border", "#34393d")
},
role: {
public: getSetting("theme_role_public", "#ffffff"),
mod: getSetting("theme_role_mod", "#2cb678"),
admin: getSetting("theme_role_admin", "#e35678")
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;
const iconMode = String(item.icon_mode || "").trim();
const iconUrl =
iconMode === "manual" ? safeExternalUrl(item.icon_url) :
iconMode === "favicon" ? safeExternalUrl(item.fetched_favicon_url) :
iconMode === "letter" ? "" :
safeExternalUrl(item.icon_url || item.fetched_favicon_url);
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: iconUrl,
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() {
@ -1922,10 +2072,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));
@ -1956,9 +2108,14 @@ function createWebServer({ loadPlugins, discordClient }) {
res.locals.botAvatar = getSetting("bot_avatar_url", null);
const platformStatus = getPlatformStatus();
res.locals.platforms = platformStatus;
res.locals.platformLogins = platformStatus.filter(
(platform) => platform.supported && platform.enabled && platform.supportsLogin
);
const localhostLogin = getLocalhostLoginPlatform(req);
res.locals.localhostLoginAvailable = Boolean(localhostLogin);
res.locals.platformLogins = [
...(localhostLogin ? [localhostLogin] : []),
...platformStatus.filter(
(platform) => platform.supported && platform.enabled && platform.supportsLogin
)
];
res.locals.platformLinks = platformStatus.filter(
(platform) => platform.supported && platform.enabled && platform.supportsLink
);
@ -2047,7 +2204,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)
});
});
@ -2577,6 +2736,53 @@ function createWebServer({ loadPlugins, discordClient }) {
}
});
app.get("/auth/localhost", (req, res) => {
if (!isLocalhostLoginAvailable(req)) {
return res.status(404).render("error", {
title: "Login unavailable",
message: "Localhost Login is only available from a localhost request."
});
}
res.render("localhost-login", {
title: "Localhost Login",
username: getSetting("localhost_login_username", "admin")
});
});
app.post("/auth/localhost", (req, res) => {
if (!isLocalhostLoginAvailable(req)) {
return res.status(404).render("error", {
title: "Login unavailable",
message: "Localhost Login is only available from a localhost request."
});
}
const username = String(req.body.username || "").trim();
const password = String(req.body.password || "");
const expectedUsername = String(getSetting("localhost_login_username", "admin"));
const expectedPassword = String(getSetting("localhost_login_password", "admin"));
if (username !== expectedUsername || password !== expectedPassword) {
setFlash(req, "error", "Invalid localhost username or password.");
return res.redirect("/auth/localhost");
}
const profile = ensureUserForIdentity({
provider: "localhost",
providerUserId: expectedUsername,
displayName: expectedUsername,
fallbackName: "Localhost Admin"
});
req.session.user = {
id: profile.id,
username: profile.internal_username,
avatar: null,
roles: ["localhost-admin"],
isAdmin: true,
isMod: true,
isLocalhost: true
};
setFlash(req, "success", "Logged in with Localhost Login.");
res.redirect("/");
});
app.post("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.redirect("/");
@ -3488,11 +3694,51 @@ function createWebServer({ loadPlugins, discordClient }) {
});
});
app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => {
const plugins = getPlugins();
const logs = listLogs({ limit: 500 });
const memory = process.memoryUsage();
const count = (table) => {
try {
return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
} catch {
return 0;
}
};
res.set("Cache-Control", "no-store");
res.json({
uptime_seconds: Math.round(process.uptime()),
memory: {
rss: memory.rss,
heap_used: memory.heapUsed,
heap_total: memory.heapTotal
},
plugins: {
total: plugins.length,
enabled: plugins.filter((plugin) => plugin.enabled).length
},
counts: {
users: count("user_profiles"),
commands: count("custom_commands"),
pages: count("custom_pages"),
logs: count("logs")
},
logs: {
error: logs.filter((entry) => entry.level === "error").length,
warn: logs.filter((entry) => entry.level === "warn").length,
info: logs.filter((entry) => entry.level === "info").length,
debug: logs.filter((entry) => entry.level === "debug").length
},
sampled_at: Date.now()
});
});
app.get("/admin/settings", requireRole("admin"), (req, res) => {
res.render("admin-settings", {
title: "Settings",
settings: getAllSettings(),
platforms: getPlatformStatus(),
localhostLoginAvailable: isLocalhostLoginAvailable(req),
navIconItems: buildNavIconItems(req.session.user, navItems, req.path)
});
});
@ -3518,6 +3764,32 @@ function createWebServer({ loadPlugins, discordClient }) {
}
}
}
if (isLocalhostLoginAvailable(req)) {
const localhostUsername = String(req.body.localhost_login_username || "").trim();
if (localhostUsername) {
setSetting("localhost_login_username", localhostUsername);
}
const localhostPassword = String(req.body.localhost_login_password || "");
if (localhostPassword) {
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) {
@ -3529,7 +3801,7 @@ function createWebServer({ loadPlugins, discordClient }) {
platform.supportsLogin &&
nextPlatformValues.get(platform.id)
);
if (!hasLoginPlatform) {
if (!hasLoginPlatform && !isLocalhostLoginAvailable(req)) {
setFlash(
req,
"error",
@ -3969,48 +4241,142 @@ function createWebServer({ loadPlugins, discordClient }) {
});
app.get("/admin/theming", requireRole("admin"), (req, res) => {
const activeTheme = getActiveTheme();
const requestedEdit = String(req.query.edit || "");
const editingTheme = requestedEdit
? getThemeById(requestedEdit)
: activeTheme.builtin
? null
: activeTheme;
const editingBaseTheme = editingTheme && !editingTheme.builtin
? getThemeById(editingTheme.baseThemeId)
: null;
res.render("admin-theme", {
title: "Theming",
theme: getThemeSettings()
theme: activeTheme,
activeTheme,
themes: listThemes(),
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null,
editingBaseTheme,
fontStacks: FONT_STACKS
});
});
app.post("/admin/theming", requireRole("admin"), (req, res) => {
const fields = [
"theme_light_bg_1",
"theme_light_bg_2",
"theme_light_bg_3",
"theme_light_text",
"theme_light_text_muted",
"theme_light_accent",
"theme_light_accent_alt",
"theme_light_danger",
"theme_light_surface",
"theme_light_surface_2",
"theme_light_surface_3",
"theme_light_border",
"theme_dark_bg_1",
"theme_dark_bg_2",
"theme_dark_bg_3",
"theme_dark_text",
"theme_dark_text_muted",
"theme_dark_accent",
"theme_dark_accent_alt",
"theme_dark_danger",
"theme_dark_surface",
"theme_dark_surface_2",
"theme_dark_surface_3",
"theme_dark_border",
"theme_role_public",
"theme_role_mod",
"theme_role_admin"
];
for (const field of fields) {
if (req.body[field] !== undefined) {
setSetting(field, req.body[field].trim());
}
app.post("/admin/theming/select", requireRole("admin"), (req, res) => {
try {
const theme = setActiveTheme(String(req.body.theme_id || ""));
setFlash(req, "success", `${theme.name} is now active.`);
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect("/admin/theming");
});
app.post("/admin/theming/duplicate", requireRole("admin"), (req, res) => {
try {
const theme = duplicateTheme(req.body.theme_id, req.body.name);
setFlash(req, "success", `${theme.name} created. You can edit it below.`);
return res.redirect(`/admin/theming?edit=${encodeURIComponent(theme.id)}#theme-editor`);
} catch (error) {
setFlash(req, "error", error.message);
return res.redirect("/admin/theming");
}
});
app.post("/admin/theming/custom/:id/save", requireRole("admin"), (req, res) => {
const themeId = `custom:${req.params.id}`;
try {
const current = getThemeById(themeId);
if (!current) throw new Error("Theme not found.");
const theme = saveCustomTheme(themeId, valuesFromRequest(req.body, current));
if (req.body.apply === "on") setActiveTheme(theme.id);
setFlash(
req,
"success",
req.body.apply === "on"
? `${theme.name} saved and applied.`
: `${theme.name} saved.`
);
} catch (error) {
const detail = error.validationErrors?.join(" ") || error.message;
setFlash(req, "error", detail);
}
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`);
});
app.post("/admin/theming/custom/:id/rename", requireRole("admin"), (req, res) => {
const themeId = `custom:${req.params.id}`;
try {
const theme = renameCustomTheme(themeId, req.body.name);
setFlash(req, "success", `Theme renamed to ${theme.name}.`);
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}`);
});
app.post("/admin/theming/custom/:id/delete", requireRole("admin"), (req, res) => {
try {
deleteCustomTheme(`custom:${req.params.id}`);
setFlash(req, "success", "Custom theme deleted.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect("/admin/theming");
});
app.post("/admin/theming", requireRole("admin"), (req, res) => {
let createdTarget = null;
try {
let target = getActiveTheme();
if (target.builtin) {
target = duplicateTheme(target.id, "Legacy Custom");
createdTarget = target.id;
}
const values = JSON.parse(JSON.stringify(target));
const legacyUpdates = [];
const legacyMap = {
theme_light_bg_1: ["light", "bg1"],
theme_light_bg_2: ["light", "bg2"],
theme_light_bg_3: ["light", "bg3"],
theme_light_text: ["light", "text"],
theme_light_text_muted: ["light", "muted"],
theme_light_accent: ["light", "accent"],
theme_light_accent_alt: ["light", "accentAlt"],
theme_light_danger: ["light", "danger"],
theme_light_surface: ["light", "surface"],
theme_light_surface_2: ["light", "surface2"],
theme_light_surface_3: ["light", "surface3"],
theme_light_border: ["light", "border"],
theme_dark_bg_1: ["dark", "bg1"],
theme_dark_bg_2: ["dark", "bg2"],
theme_dark_bg_3: ["dark", "bg3"],
theme_dark_text: ["dark", "text"],
theme_dark_text_muted: ["dark", "muted"],
theme_dark_accent: ["dark", "accent"],
theme_dark_accent_alt: ["dark", "accentAlt"],
theme_dark_danger: ["dark", "danger"],
theme_dark_surface: ["dark", "surface"],
theme_dark_surface_2: ["dark", "surface2"],
theme_dark_surface_3: ["dark", "surface3"],
theme_dark_border: ["dark", "border"],
theme_role_public: ["role", "public"],
theme_role_mod: ["role", "mod"],
theme_role_admin: ["role", "admin"]
};
for (const [field, [group, key]] of Object.entries(legacyMap)) {
if (req.body[field] === undefined) continue;
values[group][key] = String(req.body[field]).trim();
legacyUpdates.push([field, values[group][key]]);
}
saveCustomTheme(target.id, values);
for (const [field, value] of legacyUpdates) setSetting(field, value);
setActiveTheme(target.id);
setFlash(req, "success", "Theme updated.");
} catch (error) {
if (createdTarget) deleteCustomTheme(createdTarget);
setFlash(req, "error", error.message);
}
setFlash(req, "success", "Theme updated.");
res.redirect("/admin/theming");
});
@ -4520,13 +4886,134 @@ function createWebServer({ loadPlugins, discordClient }) {
}
});
function wantsJson(req) {
return req.xhr ||
req.get("accept")?.includes("application/json") ||
req.get("content-type")?.includes("application/json");
}
function updateSourceFrom(req) {
const value = req.body?.source || req.query?.source || "stable";
return value === "experimental" ? "experimental" : String(value || "stable");
}
function sendUpdateResult(req, res, result, redirectPath = "/admin/updates") {
if (wantsJson(req)) {
return res.json({ ok: true, ...result });
}
setFlash(req, "success", result.message || "Update action completed.");
return res.redirect(redirectPath);
}
function sendUpdateError(req, res, error, redirectPath = "/admin/updates") {
if (wantsJson(req)) {
return res.status(400).json({ ok: false, error: error.message });
}
setFlash(req, "error", error.message);
return res.redirect(redirectPath);
}
function scheduleRestartAfterNotice() {
setTimeout(() => requestRestart(), 5000);
}
app.get("/admin/updates/events", requireRole("admin"), subscribeWebEvents);
app.get("/admin/updates/status", requireRole("admin"), (req, res) => {
try {
res.json({ ok: true, status: getUpdateStatus({ source: updateSourceFrom(req) }) });
} catch (error) {
res.status(500).json({ ok: false, error: error.message });
}
});
app.get("/admin/updates/recovery", requireRole("admin"), (req, res) => {
res.json({ ok: true, recovery: safeModeStatus(), snapshots: listSnapshots() });
});
app.post("/admin/updates/recovery/clear-marker", requireRole("admin"), (req, res) => {
clearRecoveryMarker();
publishWebEvent("recovery:marker_detected", { status: "cleared" }, { role: "admin" });
sendUpdateResult(req, res, { message: "Recovery marker cleared." });
});
app.post("/admin/updates/recovery/retry-normal-startup", requireRole("admin"), (req, res) => {
updateRecoveryMarker({ status: "retry_startup", retry_at: new Date().toISOString() });
publishWebEvent("recovery:retry_startup", {}, { role: "admin" });
sendUpdateResult(req, res, { message: "Retrying normal startup." });
scheduleRestartAfterNotice();
});
app.get("/admin/updates", requireRole("admin"), (req, res) => {
let updateStatus = null;
let updateStatusError = null;
try {
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
} catch (error) {
updateStatusError = error.message;
}
res.render("admin-updates", {
title: "Updates",
snapshots: listSnapshots()
snapshots: listSnapshots(),
updateStatus,
updateStatusError,
recoveryStatus: safeModeStatus()
});
});
app.post("/admin/updates/core/check", requireRole("admin"), (req, res) => {
try {
const status = getUpdateStatus({ source: updateSourceFrom(req) });
sendUpdateResult(req, res, {
status,
message: status.core.update_available
? `Safe core target ${status.core.safe_target_version} is available.`
: status.core.blocked
? status.core.blocked_reason
: "No core updates found."
});
} catch (error) {
sendUpdateError(req, res, error);
}
});
app.post("/admin/updates/core/apply", requireRole("admin"), async (req, res) => {
try {
const result = await applyCoreUpdate({
source: updateSourceFrom(req),
publish: publishWebEvent
});
sendUpdateResult(req, res, {
...result,
refresh_after_ms: 5000,
message: "Core update applied. Lumi will restart after the confirmation notice."
});
scheduleRestartAfterNotice();
} catch (error) {
sendUpdateError(req, res, error);
}
});
app.post("/admin/updates/core/revert", requireRole("admin"), async (req, res) => {
try {
const status = getUpdateStatus({ source: updateSourceFrom(req) });
const snapshotId = req.body.snapshot_id || status.core.snapshot.latest_snapshot_id;
if (!snapshotId) throw new Error("No core snapshot is available to revert.");
const result = await revertCoreSnapshot(snapshotId, {
currentVersion: status.core.current_version,
publish: publishWebEvent
});
sendUpdateResult(req, res, {
...result,
refresh_after_ms: 5000,
message: "Core snapshot reverted. Lumi will restart after the confirmation notice."
});
scheduleRestartAfterNotice();
} catch (error) {
sendUpdateError(req, res, error);
}
});
app.post(
"/admin/updates/bot",
requireRole("admin"),
@ -4565,6 +5052,38 @@ function createWebServer({ loadPlugins, discordClient }) {
}
);
app.post(
"/admin/updates/core/zip",
requireRole("admin"),
uploadSingle("update_zip"),
async (req, res) => {
if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError));
if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive."));
try {
const patchMode = req.body.patch_mode === "1";
const snapshot = await applyBotUpdate(req.file.path, {
mode: patchMode ? "patch" : "full",
metadata: {
update_method: patchMode ? "zip_patch" : "zip",
source_branch: "manual_zip",
rollback_safe: req.body.rollback_safe === "1"
}
});
sendUpdateResult(req, res, {
snapshot,
restart_required: true,
refresh_after_ms: 5000,
message: patchMode ? "Core patch ZIP applied. Restarting..." : "Core ZIP update applied. Restarting..."
});
scheduleRestartAfterNotice();
} catch (error) {
sendUpdateError(req, res, error);
} finally {
try { fs.rmSync(req.file?.path, { force: true }); } catch {}
}
}
);
app.post(
"/admin/updates/plugin",
requireRole("admin"),
@ -4596,6 +5115,97 @@ function createWebServer({ loadPlugins, discordClient }) {
}
);
app.post("/admin/updates/plugins/:id/check", requireRole("admin"), (req, res) => {
try {
const status = getUpdateStatus({ source: updateSourceFrom(req) });
const plugin = status.plugins.find((item) => item.id === req.params.id);
if (!plugin) throw new Error("Plugin not found.");
sendUpdateResult(req, res, {
plugin,
message: plugin.update_available
? `Safe plugin target ${plugin.safe_target_version} is available.`
: plugin.blocked
? plugin.blocked_reason
: "No plugin updates found."
});
} catch (error) {
sendUpdateError(req, res, error);
}
});
app.post("/admin/updates/plugins/:id/apply", requireRole("admin"), async (req, res) => {
try {
const result = await applyPluginUpdateFromRepo(req.params.id, {
source: updateSourceFrom(req),
publish: publishWebEvent
});
sendUpdateResult(req, res, {
...result,
message: "Plugin update applied."
});
} catch (error) {
sendUpdateError(req, res, error);
}
});
app.post("/admin/updates/plugins/:id/revert", requireRole("admin"), async (req, res) => {
try {
const status = getUpdateStatus({ source: updateSourceFrom(req) });
const plugin = status.plugins.find((item) => item.id === req.params.id);
if (!plugin) throw new Error("Plugin not found.");
const snapshotId = req.body.snapshot_id || plugin.snapshot.latest_snapshot_id;
if (!snapshotId) throw new Error("No plugin snapshot is available to revert.");
const result = await revertPluginSnapshot(req.params.id, snapshotId, {
currentVersion: plugin.current_version,
publish: publishWebEvent
});
sendUpdateResult(req, res, {
...result,
message: "Plugin snapshot reverted. Restarting..."
});
scheduleRestartAfterNotice();
} catch (error) {
sendUpdateError(req, res, error);
}
});
app.post("/admin/updates/plugins/:id/disable", requireRole("admin"), (req, res) => {
try {
const result = disablePluginForRecovery(req.params.id, publishWebEvent);
sendUpdateResult(req, res, { ...result, message: "Plugin disabled for recovery." });
} catch (error) {
sendUpdateError(req, res, error);
}
});
app.post(
"/admin/updates/plugins/:id/zip",
requireRole("admin"),
uploadSingle("plugin_zip"),
async (req, res) => {
if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError));
if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive."));
try {
const snapshot = await applyPluginUpdate(req.file.path, {
expectedPluginId: req.params.id,
metadata: {
target_id: req.params.id,
source_branch: "manual_zip",
rollback_safe: req.body.rollback_safe === "1"
}
});
sendUpdateResult(req, res, {
snapshot,
message: "Plugin ZIP update applied."
});
} catch (error) {
sendUpdateError(req, res, error);
} finally {
try { fs.rmSync(req.file?.path, { force: true }); } catch {}
}
}
);
app.post("/admin/update", requireRole("admin"), (req, res) => {
try {
const remote = getSetting("git_remote", "origin");

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Admin dashboard</h1>
<%- include("partials/page-header", {
eyebrow: "Administration",
pageTitle: "Admin dashboard",
description: "Configure Lumi, manage community tools, and maintain the installation."
}) %>
<div class="grid">
<div class="card">
<h2>Settings</h2>
@ -9,8 +13,8 @@
</div>
<div class="card">
<h2>Theming</h2>
<p>Adjust light and dark mode colors.</p>
<a href="/admin/theming" class="link">Edit theme</a>
<p>Select protected presets or create editable custom themes.</p>
<a href="/admin/theming" class="link">Open theme studio</a>
</div>
<div class="card">
<h2>Commands</h2>
@ -39,18 +43,71 @@
</div>
</div>
</section>
<section class="card admin-metrics" data-dashboard-metrics>
<div class="section-header">
<div>
<h2>Live metrics</h2>
<p class="hint">Process health, content counts, plugin status, and recent log severity.</p>
</div>
<span class="status-indicator status-success" data-metrics-status>Loading</span>
</div>
<div class="dashboard-metric-grid">
<div><span>Uptime</span><strong data-metric="uptime">-</strong></div>
<div><span>Memory RSS</span><strong data-metric="rss">-</strong></div>
<div><span>Heap used</span><strong data-metric="heap">-</strong></div>
<div><span>Plugins enabled</span><strong data-metric="plugins">-</strong></div>
<div><span>Users</span><strong data-metric="users">-</strong></div>
<div><span>Commands</span><strong data-metric="commands">-</strong></div>
</div>
<div class="dashboard-chart-grid">
<figure class="dashboard-chart-card">
<figcaption>Memory trend</figcaption>
<svg viewBox="0 0 300 120" role="img" aria-label="Memory trend" data-memory-chart></svg>
</figure>
<figure class="dashboard-chart-card">
<figcaption>Recent logs by severity</figcaption>
<svg viewBox="0 0 300 120" role="img" aria-label="Recent logs by severity" data-log-chart></svg>
</figure>
</div>
</section>
<section class="card">
<h2>Maintenance</h2>
<form method="post" action="/admin/check-update" class="inline-form">
<button type="submit" class="button subtle">Check for updates</button>
</form>
<form method="post" action="/admin/update" class="inline-form">
<button type="submit" class="button">Update from git</button>
</form>
<form method="post" action="/admin/restart" class="inline-form">
<button type="submit" class="button subtle">Restart bot</button>
</form>
<div class="button-group">
<form method="post" action="/admin/check-update" class="inline-form">
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Check for updates" },
{ id: "loading", text: "Checking", spinner: true },
{ id: "success", text: "Checked" }
]
}) %>
</form>
<form method="post" action="/admin/update" class="inline-form" data-confirm-mode="modal" data-confirm-title="Update from git" data-confirm-text="Pull updates from the configured remote and branch, then restart Lumi if the update succeeds." data-confirm-label="Update from git">
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Update from git" },
{ id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" }
]
}) %>
</form>
<form method="post" action="/admin/restart" class="inline-form">
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Restart bot" },
{ id: "loading", text: "Restarting", spinner: true },
{ id: "success", text: "Restarting" }
]
}) %>
</form>
</div>
</section>
<script src="/dashboard.js?v=<%= assetVersion %>" defer></script>
<%- include("partials/layout-bottom") %>

View File

@ -7,13 +7,18 @@
<p class="command-subtitle">Core system logs with severity, timestamps, and details.</p>
</div>
<div class="log-controls">
<input
class="table-search"
type="search"
placeholder="Search logs"
aria-label="Search logs"
data-log-search
/>
<label>
<span>Search</span>
<input
class="table-search"
type="search"
placeholder="Search logs"
aria-label="Search logs"
data-log-search
/>
</label>
<label>
<span>Severity</span>
<select class="table-search" data-log-level aria-label="Filter log severity">
<option value="all" <%= filters.level === 'all' ? 'selected' : '' %>>All severities</option>
<option value="error" <%= filters.level === 'error' ? 'selected' : '' %>>Error</option>
@ -21,6 +26,9 @@
<option value="info" <%= filters.level === 'info' ? 'selected' : '' %>>Info</option>
<option value="debug" <%= filters.level === 'debug' ? 'selected' : '' %>>Debug</option>
</select>
</label>
<label>
<span>Range</span>
<select class="table-search" data-log-range aria-label="Filter by time range">
<option value="all" <%= filters.range === 'all' ? 'selected' : '' %>>All time</option>
<option value="<%= 60 * 60 * 1000 %>" <%= filters.range === `${60 * 60 * 1000}` ? 'selected' : '' %>>Last hour</option>
@ -28,12 +36,18 @@
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${7 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last week</option>
<option value="<%= 30 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${30 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last month</option>
</select>
</label>
<label>
<span>Entries</span>
<select class="table-search" data-log-limit aria-label="Limit log entries">
<option value="50" <%= filters.limit === '50' ? 'selected' : '' %>>50 most recent</option>
<option value="100" <%= filters.limit === '100' ? 'selected' : '' %>>100 most recent</option>
<option value="250" <%= filters.limit === '250' ? 'selected' : '' %>>250 most recent</option>
<option value="500" <%= filters.limit === '500' ? 'selected' : '' %>>500 most recent</option>
</select>
</label>
<a class="button subtle" href="/admin/logs">Reset</a>
<a class="button subtle" href="<%= `/admin/logs?range=${encodeURIComponent(filters.range)}&level=${encodeURIComponent(filters.level)}&limit=${encodeURIComponent(filters.limit)}` %>">Refresh</a>
<button type="button" class="button subtle" data-log-download>Download logs</button>
</div>
</div>

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Custom pages</h1>
<%- include("partials/page-header", {
eyebrow: "Content",
pageTitle: "Custom pages",
description: "Publish role-aware HTML or Markdown pages without changing routes."
}) %>
<form method="post" action="/admin/pages" class="form-grid" data-page-form>
<div class="field">
<label>Slug</label>
@ -49,8 +53,9 @@
</form>
<h2>Existing pages</h2>
<% if (!pages.length) { %>
<p>No pages created yet.</p>
<div class="empty-state">No pages created yet.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
@ -144,6 +149,7 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<script>

View File

@ -1,10 +1,15 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Plugins</h1>
<%- include("partials/page-header", {
eyebrow: "Extensions",
pageTitle: "Plugins",
description: "Install, update, enable, and remove Lumi modules."
}) %>
<h2>Installed plugins</h2>
<% if (!plugins.length) { %>
<p>No plugins installed.</p>
<div class="empty-state">No plugins installed.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
@ -23,12 +28,28 @@
<td>
<form method="post" action="/admin/plugins/<%= plugin.id %>/toggle" class="inline-form">
<input type="hidden" name="enabled" value="<%= plugin.enabled ? 'false' : 'true' %>" />
<button type="submit" class="button subtle"><%= plugin.enabled ? "Disable" : "Enable" %></button>
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: plugin.enabled ? "Disable" : "Enable" },
{ id: "loading", text: "Saving", spinner: true },
{ id: "success", text: "Saved" }
]
}) %>
</form>
<form method="post" action="/admin/plugins/<%= plugin.id %>/update" class="inline-form">
<button type="submit" class="button subtle">Update</button>
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Update" },
{ id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" }
]
}) %>
</form>
<form method="post" action="/admin/plugins/<%= plugin.id %>/uninstall" class="inline-form" data-confirm-mode="modal" data-confirm-title="Uninstall <%= plugin.name %>?" data-confirm-text="This removes the plugin code and configuration records, then restarts Lumi.">
<form method="post" action="/admin/plugins/<%= plugin.id %>/uninstall" class="inline-form" data-confirm-mode="modal" data-confirm-title="Uninstall <%= plugin.name %>?" data-confirm-text="This removes the plugin code and configuration records, then restarts Lumi." data-confirm-label="Uninstall plugin">
<button type="submit" class="button danger">Uninstall</button>
</form>
</td>
@ -36,15 +57,23 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<section class="card">
<h2>Install plugin from ZIP</h2>
<form method="post" action="/admin/plugins/upload" enctype="multipart/form-data" class="form-grid">
<div class="field full">
<div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required />
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Upload plugin" },
{ id: "loading", text: "Uploading", spinner: true },
{ id: "success", text: "Uploaded" }
]
}) %>
</div>
<button type="submit" class="button">Upload plugin</button>
</form>
</section>
<section class="card">
@ -54,7 +83,14 @@
<label>Repository URL</label>
<input name="url" placeholder="https://gitea.example.com/org/plugin.git" />
</div>
<button type="submit" class="button">Install plugin</button>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Install plugin" },
{ id: "loading", text: "Installing", spinner: true },
{ id: "success", text: "Installed" }
]
}) %>
</form>
</section>
<section class="card">
@ -72,7 +108,14 @@
<label>Description</label>
<input name="description" />
</div>
<button type="submit" class="button">Create plugin</button>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Create plugin" },
{ id: "loading", text: "Creating", spinner: true },
{ id: "success", text: "Created" }
]
}) %>
</form>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -1,7 +1,11 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Settings</h1>
<form method="post" action="/admin/settings" class="form-grid">
<%- include("partials/page-header", {
eyebrow: "Administration",
pageTitle: "Settings",
description: "Manage core behavior, updates, and platform integrations."
}) %>
<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 || '' %>" />
@ -37,23 +41,34 @@
</div>
<div class="field full">
<div class="inline-actions">
<button type="submit" class="button">Save settings</button>
<button
type="submit"
class="button subtle"
formaction="/admin/check-update"
formmethod="post"
>
Check for updates
</button>
<button
type="submit"
class="button subtle"
formaction="/admin/update"
formmethod="post"
>
Update from git
</button>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Save settings" },
{ id: "loading", text: "Saving", spinner: true },
{ id: "success", text: "Saved" }
]
}) %>
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
attrs: "formaction=\"/admin/check-update\" formmethod=\"post\"",
states: [
{ id: "idle", text: "Check for updates" },
{ id: "loading", text: "Checking", spinner: true },
{ id: "success", text: "Checked" }
]
}) %>
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
attrs: "formaction=\"/admin/update\" formmethod=\"post\" data-confirm-mode=\"modal\" data-confirm-title=\"Update from git\" data-confirm-text=\"Pull updates from the configured remote and branch, then restart Lumi if the update succeeds.\" data-confirm-label=\"Update from git\"",
states: [
{ id: "idle", text: "Update from git" },
{ id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" }
]
}) %>
</div>
<p class="hint">Git update checks use the configured remote and branch.</p>
</div>
@ -94,7 +109,60 @@
</div>
</div>
<button type="submit" class="button">Save settings</button>
<% if (localhostLoginAvailable) { %>
<div class="field full">
<h2>Localhost Login</h2>
<p class="hint">Development-only login shown only on localhost. Defaults are admin / admin until changed.</p>
</div>
<div class="field">
<label>Localhost username</label>
<input name="localhost_login_username" value="<%= settings.localhost_login_username || 'admin' %>" autocomplete="off" />
</div>
<div class="field">
<label>New localhost password</label>
<input name="localhost_login_password" type="password" placeholder="Leave blank to keep current password" autocomplete="new-password" />
</div>
<% } %>
<div class="field full">
<h2>Homepage content</h2>
<p class="hint">Configure public homepage link buttons and the priority-based dynamic hero without editing raw JSON.</p>
</div>
<div class="field full homepage-builder" data-homepage-builder="links">
<div class="section-header">
<div>
<h3>Homepage link buttons</h3>
<p class="hint">Add public or role-limited links shown as cards on the homepage.</p>
</div>
<button type="button" class="button subtle" data-homepage-add="links">Add link</button>
</div>
<textarea name="homepage_link_buttons" class="homepage-json-source" rows="8" spellcheck="false"><%= JSON.stringify(settings.homepage_link_buttons || [], null, 2) %></textarea>
<div class="homepage-builder-list" data-homepage-list="links"></div>
</div>
<div class="field full homepage-builder" data-homepage-builder="heroes">
<div class="section-header">
<div>
<h3>Homepage hero entries</h3>
<p class="hint">The first available enabled hero by priority is shown on the homepage.</p>
</div>
<button type="button" class="button subtle" data-homepage-add="heroes">Add hero</button>
</div>
<textarea name="homepage_hero_entries" class="homepage-json-source" rows="10" spellcheck="false"><%= JSON.stringify(settings.homepage_hero_entries || [], null, 2) %></textarea>
<div class="homepage-builder-list" data-homepage-list="heroes"></div>
<details class="advanced-theme-controls">
<summary>Advanced JSON</summary>
<p class="hint">The builder writes JSON into hidden fields before save. Edit only if you need a field the builder does not expose.</p>
</details>
</div>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Save settings" },
{ id: "loading", text: "Saving", spinner: true },
{ id: "success", text: "Saved" }
]
}) %>
</form>
</section>
<section class="card">
@ -113,10 +181,12 @@
<div class="nav-icon-actions">
<form method="post" action="/admin/settings/nav-icons" enctype="multipart/form-data" class="inline-form">
<input type="hidden" name="item_id" value="<%= item.id %>" />
<div class="input-action-row">
<input type="file" name="icon_file" accept="image/svg+xml,image/png" />
<button type="submit" class="button subtle">Upload</button>
</div>
</form>
<form method="post" action="/admin/settings/nav-icons/reset" class="inline-form">
<form method="post" action="/admin/settings/nav-icons/reset" class="inline-form" data-confirm-title="Reset navigation icon" data-confirm-text="Reset the custom icon for <%= item.label %> to its default." data-confirm-label="Reset icon">
<input type="hidden" name="item_id" value="<%= item.id %>" />
<button type="submit" class="button subtle">Reset</button>
</form>
@ -125,4 +195,5 @@
<% }) %>
</div>
</section>
<script src="/homepage-builder.js?v=<%= assetVersion %>" defer></script>
<%- include("partials/layout-bottom") %>

View File

@ -1,122 +1,384 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Theming</h1>
<p>Update light and dark mode colors used across the WebUI.</p>
<form method="post" action="/admin/theming" class="form-grid theme-grid">
<h2>Light mode</h2>
<div class="field">
<label>Background 1</label>
<input type="color" name="theme_light_bg_1" value="<%= theme.light.bg1 %>" />
</div>
<div class="field">
<label>Background 2</label>
<input type="color" name="theme_light_bg_2" value="<%= theme.light.bg2 %>" />
</div>
<div class="field">
<label>Background 3</label>
<input type="color" name="theme_light_bg_3" value="<%= theme.light.bg3 %>" />
</div>
<div class="field">
<label>Text</label>
<input type="color" name="theme_light_text" value="<%= theme.light.text %>" />
</div>
<div class="field">
<label>Muted text</label>
<input type="color" name="theme_light_text_muted" value="<%= theme.light.muted %>" />
</div>
<div class="field">
<label>Accent</label>
<input type="color" name="theme_light_accent" value="<%= theme.light.accent %>" />
</div>
<div class="field">
<label>Accent alt</label>
<input type="color" name="theme_light_accent_alt" value="<%= theme.light.accentAlt %>" />
</div>
<div class="field">
<label>Danger</label>
<input type="color" name="theme_light_danger" value="<%= theme.light.danger %>" />
</div>
<div class="field">
<label>Surface</label>
<input type="color" name="theme_light_surface" value="<%= theme.light.surface %>" />
</div>
<div class="field">
<label>Surface 2</label>
<input type="color" name="theme_light_surface_2" value="<%= theme.light.surface2 %>" />
</div>
<div class="field">
<label>Surface 3</label>
<input type="color" name="theme_light_surface_3" value="<%= theme.light.surface3 %>" />
</div>
<div class="field">
<label>Border</label>
<input type="color" name="theme_light_border" value="<%= theme.light.border %>" />
</div>
<link rel="stylesheet" href="/theme-editor.css?v=<%= assetVersion %>" />
<%
const fieldLabels = {
bg1: "Background glow",
bg2: "Page background",
bg3: "Accent glow",
text: "Primary text",
muted: "Muted text",
accent: "Primary",
accentAlt: "Accent",
success: "Success",
warning: "Warning",
danger: "Danger",
info: "Information",
surface: "Surface",
surface2: "Subtle surface",
surface3: "Raised surface",
border: "Border",
link: "Link",
buttonBg: "Button background",
buttonText: "Button text",
buttonHover: "Button hover",
inputBg: "Input background",
inputBorder: "Input border",
inputText: "Input text",
focusRing: "Focus ring"
};
const basicFields = [
"accent", "accentAlt", "bg2", "surface", "surface2", "text", "muted",
"border", "success", "warning", "danger", "info"
];
const advancedFields = [
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
"buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
];
const fontStackEntries = Object.entries((typeof fontStacks !== "undefined" && fontStacks) || {});
const baseTheme = (typeof editingBaseTheme !== "undefined" && editingBaseTheme) || editingTheme || activeTheme;
%>
<h2>Dark mode</h2>
<div class="field">
<label>Background 1</label>
<input type="color" name="theme_dark_bg_1" value="<%= theme.dark.bg1 %>" />
</div>
<div class="field">
<label>Background 2</label>
<input type="color" name="theme_dark_bg_2" value="<%= theme.dark.bg2 %>" />
</div>
<div class="field">
<label>Background 3</label>
<input type="color" name="theme_dark_bg_3" value="<%= theme.dark.bg3 %>" />
</div>
<div class="field">
<label>Text</label>
<input type="color" name="theme_dark_text" value="<%= theme.dark.text %>" />
</div>
<div class="field">
<label>Muted text</label>
<input type="color" name="theme_dark_text_muted" value="<%= theme.dark.muted %>" />
</div>
<div class="field">
<label>Accent</label>
<input type="color" name="theme_dark_accent" value="<%= theme.dark.accent %>" />
</div>
<div class="field">
<label>Accent alt</label>
<input type="color" name="theme_dark_accent_alt" value="<%= theme.dark.accentAlt %>" />
</div>
<div class="field">
<label>Danger</label>
<input type="color" name="theme_dark_danger" value="<%= theme.dark.danger %>" />
</div>
<div class="field">
<label>Surface</label>
<input type="color" name="theme_dark_surface" value="<%= theme.dark.surface %>" />
</div>
<div class="field">
<label>Surface 2</label>
<input type="color" name="theme_dark_surface_2" value="<%= theme.dark.surface2 %>" />
</div>
<div class="field">
<label>Surface 3</label>
<input type="color" name="theme_dark_surface_3" value="<%= theme.dark.surface3 %>" />
</div>
<div class="field">
<label>Border</label>
<input type="color" name="theme_dark_border" value="<%= theme.dark.border %>" />
</div>
<header class="page-header">
<div>
<span class="eyebrow">Appearance</span>
<h1>Theme studio</h1>
<p class="command-subtitle">
Select a protected Lumi preset or duplicate one into a custom theme.
</p>
</div>
<span class="status-indicator status-success">
Active: <strong><%= activeTheme.name %></strong>
</span>
</header>
<h2>Role colors</h2>
<div class="field">
<label>Public</label>
<input type="color" name="theme_role_public" value="<%= theme.role.public %>" />
<section class="theme-library" aria-labelledby="theme-library-title">
<div class="section-header">
<div>
<h2 id="theme-library-title">Theme library</h2>
<p class="hint">Built-in themes are read-only and always remain available.</p>
</div>
<div class="field">
<label>Moderator</label>
<input type="color" name="theme_role_mod" value="<%= theme.role.mod %>" />
</div>
<div class="field">
<label>Admin</label>
<input type="color" name="theme_role_admin" value="<%= theme.role.admin %>" />
</div>
<button type="submit" class="button">Save theme</button>
</form>
</div>
<div class="theme-card-grid">
<% themes.forEach((item) => { %>
<% const customId = item.builtin ? null : item.id.replace("custom:", ""); %>
<article class="theme-card <%= item.id === activeTheme.id ? 'is-active' : '' %>">
<div class="theme-swatch" style="--swatch-bg: <%= item.light.bg2 %>; --swatch-surface: <%= item.light.surface %>; --swatch-primary: <%= item.light.accent %>; --swatch-accent: <%= item.light.accentAlt %>;">
<span></span><span></span><span></span>
</div>
<div class="theme-card-copy">
<div class="theme-card-title">
<h3><%= item.name %></h3>
<span class="theme-kind"><%- item.builtin ? "Built-in &middot; read-only" : "Custom" %></span>
</div>
<p><%= item.description %></p>
</div>
<div class="theme-card-actions">
<% if (item.id !== activeTheme.id) { %>
<form method="post" action="/admin/theming/select">
<input type="hidden" name="theme_id" value="<%= item.id %>" />
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Apply" },
{ id: "loading", text: "Applying", spinner: true },
{ id: "success", text: "Applied" }
]
}) %>
</form>
<% } else { %>
<span class="button subtle disabled" aria-current="true">Active</span>
<% } %>
<% if (!item.builtin) { %>
<a class="button subtle" href="/admin/theming?edit=<%= encodeURIComponent(item.id) %>#theme-editor">Edit</a>
<% } %>
</div>
<details class="theme-card-more">
<summary>More actions</summary>
<div class="theme-card-more-body">
<form method="post" action="/admin/theming/duplicate" class="compact-form">
<input type="hidden" name="theme_id" value="<%= item.id %>" />
<label>
<span>Copy name</span>
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
</label>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Duplicate" },
{ id: "loading", text: "Duplicating", spinner: true },
{ id: "success", text: "Created" }
]
}) %>
</form>
<% if (!item.builtin) { %>
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
<label>
<span>Theme name</span>
<input name="name" value="<%= item.name %>" maxlength="60" required />
</label>
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Rename" },
{ id: "loading", text: "Renaming", spinner: true },
{ id: "success", text: "Renamed" }
]
}) %>
</form>
<form
method="post"
action="/admin/theming/custom/<%= customId %>/delete"
data-confirm-title="Delete custom theme"
data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected."
data-confirm-label="Delete theme"
>
<button type="submit" class="button danger">Delete</button>
</form>
<% } %>
</div>
</details>
</article>
<% }) %>
</div>
</section>
<% if (editingTheme) { %>
<section class="theme-editor-shell" id="theme-editor" data-theme-editor>
<div class="theme-editor-main card">
<div class="section-header">
<div>
<span class="eyebrow">Custom theme</span>
<h2>Edit <%= editingTheme.name %></h2>
<p class="hint">Based on <%= themes.find((item) => item.id === editingTheme.baseThemeId)?.name || "Lumi Default" %>.</p>
</div>
<div class="button-group" aria-label="Preview color scheme">
<button type="button" class="button subtle is-selected" data-theme-preview-mode="light">Light preview</button>
<button type="button" class="button subtle" data-theme-preview-mode="dark">Dark preview</button>
<button type="button" class="button subtle theme-mobile-preview-action" data-theme-popout>Preview</button>
</div>
</div>
<form
method="post"
action="/admin/theming/custom/<%= editingTheme.id.replace('custom:', '') %>/save"
class="theme-edit-form"
data-theme-form
>
<% ["light", "dark"].forEach((mode) => { %>
<fieldset class="theme-fieldset" data-theme-mode-fields="<%= mode %>">
<legend><%= mode === "light" ? "Light mode" : "Dark mode" %></legend>
<div class="theme-control-grid">
<% basicFields.forEach((field) => { %>
<label class="theme-color-control">
<span><%= fieldLabels[field] %></span>
<span class="theme-color-input">
<input
type="color"
name="<%= mode %>_<%= field %>"
value="<%= editingTheme[mode][field] %>"
data-theme-token="<%= field %>"
data-theme-mode="<%= mode %>"
data-base-value="<%= baseTheme[mode][field] %>"
/>
<output><%= editingTheme[mode][field] %></output>
</span>
</label>
<% }) %>
</div>
<details class="advanced-theme-controls">
<summary>Advanced <%= mode %> variables</summary>
<div class="theme-control-grid">
<% advancedFields.forEach((field) => { %>
<label class="theme-color-control">
<span><%= fieldLabels[field] %></span>
<span class="theme-color-input">
<input
type="color"
name="<%= mode %>_<%= field %>"
value="<%= editingTheme[mode][field] %>"
data-theme-token="<%= field %>"
data-theme-mode="<%= mode %>"
data-base-value="<%= baseTheme[mode][field] %>"
/>
<output><%= editingTheme[mode][field] %></output>
</span>
</label>
<% }) %>
</div>
</details>
</fieldset>
<% }) %>
<fieldset class="theme-fieldset">
<legend>Roles and shape</legend>
<div class="theme-control-grid">
<% ["public", "mod", "admin"].forEach((role) => { %>
<label class="theme-color-control">
<span><%= role.charAt(0).toUpperCase() + role.slice(1) %> role</span>
<span class="theme-color-input">
<input type="color" name="role_<%= role %>" value="<%= editingTheme.role[role] %>" data-theme-role="<%= role %>" data-base-value="<%= baseTheme.role[role] %>" />
<output><%= editingTheme.role[role] %></output>
</span>
</label>
<% }) %>
<label class="theme-range-control">
<span>Border radius <output data-range-output><%= editingTheme.metrics.radius %>px</output></span>
<input type="range" name="metrics_radius" min="0" max="32" step="1" value="<%= editingTheme.metrics.radius %>" data-theme-metric="radius" data-unit="px" data-base-value="<%= baseTheme.metrics.radius %>" />
</label>
<label class="theme-range-control">
<span>Shadow strength <output data-range-output><%= editingTheme.metrics.shadowStrength %></output></span>
<input type="range" name="metrics_shadowStrength" min="0" max="0.35" step="0.01" value="<%= editingTheme.metrics.shadowStrength %>" data-theme-metric="shadowStrength" data-base-value="<%= baseTheme.metrics.shadowStrength %>" />
</label>
<label class="theme-range-control">
<span>Spacing scale <output data-range-output><%= editingTheme.metrics.spacingScale %></output></span>
<input type="range" name="metrics_spacingScale" min="0.75" max="1.35" step="0.05" value="<%= editingTheme.metrics.spacingScale %>" data-theme-metric="spacingScale" data-base-value="<%= baseTheme.metrics.spacingScale %>" />
</label>
</div>
</fieldset>
<fieldset class="theme-fieldset">
<legend>Typography</legend>
<div class="theme-control-grid">
<% [
["bodyFont", "Body font", "data-theme-font='body'"],
["displayFont", "Heading font", "data-theme-font='display'"],
["monoFont", "Monospace font", "data-theme-font='mono'"]
].forEach(([field, label, dataAttr]) => { %>
<label class="theme-select-control">
<span><%= label %></span>
<select
name="typography_<%= field %>"
<%- dataAttr %>
data-base-value="<%= baseTheme.typography[field] %>"
>
<% fontStackEntries.forEach(([key, font]) => { %>
<option value="<%= key %>" data-font-stack="<%= font.stack %>" <%= editingTheme.typography[field] === key ? "selected" : "" %>><%= font.label %></option>
<% }) %>
</select>
</label>
<% }) %>
<label class="theme-range-control">
<span>Base text size <output data-range-output><%= editingTheme.typography.baseSize %>px</output></span>
<input type="range" name="typography_baseSize" min="14" max="19" step="1" value="<%= editingTheme.typography.baseSize %>" data-theme-typography="baseSize" data-unit="px" data-base-value="<%= baseTheme.typography.baseSize %>" />
</label>
<label class="theme-range-control">
<span>Heading scale <output data-range-output><%= editingTheme.typography.headingScale %></output></span>
<input type="range" name="typography_headingScale" min="0.9" max="1.2" step="0.01" value="<%= editingTheme.typography.headingScale %>" data-theme-typography="headingScale" data-base-value="<%= baseTheme.typography.headingScale %>" />
</label>
<label class="theme-range-control">
<span>Control density <output data-range-output><%= editingTheme.typography.controlScale %></output></span>
<input type="range" name="typography_controlScale" min="0.9" max="1.12" step="0.01" value="<%= editingTheme.typography.controlScale %>" data-theme-typography="controlScale" data-base-value="<%= baseTheme.typography.controlScale %>" />
</label>
</div>
<div class="theme-inline-actions">
<button type="button" class="button subtle" data-theme-reset-to-base>Reset changed controls to base theme</button>
</div>
</fieldset>
<div class="theme-validation-panel" data-theme-warnings aria-live="polite"></div>
<div class="theme-editor-actions">
<label class="switch">
<input type="checkbox" class="switch-input" name="apply" <%= editingTheme.id === activeTheme.id ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Apply globally after saving</span>
</label>
<div class="button-group">
<%- include("partials/state-button", {
type: "button",
classes: "subtle",
attrs: "data-theme-reset",
resetDelay: 1200,
states: [
{ id: "idle", text: "Revert unsaved" },
{ id: "success", text: "Reverted" }
]
}) %>
<a href="/admin/theming" class="button subtle">Cancel</a>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Save theme" },
{ id: "loading", text: "Saving", spinner: true },
{ id: "success", text: "Saved" }
]
}) %>
</div>
</div>
</form>
</div>
<aside class="theme-preview card" aria-label="Live theme preview">
<div class="theme-preview-header">
<span class="eyebrow">Live preview</span>
<button type="button" class="button subtle theme-popout-button" data-theme-popout>Pop out</button>
</div>
<div class="theme-preview-window" data-theme-preview-root>
<div class="theme-preview-nav">
<span class="theme-preview-logo">L</span>
<span class="is-active"></span><span></span><span></span>
</div>
<div class="theme-preview-content">
<span class="eyebrow theme-preview-pill">Preview pill</span>
<h2>Theme controlled heading</h2>
<p class="hint">Typography, spacing, colors, radius, shadows, and state tokens update only inside this preview.</p>
<div class="theme-preview-sample-card">
<strong>Community overview</strong>
<p>Preview text, surfaces, borders, and controls before saving.</p>
<div class="button-group">
<span class="button">Primary</span>
<span class="button subtle">Secondary</span>
<span class="button lumi-state-btn">
<span class="lumi-state-btn-content">
<span data-state-view="idle">Download</span>
<span data-state-view="loading" data-state-hidden="true"><span class="lumi-state-btn-spinner" aria-hidden="true"></span>Downloading</span>
<span data-state-view="success" data-state-hidden="true">Downloaded</span>
</span>
</span>
</div>
<input value="Input preview" readonly aria-label="Input preview" />
<label class="switch">
<input class="switch-input" type="checkbox" checked />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Toggle sample</span>
</label>
<div class="theme-preview-statuses">
<span class="status-success">Success</span>
<span class="status-warning">Warning</span>
<span class="status-danger">Danger</span>
</div>
<div class="theme-preview-badges">
<span class="badge">Badge</span>
<span class="pill">Role <span style="color:var(--role-admin)">admin</span></span>
</div>
<div class="is-unsaved theme-preview-dirty">Dirty state marker</div>
<div class="table-wrap theme-preview-table">
<table class="table">
<thead><tr><th>Level</th><th>Message</th></tr></thead>
<tbody><tr><td>Info</td><td>Command synced</td></tr><tr><td>Warn</td><td>Queue growing</td></tr></tbody>
</table>
</div>
<div class="theme-preview-modal-sample">
<strong>Modal sample</strong>
<p class="hint">Radius, border, surface, and shadow.</p>
</div>
<div class="theme-spacing-sample"><span></span><span></span><span></span></div>
</div>
</div>
</div>
<p class="hint" data-theme-popout-status>Preview changes are local to this page until you save.</p>
</aside>
</section>
<% } else { %>
<section class="empty-state">
<div>
<h2>Create a custom theme to edit</h2>
<p>Open "More actions" on any built-in or custom theme, then duplicate it.</p>
</div>
</section>
<% } %>
<script src="/theme-editor.js?v=<%= assetVersion %>"></script>
<%- include("partials/layout-bottom") %>

View File

@ -1,76 +1,267 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Updates</h1>
<p>Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.</p>
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
</section>
<%
const status = updateStatus || {};
const core = status.core || null;
const plugins = status.plugins || [];
const summary = status.plugins_summary || { installed_plugins: 0, total_plugins: 0, updatable_plugins: 0, blocked_plugins: 0 };
const recovery = recoveryStatus || status.recovery || {};
const marker = recovery.marker || null;
const selectedSource = status.requested_source || "stable";
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current";
const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
const applyConfirmText = (item, label) => {
const base = `${label}: create a snapshot, write a recovery marker, apply ${item?.safe_target_version || "the selected repo target"}, and verify before finishing.`;
if (!item?.unversioned_update) return base;
const warnings = (item.warnings || []).join(" ");
return `${base} Warning: this update is to or from an unversioned install/target, so Lumi cannot verify version ordering, changelog range, or rollback safety. ${warnings}`;
};
%>
<section class="card">
<h2>Git updates</h2>
<p>Check or pull updates from the remote and branch configured in Settings.</p>
<%- include("partials/page-header", {
eyebrow: "Maintenance",
pageTitle: "Updates",
description: "Version-aware core and plugin updates with snapshots, safe targets, recovery markers, revert, and advanced ZIP fallback."
}) %>
<% if (updateStatusError) { %>
<div class="callout danger">Update metadata could not be loaded: <%= updateStatusError %></div>
<% } %>
<form method="get" action="/admin/updates" class="inline-actions">
<label class="field compact">
<span>Update source</span>
<select name="source">
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option>
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option>
</select>
</label>
<button class="button subtle" type="submit">Reload metadata</button>
</form>
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
</section>
<% if (recovery.has_incomplete_marker || marker) { %>
<section class="card update-recovery-banner">
<h2>Safe Mode / Recovery</h2>
<p>
Recovery marker status:
<strong><%= marker?.status || "none" %></strong>
<% if (marker?.target_kind) { %>
for <strong><%= marker.target_kind %><%= marker.target_id ? `:${marker.target_id}` : "" %></strong>
<% } %>
</p>
<% if (marker) { %>
<div class="update-meta-grid">
<div><span>From</span><strong><%= marker.from_version || "unknown" %></strong></div>
<div><span>To</span><strong><%= marker.to_version || "unknown" %></strong></div>
<div><span>Method</span><strong><%= marker.update_method || "unknown" %></strong></div>
<div><span>Source</span><strong><%= marker.source_branch || "unknown" %></strong></div>
<div><span>Snapshot</span><strong><%= marker.snapshot_id || "pending" %></strong></div>
<div><span>Updated</span><strong><%= marker.updated_at ? new Date(marker.updated_at).toLocaleString() : "unknown" %></strong></div>
</div>
<% if (marker.error) { %><p class="hint status-danger"><%= marker.error %></p><% } %>
<% } %>
<div class="inline-actions">
<form method="post" action="/admin/check-update" class="inline-form">
<button type="submit" class="button subtle">Check for updates</button>
<form method="post" action="/admin/updates/recovery/clear-marker" data-confirm-mode="modal" data-confirm-title="Clear recovery marker" data-confirm-text="Clear the stale recovery marker? Do this only after you have verified Lumi starts normally." data-confirm-label="Clear marker">
<button class="button danger" type="submit">Clear stale marker</button>
</form>
<form method="post" action="/admin/update" class="inline-form">
<button type="submit" class="button">Update from git</button>
<form method="post" action="/admin/updates/recovery/retry-normal-startup">
<button class="button subtle" type="submit">Retry normal startup</button>
</form>
</div>
</section>
<% } %>
<section class="card">
<h2>Upload bot update</h2>
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">
<div class="field full">
<input type="file" name="update_zip" accept=".zip" required />
<section class="card" data-update-panel>
<details class="lumi-expandable-settings" <%= core?.blocked || core?.update_available || recovery.has_incomplete_marker ? "open" : "" %>>
<summary>
<span>
<strong>Core</strong>
<span class="hint">Current <%= core?.current_version || "unknown" %> · Target <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %></span>
</span>
<span class="badge <%= badgeClass(core) %>"><%= badgeText(core) %></span>
</summary>
<div class="lumi-expandable-body update-detail-grid">
<% if (core) { %>
<div class="update-meta-grid">
<div><span>Current</span><strong><%= core.current_version %></strong></div>
<div><span>Safe target</span><strong><%= core.safe_target_version || "None" %></strong></div>
<div><span>Latest</span><strong><%= core.latest_available_version %></strong></div>
<div><span>Source branch</span><strong><%= core.source_branch %></strong></div>
<div><span>Size change</span><strong><%= core.size_delta_label %></strong></div>
<div><span>Snapshot</span><strong><%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %></strong></div>
</div>
<p><%= core.version_description %></p>
<% if (core.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% core.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This core update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
<% if (core.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% core.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.requirements?.length) { %><div class="callout"><strong>Requirements</strong><ul><% core.requirements.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= core.migration_notes %></p></div><% } %>
<div>
<h3>Changelog to target</h3>
<% if (!changelogItems(core).length) { %>
<p class="hint">No changelog entries were found for this range.</p>
<% } else { %>
<ul class="update-changelog">
<% changelogItems(core).forEach((entry) => { %>
<li><strong><%= entry.version %></strong><span><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %></span></li>
<% }) %>
</ul>
<% } %>
</div>
<div class="inline-actions">
<form method="post" action="/admin/updates/core/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check core</button>
</form>
<form method="post" action="/admin/updates/core/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= core.unversioned_update ? "Apply unversioned core update" : "Apply core update" %>" data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= core.blocked || !core.update_available ? "disabled" : "" %>><%= applyLabel(core) %></button>
</form>
<% if (core.snapshot.available) { %>
<form method="post" action="/admin/updates/core/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert core snapshot" data-confirm-text="Revert only the previous core version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert core">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<input type="hidden" name="snapshot_id" value="<%= core.snapshot.latest_snapshot_id %>" />
<button class="button danger" type="submit">Revert previous</button>
</form>
<% } %>
</div>
<details class="inline-details">
<summary>Show advanced ZIP update options</summary>
<div class="callout">ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div>
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required />
<button type="submit" class="button">Upload core ZIP</button>
</div>
<label class="switch"><input type="checkbox" class="switch-input" name="patch_mode" value="1" /><span class="switch-track"></span><span class="switch-text">Patch mode</span></label>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
</form>
</details>
<% } %>
</div>
<div class="field full">
<label>Patch mode (apply only files in ZIP, skip full package verification)</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="patch_mode" value="1" />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Patch mode</span>
</label>
</details>
</section>
<section class="card" data-update-panel>
<details class="lumi-expandable-settings" <%= summary.updatable_plugins || summary.blocked_plugins ? "open" : "" %>>
<summary>
<span>
<strong>Plugins</strong>
<span class="hint"><%= summary.installed_plugins %> installed · <%= summary.total_plugins %> known · <%= summary.updatable_plugins %> updatable</span>
</span>
<span class="badge <%= summary.blocked_plugins ? "danger" : summary.updatable_plugins ? "warning" : "success" %>"><%= summary.blocked_plugins ? `${summary.blocked_plugins} blocked` : `${summary.updatable_plugins} updates` %></span>
</summary>
<div class="lumi-expandable-body plugin-update-list">
<% if (!plugins.length) { %>
<div class="empty-state">No installed plugins found.</div>
<% } %>
<% plugins.forEach((plugin) => { %>
<details class="lumi-expandable-settings plugin-update-row" <%= plugin.blocked || plugin.update_available ? "open" : "" %>>
<summary>
<span>
<strong><%= plugin.name %></strong>
<span class="hint"><%= plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
</span>
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
</summary>
<div class="lumi-expandable-body update-detail-grid">
<div class="update-meta-grid">
<div><span>Current</span><strong><%= plugin.current_version %></strong></div>
<div><span>Safe target</span><strong><%= plugin.safe_target_version || "None" %></strong></div>
<div><span>Latest</span><strong><%= plugin.latest_available_version %></strong></div>
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
</div>
<p><%= plugin.version_description %></p>
<% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
<% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= plugin.migration_notes %></p></div><% } %>
<h3>Changelog to target</h3>
<% if (!changelogItems(plugin).length) { %>
<p class="hint">No changelog entries were found for this range.</p>
<% } else { %>
<ul class="update-changelog">
<% changelogItems(plugin).forEach((entry) => { %>
<li><strong><%= entry.version %></strong><span><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %></span></li>
<% }) %>
</ul>
<% } %>
<div class="inline-actions">
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check plugin</button>
</form>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= plugin.unversioned_update ? `Apply unversioned ${plugin.name} update` : "Apply plugin update" %>" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= applyLabel(plugin) %></button>
</form>
<% if (plugin.snapshot.available) { %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert plugin snapshot" data-confirm-text="Revert only the previous plugin version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert plugin">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<input type="hidden" name="snapshot_id" value="<%= plugin.snapshot.latest_snapshot_id %>" />
<button class="button danger" type="submit">Revert previous</button>
</form>
<% } %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/disable" data-confirm-mode="modal" data-confirm-title="Disable plugin" data-confirm-text="Disable this plugin for recovery? Lumi may need a restart for already-loaded plugin code to unload." data-confirm-label="Disable plugin">
<button class="button danger" type="submit">Disable for recovery</button>
</form>
</div>
<details class="inline-details">
<summary>Show advanced plugin ZIP options</summary>
<div class="callout">Plugin ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/zip" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin ZIP</button>
</div>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
</form>
</details>
</div>
</details>
<% }) %>
</div>
<div class="field full">
<button type="submit" class="button">Upload bot update</button>
</div>
</form>
</details>
</section>
<section class="card">
<h2>Upload plugin update</h2>
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
<div class="field full">
<input type="file" name="plugin_zip" accept=".zip" required />
</div>
<div class="field full">
<button type="submit" class="button">Upload plugin update</button>
</div>
</form>
<h2>Live Progress</h2>
<div class="update-progress-log" data-update-progress-log>
<div class="hint">Update and recovery progress appears here while actions run.</div>
</div>
</section>
<section class="card">
<h2>Snapshots</h2>
<% if (!snapshots.length) { %>
<p>No snapshots yet.</p>
<div class="empty-state">No snapshots yet.</div>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>Snapshot</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<% snapshots.forEach((snap) => { %>
<tr>
<td><%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %></td>
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Target</th>
<th>Version</th>
<th>Method</th>
<th>Rollback</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<% snapshots.forEach((snap) => { %>
<tr>
<td><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td>
<td><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></td>
<td><%= snap.update_method || "snapshot" %></td>
<td><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Allowed previous-version only" %></td>
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -1,8 +1,12 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Users</h1>
<%- include("partials/page-header", {
eyebrow: "Community",
pageTitle: "Users",
description: "Review linked identities, notes, and internal usernames."
}) %>
<% if (!users.length) { %>
<p>No users yet.</p>
<div class="empty-state">No users yet.</div>
<% } else { %>
<div class="table-tools">
<input
@ -12,6 +16,7 @@
data-table-filter="user-list"
/>
</div>
<div class="table-wrap">
<table class="table" data-table="user-list">
<thead>
<tr>
@ -71,6 +76,7 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<div class="modal-backdrop" data-notes-modal aria-hidden="true">

View File

@ -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>

View File

@ -1,10 +1,14 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Leaderboards</h1>
<%- include("partials/page-header", {
eyebrow: "Community",
pageTitle: "Leaderboards",
description: "Browse activity across core features and installed plugins."
}) %>
</section>
<% if (!sections || !sections.length) { %>
<section class="card">
<section class="empty-state">
<p>No activity recorded yet.</p>
</section>
<% } else { %>
@ -20,6 +24,7 @@
<% if (!board.rows || !board.rows.length) { %>
<p><%= board.emptyMessage || "No data recorded yet." %></p>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
@ -46,6 +51,7 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% }) %>
<% } %>

View File

@ -0,0 +1,23 @@
<%- include("partials/layout-top", { title }) %>
<section class="standalone-card card">
<span class="eyebrow">Development access</span>
<h1>Localhost Login</h1>
<p class="hint">
This login option is only available when the site is opened from localhost.
The default credentials are admin / admin until changed in settings.
</p>
<form method="post" action="/auth/localhost" class="form-grid">
<div class="field full">
<label>Username</label>
<input name="username" value="<%= username || 'admin' %>" autocomplete="username" required />
</div>
<div class="field full">
<label>Password</label>
<input name="password" type="password" autocomplete="current-password" required />
</div>
<div class="field full">
<button type="submit" class="button">Login locally</button>
</div>
</form>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -10,6 +10,7 @@
<% } %>
</div>
</div>
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
<div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
<div class="modal-header">
@ -24,6 +25,8 @@
</div>
</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>

View File

@ -4,46 +4,13 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %> - <%= siteTitle %></title>
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
<% if (theme) { %>
<style>
:root {
--ink: <%= theme.light.text %>;
--ink-soft: <%= theme.light.muted %>;
--sea: <%= theme.light.accent %>;
--sun: <%= theme.light.accentAlt %>;
--rose: <%= theme.light.danger %>;
--card: <%= theme.light.surface %>;
--surface-2: <%= theme.light.surface2 %>;
--surface-3: <%= theme.light.surface3 %>;
--border: <%= theme.light.border %>;
--bg-1: <%= theme.light.bg1 %>;
--bg-2: <%= theme.light.bg2 %>;
--bg-3: <%= theme.light.bg3 %>;
--role-public: <%= theme.role.public %>;
--role-mod: <%= theme.role.mod %>;
--role-admin: <%= theme.role.admin %>;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: <%= theme.dark.text %>;
--ink-soft: <%= theme.dark.muted %>;
--sea: <%= theme.dark.accent %>;
--sun: <%= theme.dark.accentAlt %>;
--rose: <%= theme.dark.danger %>;
--card: <%= theme.dark.surface %>;
--surface-2: <%= theme.dark.surface2 %>;
--surface-3: <%= theme.dark.surface3 %>;
--border: <%= theme.dark.border %>;
--bg-1: <%= theme.dark.bg1 %>;
--bg-2: <%= theme.dark.bg2 %>;
--bg-3: <%= theme.dark.bg3 %>;
}
}
</style>
<% } %>
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
<%- include("theme-vars", { theme }) %>
</head>
<body>
<body data-theme-id="<%= theme ? theme.id : '' %>">
<% const icons = {
home: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5L12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" fill="currentColor"/></svg>',
spark: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l2.4 5.6L20 11l-5.6 2.4L12 19l-2.4-5.6L4 11l5.6-2.4z" fill="currentColor"/></svg>',

View File

@ -0,0 +1,11 @@
<header class="page-header">
<div>
<% if (typeof eyebrow !== "undefined" && eyebrow) { %>
<span class="eyebrow"><%= eyebrow %></span>
<% } %>
<h1><%= pageTitle %></h1>
<% if (typeof description !== "undefined" && description) { %>
<p class="command-subtitle"><%= description %></p>
<% } %>
</div>
</header>

View File

@ -0,0 +1,36 @@
<%
const stateButtonStates = (typeof states !== "undefined" && states) || [
{ id: "idle", text: (typeof text !== "undefined" && text) || "Submit" },
{ id: "loading", text: (typeof loadingText !== "undefined" && loadingText) || "Working...", spinner: true },
{ id: "success", text: (typeof successText !== "undefined" && successText) || "Done" }
];
const stateButtonDefault = (typeof defaultState !== "undefined" && defaultState) || "idle";
const stateButtonAttrs = (typeof attrs !== "undefined" && attrs) || "";
const stateButtonClass = `button lumi-state-btn ${(typeof classes !== "undefined" && classes) || ""}`.trim();
%>
<button
type="<%= (typeof type !== "undefined" && type) || 'button' %>"
class="<%= stateButtonClass %>"
data-lumi-state-button
data-state="<%= stateButtonDefault %>"
data-default-state="<%= stateButtonDefault %>"
data-loading-state="<%= (typeof loadingState !== "undefined" && loadingState) || 'loading' %>"
data-success-state="<%= (typeof successState !== "undefined" && successState) || 'success' %>"
data-error-state="<%= (typeof errorState !== "undefined" && errorState) || 'error' %>"
data-reset-delay="<%= (typeof resetDelay !== "undefined" && resetDelay) || 0 %>"
data-disable-while-busy="<%= typeof disableWhileBusy !== "undefined" && disableWhileBusy === false ? 'false' : 'true' %>"
<%- typeof name !== "undefined" && name ? `name="${name}"` : "" %>
<%- typeof value !== "undefined" && value ? `value="${value}"` : "" %>
<%- typeof ariaLabel !== "undefined" && ariaLabel ? `aria-label="${ariaLabel}"` : "" %>
<%- typeof disabled !== "undefined" && disabled ? "disabled" : "" %>
<%- stateButtonAttrs %>
>
<span class="lumi-state-btn-content">
<% stateButtonStates.forEach((state) => { %>
<span data-state-view="<%= state.id %>" data-state-hidden="<%= state.id === stateButtonDefault ? 'false' : 'true' %>" aria-hidden="<%= state.id === stateButtonDefault ? 'false' : 'true' %>">
<% if (state.spinner) { %><span class="lumi-state-btn-spinner" aria-hidden="true"></span><% } %>
<span><%= state.text %></span>
</span>
<% }) %>
</span>
</button>

View File

@ -0,0 +1,97 @@
<% if (theme) { %>
<style>
:root {
--ink: <%= theme.light.text %>;
--ink-soft: <%= theme.light.muted %>;
--sea: <%= theme.light.accent %>;
--sun: <%= theme.light.accentAlt %>;
--rose: <%= theme.light.danger %>;
--card: <%= theme.light.surface %>;
--surface-2: <%= theme.light.surface2 %>;
--surface-3: <%= theme.light.surface3 %>;
--border: <%= theme.light.border %>;
--bg-1: <%= theme.light.bg1 %>;
--bg-2: <%= theme.light.bg2 %>;
--bg-3: <%= theme.light.bg3 %>;
--lumi-success: <%= theme.light.success %>;
--lumi-warning: <%= theme.light.warning %>;
--lumi-info: <%= theme.light.info %>;
--lumi-link: <%= theme.light.link %>;
--lumi-button-bg: <%= theme.light.buttonBg %>;
--lumi-button-text: <%= theme.light.buttonText %>;
--lumi-button-hover: <%= theme.light.buttonHover %>;
--lumi-input-bg: <%= theme.light.inputBg %>;
--lumi-input-border: <%= theme.light.inputBorder %>;
--lumi-input-text: <%= theme.light.inputText %>;
--lumi-focus: <%= theme.light.focusRing %>;
--lumi-radius: <%= theme.metrics.radius %>px;
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
--lumi-space-scale: <%= theme.metrics.spacingScale %>;
--lumi-font-body: <%- theme.typography.bodyFontStack %>;
--lumi-font-display: <%- theme.typography.displayFontStack %>;
--lumi-font-mono: <%- theme.typography.monoFontStack %>;
--lumi-font-size-base: <%= theme.typography.baseSize %>px;
--lumi-heading-scale: <%= theme.typography.headingScale %>;
--lumi-control-scale: <%= theme.typography.controlScale %>;
--role-public: <%= theme.role.public %>;
--role-mod: <%= theme.role.mod %>;
--role-admin: <%= theme.role.admin %>;
}
@media (prefers-color-scheme: dark) {
:root:not([data-color-scheme="light"]) {
--ink: <%= theme.dark.text %>;
--ink-soft: <%= theme.dark.muted %>;
--sea: <%= theme.dark.accent %>;
--sun: <%= theme.dark.accentAlt %>;
--rose: <%= theme.dark.danger %>;
--card: <%= theme.dark.surface %>;
--surface-2: <%= theme.dark.surface2 %>;
--surface-3: <%= theme.dark.surface3 %>;
--border: <%= theme.dark.border %>;
--bg-1: <%= theme.dark.bg1 %>;
--bg-2: <%= theme.dark.bg2 %>;
--bg-3: <%= theme.dark.bg3 %>;
--lumi-success: <%= theme.dark.success %>;
--lumi-warning: <%= theme.dark.warning %>;
--lumi-info: <%= theme.dark.info %>;
--lumi-link: <%= theme.dark.link %>;
--lumi-button-bg: <%= theme.dark.buttonBg %>;
--lumi-button-text: <%= theme.dark.buttonText %>;
--lumi-button-hover: <%= theme.dark.buttonHover %>;
--lumi-input-bg: <%= theme.dark.inputBg %>;
--lumi-input-border: <%= theme.dark.inputBorder %>;
--lumi-input-text: <%= theme.dark.inputText %>;
--lumi-focus: <%= theme.dark.focusRing %>;
}
}
:root[data-color-scheme="dark"] {
color-scheme: dark;
--ink: <%= theme.dark.text %>;
--ink-soft: <%= theme.dark.muted %>;
--sea: <%= theme.dark.accent %>;
--sun: <%= theme.dark.accentAlt %>;
--rose: <%= theme.dark.danger %>;
--card: <%= theme.dark.surface %>;
--surface-2: <%= theme.dark.surface2 %>;
--surface-3: <%= theme.dark.surface3 %>;
--border: <%= theme.dark.border %>;
--bg-1: <%= theme.dark.bg1 %>;
--bg-2: <%= theme.dark.bg2 %>;
--bg-3: <%= theme.dark.bg3 %>;
--lumi-success: <%= theme.dark.success %>;
--lumi-warning: <%= theme.dark.warning %>;
--lumi-info: <%= theme.dark.info %>;
--lumi-link: <%= theme.dark.link %>;
--lumi-button-bg: <%= theme.dark.buttonBg %>;
--lumi-button-text: <%= theme.dark.buttonText %>;
--lumi-button-hover: <%= theme.dark.buttonHover %>;
--lumi-input-bg: <%= theme.dark.inputBg %>;
--lumi-input-border: <%= theme.dark.inputBorder %>;
--lumi-input-text: <%= theme.dark.inputText %>;
--lumi-focus: <%= theme.dark.focusRing %>;
}
:root[data-color-scheme="light"] {
color-scheme: light;
}
</style>
<% } %>

View File

@ -1,7 +1,10 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Initial setup</h1>
<p>Enable the platforms you plan to use, then run each wizard to configure credentials.</p>
<%- include("partials/page-header", {
eyebrow: "Get started",
pageTitle: "Initial setup",
description: "Enable the platforms you plan to use, then configure each connection."
}) %>
<p class="hint">Once at least one platform is configured, you can log in and manage everything from the WebUI.</p>
<div class="grid">
<% (platforms || []).forEach((platform) => { %>