Advance experimental OKF and Lumi AI systems
This commit is contained in:
parent
df94125089
commit
32ec0a102f
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,6 +16,7 @@ npm-debug.log
|
||||
security-audit-*.json
|
||||
security-audit-*.md
|
||||
taskfile.txt
|
||||
taskfile-export.json
|
||||
codex-guidelines
|
||||
Twitch.png
|
||||
twitch-credentials-lumi.png
|
||||
|
||||
415
TODO.md
415
TODO.md
@ -2,11 +2,13 @@
|
||||
|
||||
This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note.
|
||||
|
||||
## OKF Knowledge System
|
||||
## Current Local State / Source of Truth
|
||||
|
||||
Current state on `experimental-okf` as of 2026-06-18: standalone OKF plugin work is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants through the shared core user lookup, workflow actions, version history, version restore, role-preview tooling, and role-aware Lumi AI context integration through the generic AI context provider hook. File-backed OKF directories, indexing, and feedback-to-correction flow remain for later passes.
|
||||
### OKF Knowledge System
|
||||
|
||||
### Implemented Locally
|
||||
Current state on `experimental-okf` as of 2026-06-21: standalone OKF plugin work is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants through the shared core user lookup, workflow actions, version history, version restore, role-preview tooling, role-aware Lumi AI context integration through the generic AI context provider hook, a file-backed Markdown knowledge retrieval layer, generated core/plugin OKF baseline files, admin-editable community OKF files, and admin-created correction OKF files from reviewed core feedback.
|
||||
|
||||
#### Implemented Locally
|
||||
|
||||
- Added standalone `plugins/okf` plugin with WebUI route mounting and navigation registration.
|
||||
- Added plugin-owned SQLite tables for OKF entries, versions, and per-user OKF permission grants.
|
||||
@ -34,144 +36,35 @@ Current state on `experimental-okf` as of 2026-06-18: standalone OKF plugin work
|
||||
- Made Lumi AI context providers receive user/message/scope/origin context so per-user OKF permissions can be enforced.
|
||||
- Ensured OKF AI context includes only published and approved entries and only fields visible to the requesting user.
|
||||
- Changed OKF aliases/related questions to newline-only input so commas remain valid inside questions.
|
||||
- Added `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections` with ownership documentation and keep-files.
|
||||
- Added dependency-free OKF Markdown/frontmatter parsing for stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps.
|
||||
- Added file-backed OKF chunking by Markdown heading with source metadata: path, id, heading, score, and excerpt.
|
||||
- Wired file-backed OKF retrieval into Lumi AI context alongside existing SQLite entries.
|
||||
- Enforced file retrieval priority: corrections, community, plugins, then core.
|
||||
- Enforced file-backed visibility for user/mod/admin knowledge.
|
||||
- Added access-aware OKF placeholder resolution for visible frontmatter values, with generic missing-reference markers for hidden or unavailable references.
|
||||
- Added an mtime/size-based file knowledge cache so unchanged files are reused and changed/deleted files are reflected on later searches.
|
||||
- Added generated core/plugin OKF baseline generation from package metadata, README, detected core routes, plugin manifests, plugin routes, and static command trigger hints.
|
||||
- Added generated baseline files under `knowledge/core` and `knowledge/plugins`.
|
||||
- Generated OKF files are marked `generated: true` and `editable: false`; files marked `generated: false` or `editable: true` are not overwritten.
|
||||
- Added an OKF admin community file editor for `knowledge/community` Markdown with frontmatter fields, Markdown body editing, preview, filename rename support, and generated/non-editable save protection.
|
||||
- Added admin feedback-to-correction OKF creation that writes searchable Markdown files under `knowledge/corrections` and links the generated path back to the reviewed feedback item.
|
||||
- Changed selected OKF entry editing into a full-screen admin modal with session-backed draft preservation.
|
||||
- Added global opt-in placeholder auto-suggest for fields that declare scoped placeholder sources, with OKF Markdown fields as the first consumer.
|
||||
- Added system-generated Throne Wishlist destination template placeholders to the global placeholder auto-suggest, using double-brace syntax while preserving legacy single-brace rendering.
|
||||
- Added a server-side placeholder registry, trusted field policy registry, catalog/preview APIs, and save/render validation for first user-visible fields.
|
||||
- Split `/plugins/okf/admin` into tabbed General OKF, Community OKF, and System-generated OKF sections.
|
||||
- Added a read-only System-generated OKF admin tab that lists generated core/plugin knowledge files and previews selected Markdown while preserving role-scoped retrieval semantics.
|
||||
- Replaced Community and System-generated OKF pill rows with responsive searchable/filterable tables while preserving edit and preview actions.
|
||||
- Expanded generated core/plugin OKF route documentation with static route reference details for purpose, inputs, response format, access signals, side effects, and limitations.
|
||||
- Capped OKF AI context output from generated route docs so route help queries do not overfill the local model context, and prefer specific route chunks over generic route-list chunks.
|
||||
- Added `plugins/okf/tests/verify.js`.
|
||||
|
||||
### Remaining Work
|
||||
### Core Feedback System
|
||||
|
||||
- Add file-backed OKF Markdown/frontmatter directories for generated core/plugin/community/corrections knowledge.
|
||||
- Add Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps.
|
||||
- Add OKF indexing/chunking with path/id/heading/score/excerpt source metadata.
|
||||
- Add changed-file reindexing where practical.
|
||||
- Enforce retrieval priority for corrections, community OKF, plugin OKF, and core OKF.
|
||||
- Add safe placeholder resolution for generated OKF references.
|
||||
- Add richer category/tag management and Markdown preview/editor polish.
|
||||
- Add correction-file creation from reviewed feedback after file-backed OKF storage exists.
|
||||
- Add update preservation tests/notes for OKF file paths once those paths exist.
|
||||
Current state on `experimental-okf` as of 2026-06-21: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, screenshot attachment, Codex export, semantic target, draft-retention, and similar-feedback confirmation refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth.
|
||||
|
||||
- Add `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections` directories with documented ownership rules.
|
||||
- Implement OKF Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps.
|
||||
- Generate fixed core/plugin OKF from routes, commands, schemas, plugin metadata, README docs, and defaults.
|
||||
- Add admin-editable community OKF for community names, owner/admin names, bot name, currency, items, roles, lore, links, moderation rules, command rules, and local terminology.
|
||||
- Add corrections OKF files created only through admin review.
|
||||
- Implement safe placeholder resolution for core/plugin OKF references such as `{{community.currency.primary_name}}`.
|
||||
- Preserve source metadata for every retrieved OKF chunk: path, id, heading, score, and excerpt.
|
||||
|
||||
## OKF Retrieval and Indexing
|
||||
|
||||
- Implement or adapt an OKF indexer that chunks Markdown by heading.
|
||||
- Re-index only changed OKF files when possible.
|
||||
- Enforce retrieval priority: active corrections, community OKF, plugin OKF, core OKF.
|
||||
- Add retrieval boosts for corrections.
|
||||
- Ensure missing placeholders never crash context injection.
|
||||
|
||||
## Feedback Loop
|
||||
|
||||
- Add thumbs up/down controls to Lumi chat answers.
|
||||
- Store feedback payloads with rating, prompt, response, retrieved context, optional comment, model/provider, timestamp, and review status.
|
||||
- Show an optional comment field for thumbs down and keep thumbs up lightweight.
|
||||
- Ensure feedback never directly modifies OKF.
|
||||
|
||||
## Admin Feedback Review
|
||||
|
||||
- Add an admin feedback review page or section.
|
||||
- Show prompt, response, rating, comment, retrieved OKF sources, model/provider, and timestamp for each feedback item.
|
||||
- Add review actions: reviewed/no action, good, bad retrieval, bad response, missing knowledge, archive/delete.
|
||||
- Add correction creation UI with title, tags, affected topic/file, and Markdown body.
|
||||
- Save approved corrections as OKF files and re-index after save.
|
||||
- Add links from feedback rows to generated correction files.
|
||||
|
||||
## Storage and Preservation
|
||||
|
||||
- Store feedback queue, review status, source context snapshots, and correction links in SQLite or existing app storage.
|
||||
- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates.
|
||||
- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits.
|
||||
|
||||
## Homepage Hero Embed Requirements
|
||||
|
||||
- Continue improving service-specific "why unavailable" messages where external providers expose enough signal.
|
||||
|
||||
### Discord Server Widget Hero
|
||||
|
||||
- Add optional Discord invite-to-server-ID lookup support if Discord server IDs are not known.
|
||||
|
||||
### Twitch Stream Hero
|
||||
|
||||
- Support additional configured parent domains for alternate hostnames.
|
||||
- Support stream-only and stream-with-chat layouts.
|
||||
- Respect Twitch minimum player size requirements.
|
||||
- If live-only mode is enabled, check live status or gracefully hide/fallback when offline.
|
||||
- Show a clear admin warning when the Twitch embed fails due to missing or incorrect parent domain.
|
||||
- Add admin helper text explaining that Twitch embeds require the site domain to be allowed as a parent domain.
|
||||
|
||||
### YouTube Live Hero
|
||||
|
||||
- Support YouTube live video URLs, raw video IDs, and optional channel-based live lookup.
|
||||
- For static live heroes, extract and use the provided live video ID.
|
||||
- For automatic channel live detection, require YouTube API configuration.
|
||||
- Cache live lookup results to avoid excessive API calls.
|
||||
- Support optional live-only mode.
|
||||
- Support optional YouTube live chat only when a valid live video ID exists.
|
||||
- Support autoplay and muted settings.
|
||||
- If live-only mode is enabled and no active stream is found, skip the hero or show fallback content.
|
||||
- Show fallback/error states for private, removed, age-restricted, embedding-disabled, unavailable, or not-yet-live videos.
|
||||
- Add admin helper text explaining that YouTube Live requires either a live video link or API-based channel lookup.
|
||||
|
||||
### YouTube Video Hero
|
||||
|
||||
- Show fallback/error states for private, removed, age-restricted, unavailable, or embedding-disabled videos.
|
||||
- Detect embedding-disabled videos before save when a YouTube API key or another reliable server-side signal is available.
|
||||
|
||||
### External Embedded Content Hero
|
||||
|
||||
- Support generic external iframe embeds only for sites that explicitly allow embedding.
|
||||
- Detect or gracefully handle sites that block embedding through `X-Frame-Options` or CSP `frame-ancestors`.
|
||||
- Show an admin warning when an external site refuses to be embedded.
|
||||
|
||||
### Hero Embed Admin UX
|
||||
|
||||
- Show “why unavailable” information in the admin UI when a hero cannot render.
|
||||
|
||||
## Update Page UX Improvements
|
||||
|
||||
- Add async in-place checks for the older `/admin` and `/admin/settings` update buttons, matching `/admin/updates`.
|
||||
- Continue simplifying advanced update terminology without hiding recovery-critical detail.
|
||||
|
||||
## Homepage Hero System
|
||||
|
||||
- Improve handling of live-only Twitch heroes and live-state detection.
|
||||
- Add explicit Discord invite-to-widget guidance or lookup support if Discord server IDs are not known.
|
||||
|
||||
## Homepage Hero UX Improvements
|
||||
|
||||
- Review all homepage builder forms for non-technical usability.
|
||||
- Add tooltips and inline explanations where appropriate.
|
||||
|
||||
## UI Text and Button Language
|
||||
|
||||
- Review all button labels, field labels, headings, helper text, warnings, empty states, and confirmation dialogs across Lumi.
|
||||
- Replace technical/internal wording with clearer user-facing wording where possible.
|
||||
- Keep AI-related wording precise enough for admins to understand model, provider, context, retrieval, embeddings, feedback, corrections, and OKF behavior.
|
||||
- Review the update pages and simplify labels such as `Check plugin`, `Apply safe target`, and `Disable for recovery`.
|
||||
- Prefer clear action labels such as `Check for updates`, `Update plugin`, `Use recommended version`, `Disable temporarily`, and `Start recovery mode`.
|
||||
- Add short helper text below advanced or risky update actions explaining what will happen.
|
||||
- Replace implementation-focused wording with task-focused wording wherever practical.
|
||||
- Avoid exposing Git, branch, commit, target, repository, cache, migration, or rollback terms unless the user needs them.
|
||||
- When technical terms are unavoidable, add short tooltips or inline explanations.
|
||||
- Standardize common action wording across all pages: save, update, check, review, restore, disable, enable, archive, delete, retry, import, export, cancel, and confirm.
|
||||
- Ensure destructive or system-changing actions have clear confirmation text.
|
||||
- Ensure error messages explain both what went wrong and what the admin can try next.
|
||||
- Review plugin management, core settings, update pages, homepage management, OKF management, feedback review, and AI configuration for inconsistent wording.
|
||||
- Split highly technical pages into simple and advanced sections where practical.
|
||||
- Hide rarely needed expert controls behind expandable advanced sections.
|
||||
- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values.
|
||||
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
|
||||
- Review localization/translation keys if present so simplified wording remains consistent across languages.
|
||||
|
||||
## Core Feedback System
|
||||
|
||||
Current state on `experimental-feedback-system` as of 2026-06-18: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, and screenshot attachment refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth.
|
||||
|
||||
### Implemented Locally
|
||||
#### Implemented Locally
|
||||
|
||||
- Core-level feedback storage in SQLite:
|
||||
- `feedback_entries`
|
||||
@ -228,6 +121,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- sensitive-data cleanup for screenshots, diagnostics, target metadata, and public admin replies
|
||||
- feedback rows are collapsed by default
|
||||
- Moderate submission rate limit.
|
||||
- Similar-feedback warnings show matching reports and require an explicit confirmation that the new report is distinct or adds useful context; confirmed reports retain this decision in sanitized diagnostics for admin review.
|
||||
- Validation against empty/too-short feedback.
|
||||
- Server-side permission checks for submission, own-feedback details, submitter comments, and admin review.
|
||||
- EJS escaping and sanitized server-side JSON/text handling for submitted feedback.
|
||||
@ -281,23 +175,82 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- logged-in users can open public feedback detail without private diagnostics or attachments
|
||||
- logged-in users can mark existing feedback as affecting them too
|
||||
- logged-in users can add public context comments to existing feedback
|
||||
- Feedback form draft retention:
|
||||
- backdrop clicks and Escape no longer close the feedback modal
|
||||
- explicit close controls preserve the latest non-submitted report in session storage
|
||||
- reopening feedback from the floating button or context menu restores the draft
|
||||
- attached screenshot blobs are retained across modal close/reopen for the current page session
|
||||
- Reset clears the form, stored draft, screenshot, and detected target from the right side of the modal action row
|
||||
|
||||
### Partially Implemented
|
||||
#### Partially Implemented
|
||||
|
||||
- Optional diagnostics exist for browser/user-agent, viewport, language, current URL, page title, selected scope, target metadata, screenshot attachment source, and opt-in sanitized page structure snapshots.
|
||||
- Admin filters cover status/category/severity/scope/plugin-area/submitter/date/needs-action. Richer custom-scope filters are not implemented.
|
||||
- OKF correction conversion is deferred for a later pass. Task tracking remains manual and outside the feedback feature.
|
||||
- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; “also affected” counts; and public context comments, but not full duplicate merging.
|
||||
- Admin-created OKF correction files are available from feedback review, but broader task tracking remains manual and outside the feedback feature.
|
||||
- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; an explicit confirmation path for distinct/additional reports; “also affected” counts; and public context comments, but not full duplicate merging.
|
||||
- Feedback data is in SQLite under `data/app.db`, which is preserved by the update system through the existing protected `data/` path. Screenshot files are stored under `data/feedback/screenshots`, also covered by the protected `data/` path.
|
||||
|
||||
### Remaining Work
|
||||
## Active Work
|
||||
|
||||
### OKF Knowledge System: File-Backed Knowledge Storage
|
||||
|
||||
- Add richer category/tag management and Markdown preview/editor polish.
|
||||
- Continue enriching the non-editable system-generated OKF files so they act as scoped user manuals and developer support context. Route-level details are now generated; remaining work is broader content quality, role-specific depth, and retrieval tuning so casual admin questions do not receive unnecessary technical detail.
|
||||
|
||||
### OKF Retrieval and Indexing
|
||||
|
||||
- Add a more advanced persistent OKF index if file count or search volume requires it later.
|
||||
|
||||
### Global Placeholder System
|
||||
|
||||
- Continue migrating remaining placeholder-compatible fields away from inline JSON/static sources to trusted server-side `field_id` policies.
|
||||
- Consider an admin-managed `/admin/settings -> Placeholders` registry later for custom placeholders under the `{{custom.*}}` namespace.
|
||||
- Expand additional plugin-owned destination template fields to register generated placeholder sources, following the Throne Wishlist pattern.
|
||||
- Add API-backed platform statistic placeholders after reliable fetch/cache layers exist, especially Twitch follower/subscriber counts and scheduled stream start times.
|
||||
- Add async OKF AI context placeholder rendering if Lumi AI context providers are updated to support async providers.
|
||||
|
||||
### Lumi AI Controller Routing
|
||||
|
||||
- Consider a separate controller enablement/advanced-tuning switch only if live testing shows admins need to disable or override the heuristic controller. Internal mode budgets, source profile final-output limits, and controller diagnostics are exposed locally.
|
||||
- Expand controller tests with simulated controller timeout cases if the gate model is later changed to emit full controller schema directly. Invalid controller normalization is covered locally.
|
||||
- Tune controller heuristics after live use, especially unlimited-mode eligibility and OKF deep-vs-light retrieval thresholds.
|
||||
|
||||
### AI Reply Feedback and OKF Correction Pipeline
|
||||
|
||||
This section is for Lumi chat answer feedback and AI Improvement Center work, not the separate core feedback system.
|
||||
|
||||
#### Feedback Loop
|
||||
|
||||
- Add thumbs up/down controls to Lumi chat answers.
|
||||
- Store feedback payloads with rating, prompt, response, retrieved context, optional comment, model/provider, timestamp, and review status.
|
||||
- Show an optional comment field for thumbs down and keep thumbs up lightweight.
|
||||
- Ensure feedback never directly modifies OKF.
|
||||
|
||||
#### Admin Feedback Review
|
||||
|
||||
- Add an admin feedback review page or section.
|
||||
- Show prompt, response, rating, comment, retrieved OKF sources, model/provider, and timestamp for each feedback item.
|
||||
- Add review actions: reviewed/no action, good, bad retrieval, bad response, missing knowledge, archive/delete.
|
||||
- Add correction creation UI with title, tags, affected topic/file, and Markdown body.
|
||||
- Save approved corrections as OKF files and re-index after save.
|
||||
- Add links from feedback rows to generated correction files.
|
||||
|
||||
#### Storage and Preservation
|
||||
|
||||
- Store feedback queue, review status, source context snapshots, and correction links in SQLite or existing app storage.
|
||||
- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates.
|
||||
- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits.
|
||||
|
||||
### Core Feedback System: Remaining Hardening
|
||||
|
||||
- Full scroll-page or automatic current-tab screenshot capture is not implemented. Current browser APIs require prompts for page capture, so the implemented path is explicit upload/paste.
|
||||
- Full duplicate merge workflow and richer duplicate detection beyond lightweight summary/description/scope/category/page matching.
|
||||
- Richer custom scope targeting filters.
|
||||
- OKF correction conversion for documentation/terminology/knowledge feedback, deferred.
|
||||
- Broader OKF correction workflow polish for documentation/terminology/knowledge feedback, including review guidance and discoverability.
|
||||
- Broader browser testing for image clipboard copy and clipboard-read availability because browser support and permissions vary.
|
||||
|
||||
#### Foundation
|
||||
|
||||
- Implement a general core feedback system available to logged-in users only.
|
||||
- Add a persistent feedback entry point somewhere logical in the UI.
|
||||
- Add `/feedback` under the Community navbar section for logged-in users.
|
||||
@ -306,7 +259,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Keep the AI Improvement Center separate from this system; it should continue handling AI reply quality feedback.
|
||||
- Add a `wrong tool` classification to the AI Improvement Center for cases where the AI used the wrong tool, action, integration, or capability.
|
||||
|
||||
### Feedback Categories and Scope
|
||||
#### Feedback Categories and Scope
|
||||
|
||||
- Use generic feedback categories based on issue type rather than feature-specific categories.
|
||||
- Suggested categories:
|
||||
@ -331,7 +284,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Encourage users to submit one feedback item per issue.
|
||||
- Add helper text explaining that small, specific feedback reports are preferred over broad combined reports.
|
||||
|
||||
### Feedback Submission UI
|
||||
#### Feedback Submission UI
|
||||
|
||||
- Add a feedback modal for creating feedback.
|
||||
- The modal should be accessible from the persistent feedback button and from the custom context menu.
|
||||
@ -356,7 +309,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Allow users to change the auto-detected scope before submitting.
|
||||
- Show clear confirmation after feedback is submitted.
|
||||
|
||||
### Site-Wide Custom Context Menu
|
||||
#### Site-Wide Custom Context Menu
|
||||
|
||||
- Replace the standard browser right-click context menu across Lumi with a custom site-wide context menu.
|
||||
- Include the following actions:
|
||||
@ -375,7 +328,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Add the Feedback action to open the feedback modal with the clicked element as the target.
|
||||
- Preserve keyboard accessibility and provide fallback behavior if custom context menu features are unavailable in a browser.
|
||||
|
||||
### Element-Targeted Feedback
|
||||
#### Element-Targeted Feedback
|
||||
|
||||
- When feedback is opened from the context menu, capture the clicked element as the feedback target.
|
||||
- Store safe metadata about the clicked element, such as:
|
||||
@ -392,7 +345,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Highlight the selected target while the feedback modal is open when practical.
|
||||
- Remove the highlight when the modal closes.
|
||||
|
||||
### Optional Screenshot Support
|
||||
#### Optional Screenshot Support
|
||||
|
||||
- Add optional screenshot attachment support without browser tab/window picker prompts.
|
||||
- Screenshots must be opt-in by the submitting user.
|
||||
@ -404,7 +357,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Clearly warn users not to include sensitive information in screenshots.
|
||||
- Allow users to remove an attached screenshot before submitting.
|
||||
|
||||
### Optional Diagnostic Data
|
||||
#### Optional Diagnostic Data
|
||||
|
||||
- Automatically attach basic non-sensitive context:
|
||||
- user ID
|
||||
@ -418,7 +371,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Do not capture sensitive form values, passwords, tokens, secrets, private messages, or hidden data.
|
||||
- Clearly label optional diagnostic data so users understand what they are submitting.
|
||||
|
||||
### Feedback Visibility for Users
|
||||
#### Feedback Visibility for Users
|
||||
|
||||
- On `/feedback`, show logged-in users a general list of current and past feedback that has not been deleted.
|
||||
- Public/general feedback list should only show basic non-identifying information:
|
||||
@ -435,7 +388,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Allow users to add comments to their own feedback when follow-up is needed.
|
||||
- Allow users to see admin replies that are visible to the submitter.
|
||||
|
||||
### User Feedback Notifications
|
||||
#### User Feedback Notifications
|
||||
|
||||
- Add per-user feedback notification badges.
|
||||
- Show a green badge/sphere with the number of the user’s feedback items solved since they last opened the feedback center.
|
||||
@ -444,7 +397,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Reset or update badge counts when the user opens the feedback center and views the relevant items.
|
||||
- Ensure notification badges only reflect the logged-in user’s own feedback.
|
||||
|
||||
### Feedback Statuses
|
||||
#### Feedback Statuses
|
||||
|
||||
- Support feedback statuses such as:
|
||||
- new
|
||||
@ -465,7 +418,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Store status history with timestamps and actor information.
|
||||
- Show user-friendly status names and explanations on `/feedback`.
|
||||
|
||||
### Replies, Comments, and Work Notes
|
||||
#### Replies, Comments, and Work Notes
|
||||
|
||||
- Allow admins to reply to feedback.
|
||||
- Allow submitters to comment on their own feedback.
|
||||
@ -475,7 +428,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Allow the submitter to add additional context/comments after the request.
|
||||
- Distinguish clearly between public/admin replies, submitter comments, and private admin work notes.
|
||||
|
||||
### Admin Feedback Review
|
||||
#### Admin Feedback Review
|
||||
|
||||
- Add `/admin/feedback` for admin feedback review.
|
||||
- Admins should be able to view full feedback details, including:
|
||||
@ -516,7 +469,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- convert to OKF correction where relevant
|
||||
- link to existing issue/correction
|
||||
|
||||
### OKF and AI Improvement Integration
|
||||
#### OKF and AI Improvement Integration
|
||||
|
||||
- Keep this feedback system separate from AI reply feedback.
|
||||
- Do not use this feedback system to rate individual AI replies.
|
||||
@ -526,7 +479,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Allow admins to convert relevant core feedback into OKF corrections only when the feedback concerns documentation, feature behavior, terminology, or community/system knowledge.
|
||||
- Do not automatically modify OKF based on general feedback.
|
||||
|
||||
### Rate Limits and Abuse Prevention
|
||||
#### Rate Limits and Abuse Prevention
|
||||
|
||||
- Add moderate rate limits for feedback submissions.
|
||||
- Rate limits should prevent spam without blocking normal use.
|
||||
@ -536,7 +489,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- If similar existing feedback exists, suggest adding a public comment or marking that it affects the user too instead of creating a duplicate.
|
||||
- Allow admins to delete or archive abusive/spam feedback.
|
||||
|
||||
### Data Storage and Preservation
|
||||
#### Data Storage and Preservation
|
||||
|
||||
- Store feedback data in SQLite or the existing app storage system.
|
||||
- Store screenshots and optional attachments in a protected feedback uploads/storage directory.
|
||||
@ -553,7 +506,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- comments/replies
|
||||
- linked issues/corrections
|
||||
|
||||
### Privacy and Safety
|
||||
#### Privacy and Safety
|
||||
|
||||
- Do not expose submitter identity in the general feedback list.
|
||||
- Only admins should see submitter identity and full diagnostic details.
|
||||
@ -564,7 +517,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Add admin controls for deleting sensitive feedback, screenshots, or diagnostic data.
|
||||
- Ensure permissions are enforced server-side, not only in the UI.
|
||||
|
||||
### Acceptance Criteria
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Logged-in users can submit general feedback from a persistent UI entry point.
|
||||
- Logged-in users can submit targeted feedback from the custom right-click context menu.
|
||||
@ -579,8 +532,151 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- Feedback data is preserved across updates and included in protected user data.
|
||||
- AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification.
|
||||
|
||||
### Homepage Hero System and Embeds
|
||||
|
||||
#### General Hero Embed Requirements
|
||||
|
||||
- Continue improving service-specific "why unavailable" messages where external providers expose enough signal.
|
||||
- Improve handling of live-only Twitch heroes and live-state detection.
|
||||
- Add explicit Discord invite-to-widget guidance or lookup support if Discord server IDs are not known.
|
||||
|
||||
#### Discord Server Widget Hero
|
||||
|
||||
- Add optional Discord invite-to-server-ID lookup support if Discord server IDs are not known.
|
||||
|
||||
#### Twitch Stream Hero
|
||||
|
||||
- Support additional configured parent domains for alternate hostnames.
|
||||
- Support stream-only and stream-with-chat layouts.
|
||||
- Respect Twitch minimum player size requirements.
|
||||
- If live-only mode is enabled, check live status or gracefully hide/fallback when offline.
|
||||
- Show a clear admin warning when the Twitch embed fails due to missing or incorrect parent domain.
|
||||
- Add admin helper text explaining that Twitch embeds require the site domain to be allowed as a parent domain.
|
||||
|
||||
#### YouTube Live Hero
|
||||
|
||||
- Support YouTube live video URLs, raw video IDs, and optional channel-based live lookup.
|
||||
- For static live heroes, extract and use the provided live video ID.
|
||||
- For automatic channel live detection, require YouTube API configuration.
|
||||
- Cache live lookup results to avoid excessive API calls.
|
||||
- Support optional live-only mode.
|
||||
- Support optional YouTube live chat only when a valid live video ID exists.
|
||||
- Support autoplay and muted settings.
|
||||
- If live-only mode is enabled and no active stream is found, skip the hero or show fallback content.
|
||||
- Show fallback/error states for private, removed, age-restricted, embedding-disabled, unavailable, or not-yet-live videos.
|
||||
- Add admin helper text explaining that YouTube Live requires either a live video link or API-based channel lookup.
|
||||
|
||||
#### YouTube Video Hero
|
||||
|
||||
- Show fallback/error states for private, removed, age-restricted, unavailable, or embedding-disabled videos.
|
||||
- Detect embedding-disabled videos before save when a YouTube API key or another reliable server-side signal is available.
|
||||
|
||||
#### External Embedded Content Hero
|
||||
|
||||
- Support generic external iframe embeds only for sites that explicitly allow embedding.
|
||||
- Detect or gracefully handle sites that block embedding through `X-Frame-Options` or CSP `frame-ancestors`.
|
||||
- Show an admin warning when an external site refuses to be embedded.
|
||||
|
||||
#### Hero Embed Admin UX
|
||||
|
||||
- Show “why unavailable” information in the admin UI when a hero cannot render.
|
||||
|
||||
#### Homepage Hero UX Improvements
|
||||
|
||||
- Review all homepage builder forms for non-technical usability.
|
||||
- Add tooltips and inline explanations where appropriate.
|
||||
|
||||
### Update Page UX Improvements
|
||||
|
||||
- Add async in-place checks for the older `/admin` and `/admin/settings` update buttons, matching `/admin/updates`.
|
||||
- Continue simplifying advanced update terminology without hiding recovery-critical detail.
|
||||
|
||||
### UI Text and Button Language
|
||||
|
||||
- Review all button labels, field labels, headings, helper text, warnings, empty states, and confirmation dialogs across Lumi.
|
||||
- Replace technical/internal wording with clearer user-facing wording where possible.
|
||||
- Keep AI-related wording precise enough for admins to understand model, provider, context, retrieval, embeddings, feedback, corrections, and OKF behavior.
|
||||
- Review the update pages and simplify labels such as `Check plugin`, `Apply safe target`, and `Disable for recovery`.
|
||||
- Prefer clear action labels such as `Check for updates`, `Update plugin`, `Use recommended version`, `Disable temporarily`, and `Start recovery mode`.
|
||||
- Add short helper text below advanced or risky update actions explaining what will happen.
|
||||
- Replace implementation-focused wording with task-focused wording wherever practical.
|
||||
- Avoid exposing Git, branch, commit, target, repository, cache, migration, or rollback terms unless the user needs them.
|
||||
- When technical terms are unavoidable, add short tooltips or inline explanations.
|
||||
- Standardize common action wording across all pages: save, update, check, review, restore, disable, enable, archive, delete, retry, import, export, cancel, and confirm.
|
||||
- Ensure destructive or system-changing actions have clear confirmation text.
|
||||
- Ensure error messages explain both what went wrong and what the admin can try next.
|
||||
- Review plugin management, core settings, update pages, homepage management, OKF management, feedback review, and AI configuration for inconsistent wording.
|
||||
- Split highly technical pages into simple and advanced sections where practical.
|
||||
- Hide rarely needed expert controls behind expandable advanced sections.
|
||||
- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values.
|
||||
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
|
||||
- Review localization/translation keys if present so simplified wording remains consistent across languages.
|
||||
|
||||
### Custom Commands
|
||||
- Wrap `function run(ctx){ ... }` around each custom command, preventing the requirement of it being defined, but leaving support for it being defined if it is.
|
||||
- Incorporate more custom command modes:
|
||||
- Random Reply
|
||||
- Has an optional RNG number that can be enabled and refernced from within the messages, and can also be used to define message selection criteria (eg. rng must = <20|>10)
|
||||
- Has an optional weight system that returns a message randomly based on a weight as an int 1-999
|
||||
|
||||
## Done
|
||||
|
||||
### 2026-06-25
|
||||
|
||||
- 2026-06-25: Continued Lumi AI runtime CUDA-preference work locally: Windows NVIDIA-compatible installs now prefer the managed CUDA llama.cpp runtime over Vulkan, the runtime manifest supports CUDA dependency archives, runtime downloads validate/reuse complete partial archives, install metadata records the backend variant/dependencies, and `/plugins/lumi_ai` shows the total runtime download size. No repo push yet.
|
||||
- 2026-06-25: Fixed Lumi AI runtime reinstall lock handling locally: redownloading/reinstalling the managed runtime now stops main and gate runtimes just before file replacement, pauses gate auto-recovery during install, replaces runtime files through a retryable backup/restore path, reports locked runtime files clearly, and shows install-stage download status. No repo push yet.
|
||||
- 2026-06-25: Fixed Lumi AI simple OKF lookup routing locally: short factual identity-style prompts now deterministically route to main LLM with light OKF retrieval instead of failing through the gate runtime, OKF file search ignores common lookup stopwords and handles compact/camel-case names, no-context prompts use a smaller compact scaffold, OKF retrieval diagnostics record query/candidate/fallback details, and a community people OKF seed file plus verifier coverage were added. No repo push yet.
|
||||
- 2026-06-25: Fixed broken timed-confirmation buttons locally: added core `LumiConfirm.destructiveToken/destructiveFetch` helpers for JSON/fetch actions that need backend confirmation tokens, moved the Lumi AI tool settings reset button onto that flow, and changed the Lumi AI eval-case delete button to the shared timed confirmation modal instead of the older custom confirm prompt. No repo push yet.
|
||||
|
||||
### 2026-06-21
|
||||
|
||||
- 2026-06-21: Fixed Lumi AI WebUI final delivery locally: WebUI no longer inherits a fixed platform character cap, constrained sources compact at safe text boundaries instead of raw tail truncation, and delivery diagnostics record whether compaction occurred. No repo push yet.
|
||||
- 2026-06-21: Tightened feedback-to-Codex default exports locally: output is now task-centric strict JSON with short source references, route-only internal locations, deduplicated task sections, and full raw metadata retained only in the separate debug export. No repo push yet.
|
||||
- 2026-06-21: Reworked Community and System-generated OKF file listings locally into responsive searchable/filterable tables using existing Lumi UI patterns while preserving file actions and permission behavior. No repo push yet.
|
||||
- 2026-06-21: Added confirmed similar-feedback submission locally: matching reports are shown before submission, users can explicitly confirm that a report is distinct or adds context, and the server validates and records that confirmation before creating the report. No repo push yet.
|
||||
|
||||
### 2026-06-20
|
||||
|
||||
- 2026-06-20: Split OKF Management locally into General OKF, Community OKF, and System-generated OKF tabs; kept community files editable, kept generated core/plugin OKF read-only, and added generated file preview links backed by the existing file knowledge loader. No repo push yet.
|
||||
- 2026-06-20: Expanded generated system OKF route documentation locally: generated core/plugin files now include route purpose, detected request inputs, response format, access signals, side effects, and static-analysis limitations; plugin routes now use mounted full paths. No repo push yet.
|
||||
- 2026-06-20: Fixed generated OKF route docs overloading Lumi AI prompts locally: OKF context now keeps only the top high-signal chunks, drops generic route-list chunks when specific route chunks match, and enforces a hard context character budget before inference. No repo push yet.
|
||||
- 2026-06-20: Added a first Lumi AI controller-routing pass locally: introduced a validated controller decision schema, internal modes `fast|normal|expanded|unlimited`, OKF retrieval depths, source profile metadata, controller metrics/diagnostics, controller-aware output budgets, and safe fallback so low-confidence/failed gate decisions escalate to at least normal processing instead of tiny simple-answer budgets. No repo push yet.
|
||||
- 2026-06-20: Continued Lumi AI controller-routing locally: exposed source-profile final-output limits in Lumi AI admin settings, kept internal processing budgets code-owned/dynamically selected, added a compact controller stage event for assistant jobs, and surfaced controller mode/reason in recent job diagnostics without showing token-budget columns. No repo push yet.
|
||||
- 2026-06-20: Hardened Lumi AI controller diagnostics locally: controller normalization now clamps invalid source profiles and bad enum values, delivery metrics include controller details plus truncation/final-delivered lengths, and settings metrics visibly mark truncated replies. No repo push yet.
|
||||
- 2026-06-20: Corrected Lumi AI controller budget UX locally: removed user-facing token budget controls, raised code-owned dynamic controller mode budgets so OKF route explanations are not starved, removed token-budget columns from diagnostics, and lifted the global Feedback button above the assistant chat panel when visible. No repo push yet.
|
||||
- 2026-06-20: Added grouped Lumi AI work history locally as an enhancement to the existing Metrics table: request-scoped prompt/controller/gate/OKF/prompt-build/model/delivery/error/fallback events are grouped by work item inside `#metrics > div.table-wrap > table`, with full-row expandable summaries and nested lifecycle sub-tables, processed/final/delivered token estimates, filters, count/age retention, safer route-like prompt classification, and fallback recording for empty model responses. No repo push yet.
|
||||
|
||||
### 2026-06-19
|
||||
|
||||
- 2026-06-19: Added admin feedback-to-Codex export locally on `experimental-okf`: per-feedback Export to job, selected/all eligible global export, sanitized JSON taskfile modal with copy/download, optional Lumi AI compression using a configurable token/context limit, and the `internal-feedback-to-codex-taskfile` trigger phrase. No repo push yet.
|
||||
- 2026-06-19: Fixed feedback-to-Codex export UX locally: export clicks now open a modal reliably, the result surface is copy-focused, AI compression controls render only when Lumi AI is actually available, and token/context limit appears only when AI compression is enabled. No repo push yet.
|
||||
- 2026-06-19: Refined feedback-to-Codex export locally: default output is now a compact strict-JSON Codex taskfile with normalized severity/priority, actionable areas to inspect, concrete requirements/criteria/validation, privacy redaction, and closed/duplicate/rejected feedback kept debug-only; full verbose export remains selectable as debug mode. No repo push yet.
|
||||
- 2026-06-19: Fixed feedback submission without screenshots by making the screenshot normalizer accept null/no-file submissions and store empty screenshot columns instead of throwing. No repo push yet.
|
||||
- 2026-06-19: Applied exported feedback task `51386173-03bc-4674-82ca-e5cdcb6088ed` locally: OKF entry creation moved into a modal opened by a top-right OKF admin header button, while editing remains inline and server-side permissions/routes are unchanged. No repo push yet.
|
||||
- 2026-06-19: Improved feedback target capture locally: detected target now stores a semicolon-separated semantic UI path such as `/admin/feedback;Feedback queue;Feedback entry metadata` instead of raw clicked-container text, and exports prefer that path. No repo push yet.
|
||||
- 2026-06-19: Updated feedback-to-Codex export context locally: compact taskfiles and AI compression now include sanitized submitter comments, admin replies, and private admin work-note context without actor identity or raw private data. No repo push yet.
|
||||
- 2026-06-19: Applied exported feedback task locally: `taskfile-export.json` is ignored/local-only, feedback modal backdrop clicks and Escape no longer close the form, non-submitted feedback drafts persist in session storage and restore from the floating button or context menu, current-session screenshot attachments survive close/reopen, and Reset clears the draft from the right side of the modal action row. No repo push yet.
|
||||
- 2026-06-19: Added conservative feedback-to-Codex grouping locally: default compact exports now merge likely related actionable feedback by shared root-cause signals, route/target similarity, and error patterns, with grouped source references, affected targets, support counts, privacy-safe distinct reporter counts, and debug export left unchanged. No repo push yet.
|
||||
- 2026-06-19: Added first file-backed OKF retrieval locally: documented `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections`; added Markdown/frontmatter parsing, heading chunks, source metadata, user/mod/admin visibility checks, corrections/community/plugins/core priority, Lumi AI context integration, and verifier coverage. No repo push yet.
|
||||
- 2026-06-19: Hardened file-backed OKF retrieval locally: added access-aware placeholder resolution for visible frontmatter values, generic missing-reference markers for hidden/unavailable references, mtime/size-based changed-file reuse, deleted-file cache cleanup, and verifier coverage for placeholder privacy plus update/delete reindexing. No repo push yet.
|
||||
- 2026-06-19: Added generated core/plugin OKF locally: generator creates reproducible `knowledge/core/lumi-core.md` and per-plugin `knowledge/plugins/*.md` files from package metadata, README, core routes, plugin manifests, plugin routes, and static command trigger hints; generated files are skipped when marked editable or non-generated. No repo push yet.
|
||||
- 2026-06-19: Added admin-editable community OKF files locally: `/plugins/okf/admin` now lists, creates, edits, previews, and renames `knowledge/community` Markdown files with frontmatter fields and refuses saves to generated or non-editable files. No repo push yet.
|
||||
- 2026-06-19: Added admin feedback-to-correction OKF creation locally: `/admin/feedback` can create sanitized, searchable correction Markdown files under `knowledge/corrections`, records source feedback metadata, links the generated path back to the feedback item, and verifier coverage confirms correction file search priority. No repo push yet.
|
||||
- 2026-06-19: Added OKF admin category/tag suggestions locally: create/edit entry forms and community file forms now use existing category/tag datalist suggestions from all manageable OKF entries plus community files to reduce duplicated taxonomy wording. No repo push yet.
|
||||
- 2026-06-19: Refined OKF admin editing locally: selected entries now open in a full-screen modal with session-backed draft preservation and explicit close/reset controls, and the Community OKF category suggestion input no longer shows a misleading dropdown arrow. No repo push yet.
|
||||
- 2026-06-19: Added OKF update preservation notes/tests locally: `knowledge/README.md` documents preserved local OKF paths, and the OKF verifier asserts repo-based updates preserve `knowledge/community` and `knowledge/corrections`. No repo push yet.
|
||||
- 2026-06-19: Added global placeholder auto-suggest locally: placeholder-compatible fields opt into scoped JSON placeholder sources, suggestions trigger from `{{` through the first whitespace-delimited token, current-level suggestions can be inserted by keyboard or click, invalid completed placeholders get a faint red backlight when the cursor leaves them, and OKF admin Markdown fields now use OKF placeholder suggestions. No repo push yet.
|
||||
- 2026-06-19: Refined global placeholder auto-suggest locally: inline suggestions now show up to 9 current-level options plus a distinct Expand action that opens a searchable placeholder tree; live search greys out non-matching tree entries and collapses branches with no matches; selecting a tree leaf inserts the full placeholder and closes the modal. No repo push yet.
|
||||
- 2026-06-19: Refined placeholder tree display locally: the Expand modal now renders recursive collapsible placeholder levels instead of repeating full placeholder strings, with structural `{{`, `.`, and `}}` tokens muted so branch/field names are easier to scan. No repo push yet.
|
||||
- 2026-06-19: Fixed placeholder suggestion source locally: auto-suggest now exposes only authored/custom frontmatter keys, not generic metadata like `scope`, `status`, `editable`, or `generated` on every OKF entry; manual placeholder resolution remains permissive for explicit metadata references. No repo push yet.
|
||||
- 2026-06-19: Improved placeholder level selection locally: choosing an inline branch suggestion ending in `.` keeps the suggestion menu open and refreshes to the next level, so admins can click through levels smoothly without deleting/retyping punctuation. No repo push yet.
|
||||
- 2026-06-19: Corrected placeholder direction locally: removed the Community OKF per-file custom placeholder editor, kept OKF placeholder suggestions system-generated from visible file metadata, and added Throne Wishlist destination message placeholders as a generated double-brace source with legacy single-brace rendering preserved. No repo push yet.
|
||||
- 2026-06-19: Added secure placeholder registry locally: `src/services/placeholders.js` now owns server-side placeholder definitions, trusted field policies, shared access checks, catalog/preview APIs, save-time validation, and runtime rendering; custom command static responses and Throne Wishlist message templates use field policies, Throne registers namespaced `{{plugin.throne_wishlist.*}}` placeholders with legacy aliases, raw `payload_json` is withheld from user-visible output, the frontend catalog fetches by `data-placeholder-field`, and `docs/placeholder-system.md` documents the architecture/security rules. No repo push yet.
|
||||
- 2026-06-20: Extended placeholders locally for OKF and platform integrations: OKF Markdown fields now use the global server catalog with output-audience-specific policies, SQLite-backed OKF entries validate placeholders on save and resolve them on display, file-backed OKF frontmatter values register as `{{okf.file.*}}` placeholders with legacy aliases, and safe platform placeholders were added for Discord guild stats plus locally available Twitch/YouTube channel/runtime values. No repo push yet.
|
||||
- 2026-06-19: Added OKF placeholder migration locally: OKF startup runs a one-time conversion for single-brace dotted OKF placeholders in `knowledge/*.md`, changing examples like `{community.currency.primary_name}` into `{{community.currency.primary_name}}` without touching already-double placeholders or unrelated plugin token systems. No repo push yet.
|
||||
|
||||
### 2026-06-18
|
||||
|
||||
- 2026-06-18: Fixed urgent UI tail items locally on `experimental-okf`: mobile sidebar now fits the viewport and scrolls internally, moderation notes can be permanently deleted by admins, both theme preview pop-out buttons open the preview, unauthenticated pages use the Lumi context menu with Feedback disabled, and the global savebar now submits URL-encoded settings data so normal settings routes actually save. No repo push yet.
|
||||
- 2026-06-18: Updated OKF aliases/related questions to newline-only parsing and display so commas remain valid inside questions. No repo push yet.
|
||||
- 2026-06-18: Continued `experimental-okf` locally: wired OKF into Lumi AI through the generic context provider hook, made AI context providers user/message-aware, and verified OKF AI context only includes published/approved entries and fields visible to the requester. No repo push yet.
|
||||
@ -595,6 +691,9 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
- 2026-06-18: Added opt-in sanitized page structure diagnostics for feedback, excluding sensitive form values and capping stored snapshot text server-side.
|
||||
- 2026-06-18: Updated the experimental feedback context menu locally so all actions remain visible with disabled unavailable states, Back/Forward/Copy/Cut/Paste availability is context-aware, image/text copy and input-only cut/paste rules are enforced where browsers allow, and one-second viewport-clamped tooltips explain each action.
|
||||
- 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes, and AI Improvement Center `wrong_tool_usage` confirmed present.
|
||||
|
||||
### 2026-06-17
|
||||
|
||||
- 2026-06-17: Added Economy cleanup for fallback-name artifact users such as `SammyCat-2`, merging them into matching real platform users and making Economy stats tolerate missing/mid-migration tables; bumped Economy Framework to v0.2.9.
|
||||
- 2026-06-17: Fixed update metadata for renamed Economy plugins so legacy installed rows are folded into canonical `economy-*` plugin update rows instead of appearing as separate installs; bumped core to v0.1.8.
|
||||
- 2026-06-17: Renamed all remaining Economy internals from the old misspelled IDs/paths/tables to `economy-*`, added startup migration for legacy plugin rows, settings, command usage IDs, tables, uploads, asset paths, old URLs, and bumped core/plugin patch versions.
|
||||
|
||||
132
docs/placeholder-system.md
Normal file
132
docs/placeholder-system.md
Normal file
@ -0,0 +1,132 @@
|
||||
# Placeholder System Guidelines
|
||||
|
||||
This note is the working guideline for Lumi placeholder and variable support.
|
||||
Future placeholder work should follow this model unless the architecture is
|
||||
explicitly revised.
|
||||
|
||||
## Architecture
|
||||
|
||||
Placeholders are registered server-side through `src/services/placeholders.js`.
|
||||
The frontend must not declare placeholder permissions, allowed plugins, or
|
||||
sensitivity. Editable fields reference a trusted `field_id`, and the server
|
||||
uses that field policy to decide which placeholders are available.
|
||||
|
||||
Core and plugins can register:
|
||||
|
||||
- placeholder definitions with metadata and a resolver function
|
||||
- field policies for template/input destinations that support placeholders
|
||||
|
||||
Catalog, preview, save validation, and runtime rendering must share the same
|
||||
permission-checking path. Saved templates are not trusted permanently; they must
|
||||
be revalidated at render time because plugin availability, policies, settings,
|
||||
or sensitivity can change.
|
||||
|
||||
## Naming
|
||||
|
||||
Use namespaced double-brace tokens:
|
||||
|
||||
- `{{core.main.bot_name}}`
|
||||
- `{{core.main.command_prefix}}`
|
||||
- `{{user.public.display_name}}`
|
||||
- `{{platform.discord.guild.member_count}}`
|
||||
- `{{okf.file.community.currency.primary_name}}`
|
||||
- `{{plugin.throne_wishlist.item_name}}`
|
||||
|
||||
Avoid unqualified names for new work. Compatibility aliases may exist for old
|
||||
templates, but the catalog should present canonical namespaced tokens.
|
||||
|
||||
Custom admin-defined placeholders, if added later, should live under the
|
||||
`{{custom.*}}` namespace and be managed from a trusted admin settings page.
|
||||
|
||||
## Core Namespaces
|
||||
|
||||
Current core-owned namespaces include:
|
||||
|
||||
- `core.main.*`: safe Lumi core settings such as bot/site name and command prefix
|
||||
- `user.public.*`: safe viewer/triggering-user display information
|
||||
- `platform.discord.guild.*`: safe Discord guild statistics from the configured guild
|
||||
- `platform.twitch.channel.*`: safe locally configured Twitch channel/runtime values
|
||||
- `platform.youtube.channel.*`: safe locally configured or hydrated YouTube channel/runtime values
|
||||
- `okf.file.*`: file-backed OKF frontmatter values, with legacy aliases such as `{{community.currency.primary_name}}`
|
||||
|
||||
API-backed platform statistics such as Twitch follower/subscriber counts or
|
||||
future stream schedules should be registered here only after the integration has
|
||||
a reliable server-side fetch/cache layer and clear sensitivity rules.
|
||||
|
||||
## Sensitivity
|
||||
|
||||
Supported sensitivity levels:
|
||||
|
||||
- `public_safe`: safe in user-visible output
|
||||
- `user`: user-specific content, only for fields whose output audience allows it
|
||||
- `moderator`: moderator/support visible content
|
||||
- `admin`: admin-only content
|
||||
- `internal`: internal implementation/runtime content
|
||||
- `secret_never_render`: must not be registered, listed, previewed, or rendered
|
||||
|
||||
Never expose API keys, tokens, cookies, passwords, database URLs, session IDs,
|
||||
raw secrets, private file paths, sensitive query strings, or unredacted raw
|
||||
diagnostics through placeholders. If diagnostics are needed, register explicit
|
||||
redacted safe variants.
|
||||
|
||||
## Field Policies
|
||||
|
||||
Every placeholder-compatible input must use a stable `field_id` whose policy is
|
||||
registered by trusted backend code. A field policy should define:
|
||||
|
||||
- `field_id`
|
||||
- label and field type
|
||||
- output audience
|
||||
- minimum editor role
|
||||
- allowed namespaces or placeholder IDs
|
||||
- maximum sensitivity
|
||||
|
||||
Security is based on the intersection of:
|
||||
|
||||
- current editor role/capability during catalog, preview, and save
|
||||
- field policy
|
||||
- output audience that will see rendered content
|
||||
- placeholder sensitivity
|
||||
- placeholder minimum viewer role
|
||||
- plugin availability
|
||||
- runtime context
|
||||
|
||||
An admin editing a user-visible template is not enough to allow admin-only
|
||||
placeholders. The output audience still limits what can render.
|
||||
|
||||
## Plugin Registration
|
||||
|
||||
Plugins should register placeholders during backend plugin initialization using
|
||||
the placeholder service passed to `init` or available through
|
||||
`web.placeholders`. Plugin definitions should include `plugin_id` so disabled or
|
||||
missing plugins can fail safely.
|
||||
|
||||
Resolvers must return safe display values and avoid leaking raw internal data.
|
||||
Renderers that send chat messages or HTML should still sanitize or escape for
|
||||
their output context after placeholder resolution.
|
||||
|
||||
## Frontend
|
||||
|
||||
Frontend fields use:
|
||||
|
||||
```html
|
||||
data-placeholder-field="plugin.example.message_template"
|
||||
data-placeholder-output-audience="user"
|
||||
```
|
||||
|
||||
The frontend requests `/api/placeholders/catalog?field_id=...` and uses the
|
||||
returned catalog for autocomplete and tree browsing. Inline JSON placeholder
|
||||
lists are a transitional fallback only and should not be used for new fields.
|
||||
|
||||
## Validation
|
||||
|
||||
Templates must be checked:
|
||||
|
||||
- when the editor opens a catalog
|
||||
- during preview
|
||||
- before save
|
||||
- at runtime before rendering output
|
||||
|
||||
Unauthorized placeholders should fail closed. User-visible output should show a
|
||||
generic unavailable marker or leave legacy unknown tokens unchanged, depending
|
||||
on the existing renderer contract, but it must not reveal the restricted value.
|
||||
53
knowledge/README.md
Normal file
53
knowledge/README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Lumi OKF Knowledge Files
|
||||
|
||||
This directory holds file-backed OKF Markdown sources.
|
||||
|
||||
- `core/`: generated or maintained Lumi core behavior knowledge.
|
||||
- `plugins/`: generated or maintained plugin behavior knowledge.
|
||||
- `community/`: admin-maintained community facts, terminology, names, rules, links, and local context.
|
||||
- `corrections/`: admin-reviewed corrections created from feedback or AI review flows.
|
||||
|
||||
Files use Markdown with optional frontmatter:
|
||||
|
||||
```md
|
||||
---
|
||||
id: community.currency
|
||||
title: Community currency
|
||||
scope: community
|
||||
status: active
|
||||
priority: 50
|
||||
visibility: user
|
||||
tags: currency, economy
|
||||
generated: false
|
||||
editable: true
|
||||
created_at: 2026-06-19T00:00:00.000Z
|
||||
updated_at: 2026-06-19T00:00:00.000Z
|
||||
---
|
||||
|
||||
# Community currency
|
||||
|
||||
The community currency is coins.
|
||||
```
|
||||
|
||||
`status` should be `active` or `published` for retrieval. `visibility` may be `user`, `mod`, or `admin`.
|
||||
Corrections have the highest retrieval priority, followed by community, plugin, and core knowledge.
|
||||
|
||||
## Placeholders
|
||||
|
||||
Markdown bodies may reference visible frontmatter values with `{{entry.id.field_name}}`.
|
||||
For example, a file with `id: community.currency` and `primary_name: coins` can be referenced as:
|
||||
|
||||
```md
|
||||
The currency is {{community.currency.primary_name}}.
|
||||
```
|
||||
|
||||
Placeholders are resolved only from knowledge entries visible to the current user. Missing or hidden references become a short missing-reference marker instead of crashing retrieval.
|
||||
|
||||
## Update Preservation
|
||||
|
||||
Repo-based and ZIP fallback updates preserve the local OKF authoring paths:
|
||||
|
||||
- `knowledge/community`
|
||||
- `knowledge/corrections`
|
||||
|
||||
Generated `knowledge/core` and `knowledge/plugins` files are reproducible from the current codebase and plugin manifests. Do not place local-only edits in generated files unless they are explicitly marked `generated: false` or `editable: true`.
|
||||
1
knowledge/community/.gitkeep
Normal file
1
knowledge/community/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
24
knowledge/community/community-people.md
Normal file
24
knowledge/community/community-people.md
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
id: community.people
|
||||
title: Community People
|
||||
scope: community
|
||||
status: active
|
||||
priority: 20
|
||||
visibility: user
|
||||
category: Community
|
||||
tags: people, identity, staff, ookamikuntv, jenni, discord
|
||||
generated: false
|
||||
editable: true
|
||||
created_at: "2026-06-25T00:00:00.000Z"
|
||||
updated_at: "2026-06-25T00:00:00.000Z"
|
||||
---
|
||||
|
||||
# Community People
|
||||
|
||||
## OokamiKunTV
|
||||
|
||||
OokamiKunTV is a known community contact in Lumi's local community knowledge. When users ask how to contact OokamiKunTV, Jenni, administrators, moderators, or community staff, recommend using the Discord community server unless a verified internal Lumi workflow is available.
|
||||
|
||||
## Jenni
|
||||
|
||||
Jenni is a known community contact in Lumi's local community knowledge. When users ask how to contact Jenni or community staff, recommend using the Discord community server unless a verified internal Lumi workflow is available.
|
||||
15
knowledge/community/test.md
Normal file
15
knowledge/community/test.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
id: test
|
||||
title: Test
|
||||
scope: community
|
||||
status: active
|
||||
priority: 0
|
||||
visibility: user
|
||||
category: Community
|
||||
generated: false
|
||||
editable: true
|
||||
created_at: "2026-06-19T23:34:37.873Z"
|
||||
updated_at: "2026-06-19T23:34:37.873Z"
|
||||
---
|
||||
|
||||
# Test
|
||||
1
knowledge/core/.gitkeep
Normal file
1
knowledge/core/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1292
knowledge/core/lumi-core.md
Normal file
1292
knowledge/core/lumi-core.md
Normal file
File diff suppressed because it is too large
Load Diff
1
knowledge/corrections/.gitkeep
Normal file
1
knowledge/corrections/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
knowledge/plugins/.gitkeep
Normal file
1
knowledge/plugins/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
74
knowledge/plugins/auto-vc.md
Normal file
74
knowledge/plugins/auto-vc.md
Normal file
@ -0,0 +1,74 @@
|
||||
---
|
||||
id: plugin.auto-vc
|
||||
title: Auto VC
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, auto-vc
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Auto VC
|
||||
Auto-create managed voice channels from lobby rooms.
|
||||
## Metadata
|
||||
Plugin ID: auto-vc
|
||||
Version: 0.1.5
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/auto-vc
|
||||
- GET /plugins/auto-vc
|
||||
- POST /plugins/auto-vc/settings
|
||||
- POST /plugins/auto-vc/bans
|
||||
- POST /plugins/auto-vc/unban
|
||||
## Route Reference
|
||||
### MOUNT /plugins/auto-vc
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/auto-vc
|
||||
|
||||
- Purpose: Renders or serves the auto-vc plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: plain or HTML response
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/auto-vc/settings
|
||||
|
||||
- Purpose: Processes the auto-vc plugin action for settings.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/auto-vc/bans
|
||||
|
||||
- Purpose: Processes the auto-vc plugin action for bans.
|
||||
- Inputs: body: `ban_input`, `ban_reason`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/auto-vc/unban
|
||||
|
||||
- Purpose: Processes the auto-vc plugin action for unban.
|
||||
- Inputs: body: `unban_ids`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/auto-vc
|
||||
124
knowledge/plugins/birthday.md
Normal file
124
knowledge/plugins/birthday.md
Normal file
@ -0,0 +1,124 @@
|
||||
---
|
||||
id: plugin.birthday
|
||||
title: Birthday
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, birthday
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Birthday
|
||||
Birthday profiles, announcements, lookup commands, and optional birthday currency gifts.
|
||||
## Metadata
|
||||
Plugin ID: birthday
|
||||
Version: 0.1.2
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/birthday
|
||||
- GET /plugins/birthday
|
||||
- POST /plugins/birthday/settings
|
||||
- POST /plugins/birthday/templates/create
|
||||
- POST /plugins/birthday/templates/:id/update
|
||||
- POST /plugins/birthday/templates/:id/duplicate
|
||||
- POST /plugins/birthday/templates/:id/remove
|
||||
- POST /plugins/birthday/profile
|
||||
- POST /plugins/birthday/profile/unset
|
||||
- GET /plugins/birthday/u/:username
|
||||
## Route Reference
|
||||
### MOUNT /plugins/birthday
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/birthday
|
||||
|
||||
- Purpose: Renders or serves the birthday plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/birthday/settings
|
||||
|
||||
- Purpose: Processes the birthday plugin action for settings.
|
||||
- Inputs: body: `announcement_channel_id`, `birthday_check_interval_minutes`, `enabled`, `gift_amount`, `gift_mode`, `leap_day_policy`, `timezone`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/birthday/templates/create
|
||||
|
||||
- Purpose: Processes the birthday plugin action for templates create.
|
||||
- Inputs: body: `pool`, `text`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/birthday/templates/:id/update
|
||||
|
||||
- Purpose: Processes the birthday plugin action for templates id update.
|
||||
- Inputs: path params: `id`; body: `enabled`, `pool`, `text`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/birthday/templates/:id/duplicate
|
||||
|
||||
- Purpose: Processes the birthday plugin action for templates id duplicate.
|
||||
- Inputs: path params: `id`; body: `pool`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/birthday/templates/:id/remove
|
||||
|
||||
- Purpose: Processes the birthday plugin action for templates id remove.
|
||||
- Inputs: path params: `id`; body: `pool`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/birthday/profile
|
||||
|
||||
- Purpose: Processes the birthday plugin action for profile.
|
||||
- Inputs: body: `birthday`, `privacy`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/birthday/profile/unset
|
||||
|
||||
- Purpose: Processes the birthday plugin action for profile unset.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/birthday/u/:username
|
||||
|
||||
- Purpose: Renders or serves the birthday plugin page for u username.
|
||||
- Inputs: path params: `username`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/birthday
|
||||
304
knowledge/plugins/economy-framework.md
Normal file
304
knowledge/plugins/economy-framework.md
Normal file
@ -0,0 +1,304 @@
|
||||
---
|
||||
id: plugin.economy-framework
|
||||
title: Economy Framework
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, economy-framework
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Economy Framework
|
||||
Cross-platform currency framework with shared balances and extensible hooks.
|
||||
## Metadata
|
||||
Plugin ID: economy-framework
|
||||
Version: 0.2.9
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/economy-framework
|
||||
- /profile/banking
|
||||
- GET /plugins/economy-framework
|
||||
- GET /profile/banking
|
||||
- POST /plugins/economy-framework/settings/currency
|
||||
- POST /profile/banking/settings/currency
|
||||
- POST /plugins/economy-framework/settings/platforms
|
||||
- POST /profile/banking/settings/platforms
|
||||
- POST /plugins/economy-framework/settings/earn
|
||||
- POST /profile/banking/settings/earn
|
||||
- POST /plugins/economy-framework/settings/tiers
|
||||
- POST /profile/banking/settings/tiers
|
||||
- POST /plugins/economy-framework/settings/banking
|
||||
- POST /profile/banking/settings/banking
|
||||
- POST /plugins/economy-framework/settings/responses
|
||||
- POST /profile/banking/settings/responses
|
||||
- POST /plugins/economy-framework/settings/icon
|
||||
- POST /profile/banking/settings/icon
|
||||
- POST /plugins/economy-framework/accounts/adjust
|
||||
- POST /profile/banking/accounts/adjust
|
||||
- POST /plugins/economy-framework/funds/create
|
||||
- POST /profile/banking/funds/create
|
||||
- POST /plugins/economy-framework/funds/:id/update
|
||||
- POST /profile/banking/funds/:id/update
|
||||
- POST /plugins/economy-framework/events/create
|
||||
- POST /profile/banking/events/create
|
||||
- POST /plugins/economy-framework/events/:id/delete
|
||||
- POST /profile/banking/events/:id/delete
|
||||
## Route Reference
|
||||
### MOUNT /plugins/economy-framework
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### MOUNT /profile/banking
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/economy-framework
|
||||
|
||||
- Purpose: Renders or serves the economy-framework plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /profile/banking
|
||||
|
||||
- Purpose: Renders the profile banking WebUI page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/economy-framework/settings/currency
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for settings currency.
|
||||
- Inputs: body: `command_aliases`, `command_root`, `currency_name`, `currency_name_plural`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/settings/currency
|
||||
|
||||
- Purpose: Processes the profile banking settings currency action and stores or applies submitted form data.
|
||||
- Inputs: body: `command_aliases`, `command_root`, `currency_name`, `currency_name_plural`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/settings/platforms
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for settings platforms.
|
||||
- Inputs: body: `platform_discord`, `platform_twitch`, `platform_youtube`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/settings/platforms
|
||||
|
||||
- Purpose: Processes the profile banking settings platforms action and stores or applies submitted form data.
|
||||
- Inputs: body: `platform_discord`, `platform_twitch`, `platform_youtube`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/settings/earn
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for settings earn.
|
||||
- Inputs: body: `earn_discord_message_amount`, `earn_discord_message_cooldown`, `earn_discord_message_enabled`, `earn_discord_voice_amount_per_min`, `earn_discord_voice_enabled`, `earn_discord_voice_tick_minutes`, `earn_twitch_message_amount`, `earn_twitch_message_cooldown`, `earn_twitch_message_enabled`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/settings/earn
|
||||
|
||||
- Purpose: Processes the profile banking settings earn action and stores or applies submitted form data.
|
||||
- Inputs: body: `earn_discord_message_amount`, `earn_discord_message_cooldown`, `earn_discord_message_enabled`, `earn_discord_voice_amount_per_min`, `earn_discord_voice_enabled`, `earn_discord_voice_tick_minutes`, `earn_twitch_message_amount`, `earn_twitch_message_cooldown`, `earn_twitch_message_enabled`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/settings/tiers
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for settings tiers.
|
||||
- Inputs: body: `tier_discord_booster_multiplier`, `tier_twitch_broadcaster_multiplier`, `tier_twitch_mod_multiplier`, `tier_twitch_sub_multiplier`, `tier_twitch_vip_multiplier`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/settings/tiers
|
||||
|
||||
- Purpose: Processes the profile banking settings tiers action and stores or applies submitted form data.
|
||||
- Inputs: body: `tier_discord_booster_multiplier`, `tier_twitch_broadcaster_multiplier`, `tier_twitch_mod_multiplier`, `tier_twitch_sub_multiplier`, `tier_twitch_vip_multiplier`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/settings/banking
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for settings banking.
|
||||
- Inputs: body: `banking_enabled`, `banking_label`, `community_fund_name`, `community_fund_name_plural`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/settings/banking
|
||||
|
||||
- Purpose: Processes the profile banking settings banking action and stores or applies submitted form data.
|
||||
- Inputs: body: `banking_enabled`, `banking_label`, `community_fund_name`, `community_fund_name_plural`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/settings/responses
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for settings responses.
|
||||
- Inputs: body: `response_key`, `response_mode`, `response_text`, `response_weight`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/settings/responses
|
||||
|
||||
- Purpose: Processes the profile banking settings responses action and stores or applies submitted form data.
|
||||
- Inputs: body: `response_key`, `response_mode`, `response_text`, `response_weight`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/settings/icon
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for settings icon.
|
||||
- Inputs: file upload: multipart form file data
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes files
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/settings/icon
|
||||
|
||||
- Purpose: Processes the profile banking settings icon action and stores or applies submitted form data.
|
||||
- Inputs: file upload: multipart form file data
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes files
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/accounts/adjust
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for accounts adjust.
|
||||
- Inputs: body: `amount`, `note`, `target_user_id`, `username`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/accounts/adjust
|
||||
|
||||
- Purpose: Processes the profile banking accounts adjust action and stores or applies submitted form data.
|
||||
- Inputs: body: `amount`, `note`, `target_user_id`, `username`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/funds/create
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for funds create.
|
||||
- Inputs: body: `description`, `name`, `target_amount`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/funds/create
|
||||
|
||||
- Purpose: Processes the profile banking funds create action and stores or applies submitted form data.
|
||||
- Inputs: body: `description`, `name`, `target_amount`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/funds/:id/update
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for funds id update.
|
||||
- Inputs: path params: `id`; body: `description`, `name`, `status`, `target_amount`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/funds/:id/update
|
||||
|
||||
- Purpose: Processes the profile banking funds id update action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `description`, `name`, `status`, `target_amount`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/events/create
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for events create.
|
||||
- Inputs: body: `amount`, `name`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/events/create
|
||||
|
||||
- Purpose: Processes the profile banking events create action and stores or applies submitted form data.
|
||||
- Inputs: body: `amount`, `name`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-framework/events/:id/delete
|
||||
|
||||
- Purpose: Processes the economy-framework plugin action for events id delete.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /profile/banking/events/:id/delete
|
||||
|
||||
- Purpose: Processes the profile banking events id delete action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/economy-framework
|
||||
84
knowledge/plugins/economy-games.md
Normal file
84
knowledge/plugins/economy-games.md
Normal file
@ -0,0 +1,84 @@
|
||||
---
|
||||
id: plugin.economy-games
|
||||
title: Economy Games
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, economy-games
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Economy Games
|
||||
Cross-platform mini-games that use the Economy currency framework.
|
||||
## Metadata
|
||||
Plugin ID: economy-games
|
||||
Version: 0.1.7
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/economy-games
|
||||
- GET /plugins/economy-games
|
||||
- POST /plugins/economy-games/settings/hotpotato
|
||||
- POST /plugins/economy-games/settings/coinflip
|
||||
- POST /plugins/economy-games/settings/mystery
|
||||
- POST /plugins/economy-games/settings/responses
|
||||
## Route Reference
|
||||
### MOUNT /plugins/economy-games
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/economy-games
|
||||
|
||||
- Purpose: Renders or serves the economy-games plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/economy-games/settings/hotpotato
|
||||
|
||||
- Purpose: Processes the economy-games plugin action for settings hotpotato.
|
||||
- Inputs: body: `hotpotato_aliases`, `hotpotato_enabled`, `hotpotato_loss_additive`, `hotpotato_loss_multiplier`, `hotpotato_max_cost`, `hotpotato_min_cost`, `hotpotato_name`, `hotpotato_platform_discord`, `hotpotato_platform_twitch`, `hotpotato_platform_youtube`, `hotpotato_presence_window`, `hotpotato_toss_max`, `hotpotato_toss_min`, `hotpotato_trigger`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-games/settings/coinflip
|
||||
|
||||
- Purpose: Processes the economy-games plugin action for settings coinflip.
|
||||
- Inputs: body: `coinflip_aliases`, `coinflip_cooldown`, `coinflip_enabled`, `coinflip_max_bet`, `coinflip_min_bet`, `coinflip_multiplier`, `coinflip_name`, `coinflip_platform_discord`, `coinflip_platform_twitch`, `coinflip_platform_youtube`, `coinflip_trigger`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-games/settings/mystery
|
||||
|
||||
- Purpose: Processes the economy-games plugin action for settings mystery.
|
||||
- Inputs: body: `mystery_aliases`, `mystery_cooldown`, `mystery_enabled`, `mystery_max_bet`, `mystery_min_bet`, `mystery_multiplier`, `mystery_name`, `mystery_platform_discord`, `mystery_platform_twitch`, `mystery_platform_youtube`, `mystery_trigger`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/economy-games/settings/responses
|
||||
|
||||
- Purpose: Processes the economy-games plugin action for settings responses.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/economy-games
|
||||
104
knowledge/plugins/expression-interaction.md
Normal file
104
knowledge/plugins/expression-interaction.md
Normal file
@ -0,0 +1,104 @@
|
||||
---
|
||||
id: plugin.expression-interaction
|
||||
title: Expression Interaction
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, expression-interaction
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Expression Interaction
|
||||
Express yourself through interactions with other users, such as hugging, bonking, comforting, etc
|
||||
## Metadata
|
||||
Plugin ID: expression-interaction
|
||||
Version: 0.2.0
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/expression-interaction
|
||||
- GET /plugins/expression-interaction
|
||||
- POST /plugins/expression-interaction/settings
|
||||
- POST /plugins/expression-interaction/actions/create
|
||||
- POST /plugins/expression-interaction/actions/:id/update
|
||||
- POST /plugins/expression-interaction/actions/:id/toggle
|
||||
- POST /plugins/expression-interaction/actions/:id/archive
|
||||
- POST /plugins/expression-interaction/actions/:id/restore
|
||||
## Route Reference
|
||||
### MOUNT /plugins/expression-interaction
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/expression-interaction
|
||||
|
||||
- Purpose: Renders or serves the expression-interaction plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/expression-interaction/settings
|
||||
|
||||
- Purpose: Processes the expression-interaction plugin action for settings.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/expression-interaction/actions/create
|
||||
|
||||
- Purpose: Processes the expression-interaction plugin action for actions create.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/expression-interaction/actions/:id/update
|
||||
|
||||
- Purpose: Processes the expression-interaction plugin action for actions id update.
|
||||
- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/expression-interaction/actions/:id/toggle
|
||||
|
||||
- Purpose: Processes the expression-interaction plugin action for actions id toggle.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/expression-interaction/actions/:id/archive
|
||||
|
||||
- Purpose: Processes the expression-interaction plugin action for actions id archive.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/expression-interaction/actions/:id/restore
|
||||
|
||||
- Purpose: Processes the expression-interaction plugin action for actions id restore.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/expression-interaction
|
||||
524
knowledge/plugins/lumi-ai.md
Normal file
524
knowledge/plugins/lumi-ai.md
Normal file
@ -0,0 +1,524 @@
|
||||
---
|
||||
id: plugin.lumi_ai
|
||||
title: Lumi AI
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, lumi_ai
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Lumi AI
|
||||
Managed local AI provider and scoped WebUI assistant for Lumi.
|
||||
## Metadata
|
||||
Plugin ID: lumi_ai
|
||||
Version: 0.8.1
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/lumi_ai
|
||||
- GET /plugins/lumi_ai
|
||||
- POST /plugins/lumi_ai/settings
|
||||
- POST /plugins/lumi_ai/download/runtime
|
||||
- POST /plugins/lumi_ai/download/model/:id
|
||||
- GET /plugins/lumi_ai/api/status
|
||||
- GET /plugins/lumi_ai/api/downloads
|
||||
- GET /plugins/lumi_ai/api/gpu-capacity
|
||||
- GET /plugins/lumi_ai/api/assistant-diagnostics
|
||||
- GET /plugins/lumi_ai/api/assistant/visibility-debug
|
||||
- POST /plugins/lumi_ai/api/assistant/visibility-debug
|
||||
- POST /plugins/lumi_ai/repo-index/refresh
|
||||
- POST /plugins/lumi_ai/access-control
|
||||
- POST /plugins/lumi_ai/models/:id/delete
|
||||
- POST /plugins/lumi_ai/models/:id/verify
|
||||
- POST /plugins/lumi_ai/storage/cleanup
|
||||
- GET /plugins/lumi_ai/logs/:name
|
||||
- GET /plugins/lumi_ai/logs/:name/download
|
||||
- POST /plugins/lumi_ai/logs/:name/delete
|
||||
- POST /plugins/lumi_ai/runtime/:action
|
||||
- GET /plugins/lumi_ai/diagnostics/download
|
||||
- POST /plugins/lumi_ai/assistant/message
|
||||
- POST /plugins/lumi_ai/assistant/feedback
|
||||
- GET /plugins/lumi_ai/assistant/jobs/:id
|
||||
- POST /plugins/lumi_ai/assistant/jobs/:id/cancel
|
||||
- POST /plugins/lumi_ai/assistant/jobs/:id/soft-timeout
|
||||
- POST /plugins/lumi_ai/assistant/test
|
||||
- POST /plugins/lumi_ai/assistant/confirm
|
||||
- POST /plugins/lumi_ai/assistant/cancel
|
||||
- GET /plugins/lumi_ai/api/tools
|
||||
- GET /plugins/lumi_ai/api/tools-diagnostics
|
||||
- GET /plugins/lumi_ai/api/tools/:id/readme
|
||||
- GET /plugins/lumi_ai/api/tools/:id/settings
|
||||
- POST /plugins/lumi_ai/api/tools/:id/settings
|
||||
- POST /plugins/lumi_ai/api/tools/:id/settings/reset
|
||||
- GET /plugins/lumi_ai/tools/:id/assets/*
|
||||
- POST /plugins/lumi_ai/tools/:id/enable
|
||||
- POST /plugins/lumi_ai/tools/:id/disable
|
||||
- POST /plugins/lumi_ai/tools/:id/update
|
||||
- POST /plugins/lumi_ai/tools/:id/delete
|
||||
- GET /plugins/lumi_ai/improvement_center
|
||||
- POST /plugins/lumi_ai/improvement_center/settings
|
||||
- POST /plugins/lumi_ai/improvement_center/reviews/:id
|
||||
- POST /plugins/lumi_ai/improvement_center/reviews/:id/implement
|
||||
- POST /plugins/lumi_ai/improvement_center/corrections/save
|
||||
- POST /plugins/lumi_ai/improvement_center/corrections/:id
|
||||
- POST /plugins/lumi_ai/improvement_center/evals
|
||||
- POST /plugins/lumi_ai/improvement_center/evals/:id/delete
|
||||
- POST /plugins/lumi_ai/improvement_center/evals/run
|
||||
- POST /plugins/lumi_ai/improvement_center/exports/:format
|
||||
## Route Reference
|
||||
### MOUNT /plugins/lumi_ai
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/lumi_ai
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page.
|
||||
- Inputs: query: `access_page`, `logs_page`, `metrics_page`, `slow_page`, `work_page`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/lumi_ai/settings
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for settings.
|
||||
- Inputs: body: `admin_bypass_rate_limit`, `admin_custom`, `allow_deterministic_help_shortcuts`, `allow_moderator_code_help`, `allow_moderators_to_review_responses`, `allowed_support_domains`, `allowed_topics`, `answer_style`, `assistant_debug_logging`, `assistant_enabled`, `batch_size`, `clarification_behavior`, `command_denied_message`, `command_enabled`, `command_platform_discord`, `command_platform_kick`, `command_platform_other`, `command_platform_twitch`, `command_platform_youtube`, `command_role_admins`, `command_role_mods`, `command_role_users`, `command_triggers`, `command_unavailable_message`, `community_tone`, `concurrency`, `context_size`, `corrections_enabled`, `enabled`, `gate_cache_ttl_seconds`, `gate_context_size`, `gate_force_prefix`, `gate_high_confidence_threshold`, `gate_main_llm_threshold`, `gate_model_id`, `gate_predefined_enabled`, `gate_repeat_force_window_seconds`, `gate_similarity_threshold`, `gate_threads`, `gate_timeout_ms`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/download/runtime
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for download runtime.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/download/model/:id
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for download model id.
|
||||
- Inputs: path params: `id`; body: `override_compatibility`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/lumi_ai/api/status
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api status.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/api/downloads
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api downloads.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/api/gpu-capacity
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api gpu capacity.
|
||||
- Inputs: query: `context_size`, `intent_percent`, `model_id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/api/assistant-diagnostics
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api assistant diagnostics.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/api/assistant/visibility-debug
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api assistant visibility debug.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /plugins/lumi_ai/api/assistant/visibility-debug
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for api assistant visibility debug.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /plugins/lumi_ai/repo-index/refresh
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for repo index refresh.
|
||||
- Inputs: body: `source`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/access-control
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for access control.
|
||||
- Inputs: body: `action`, `reason`, `silent`, `timeout_until`, `user_id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/models/:id/delete
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for models id delete.
|
||||
- Inputs: path params: `id`; body: `confirm`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/models/:id/verify
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for models id verify.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/storage/cleanup
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for storage cleanup.
|
||||
- Inputs: body: `categories`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/lumi_ai/logs/:name
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for logs name.
|
||||
- Inputs: path params: `name`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page.
|
||||
|
||||
### GET /plugins/lumi_ai/logs/:name/download
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for logs name download.
|
||||
- Inputs: path params: `name`
|
||||
- Response format: file download
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page.
|
||||
|
||||
### POST /plugins/lumi_ai/logs/:name/delete
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for logs name delete.
|
||||
- Inputs: path params: `name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/runtime/:action
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for runtime action.
|
||||
- Inputs: path params: `action`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/lumi_ai/diagnostics/download
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for diagnostics download.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: file download
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/message
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant message.
|
||||
- Inputs: body: `history`, `message`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/feedback
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant feedback.
|
||||
- Inputs: body: `assistant_answer`, `feedback_kind`, `feedback_tag`, `model`, `optional_correction`, `route_used`, `timestamp`, `user_message`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/lumi_ai/assistant/jobs/:id
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for assistant jobs id.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/jobs/:id/cancel
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant jobs id cancel.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/jobs/:id/soft-timeout
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant jobs id soft timeout.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/test
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant test.
|
||||
- Inputs: body: `allow_tools`, `message`, `origin`, `role`, `show_raw_output`, `show_raw_prompt`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/confirm
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant confirm.
|
||||
- Inputs: body: `id`
|
||||
- Response format: JSON response
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/cancel
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant cancel.
|
||||
- Inputs: body: `id`
|
||||
- Response format: JSON response
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/lumi_ai/api/tools
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api tools.
|
||||
- Inputs: query: `refresh`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/api/tools-diagnostics
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api tools diagnostics.
|
||||
- Inputs: query: `origin`, `role`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/api/tools/:id/readme
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api tools id readme.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/api/tools/:id/settings
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for api tools id settings.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /plugins/lumi_ai/api/tools/:id/settings
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for api tools id settings.
|
||||
- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /plugins/lumi_ai/api/tools/:id/settings/reset
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for api tools id settings reset.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/lumi_ai/tools/:id/assets/*
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for tools id assets *.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: static file response
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/lumi_ai/tools/:id/enable
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for tools id enable.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/tools/:id/disable
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for tools id disable.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/tools/:id/update
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for tools id update.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/tools/:id/delete
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for tools id delete.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/lumi_ai/improvement_center
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for improvement center.
|
||||
- Inputs: query: `correction_page`, `eval_page`, `review_page`, `status`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/settings
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center settings.
|
||||
- Inputs: body: `allow_moderators_to_review_responses`, `corrections_enabled`, `trusted_moderator_reviewers`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/reviews/:id
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id.
|
||||
- Inputs: path params: `id`; body: `action`, `review_notes`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/reviews/:id/implement
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id implement.
|
||||
- Inputs: path params: `id`; body: `corrected_answer`, `enabled`, `expected_link`, `explicitly_safe`, `forbidden_behavior`, `min_role`, `notes`, `permission_origin`, `target`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/corrections/save
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center corrections save.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/corrections/:id
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center corrections id.
|
||||
- Inputs: path params: `id`; body: `action`, `enabled`, `expected_link`, `explicitly_safe`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/evals
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center evals.
|
||||
- Inputs: body: `expected_link`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/evals/:id/delete
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center evals id delete.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/evals/run
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center evals run.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/exports/:format
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center exports format.
|
||||
- Inputs: path params: `format`
|
||||
- Response format: file download
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/lumi_ai
|
||||
124
knowledge/plugins/moderation.md
Normal file
124
knowledge/plugins/moderation.md
Normal file
@ -0,0 +1,124 @@
|
||||
---
|
||||
id: plugin.moderation
|
||||
title: Moderation Center
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, moderation
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Moderation Center
|
||||
Cross-platform moderation actions, notes, and sanctions.
|
||||
## Metadata
|
||||
Plugin ID: moderation
|
||||
Version: 0.1.4
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/moderation
|
||||
- GET /plugins/moderation/status
|
||||
- GET /plugins/moderation
|
||||
- GET /plugins/moderation/tos-bans
|
||||
- GET /plugins/moderation/evidence/:id
|
||||
- POST /plugins/moderation/actions
|
||||
- POST /plugins/moderation/actions/:id/update-timeout
|
||||
- POST /plugins/moderation/actions/:id/revoke
|
||||
- POST /plugins/moderation/notes
|
||||
- POST /plugins/moderation/notes/:id/delete
|
||||
## Route Reference
|
||||
### MOUNT /plugins/moderation
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/moderation/status
|
||||
|
||||
- Purpose: Renders or serves the moderation plugin page for status.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /plugins/moderation
|
||||
|
||||
- Purpose: Renders or serves the moderation plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /plugins/moderation/tos-bans
|
||||
|
||||
- Purpose: Renders or serves the moderation plugin page for tos bans.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /plugins/moderation/evidence/:id
|
||||
|
||||
- Purpose: Renders or serves the moderation plugin page for evidence id.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request; file download
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/moderation/actions
|
||||
|
||||
- Purpose: Processes the moderation plugin action for actions.
|
||||
- Inputs: body: `action_type`, `reason_detail`, `reason_short`; file upload: multipart form file data
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/moderation/actions/:id/update-timeout
|
||||
|
||||
- Purpose: Processes the moderation plugin action for actions id update timeout.
|
||||
- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/moderation/actions/:id/revoke
|
||||
|
||||
- Purpose: Processes the moderation plugin action for actions id revoke.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/moderation/notes
|
||||
|
||||
- Purpose: Processes the moderation plugin action for notes.
|
||||
- Inputs: body: `note`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/moderation/notes/:id/delete
|
||||
|
||||
- Purpose: Processes the moderation plugin action for notes id delete.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/moderation
|
||||
144
knowledge/plugins/okf.md
Normal file
144
knowledge/plugins/okf.md
Normal file
@ -0,0 +1,144 @@
|
||||
---
|
||||
id: plugin.okf
|
||||
title: OKF Knowledge
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, okf
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# OKF Knowledge
|
||||
Role-gated knowledge, facts, and Q&A entries for Lumi communities.
|
||||
## Metadata
|
||||
Plugin ID: okf
|
||||
Version: 0.1.0
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/okf
|
||||
- GET /plugins/okf
|
||||
- GET /plugins/okf/admin
|
||||
- POST /plugins/okf/admin/community
|
||||
- POST /plugins/okf/admin/community/:slug
|
||||
- POST /plugins/okf/admin/entries
|
||||
- POST /plugins/okf/admin/entries/:slug
|
||||
- POST /plugins/okf/admin/entries/:slug/:action
|
||||
- POST /plugins/okf/admin/entries/:slug/versions/:version/restore
|
||||
- POST /plugins/okf/admin/permissions
|
||||
- POST /plugins/okf/admin/permissions/:id/revoke
|
||||
- GET /plugins/okf/:slug
|
||||
## Route Reference
|
||||
### MOUNT /plugins/okf
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/okf
|
||||
|
||||
- Purpose: Renders or serves the okf plugin page.
|
||||
- Inputs: query: `category`, `q`, `tag`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /plugins/okf/admin
|
||||
|
||||
- Purpose: Renders the okf plugin administration page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered by a helper
|
||||
- Access: OKF editor or manager permission required; admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/okf/admin/community
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for community.
|
||||
- Inputs: body: `body`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: OKF editor or manager permission required; admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/okf/admin/community/:slug
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for community slug.
|
||||
- Inputs: path params: `slug`; body: `body`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: OKF editor or manager permission required; admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/okf/admin/entries
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for entries.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: OKF editor or manager permission required; admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/okf/admin/entries/:slug
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for entries slug.
|
||||
- Inputs: path params: `slug`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: OKF editor or manager permission required; admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/okf/admin/entries/:slug/:action
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for entries slug action.
|
||||
- Inputs: path params: `action`, `slug`; body: `note`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: OKF editor or manager permission required; admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/okf/admin/entries/:slug/versions/:version/restore
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for entries slug versions version restore.
|
||||
- Inputs: path params: `slug`, `version`; body: `note`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: OKF editor or manager permission required; admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/okf/admin/permissions
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for permissions.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/okf/admin/permissions/:id/revoke
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for permissions id revoke.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/okf/:slug
|
||||
|
||||
- Purpose: Renders or serves the okf plugin page for slug.
|
||||
- Inputs: path params: `slug`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/okf
|
||||
124
knowledge/plugins/quotes.md
Normal file
124
knowledge/plugins/quotes.md
Normal file
@ -0,0 +1,124 @@
|
||||
---
|
||||
id: plugin.quotes
|
||||
title: Quotes
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, quotes
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Quotes
|
||||
Store, search, and manage community quotes.
|
||||
## Metadata
|
||||
Plugin ID: quotes
|
||||
Version: 0.1.1
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/quotes
|
||||
- GET /plugins/quotes
|
||||
- POST /plugins/quotes/quotes/create
|
||||
- POST /plugins/quotes/quotes/:id/update
|
||||
- POST /plugins/quotes/quotes/:id/hide
|
||||
- POST /plugins/quotes/quotes/:id/unhide
|
||||
- POST /plugins/quotes/quotes/:id/archive
|
||||
- POST /plugins/quotes/quotes/:id/restore
|
||||
- GET /plugins/quotes/api/quotes
|
||||
- GET /plugins/quotes/api/quotes/:id
|
||||
## Route Reference
|
||||
### MOUNT /plugins/quotes
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/quotes
|
||||
|
||||
- Purpose: Renders or serves the quotes plugin page.
|
||||
- Inputs: query: `edit`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/quotes/quotes/create
|
||||
|
||||
- Purpose: Processes the quotes plugin action for quotes create.
|
||||
- Inputs: body: `archived`, `game_name`, `hidden`, `quote_datetime`, `quote_text`, `quoter`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/quotes/quotes/:id/update
|
||||
|
||||
- Purpose: Processes the quotes plugin action for quotes id update.
|
||||
- Inputs: path params: `id`; body: `archived`, `game_name`, `hidden`, `quote_datetime`, `quote_text`, `quoter`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/quotes/quotes/:id/hide
|
||||
|
||||
- Purpose: Processes the quotes plugin action for quotes id hide.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/quotes/quotes/:id/unhide
|
||||
|
||||
- Purpose: Processes the quotes plugin action for quotes id unhide.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/quotes/quotes/:id/archive
|
||||
|
||||
- Purpose: Processes the quotes plugin action for quotes id archive.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/quotes/quotes/:id/restore
|
||||
|
||||
- Purpose: Processes the quotes plugin action for quotes id restore.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/quotes/api/quotes
|
||||
|
||||
- Purpose: Renders or serves the quotes plugin page for api quotes.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /plugins/quotes/api/quotes/:id
|
||||
|
||||
- Purpose: Renders or serves the quotes plugin page for api quotes id.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected; moderator access may be required; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/quotes
|
||||
44
knowledge/plugins/sample-plugin.md
Normal file
44
knowledge/plugins/sample-plugin.md
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
id: plugin.sample-plugin
|
||||
title: Sample Plugin
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, sample-plugin
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Sample Plugin
|
||||
Example plugin with a simple page.
|
||||
## Metadata
|
||||
Plugin ID: sample-plugin
|
||||
Version: 0.1.0
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/sample-plugin
|
||||
- GET /plugins/sample-plugin
|
||||
## Route Reference
|
||||
### MOUNT /plugins/sample-plugin
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/sample-plugin
|
||||
|
||||
- Purpose: Renders or serves the sample-plugin plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/sample-plugin
|
||||
124
knowledge/plugins/throne-wishlist.md
Normal file
124
knowledge/plugins/throne-wishlist.md
Normal file
@ -0,0 +1,124 @@
|
||||
---
|
||||
id: plugin.throne_wishlist
|
||||
title: Throne Wishlist
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, throne_wishlist
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Throne Wishlist
|
||||
Throne wishlist webhook integration with verified payloads, debug viewer, and cross-platform event messages.
|
||||
## Metadata
|
||||
Plugin ID: throne_wishlist
|
||||
Version: 0.1.1
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/throne_wishlist
|
||||
- GET /plugins/throne_wishlist
|
||||
- POST /plugins/throne_wishlist/endpoints/create
|
||||
- POST /plugins/throne_wishlist/endpoints/:id/renew
|
||||
- POST /plugins/throne_wishlist/endpoints/:id/remove
|
||||
- POST /plugins/throne_wishlist/destinations
|
||||
- POST /plugins/throne_wishlist/templates
|
||||
- POST /plugins/throne_wishlist/debug/start
|
||||
- GET /plugins/throne_wishlist/debug/stream/:sessionId
|
||||
- POST /plugins/throne_wishlist/debug/stop/:sessionId
|
||||
## Route Reference
|
||||
### MOUNT /plugins/throne_wishlist
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/throne_wishlist
|
||||
|
||||
- Purpose: Renders or serves the throne_wishlist plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/throne_wishlist/endpoints/create
|
||||
|
||||
- Purpose: Processes the throne_wishlist plugin action for endpoints create.
|
||||
- Inputs: body: `identifier`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/throne_wishlist/endpoints/:id/renew
|
||||
|
||||
- Purpose: Processes the throne_wishlist plugin action for endpoints id renew.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/throne_wishlist/endpoints/:id/remove
|
||||
|
||||
- Purpose: Processes the throne_wishlist plugin action for endpoints id remove.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/throne_wishlist/destinations
|
||||
|
||||
- Purpose: Processes the throne_wishlist plugin action for destinations.
|
||||
- Inputs: body: `discord_destination_id`, `enabled_platforms`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/throne_wishlist/templates
|
||||
|
||||
- Purpose: Processes the throne_wishlist plugin action for templates.
|
||||
- Inputs: body: `enabled`, `event_type`, `platform`, `template`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/throne_wishlist/debug/start
|
||||
|
||||
- Purpose: Processes the throne_wishlist plugin action for debug start.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /plugins/throne_wishlist/debug/stream/:sessionId
|
||||
|
||||
- Purpose: Renders or serves the throne_wishlist plugin page for debug stream sessionId.
|
||||
- Inputs: path params: `sessionId`
|
||||
- Response format: streaming event response
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/throne_wishlist/debug/stop/:sessionId
|
||||
|
||||
- Purpose: Processes the throne_wishlist plugin action for debug stop sessionId.
|
||||
- Inputs: path params: `sessionId`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/throne_wishlist
|
||||
114
knowledge/plugins/welcome-messages.md
Normal file
114
knowledge/plugins/welcome-messages.md
Normal file
@ -0,0 +1,114 @@
|
||||
---
|
||||
id: plugin.welcome_messages
|
||||
title: Welcome Messages
|
||||
scope: plugins
|
||||
status: active
|
||||
priority: 10
|
||||
visibility: user
|
||||
category: Plugin
|
||||
tags: plugin, welcome_messages
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Welcome Messages
|
||||
Randomized Discord welcome and welcome-back messages with safe pronoun preferences.
|
||||
## Metadata
|
||||
Plugin ID: welcome_messages
|
||||
Version: 0.1.0
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/welcome_messages
|
||||
- GET /plugins/welcome_messages
|
||||
- POST /plugins/welcome_messages/settings
|
||||
- POST /plugins/welcome_messages/messages/create
|
||||
- POST /plugins/welcome_messages/messages/:id/update
|
||||
- POST /plugins/welcome_messages/messages/:id/duplicate
|
||||
- POST /plugins/welcome_messages/messages/:id/archive
|
||||
- POST /plugins/welcome_messages/messages/:id/restore
|
||||
- POST /plugins/welcome_messages/profile/pronouns
|
||||
## Route Reference
|
||||
### MOUNT /plugins/welcome_messages
|
||||
|
||||
- Purpose: Mounts the plugin router at this base WebUI path.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Plugin router mount point.
|
||||
- Access: Access is controlled by the mount options and individual plugin routes.
|
||||
- Side effects: No direct route action; child routes handle requests.
|
||||
- Limits/notes: Mount metadata is inferred from static source scanning.
|
||||
|
||||
### GET /plugins/welcome_messages
|
||||
|
||||
- Purpose: Renders or serves the welcome_messages plugin page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/welcome_messages/settings
|
||||
|
||||
- Purpose: Processes the welcome_messages plugin action for settings.
|
||||
- Inputs: body: `channel_id`, `enabled`, `welcome_back_enabled`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/welcome_messages/messages/create
|
||||
|
||||
- Purpose: Processes the welcome_messages plugin action for messages create.
|
||||
- Inputs: body: `enabled`, `pool`, `text`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/welcome_messages/messages/:id/update
|
||||
|
||||
- Purpose: Processes the welcome_messages plugin action for messages id update.
|
||||
- Inputs: path params: `id`; body: `enabled`, `pool`, `text`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/welcome_messages/messages/:id/duplicate
|
||||
|
||||
- Purpose: Processes the welcome_messages plugin action for messages id duplicate.
|
||||
- Inputs: path params: `id`; body: `pool`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/welcome_messages/messages/:id/archive
|
||||
|
||||
- Purpose: Processes the welcome_messages plugin action for messages id archive.
|
||||
- Inputs: path params: `id`; body: `pool`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/welcome_messages/messages/:id/restore
|
||||
|
||||
- Purpose: Processes the welcome_messages plugin action for messages id restore.
|
||||
- Inputs: path params: `id`; body: `pool`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/welcome_messages/profile/pronouns
|
||||
|
||||
- Purpose: Processes the welcome_messages plugin action for profile pronouns.
|
||||
- Inputs: body: `pronoun_set`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
## Commands
|
||||
- No plugin command triggers detected.
|
||||
## Source
|
||||
Plugin folder: plugins/welcome_messages
|
||||
@ -1,5 +1,10 @@
|
||||
const crypto = require("crypto");
|
||||
const { buildPrompt, buildToolResultInstruction } = require("./prompt_builder");
|
||||
const {
|
||||
buildControllerDecision,
|
||||
okfLimitForController,
|
||||
outputBudgetForController
|
||||
} = require("./controller");
|
||||
const { roleOf } = require("./permissions");
|
||||
const { parseToolCallResult } = require("./tool_router");
|
||||
const { normalizeScope } = require("./scope_manager");
|
||||
@ -33,6 +38,19 @@ class AiProvider {
|
||||
let effectiveMessage = message;
|
||||
let gateDecision = null;
|
||||
let requestClass = classifyRequestType(message, { role, scope });
|
||||
let controllerDecision = null;
|
||||
this.metrics.record({
|
||||
kind: "prompt",
|
||||
status: "received",
|
||||
request_id: requestId,
|
||||
user_id: user?.id,
|
||||
role,
|
||||
scope,
|
||||
origin: originContext?.origin || originContext?.platform || "webui",
|
||||
platform: originContext?.platform || originContext?.origin || "webui",
|
||||
prompt: redactPrompt(message),
|
||||
prompt_tokens: estimateTokens(message)
|
||||
});
|
||||
if (this.gate) {
|
||||
gateDecision = await this.gate.route({
|
||||
message,
|
||||
@ -47,6 +65,40 @@ class AiProvider {
|
||||
});
|
||||
effectiveMessage = gateDecision.message || message;
|
||||
requestClass = gateDecision.request_class || requestClass;
|
||||
this.metrics.record({
|
||||
kind: "gate_summary",
|
||||
status: "success",
|
||||
request_id: requestId,
|
||||
user_id: user?.id,
|
||||
role,
|
||||
scope,
|
||||
route_used: gateDecision.route,
|
||||
confidence: gateDecision.confidence,
|
||||
reason_code: gateDecision.reason_code,
|
||||
route_class: requestClass,
|
||||
gate_error: gateDecision.gate_error || null,
|
||||
deterministic_ms: gateDecision.deterministic_ms,
|
||||
gate_ms: gateDecision.gate_ms,
|
||||
platform: originContext?.origin || originContext?.platform || "webui"
|
||||
});
|
||||
controllerDecision = buildControllerDecision({
|
||||
message: effectiveMessage,
|
||||
role,
|
||||
scope,
|
||||
originContext,
|
||||
gateDecision,
|
||||
requestClass,
|
||||
config: cfg
|
||||
});
|
||||
this.metrics.record(controllerMetricEntry({
|
||||
requestId,
|
||||
user,
|
||||
role,
|
||||
scope,
|
||||
originContext,
|
||||
decision: controllerDecision
|
||||
}));
|
||||
onStage("controller", controllerStageDetails(controllerDecision));
|
||||
if (gateDecision.answer) {
|
||||
const answer = gateDecision.answer;
|
||||
this.metrics.record({
|
||||
@ -76,6 +128,7 @@ class AiProvider {
|
||||
model_id: cfg.gate?.model_id || "lumi-gate",
|
||||
route_used: gateDecision.route,
|
||||
route_class: requestClass,
|
||||
controller_decision: controllerDecision,
|
||||
max_output_tokens_used: 0,
|
||||
gate_decision: gateDecision,
|
||||
stage_timings: {
|
||||
@ -110,6 +163,14 @@ class AiProvider {
|
||||
model_id: "lumi-repo-index",
|
||||
route_used: `repo_${repoAnswer.type}`,
|
||||
route_class: requestClass,
|
||||
controller_decision: buildControllerDecision({
|
||||
message: effectiveMessage,
|
||||
role,
|
||||
scope,
|
||||
originContext,
|
||||
requestClass,
|
||||
config: cfg
|
||||
}),
|
||||
max_output_tokens_used: 0,
|
||||
internal_generated_length: repoAnswer.text.length,
|
||||
duration_ms: Date.now() - started,
|
||||
@ -122,6 +183,25 @@ class AiProvider {
|
||||
route: "main_llm",
|
||||
reason_code: gateDecision?.reason_code || "direct_main_llm"
|
||||
});
|
||||
if (!controllerDecision) {
|
||||
controllerDecision = buildControllerDecision({
|
||||
message: effectiveMessage,
|
||||
role,
|
||||
scope,
|
||||
originContext,
|
||||
requestClass,
|
||||
config: cfg
|
||||
});
|
||||
this.metrics.record(controllerMetricEntry({
|
||||
requestId,
|
||||
user,
|
||||
role,
|
||||
scope,
|
||||
originContext,
|
||||
decision: controllerDecision
|
||||
}));
|
||||
onStage("controller", controllerStageDetails(controllerDecision));
|
||||
}
|
||||
if (this.ensureRuntime) {
|
||||
const health = await this.runtime.health();
|
||||
if (!health.healthy) {
|
||||
@ -158,28 +238,83 @@ class AiProvider {
|
||||
.filter((decision) => !decision.exposed)
|
||||
.map((decision) => ({ tool_id: decision.tool.tool_id, reason: decision.reason }))
|
||||
});
|
||||
let contextBlocks = [];
|
||||
let contextFailure = null;
|
||||
let contextDiagnostics = [];
|
||||
const okfLimit = okfLimitForController(controllerDecision);
|
||||
try {
|
||||
contextBlocks = this.getContext({
|
||||
role,
|
||||
user,
|
||||
message: effectiveMessage,
|
||||
originContext,
|
||||
scope,
|
||||
okf_retrieval: controllerDecision.okf_retrieval,
|
||||
limit: okfLimit,
|
||||
controller: controllerDecision
|
||||
}) || [];
|
||||
contextDiagnostics = Array.isArray(this.getContext?.lastDiagnostics)
|
||||
? this.getContext.lastDiagnostics
|
||||
: [];
|
||||
} catch (error) {
|
||||
contextFailure = error;
|
||||
contextBlocks = [];
|
||||
}
|
||||
const okfDiagnostics = summarizeContextDiagnostics(contextDiagnostics);
|
||||
this.metrics.record({
|
||||
kind: "okf_retrieval",
|
||||
status: contextFailure ? "failed" : "success",
|
||||
request_id: requestId,
|
||||
user_id: user.id,
|
||||
role,
|
||||
scope,
|
||||
okf_retrieval_depth: controllerDecision.okf_retrieval,
|
||||
okf_match_count: Array.isArray(contextBlocks) ? contextBlocks.length : 0,
|
||||
okf_candidate_count: okfDiagnostics.candidate_count,
|
||||
okf_query: okfDiagnostics.query || effectiveMessage,
|
||||
okf_limit: okfLimit,
|
||||
okf_provider_count: okfDiagnostics.provider_count,
|
||||
fallback_reason: controllerDecision.fallback_used ? controllerDecision.reason_code : null,
|
||||
gate_reason_code: gateDecision?.reason_code || null,
|
||||
gate_error: gateDecision?.gate_error || null,
|
||||
error_code: contextFailure?.code || null,
|
||||
message: contextFailure?.message ? String(contextFailure.message).slice(0, 300) : null
|
||||
});
|
||||
const prompt = buildPrompt({
|
||||
config: cfg,
|
||||
role,
|
||||
message: effectiveMessage,
|
||||
requestClass,
|
||||
contextBlocks: this.getContext({
|
||||
role,
|
||||
user,
|
||||
message: effectiveMessage,
|
||||
originContext,
|
||||
scope
|
||||
}),
|
||||
contextBlocks,
|
||||
correctionContext,
|
||||
repoContext,
|
||||
originContext,
|
||||
originContext: originContext ? {
|
||||
...originContext,
|
||||
response_profile: controllerDecision.source_profile
|
||||
} : null,
|
||||
controllerDecision,
|
||||
tools: toolExposure.exposed
|
||||
});
|
||||
this.metrics.record({
|
||||
kind: "prompt_build",
|
||||
status: "success",
|
||||
request_id: requestId,
|
||||
user_id: user.id,
|
||||
role,
|
||||
scope,
|
||||
context_block_count: contextBlocks.length,
|
||||
correction_count: correctionContext.length,
|
||||
repo_context_count: repoContext.length,
|
||||
prompt_tokens: estimateTokens(prompt),
|
||||
controller_complexity: controllerDecision.complexity,
|
||||
okf_retrieval_depth: controllerDecision.okf_retrieval
|
||||
});
|
||||
const conversation = normalizeHistory(history);
|
||||
const outputTokenLimit = resolveOutputBudget({
|
||||
config: cfg,
|
||||
requestClass,
|
||||
explicitMaxTokens: max_tokens
|
||||
explicitMaxTokens: max_tokens,
|
||||
controllerDecision
|
||||
});
|
||||
const runtimeSettings = {
|
||||
backend: this.runtime.activeAcceleration?.backend || this.runtime.runtimeMetadata?.()?.backend || "cpu",
|
||||
@ -190,7 +325,11 @@ class AiProvider {
|
||||
threads: Number(cfg.threads) || 0,
|
||||
max_output_tokens: outputTokenLimit,
|
||||
max_output_tokens_used: outputTokenLimit,
|
||||
route_class: requestClass
|
||||
route_class: requestClass,
|
||||
controller_complexity: controllerDecision.complexity,
|
||||
okf_retrieval_depth: controllerDecision.okf_retrieval,
|
||||
controller_intent: controllerDecision.intent,
|
||||
source_profile: controllerDecision.source_profile.source
|
||||
};
|
||||
onStage("prompt_eval", { route: "main_llm", queue_ms: queueWait, ...runtimeSettings });
|
||||
const generatingTimer = setTimeout(() => {
|
||||
@ -199,6 +338,18 @@ class AiProvider {
|
||||
generatingTimer.unref?.();
|
||||
let result;
|
||||
try {
|
||||
this.metrics.record({
|
||||
kind: "model_request",
|
||||
status: "started",
|
||||
request_id: requestId,
|
||||
user_id: user.id,
|
||||
role,
|
||||
scope,
|
||||
model: cfg.selected_model_id,
|
||||
controller_complexity: controllerDecision.complexity,
|
||||
okf_retrieval_depth: controllerDecision.okf_retrieval,
|
||||
prompt_tokens: estimateTokens(prompt)
|
||||
});
|
||||
result = await this.runtime.infer(
|
||||
[
|
||||
{ role: "system", content: prompt },
|
||||
@ -211,6 +362,18 @@ class AiProvider {
|
||||
timeoutMs: cfg.hard_generation_timeout_ms
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
this.metrics.record({
|
||||
kind: "error",
|
||||
status: "failed",
|
||||
request_id: requestId,
|
||||
user_id: user.id,
|
||||
role,
|
||||
scope,
|
||||
error_code: error.code || error.name || "model_request_failed",
|
||||
message: String(error.message || "Main model request failed.").slice(0, 300)
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(generatingTimer);
|
||||
}
|
||||
@ -320,6 +483,21 @@ class AiProvider {
|
||||
}
|
||||
}
|
||||
const inference = combineInferenceDiagnostics(initialInference, finalInference);
|
||||
let fallbackReason = null;
|
||||
if (!String(finalText || "").trim()) {
|
||||
fallbackReason = "empty_model_response";
|
||||
rejectedReason = rejectedReason || fallbackReason;
|
||||
finalText = "Lumi did not return a usable answer. Please retry, rephrase, or check Work History for the failed stage.";
|
||||
this.metrics.record({
|
||||
kind: "fallback",
|
||||
status: "partial",
|
||||
request_id: requestId,
|
||||
user_id: user.id,
|
||||
role,
|
||||
scope,
|
||||
fallback_reason: fallbackReason
|
||||
});
|
||||
}
|
||||
const out = {
|
||||
success: true,
|
||||
text: finalText,
|
||||
@ -338,10 +516,12 @@ class AiProvider {
|
||||
request_id: requestId,
|
||||
route_used: gateDecision ? "main_llm" : "llm",
|
||||
route_class: requestClass,
|
||||
controller_decision: controllerDecision,
|
||||
max_output_tokens_used: outputTokenLimit,
|
||||
gate_decision: gateDecision,
|
||||
force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null,
|
||||
internal_generated_length: initialText.length + String(finalText || "").length,
|
||||
fallback_reason: fallbackReason,
|
||||
stage_timings: {
|
||||
deterministic_ms: gateDecision?.deterministic_ms || 0,
|
||||
gate_ms: gateDecision?.gate_ms || 0,
|
||||
@ -355,6 +535,9 @@ class AiProvider {
|
||||
diagnostics: {
|
||||
...inference,
|
||||
...runtimeSettings,
|
||||
controller_reason_code: controllerDecision.reason_code,
|
||||
controller_confidence: controllerDecision.confidence,
|
||||
source_profile: controllerDecision.source_profile,
|
||||
gate_ms: gateDecision?.gate_ms || 0,
|
||||
queue_ms: queueWait,
|
||||
total_ms: Date.now() - started
|
||||
@ -368,13 +551,25 @@ class AiProvider {
|
||||
exposed_tools: toolExposure.exposed.map((tool) => tool.tool_id),
|
||||
selected_tool: selectedTool,
|
||||
rejected_reason: rejectedReason,
|
||||
fallback_reason: fallbackReason,
|
||||
execution_ms: toolExecutionMs,
|
||||
tool_executed: Boolean(toolResult),
|
||||
route_used: gateDecision ? "main_llm" : "llm",
|
||||
route_class: requestClass,
|
||||
controller_route: controllerDecision.route,
|
||||
controller_intent: controllerDecision.intent,
|
||||
controller_complexity: controllerDecision.complexity,
|
||||
controller_reason_code: controllerDecision.reason_code,
|
||||
controller_confidence: controllerDecision.confidence,
|
||||
okf_retrieval_depth: controllerDecision.okf_retrieval,
|
||||
source_profile: controllerDecision.source_profile.source,
|
||||
target_final_chars: controllerDecision.source_profile.target_chars,
|
||||
hard_final_chars: controllerDecision.source_profile.hard_chars,
|
||||
controller_fallback_used: controllerDecision.fallback_used,
|
||||
max_output_tokens_used: outputTokenLimit,
|
||||
gate_confidence: gateDecision?.confidence,
|
||||
gate_reason_code: gateDecision?.reason_code,
|
||||
gate_error: gateDecision?.gate_error || null,
|
||||
force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null,
|
||||
deterministic_ms: out.stage_timings.deterministic_ms,
|
||||
gate_ms: out.stage_timings.gate_ms,
|
||||
@ -553,18 +748,63 @@ function normalizeInferenceDiagnostics(result, elapsedMs = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOutputBudget({ config, requestClass, explicitMaxTokens }) {
|
||||
const requested = Number(explicitMaxTokens);
|
||||
const classBudget = Number(config?.output_budgets?.[requestClass]);
|
||||
const legacyFallback = Number(config?.max_output_tokens);
|
||||
const selected = Number.isFinite(requested) && requested > 0
|
||||
? requested
|
||||
: Number.isFinite(classBudget) && classBudget > 0
|
||||
? classBudget
|
||||
: Number.isFinite(legacyFallback) && legacyFallback > 0
|
||||
? legacyFallback
|
||||
: 512;
|
||||
return Math.max(64, Math.min(32768, Math.round(selected)));
|
||||
function resolveOutputBudget({ config, requestClass, explicitMaxTokens, controllerDecision } = {}) {
|
||||
return outputBudgetForController({ config, requestClass, explicitMaxTokens, controllerDecision });
|
||||
}
|
||||
|
||||
function controllerMetricEntry({ requestId, user, role, scope, originContext, decision }) {
|
||||
return {
|
||||
kind: "controller_decision",
|
||||
status: "success",
|
||||
request_id: requestId,
|
||||
user_id: user?.id,
|
||||
role,
|
||||
scope,
|
||||
route_used: decision.route,
|
||||
controller_route: decision.route,
|
||||
controller_intent: decision.intent,
|
||||
controller_complexity: decision.complexity,
|
||||
controller_reason_code: decision.reason_code,
|
||||
controller_confidence: decision.confidence,
|
||||
okf_retrieval_depth: decision.okf_retrieval,
|
||||
answer_style: decision.answer_style,
|
||||
source_profile: decision.source_profile.source,
|
||||
target_final_chars: decision.source_profile.target_chars,
|
||||
hard_final_chars: decision.source_profile.hard_chars,
|
||||
permission_sensitive: decision.permission_sensitive,
|
||||
admin_only: decision.admin_only,
|
||||
risk_of_private_data: decision.risk_of_private_data,
|
||||
controller_fallback_used: decision.fallback_used,
|
||||
gate_reason_code: decision.gate_reason_code,
|
||||
platform: originContext?.origin || originContext?.platform || "other"
|
||||
};
|
||||
}
|
||||
|
||||
function controllerStageDetails(decision) {
|
||||
return {
|
||||
route: decision.route,
|
||||
controller_intent: decision.intent,
|
||||
controller_complexity: decision.complexity,
|
||||
controller_reason_code: decision.reason_code,
|
||||
controller_confidence: decision.confidence,
|
||||
okf_retrieval_depth: decision.okf_retrieval,
|
||||
answer_style: decision.answer_style,
|
||||
source_profile: decision.source_profile.source,
|
||||
target_final_chars: decision.source_profile.target_chars,
|
||||
hard_final_chars: decision.source_profile.hard_chars,
|
||||
controller_fallback_used: decision.fallback_used
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeContextDiagnostics(rows = []) {
|
||||
const diagnostics = Array.isArray(rows) ? rows.filter((row) => row && typeof row === "object") : [];
|
||||
const okfRows = diagnostics.filter((row) => row.provider === "okf" || row.kind === "okf");
|
||||
const preferred = okfRows[0] || diagnostics[0] || {};
|
||||
return {
|
||||
query: preferred.query ? String(preferred.query).slice(0, 500) : null,
|
||||
candidate_count: okfRows.reduce((sum, row) => sum + (Number(row.candidate_count) || 0), 0),
|
||||
provider_count: diagnostics.length
|
||||
};
|
||||
}
|
||||
|
||||
function numberValue(...values) {
|
||||
@ -588,6 +828,19 @@ function requestCancelledError() {
|
||||
);
|
||||
}
|
||||
|
||||
function estimateTokens(value) {
|
||||
const text = String(value || "");
|
||||
if (!text.trim()) return 0;
|
||||
return Math.max(1, Math.ceil(text.length / 4));
|
||||
}
|
||||
|
||||
function redactPrompt(value) {
|
||||
return String(value || "")
|
||||
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]")
|
||||
.replace(/\b((?:token|secret|password|api[_ -]?key|client[_ -]?secret)\s*[:=]\s*)\S+/gi, "$1[redacted]")
|
||||
.slice(0, 6000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AiProvider,
|
||||
isInScope,
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
const { roleOf } = require("./permissions");
|
||||
const { constrainFinal } = require("./response_formatter");
|
||||
|
||||
const PLATFORM_DEFAULTS = {
|
||||
discord: { markdown: true, html: false, reply_thread: true, max: 1900 },
|
||||
twitch: { markdown: false, html: false, reply_thread: false, max: 450 },
|
||||
youtube: { markdown: false, html: false, reply_thread: false, max: 1800 },
|
||||
kick: { markdown: false, html: false, reply_thread: false, max: 450 },
|
||||
webui: { markdown: false, html: true, reply_thread: false, max: 4000 },
|
||||
webui: { markdown: false, html: true, reply_thread: false, max: null },
|
||||
other: { markdown: false, html: false, reply_thread: false, max: 1000 }
|
||||
};
|
||||
|
||||
@ -58,11 +59,13 @@ function formatPlatformReplyDetails(text, links, origin) {
|
||||
}
|
||||
output = output.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||
const originalLength = output.length;
|
||||
const delivered = truncate(output, origin.max_message_length);
|
||||
const constrained = constrainFinal(output, origin.max_message_length);
|
||||
const delivered = constrained.text;
|
||||
return {
|
||||
text: delivered,
|
||||
original_final_length: originalLength,
|
||||
delivered_length: delivered.length
|
||||
delivered_length: delivered.length,
|
||||
delivery_action: constrained.action
|
||||
};
|
||||
}
|
||||
|
||||
@ -82,10 +85,4 @@ function hasPermission(ctx, names) {
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(value, max) {
|
||||
if (value.length <= max) return value;
|
||||
const suffix = " [reply truncated]";
|
||||
return `${value.slice(0, Math.max(1, max - suffix.length)).trimEnd()}${suffix}`;
|
||||
}
|
||||
|
||||
module.exports = { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails, inferRole };
|
||||
|
||||
@ -2,6 +2,7 @@ const fs = require("fs");
|
||||
const { resolveData, ensureDataDirs } = require("./paths");
|
||||
const { DEFAULT_SCOPE, normalizeScope } = require("./scope_manager");
|
||||
const { DEFAULT_RATE_LIMITS, mergeLimits } = require("./rate_limits");
|
||||
const { SOURCE_DEFAULTS } = require("./controller");
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: false,
|
||||
@ -23,6 +24,13 @@ const DEFAULT_CONFIG = {
|
||||
admin_debug: 1280,
|
||||
explicit_long: 2048
|
||||
},
|
||||
internal_mode_budgets: {
|
||||
fast: 1024,
|
||||
normal: 4096,
|
||||
expanded: 8192,
|
||||
unlimited: 16384
|
||||
},
|
||||
source_profiles: SOURCE_DEFAULTS,
|
||||
batch_size: 512,
|
||||
ubatch_size: 128,
|
||||
per_user_requests_per_minute: 6,
|
||||
@ -30,6 +38,12 @@ const DEFAULT_CONFIG = {
|
||||
assistant_enabled: true,
|
||||
assistant_debug_logging: false,
|
||||
assistant_visibility: { admins: true, mods: false, users: false },
|
||||
work_history_retention: {
|
||||
mode: "count",
|
||||
count: 500,
|
||||
age_value: 30,
|
||||
age_unit: "days"
|
||||
},
|
||||
improvement: {
|
||||
allow_moderators_to_review_responses: false,
|
||||
trusted_moderator_reviewers: [],
|
||||
@ -101,8 +115,11 @@ function getConfig() {
|
||||
max_answer_length: config.instructions?.maximum_answer_length
|
||||
});
|
||||
config.assistant_visibility = { ...DEFAULT_CONFIG.assistant_visibility, ...(config.assistant_visibility || {}) };
|
||||
config.work_history_retention = mergeWorkHistoryRetention(config.work_history_retention);
|
||||
config.improvement = mergeImprovement(config.improvement);
|
||||
config.output_budgets = mergeOutputBudgets(config.output_budgets);
|
||||
config.internal_mode_budgets = mergeInternalModeBudgets(config.internal_mode_budgets);
|
||||
config.source_profiles = mergeSourceProfiles(config.source_profiles);
|
||||
config.gate = mergeGate(config.gate);
|
||||
config.instructions = { ...DEFAULT_CONFIG.instructions, ...(config.instructions || {}) };
|
||||
config.logging = { ...DEFAULT_CONFIG.logging, ...(config.logging || {}) };
|
||||
@ -140,6 +157,8 @@ function saveConfig(value) {
|
||||
DEFAULT_CONFIG.max_output_tokens
|
||||
);
|
||||
merged.output_budgets = mergeOutputBudgets(value.output_budgets);
|
||||
merged.internal_mode_budgets = mergeInternalModeBudgets(value.internal_mode_budgets);
|
||||
merged.source_profiles = mergeSourceProfiles(value.source_profiles);
|
||||
merged.batch_size = boundedNumber(value.batch_size, 32, 4096, DEFAULT_CONFIG.batch_size);
|
||||
merged.ubatch_size = Math.min(
|
||||
merged.batch_size,
|
||||
@ -147,6 +166,7 @@ function saveConfig(value) {
|
||||
);
|
||||
delete merged.gpu_workload_percent;
|
||||
merged.assistant_visibility = { ...DEFAULT_CONFIG.assistant_visibility, ...(value.assistant_visibility || {}) };
|
||||
merged.work_history_retention = mergeWorkHistoryRetention(value.work_history_retention);
|
||||
merged.improvement = mergeImprovement(value.improvement);
|
||||
merged.gate = mergeGate(value.gate);
|
||||
merged.support_scope = normalizeScope(value.support_scope);
|
||||
@ -210,6 +230,45 @@ function mergeOutputBudgets(value = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function mergeWorkHistoryRetention(value = {}) {
|
||||
const mode = value.mode === "age" ? "age" : "count";
|
||||
return {
|
||||
mode,
|
||||
count: boundedNumber(value.count, 50, 10000, DEFAULT_CONFIG.work_history_retention.count),
|
||||
age_value: boundedNumber(value.age_value, 1, 1000, DEFAULT_CONFIG.work_history_retention.age_value),
|
||||
age_unit: ["hours", "days", "weeks", "months", "years"].includes(value.age_unit)
|
||||
? value.age_unit
|
||||
: DEFAULT_CONFIG.work_history_retention.age_unit
|
||||
};
|
||||
}
|
||||
|
||||
function mergeInternalModeBudgets(value = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(DEFAULT_CONFIG.internal_mode_budgets).map(([key, fallback]) => [
|
||||
key,
|
||||
boundedNumber(value?.[key], 64, 32768, fallback)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSourceProfiles(value = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(SOURCE_DEFAULTS).map(([source, fallback]) => {
|
||||
const incoming = value?.[source] || {};
|
||||
return [source, {
|
||||
source,
|
||||
hard_chars: incoming.hard_chars === null || incoming.hard_chars === undefined
|
||||
? fallback.hard_chars
|
||||
: boundedNumber(incoming.hard_chars, 100, 12000, fallback.hard_chars || 1000),
|
||||
target_chars: boundedNumber(incoming.target_chars, 100, 12000, fallback.target_chars),
|
||||
allow_sections: incoming.allow_sections !== undefined ? incoming.allow_sections === true : fallback.allow_sections,
|
||||
allow_long_answer: incoming.allow_long_answer !== undefined ? incoming.allow_long_answer === true : fallback.allow_long_answer,
|
||||
allow_split: incoming.allow_split !== undefined ? incoming.allow_split === true : fallback.allow_split
|
||||
}];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mergeImprovement(value = {}) {
|
||||
return {
|
||||
...DEFAULT_CONFIG.improvement,
|
||||
|
||||
288
plugins/lumi_ai/backend/controller.js
Normal file
288
plugins/lumi_ai/backend/controller.js
Normal file
@ -0,0 +1,288 @@
|
||||
const INTENTS = new Set([
|
||||
"casual",
|
||||
"factual",
|
||||
"support",
|
||||
"explain_internal",
|
||||
"admin_task",
|
||||
"codex_task",
|
||||
"feedback_export",
|
||||
"debug",
|
||||
"unknown"
|
||||
]);
|
||||
const COMPLEXITIES = new Set(["fast", "normal", "expanded", "unlimited"]);
|
||||
const OKF_DEPTHS = new Set(["none", "light", "deep"]);
|
||||
const ANSWER_STYLES = new Set(["compact", "normal", "detailed", "json_only"]);
|
||||
const SOURCE_DEFAULTS = Object.freeze({
|
||||
webui: { hard_chars: null, target_chars: 2500, allow_sections: true, allow_long_answer: true, allow_split: false },
|
||||
discord: { hard_chars: 1900, target_chars: 1200, allow_sections: true, allow_long_answer: false, allow_split: false },
|
||||
twitch: { hard_chars: 450, target_chars: 320, allow_sections: false, allow_long_answer: false, allow_split: false },
|
||||
youtube: { hard_chars: 1800, target_chars: 900, allow_sections: false, allow_long_answer: false, allow_split: false },
|
||||
kick: { hard_chars: 450, target_chars: 320, allow_sections: false, allow_long_answer: false, allow_split: false },
|
||||
other: { hard_chars: 1000, target_chars: 700, allow_sections: false, allow_long_answer: false, allow_split: false }
|
||||
});
|
||||
const MODE_BUDGETS = Object.freeze({
|
||||
fast: 1024,
|
||||
normal: 4096,
|
||||
expanded: 8192,
|
||||
unlimited: 16384
|
||||
});
|
||||
|
||||
function buildControllerDecision({
|
||||
message = "",
|
||||
role = "user",
|
||||
scope = "assistant",
|
||||
originContext = null,
|
||||
gateDecision = null,
|
||||
requestClass = "simple_answer",
|
||||
config = {}
|
||||
} = {}) {
|
||||
const text = String(gateDecision?.message || message || "");
|
||||
const source = sourceKey(originContext);
|
||||
const signals = detectSignals(text, role, scope);
|
||||
const lowConfidenceGate = gateDecision && (
|
||||
gateDecision.confidence < 0.5 ||
|
||||
/(?:low_confidence|timeout|error|invalid|unsafe|escalated)/i.test(gateDecision.reason_code || "")
|
||||
);
|
||||
let intent = intentFromSignals(signals, requestClass);
|
||||
let complexity = complexityFromSignals(signals, requestClass, role);
|
||||
let okfRetrieval = okfDepthFromSignals(signals, complexity);
|
||||
let answerStyle = answerStyleFromSignals(signals, complexity, source);
|
||||
|
||||
if (lowConfidenceGate && complexity === "fast") {
|
||||
complexity = "normal";
|
||||
okfRetrieval = okfRetrieval === "none" && signals.needsOkf ? "light" : okfRetrieval;
|
||||
}
|
||||
if (gateDecision?.forced && complexity === "fast") {
|
||||
complexity = "normal";
|
||||
}
|
||||
if (signals.adminOnly && role !== "admin" && complexity === "unlimited") {
|
||||
complexity = "expanded";
|
||||
}
|
||||
|
||||
const decision = normalizeControllerDecision({
|
||||
schema: "lumi.ai.controller.v1",
|
||||
route: gateDecision?.route || "main_llm",
|
||||
intent,
|
||||
complexity,
|
||||
okf_retrieval: okfRetrieval,
|
||||
answer_style: answerStyle,
|
||||
source_scoped: true,
|
||||
source_profile: buildSourceProfile(source, config),
|
||||
permission_sensitive: signals.permissionSensitive,
|
||||
admin_only: signals.adminOnly,
|
||||
risk_of_private_data: signals.privateDataRisk,
|
||||
confidence: gateDecision ? gateDecision.confidence : confidenceFromSignals(signals),
|
||||
reason_code: controllerReason(signals, gateDecision, lowConfidenceGate),
|
||||
legacy_request_class: requestClass,
|
||||
gate_reason_code: gateDecision?.reason_code || null,
|
||||
fallback_used: Boolean(lowConfidenceGate)
|
||||
});
|
||||
return decision;
|
||||
}
|
||||
|
||||
function normalizeControllerDecision(value = {}) {
|
||||
const sourceProfile = value.source_profile && typeof value.source_profile === "object"
|
||||
? value.source_profile
|
||||
: SOURCE_DEFAULTS.other;
|
||||
const requestedSource = String(sourceProfile.source || "other").toLowerCase();
|
||||
const source = SOURCE_DEFAULTS[requestedSource] ? requestedSource : "other";
|
||||
const fallbackProfile = SOURCE_DEFAULTS[source];
|
||||
return {
|
||||
schema: "lumi.ai.controller.v1",
|
||||
route: ["main_llm", "refusal", "unavailable", "cached_answer", "predefined_answer"].includes(value.route)
|
||||
? value.route
|
||||
: "main_llm",
|
||||
intent: INTENTS.has(value.intent) ? value.intent : "unknown",
|
||||
complexity: COMPLEXITIES.has(value.complexity) ? value.complexity : "normal",
|
||||
okf_retrieval: OKF_DEPTHS.has(value.okf_retrieval) ? value.okf_retrieval : "light",
|
||||
answer_style: ANSWER_STYLES.has(value.answer_style) ? value.answer_style : "normal",
|
||||
source_scoped: value.source_scoped !== false,
|
||||
source_profile: {
|
||||
source,
|
||||
hard_chars: sourceProfile.hard_chars === null || sourceProfile.hard_chars === undefined
|
||||
? fallbackProfile.hard_chars
|
||||
: clampChars(sourceProfile.hard_chars, fallbackProfile.hard_chars || 1000),
|
||||
target_chars: clampChars(sourceProfile.target_chars, fallbackProfile.target_chars),
|
||||
allow_sections: Boolean(sourceProfile.allow_sections),
|
||||
allow_long_answer: Boolean(sourceProfile.allow_long_answer),
|
||||
allow_split: Boolean(sourceProfile.allow_split)
|
||||
},
|
||||
permission_sensitive: Boolean(value.permission_sensitive),
|
||||
admin_only: Boolean(value.admin_only),
|
||||
risk_of_private_data: Boolean(value.risk_of_private_data),
|
||||
confidence: Math.max(0, Math.min(1, Number(value.confidence) || 0)),
|
||||
reason_code: /^[a-z0-9_]{2,80}$/.test(String(value.reason_code || ""))
|
||||
? value.reason_code
|
||||
: "controller_normalized",
|
||||
legacy_request_class: String(value.legacy_request_class || "simple_answer"),
|
||||
gate_reason_code: value.gate_reason_code ? String(value.gate_reason_code).slice(0, 100) : null,
|
||||
fallback_used: Boolean(value.fallback_used)
|
||||
};
|
||||
}
|
||||
|
||||
function outputBudgetForController({ config = {}, requestClass = "simple_answer", explicitMaxTokens = null, controllerDecision = null } = {}) {
|
||||
const requested = Number(explicitMaxTokens);
|
||||
if (Number.isFinite(requested) && requested > 0) return clampBudget(requested);
|
||||
const mode = controllerDecision?.complexity || modeFromRequestClass(requestClass);
|
||||
const modeBudget = Number(MODE_BUDGETS[mode]);
|
||||
if (Number.isFinite(modeBudget) && modeBudget > 0) return clampBudget(modeBudget);
|
||||
const classBudget = Number(config.output_budgets?.[requestClass]);
|
||||
if (Number.isFinite(classBudget) && classBudget > 0) return clampBudget(classBudget);
|
||||
const fallback = Number(config.max_output_tokens);
|
||||
return clampBudget(Number.isFinite(fallback) && fallback > 0 ? fallback : 512);
|
||||
}
|
||||
|
||||
function okfLimitForController(decision) {
|
||||
if (!decision || decision.okf_retrieval === "none") return 0;
|
||||
if (decision.okf_retrieval === "deep") return decision.complexity === "expanded" || decision.complexity === "unlimited" ? 5 : 4;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function buildSourceProfile(source, config = {}) {
|
||||
const key = SOURCE_DEFAULTS[source] ? source : "other";
|
||||
const configured = config.source_profiles?.[key] || {};
|
||||
const merged = { ...SOURCE_DEFAULTS[key], ...configured, source: key };
|
||||
return {
|
||||
source: key,
|
||||
hard_chars: merged.hard_chars === null || merged.hard_chars === undefined
|
||||
? null
|
||||
: Math.max(100, Math.min(12000, Number(merged.hard_chars) || SOURCE_DEFAULTS[key].hard_chars || 1000)),
|
||||
target_chars: Math.max(100, Math.min(12000, Number(merged.target_chars) || SOURCE_DEFAULTS[key].target_chars)),
|
||||
allow_sections: merged.allow_sections !== false,
|
||||
allow_long_answer: merged.allow_long_answer === true,
|
||||
allow_split: merged.allow_split === true
|
||||
};
|
||||
}
|
||||
|
||||
function detectSignals(message, role, scope) {
|
||||
const text = String(message || "");
|
||||
const routeOrApi = /\b(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+\/[^\s]+/i.test(text) ||
|
||||
/\b(?:route|webroute|web route|endpoint|request|api)\b[\s\S]{0,80}\/[a-z0-9_/-]+/i.test(text) ||
|
||||
/\/(?:admin|api|setup|auth|plugins|commands|feedback|stats|pages)(?:\/[a-z0-9_/-]*)?\b/i.test(text);
|
||||
const lumiInternal = /\b(lumi|webui|route|endpoint|api|plugin|okf|settings?|setup|wizard|admin page|moderator|permission|config|configuration|update|dashboard|navigation)\b/i.test(text) || routeOrApi;
|
||||
const codex = /\b(codex|taskfile|task file|implementation plan|acceptance criteria|validation steps|json output)\b/i.test(text);
|
||||
const feedbackExport = /\b(feedback.*export|export.*feedback|feedback-to-codex|internal-feedback-to-codex-taskfile)\b/i.test(text);
|
||||
const debug = /\b(debug|diagnos|troubleshoot|stack trace|runtime|backend|database|logs?|metrics?|failed|failure|error|exception)\b/i.test(text);
|
||||
const security = /\b(security|permission|private|secret|token|password|credential|audit|rbac|role|admin-only|sensitive)\b/i.test(text);
|
||||
const unlimited = role === "admin" && /\b(large-scale|architecture analysis|security audit|incident investigation|major migration|major refactor|comprehensive report|extensive synthesis|full system review)\b/i.test(text);
|
||||
const support = /\b(how do i|how to|what does|tell me about|explain|configure|set up|setup|support|help with|why does)\b/i.test(text);
|
||||
const knowledgeLookup = isSimpleKnowledgeLookup(text);
|
||||
const casual = text.split(/\s+/).length <= 18 && !lumiInternal && !debug && !codex && !security && !knowledgeLookup;
|
||||
return {
|
||||
routeOrApi,
|
||||
lumiInternal,
|
||||
knowledgeLookup,
|
||||
needsOkf: lumiInternal || support || debug || codex || feedbackExport || knowledgeLookup,
|
||||
codex,
|
||||
feedbackExport,
|
||||
debug,
|
||||
security,
|
||||
unlimited,
|
||||
support,
|
||||
casual,
|
||||
permissionSensitive: security || /\b(admin|moderator|mod|permission|role|private)\b/i.test(text),
|
||||
adminOnly: role === "admin" && (debug || security || unlimited || scope === "model_test"),
|
||||
privateDataRisk: security || /\b(my|mine|our|token|secret|password|credential|user id|database)\b/i.test(text)
|
||||
};
|
||||
}
|
||||
|
||||
function intentFromSignals(signals, requestClass) {
|
||||
if (signals.feedbackExport) return "feedback_export";
|
||||
if (signals.codex) return "codex_task";
|
||||
if (signals.debug) return "debug";
|
||||
if (signals.security || signals.adminOnly) return "admin_task";
|
||||
if (signals.lumiInternal) return "explain_internal";
|
||||
if (signals.knowledgeLookup) return "factual";
|
||||
if (signals.support) return "support";
|
||||
if (requestClass === "navigation_help") return "support";
|
||||
if (signals.casual) return "casual";
|
||||
return "factual";
|
||||
}
|
||||
|
||||
function complexityFromSignals(signals, requestClass, role) {
|
||||
if (signals.unlimited) return "unlimited";
|
||||
if (signals.codex || signals.feedbackExport || signals.debug || signals.security || requestClass === "admin_debug" || requestClass === "explicit_long") {
|
||||
return "expanded";
|
||||
}
|
||||
if (signals.knowledgeLookup) return "fast";
|
||||
if (signals.lumiInternal || signals.support || requestClass === "code_custom_command") return "normal";
|
||||
if (signals.casual && role !== "admin") return "fast";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function okfDepthFromSignals(signals, complexity) {
|
||||
if (!signals.needsOkf) return "none";
|
||||
if (complexity === "expanded" || complexity === "unlimited" || signals.routeOrApi || signals.debug) return "deep";
|
||||
return "light";
|
||||
}
|
||||
|
||||
function answerStyleFromSignals(signals, complexity, source) {
|
||||
if (signals.codex || signals.feedbackExport) return "json_only";
|
||||
if (complexity === "expanded" || complexity === "unlimited") return "detailed";
|
||||
if (source === "twitch" || source === "kick") return "compact";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function confidenceFromSignals(signals) {
|
||||
if (signals.routeOrApi || signals.codex || signals.feedbackExport || signals.debug) return 0.9;
|
||||
if (signals.knowledgeLookup) return 0.82;
|
||||
if (signals.lumiInternal || signals.support) return 0.8;
|
||||
if (signals.casual) return 0.75;
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
function controllerReason(signals, gateDecision, lowConfidenceGate) {
|
||||
if (lowConfidenceGate) return "gate_fallback_normal";
|
||||
if (gateDecision?.forced) return "forced_main_llm";
|
||||
if (signals.unlimited) return "admin_unlimited_request";
|
||||
if (signals.codex) return "codex_expanded";
|
||||
if (signals.feedbackExport) return "feedback_export_expanded";
|
||||
if (signals.debug) return "debug_expanded";
|
||||
if (signals.routeOrApi) return "route_docs_okf";
|
||||
if (signals.lumiInternal) return "lumi_internal_okf";
|
||||
if (signals.knowledgeLookup) return "simple_knowledge_okf";
|
||||
if (signals.support) return "support_okf";
|
||||
if (signals.casual) return "casual_fast";
|
||||
return "default_normal";
|
||||
}
|
||||
|
||||
function modeFromRequestClass(requestClass) {
|
||||
if (requestClass === "navigation_help" || requestClass === "simple_answer") return "fast";
|
||||
if (requestClass === "admin_debug" || requestClass === "explicit_long") return "expanded";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function sourceKey(originContext) {
|
||||
const source = String(originContext?.origin || originContext?.platform || "webui").toLowerCase();
|
||||
return SOURCE_DEFAULTS[source] ? source : "other";
|
||||
}
|
||||
|
||||
function clampBudget(value) {
|
||||
return Math.max(64, Math.min(32768, Math.round(Number(value) || 512)));
|
||||
}
|
||||
|
||||
function clampChars(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Math.max(100, Math.min(12000, Math.round(Number.isFinite(number) ? number : fallback)));
|
||||
}
|
||||
|
||||
function isSimpleKnowledgeLookup(message) {
|
||||
const text = String(message || "").trim();
|
||||
if (!text || text.length > 180 || text.split(/\s+/).length > 18) return false;
|
||||
if (/\b(who|what)\s+(?:are|r)\s+you\b|\byour\s+(?:name|identity)\b/i.test(text)) return false;
|
||||
return (
|
||||
/^(?:who|what)\s+(?:is|are|was|were)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text) ||
|
||||
/^(?:tell me about|describe|identify)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MODE_BUDGETS,
|
||||
SOURCE_DEFAULTS,
|
||||
buildControllerDecision,
|
||||
buildSourceProfile,
|
||||
isSimpleKnowledgeLookup,
|
||||
normalizeControllerDecision,
|
||||
okfLimitForController,
|
||||
outputBudgetForController
|
||||
};
|
||||
@ -6,54 +6,318 @@ const AdmZip = require("adm-zip");
|
||||
const { resolveData } = require("./paths");
|
||||
|
||||
class DownloadManager {
|
||||
constructor(onEvent){ this.jobs=new Map(); this.onEvent=onEvent; }
|
||||
status(id){ return this.jobs.get(id)||null; }
|
||||
start({id,url,filename,sha256,kind,archive=false,size=0,runtimeMetadata=null}){
|
||||
if(this.jobs.get(id)?.state==="downloading") throw new Error("Download already running.");
|
||||
if(size&&freeDiskBytes()<size*1.2)throw new Error("not enough disk space");
|
||||
const job={id,state:"queued",downloaded:0,total:0,error:null,started_at:Date.now()};this.jobs.set(id,job);
|
||||
this.download({job,url,filename,sha256,kind,archive,runtimeMetadata}).catch(error=>{const classified=classifyError(error);job.state="error";job.error=classified.message;job.error_category=classified.category;this.onEvent?.({kind:"download",status:"failed",download_id:id,error:job.error,category:classified.category});});
|
||||
constructor(onEvent) {
|
||||
this.jobs = new Map();
|
||||
this.onEvent = onEvent;
|
||||
}
|
||||
|
||||
status(id) {
|
||||
return this.jobs.get(id) || null;
|
||||
}
|
||||
|
||||
start({
|
||||
id,
|
||||
url,
|
||||
filename,
|
||||
sha256,
|
||||
kind,
|
||||
archive = false,
|
||||
size = 0,
|
||||
runtimeMetadata = null,
|
||||
dependencies = [],
|
||||
beforeInstall = null,
|
||||
afterInstall = null
|
||||
}) {
|
||||
if (this.jobs.get(id) && !["complete", "error"].includes(this.jobs.get(id).state)) {
|
||||
throw new Error("Download already running.");
|
||||
}
|
||||
|
||||
const dependencyList = normalizeDependencies(dependencies);
|
||||
const requiredSize = Number(size || 0) + dependencyList.reduce((sum, item) => sum + Number(item.size || 0), 0);
|
||||
|
||||
if (requiredSize && freeDiskBytes() < requiredSize * 1.2) {
|
||||
throw new Error("not enough disk space");
|
||||
}
|
||||
|
||||
const job = {
|
||||
id,
|
||||
state: "queued",
|
||||
downloaded: 0,
|
||||
total: requiredSize || 0,
|
||||
error: null,
|
||||
started_at: Date.now(),
|
||||
current_file: null,
|
||||
files: [
|
||||
{ filename, size: Number(size || 0), role: "primary" },
|
||||
...dependencyList.map((dependency) => ({
|
||||
filename: dependency.filename,
|
||||
size: Number(dependency.size || 0),
|
||||
role: "dependency"
|
||||
}))
|
||||
]
|
||||
};
|
||||
|
||||
this.jobs.set(id, job);
|
||||
|
||||
this.download({
|
||||
job,
|
||||
url,
|
||||
filename,
|
||||
sha256,
|
||||
kind,
|
||||
archive,
|
||||
size,
|
||||
runtimeMetadata,
|
||||
dependencies: dependencyList,
|
||||
beforeInstall,
|
||||
afterInstall
|
||||
}).catch((error) => {
|
||||
const classified = classifyError(error);
|
||||
job.state = "error";
|
||||
job.error = classified.message;
|
||||
job.error_category = classified.category;
|
||||
this.onEvent?.({
|
||||
kind: "download",
|
||||
status: "failed",
|
||||
download_id: id,
|
||||
error: job.error,
|
||||
category: classified.category
|
||||
});
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
async download({job,url,filename,sha256,kind,archive,runtimeMetadata}){
|
||||
job.state="downloading";
|
||||
const tmp=resolveData("tmp",`${filename}.part`), finalDir=resolveData(kind==="model"?"models":"runtime");
|
||||
const existing=fs.existsSync(tmp)?fs.statSync(tmp).size:0;
|
||||
const headers=existing?{Range:`bytes=${existing}-`}:{};
|
||||
const response=await fetch(url,{headers}); if(!response.ok && response.status!==206) throw new Error(`source unavailable (${response.status})`);
|
||||
const resumed=existing>0&&response.status===206;
|
||||
const total=Number(response.headers.get("content-length")||0)+(resumed?existing:0); job.total=total; job.downloaded=resumed?existing:0;
|
||||
const stream=fs.createWriteStream(tmp,{flags:resumed?"a":"w"});
|
||||
for await(const chunk of response.body){ if(!stream.write(chunk)) await new Promise(r=>stream.once("drain",r)); job.downloaded+=chunk.length; }
|
||||
await new Promise((resolve,reject)=>stream.end(error=>error?reject(error):resolve()));
|
||||
job.state="verifying"; const actual=await hashFile(tmp); if(actual!==sha256.toLowerCase()){fs.unlinkSync(tmp);throw new Error("hash mismatch");}
|
||||
if(archive){
|
||||
job.state="extracting";
|
||||
const staging=resolveData("tmp",`runtime-extract-${Date.now()}`);
|
||||
fs.mkdirSync(staging,{recursive:true});
|
||||
try{
|
||||
await extractArchive(tmp,staging,filename);
|
||||
|
||||
async download({
|
||||
job,
|
||||
url,
|
||||
filename,
|
||||
sha256,
|
||||
kind,
|
||||
archive,
|
||||
size = 0,
|
||||
runtimeMetadata,
|
||||
dependencies = [],
|
||||
beforeInstall = null,
|
||||
afterInstall = null
|
||||
}) {
|
||||
job.state = "downloading";
|
||||
|
||||
const finalDir = resolveData(kind === "model" ? "models" : "runtime");
|
||||
const primaryTmp = await this.downloadOne({ job, url, filename, sha256, expectedSize: size });
|
||||
|
||||
if (archive) {
|
||||
job.state = "extracting";
|
||||
const staging = resolveData("tmp", `runtime-extract-${Date.now()}`);
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
|
||||
try {
|
||||
await extractArchive(primaryTmp, staging, filename);
|
||||
|
||||
for (const dependency of dependencies) {
|
||||
job.state = "downloading_dependency";
|
||||
const dependencyTmp = await this.downloadOne({
|
||||
job,
|
||||
url: dependency.url,
|
||||
filename: dependency.filename,
|
||||
sha256: dependency.sha256,
|
||||
expectedSize: dependency.size
|
||||
});
|
||||
|
||||
job.state = "extracting_dependency";
|
||||
await extractArchive(dependencyTmp, staging, dependency.filename);
|
||||
fs.unlinkSync(dependencyTmp);
|
||||
}
|
||||
|
||||
await makeRuntimeExecutable(staging);
|
||||
const executable=findRuntimeExecutable(staging);
|
||||
if(!executable)throw new Error("runtime executable missing after extraction");
|
||||
for(const entry of fs.readdirSync(finalDir))fs.rmSync(path.join(finalDir,entry),{recursive:true,force:true});
|
||||
for(const entry of fs.readdirSync(staging))fs.renameSync(path.join(staging,entry),path.join(finalDir,entry));
|
||||
fs.writeFileSync(path.join(finalDir,"lumi-runtime.json"),`${JSON.stringify({
|
||||
backend: runtimeMetadata?.backend || "cpu",
|
||||
version: runtimeMetadata?.version || null,
|
||||
target: runtimeMetadata?.target || filename,
|
||||
installed_at: new Date().toISOString()
|
||||
},null,2)}\n`);
|
||||
fs.unlinkSync(tmp);
|
||||
job.executable=findRuntimeExecutable(finalDir);
|
||||
}finally{
|
||||
fs.rmSync(staging,{recursive:true,force:true});
|
||||
const executable = findRuntimeExecutable(staging);
|
||||
if (!executable) throw new Error("runtime executable missing after extraction");
|
||||
|
||||
let installCallbackStarted = false;
|
||||
try {
|
||||
if (typeof beforeInstall === "function") {
|
||||
installCallbackStarted = true;
|
||||
job.state = "preparing_install";
|
||||
await beforeInstall({ job, finalDir, staging, runtimeMetadata });
|
||||
}
|
||||
|
||||
job.state = "installing";
|
||||
await replaceDirectoryContents(staging, finalDir);
|
||||
|
||||
fs.writeFileSync(path.join(finalDir, "lumi-runtime.json"), `${JSON.stringify({
|
||||
backend: runtimeMetadata?.backend || "cpu",
|
||||
backend_variant: runtimeMetadata?.backend_variant || null,
|
||||
version: runtimeMetadata?.version || null,
|
||||
target: runtimeMetadata?.target || filename,
|
||||
dependencies: runtimeMetadata?.dependencies || dependencies.map((dependency) => ({
|
||||
filename: dependency.filename,
|
||||
sha256: dependency.sha256,
|
||||
size: dependency.size || 0
|
||||
})),
|
||||
installed_at: new Date().toISOString()
|
||||
}, null, 2)}\n`);
|
||||
} finally {
|
||||
if (installCallbackStarted && typeof afterInstall === "function") {
|
||||
await afterInstall({ job, finalDir, staging, runtimeMetadata });
|
||||
}
|
||||
}
|
||||
|
||||
fs.unlinkSync(primaryTmp);
|
||||
job.executable = findRuntimeExecutable(finalDir);
|
||||
} finally {
|
||||
fs.rmSync(primaryTmp, { force: true });
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
} else {
|
||||
const final = path.join(finalDir, filename);
|
||||
if (fs.existsSync(final)) fs.unlinkSync(final);
|
||||
fs.renameSync(primaryTmp, final);
|
||||
}
|
||||
else { const final=path.join(finalDir,filename); if(fs.existsSync(final))fs.unlinkSync(final); fs.renameSync(tmp,final); }
|
||||
job.state="complete";job.finished_at=Date.now();job.sha256=actual;this.onEvent?.({kind:"download",status:"success",download_id:job.id,sha256:actual,duration_ms:job.finished_at-job.started_at});
|
||||
|
||||
job.state = "complete";
|
||||
job.finished_at = Date.now();
|
||||
job.sha256 = sha256 ? String(sha256).toLowerCase() : null;
|
||||
this.onEvent?.({
|
||||
kind: "download",
|
||||
status: "success",
|
||||
download_id: job.id,
|
||||
sha256: job.sha256,
|
||||
duration_ms: job.finished_at - job.started_at
|
||||
});
|
||||
}
|
||||
|
||||
async downloadOne({ job, url, filename, sha256, expectedSize = 0 }) {
|
||||
const tmp = resolveData("tmp", `${filename}.part`);
|
||||
let existing = fs.existsSync(tmp) ? fs.statSync(tmp).size : 0;
|
||||
const normalizedExpected = Number(expectedSize || 0);
|
||||
if (existing && normalizedExpected && existing > normalizedExpected) {
|
||||
fs.unlinkSync(tmp);
|
||||
existing = 0;
|
||||
}
|
||||
if (existing && normalizedExpected && existing === normalizedExpected) {
|
||||
job.current_file = filename;
|
||||
job.state = "verifying";
|
||||
const actual = await hashFile(tmp);
|
||||
if (actual === String(sha256 || "").toLowerCase()) {
|
||||
job.downloaded += existing;
|
||||
return tmp;
|
||||
}
|
||||
fs.unlinkSync(tmp);
|
||||
existing = 0;
|
||||
}
|
||||
const headers = existing ? { Range: `bytes=${existing}-` } : {};
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (response.status === 416 && existing) {
|
||||
job.state = "verifying";
|
||||
const actual = await hashFile(tmp);
|
||||
if (actual === String(sha256 || "").toLowerCase()) {
|
||||
job.downloaded += existing;
|
||||
return tmp;
|
||||
}
|
||||
fs.unlinkSync(tmp);
|
||||
throw new Error("hash mismatch");
|
||||
}
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
throw new Error(`source unavailable (${response.status})`);
|
||||
}
|
||||
|
||||
const resumed = existing > 0 && response.status === 206;
|
||||
const contentLength = Number(response.headers.get("content-length") || 0);
|
||||
const fileTotal = contentLength + (resumed ? existing : 0);
|
||||
|
||||
if (!job.total && fileTotal) job.total = fileTotal;
|
||||
job.current_file = filename;
|
||||
|
||||
const stream = fs.createWriteStream(tmp, { flags: resumed ? "a" : "w" });
|
||||
if (resumed) job.downloaded += existing;
|
||||
|
||||
for await (const chunk of response.body) {
|
||||
if (!stream.write(chunk)) await new Promise((resolve) => stream.once("drain", resolve));
|
||||
job.downloaded += chunk.length;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
|
||||
|
||||
job.state = "verifying";
|
||||
const actual = await hashFile(tmp);
|
||||
if (actual !== String(sha256 || "").toLowerCase()) {
|
||||
fs.unlinkSync(tmp);
|
||||
throw new Error("hash mismatch");
|
||||
}
|
||||
|
||||
return tmp;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDependencies(dependencies) {
|
||||
if (!Array.isArray(dependencies)) return [];
|
||||
return dependencies.filter(Boolean).map((dependency) => {
|
||||
if (!dependency.url || !dependency.filename || !dependency.sha256) {
|
||||
throw new Error(`Runtime dependency is incomplete: ${dependency.filename || "unknown"}`);
|
||||
}
|
||||
return {
|
||||
...dependency,
|
||||
size: Number(dependency.size || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceDirectoryContents(staging, finalDir) {
|
||||
fs.mkdirSync(finalDir, { recursive: true });
|
||||
const backup = resolveData("tmp", `runtime-replace-backup-${Date.now()}`);
|
||||
fs.mkdirSync(backup, { recursive: true });
|
||||
const movedOld = [];
|
||||
const installedNew = [];
|
||||
|
||||
try {
|
||||
for (const entry of fs.readdirSync(finalDir)) {
|
||||
const from = path.join(finalDir, entry);
|
||||
const to = path.join(backup, entry);
|
||||
await retryFileOperation(() => fs.renameSync(from, to));
|
||||
movedOld.push({ from, to });
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(staging)) {
|
||||
const from = path.join(staging, entry);
|
||||
const to = path.join(finalDir, entry);
|
||||
await retryFileOperation(() => fs.renameSync(from, to));
|
||||
installedNew.push(to);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const target of installedNew.reverse()) {
|
||||
try { fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {}
|
||||
}
|
||||
for (const entry of movedOld.reverse()) {
|
||||
try {
|
||||
if (!fs.existsSync(entry.from) && fs.existsSync(entry.to)) fs.renameSync(entry.to, entry.from);
|
||||
} catch {}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
try { fs.rmSync(backup, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function retryFileOperation(operation, attempts = 8, delayMs = 250) {
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isRetriableFileError(error) || attempt === attempts - 1) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function isRetriableFileError(error) {
|
||||
return ["EBUSY", "EPERM", "EACCES", "ENOTEMPTY"].includes(error?.code);
|
||||
}
|
||||
|
||||
async function makeRuntimeExecutable(dir){
|
||||
if(process.platform==="win32")return;
|
||||
for(const entry of fs.readdirSync(dir,{withFileTypes:true})){
|
||||
@ -90,9 +354,9 @@ function validateArchivePath(entry){
|
||||
}
|
||||
function capture(command,args){return new Promise((resolve,reject)=>{const child=spawn(command,args,{windowsHide:true,shell:false});let stdout="",stderr="";child.stdout.on("data",c=>stdout+=c);child.stderr.on("data",c=>stderr+=c);child.on("error",reject);child.on("exit",code=>resolve({code,stdout,stderr}));});}
|
||||
function classifyError(error){
|
||||
const message=error?.message||String(error);
|
||||
const message=`${error?.code || ""} ${error?.message || String(error)}`.trim();
|
||||
if(/ENOSPC|not enough disk/i.test(message))return{category:"disk_full",message:"Not enough disk space."};
|
||||
if(/EACCES|EPERM|permission denied/i.test(message))return{category:"permission_denied",message:"Permission denied."};
|
||||
if(/EACCES|EPERM|EBUSY|ENOTEMPTY|resource busy|permission denied/i.test(message))return{category:"permission_denied",message:"Runtime files are locked or permission was denied. Stop Lumi AI runtimes and retry."};
|
||||
if(/hash mismatch/i.test(message))return{category:"hash_mismatch",message:"Downloaded file failed SHA-256 verification."};
|
||||
if(/archive path traversal/i.test(message))return{category:"archive_path_traversal",message:"Archive contains an unsafe path."};
|
||||
if(/archive corrupt|extraction failed/i.test(message))return{category:"archive_corrupt",message};
|
||||
|
||||
@ -132,6 +132,18 @@ class GateProvider {
|
||||
}, started, context);
|
||||
}
|
||||
|
||||
if (isSimpleKnowledgeLookup(context.message)) {
|
||||
return this.finish({
|
||||
route: "main_llm",
|
||||
confidence: 0.86,
|
||||
reason_code: "simple_knowledge_lookup",
|
||||
message: context.message,
|
||||
request_class: requestClass,
|
||||
deterministic_ms: Date.now() - started,
|
||||
gate_ms: 0
|
||||
}, started, context);
|
||||
}
|
||||
|
||||
const deterministicMs = Date.now() - started;
|
||||
const gateStarted = Date.now();
|
||||
onStage("gating");
|
||||
@ -243,6 +255,7 @@ class GateProvider {
|
||||
reason_code: decision.reason_code || "unspecified",
|
||||
answer: decision.answer || null,
|
||||
message: decision.message || context.message,
|
||||
gate_error: decision.gate_error ? String(decision.gate_error).slice(0, 300) : null,
|
||||
forced: Boolean(decision.forced),
|
||||
request_class: normalizeRequestClass(decision.request_class),
|
||||
deterministic_ms: Math.max(0, Number(decision.deterministic_ms) || 0),
|
||||
@ -255,6 +268,7 @@ class GateProvider {
|
||||
route_used: output.route,
|
||||
confidence: output.confidence,
|
||||
reason_code: output.reason_code,
|
||||
gate_error: output.gate_error,
|
||||
request_class: output.request_class,
|
||||
route_class: output.request_class,
|
||||
deterministic_ms: output.deterministic_ms,
|
||||
@ -279,7 +293,8 @@ function normalizeDecision(value = {}) {
|
||||
confidence: Math.max(0, Math.min(1, Number(value.confidence) || 0)),
|
||||
reason_code: /^[a-z0-9_]{2,80}$/.test(String(value.reason_code || ""))
|
||||
? value.reason_code
|
||||
: "invalid_reason_code"
|
||||
: "invalid_reason_code",
|
||||
gate_error: value.gate_error ? String(value.gate_error).slice(0, 300) : null
|
||||
};
|
||||
}
|
||||
|
||||
@ -319,12 +334,25 @@ function isComplexOrAmbiguous(message) {
|
||||
.test(text);
|
||||
}
|
||||
|
||||
function isSimpleKnowledgeLookup(message) {
|
||||
const text = String(message || "").trim();
|
||||
if (!text || text.length > 180 || text.split(/\s+/).length > 18) return false;
|
||||
if (/\b(who|what)\s+(?:are|r)\s+you\b|\byour\s+(?:name|identity)\b/i.test(text)) return false;
|
||||
return (
|
||||
/^(?:who|what)\s+(?:is|are|was|were)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text) ||
|
||||
/^(?:tell me about|describe|identify)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isTimeoutError(error) {
|
||||
return error?.name === "TimeoutError" || error?.name === "AbortError" || /timed?\s*out|timeout/i.test(error?.message || "");
|
||||
}
|
||||
|
||||
function classifyRequestType(message, { role = "user", scope = "assistant" } = {}) {
|
||||
const text = String(message || "").trim();
|
||||
if (hasRouteReference(text)) {
|
||||
return "navigation_help";
|
||||
}
|
||||
if (/\b(explicitly|please|give|write|provide|show)\b[\s\S]{0,60}\b(long|detailed|comprehensive|thorough|in[- ]depth)\b|\b(full (analysis|report|guide|explanation)|in detail|very detailed|long answer)\b/i.test(text)) {
|
||||
return "explicit_long";
|
||||
}
|
||||
@ -343,6 +371,12 @@ function classifyRequestType(message, { role = "user", scope = "assistant" } = {
|
||||
return "simple_answer";
|
||||
}
|
||||
|
||||
function hasRouteReference(text) {
|
||||
return /\b(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+\/[^\s]+/i.test(text) ||
|
||||
/\b(?:route|webroute|web route|endpoint|request|api)\b[\s\S]{0,80}\/[a-z0-9_/-]+/i.test(text) ||
|
||||
/\/(?:admin|api|setup|auth|plugins|commands|feedback|stats|pages)(?:\/[a-z0-9_/-]*)?\b/i.test(text);
|
||||
}
|
||||
|
||||
function normalizeRequestClass(value) {
|
||||
return [
|
||||
"navigation_help",
|
||||
@ -392,7 +426,9 @@ module.exports = {
|
||||
isCacheSafeRepoAnswer,
|
||||
isExactPredefinedQuery,
|
||||
isComplexOrAmbiguous,
|
||||
isSimpleKnowledgeLookup,
|
||||
classifyRequestType,
|
||||
normalizeRequestClass,
|
||||
hasRouteReference,
|
||||
withTimeout
|
||||
};
|
||||
|
||||
@ -5,6 +5,7 @@ const { spawnSync } = require("child_process");
|
||||
const { PLUGIN_DATA, PLUGIN_ROOT } = require("./paths");
|
||||
|
||||
const SUPPORTED_BACKENDS = ["cuda", "vulkan", "metal", "sycl"];
|
||||
const BACKEND_PREFERENCE = ["cuda", "metal", "sycl", "vulkan"];
|
||||
|
||||
function detectHardware(models, runtimeManifest = null) {
|
||||
const freeDisk = getFreeDisk();
|
||||
@ -175,25 +176,58 @@ function choosePrimaryGpu(gpus) {
|
||||
function selectRuntimeTarget(runtimeManifest, gpu, platform = os.platform(), architecture = os.arch()) {
|
||||
const key = `${platform}-${architecture}`;
|
||||
const cpu = runtimeManifest?.targets?.[key] || null;
|
||||
const accelerated = runtimeManifest?.accelerated_targets?.[key] || null;
|
||||
const backend = accelerated?.backend;
|
||||
const compatible = Boolean(
|
||||
gpu?.present &&
|
||||
accelerated &&
|
||||
(backend === "metal" || gpu.compute_api?.includes(backend))
|
||||
);
|
||||
if (compatible) {
|
||||
return { backend, accelerated: true, fallback_to_cpu: false, target: accelerated };
|
||||
const acceleratedTargets = normalizeRuntimeTargets(runtimeManifest?.accelerated_targets?.[key]);
|
||||
|
||||
const candidates = acceleratedTargets
|
||||
.filter((target) => runtimeTargetCompatible(target, gpu))
|
||||
.sort((a, b) => rankBackend(a.backend) - rankBackend(b.backend));
|
||||
|
||||
const selected = candidates[0] || null;
|
||||
|
||||
if (selected) {
|
||||
return {
|
||||
backend: selected.backend,
|
||||
accelerated: true,
|
||||
fallback_to_cpu: false,
|
||||
target: selected,
|
||||
available_backends: candidates.map((target) => target.backend)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backend: "cpu",
|
||||
accelerated: false,
|
||||
fallback_to_cpu: Boolean(gpu?.present),
|
||||
reason: gpu?.present ? "No compatible managed GPU runtime is available." : "No supported GPU detected.",
|
||||
target: cpu
|
||||
target: cpu,
|
||||
available_backends: []
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimeTargets(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.filter(Boolean);
|
||||
if (Array.isArray(value.targets)) return value.targets.filter(Boolean);
|
||||
if (value.filename || value.url) return [value];
|
||||
|
||||
return Object.values(value).flatMap((entry) => normalizeRuntimeTargets(entry));
|
||||
}
|
||||
|
||||
function runtimeTargetCompatible(target, gpu) {
|
||||
const backend = target?.backend;
|
||||
return Boolean(
|
||||
gpu?.present &&
|
||||
target &&
|
||||
backend &&
|
||||
gpu.compute_api?.includes(backend)
|
||||
);
|
||||
}
|
||||
|
||||
function rankBackend(backend) {
|
||||
const fallbackRank = BACKEND_PREFERENCE.indexOf(backend);
|
||||
return fallbackRank === -1 ? Number.MAX_SAFE_INTEGER : fallbackRank;
|
||||
}
|
||||
|
||||
function calculateGpuCapacity({ model, contextSize, gpu, backend, managedUsageMb = 0 }) {
|
||||
const requestedBackend = backend || "cpu";
|
||||
const supported = Boolean(
|
||||
@ -382,6 +416,7 @@ function testWritable() {
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_BACKENDS,
|
||||
BACKEND_PREFERENCE,
|
||||
detectHardware,
|
||||
detectGpus,
|
||||
selectRuntimeTarget,
|
||||
|
||||
@ -3,11 +3,13 @@ const { resolveData } = require("./paths");
|
||||
|
||||
const historyFile = () => resolveData("metrics", "history.jsonl");
|
||||
const stateFile = () => resolveData("metrics", "summary.json");
|
||||
let retention = { mode: "count", count: 500, age_value: 30, age_unit: "days" };
|
||||
function getSummary() {
|
||||
try { return JSON.parse(fs.readFileSync(stateFile(), "utf8")); }
|
||||
catch { return { total_requests:0, successful:0, failed:0, refusals:0, gate_decisions:0, tool_decisions:0, tool_suggestions:0, tool_executions:0, tool_denials:0, confirmation_cancellations:0, timeout_count:0, runtime_crash_count:0, runtime_self_test_total:0, runtime_self_test_failed_total:0, runtime_start_attempt_total:0, runtime_start_failed_total:0, verified_downloads:0, failed_downloads:0, requests_by_role:{}, requests_by_scope:{}, requests_by_route:{}, gate_reason_codes:{}, tool_rejections_by_reason:{}, runtime_exit_code_counts:{}, stage_totals:{}, stage_samples:0, slow_requests:[], durations:[], queue_wait_total_ms:0 }; }
|
||||
}
|
||||
function record(entry) {
|
||||
maybeApplyRetention();
|
||||
const summary = getSummary();
|
||||
summary.requests_by_role ||= {};
|
||||
summary.requests_by_scope ||= {};
|
||||
@ -167,6 +169,13 @@ function historyPage(page = 1, pageSize = 25) {
|
||||
return { entries: [], page: 1, pages: 1, page_size: safeSize, total: 0 };
|
||||
}
|
||||
}
|
||||
function workHistoryPage(query = {}, page = 1, pageSize = 20) {
|
||||
const rows = readHistoryRows();
|
||||
const filters = normalizeWorkFilters(query);
|
||||
const grouped = groupWorkRows(rows)
|
||||
.filter((work) => matchesWorkFilters(work, filters));
|
||||
return paginateRows(grouped, page, pageSize);
|
||||
}
|
||||
function slowRequestsPage(page = 1, pageSize = 15) {
|
||||
const safePage = Math.max(1, Number.parseInt(page, 10) || 1);
|
||||
const safeSize = Math.max(1, Math.min(100, Number.parseInt(pageSize, 10) || 15));
|
||||
@ -184,6 +193,232 @@ function slowRequestsPage(page = 1, pageSize = 15) {
|
||||
return { entries: [], page: 1, pages: 1, page_size: safeSize, total: 0 };
|
||||
}
|
||||
}
|
||||
function configureRetention(value = {}) {
|
||||
retention = normalizeRetention(value);
|
||||
applyRetention();
|
||||
}
|
||||
function applyRetention() {
|
||||
const rows = readHistoryRows();
|
||||
if (!rows.length) return;
|
||||
const keepIds = retainedWorkIds(groupWorkRows(rows), retention);
|
||||
if (!keepIds) return;
|
||||
const kept = rows.filter((row) => {
|
||||
const id = row.request_id || row.work_id;
|
||||
return !id || keepIds.has(id);
|
||||
});
|
||||
if (kept.length === rows.length) return;
|
||||
fs.writeFileSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : ""));
|
||||
}
|
||||
function maybeApplyRetention() {
|
||||
if (Math.random() > 0.02) return;
|
||||
try { applyRetention(); } catch {}
|
||||
}
|
||||
function readHistoryRows() {
|
||||
try {
|
||||
return fs.readFileSync(historyFile(), "utf8").trim().split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(parseHistoryRow)
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function groupWorkRows(rows) {
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const id = row.request_id || row.work_id;
|
||||
if (!id) continue;
|
||||
if (!groups.has(id)) {
|
||||
groups.set(id, {
|
||||
work_id: id,
|
||||
started_at: row.timestamp,
|
||||
finished_at: row.timestamp,
|
||||
status: "success",
|
||||
source: row.origin || row.platform || "webui",
|
||||
user_id: row.user_id || "",
|
||||
role: row.role || "",
|
||||
internal_mode: row.controller_complexity || "",
|
||||
okf_retrieval: row.okf_retrieval_depth || "",
|
||||
reason_code: row.controller_reason_code || row.gate_reason_code || row.reason_code || "",
|
||||
processed_tokens: 0,
|
||||
final_tokens: 0,
|
||||
delivered_tokens: 0,
|
||||
duration_ms: 0,
|
||||
prompt: "",
|
||||
prompt_tokens: 0,
|
||||
has_error: false,
|
||||
has_refusal: false,
|
||||
has_fallback: false,
|
||||
has_truncation: false,
|
||||
has_okf_context: false,
|
||||
events: []
|
||||
});
|
||||
}
|
||||
const group = groups.get(id);
|
||||
group.started_at = earlierIso(group.started_at, row.timestamp);
|
||||
group.finished_at = laterIso(group.finished_at, row.timestamp);
|
||||
group.source = row.origin || row.platform || group.source;
|
||||
group.user_id = row.user_id || group.user_id;
|
||||
group.role = row.role || group.role;
|
||||
group.internal_mode = row.controller_complexity || group.internal_mode;
|
||||
group.okf_retrieval = row.okf_retrieval_depth || group.okf_retrieval;
|
||||
group.reason_code = row.controller_reason_code || row.gate_reason_code || row.reason_code || group.reason_code;
|
||||
group.has_error ||= row.status === "failed" || row.kind === "error" || Boolean(row.error_code);
|
||||
group.has_refusal ||= row.status === "refused" || row.route_used === "refusal";
|
||||
group.has_fallback ||= Boolean(row.controller_fallback_used || row.fallback_reason || row.rejected_reason);
|
||||
group.has_truncation ||= Boolean(row.truncated);
|
||||
group.has_okf_context ||= row.kind === "okf_retrieval" || row.okf_retrieval_depth === "light" || row.okf_retrieval_depth === "deep" || Number(row.okf_match_count) > 0;
|
||||
if (row.kind === "prompt") {
|
||||
group.prompt = row.prompt || group.prompt;
|
||||
group.prompt_tokens = validOrZero(row.prompt_tokens);
|
||||
}
|
||||
const promptTokens = validOrZero(row.prompt_tokens);
|
||||
const generatedTokens = validOrZero(row.generated_tokens);
|
||||
group.processed_tokens += promptTokens + generatedTokens;
|
||||
if (row.final_tokens != null) group.final_tokens = validOrZero(row.final_tokens);
|
||||
else if (row.final_reply_length != null || row.original_final_length != null) {
|
||||
group.final_tokens = estimateTokensFromChars(row.final_reply_length ?? row.original_final_length);
|
||||
}
|
||||
if (row.delivered_tokens != null) group.delivered_tokens = validOrZero(row.delivered_tokens);
|
||||
else if (row.delivered_length != null) group.delivered_tokens = estimateTokensFromChars(row.delivered_length);
|
||||
group.duration_ms = Math.max(group.duration_ms, validOrZero(row.total_ms ?? row.duration_ms));
|
||||
group.events.push(workEvent(row));
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((group) => {
|
||||
if (!group.processed_tokens) group.processed_tokens = group.prompt_tokens + group.final_tokens;
|
||||
if (group.has_error && !group.delivered_tokens) group.status = "failed";
|
||||
else if (group.has_error || group.has_refusal || group.has_fallback || group.has_truncation) group.status = "partial";
|
||||
else group.status = "success";
|
||||
group.events.sort((left, right) => String(left.timestamp).localeCompare(String(right.timestamp)));
|
||||
return group;
|
||||
})
|
||||
.sort((left, right) => String(left.started_at).localeCompare(String(right.started_at)));
|
||||
}
|
||||
function workEvent(row) {
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
type: normalizeWorkEventType(row),
|
||||
status: row.status || "",
|
||||
summary: workEventSummary(row),
|
||||
prompt: row.kind === "prompt" ? row.prompt || "" : "",
|
||||
tokens: validOrZero(row.prompt_tokens) + validOrZero(row.generated_tokens),
|
||||
data: compactWorkData(row)
|
||||
};
|
||||
}
|
||||
function normalizeWorkEventType(row) {
|
||||
if (row.kind === "prompt") return "prompt";
|
||||
if (row.kind === "controller_decision") return "controller";
|
||||
if (row.kind === "gate_summary" || row.kind === "gate_decision") return "controller";
|
||||
if (row.kind === "okf_retrieval") return "okf_retrieval";
|
||||
if (row.kind === "prompt_build") return "prompt_build";
|
||||
if (row.kind === "model_request") return "model_request";
|
||||
if (row.kind === "request") return row.status === "failed" ? "error" : "model_response";
|
||||
if (row.kind === "delivery") return row.truncated ? "source_limit" : "delivery";
|
||||
if (row.kind === "tool_exposure") return "context";
|
||||
return row.kind || "metrics";
|
||||
}
|
||||
function workEventSummary(row) {
|
||||
if (row.kind === "prompt") return `Prompt from ${row.origin || row.platform || "webui"} as ${row.role || "unknown role"}`;
|
||||
if (row.kind === "controller_decision") {
|
||||
return `Controller selected ${row.controller_complexity || "-"} / ${row.okf_retrieval_depth || "-"} (${row.controller_reason_code || row.reason_code || "no reason"})`;
|
||||
}
|
||||
if (row.kind === "gate_summary" || row.kind === "gate_decision") {
|
||||
return `Gate route ${row.route_used || "-"} (${row.reason_code || "no reason"})`;
|
||||
}
|
||||
if (row.kind === "okf_retrieval") return `OKF retrieval ${row.status || "success"} with ${row.okf_match_count || 0} match(es)`;
|
||||
if (row.kind === "prompt_build") return `Prompt built with ${row.context_block_count || 0} context block(s)`;
|
||||
if (row.kind === "model_request") return `Main model request in ${row.controller_complexity || "dynamic"} mode`;
|
||||
if (row.kind === "delivery") return row.truncated ? "Delivered after source limit truncation" : "Delivered reply";
|
||||
if (row.kind === "request" && row.status === "failed") return row.message || row.error_code || "Request failed";
|
||||
return row.reason_code || row.controller_reason_code || row.status || row.kind || "Event";
|
||||
}
|
||||
function compactWorkData(row) {
|
||||
const keys = [
|
||||
"route_used", "route_class", "reason_code", "gate_reason_code", "controller_intent",
|
||||
"controller_complexity", "okf_retrieval_depth", "answer_style", "source_profile",
|
||||
"controller_confidence", "okf_match_count", "context_block_count", "prompt_tokens",
|
||||
"generated_tokens", "final_tokens", "delivered_tokens", "final_reply_length",
|
||||
"delivered_length", "truncated", "delivery_action", "fallback_reason", "rejected_reason", "error_code"
|
||||
];
|
||||
return Object.fromEntries(keys.filter((key) => row[key] != null).map((key) => [key, row[key]]));
|
||||
}
|
||||
function normalizeWorkFilters(query = {}) {
|
||||
return {
|
||||
q: String(query.work_q || query.q || "").trim().toLowerCase(),
|
||||
status: String(query.work_status || "").trim(),
|
||||
source: String(query.work_source || "").trim(),
|
||||
role: String(query.work_role || "").trim(),
|
||||
mode: String(query.work_mode || "").trim(),
|
||||
okf: String(query.work_okf || "").trim(),
|
||||
flag: String(query.work_flag || "").trim(),
|
||||
from: parseTime(query.work_from),
|
||||
to: parseTime(query.work_to, true)
|
||||
};
|
||||
}
|
||||
function matchesWorkFilters(work, filters) {
|
||||
if (filters.status && work.status !== filters.status) return false;
|
||||
if (filters.source && work.source !== filters.source) return false;
|
||||
if (filters.role && work.role !== filters.role) return false;
|
||||
if (filters.mode && work.internal_mode !== filters.mode) return false;
|
||||
if (filters.okf && work.okf_retrieval !== filters.okf) return false;
|
||||
if (filters.flag === "error" && !work.has_error) return false;
|
||||
if (filters.flag === "refusal" && !work.has_refusal) return false;
|
||||
if (filters.flag === "fallback" && !work.has_fallback) return false;
|
||||
if (filters.flag === "truncation" && !work.has_truncation) return false;
|
||||
if (filters.flag === "okf" && !work.has_okf_context) return false;
|
||||
const started = Date.parse(work.started_at);
|
||||
if (filters.from && started < filters.from) return false;
|
||||
if (filters.to && started > filters.to) return false;
|
||||
if (!filters.q) return true;
|
||||
const haystack = [
|
||||
work.work_id, work.prompt, work.source, work.user_id, work.role, work.status, work.internal_mode,
|
||||
work.okf_retrieval, work.reason_code,
|
||||
...work.events.map((event) => `${event.type} ${event.status} ${event.summary} ${JSON.stringify(event.data)}`)
|
||||
].join("\n").toLowerCase();
|
||||
return haystack.includes(filters.q);
|
||||
}
|
||||
function normalizeRetention(value = {}) {
|
||||
const mode = value.mode === "age" ? "age" : "count";
|
||||
return {
|
||||
mode,
|
||||
count: Math.max(50, Math.min(10000, Number.parseInt(value.count, 10) || 500)),
|
||||
age_value: Math.max(1, Math.min(1000, Number.parseInt(value.age_value, 10) || 30)),
|
||||
age_unit: ["hours", "days", "weeks", "months", "years"].includes(value.age_unit) ? value.age_unit : "days"
|
||||
};
|
||||
}
|
||||
function retainedWorkIds(groups, config) {
|
||||
if (config.mode === "count") {
|
||||
return new Set(groups.slice(-config.count).map((group) => group.work_id));
|
||||
}
|
||||
const cutoff = Date.now() - ageMs(config.age_value, config.age_unit);
|
||||
return new Set(groups.filter((group) => Date.parse(group.started_at) >= cutoff).map((group) => group.work_id));
|
||||
}
|
||||
function ageMs(value, unit) {
|
||||
const hours = unit === "hours" ? value
|
||||
: unit === "weeks" ? value * 24 * 7
|
||||
: unit === "months" ? value * 24 * 30
|
||||
: unit === "years" ? value * 24 * 365
|
||||
: value * 24;
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
function parseTime(value, endOfDay = false) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
if (endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(String(value))) date.setHours(23, 59, 59, 999);
|
||||
return date.getTime();
|
||||
}
|
||||
function earlierIso(left, right) {
|
||||
return Date.parse(left) <= Date.parse(right) ? left : right;
|
||||
}
|
||||
function laterIso(left, right) {
|
||||
return Date.parse(left) >= Date.parse(right) ? left : right;
|
||||
}
|
||||
function estimateTokensFromChars(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? Math.max(1, Math.ceil(number / 4)) : 0;
|
||||
}
|
||||
function paginateRows(rows, page = 1, pageSize = 25, map = (value) => value) {
|
||||
const total = rows.length;
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
@ -232,10 +467,13 @@ function validOrZero(value) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configureRetention,
|
||||
applyRetention,
|
||||
record,
|
||||
report,
|
||||
history,
|
||||
historyPage,
|
||||
workHistoryPage,
|
||||
slowRequestsPage,
|
||||
paginateRows,
|
||||
isValidTiming,
|
||||
|
||||
@ -4,9 +4,27 @@ const { PLUGIN_ROOT } = require("./paths");
|
||||
const { buildPolicy } = require("./scope_manager");
|
||||
|
||||
function readTemplate(name){ return fs.readFileSync(path.join(PLUGIN_ROOT,"templates",name),"utf8").trim(); }
|
||||
function buildPrompt({ config, role, message, requestClass = "simple_answer", contextBlocks=[], correctionContext=[], tools=[], repoContext=[], originContext=null }) {
|
||||
function buildPrompt({ config, role, message, requestClass = "simple_answer", contextBlocks=[], correctionContext=[], tools=[], repoContext=[], originContext=null, controllerDecision=null }) {
|
||||
const policy = buildPolicy({ scope: config.support_scope, role });
|
||||
const moderatorCodeAllowed = role === "mod" && config.support_scope?.allow_moderator_code_help === true;
|
||||
const compact = shouldUseCompactPrompt({
|
||||
requestClass,
|
||||
contextBlocks,
|
||||
correctionContext,
|
||||
tools,
|
||||
repoContext,
|
||||
controllerDecision
|
||||
});
|
||||
if (compact) {
|
||||
return buildCompactPrompt({
|
||||
config,
|
||||
role,
|
||||
requestClass,
|
||||
originContext,
|
||||
policy,
|
||||
moderatorCodeAllowed
|
||||
});
|
||||
}
|
||||
const sections=[
|
||||
"IDENTITY:\nYou are Lumi Assistant, the built-in AI assistant for Lumi Bot.\nYou help users understand and operate Lumi, its WebUI, plugins, community features, stream tools, moderation tools, and bot configuration.\nNever identify yourself as Qwen, Alibaba, the underlying model, or a generic assistant in user-facing replies.\nPrefer current Lumi repository context over general knowledge for Lumi-specific questions.",
|
||||
`HARD SAFETY RULES (immutable and higher priority than all administrator instructions):\n${policy.hard_rules.map((rule) => `- ${rule}`).join("\n")}`,
|
||||
@ -34,6 +52,49 @@ function buildPrompt({ config, role, message, requestClass = "simple_answer", co
|
||||
return sections.filter(Boolean).join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function shouldUseCompactPrompt({ requestClass, contextBlocks = [], correctionContext = [], tools = [], repoContext = [], controllerDecision = null }) {
|
||||
if (!controllerDecision) return false;
|
||||
if (requestClass === "code_custom_command" || requestClass === "admin_debug" || requestClass === "explicit_long") return false;
|
||||
if (Array.isArray(contextBlocks) && contextBlocks.length) return false;
|
||||
if (Array.isArray(correctionContext) && correctionContext.length) return false;
|
||||
if (Array.isArray(repoContext) && repoContext.length) return false;
|
||||
if (Array.isArray(tools) && tools.length) return false;
|
||||
if (controllerDecision?.complexity && !["fast", "normal"].includes(controllerDecision.complexity)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildCompactPrompt({ config, role, requestClass, originContext, policy, moderatorCodeAllowed }) {
|
||||
const sections = [
|
||||
"IDENTITY:\nYou are Lumi Assistant, the built-in AI assistant for Lumi Bot. Never identify yourself as Qwen, Alibaba, the underlying model, or a generic assistant.",
|
||||
`HARD SAFETY RULES:\n${policy.hard_rules.map((rule) => `- ${rule}`).join("\n")}`,
|
||||
`ROLE AND DISCLOSURE:\nRole: ${role}.\n${role === "admin"
|
||||
? "You may explain technical details when verified context is present or the answer is general and safe."
|
||||
: moderatorCodeAllowed
|
||||
? "Give user-facing and moderation help. Do not expose private internals."
|
||||
: "Give user-facing help only. Do not expose private internals, source paths, or implementation details."}`,
|
||||
originContext ? `REQUEST ORIGIN:\n${compactOrigin(originContext)}` : "",
|
||||
`STYLE:\n${policy.style}\nAnswer directly and avoid broad background. Request class: ${requestClass}.`,
|
||||
"VERIFICATION RULE:\nNo verified Lumi/OKF context was retrieved for this request. For Lumi-specific or community-specific facts, say when the answer cannot be verified instead of inventing details.",
|
||||
config.instructions?.community_tone ? `COMMUNITY TONE:\n${config.instructions.community_tone}` : "",
|
||||
config.instructions?.admin_custom ? `ADMIN CUSTOM INSTRUCTIONS:\n${config.instructions.admin_custom}` : ""
|
||||
];
|
||||
return sections.filter(Boolean).join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function compactOrigin(originContext) {
|
||||
const profile = originContext?.response_profile || {};
|
||||
return JSON.stringify({
|
||||
origin: originContext.origin || originContext.platform || "webui",
|
||||
platform: originContext.platform || originContext.origin || "webui",
|
||||
role: originContext.role,
|
||||
response_profile: {
|
||||
source: profile.source,
|
||||
target_chars: profile.target_chars,
|
||||
hard_chars: profile.hard_chars
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function webSearchPolicy(tools = []) {
|
||||
if (!tools.some((tool) => String(tool.tool_id).startsWith("web_search."))) return "";
|
||||
return [
|
||||
|
||||
@ -145,7 +145,11 @@ function sanitizeDetails(details) {
|
||||
]) {
|
||||
if (Number.isFinite(Number(details[key]))) output[key] = Number(details[key]);
|
||||
}
|
||||
for (const key of ["route", "route_class", "reason_code", "backend", "error_code"]) {
|
||||
for (const key of [
|
||||
"route", "route_class", "reason_code", "backend", "error_code",
|
||||
"controller_intent", "controller_complexity", "controller_reason_code",
|
||||
"okf_retrieval_depth", "answer_style", "source_profile"
|
||||
]) {
|
||||
if (details[key] != null) output[key] = String(details[key]).slice(0, 120);
|
||||
}
|
||||
if (details.frontend_soft_timeout != null) output.frontend_soft_timeout = Boolean(details.frontend_soft_timeout);
|
||||
|
||||
@ -55,15 +55,15 @@ function formatAssistantResponse({
|
||||
output = output.replace(/[ \t]+\n/g, "\n").replace(/\n{4,}/g, "\n\n\n").trim();
|
||||
|
||||
const originalLength = output.length;
|
||||
const delivered = Number.isFinite(Number(maxLength)) && Number(maxLength) > 0
|
||||
? truncateFinal(output, maxLength)
|
||||
: output;
|
||||
const constrained = constrainFinal(output, maxLength);
|
||||
const delivered = constrained.text;
|
||||
return {
|
||||
text: delivered,
|
||||
links: uniqueLinks(collected),
|
||||
original_final_length: originalLength,
|
||||
delivered_length: delivered.length,
|
||||
truncated: delivered.length < originalLength
|
||||
truncated: delivered.length < originalLength,
|
||||
delivery_action: constrained.action
|
||||
};
|
||||
}
|
||||
|
||||
@ -74,10 +74,43 @@ function collectLink(output, link, baseUrl, routeSet) {
|
||||
}
|
||||
|
||||
function truncateFinal(value, maxLength) {
|
||||
const limit = Math.max(100, Number.parseInt(maxLength, 10) || 4000);
|
||||
if (value.length <= limit) return value;
|
||||
const suffix = " [reply truncated]";
|
||||
return `${value.slice(0, Math.max(1, limit - suffix.length)).trimEnd()}${suffix}`;
|
||||
return constrainFinal(value, maxLength).text;
|
||||
}
|
||||
|
||||
function constrainFinal(value, maxLength) {
|
||||
const output = String(value || "");
|
||||
const requestedLimit = Number(maxLength);
|
||||
if (!Number.isFinite(requestedLimit) || requestedLimit <= 0 || output.length <= requestedLimit) {
|
||||
return { text: output, action: "none" };
|
||||
}
|
||||
const limit = Math.max(100, Number.parseInt(requestedLimit, 10) || 4000);
|
||||
const suffix = " [Reply shortened for platform limits.]";
|
||||
const available = Math.max(1, limit - suffix.length);
|
||||
const candidate = output.slice(0, available);
|
||||
const minimumBoundary = Math.floor(available * 0.55);
|
||||
const sentenceBoundary = lastBoundary(candidate, /(?:[.!?][\])}"']?|\n\n)(?=\s|$)/g, minimumBoundary);
|
||||
const lineBoundary = lastBoundary(candidate, /\n/g, minimumBoundary);
|
||||
const wordBoundary = candidate.lastIndexOf(" ");
|
||||
const boundary = sentenceBoundary >= minimumBoundary
|
||||
? sentenceBoundary
|
||||
: lineBoundary >= minimumBoundary
|
||||
? lineBoundary
|
||||
: wordBoundary >= minimumBoundary
|
||||
? wordBoundary
|
||||
: available;
|
||||
return {
|
||||
text: `${candidate.slice(0, boundary).trimEnd()}${suffix}`,
|
||||
action: "compacted"
|
||||
};
|
||||
}
|
||||
|
||||
function lastBoundary(value, pattern, minimum) {
|
||||
let boundary = -1;
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
const end = (match.index || 0) + match[0].length;
|
||||
if (end >= minimum) boundary = end;
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
function markdownLink(link) {
|
||||
@ -99,6 +132,7 @@ module.exports = {
|
||||
formatAssistantResponse,
|
||||
normalizeLink,
|
||||
truncateFinal,
|
||||
constrainFinal,
|
||||
absoluteInternalUrl,
|
||||
normalizeCodeFences
|
||||
};
|
||||
|
||||
@ -37,6 +37,7 @@ const { ToolSettings } = require("./backend/tool_settings");
|
||||
const { buildAllowedToolsSection } = require("./backend/prompt_builder");
|
||||
const storage = require("./backend/storage");
|
||||
const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils");
|
||||
const { SOURCE_DEFAULTS } = require("./backend/controller");
|
||||
|
||||
const PLUGIN_ID = "lumi_ai";
|
||||
const TOKEN_PRESETS = Object.freeze([
|
||||
@ -63,6 +64,7 @@ module.exports = {
|
||||
catch (error) { console.warn("Lumi AI repository index initialization failed", error.message); }
|
||||
}
|
||||
let config = getConfig();
|
||||
metrics.configureRetention(config.work_history_retention);
|
||||
const feedbackStore = new FeedbackStore();
|
||||
const correctionStore = new CorrectionStore({
|
||||
getConfig: () => config,
|
||||
@ -101,10 +103,27 @@ module.exports = {
|
||||
const frontendVisibility = new Map();
|
||||
const getSafeContext = (input = {}) => {
|
||||
const context = typeof input === "string" ? { role: input } : input;
|
||||
return [...contextProviders.values()].flatMap((fn) => {
|
||||
try { return normalizeContext(fn(context)); } catch { return []; }
|
||||
});
|
||||
const diagnostics = [];
|
||||
const blocks = [];
|
||||
getSafeContext.lastDiagnostics = diagnostics;
|
||||
getSafeContext.lastErrors = [];
|
||||
for (const fn of contextProviders.values()) {
|
||||
try {
|
||||
const result = fn(context);
|
||||
if (result && typeof result === "object" && !Array.isArray(result)) {
|
||||
if (result.diagnostics) diagnostics.push(safeContextDiagnostic(result.diagnostics));
|
||||
blocks.push(...normalizeContext(result.blocks ?? result.context ?? result.items ?? []));
|
||||
} else {
|
||||
blocks.push(...normalizeContext(result));
|
||||
}
|
||||
} catch (error) {
|
||||
getSafeContext.lastErrors.push(String(error.message || error).slice(0, 300));
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
getSafeContext.lastDiagnostics = [];
|
||||
getSafeContext.lastErrors = [];
|
||||
const runtime = new RuntimeManager({
|
||||
getConfig: () => config,
|
||||
getModel,
|
||||
@ -199,9 +218,11 @@ module.exports = {
|
||||
return { ...main, gate: await gateRuntime.health() };
|
||||
};
|
||||
let gateRecoveryPending = false;
|
||||
let runtimeInstallInProgress = false;
|
||||
const gateMonitor = setInterval(async () => {
|
||||
if (
|
||||
gateRecoveryPending ||
|
||||
runtimeInstallInProgress ||
|
||||
!config.enabled ||
|
||||
gateRuntime.status().state === "running"
|
||||
) return;
|
||||
@ -301,6 +322,7 @@ module.exports = {
|
||||
allocation: gpuAllocation,
|
||||
generationTps: Number(recentGeneration?.generation_tps) || 0
|
||||
});
|
||||
const workHistoryPage = metrics.workHistoryPage(req.query, req.query.work_page, 20);
|
||||
const metricsPage = metrics.historyPage(req.query.metrics_page, 25);
|
||||
const slowRequestsPage = metrics.slowRequestsPage(req.query.slow_page, 15);
|
||||
const accessPage = paginateRows(accessControl.list(), req.query.access_page, 25);
|
||||
@ -310,9 +332,12 @@ module.exports = {
|
||||
const modelFileSize = selectedModelPath && fs.existsSync(selectedModelPath)
|
||||
? fs.statSync(selectedModelPath).size
|
||||
: 0;
|
||||
const runtimeDownloadSize = runtimeTarget
|
||||
? Number(runtimeTarget.size || 0) + (runtimeTarget.dependencies || []).reduce((sum, dependency) => sum + Number(dependency.size || 0), 0)
|
||||
: 0;
|
||||
const sizeDiagnostics = [
|
||||
sanityCheckSize("Runtime folder", runtimeFolderSize, 5 * 1024 ** 3),
|
||||
sanityCheckSize("Runtime archive", runtimeTarget?.size || 0, 2 * 1024 ** 3),
|
||||
sanityCheckSize("Runtime archive", runtimeDownloadSize, 2 * 1024 ** 3),
|
||||
sanityCheckSize("Selected model", selectedModel?.size || 0, 100 * 1024 ** 3),
|
||||
sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3),
|
||||
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
|
||||
@ -337,6 +362,7 @@ module.exports = {
|
||||
tokenPresets: TOKEN_PRESETS,
|
||||
gateContextOptions: GATE_CONTEXT_OPTIONS,
|
||||
runtimeTarget,
|
||||
runtimeDownloadSize,
|
||||
runtimeManifest,
|
||||
runtimeStatus,
|
||||
gateStatus,
|
||||
@ -361,6 +387,8 @@ module.exports = {
|
||||
sizeDiagnostics,
|
||||
hardware,
|
||||
metrics: metrics.report(),
|
||||
workHistoryPage,
|
||||
workFilters: req.query,
|
||||
history: metricsPage.entries,
|
||||
metricsPage,
|
||||
slowRequestsPage,
|
||||
@ -385,19 +413,11 @@ module.exports = {
|
||||
return flash(req, res, "error", "Choose a supported AI context size.");
|
||||
}
|
||||
const contextSize = requestedContext;
|
||||
const tokenValues = TOKEN_PRESETS.map((option) => option.value);
|
||||
const gateContextValues = GATE_CONTEXT_OPTIONS.map((option) => option.value);
|
||||
const requestedGateContext = Number(req.body.gate_context_size);
|
||||
if (!gateContextValues.includes(requestedGateContext)) {
|
||||
return flash(req, res, "error", "Choose a supported gate context size.");
|
||||
}
|
||||
const presetToken = (field, fallback, label) => {
|
||||
const value = Number(req.body[field]);
|
||||
if (!tokenValues.includes(value)) {
|
||||
throw new Error(`Choose a supported preset for ${label}.`);
|
||||
}
|
||||
return value || fallback;
|
||||
};
|
||||
const previousConfig = config;
|
||||
let nextConfig;
|
||||
try {
|
||||
@ -414,14 +434,7 @@ 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: presetToken("max_output_tokens", 2048, "API/test output tokens"),
|
||||
output_budgets: {
|
||||
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")
|
||||
},
|
||||
source_profiles: sourceProfilesFromBody(req.body, config.source_profiles),
|
||||
batch_size: boundedInt(req.body.batch_size, 32, 4096, 512),
|
||||
ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128),
|
||||
per_user_requests_per_minute: boundedInt(req.body.per_user_requests_per_minute, 1, 120, 6),
|
||||
@ -433,6 +446,14 @@ module.exports = {
|
||||
mods: req.body.visibility_mods === "on",
|
||||
users: req.body.visibility_users === "on"
|
||||
},
|
||||
work_history_retention: {
|
||||
mode: req.body.work_history_retention_mode === "age" ? "age" : "count",
|
||||
count: boundedInt(req.body.work_history_retention_count, 50, 10000, 500),
|
||||
age_value: boundedInt(req.body.work_history_retention_age_value, 1, 1000, 30),
|
||||
age_unit: ["hours", "days", "weeks", "months", "years"].includes(req.body.work_history_retention_age_unit)
|
||||
? req.body.work_history_retention_age_unit
|
||||
: "days"
|
||||
},
|
||||
commands: {
|
||||
enabled: req.body.command_enabled === "on",
|
||||
triggers: parseCommandTriggers(req.body.command_triggers),
|
||||
@ -523,6 +544,7 @@ module.exports = {
|
||||
return flash(req, res, "error", error.message);
|
||||
}
|
||||
config = saveConfig(nextConfig);
|
||||
metrics.configureRetention(config.work_history_retention);
|
||||
registerAssistantCommands({
|
||||
commandRouter,
|
||||
provider,
|
||||
@ -573,12 +595,27 @@ module.exports = {
|
||||
archive: true,
|
||||
runtimeMetadata: {
|
||||
backend: target.backend || "cpu",
|
||||
backend_variant: target.backend_variant || null,
|
||||
version: runtimeManifest.version,
|
||||
target: target.filename
|
||||
target: target.filename,
|
||||
dependencies: Array.isArray(target.dependencies)
|
||||
? target.dependencies.map((dependency) => ({
|
||||
filename: dependency.filename,
|
||||
sha256: dependency.sha256,
|
||||
size: dependency.size || 0
|
||||
}))
|
||||
: []
|
||||
},
|
||||
beforeInstall: async () => {
|
||||
runtimeInstallInProgress = true;
|
||||
await stopRuntimes({ manual: true, reason: "runtime_reinstall" });
|
||||
},
|
||||
afterInstall: async () => {
|
||||
runtimeInstallInProgress = false;
|
||||
}
|
||||
});
|
||||
if (wantsJson) return res.json({ success: true, job });
|
||||
return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`);
|
||||
return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started. Lumi AI runtimes will stop when the new runtime is ready to install; start them again after the download completes.`);
|
||||
} catch (error) {
|
||||
if (wantsJson) return res.status(400).json({ error: error.message });
|
||||
return flash(req, res, "error", error.message);
|
||||
@ -901,6 +938,11 @@ module.exports = {
|
||||
final_reply_length: delivered.original_final_length,
|
||||
original_final_length: delivered.original_final_length,
|
||||
delivered_length: delivered.delivered_length,
|
||||
final_tokens: estimateTokenCountFromLength(delivered.original_final_length),
|
||||
delivered_tokens: estimateTokenCountFromLength(delivered.delivered_length),
|
||||
truncated: delivered.truncated,
|
||||
delivery_action: delivered.delivery_action,
|
||||
...controllerDeliveryFields(result),
|
||||
...(result.stage_timings || {})
|
||||
});
|
||||
return {
|
||||
@ -1489,6 +1531,19 @@ function normalizeContext(value) {
|
||||
if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
|
||||
return typeof value === "string" ? [value] : [];
|
||||
}
|
||||
function safeContextDiagnostic(value) {
|
||||
const row = value && typeof value === "object" ? value : {};
|
||||
return {
|
||||
provider: cleanText(row.provider || row.kind, 40),
|
||||
kind: cleanText(row.kind, 40),
|
||||
query: cleanText(row.query, 500),
|
||||
candidate_count: boundedInt(row.candidate_count, 0, 1000, 0),
|
||||
returned_count: boundedInt(row.returned_count, 0, 1000, 0),
|
||||
limit: boundedInt(row.limit, 0, 1000, 0),
|
||||
okf_retrieval: cleanText(row.okf_retrieval, 40),
|
||||
reason: cleanText(row.reason, 160)
|
||||
};
|
||||
}
|
||||
function boundedInt(value, min, max, fallback) {
|
||||
const number = Number.parseInt(value, 10);
|
||||
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
||||
@ -1747,6 +1802,11 @@ function registerAssistantCommands({
|
||||
original_final_length: delivered.original_final_length,
|
||||
final_reply_length: reply.original_final_length,
|
||||
delivered_length: reply.delivered_length,
|
||||
final_tokens: estimateTokenCountFromLength(delivered.original_final_length),
|
||||
delivered_tokens: estimateTokenCountFromLength(reply.delivered_length),
|
||||
truncated: delivered.truncated || reply.delivered_length < delivered.original_final_length,
|
||||
delivery_action: reply.delivery_action !== "none" ? reply.delivery_action : delivered.delivery_action,
|
||||
...controllerDeliveryFields(result),
|
||||
internal_generated_length: result.internal_generated_length || String(result.text || "").length
|
||||
});
|
||||
} catch (error) {
|
||||
@ -1799,7 +1859,7 @@ function webOriginContext(req) {
|
||||
message_id: null,
|
||||
reply_mode: "panel",
|
||||
format_capabilities: { markdown: false, html: true },
|
||||
max_message_length: 8000,
|
||||
max_message_length: null,
|
||||
base_url: `${req.protocol}://${req.get("host")}`,
|
||||
source_plugin: "lumi_ai",
|
||||
source_command: "webui_assistant",
|
||||
@ -1809,7 +1869,7 @@ function webOriginContext(req) {
|
||||
|
||||
function diagnosticOriginContext(user, origin = "webui") {
|
||||
const role = roleOf(user);
|
||||
const limits = { webui: 8000, discord: 1900, twitch: 450, youtube: 1800, kick: 450, other: 1000 };
|
||||
const limits = { webui: null, discord: 1900, twitch: 450, youtube: 1800, kick: 450, other: 1000 };
|
||||
return {
|
||||
origin,
|
||||
platform: origin,
|
||||
@ -1828,7 +1888,7 @@ function diagnosticOriginContext(user, origin = "webui") {
|
||||
markdown: origin === "discord",
|
||||
html: origin === "webui"
|
||||
},
|
||||
max_message_length: limits[origin] || limits.other,
|
||||
max_message_length: origin === "webui" ? null : (limits[origin] || limits.other),
|
||||
source_plugin: "lumi_ai",
|
||||
source_command: "admin_diagnostic",
|
||||
permission_context: {
|
||||
@ -1856,6 +1916,28 @@ function finalizeAssistantResult(result, { role, config, baseUrl = "", maxLength
|
||||
return output;
|
||||
}
|
||||
|
||||
function controllerDeliveryFields(result = {}) {
|
||||
const decision = result.controller_decision || {};
|
||||
const profile = decision.source_profile || {};
|
||||
return {
|
||||
controller_route: decision.route,
|
||||
controller_intent: decision.intent,
|
||||
controller_complexity: decision.complexity,
|
||||
controller_reason_code: decision.reason_code,
|
||||
controller_confidence: decision.confidence,
|
||||
okf_retrieval_depth: decision.okf_retrieval,
|
||||
source_profile: profile.source,
|
||||
target_final_chars: profile.target_chars,
|
||||
hard_final_chars: profile.hard_chars,
|
||||
controller_fallback_used: decision.fallback_used
|
||||
};
|
||||
}
|
||||
|
||||
function estimateTokenCountFromLength(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? Math.max(1, Math.ceil(number / 4)) : 0;
|
||||
}
|
||||
|
||||
function normalizeCustomCommandReply(text, requestMessage) {
|
||||
const request = String(requestMessage || "");
|
||||
const output = String(text || "");
|
||||
@ -1937,6 +2019,25 @@ function limitFromBody(body, prefix, fallbackRequests, fallbackWindow) {
|
||||
};
|
||||
}
|
||||
|
||||
function sourceProfilesFromBody(body, current = {}) {
|
||||
return Object.fromEntries(Object.entries(SOURCE_DEFAULTS).map(([source, fallback]) => {
|
||||
const existing = current?.[source] || {};
|
||||
const hardRaw = body[`source_profile_${source}_hard_chars`];
|
||||
const targetFallback = existing.target_chars ?? fallback.target_chars;
|
||||
const hardFallback = existing.hard_chars ?? fallback.hard_chars ?? 1000;
|
||||
return [source, {
|
||||
source,
|
||||
target_chars: boundedInt(body[`source_profile_${source}_target_chars`], 100, 12000, targetFallback),
|
||||
hard_chars: String(hardRaw ?? "").trim() === ""
|
||||
? null
|
||||
: boundedInt(hardRaw, 100, 12000, hardFallback),
|
||||
allow_sections: body[`source_profile_${source}_allow_sections`] === "on",
|
||||
allow_long_answer: body[`source_profile_${source}_allow_long_answer`] === "on",
|
||||
allow_split: body[`source_profile_${source}_allow_split`] === "on"
|
||||
}];
|
||||
}));
|
||||
}
|
||||
|
||||
function writeCommandsManifest(pluginDir, config) {
|
||||
const triggers = config.commands.triggers || ["assistant", "lumi"];
|
||||
const primary = triggers[0] || "assistant";
|
||||
|
||||
@ -157,11 +157,12 @@
|
||||
};
|
||||
const baseLabel = labels[stage] || "Lumi Assistant is processing...";
|
||||
const elapsed = latestJob?.elapsed_ms ? ` · ${formatElapsed(latestJob.elapsed_ms)}` : "";
|
||||
const budget = Number(latestJob?.details?.max_output_tokens_used || latestJob?.details?.max_output_tokens) || 0;
|
||||
const budgetText = budget && ["prompt_eval", "generating"].includes(stage)
|
||||
? ` · budget ${budget} tokens`
|
||||
const mode = latestJob?.details?.controller_complexity;
|
||||
const retrieval = latestJob?.details?.okf_retrieval_depth;
|
||||
const routingText = mode && ["prompt_eval", "generating"].includes(stage)
|
||||
? ` · ${mode}${retrieval ? `/${retrieval}` : ""}`
|
||||
: "";
|
||||
label.textContent = `${baseLabel}${elapsed}${budgetText}`;
|
||||
label.textContent = `${baseLabel}${elapsed}${routingText}`;
|
||||
if (!details.hidden && latestJob) this.updateDetails(latestJob);
|
||||
},
|
||||
showSoftTimeout(actions) {
|
||||
|
||||
@ -73,6 +73,20 @@
|
||||
.ai-fieldset legend { padding: 0 5px; font-weight: 700; }
|
||||
.ai-fieldset label { display: flex; align-items: center; gap: 6px; }
|
||||
.ai-test-output { max-height: 420px; overflow: auto; margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.ai-work-filters { margin: 14px 0 10px; }
|
||||
.ai-work-summary-row { cursor: pointer; }
|
||||
.ai-work-summary-row:hover > td,
|
||||
.ai-work-summary-row:focus-within > td { background: var(--surface-2); }
|
||||
.ai-work-summary-row.is-open > td { border-bottom-color: transparent; }
|
||||
.ai-work-expand-button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.ai-work-expand-icon { flex: 0 0 auto; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform 120ms ease; }
|
||||
.ai-work-summary-row.is-open .ai-work-expand-icon { transform: rotate(45deg); }
|
||||
.ai-work-detail-row > td { padding: 0; background: var(--surface-2); }
|
||||
.ai-work-detail-content { display: grid; gap: 12px; padding: 14px; border-bottom: 1px solid var(--border); }
|
||||
.ai-work-prompt { padding: 0; }
|
||||
.ai-work-prompt pre,
|
||||
.ai-work-events td pre { max-width: 520px; max-height: 220px; overflow: auto; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.ai-work-flags { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.ai-remediation { margin: 14px 0; padding-left: 24px; }
|
||||
.ai-raw-diagnostic pre { max-height: 420px; overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.ai-tools-modal { z-index: 110; }
|
||||
|
||||
@ -69,6 +69,25 @@
|
||||
});
|
||||
const pollDownloads = async () => {
|
||||
if (!downloadStatus) return;
|
||||
const downloadStateLabel = (job) => {
|
||||
const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0;
|
||||
const labels = {
|
||||
queued: "Queued",
|
||||
downloading: "Downloading",
|
||||
downloading_dependency: "Downloading dependency",
|
||||
verifying: "Verifying",
|
||||
extracting: "Extracting",
|
||||
extracting_dependency: "Extracting dependency",
|
||||
preparing_install: "Stopping runtimes",
|
||||
installing: "Installing",
|
||||
complete: "Complete",
|
||||
error: "Error"
|
||||
};
|
||||
const label = labels[job.state] || job.state;
|
||||
const progress = job.total && /download/i.test(label) ? ` ${percent}%` : "";
|
||||
const file = job.current_file ? ` (${job.current_file})` : "";
|
||||
return `${job.id}: ${label}${progress}${file}${job.error ? ` - ${job.error}` : ""}`;
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/plugins/lumi_ai/api/downloads");
|
||||
if (!response.ok) return;
|
||||
@ -76,10 +95,7 @@
|
||||
const active = jobs.filter((job) => !["complete", "error"].includes(job.state));
|
||||
if (!jobs.length) return;
|
||||
downloadStatus.hidden = false;
|
||||
downloadStatus.textContent = jobs.map((job) => {
|
||||
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(" | ");
|
||||
downloadStatus.textContent = jobs.map(downloadStateLabel).join(" | ");
|
||||
jobs.forEach((job) => {
|
||||
const button = document.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`);
|
||||
if (!button || !window.LumiStateButton) return;
|
||||
@ -87,7 +103,12 @@
|
||||
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 (label) {
|
||||
const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0;
|
||||
label.textContent = /download/i.test(job.state)
|
||||
? `Downloading ${percent}%`
|
||||
: (job.state === "preparing_install" ? "Stopping runtimes" : job.state === "installing" ? "Installing" : "Working");
|
||||
}
|
||||
});
|
||||
if (active.length) window.setTimeout(pollDownloads, 1000);
|
||||
} catch {}
|
||||
@ -228,6 +249,33 @@
|
||||
action.addEventListener("change", updateTimeoutVisibility);
|
||||
updateTimeoutVisibility();
|
||||
}
|
||||
document.querySelectorAll("[data-work-retention]").forEach((section) => {
|
||||
const mode = section.querySelector("[data-work-retention-mode]");
|
||||
const count = section.querySelector("[data-work-retention-count]");
|
||||
const age = section.querySelector("[data-work-retention-age]");
|
||||
const sync = () => {
|
||||
const ageMode = mode?.value === "age";
|
||||
if (count) count.hidden = ageMode;
|
||||
if (age) age.hidden = !ageMode;
|
||||
};
|
||||
mode?.addEventListener("change", sync);
|
||||
sync();
|
||||
});
|
||||
document.querySelectorAll("[data-ai-work-row]").forEach((row) => {
|
||||
const button = row.querySelector(".ai-work-expand-button");
|
||||
const detail = document.getElementById(row.dataset.aiWorkDetail || "");
|
||||
if (!button || !detail) return;
|
||||
const toggle = () => {
|
||||
const open = detail.hidden;
|
||||
detail.hidden = !open;
|
||||
row.classList.toggle("is-open", open);
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
};
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("a, input, select, textarea") && !event.target.closest(".ai-work-expand-button")) return;
|
||||
toggle();
|
||||
});
|
||||
});
|
||||
const assistantDiagnostics = document.querySelector("[data-assistant-diagnostics]");
|
||||
if (assistantDiagnostics) {
|
||||
const status = assistantDiagnostics.querySelector("[data-assistant-status]");
|
||||
|
||||
@ -414,13 +414,23 @@
|
||||
});
|
||||
|
||||
settingsReset.addEventListener("click", async () => {
|
||||
if (!activeSettingsTool || !window.confirm(`Reset ${activeSettingsTool.display_name || activeSettingsTool.tool_id} settings to defaults?`)) return;
|
||||
if (!activeSettingsTool) return;
|
||||
if (!window.LumiConfirm?.destructiveFetch) {
|
||||
window.alert("Timed confirmation is unavailable. Reload the page and try again.");
|
||||
return;
|
||||
}
|
||||
settingsReset.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(activeSettingsTool.tool_id)}/settings/reset`, {
|
||||
const action = `/plugins/lumi_ai/api/tools/${encodeURIComponent(activeSettingsTool.tool_id)}/settings/reset`;
|
||||
const response = await window.LumiConfirm.destructiveFetch(action, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" }
|
||||
}, {
|
||||
title: "Reset AI tool settings",
|
||||
text: `Reset ${activeSettingsTool.display_name || activeSettingsTool.tool_id} settings to defaults?`,
|
||||
label: "Reset settings"
|
||||
});
|
||||
if (!response) return;
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "Unable to reset tool settings.");
|
||||
renderSettings(payload);
|
||||
|
||||
@ -34,13 +34,31 @@
|
||||
}
|
||||
},
|
||||
"accelerated_targets": {
|
||||
"win32-x64": {
|
||||
"backend": "vulkan",
|
||||
"filename": "llama-b9592-bin-win-vulkan-x64.zip",
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-vulkan-x64.zip",
|
||||
"sha256": "126667a2b89892fdc0c3b0b95cba0783bbdfaa69d8fd13e3d8da5c4c1307c8f4",
|
||||
"size": 38407211
|
||||
},
|
||||
"win32-x64": [
|
||||
{
|
||||
"backend": "cuda",
|
||||
"backend_variant": "cuda-12.4",
|
||||
"filename": "llama-b9592-bin-win-cuda-12.4-x64.zip",
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-cuda-12.4-x64.zip",
|
||||
"sha256": "d00b3e988f0fbd03d055904eb361b1065cfa014e1860366d42eb599af4016260",
|
||||
"size": 260958266,
|
||||
"dependencies": [
|
||||
{
|
||||
"filename": "cudart-llama-bin-win-cuda-12.4-x64.zip",
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/cudart-llama-bin-win-cuda-12.4-x64.zip",
|
||||
"sha256": "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6",
|
||||
"size": 391443627
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"backend": "vulkan",
|
||||
"filename": "llama-b9592-bin-win-vulkan-x64.zip",
|
||||
"url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-vulkan-x64.zip",
|
||||
"sha256": "126667a2b89892fdc0c3b0b95cba0783bbdfaa69d8fd13e3d8da5c4c1307c8f4",
|
||||
"size": 38407211
|
||||
}
|
||||
],
|
||||
"linux-x64": {
|
||||
"backend": "vulkan",
|
||||
"filename": "llama-b9592-bin-ubuntu-vulkan-x64.tar.gz",
|
||||
|
||||
@ -231,6 +231,9 @@ async function run() {
|
||||
assert(clientScript.includes('button("Settings"'));
|
||||
assert(clientScript.includes("update.disabled = !tool.update_enabled"));
|
||||
assert(clientScript.includes('form.dataset.confirmMode = "modal"'));
|
||||
assert(clientScript.includes("window.LumiConfirm.destructiveFetch"));
|
||||
assert(clientScript.includes("/settings/reset"));
|
||||
assert.equal(clientScript.includes("window.confirm(`Reset ${activeSettingsTool.display_name"), false);
|
||||
assert(pluginLoader.includes('entry.name, "tool_info.json"'));
|
||||
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@ -6,9 +6,15 @@ const { canUseAssistant } = require("../backend/assistant_permissions");
|
||||
const { ToolRegistry } = require("../backend/tool_router");
|
||||
const { RequestQueue } = require("../backend/queue_manager");
|
||||
const { RuntimeManager, combinedResourceEstimate, runCaptured, buildRuntimeArgs } = require("../backend/runtime_manager");
|
||||
const { getRuntimeState } = require("../backend/config_manager");
|
||||
const { DEFAULT_CONFIG, getRuntimeState } = require("../backend/config_manager");
|
||||
const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget } = require("../backend/ai_provider");
|
||||
const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, classifyRequestType, withTimeout } = require("../backend/gate_provider");
|
||||
const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, isSimpleKnowledgeLookup, classifyRequestType, withTimeout } = require("../backend/gate_provider");
|
||||
const {
|
||||
buildControllerDecision,
|
||||
normalizeControllerDecision,
|
||||
okfLimitForController,
|
||||
outputBudgetForController
|
||||
} = require("../backend/controller");
|
||||
const { AssistantRequestJobs } = require("../backend/request_jobs");
|
||||
const { shouldAutoResume } = require("../index");
|
||||
const { normalizeExitCode, classifyLaunchError } = require("../backend/error_codes");
|
||||
@ -24,10 +30,10 @@ const modelManifest = require("../models_manifest.json");
|
||||
const runtimeManifest = require("../runtime_manifest.json");
|
||||
const storage = require("../backend/storage");
|
||||
const { formatBytes, bytesFromMb, sanityCheckSize } = require("../backend/size_utils");
|
||||
const { paginateRows, summarizeMetrics, isValidTiming } = require("../backend/metrics");
|
||||
const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage } = require("../backend/metrics");
|
||||
const { AiAccessControl } = require("../backend/access_control");
|
||||
const { AiRateLimiter, mergeLimits } = require("../backend/rate_limits");
|
||||
const { buildOriginContext, formatPlatformReply } = require("../backend/commands");
|
||||
const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands");
|
||||
const { AssistantPanelDiagnostics } = require("../backend/assistant_panel_diagnostics");
|
||||
const { formatAssistantResponse, normalizeLink, normalizeCodeFences } = require("../backend/response_formatter");
|
||||
const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("../backend/feedback");
|
||||
@ -48,6 +54,8 @@ async function run() {
|
||||
assert.doesNotThrow(() => validateArchivePath("bin/llama-server.exe"));
|
||||
assert.equal(classifyError(new Error("source unavailable (404)")).category, "http_404");
|
||||
assert.equal(classifyError(new Error("hash mismatch")).category, "hash_mismatch");
|
||||
assert.equal(classifyError(Object.assign(new Error("busy"), { code: "EBUSY" })).category, "permission_denied");
|
||||
assert.match(classifyError(Object.assign(new Error("busy"), { code: "EBUSY" })).message, /Runtime files are locked/);
|
||||
|
||||
const accessViolation = normalizeExitCode(-1073741819, null, "win32");
|
||||
assert.equal(accessViolation.unsigned_exit_code, 3221225477);
|
||||
@ -111,6 +119,59 @@ async function run() {
|
||||
assert.equal(sanitizedMetrics.average_stage_ms.generation_ms, undefined);
|
||||
assert.equal(isValidTiming(-1), false);
|
||||
assert.equal(isValidTiming(0), true);
|
||||
const workRequestId = `verify-work-${Date.now()}`;
|
||||
recordMetric({
|
||||
kind: "prompt",
|
||||
status: "received",
|
||||
request_id: workRequestId,
|
||||
user_id: "history-user",
|
||||
role: "admin",
|
||||
origin: "webui",
|
||||
prompt: "Tell me about doing a GET request to /setup/twitch",
|
||||
prompt_tokens: 12
|
||||
});
|
||||
recordMetric({
|
||||
kind: "controller_decision",
|
||||
status: "success",
|
||||
request_id: workRequestId,
|
||||
user_id: "history-user",
|
||||
role: "admin",
|
||||
route_used: "main_llm",
|
||||
controller_intent: "explain_internal",
|
||||
controller_complexity: "normal",
|
||||
okf_retrieval_depth: "deep",
|
||||
controller_reason_code: "route_docs_okf",
|
||||
controller_confidence: 0.9
|
||||
});
|
||||
recordMetric({
|
||||
kind: "request",
|
||||
status: "success",
|
||||
request_id: workRequestId,
|
||||
user_id: "history-user",
|
||||
role: "admin",
|
||||
route_used: "main_llm",
|
||||
prompt_tokens: 100,
|
||||
generated_tokens: 40,
|
||||
duration_ms: 1200
|
||||
});
|
||||
recordMetric({
|
||||
kind: "delivery",
|
||||
status: "success",
|
||||
request_id: workRequestId,
|
||||
user_id: "history-user",
|
||||
role: "admin",
|
||||
origin: "webui",
|
||||
final_tokens: 40,
|
||||
delivered_tokens: 35,
|
||||
duration_ms: 1200
|
||||
});
|
||||
const workPage = workHistoryPage({ work_q: workRequestId }, 1, 5);
|
||||
assert.equal(workPage.entries.length, 1);
|
||||
assert.equal(workPage.entries[0].status, "success");
|
||||
assert.equal(workPage.entries[0].processed_tokens >= 152, true);
|
||||
assert.equal(workPage.entries[0].final_tokens, 40);
|
||||
assert.equal(workPage.entries[0].delivered_tokens, 35);
|
||||
assert(workPage.entries[0].events.some((event) => event.type === "prompt"));
|
||||
|
||||
const panelTemplate = require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs");
|
||||
const panelDiagnostic = new AssistantPanelDiagnostics(panelTemplate);
|
||||
@ -125,14 +186,47 @@ async function run() {
|
||||
for (const group of ["Platform commands", "Rate limits", "User AI access", "Assistant identity and scope"]) {
|
||||
assert(settingsTemplate.includes(group));
|
||||
}
|
||||
for (const control of [
|
||||
"Controller source profiles",
|
||||
"ai-work-history-table",
|
||||
"ai-work-detail-row",
|
||||
"data-ai-work-row",
|
||||
"work_history_retention_mode",
|
||||
"dependency archive",
|
||||
"runtimeDownloadSize",
|
||||
"source_profile_<%= source %>_target_chars",
|
||||
"source_profile_<%= source %>_hard_chars",
|
||||
"job.details.controller_complexity"
|
||||
]) {
|
||||
assert(settingsTemplate.includes(control));
|
||||
}
|
||||
for (const removedControl of [
|
||||
"output_budget_simple_answer",
|
||||
"internal_budget_normal",
|
||||
"API/test output token fallback",
|
||||
"Class / budget"
|
||||
]) {
|
||||
assert.equal(settingsTemplate.includes(removedControl), false);
|
||||
}
|
||||
assert(settingsTemplate.includes("Improvement Center"));
|
||||
assert(settingsTemplate.includes("work.has_truncation"));
|
||||
assert(settingsTemplate.includes("work.processed_tokens"));
|
||||
const improvementTemplate = fs.readFileSync(path.join(PLUGIN_ROOT, "views", "improvement-center.ejs"), "utf8");
|
||||
for (const control of ["Review queue", "Save Corrections", "Run all evals", "Export instruction JSONL", "Export DPO JSONL"]) {
|
||||
assert(improvementTemplate.includes(control));
|
||||
}
|
||||
assert(improvementTemplate.includes('data-confirm-title="Delete eval case"'));
|
||||
const assistantFeedbackScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "assistant.js"), "utf8");
|
||||
for (const tag of FEEDBACK_TAGS) assert(assistantFeedbackScript.includes(`"${tag}"`));
|
||||
assert(assistantFeedbackScript.includes("/assistant/feedback"));
|
||||
const settingsFrontendScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "settings.js"), "utf8");
|
||||
for (const marker of ["preparing_install", "installing", "Stopping runtimes", "Downloading dependency"]) {
|
||||
assert(settingsFrontendScript.includes(marker));
|
||||
}
|
||||
const pluginIndexSource = fs.readFileSync(path.join(PLUGIN_ROOT, "index.js"), "utf8");
|
||||
for (const marker of ["runtimeInstallInProgress", "beforeInstall", "afterInstall", "runtime_reinstall"]) {
|
||||
assert(pluginIndexSource.includes(marker));
|
||||
}
|
||||
|
||||
const improvementConfig = {
|
||||
improvement: {
|
||||
@ -259,8 +353,16 @@ async function run() {
|
||||
compute_api: ["cuda", "vulkan"]
|
||||
};
|
||||
const selectedRuntime = selectRuntimeTarget(runtimeManifest, testGpu, "win32", "x64");
|
||||
assert.equal(selectedRuntime.backend, "vulkan");
|
||||
assert.equal(selectedRuntime.backend, "cuda");
|
||||
assert.equal(selectedRuntime.accelerated, true);
|
||||
assert.equal(selectedRuntime.target.backend_variant, "cuda-12.4");
|
||||
assert.equal(selectedRuntime.target.dependencies.length, 1);
|
||||
assert.equal(selectedRuntime.available_backends.includes("vulkan"), true);
|
||||
const reversedGpuApiRuntime = selectRuntimeTarget(runtimeManifest, {
|
||||
...testGpu,
|
||||
compute_api: ["vulkan", "cuda"]
|
||||
}, "win32", "x64");
|
||||
assert.equal(reversedGpuApiRuntime.backend, "cuda");
|
||||
const cpuRuntime = selectRuntimeTarget(runtimeManifest, { present: false, compute_api: [] }, "win32", "x64");
|
||||
assert.equal(cpuRuntime.backend, "cpu");
|
||||
assert.equal(cpuRuntime.target.filename, "llama-b9592-bin-win-cpu-x64.zip");
|
||||
@ -440,7 +542,16 @@ async function run() {
|
||||
assert.equal(twitchOrigin.role, "mod");
|
||||
assert.equal(twitchOrigin.channel_id, "#channel");
|
||||
assert.equal(twitchOrigin.max_message_length, 450);
|
||||
assert.equal(PLATFORM_DEFAULTS.webui.max, null);
|
||||
assert.equal(formatPlatformReply("**Hello** <b>world</b>", [], twitchOrigin), "Hello world");
|
||||
const compactPlatformReply = formatPlatformReplyDetails(
|
||||
`${"A complete sentence. ".repeat(30)}Final sentence.`,
|
||||
[],
|
||||
twitchOrigin
|
||||
);
|
||||
assert(compactPlatformReply.delivered_length <= twitchOrigin.max_message_length);
|
||||
assert.equal(compactPlatformReply.delivery_action, "compacted");
|
||||
assert(compactPlatformReply.text.endsWith("[Reply shortened for platform limits.]"));
|
||||
const normalizedReply = formatAssistantResponse({
|
||||
text: 'Open GET /settings/admin or [Twitch wizard](/admin/twitch-wizard). <a href="javascript:alert(1)">Unsafe</a>',
|
||||
links: [],
|
||||
@ -469,6 +580,7 @@ async function run() {
|
||||
role: "admin"
|
||||
});
|
||||
assert.equal(styleOnlyLength.delivered_length, 5000);
|
||||
assert.equal(styleOnlyLength.delivery_action, "none");
|
||||
const punctuatedRouteReply = formatAssistantResponse({
|
||||
text: "Open GET /admin/twitch-wizard.",
|
||||
baseUrl: "https://lumi.example",
|
||||
@ -484,7 +596,9 @@ async function run() {
|
||||
maxLength: 4000
|
||||
});
|
||||
assert.equal(truncatedReply.original_final_length, 5000);
|
||||
assert.equal(truncatedReply.delivered_length, 4000);
|
||||
assert(truncatedReply.delivered_length <= 4000);
|
||||
assert.equal(truncatedReply.delivery_action, "compacted");
|
||||
assert(truncatedReply.text.endsWith("[Reply shortened for platform limits.]"));
|
||||
const codeReply = formatAssistantResponse({
|
||||
text: "Example:\n```javascript\nfunction run(ctx) {\n return 'ok';\n}\n```",
|
||||
verifiedRoutes: [],
|
||||
@ -866,6 +980,29 @@ async function run() {
|
||||
assert.equal(sensitiveGate.route, "main_llm");
|
||||
assert.equal(sensitiveGate.reason_code, "sensitive_or_user_specific");
|
||||
assert.equal(gateCacheReads.includes("Delete this user's economy balance"), false);
|
||||
let simpleLookupGateCalls = 0;
|
||||
const simpleLookupGate = new GateProvider({
|
||||
getConfig: () => gateConfig,
|
||||
runtime: {
|
||||
status: () => ({ state: "stopped" }),
|
||||
infer: async () => { simpleLookupGateCalls += 1; }
|
||||
},
|
||||
lookupRepo: () => null,
|
||||
cache: { get: () => null, set: () => null },
|
||||
metrics: { record: (entry) => gateAudit.push(entry) }
|
||||
});
|
||||
const simpleLookupDecision = await simpleLookupGate.route({
|
||||
message: "Who is OokamiKunTV?",
|
||||
user: { id: "gate-user-identity" },
|
||||
role: "admin",
|
||||
scope: "assistant",
|
||||
originContext: { platform: "webui" }
|
||||
});
|
||||
assert.equal(simpleLookupDecision.route, "main_llm");
|
||||
assert.equal(simpleLookupDecision.reason_code, "simple_knowledge_lookup");
|
||||
assert.equal(simpleLookupDecision.request_class, "simple_answer");
|
||||
assert.equal(simpleLookupDecision.gate_error, null);
|
||||
assert.equal(simpleLookupGateCalls, 0);
|
||||
const uncertainGate = new GateProvider({
|
||||
getConfig: () => gateConfig,
|
||||
runtime: {
|
||||
@ -951,10 +1088,82 @@ async function run() {
|
||||
assert(similarity("where are twitch settings", "where can I find twitch settings") >= 0.8);
|
||||
assert.equal(stripForcePrefix("force ai: hello", "force ai:").forced, true);
|
||||
assert.equal(isSensitiveRequest("Show my balance"), true);
|
||||
assert.equal(isSimpleKnowledgeLookup("Who is OokamiKunTV?"), true);
|
||||
assert.equal(isSimpleKnowledgeLookup("who are you?"), false);
|
||||
assert.equal(classifyRequestType("Where are Twitch settings?"), "navigation_help");
|
||||
assert.equal(classifyRequestType("explain the webroute /setup/twitch for me"), "navigation_help");
|
||||
assert.equal(classifyRequestType("Tell me about doing a GET request to /setup/twitch"), "navigation_help");
|
||||
assert.equal(classifyRequestType("Write a Lumi custom command in JavaScript"), "code_custom_command");
|
||||
assert.equal(classifyRequestType("Please provide a detailed long explanation"), "explicit_long");
|
||||
assert.equal(classifyRequestType("Debug the runtime metrics", { role: "admin" }), "admin_debug");
|
||||
const routeController = buildControllerDecision({
|
||||
message: "Tell me about doing a GET request to /setup/twitch",
|
||||
role: "admin",
|
||||
originContext: { origin: "webui" },
|
||||
requestClass: "simple_answer",
|
||||
gateDecision: { route: "main_llm", confidence: 0.91, reason_code: "route_docs_okf" }
|
||||
});
|
||||
assert.equal(routeController.schema, "lumi.ai.controller.v1");
|
||||
assert.equal(routeController.intent, "explain_internal");
|
||||
assert.equal(routeController.complexity, "normal");
|
||||
assert.equal(routeController.okf_retrieval, "deep");
|
||||
assert.equal(okfLimitForController(routeController) >= 4, true);
|
||||
const identityController = buildControllerDecision({
|
||||
message: "Who is OokamiKunTV?",
|
||||
role: "admin",
|
||||
originContext: { origin: "webui" },
|
||||
requestClass: "simple_answer",
|
||||
gateDecision: simpleLookupDecision
|
||||
});
|
||||
assert.equal(identityController.intent, "factual");
|
||||
assert.equal(identityController.complexity, "fast");
|
||||
assert.equal(identityController.okf_retrieval, "light");
|
||||
assert.equal(identityController.reason_code, "simple_knowledge_okf");
|
||||
assert.equal(okfLimitForController(identityController), 2);
|
||||
const fallbackController = buildControllerDecision({
|
||||
message: "hi",
|
||||
role: "user",
|
||||
originContext: { origin: "twitch" },
|
||||
requestClass: "simple_answer",
|
||||
gateDecision: { route: "main_llm", confidence: 0.1, reason_code: "gate_timeout_escalated" }
|
||||
});
|
||||
assert.equal(fallbackController.complexity, "normal");
|
||||
assert.equal(fallbackController.fallback_used, true);
|
||||
assert.equal(fallbackController.source_profile.source, "twitch");
|
||||
assert.equal(fallbackController.source_profile.hard_chars, 450);
|
||||
const codexController = buildControllerDecision({
|
||||
message: "Export this feedback to a Codex taskfile with acceptance criteria",
|
||||
role: "admin",
|
||||
originContext: { origin: "webui" },
|
||||
requestClass: "simple_answer"
|
||||
});
|
||||
assert.equal(codexController.intent, "feedback_export");
|
||||
assert.equal(codexController.complexity, "expanded");
|
||||
assert.equal(codexController.answer_style, "json_only");
|
||||
const normalizedInvalidController = normalizeControllerDecision({
|
||||
route: "unsafe_route",
|
||||
intent: "surprise",
|
||||
complexity: "tiny",
|
||||
okf_retrieval: "everything",
|
||||
answer_style: "essay",
|
||||
confidence: 42,
|
||||
reason_code: "bad reason!",
|
||||
source_profile: {
|
||||
source: "unknown-platform",
|
||||
hard_chars: 1,
|
||||
target_chars: 2
|
||||
}
|
||||
});
|
||||
assert.equal(normalizedInvalidController.route, "main_llm");
|
||||
assert.equal(normalizedInvalidController.intent, "unknown");
|
||||
assert.equal(normalizedInvalidController.complexity, "normal");
|
||||
assert.equal(normalizedInvalidController.okf_retrieval, "light");
|
||||
assert.equal(normalizedInvalidController.answer_style, "normal");
|
||||
assert.equal(normalizedInvalidController.confidence, 1);
|
||||
assert.equal(normalizedInvalidController.reason_code, "controller_normalized");
|
||||
assert.equal(normalizedInvalidController.source_profile.source, "other");
|
||||
assert.equal(normalizedInvalidController.source_profile.hard_chars, 100);
|
||||
assert.equal(normalizedInvalidController.source_profile.target_chars, 100);
|
||||
const outputBudgets = {
|
||||
navigation_help: 256,
|
||||
simple_answer: 512,
|
||||
@ -965,15 +1174,33 @@ async function run() {
|
||||
assert.equal(resolveOutputBudget({
|
||||
config: { output_budgets: outputBudgets },
|
||||
requestClass: classifyRequestType("Where is the settings page?")
|
||||
}), 256);
|
||||
}), 1024);
|
||||
assert.equal(resolveOutputBudget({
|
||||
config: { output_budgets: outputBudgets },
|
||||
requestClass: classifyRequestType("Write a custom command")
|
||||
}), 896);
|
||||
}), 4096);
|
||||
assert.equal(resolveOutputBudget({
|
||||
config: { output_budgets: outputBudgets },
|
||||
requestClass: classifyRequestType("Please provide a comprehensive long guide")
|
||||
}), 2048);
|
||||
}), 8192);
|
||||
assert.equal(outputBudgetForController({
|
||||
config: {
|
||||
output_budgets: outputBudgets,
|
||||
internal_mode_budgets: { fast: 512, normal: 1024, expanded: 2048, unlimited: 4096 }
|
||||
},
|
||||
requestClass: "simple_answer",
|
||||
controllerDecision: routeController
|
||||
}), 4096);
|
||||
assert.equal(resolveOutputBudget({
|
||||
config: {
|
||||
output_budgets: outputBudgets,
|
||||
internal_mode_budgets: { fast: 512, normal: 1024, expanded: 2048, unlimited: 4096 }
|
||||
},
|
||||
requestClass: "simple_answer",
|
||||
controllerDecision: codexController
|
||||
}), 8192);
|
||||
assert.equal(DEFAULT_CONFIG.internal_mode_budgets.normal, 4096);
|
||||
assert.equal(DEFAULT_CONFIG.source_profiles.twitch.hard_chars, 450);
|
||||
|
||||
const combined = combinedResourceEstimate({
|
||||
main: { estimated_cpu_memory_mb: 4000, estimated_gpu_memory_mb: 2000 },
|
||||
@ -986,6 +1213,15 @@ async function run() {
|
||||
|
||||
const stageAudit = [];
|
||||
const stages = [];
|
||||
const diagnosticContextProvider = () => [];
|
||||
diagnosticContextProvider.lastDiagnostics = [{
|
||||
provider: "okf",
|
||||
query: "Need help",
|
||||
candidate_count: 0,
|
||||
returned_count: 0,
|
||||
limit: 2,
|
||||
okf_retrieval: "light"
|
||||
}];
|
||||
const timingProvider = new AiProvider({
|
||||
getConfig: () => ({
|
||||
selected_model_id: "test",
|
||||
@ -993,7 +1229,8 @@ async function run() {
|
||||
instructions: {},
|
||||
logging: {},
|
||||
internal_generation_char_budget: 2000,
|
||||
output_budgets: outputBudgets
|
||||
output_budgets: outputBudgets,
|
||||
internal_mode_budgets: { fast: 512, normal: 1024, expanded: 2048, unlimited: 4096 }
|
||||
}),
|
||||
runtime: {
|
||||
infer: async () => ({
|
||||
@ -1023,7 +1260,7 @@ async function run() {
|
||||
})),
|
||||
tools: registry,
|
||||
metrics: { record: (entry) => stageAudit.push(entry) },
|
||||
getContext: () => []
|
||||
getContext: diagnosticContextProvider
|
||||
});
|
||||
const timedResult = await timingProvider.generate({
|
||||
message: "Need help",
|
||||
@ -1035,11 +1272,14 @@ async function run() {
|
||||
assert.equal(timedResult.stage_timings.deterministic_ms, 2);
|
||||
assert.equal(timedResult.stage_timings.gate_ms, 3);
|
||||
assert.equal(timedResult.route_class, "simple_answer");
|
||||
assert.equal(timedResult.max_output_tokens_used, 512);
|
||||
assert.equal(timedResult.controller_decision.complexity, "normal");
|
||||
assert.equal(timedResult.controller_decision.fallback_used, true);
|
||||
assert.equal(timedResult.max_output_tokens_used, 4096);
|
||||
assert(Number.isFinite(timedResult.stage_timings.main_queue_ms));
|
||||
assert(Number.isFinite(timedResult.stage_timings.main_generate_ms));
|
||||
assert(Number.isFinite(timedResult.stage_timings.total_ms));
|
||||
assert(stages.includes("queued"));
|
||||
assert(stages.includes("controller"));
|
||||
assert(stages.includes("prompt_eval"));
|
||||
assert(stages.includes("generating"));
|
||||
assert(stageAudit.some((entry) =>
|
||||
@ -1050,12 +1290,20 @@ async function run() {
|
||||
assert(stageAudit.some((entry) =>
|
||||
entry.kind === "request" &&
|
||||
entry.route_class === "simple_answer" &&
|
||||
entry.max_output_tokens_used === 512 &&
|
||||
entry.controller_complexity === "normal" &&
|
||||
entry.max_output_tokens_used === 4096 &&
|
||||
entry.prompt_tokens === 80 &&
|
||||
entry.generated_tokens === 20 &&
|
||||
entry.prompt_tps === 200 &&
|
||||
entry.generation_tps === 20
|
||||
));
|
||||
assert(stageAudit.some((entry) =>
|
||||
entry.kind === "okf_retrieval" &&
|
||||
entry.okf_query === "Need help" &&
|
||||
entry.okf_candidate_count === 0 &&
|
||||
entry.fallback_reason === "gate_fallback_normal" &&
|
||||
entry.gate_reason_code === "low_confidence"
|
||||
));
|
||||
|
||||
const requestJobs = new AssistantRequestJobs({ ttlMs: 60000, maxJobs: 5 });
|
||||
const job = requestJobs.create({
|
||||
@ -1173,7 +1421,7 @@ async function run() {
|
||||
assert(assembledPrompt.includes("Discord community server"));
|
||||
assert.equal(assembledMessages[1].content, "Earlier question");
|
||||
assert.equal(assembledMessages[2].content, "Earlier answer");
|
||||
assert.equal(generatedTokenBudget, 896);
|
||||
assert.equal(generatedTokenBudget, 4096);
|
||||
assert(assembledPrompt.includes("Request class: code_custom_command"));
|
||||
assert(assembledPrompt.includes("Put one complete runnable code block first."));
|
||||
const ambiguousProvider = new AiProvider({
|
||||
@ -1314,6 +1562,24 @@ async function run() {
|
||||
assert.match(policyPrompt, /Include advanced operational explanations/);
|
||||
for (const rule of HARD_RULES) assert(policyPrompt.includes(rule));
|
||||
assert(policyPrompt.indexOf("HARD SAFETY RULES") < policyPrompt.indexOf("ADMIN CUSTOM INSTRUCTIONS"));
|
||||
const compactPrompt = buildPrompt({
|
||||
config: {
|
||||
support_scope: normalizeScope(),
|
||||
instructions: { roleplay_intensity: 0 }
|
||||
},
|
||||
role: "admin",
|
||||
message: "Who is OokamiKunTV?",
|
||||
requestClass: "simple_answer",
|
||||
contextBlocks: [],
|
||||
correctionContext: [],
|
||||
repoContext: [],
|
||||
tools: [],
|
||||
controllerDecision: identityController
|
||||
});
|
||||
assert(compactPrompt.length < policyPrompt.length);
|
||||
assert.equal(compactPrompt.includes("CUSTOM COMMAND OUTPUT RULES"), false);
|
||||
assert.equal(compactPrompt.includes("SAFE LUMI CONTEXT:\n(none)"), false);
|
||||
assert.equal(compactPrompt.includes("ALLOWED TOOLS"), false);
|
||||
|
||||
const indexPath = resolveData("repo_index", "index.json");
|
||||
const originalIndex = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, "utf8") : null;
|
||||
@ -1377,10 +1643,15 @@ async function run() {
|
||||
const assistantStyles = fs.readFileSync(require("path").join(PLUGIN_ROOT, "public", "assistant.css"), "utf8");
|
||||
const assistantPanel = fs.readFileSync(require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs"), "utf8");
|
||||
const pluginIndex = fs.readFileSync(require("path").join(PLUGIN_ROOT, "index.js"), "utf8");
|
||||
const globalAssistantPanels = fs.readFileSync(path.join(PLUGIN_ROOT, "..", "..", "src", "web", "public", "assistant-panels.js"), "utf8");
|
||||
const globalStyles = fs.readFileSync(path.join(PLUGIN_ROOT, "..", "..", "src", "web", "public", "styles.css"), "utf8");
|
||||
assert(accessSettingsTemplate.includes("data-user-lookup"));
|
||||
assert(accessSettingsTemplate.includes("data-user-lookup-search"));
|
||||
assert(accessSettingsTemplate.includes("data-timeout-field hidden"));
|
||||
assert(settingsScript.includes('action.value === "timeout"'));
|
||||
assert(settingsScript.includes("[data-work-retention-mode]"));
|
||||
assert(settingsScript.includes("[data-ai-work-row]"));
|
||||
assert(settingsScript.includes('button.setAttribute("aria-expanded", String(open))'));
|
||||
assert(assistantScript.includes("window.innerHeight / 6"));
|
||||
assert(assistantScript.includes("window.localStorage"));
|
||||
assert(assistantScript.includes("copyText(codeValue)"));
|
||||
@ -1388,6 +1659,8 @@ async function run() {
|
||||
assert(assistantScript.includes('overlayRoot.className = "lumi-ai-overlay-root"'));
|
||||
assert(assistantScript.includes("Queued for Lumi Assistant"));
|
||||
assert(assistantScript.includes("Lumi Assistant is processing"));
|
||||
assert(globalAssistantPanels.includes("has-assistant-panel"));
|
||||
assert(globalStyles.includes("body.has-assistant-panel .feedback-floating-button"));
|
||||
assert(assistantScript.includes("lumi-ai-retry"));
|
||||
assert(assistantScript.includes("beginCooldown(error.retryAfterSeconds)"));
|
||||
assert(assistantScript.includes("Retry available in ${seconds}s"));
|
||||
@ -1402,7 +1675,7 @@ async function run() {
|
||||
assert(liveTimeoutControls);
|
||||
assert.equal(liveTimeoutControls[1].includes('"Retry"'), false);
|
||||
assert(assistantScript.includes('addError("Assistant request was cancelled."'));
|
||||
assert(assistantScript.includes("budget ${budget} tokens"));
|
||||
assert(assistantScript.includes("controller_complexity"));
|
||||
assert(assistantScript.includes("soft_timeout_url"));
|
||||
assert(assistantScript.includes("job.still_running"));
|
||||
assert(assistantStyles.includes(".lumi-ai-timeout-controls"));
|
||||
@ -1415,8 +1688,8 @@ async function run() {
|
||||
assert(accessSettingsTemplate.includes("Managed model VRAM"));
|
||||
assert(accessSettingsTemplate.includes("External VRAM estimate"));
|
||||
assert(accessSettingsTemplate.includes("Current and recent assistant jobs"));
|
||||
assert(accessSettingsTemplate.includes("Code/custom command tokens"));
|
||||
assert(accessSettingsTemplate.includes("Explicit long-answer tokens"));
|
||||
assert(accessSettingsTemplate.includes("Lumi chooses internal processing depth automatically"));
|
||||
assert(accessSettingsTemplate.includes("Controller source profiles"));
|
||||
assert(accessSettingsTemplate.includes("Next slow requests"));
|
||||
assert(assistantScript.includes('messages.addEventListener("wheel"'));
|
||||
assert(assistantStyles.includes("z-index: 60"));
|
||||
|
||||
@ -197,7 +197,7 @@
|
||||
</details>
|
||||
<% } %>
|
||||
<div class="table-wrap"><table class="table"><thead><tr><th>Prompt</th><th>Role / origin</th><th>Expected</th><th>Forbidden</th><th>Expected link</th><th>Actions</th></tr></thead><tbody>
|
||||
<% evalCases.entries.forEach((entry) => { %><tr><td><%= entry.prompt %></td><td><%= entry.role %> / <%= entry.origin %></td><td><%= entry.expected_behavior || "-" %></td><td><%= entry.forbidden_behavior || "-" %></td><td><%= entry.expected_link || "-" %></td><td><% if (access.can_run_evals) { %><form method="post" action="/plugins/lumi_ai/improvement_center/evals/<%= entry.id %>/delete" data-improvement-confirm="Delete this eval case?"><button class="button danger" type="submit">Delete</button></form><% } %></td></tr><% }) %>
|
||||
<% evalCases.entries.forEach((entry) => { %><tr><td><%= entry.prompt %></td><td><%= entry.role %> / <%= entry.origin %></td><td><%= entry.expected_behavior || "-" %></td><td><%= entry.forbidden_behavior || "-" %></td><td><%= entry.expected_link || "-" %></td><td><% if (access.can_run_evals) { %><form method="post" action="/plugins/lumi_ai/improvement_center/evals/<%= entry.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete eval case" data-confirm-text="Delete this eval case?" data-confirm-label="Delete eval"><button class="button danger" type="submit">Delete</button></form><% } %></td></tr><% }) %>
|
||||
<% if (!evalCases.entries.length) { %><tr><td colspan="6">No eval cases.</td></tr><% } %>
|
||||
</tbody></table></div>
|
||||
<div class="table-pagination">
|
||||
|
||||
@ -11,6 +11,14 @@
|
||||
html += options.map((option) => `<option value="${option.value}" ${option.value === value ? "selected" : ""}>${option.label}</option>`).join("");
|
||||
return html;
|
||||
};
|
||||
const workPageHref = (page) => {
|
||||
const params = new URLSearchParams();
|
||||
["work_q", "work_status", "work_source", "work_role", "work_mode", "work_okf", "work_flag", "work_from", "work_to"].forEach((key) => {
|
||||
if (workFilters?.[key]) params.set(key, workFilters[key]);
|
||||
});
|
||||
params.set("work_page", String(page));
|
||||
return `?${params.toString()}#metrics`;
|
||||
};
|
||||
%>
|
||||
|
||||
<section class="ai-titlebar">
|
||||
@ -159,7 +167,7 @@
|
||||
<span>Platform</span><strong><%= hardware.platform %>-<%= hardware.architecture %></strong>
|
||||
<span>Self-test</span><strong><%= runtimeStatus.last_self_test?.success ? "Passed" : runtimeStatus.last_self_test ? "Failed" : "Not run" %></strong>
|
||||
<span>Runtime folder</span><strong><%= formatBytes(runtimeFolderSize) %></strong>
|
||||
<span>Runtime archive</span><strong><%= runtimeTarget ? formatBytes(runtimeTarget.size) : "Unavailable" %></strong>
|
||||
<span>Runtime download</span><strong><%= runtimeTarget ? formatBytes(runtimeDownloadSize) : "Unavailable" %></strong>
|
||||
<span>Model installed</span><strong><%= formatBytes(modelFileSize) %></strong>
|
||||
<span>Model download</span><strong><%= formatBytes(models.find((model) => model.id === config.selected_model_id)?.size || 0) %></strong>
|
||||
<span>Backend</span><strong><%= String(runtimeStatus.runtime_backend || "cpu").toUpperCase() %></strong>
|
||||
@ -181,8 +189,14 @@
|
||||
</p>
|
||||
<% if (gateStatus.last_error) { %><div class="callout"><%= gateStatus.last_error %></div><% } %>
|
||||
<% if (runtimeTarget) { %>
|
||||
<p><strong>Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p>
|
||||
<p class="hint"><%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %></p>
|
||||
<p><strong>Managed <%= String(runtimeTarget.backend_variant || runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p>
|
||||
<p class="hint">
|
||||
<%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %>
|
||||
<% if (runtimeTarget.dependencies?.length) { %>
|
||||
· plus <%= runtimeTarget.dependencies.length %> dependency archive<%= runtimeTarget.dependencies.length === 1 ? "" : "s" %>
|
||||
· total <%= formatBytes(runtimeDownloadSize) %>
|
||||
<% } %>
|
||||
</p>
|
||||
<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",
|
||||
@ -320,12 +334,7 @@
|
||||
<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><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 full"><span class="hint">Lumi chooses internal processing depth automatically from the request. Platform/source limits below only shape the final delivered reply.</span></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>
|
||||
@ -489,6 +498,44 @@
|
||||
<label><input type="checkbox" name="<%= key %>" <%= config.logging[key] ? "checked" : "" %> /> <%= label %></label>
|
||||
<% }) %>
|
||||
</fieldset>
|
||||
<fieldset class="field full ai-fieldset" data-work-retention>
|
||||
<legend>Work history retention</legend>
|
||||
<label>Retention mode
|
||||
<select name="work_history_retention_mode" data-work-retention-mode>
|
||||
<option value="count" <%= config.work_history_retention.mode === "count" ? "selected" : "" %>>By count</option>
|
||||
<option value="age" <%= config.work_history_retention.mode === "age" ? "selected" : "" %>>By age</option>
|
||||
</select>
|
||||
</label>
|
||||
<label data-work-retention-count>Keep latest
|
||||
<input type="number" min="50" max="10000" name="work_history_retention_count" value="<%= config.work_history_retention.count %>" />
|
||||
entries
|
||||
</label>
|
||||
<label data-work-retention-age>Keep entries for
|
||||
<input type="number" min="1" max="1000" name="work_history_retention_age_value" value="<%= config.work_history_retention.age_value %>" />
|
||||
<select name="work_history_retention_age_unit">
|
||||
<% ["hours","days","weeks","months","years"].forEach((unit) => { %>
|
||||
<option value="<%= unit %>" <%= config.work_history_retention.age_unit === unit ? "selected" : "" %>><%= unit %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<span class="hint">Cleanup removes grouped work entries and child events together from local metrics history.</span>
|
||||
</fieldset>
|
||||
<fieldset class="field full ai-fieldset">
|
||||
<legend>Controller source profiles</legend>
|
||||
<p class="hint">Source profiles shape delivered answers for each origin. Leave WebUI hard cap blank for no hard character cap.</p>
|
||||
<div class="ai-limit-grid">
|
||||
<% Object.entries(config.source_profiles).forEach(([source, profile]) => { %>
|
||||
<div>
|
||||
<strong><%= source.toUpperCase() %></strong>
|
||||
<label>Target chars <input type="number" min="100" max="12000" name="source_profile_<%= source %>_target_chars" value="<%= profile.target_chars %>" /></label>
|
||||
<label>Hard cap <input type="number" min="100" max="12000" name="source_profile_<%= source %>_hard_chars" value="<%= profile.hard_chars ?? '' %>" placeholder="<%= profile.hard_chars == null ? 'No hard cap' : '' %>" /></label>
|
||||
<label><input type="checkbox" name="source_profile_<%= source %>_allow_sections" <%= profile.allow_sections ? "checked" : "" %> /> Allow sections</label>
|
||||
<label><input type="checkbox" name="source_profile_<%= source %>_allow_long_answer" <%= profile.allow_long_answer ? "checked" : "" %> /> Allow long answers</label>
|
||||
<label><input type="checkbox" name="source_profile_<%= source %>_allow_split" <%= profile.allow_split ? "checked" : "" %> /> Allow split replies</label>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@ -586,17 +633,17 @@
|
||||
<details class="ai-settings-group">
|
||||
<summary>Current and recent assistant jobs</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table"><thead><tr><th>Created</th><th>State / stage</th><th>Class / budget</th><th>Elapsed</th><th>Gate</th><th>Queue</th><th>Prompt eval</th><th>Generation</th><th>Tokens</th><th>Speed</th><th>Runtime</th><th>UI timeout</th></tr></thead><tbody>
|
||||
<% jobDiagnostics.forEach((job) => { %><tr><td><%= formatDate(job.created_at) %></td><td><%= job.state %> / <%= job.stage %></td><td><%= job.details.route_class || "-" %> / <%= job.details.max_output_tokens_used || job.details.max_output_tokens || "-" %></td><td><%= formatDuration(job.elapsed_ms) %></td><td><%= formatDuration(job.details.gate_ms) %></td><td><%= formatDuration(job.details.queue_ms) %></td><td><%= formatDuration(job.details.prompt_eval_ms) %></td><td><%= formatDuration(job.details.generation_ms) %></td><td><%= job.details.prompt_tokens || 0 %> / <%= job.details.generated_tokens || 0 %></td><td><%= job.details.prompt_tps || 0 %> / <%= job.details.generation_tps || 0 %> tok/s</td><td><%= job.details.backend || "-" %>, <%= job.details.gpu_layers || 0 %> layers, ctx <%= job.details.context_size || "-" %></td><td><%= job.frontend_soft_timeout_at ? (job.still_running ? "Still running" : "Recorded") : "No" %></td></tr><% }) %>
|
||||
<% if (!jobDiagnostics.length) { %><tr><td colspan="12">No assistant jobs recorded since this plugin process started.</td></tr><% } %>
|
||||
<table class="table"><thead><tr><th>Created</th><th>State / stage</th><th>Route class</th><th>Controller</th><th>Elapsed</th><th>Gate</th><th>Queue</th><th>Prompt eval</th><th>Generation</th><th>Tokens</th><th>Speed</th><th>Runtime</th><th>UI timeout</th></tr></thead><tbody>
|
||||
<% jobDiagnostics.forEach((job) => { %><tr><td><%= formatDate(job.created_at) %></td><td><%= job.state %> / <%= job.stage %></td><td><%= job.details.route_class || "-" %></td><td><%= job.details.controller_complexity || "-" %> / <%= job.details.okf_retrieval_depth || "-" %><br /><span class="hint"><%= job.details.controller_reason_code || job.details.controller_intent || "-" %></span></td><td><%= formatDuration(job.elapsed_ms) %></td><td><%= formatDuration(job.details.gate_ms) %></td><td><%= formatDuration(job.details.queue_ms) %></td><td><%= formatDuration(job.details.prompt_eval_ms) %></td><td><%= formatDuration(job.details.generation_ms) %></td><td><%= job.details.prompt_tokens || 0 %> / <%= job.details.generated_tokens || 0 %></td><td><%= job.details.prompt_tps || 0 %> / <%= job.details.generation_tps || 0 %> tok/s</td><td><%= job.details.backend || "-" %>, <%= job.details.gpu_layers || 0 %> layers, ctx <%= job.details.context_size || "-" %></td><td><%= job.frontend_soft_timeout_at ? (job.still_running ? "Still running" : "Recorded") : "No" %></td></tr><% }) %>
|
||||
<% if (!jobDiagnostics.length) { %><tr><td colspan="13">No assistant jobs recorded since this plugin process started.</td></tr><% } %>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</details>
|
||||
<details class="ai-settings-group">
|
||||
<summary>Recent slow and 504-risk requests</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table"><thead><tr><th>Time</th><th>Route / class</th><th>Reason / budget</th><th>Gate</th><th>Queue</th><th>Prompt eval</th><th>Generation</th><th>Tokens</th><th>Speed</th><th>Total</th><th>Risk</th></tr></thead><tbody>
|
||||
<% slowRequestsPage.entries.forEach((entry) => { %><tr><td><%= entry.timestamp %></td><td><%= entry.route_used || "-" %> / <%= entry.route_class || "-" %></td><td><%= entry.reason_code || "-" %> / max <%= entry.max_output_tokens_used || "-" %></td><td><%= formatDuration(entry.gate_ms) %></td><td><%= formatDuration(entry.queue_ms) %></td><td><%= formatDuration(entry.prompt_eval_ms) %></td><td><%= formatDuration(entry.generation_ms) %></td><td><%= entry.prompt_tokens || 0 %> / <%= entry.generated_tokens || 0 %></td><td><%= entry.prompt_tps || 0 %> / <%= entry.generation_tps || 0 %> tok/s</td><td><%= formatDuration(entry.total_ms) %></td><td><%= entry.frontend_soft_timeout ? "UI waited" : entry.risk_504 ? "504 risk" : "Slow" %></td></tr><% }) %>
|
||||
<table class="table"><thead><tr><th>Time</th><th>Route / class</th><th>Controller</th><th>Gate</th><th>Queue</th><th>Prompt eval</th><th>Generation</th><th>Tokens</th><th>Speed</th><th>Total</th><th>Risk</th></tr></thead><tbody>
|
||||
<% slowRequestsPage.entries.forEach((entry) => { %><tr><td><%= entry.timestamp %></td><td><%= entry.route_used || "-" %> / <%= entry.route_class || "-" %></td><td><%= entry.controller_complexity || "-" %> / <%= entry.okf_retrieval_depth || "-" %><br /><span class="hint"><%= entry.controller_reason_code || entry.reason_code || "-" %></span></td><td><%= formatDuration(entry.gate_ms) %></td><td><%= formatDuration(entry.queue_ms) %></td><td><%= formatDuration(entry.prompt_eval_ms) %></td><td><%= formatDuration(entry.generation_ms) %></td><td><%= entry.prompt_tokens || 0 %> / <%= entry.generated_tokens || 0 %></td><td><%= entry.prompt_tps || 0 %> / <%= entry.generation_tps || 0 %> tok/s</td><td><%= formatDuration(entry.total_ms) %></td><td><%= entry.frontend_soft_timeout ? "UI waited" : entry.risk_504 ? "504 risk" : "Slow" %></td></tr><% }) %>
|
||||
<% if (!slowRequestsPage.entries.length) { %><tr><td colspan="11">No requests over 30 seconds.</td></tr><% } %>
|
||||
</tbody></table>
|
||||
</div>
|
||||
@ -606,16 +653,109 @@
|
||||
<a class="button subtle <%= slowRequestsPage.page >= slowRequestsPage.pages ? 'disabled' : '' %>" href="?slow_page=<%= Math.min(slowRequestsPage.pages, slowRequestsPage.page + 1) %>#metrics">Next slow requests</a>
|
||||
</div>
|
||||
</details>
|
||||
<form class="log-controls ai-work-filters" method="get" action="#metrics">
|
||||
<label>Search <input name="work_q" value="<%= workFilters.work_q || "" %>" placeholder="Prompt, reason, user, event" /></label>
|
||||
<label>Status
|
||||
<select name="work_status">
|
||||
<option value="">Any</option>
|
||||
<% ["success","partial","failed"].forEach((value) => { %><option value="<%= value %>" <%= workFilters.work_status === value ? "selected" : "" %>><%= value %></option><% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>Source
|
||||
<select name="work_source">
|
||||
<option value="">Any</option>
|
||||
<% ["webui","discord","twitch","youtube","kick","other"].forEach((value) => { %><option value="<%= value %>" <%= workFilters.work_source === value ? "selected" : "" %>><%= value %></option><% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>Role
|
||||
<select name="work_role">
|
||||
<option value="">Any</option>
|
||||
<% ["admin","mod","user"].forEach((value) => { %><option value="<%= value %>" <%= workFilters.work_role === value ? "selected" : "" %>><%= value %></option><% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>Mode
|
||||
<select name="work_mode">
|
||||
<option value="">Any</option>
|
||||
<% ["fast","normal","expanded","unlimited"].forEach((value) => { %><option value="<%= value %>" <%= workFilters.work_mode === value ? "selected" : "" %>><%= value %></option><% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>OKF
|
||||
<select name="work_okf">
|
||||
<option value="">Any</option>
|
||||
<% ["none","light","deep"].forEach((value) => { %><option value="<%= value %>" <%= workFilters.work_okf === value ? "selected" : "" %>><%= value %></option><% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>Flag
|
||||
<select name="work_flag">
|
||||
<option value="">Any</option>
|
||||
<% [["error","Has error"],["refusal","Has refusal"],["fallback","Has fallback"],["truncation","Has truncation"],["okf","Has OKF context"]].forEach(([value,label]) => { %><option value="<%= value %>" <%= workFilters.work_flag === value ? "selected" : "" %>><%= label %></option><% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>From <input type="date" name="work_from" value="<%= workFilters.work_from || "" %>" /></label>
|
||||
<label>To <input type="date" name="work_to" value="<%= workFilters.work_to || "" %>" /></label>
|
||||
<button class="button subtle" type="submit">Filter</button>
|
||||
<a class="button subtle" href="/plugins/lumi_ai#metrics">Reset</a>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table class="table"><thead><tr><th>Time</th><th>Kind</th><th>Status</th><th>Route</th><th>Confidence / reason</th><th>Role</th><th>Generated / final / delivered</th><th>Duration</th></tr></thead><tbody>
|
||||
<% history.forEach((entry) => { %><tr><td><%= entry.timestamp %></td><td><%= entry.kind %></td><td><%= entry.status %></td><td><%= entry.route_used || "-" %></td><td><%= entry.confidence ?? entry.gate_confidence ?? "-" %> / <%= entry.reason_code || entry.gate_reason_code || "-" %></td><td><%= entry.role || "-" %></td><td><%= entry.internal_generated_length ?? "-" %> / <%= entry.final_reply_length ?? entry.original_final_length ?? "-" %> / <%= entry.delivered_length ?? "-" %></td><td><%= formatDuration(entry.duration_ms) %></td></tr><% }) %>
|
||||
<% if (!history.length) { %><tr><td colspan="8">No requests recorded.</td></tr><% } %>
|
||||
<table class="table ai-work-history-table"><thead><tr><th>Time</th><th>Status</th><th>Source</th><th>Prompt and lifecycle</th><th>Processed / final / delivered</th><th>Duration</th><th>Flags</th></tr></thead><tbody>
|
||||
<% workHistoryPage.entries.forEach((work, workIndex) => { %>
|
||||
<% const workDetailId = `ai-work-detail-${workIndex}`; %>
|
||||
<tr class="ai-work-summary-row" data-ai-work-row data-ai-work-detail="<%= workDetailId %>">
|
||||
<td><%= formatDate(work.started_at) %></td>
|
||||
<td><span class="ai-tag <%= work.status === 'success' ? 'installed' : work.status === 'failed' ? 'warning' : '' %>"><%= work.status %></span></td>
|
||||
<td><%= work.source %><br /><span class="hint"><%= work.role || "unknown" %> · <%= work.internal_mode || "-" %>/<%= work.okf_retrieval || "-" %></span></td>
|
||||
<td>
|
||||
<button class="ai-work-expand-button" type="button" aria-expanded="false" aria-controls="<%= workDetailId %>">
|
||||
<span class="ai-work-expand-icon" aria-hidden="true"></span>
|
||||
<span><%= work.prompt ? work.prompt.slice(0, 160) : "Prompt was not recorded for this legacy entry." %></span>
|
||||
</button>
|
||||
</td>
|
||||
<td><%= work.processed_tokens %> / <%= work.final_tokens %> / <%= work.delivered_tokens %></td>
|
||||
<td><%= formatDuration(work.duration_ms) %></td>
|
||||
<td class="ai-work-flags">
|
||||
<% if (work.has_error) { %><span class="ai-tag warning">error</span><% } %>
|
||||
<% if (work.has_refusal) { %><span class="ai-tag warning">refusal</span><% } %>
|
||||
<% if (work.has_fallback) { %><span class="ai-tag">fallback</span><% } %>
|
||||
<% if (work.has_truncation) { %><span class="ai-tag warning">truncated</span><% } %>
|
||||
<% if (work.has_okf_context) { %><span class="ai-tag installed">okf</span><% } %>
|
||||
<% if (!work.has_error && !work.has_refusal && !work.has_fallback && !work.has_truncation && !work.has_okf_context) { %>-<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="ai-work-detail-row" id="<%= workDetailId %>" hidden>
|
||||
<td colspan="7">
|
||||
<div class="ai-work-detail-content">
|
||||
<div class="ai-work-prompt">
|
||||
<strong>Original prompt</strong>
|
||||
<pre><%= work.prompt || "Prompt was not recorded for this legacy entry." %></pre>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table ai-work-events">
|
||||
<thead><tr><th>Time</th><th>Event</th><th>Status</th><th>Summary</th><th>Tokens</th><th>Diagnostics</th></tr></thead>
|
||||
<tbody>
|
||||
<% work.events.forEach((event) => { %>
|
||||
<tr>
|
||||
<td><%= formatDate(event.timestamp) %></td>
|
||||
<td><%= event.type %></td>
|
||||
<td><%= event.status || "-" %></td>
|
||||
<td><%= event.summary %></td>
|
||||
<td><%= event.tokens || "-" %></td>
|
||||
<td><pre><%= JSON.stringify(event.data, null, 2) %></pre></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
<% if (!workHistoryPage.entries.length) { %><tr><td colspan="7">No grouped work history entries match the current filters.</td></tr><% } %>
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="table-pagination">
|
||||
<a class="button subtle <%= metricsPage.page <= 1 ? 'disabled' : '' %>" href="?metrics_page=<%= Math.max(1, metricsPage.page - 1) %>#metrics">Previous</a>
|
||||
<span class="table-page-label">Page <%= metricsPage.page %> of <%= metricsPage.pages %> (<%= metricsPage.total %> entries)</span>
|
||||
<a class="button subtle <%= metricsPage.page >= metricsPage.pages ? 'disabled' : '' %>" href="?metrics_page=<%= Math.min(metricsPage.pages, metricsPage.page + 1) %>#metrics">Next</a>
|
||||
<a class="button subtle <%= workHistoryPage.page <= 1 ? 'disabled' : '' %>" href="<%= workPageHref(Math.max(1, workHistoryPage.page - 1)) %>">Previous work entries</a>
|
||||
<span class="table-page-label">Page <%= workHistoryPage.page %> of <%= workHistoryPage.pages %> (<%= workHistoryPage.total %> work entries)</span>
|
||||
<a class="button subtle <%= workHistoryPage.page >= workHistoryPage.pages ? 'disabled' : '' %>" href="<%= workPageHref(Math.min(workHistoryPage.pages, workHistoryPage.page + 1)) %>">Next work entries</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
674
plugins/okf/backend/file_knowledge.js
Normal file
674
plugins/okf/backend/file_knowledge.js
Normal file
@ -0,0 +1,674 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const KNOWLEDGE_SCOPES = Object.freeze(["corrections", "community", "plugins", "core"]);
|
||||
const SCOPE_PRIORITY = Object.freeze({
|
||||
corrections: 400,
|
||||
community: 300,
|
||||
plugins: 200,
|
||||
core: 100
|
||||
});
|
||||
const ACTIVE_STATUSES = new Set(["active", "published", "approved"]);
|
||||
const HIDDEN_STATUSES = new Set(["archived", "deleted", "disabled", "draft"]);
|
||||
const VISIBILITY_VALUES = new Set(["user", "mod", "admin"]);
|
||||
const PLACEHOLDER_SUGGEST_RESERVED_KEYS = new Set([
|
||||
"id",
|
||||
"title",
|
||||
"scope",
|
||||
"status",
|
||||
"priority",
|
||||
"visibility",
|
||||
"category",
|
||||
"tags",
|
||||
"generated",
|
||||
"editable",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
]);
|
||||
const SEARCH_STOPWORDS = new Set([
|
||||
"a", "an", "and", "are", "about", "describe", "for", "identify", "is", "me",
|
||||
"of", "please", "tell", "the", "to", "what", "who", "was", "were"
|
||||
]);
|
||||
const knowledgeIndexCache = new Map();
|
||||
|
||||
function knowledgeRoot(rootDir = process.cwd()) {
|
||||
return path.join(rootDir, "knowledge");
|
||||
}
|
||||
|
||||
function ensureKnowledgeDirs(rootDir = process.cwd()) {
|
||||
const root = knowledgeRoot(rootDir);
|
||||
for (const scope of KNOWLEDGE_SCOPES) {
|
||||
fs.mkdirSync(path.join(root, scope), { recursive: true });
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function migrateSingleBracePlaceholders(rootDir = process.cwd()) {
|
||||
const root = ensureKnowledgeDirs(rootDir);
|
||||
let changed = 0;
|
||||
for (const filePath of listKnowledgeFiles(rootDir)) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const next = content.replace(/(^|[^\{])\{([A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)\}(?!\})/g, (match, prefix, key) => {
|
||||
return `${prefix}{{${key}}}`;
|
||||
});
|
||||
if (next !== content) {
|
||||
fs.writeFileSync(filePath, next);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
if (changed) knowledgeIndexCache.clear();
|
||||
return { root, changed };
|
||||
}
|
||||
|
||||
function listKnowledgeFiles(rootDir = process.cwd()) {
|
||||
const root = knowledgeRoot(rootDir);
|
||||
const files = [];
|
||||
for (const scope of KNOWLEDGE_SCOPES) {
|
||||
const scopeRoot = path.join(root, scope);
|
||||
if (!fs.existsSync(scopeRoot)) continue;
|
||||
scanMarkdownFiles(scopeRoot, files);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function loadKnowledgeEntries(rootDir = process.cwd(), options = {}) {
|
||||
const root = knowledgeRoot(rootDir);
|
||||
const files = listKnowledgeFiles(rootDir);
|
||||
const cacheKey = `${path.resolve(root)}:${options.includeHidden ? "all" : "active"}`;
|
||||
const previous = knowledgeIndexCache.get(cacheKey) || { files: new Map(), entries: [] };
|
||||
const nextFiles = new Map();
|
||||
const entries = [];
|
||||
for (const filePath of files) {
|
||||
const stat = fs.statSync(filePath);
|
||||
const previousFile = previous.files.get(filePath);
|
||||
const cacheMeta = {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size
|
||||
};
|
||||
const parsed = previousFile && previousFile.mtimeMs === cacheMeta.mtimeMs && previousFile.size === cacheMeta.size
|
||||
? previousFile.entry
|
||||
: parseKnowledgeFile(filePath, root, options);
|
||||
nextFiles.set(filePath, {
|
||||
...cacheMeta,
|
||||
entry: parsed
|
||||
});
|
||||
if (parsed) entries.push(parsed);
|
||||
}
|
||||
knowledgeIndexCache.set(cacheKey, { files: nextFiles, entries });
|
||||
return entries.map(cloneKnowledgeEntry);
|
||||
}
|
||||
|
||||
function searchFileKnowledge({ query = "", user, limit = 5, rootDir = process.cwd() } = {}) {
|
||||
const access = accessForUser(user);
|
||||
if (!access.authenticated) return [];
|
||||
const tokens = tokenSet(query);
|
||||
const entries = resolveVisibleKnowledgePlaceholders(loadKnowledgeEntries(rootDir)
|
||||
.filter((entry) => canSeeKnowledgeEntry(entry, access))
|
||||
);
|
||||
return entries
|
||||
.flatMap((entry) => entry.chunks.map((chunk) => scoreKnowledgeChunk(entry, chunk, tokens)))
|
||||
.filter((result) => result.matched || !tokens.size)
|
||||
.sort((a, b) => b.score - a.score || b.priority - a.priority || a.source_metadata.path.localeCompare(b.source_metadata.path))
|
||||
.slice(0, Math.max(1, Math.min(Number(limit) || 5, 25)))
|
||||
.map((result) => ({
|
||||
id: result.id,
|
||||
slug: result.slug,
|
||||
title: result.title,
|
||||
category: result.category,
|
||||
visibility: result.visibility,
|
||||
summary: result.summary,
|
||||
facts: result.facts,
|
||||
source: result.source,
|
||||
source_metadata: result.source_metadata
|
||||
}));
|
||||
}
|
||||
|
||||
function listKnowledgePlaceholders({ user, rootDir = process.cwd(), includeHidden = false } = {}) {
|
||||
const access = accessForUser(user);
|
||||
if (!access.authenticated) return [];
|
||||
const entries = loadKnowledgeEntries(rootDir, { includeHidden })
|
||||
.filter((entry) => canSeeKnowledgeEntry(entry, access));
|
||||
const placeholders = new Set();
|
||||
for (const entry of entries) {
|
||||
for (const key of Object.keys(entry.frontmatter || {})) {
|
||||
if (PLACEHOLDER_SUGGEST_RESERVED_KEYS.has(key)) continue;
|
||||
const value = placeholderValue(entry.frontmatter[key]);
|
||||
if (!value) continue;
|
||||
placeholders.add(`${entry.id}.${key}`);
|
||||
placeholders.add(`${entry.scope}.${entry.slug}.${key}`);
|
||||
}
|
||||
}
|
||||
return Array.from(placeholders)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((key) => `{{${key}}}`);
|
||||
}
|
||||
|
||||
function registerKnowledgePlaceholderDefinitions(placeholders, { rootDir = process.cwd() } = {}) {
|
||||
if (!placeholders?.registerPlaceholders) return [];
|
||||
placeholders.unregisterNamespace?.("okf.file");
|
||||
const definitions = [];
|
||||
const entries = loadKnowledgeEntries(rootDir)
|
||||
.filter((entry) => !HIDDEN_STATUSES.has(entry.status));
|
||||
for (const entry of entries) {
|
||||
for (const key of Object.keys(entry.frontmatter || {})) {
|
||||
if (PLACEHOLDER_SUGGEST_RESERVED_KEYS.has(key)) continue;
|
||||
const value = placeholderValue(entry.frontmatter[key]);
|
||||
if (!value) continue;
|
||||
definitions.push({
|
||||
id: `okf.file.${entry.scope}.${entry.slug}.${key}`,
|
||||
namespace: `okf.file.${entry.scope}`,
|
||||
aliases: [
|
||||
`${entry.id}.${key}`,
|
||||
`${entry.scope}.${entry.slug}.${key}`
|
||||
],
|
||||
label: `${entry.title}: ${key.replace(/[_-]+/g, " ")}`,
|
||||
description: `Frontmatter value from ${entry.title}.`,
|
||||
value_type: "string",
|
||||
sensitivity: visibilitySensitivity(entry.visibility),
|
||||
min_editor_role: "user",
|
||||
min_viewer_role: visibilityRole(entry.visibility),
|
||||
allowed_field_types: ["okf_markdown"],
|
||||
group: `OKF ${entry.scope}`,
|
||||
resolver: () => value
|
||||
});
|
||||
}
|
||||
}
|
||||
return placeholders.registerPlaceholders(definitions);
|
||||
}
|
||||
|
||||
function parseKnowledgeFile(filePath, root, options = {}) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const relativePath = normalizePath(path.relative(root, filePath));
|
||||
const scope = relativePath.split("/")[0] || "core";
|
||||
const parsed = splitFrontmatter(content);
|
||||
const metadata = normalizeMetadata(parsed.frontmatter, filePath, relativePath, scope);
|
||||
if (!options.includeHidden && HIDDEN_STATUSES.has(metadata.status)) return null;
|
||||
const body = cleanText(parsed.body, 180000);
|
||||
return {
|
||||
...metadata,
|
||||
path: relativePath,
|
||||
file_slug: slugify(path.basename(relativePath, path.extname(relativePath))),
|
||||
body,
|
||||
chunks: chunkMarkdown(body, metadata).map((chunk) => ({
|
||||
...chunk,
|
||||
path: relativePath
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function listCommunityKnowledgeFiles(rootDir = process.cwd()) {
|
||||
return loadKnowledgeEntries(rootDir, { includeHidden: true })
|
||||
.filter((entry) => entry.scope === "community")
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
slug: entry.file_slug,
|
||||
entry_slug: entry.slug,
|
||||
title: entry.title,
|
||||
status: entry.status,
|
||||
visibility: entry.visibility,
|
||||
priority: entry.priority,
|
||||
tags: entry.tags,
|
||||
editable: entry.editable,
|
||||
generated: entry.generated,
|
||||
path: `knowledge/${entry.path}`,
|
||||
updated_at: entry.updated_at
|
||||
}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function getCommunityKnowledgeFile(rootDir = process.cwd(), slug) {
|
||||
const entry = loadKnowledgeEntries(rootDir, { includeHidden: true })
|
||||
.find((item) => item.scope === "community" && (item.file_slug === slug || item.slug === slug || item.id === slug));
|
||||
return entry ? {
|
||||
...entry,
|
||||
slug: entry.file_slug,
|
||||
entry_slug: entry.slug,
|
||||
path: `knowledge/${entry.path}`
|
||||
} : null;
|
||||
}
|
||||
|
||||
function saveCommunityKnowledgeFile(rootDir = process.cwd(), values = {}) {
|
||||
const root = ensureKnowledgeDirs(rootDir);
|
||||
const slug = slugify(values.slug || values.id || values.title);
|
||||
const existing = values.existing_slug ? getCommunityKnowledgeFile(rootDir, values.existing_slug) : null;
|
||||
if (existing && (existing.generated || !existing.editable)) {
|
||||
throw new Error("This community OKF file is not editable.");
|
||||
}
|
||||
const filePath = path.join(root, "community", `${slug}.md`);
|
||||
if (existing && path.resolve(filePath) !== path.resolve(rootDir, existing.path)) {
|
||||
const existingPath = path.resolve(rootDir, existing.path);
|
||||
if (fs.existsSync(existingPath)) fs.rmSync(existingPath, { force: true });
|
||||
}
|
||||
const metadata = normalizeCommunityFileValues(values, slug);
|
||||
const markdown = serializeKnowledgeFile(metadata, values.body || "");
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
knowledgeIndexCache.clear();
|
||||
parseKnowledgeFile(filePath, root, { includeHidden: true });
|
||||
return getCommunityKnowledgeFile(rootDir, slug);
|
||||
}
|
||||
|
||||
function saveCorrectionKnowledgeFile(rootDir = process.cwd(), values = {}) {
|
||||
const root = ensureKnowledgeDirs(rootDir);
|
||||
const slug = slugify(values.slug || values.id || values.title);
|
||||
const filePath = path.join(root, "corrections", `${slug}.md`);
|
||||
const metadata = normalizeCorrectionFileValues(values, slug);
|
||||
const markdown = serializeKnowledgeFile(metadata, values.body || "");
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
knowledgeIndexCache.clear();
|
||||
const entry = parseKnowledgeFile(filePath, root, { includeHidden: true });
|
||||
return entry ? {
|
||||
...entry,
|
||||
slug: entry.file_slug,
|
||||
entry_slug: entry.slug,
|
||||
path: `knowledge/${entry.path}`
|
||||
} : null;
|
||||
}
|
||||
|
||||
function cloneKnowledgeEntry(entry) {
|
||||
return {
|
||||
...entry,
|
||||
tags: [...entry.tags],
|
||||
frontmatter: { ...entry.frontmatter },
|
||||
chunks: entry.chunks.map((chunk) => ({ ...chunk }))
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVisibleKnowledgePlaceholders(entries) {
|
||||
const placeholders = buildPlaceholderMap(entries);
|
||||
return entries.map((entry) => {
|
||||
const body = resolvePlaceholders(entry.body, placeholders);
|
||||
return {
|
||||
...entry,
|
||||
body,
|
||||
chunks: chunkMarkdown(body, entry).map((chunk) => ({
|
||||
...chunk,
|
||||
path: entry.path
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildPlaceholderMap(entries) {
|
||||
const map = new Map();
|
||||
for (const entry of entries) {
|
||||
const values = {
|
||||
...entry.frontmatter,
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
scope: entry.scope,
|
||||
status: entry.status,
|
||||
priority: entry.priority,
|
||||
visibility: entry.visibility,
|
||||
category: entry.category,
|
||||
tags: entry.tags,
|
||||
generated: entry.generated,
|
||||
editable: entry.editable,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at
|
||||
};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const normalizedValue = placeholderValue(value);
|
||||
if (!normalizedValue) continue;
|
||||
map.set(`${entry.id}.${key}`, normalizedValue);
|
||||
map.set(`${entry.scope}.${entry.slug}.${key}`, normalizedValue);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolvePlaceholders(value, placeholders) {
|
||||
let output = String(value || "");
|
||||
for (let pass = 0; pass < 3; pass += 1) {
|
||||
const next = output.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (match, key) => {
|
||||
const replacement = placeholders.get(key);
|
||||
return replacement === undefined ? "[missing OKF reference]" : replacement;
|
||||
});
|
||||
if (next === output) break;
|
||||
output = next;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function splitFrontmatter(content) {
|
||||
const normalized = String(content || "").replace(/\r\n?/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) {
|
||||
return { frontmatter: {}, body: normalized };
|
||||
}
|
||||
const end = normalized.indexOf("\n---", 4);
|
||||
if (end === -1) {
|
||||
return { frontmatter: {}, body: normalized };
|
||||
}
|
||||
const rawFrontmatter = normalized.slice(4, end);
|
||||
const body = normalized.slice(end + 4).replace(/^\n/, "");
|
||||
return {
|
||||
frontmatter: parseFrontmatter(rawFrontmatter),
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw) {
|
||||
const out = {};
|
||||
for (const line of String(raw || "").split("\n")) {
|
||||
if (!line.trim() || line.trim().startsWith("#")) continue;
|
||||
const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
out[match[1].trim()] = parseFrontmatterValue(match[2].trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(value) {
|
||||
const unquoted = value.replace(/^["']|["']$/g, "");
|
||||
if (/^(true|false)$/i.test(unquoted)) return unquoted.toLowerCase() === "true";
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(unquoted)) return Number(unquoted);
|
||||
if (unquoted.startsWith("[") && unquoted.endsWith("]")) {
|
||||
return unquoted
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((item) => cleanText(item.replace(/^["']|["']$/g, ""), 120))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
function normalizeCommunityFileValues(values, slug) {
|
||||
const id = cleanText(values.id, 180) || `community.${slug}`;
|
||||
const title = cleanText(values.title, 180);
|
||||
if (!title) throw new Error("Community OKF title is required.");
|
||||
const status = cleanText(values.status, 40).toLowerCase() || "active";
|
||||
const visibility = cleanText(values.visibility, 20).toLowerCase() || "user";
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
scope: "community",
|
||||
status,
|
||||
priority: Number.isFinite(Number(values.priority)) ? Number(values.priority) : 0,
|
||||
visibility: VISIBILITY_VALUES.has(visibility) ? visibility : "user",
|
||||
category: cleanText(values.category || "Community", 120),
|
||||
tags: splitList(values.tags),
|
||||
generated: false,
|
||||
editable: true,
|
||||
created_at: cleanText(values.created_at, 80) || new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCorrectionFileValues(values, slug) {
|
||||
const id = cleanText(values.id, 180) || `correction.${slug}`;
|
||||
const title = cleanText(values.title, 180);
|
||||
if (!title) throw new Error("Correction OKF title is required.");
|
||||
const status = cleanText(values.status, 40).toLowerCase() || "active";
|
||||
const visibility = cleanText(values.visibility, 20).toLowerCase() || "user";
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
scope: "corrections",
|
||||
status,
|
||||
priority: Number.isFinite(Number(values.priority)) ? Number(values.priority) : 100,
|
||||
visibility: VISIBILITY_VALUES.has(visibility) ? visibility : "user",
|
||||
category: cleanText(values.category || "Correction", 120),
|
||||
tags: splitList(values.tags || "feedback, correction"),
|
||||
generated: false,
|
||||
editable: true,
|
||||
created_at: cleanText(values.created_at, 80) || new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
extra_frontmatter: {
|
||||
source_feedback_id: cleanText(values.source_feedback_id, 180),
|
||||
source_feedback_url: cleanText(values.source_feedback_url, 1000)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function serializeKnowledgeFile(metadata, body) {
|
||||
const frontmatter = {
|
||||
id: metadata.id,
|
||||
title: metadata.title,
|
||||
scope: metadata.scope,
|
||||
status: metadata.status,
|
||||
priority: metadata.priority,
|
||||
visibility: metadata.visibility,
|
||||
category: metadata.category,
|
||||
tags: metadata.tags.join(", "),
|
||||
generated: metadata.generated,
|
||||
editable: metadata.editable,
|
||||
created_at: metadata.created_at,
|
||||
updated_at: metadata.updated_at,
|
||||
...(metadata.extra_frontmatter || {})
|
||||
};
|
||||
const lines = ["---"];
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (value === "" || value === null || value === undefined) continue;
|
||||
lines.push(`${key}: ${frontmatterValue(value)}`);
|
||||
}
|
||||
lines.push("---", "", cleanText(body, 64000) || `# ${metadata.title}`, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function frontmatterValue(value) {
|
||||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
const text = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
return /[:#[\]{}"'\\]|^\s|\s$/.test(text) ? JSON.stringify(text) : text;
|
||||
}
|
||||
|
||||
function normalizeMetadata(frontmatter, filePath, relativePath, scope) {
|
||||
const id = cleanText(frontmatter.id, 180) || normalizePath(relativePath).replace(/\.md$/i, "").replace(/\//g, ".");
|
||||
const title = cleanText(frontmatter.title, 180) || titleFromPath(filePath);
|
||||
const status = cleanText(frontmatter.status, 40).toLowerCase() || "active";
|
||||
const visibility = VISIBILITY_VALUES.has(cleanText(frontmatter.visibility, 20).toLowerCase())
|
||||
? cleanText(frontmatter.visibility, 20).toLowerCase()
|
||||
: "user";
|
||||
return {
|
||||
id,
|
||||
slug: slugify(id),
|
||||
title,
|
||||
scope: KNOWLEDGE_SCOPES.includes(scope) ? scope : "core",
|
||||
status,
|
||||
priority: Number.isFinite(Number(frontmatter.priority)) ? Number(frontmatter.priority) : 0,
|
||||
visibility,
|
||||
category: cleanText(frontmatter.category || frontmatter.scope || scope, 120),
|
||||
tags: splitList(frontmatter.tags),
|
||||
generated: Boolean(frontmatter.generated),
|
||||
editable: frontmatter.editable === undefined ? scope === "community" || scope === "corrections" : Boolean(frontmatter.editable),
|
||||
created_at: cleanText(frontmatter.created_at, 80),
|
||||
updated_at: cleanText(frontmatter.updated_at, 80),
|
||||
frontmatter: normalizeFrontmatterValues(frontmatter)
|
||||
};
|
||||
}
|
||||
|
||||
function chunkMarkdown(body, metadata) {
|
||||
const lines = String(body || "").split("\n");
|
||||
const chunks = [];
|
||||
let current = { heading: metadata.title, level: 1, lines: [] };
|
||||
const flush = () => {
|
||||
const text = cleanText(current.lines.join("\n"), 8000);
|
||||
if (!text) return;
|
||||
chunks.push({
|
||||
id: `${metadata.id}#${slugify(current.heading || "section")}`,
|
||||
heading: cleanText(current.heading || metadata.title, 180),
|
||||
level: current.level,
|
||||
text
|
||||
});
|
||||
};
|
||||
for (const line of lines) {
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
flush();
|
||||
current = {
|
||||
heading: cleanText(heading[2], 180),
|
||||
level: heading[1].length,
|
||||
lines: []
|
||||
};
|
||||
continue;
|
||||
}
|
||||
current.lines.push(line);
|
||||
}
|
||||
flush();
|
||||
if (!chunks.length && body.trim()) {
|
||||
chunks.push({
|
||||
id: `${metadata.id}#body`,
|
||||
heading: metadata.title,
|
||||
level: 1,
|
||||
text: cleanText(body, 8000)
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function scoreKnowledgeChunk(entry, chunk, queryTokens) {
|
||||
const text = [entry.title, entry.category, entry.tags.join(" "), chunk.heading, chunk.text].join(" ");
|
||||
const textTokens = tokenSet(text);
|
||||
const overlap = queryTokens.size ? intersectionSize(queryTokens, textTokens) : 1;
|
||||
const score = (overlap * 100) + SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0);
|
||||
const excerpt = excerptForChunk(chunk.text, queryTokens);
|
||||
return {
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
visibility: entry.visibility,
|
||||
summary: excerpt,
|
||||
facts: chunk.text.slice(0, 4000),
|
||||
priority: SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0),
|
||||
score,
|
||||
matched: overlap > 0,
|
||||
source: `knowledge/${chunk.path}${chunk.heading ? `#${slugify(chunk.heading)}` : ""}`,
|
||||
source_metadata: {
|
||||
path: `knowledge/${chunk.path}`,
|
||||
id: entry.id,
|
||||
heading: chunk.heading,
|
||||
score,
|
||||
excerpt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function canSeeKnowledgeEntry(entry, access) {
|
||||
if (!entry) return false;
|
||||
if (!ACTIVE_STATUSES.has(entry.status) && !access.isAdmin) return false;
|
||||
if (entry.visibility === "admin") return access.isAdmin;
|
||||
if (entry.visibility === "mod") return access.isMod || access.isAdmin;
|
||||
return true;
|
||||
}
|
||||
|
||||
function accessForUser(user) {
|
||||
return {
|
||||
authenticated: Boolean(user),
|
||||
isAdmin: Boolean(user?.isAdmin),
|
||||
isMod: Boolean(user?.isAdmin || user?.isMod)
|
||||
};
|
||||
}
|
||||
|
||||
function scanMarkdownFiles(dir, output) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
scanMarkdownFiles(fullPath, output);
|
||||
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
||||
output.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function excerptForChunk(text, queryTokens) {
|
||||
const cleaned = cleanText(text, 1200);
|
||||
if (!queryTokens.size) return cleaned.slice(0, 360);
|
||||
const lower = cleaned.toLowerCase();
|
||||
const token = Array.from(queryTokens).find((item) => lower.includes(item));
|
||||
if (!token) return cleaned.slice(0, 360);
|
||||
const index = Math.max(0, lower.indexOf(token) - 120);
|
||||
return cleaned.slice(index, index + 420).trim();
|
||||
}
|
||||
|
||||
function tokenSet(value) {
|
||||
const cleaned = cleanText(value, 4000);
|
||||
const expanded = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||||
const tokens = new Set();
|
||||
for (const token of `${cleaned} ${expanded}`.toLowerCase().split(/[^a-z0-9_]+/)) {
|
||||
if (token.length < 2 || SEARCH_STOPWORDS.has(token)) continue;
|
||||
tokens.add(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function intersectionSize(a, b) {
|
||||
let count = 0;
|
||||
for (const item of a) {
|
||||
if (b.has(item)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function normalizeFrontmatterValues(frontmatter = {}) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
const normalizedKey = cleanText(key, 80).replace(/[^A-Za-z0-9_-]/g, "_");
|
||||
const normalizedValue = placeholderValue(value);
|
||||
if (normalizedKey && normalizedValue) out[normalizedKey] = normalizedValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function placeholderValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cleanText(item, 240)).filter(Boolean).join(", ");
|
||||
}
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return cleanText(value, 1000);
|
||||
}
|
||||
|
||||
function visibilityRole(value) {
|
||||
const visibility = VISIBILITY_VALUES.has(value) ? value : "user";
|
||||
return visibility === "admin" ? "admin" : visibility === "mod" ? "mod" : "user";
|
||||
}
|
||||
|
||||
function visibilitySensitivity(value) {
|
||||
const visibility = VISIBILITY_VALUES.has(value) ? value : "user";
|
||||
return visibility === "admin" ? "admin" : visibility === "mod" ? "moderator" : "public_safe";
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
if (Array.isArray(value)) return value.map((item) => cleanText(item, 120)).filter(Boolean).slice(0, 50);
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => cleanText(item, 120))
|
||||
.filter(Boolean)
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function titleFromPath(filePath) {
|
||||
return path.basename(filePath, path.extname(filePath)).replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return cleanText(value, 180)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "entry";
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
return String(value || "").replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function cleanText(value, maximum = 4000) {
|
||||
return String(value || "").replace(/\r\n?/g, "\n").trim().slice(0, maximum);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KNOWLEDGE_SCOPES,
|
||||
ensureKnowledgeDirs,
|
||||
getCommunityKnowledgeFile,
|
||||
knowledgeRoot,
|
||||
listKnowledgeFiles,
|
||||
listCommunityKnowledgeFiles,
|
||||
listKnowledgePlaceholders,
|
||||
loadKnowledgeEntries,
|
||||
migrateSingleBracePlaceholders,
|
||||
parseKnowledgeFile,
|
||||
registerKnowledgePlaceholderDefinitions,
|
||||
saveCorrectionKnowledgeFile,
|
||||
saveCommunityKnowledgeFile,
|
||||
searchFileKnowledge
|
||||
};
|
||||
519
plugins/okf/backend/generate_knowledge.js
Normal file
519
plugins/okf/backend/generate_knowledge.js
Normal file
@ -0,0 +1,519 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { ensureKnowledgeDirs, knowledgeRoot } = require("./file_knowledge");
|
||||
|
||||
function generateKnowledgeFiles(rootDir = process.cwd()) {
|
||||
const root = ensureKnowledgeDirs(rootDir);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const written = [];
|
||||
const coreFile = path.join(root, "core", "lumi-core.md");
|
||||
const coreMarkdown = buildCoreKnowledge(rootDir, generatedAt);
|
||||
if (writeGeneratedFile(coreFile, coreMarkdown)) {
|
||||
written.push(path.relative(rootDir, coreFile));
|
||||
}
|
||||
for (const plugin of discoverPlugins(rootDir)) {
|
||||
const pluginFile = path.join(root, "plugins", `${slugify(plugin.id)}.md`);
|
||||
const pluginMarkdown = buildPluginKnowledge(plugin, generatedAt);
|
||||
if (writeGeneratedFile(pluginFile, pluginMarkdown)) {
|
||||
written.push(path.relative(rootDir, pluginFile));
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
function buildCoreKnowledge(rootDir, generatedAt) {
|
||||
const packageJson = readJson(path.join(rootDir, "package.json"));
|
||||
const readme = readText(path.join(rootDir, "README.md"));
|
||||
const routes = discoverCoreRoutes(rootDir);
|
||||
const routeReference = buildRouteReference(routes);
|
||||
return frontmatter({
|
||||
id: "core.lumi",
|
||||
title: packageJson.name || "Lumi Core",
|
||||
scope: "core",
|
||||
status: "active",
|
||||
priority: 20,
|
||||
visibility: "user",
|
||||
category: "Core",
|
||||
tags: "core, routes, commands, settings",
|
||||
generated: true,
|
||||
editable: false,
|
||||
updated_at: generatedAt
|
||||
}) + [
|
||||
`# ${packageJson.name || "Lumi Core"}`,
|
||||
"",
|
||||
packageJson.description || "Lumi is the core web UI and bot runtime.",
|
||||
"",
|
||||
"## Runtime",
|
||||
"",
|
||||
`Package: ${packageJson.name || "unknown"}`,
|
||||
packageJson.version ? `Version: ${packageJson.version}` : "",
|
||||
packageJson.main ? `Entry point: ${packageJson.main}` : "",
|
||||
"",
|
||||
"## Routes",
|
||||
"",
|
||||
routes.length ? routes.map((route) => `- ${route.method.toUpperCase()} ${route.path}`).join("\n") : "- No core routes detected.",
|
||||
"",
|
||||
"## Route Reference",
|
||||
"",
|
||||
routeReference || "No route details detected.",
|
||||
"",
|
||||
"## README Summary",
|
||||
"",
|
||||
excerpt(readme, 1800) || "No README summary available."
|
||||
].filter((line) => line !== "").join("\n");
|
||||
}
|
||||
|
||||
function buildPluginKnowledge(plugin, generatedAt) {
|
||||
const routeLines = plugin.routes.length
|
||||
? plugin.routes.map((route) => `- ${route.method ? `${route.method.toUpperCase()} ` : ""}${route.path}`).join("\n")
|
||||
: "- No plugin routes detected.";
|
||||
const routeReference = buildRouteReference(plugin.routes);
|
||||
const commandLines = plugin.commands.length
|
||||
? plugin.commands.map((command) => `- ${command}`).join("\n")
|
||||
: "- No plugin command triggers detected.";
|
||||
return frontmatter({
|
||||
id: `plugin.${plugin.id}`,
|
||||
title: plugin.name || plugin.id,
|
||||
scope: "plugins",
|
||||
status: "active",
|
||||
priority: 10,
|
||||
visibility: "user",
|
||||
category: "Plugin",
|
||||
tags: ["plugin", plugin.id, ...(plugin.keywords || [])].join(", "),
|
||||
generated: true,
|
||||
editable: false,
|
||||
updated_at: generatedAt
|
||||
}) + [
|
||||
`# ${plugin.name || plugin.id}`,
|
||||
"",
|
||||
plugin.description || "No plugin description is available.",
|
||||
"",
|
||||
"## Metadata",
|
||||
"",
|
||||
`Plugin ID: ${plugin.id}`,
|
||||
plugin.version ? `Version: ${plugin.version}` : "",
|
||||
plugin.author ? `Author: ${plugin.author}` : "",
|
||||
plugin.enabled === false ? "Default state: disabled" : "Default state: enabled",
|
||||
"",
|
||||
"## Web Routes",
|
||||
"",
|
||||
routeLines,
|
||||
"",
|
||||
"## Route Reference",
|
||||
"",
|
||||
routeReference || "No route details detected.",
|
||||
"",
|
||||
"## Commands",
|
||||
"",
|
||||
commandLines,
|
||||
"",
|
||||
"## Source",
|
||||
"",
|
||||
`Plugin folder: plugins/${plugin.folder}`
|
||||
].filter((line) => line !== "").join("\n");
|
||||
}
|
||||
|
||||
function discoverPlugins(rootDir) {
|
||||
const pluginsDir = path.join(rootDir, "plugins");
|
||||
if (!fs.existsSync(pluginsDir)) return [];
|
||||
return fs.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const dir = path.join(pluginsDir, entry.name);
|
||||
const manifest = readJson(path.join(dir, "plugin.json"));
|
||||
if (!manifest.id && !manifest.name) return null;
|
||||
const source = readText(path.join(dir, "index.js"));
|
||||
return {
|
||||
id: cleanText(manifest.id || entry.name, 120),
|
||||
folder: entry.name,
|
||||
name: cleanText(manifest.name || manifest.label || manifest.id || entry.name, 180),
|
||||
version: cleanText(manifest.version, 80),
|
||||
description: cleanText(manifest.description, 800),
|
||||
author: cleanText(manifest.author, 180),
|
||||
enabled: manifest.enabled,
|
||||
keywords: Array.isArray(manifest.keywords) ? manifest.keywords.map((item) => cleanText(item, 80)).filter(Boolean) : [],
|
||||
routes: discoverPluginRoutes(source, manifest.id || entry.name),
|
||||
commands: discoverCommandTriggers(source)
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
function discoverCoreRoutes(rootDir) {
|
||||
const server = readText(path.join(rootDir, "src", "web", "server.js"));
|
||||
const routes = [];
|
||||
for (const match of server.matchAll(/\bapp\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
const source = extractCallExpression(server, match.index);
|
||||
routes.push({
|
||||
method: match[1],
|
||||
path: match[2],
|
||||
details: analyzeRouteSource(source, match[1], match[2])
|
||||
});
|
||||
}
|
||||
return uniqueRoutes(routes).slice(0, 200);
|
||||
}
|
||||
|
||||
function discoverPluginRoutes(source, pluginId) {
|
||||
const routes = [];
|
||||
const mountPaths = discoverMountPaths(source, pluginId);
|
||||
for (const match of source.matchAll(/\bweb\.mount\(\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
routes.push({
|
||||
method: "",
|
||||
path: normalizeTemplatePath(match[1], pluginId),
|
||||
details: {
|
||||
purpose: "Mounts the plugin router at this base WebUI path.",
|
||||
inputs: [],
|
||||
response: "Plugin router mount point.",
|
||||
access: "Access is controlled by the mount options and individual plugin routes.",
|
||||
sideEffects: "No direct route action; child routes handle requests.",
|
||||
limits: "Mount metadata is inferred from static source scanning."
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const match of source.matchAll(/\brouter\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
const sourceSnippet = extractCallExpression(source, match.index);
|
||||
const routePath = normalizeTemplatePath(match[2], pluginId);
|
||||
const bases = mountPaths.length ? mountPaths : [`/plugins/${pluginId}`];
|
||||
for (const base of bases) {
|
||||
const fullPath = joinRoutePath(base, routePath);
|
||||
routes.push({
|
||||
method: match[1],
|
||||
path: fullPath,
|
||||
details: analyzeRouteSource(sourceSnippet, match[1], fullPath)
|
||||
});
|
||||
}
|
||||
}
|
||||
return uniqueRoutes(routes).slice(0, 100);
|
||||
}
|
||||
|
||||
function discoverMountPaths(source, pluginId) {
|
||||
const paths = [];
|
||||
for (const match of source.matchAll(/\bweb\.mount\(\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
paths.push(normalizeTemplatePath(match[1], pluginId));
|
||||
}
|
||||
return Array.from(new Set(paths)).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTemplatePath(value, pluginId) {
|
||||
return String(value || "").replace(/\$\{PLUGIN_ID\}/g, pluginId);
|
||||
}
|
||||
|
||||
function joinRoutePath(base, child) {
|
||||
const normalizedBase = String(base || "").replace(/\/+$/, "") || "/";
|
||||
const normalizedChild = String(child || "").replace(/^\/+/, "");
|
||||
if (!normalizedChild) return normalizedBase;
|
||||
if (normalizedChild === "/") return normalizedBase;
|
||||
return `${normalizedBase}/${normalizedChild}`.replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
function discoverCommandTriggers(source) {
|
||||
const triggers = new Set();
|
||||
for (const match of source.matchAll(/\btrigger\s*:\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
triggers.add(match[1]);
|
||||
}
|
||||
for (const match of source.matchAll(/\bcommand\s*:\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
triggers.add(match[1]);
|
||||
}
|
||||
return Array.from(triggers).sort().slice(0, 100);
|
||||
}
|
||||
|
||||
function writeGeneratedFile(filePath, content) {
|
||||
if (!canOverwriteGeneratedFile(filePath)) return false;
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${content.trim()}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function canOverwriteGeneratedFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return true;
|
||||
const text = readText(filePath);
|
||||
if (/generated\s*:\s*false/i.test(text)) return false;
|
||||
if (/editable\s*:\s*true/i.test(text)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function uniqueRoutes(routes) {
|
||||
const seen = new Set();
|
||||
return routes.filter((route) => {
|
||||
const key = `${route.method}:${route.path}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildRouteReference(routes) {
|
||||
if (!routes.length) return "";
|
||||
return routes.map((route) => {
|
||||
const details = route.details || analyzeRouteSource("", route.method, route.path);
|
||||
return [
|
||||
`### ${route.method ? route.method.toUpperCase() : "MOUNT"} ${route.path}`,
|
||||
"",
|
||||
`- Purpose: ${details.purpose}`,
|
||||
`- Inputs: ${details.inputs.length ? details.inputs.join("; ") : "No request parameters detected by static analysis."}`,
|
||||
`- Response format: ${details.response}`,
|
||||
`- Access: ${details.access}`,
|
||||
`- Side effects: ${details.sideEffects}`,
|
||||
`- Limits/notes: ${details.limits}`
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function analyzeRouteSource(source, method, routePath) {
|
||||
const bodyFields = findRequestFields(source, "body");
|
||||
const queryFields = findRequestFields(source, "query");
|
||||
const paramFields = Array.from(new Set([
|
||||
...extractPathParams(routePath),
|
||||
...findRequestFields(source, "params")
|
||||
])).sort();
|
||||
const inputs = [];
|
||||
if (paramFields.length) inputs.push(`path params: ${paramFields.map((field) => `\`${field}\``).join(", ")}`);
|
||||
if (queryFields.length) inputs.push(`query: ${queryFields.map((field) => `\`${field}\``).join(", ")}`);
|
||||
if (bodyFields.length) inputs.push(`body: ${bodyFields.map((field) => `\`${field}\``).join(", ")}`);
|
||||
if (/\breq\.file\b|\breq\.files\b|\bupload\.(single|array|fields)\(/.test(source)) {
|
||||
inputs.push("file upload: multipart form file data");
|
||||
}
|
||||
if (!bodyFields.length && /\breq\.body\b/.test(source)) {
|
||||
inputs.push("body: full submitted body is passed to a helper; exact fields are defined by the matching form/service");
|
||||
}
|
||||
if (!queryFields.length && /\breq\.query\b/.test(source)) {
|
||||
inputs.push("query: full query object is passed to a helper; exact fields are defined by the matching view/service");
|
||||
}
|
||||
if (!paramFields.length && /\breq\.params\b/.test(source)) {
|
||||
inputs.push("path params: full params object is used; exact keys come from the route path");
|
||||
}
|
||||
const response = describeResponseFormat(source, method, routePath);
|
||||
return {
|
||||
purpose: describeRoutePurpose(source, method, routePath, response),
|
||||
inputs,
|
||||
response,
|
||||
access: describeAccess(source, routePath),
|
||||
sideEffects: describeSideEffects(source, method, bodyFields),
|
||||
limits: describeRouteLimits(source, method, routePath)
|
||||
};
|
||||
}
|
||||
|
||||
function findRequestFields(source, bucket) {
|
||||
const fields = new Set();
|
||||
const dotPattern = new RegExp(`\\breq\\.${bucket}\\.([A-Za-z_$][A-Za-z0-9_$]*)`, "g");
|
||||
for (const match of source.matchAll(dotPattern)) {
|
||||
fields.add(match[1]);
|
||||
}
|
||||
const bracketPattern = new RegExp(`\\breq\\.${bucket}\\s*\\[\\s*["'\`]([^"'\`]+)["'\`]\\s*\\]`, "g");
|
||||
for (const match of source.matchAll(bracketPattern)) {
|
||||
fields.add(match[1]);
|
||||
}
|
||||
return Array.from(fields).sort().slice(0, 40);
|
||||
}
|
||||
|
||||
function extractPathParams(routePath) {
|
||||
const fields = new Set();
|
||||
for (const match of String(routePath || "").matchAll(/:([A-Za-z0-9_]+)/g)) {
|
||||
fields.add(match[1]);
|
||||
}
|
||||
return Array.from(fields);
|
||||
}
|
||||
|
||||
function describeResponseFormat(source, method, routePath) {
|
||||
const formats = [];
|
||||
if (/\bres\.render\(/.test(source)) formats.push("HTML page rendered from an EJS view");
|
||||
if (/\brenderAdmin\(/.test(source)) formats.push("HTML page rendered by a helper");
|
||||
if (/\bres\.json\(/.test(source)) formats.push("JSON response");
|
||||
if (/\bres\.redirect\(/.test(source)) formats.push("HTTP redirect after handling the request");
|
||||
if (/\bres\.download\(/.test(source)) formats.push("file download");
|
||||
if (/\bres\.sendFile\(/.test(source)) formats.push("static file response");
|
||||
if (/\bres\.write\(/.test(source) || /text\/event-stream|subscribeWebEvents/.test(source)) formats.push("streaming event response");
|
||||
if (/\bres\.send\(/.test(source) && !formats.length) formats.push("plain or HTML response");
|
||||
if (!formats.length && !method) return "Router mount; child routes provide concrete responses.";
|
||||
if (!formats.length && String(routePath).startsWith("/api/")) return "API response; exact schema was not detected statically.";
|
||||
if (!formats.length && method === "get") return "HTML or data response; exact format was not detected statically.";
|
||||
if (!formats.length) return "Form/action response; exact format was not detected statically.";
|
||||
return Array.from(new Set(formats)).join("; ");
|
||||
}
|
||||
|
||||
function describeRoutePurpose(source, method, routePath, response) {
|
||||
const pathText = String(routePath || "");
|
||||
const normalized = pathText.replace(/[:/._-]+/g, " ").trim();
|
||||
if (!method) return "Registers a plugin route namespace.";
|
||||
if (/\/api\/events/.test(pathText)) return "Streams live WebUI event notifications to the browser.";
|
||||
const pluginAdminMatch = pathText.match(/^\/plugins\/([^/]+)\/admin(?:\/(.+))?$/);
|
||||
if (pluginAdminMatch) {
|
||||
const pluginId = pluginAdminMatch[1];
|
||||
const action = pluginAdminMatch[2] ? pluginAdminMatch[2].replace(/[:/._-]+/g, " ").trim() : "";
|
||||
if (method === "get") return `Renders the ${pluginId} plugin administration page${action ? ` for ${action}` : ""}.`;
|
||||
return `Processes the ${pluginId} plugin administration action${action ? ` for ${action}` : ""}.`;
|
||||
}
|
||||
const pluginPageMatch = pathText.match(/^\/plugins\/([^/]+)(?:\/(.+))?$/);
|
||||
if (pluginPageMatch) {
|
||||
const pluginId = pluginPageMatch[1];
|
||||
const action = pluginPageMatch[2] ? pluginPageMatch[2].replace(/[:/._-]+/g, " ").trim() : "";
|
||||
if (method === "get") return `Renders or serves the ${pluginId} plugin page${action ? ` for ${action}` : ""}.`;
|
||||
return `Processes the ${pluginId} plugin action${action ? ` for ${action}` : ""}.`;
|
||||
}
|
||||
if (/\/api\/users\/search/.test(pathText)) return "Searches known linked Lumi users for shared user-lookup fields.";
|
||||
if (/\/api\/placeholders\/catalog/.test(pathText)) return "Returns placeholder suggestions available to the current user and field policy.";
|
||||
if (/\/api\/placeholders\/preview/.test(pathText)) return "Previews placeholder rendering for a submitted template without saving it.";
|
||||
if (/\/feedback/.test(pathText) && method === "post") return "Creates, updates, comments on, exports, or manages feedback records depending on the action path.";
|
||||
if (/\/admin\/updates/.test(pathText)) return "Checks, applies, reverts, or reports update state for core or plugin updates.";
|
||||
if (/\/admin\/settings/.test(pathText)) return method === "get" ? "Renders the core settings page." : "Saves core settings from the admin settings form.";
|
||||
if (/\/auth\//.test(pathText)) return "Starts, completes, or cancels a platform authentication/linking flow.";
|
||||
if (/\/setup/.test(pathText)) return "Guides first-time platform setup and verification.";
|
||||
if (/\/commands/.test(pathText)) return method === "get" ? "Displays configured bot commands." : "Creates, updates, previews, toggles, or deletes custom commands.";
|
||||
if (/^\/admin\/plugins/.test(pathText)) return "Manages installed plugins, plugin uploads, installs, updates, or plugin enablement.";
|
||||
if (/\/plugins/.test(pathText)) return "Displays or handles plugin-specific WebUI functionality.";
|
||||
if (/\/health/.test(pathText)) return "Returns runtime health information.";
|
||||
if (/\/logs/.test(pathText)) return "Displays, downloads, or manages application logs.";
|
||||
if (/\/stats/.test(pathText)) return "Displays user or community statistics.";
|
||||
if (/\/leaderboards/.test(pathText)) return "Displays leaderboard data.";
|
||||
if (/\bres\.render\(/.test(source)) return `Renders the ${normalized || "requested"} WebUI page.`;
|
||||
if (/\bres\.json\(/.test(source)) return `Provides ${normalized || "route"} data as JSON.`;
|
||||
if (method === "post") return `Processes the ${normalized || "route"} action and stores or applies submitted form data.`;
|
||||
return `Handles ${normalized || "this route"}.`;
|
||||
}
|
||||
|
||||
function describeAccess(source, routePath) {
|
||||
const signals = [];
|
||||
if (/\brequireOkf(Edit|Management)\b/.test(source)) {
|
||||
signals.push("OKF editor or manager permission required");
|
||||
}
|
||||
if (/\brequireOkfReview\b|\brequireOkfImplement\b/.test(source)) {
|
||||
signals.push("higher OKF review or implementation permission may be required");
|
||||
}
|
||||
if (/\brequireAdmin\b|\.isAdmin\b|hasAccess\([^)]*admin/.test(source) || /\/admin(\/|$)/.test(routePath)) {
|
||||
signals.push("admin access expected");
|
||||
}
|
||||
if (/\brequireMod\b|\.isMod\b|hasAccess\([^)]*mod/.test(source) || /\/moderator(\/|$)/.test(routePath)) {
|
||||
signals.push("moderator access may be required");
|
||||
}
|
||||
if (/\brequire(Login|Auth)\b|\breq\.session\.user\b|authRequired/.test(source)) {
|
||||
signals.push("logged-in session required or used");
|
||||
}
|
||||
if (/\bcanAccess\b/.test(source)) {
|
||||
signals.push("mount-level canAccess predicate applies");
|
||||
}
|
||||
if (!signals.length && /^\/api\//.test(routePath)) return "API route; access requirements were not fully detected by static analysis.";
|
||||
if (!signals.length) return "No explicit access guard detected in the route handler; check surrounding router/mount middleware.";
|
||||
return Array.from(new Set(signals)).join("; ");
|
||||
}
|
||||
|
||||
function describeSideEffects(source, method, bodyFields) {
|
||||
const effects = [];
|
||||
if (/\bset[A-Z]|\bsetSetting\b|\bsave[A-Z]|\bupdate[A-Z]|\bcreate[A-Z]|\bdelete[A-Z]|\bremove[A-Z]|\binstall[A-Z]|\bapply[A-Z]|\brevert[A-Z]|\brestore[A-Z]|\bgrant[A-Z]|\brevoke[A-Z]/.test(source)) {
|
||||
effects.push("writes or mutates server-side state");
|
||||
}
|
||||
if (/\bfs\.(writeFileSync|rmSync|mkdirSync|renameSync|copyFileSync)\b/.test(source)) effects.push("writes files");
|
||||
if (/\bdb\.(prepare|exec)\b|\.run\(/.test(source)) effects.push("writes database state when the called service mutates data");
|
||||
if (/\bpublishWebEvent\b|subscribeWebEvents/.test(source)) effects.push("publishes or streams live WebUI events");
|
||||
if (/\brequestRestart\b|process\.exit/.test(source)) effects.push("may restart or stop runtime processes");
|
||||
if (method === "get" && !effects.length) return "Usually read-only.";
|
||||
if (method === "post" && !effects.length && bodyFields.length) return "Consumes submitted data; state mutation happens in called helpers if present.";
|
||||
if (method === "post" && !effects.length) return "Action route; side effects were not detected statically.";
|
||||
return effects.length ? Array.from(new Set(effects)).join("; ") : "No side effects detected statically.";
|
||||
}
|
||||
|
||||
function describeRouteLimits(source, method, routePath) {
|
||||
const notes = ["Generated from static route source analysis; confirm exact behavior in the handler before changing integrations."];
|
||||
if (/\bcleanText\b|slice\(0,\s*\d+|limitFromBody|boundedInt|boundedNumber|clampInt/.test(source)) {
|
||||
notes.push("Input length or numeric bounds are enforced by helper functions in the handler.");
|
||||
}
|
||||
if (/\btry\b[\s\S]*\bcatch\b/.test(source)) {
|
||||
notes.push("Errors are caught and usually returned as a flash message, JSON error, or error page.");
|
||||
}
|
||||
if (method === "post" && !/^\/api\//.test(routePath)) {
|
||||
notes.push("Most non-API POST routes are browser form submissions and usually redirect after completion.");
|
||||
}
|
||||
if (/\/api\//.test(routePath)) {
|
||||
notes.push("API consumers should expect JSON unless the response format says otherwise.");
|
||||
}
|
||||
return notes.join(" ");
|
||||
}
|
||||
|
||||
function extractCallExpression(source, startIndex) {
|
||||
const start = source.indexOf("(", startIndex);
|
||||
if (start === -1) return source.slice(startIndex, startIndex + 1200);
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
quote = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "\"" || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
} else if (char === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return source.slice(startIndex, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return source.slice(startIndex, startIndex + 4000);
|
||||
}
|
||||
|
||||
function frontmatter(values) {
|
||||
const lines = ["---"];
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === "" || value === null || value === undefined) continue;
|
||||
lines.push(`${key}: ${frontmatterValue(value)}`);
|
||||
}
|
||||
lines.push("---", "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function frontmatterValue(value) {
|
||||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
const text = String(value).replace(/\n/g, " ");
|
||||
return /[:#[\]{}"'\\]|^\s|\s$/.test(text) ? JSON.stringify(text) : text;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function excerpt(value, limit) {
|
||||
return cleanText(value, limit).split("\n").filter((line) => line.trim()).slice(0, 12).join("\n");
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return cleanText(value, 180)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "entry";
|
||||
}
|
||||
|
||||
function cleanText(value, maximum = 4000) {
|
||||
return String(value || "").replace(/\r\n?/g, "\n").trim().slice(0, maximum);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCoreKnowledge,
|
||||
buildPluginKnowledge,
|
||||
discoverPlugins,
|
||||
generateKnowledgeFiles
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
const path = require("path");
|
||||
const { getSetting, setSetting } = require("../../src/services/settings");
|
||||
const {
|
||||
PERMISSION_LEVELS,
|
||||
REVIEW_STATES,
|
||||
@ -19,21 +20,73 @@ const {
|
||||
setEntryWorkflow,
|
||||
updateEntry
|
||||
} = require("./backend/okf_store");
|
||||
const {
|
||||
ensureKnowledgeDirs,
|
||||
getCommunityKnowledgeFile,
|
||||
listCommunityKnowledgeFiles,
|
||||
loadKnowledgeEntries,
|
||||
migrateSingleBracePlaceholders,
|
||||
registerKnowledgePlaceholderDefinitions,
|
||||
saveCommunityKnowledgeFile,
|
||||
searchFileKnowledge
|
||||
} = require("./backend/file_knowledge");
|
||||
const { generateKnowledgeFiles } = require("./backend/generate_knowledge");
|
||||
const { renderMarkdown } = require("./backend/markdown");
|
||||
const placeholderService = require("../../src/services/placeholders");
|
||||
|
||||
const PLUGIN_ID = "okf";
|
||||
|
||||
module.exports = {
|
||||
id: PLUGIN_ID,
|
||||
init({ web, db }) {
|
||||
init({ web, db, placeholders = placeholderService }) {
|
||||
ensureTables(db);
|
||||
const okfContextProvider = ({ message, user, limit } = {}) =>
|
||||
formatAiContext(searchForAi(db, message || "", user, limit || 5));
|
||||
ensureKnowledgeDirs(process.cwd());
|
||||
if (!getSetting("okf_single_brace_placeholder_migration_v1", false)) {
|
||||
migrateSingleBracePlaceholders(process.cwd());
|
||||
setSetting("okf_single_brace_placeholder_migration_v1", true);
|
||||
}
|
||||
generateKnowledgeFiles(process.cwd());
|
||||
registerOkfPlaceholderFields(placeholders);
|
||||
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
|
||||
const searchOkfForAi = ({ query = "", user, limit = 5, okf_retrieval = "light" } = {}) => {
|
||||
if (okf_retrieval === "none" || Number(limit) === 0) return [];
|
||||
const normalizedLimit = Math.max(1, Math.min(Number(limit) || 5, 25));
|
||||
const fileResults = searchFileKnowledge({ query, user, limit: normalizedLimit, rootDir: process.cwd() });
|
||||
const dbResults = searchForAi(db, query || "", user, normalizedLimit);
|
||||
const seen = new Set(fileResults.map((entry) => entry.source || entry.id));
|
||||
return [
|
||||
...fileResults,
|
||||
...dbResults.filter((entry) => {
|
||||
const key = entry.source || entry.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
].slice(0, normalizedLimit);
|
||||
};
|
||||
const okfContextProvider = ({ message, user, limit, okf_retrieval } = {}) => {
|
||||
const normalizedLimit = limit ?? 5;
|
||||
const query = message || "";
|
||||
const results = searchOkfForAi({ query, user, limit: normalizedLimit, okf_retrieval });
|
||||
return {
|
||||
blocks: formatAiContext(results),
|
||||
diagnostics: {
|
||||
provider: "okf",
|
||||
kind: "okf",
|
||||
query,
|
||||
okf_retrieval: okf_retrieval || "light",
|
||||
limit: normalizedLimit,
|
||||
candidate_count: results.length,
|
||||
returned_count: results.length,
|
||||
reason: okf_retrieval === "none" || Number(normalizedLimit) === 0 ? "disabled_by_controller" : "search_complete"
|
||||
}
|
||||
};
|
||||
};
|
||||
if (!global.lumiFrameworks) {
|
||||
global.lumiFrameworks = {};
|
||||
}
|
||||
global.lumiFrameworks.okf = {
|
||||
search: ({ query, user, limit } = {}) => searchForAi(db, query || "", user, limit || 5),
|
||||
search: searchOkfForAi,
|
||||
context: okfContextProvider,
|
||||
accessForUser: (user) => accessForUser(db, user)
|
||||
};
|
||||
@ -66,25 +119,60 @@ module.exports = {
|
||||
renderAdmin(req, res, db);
|
||||
});
|
||||
|
||||
router.post("/admin/entries", requireOkfEdit(db), (req, res) => {
|
||||
router.post("/admin/community", requireOkfEdit(db), (req, res) => {
|
||||
try {
|
||||
const entry = createEntry(db, req.body, req.session.user);
|
||||
req.session.flash = { type: "success", message: "OKF entry created." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||
validateOkfPlaceholderFields({
|
||||
body: req.body.body
|
||||
}, req.session.user, placeholders);
|
||||
const entry = saveCommunityKnowledgeFile(process.cwd(), req.body);
|
||||
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
|
||||
req.session.flash = { type: "success", message: "Community OKF file saved." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community#okf-community-files`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin/community/:slug", requireOkfEdit(db), (req, res) => {
|
||||
try {
|
||||
validateOkfPlaceholderFields({
|
||||
body: req.body.body
|
||||
}, req.session.user, placeholders);
|
||||
const entry = saveCommunityKnowledgeFile(process.cwd(), {
|
||||
...req.body,
|
||||
existing_slug: req.params.slug
|
||||
});
|
||||
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
|
||||
req.session.flash = { type: "success", message: "Community OKF file updated." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(req.params.slug)}#okf-community-files`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin/entries", requireOkfEdit(db), (req, res) => {
|
||||
try {
|
||||
validateOkfPlaceholderFields(req.body, req.session.user, placeholders);
|
||||
const entry = createEntry(db, req.body, req.session.user);
|
||||
req.session.flash = { type: "success", message: "OKF entry created." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin/entries/:slug", requireOkfEdit(db), (req, res) => {
|
||||
try {
|
||||
validateOkfPlaceholderFields(req.body, req.session.user, placeholders);
|
||||
const entry = updateEntry(db, req.params.slug, req.body, req.session.user);
|
||||
req.session.flash = { type: "success", message: "OKF entry updated." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(req.params.slug)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -92,10 +180,10 @@ module.exports = {
|
||||
try {
|
||||
const entry = setEntryWorkflow(db, req.params.slug, req.params.action, req.session.user, req.body.note || "");
|
||||
req.session.flash = { type: "success", message: "OKF workflow updated." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(req.params.slug)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -111,10 +199,10 @@ module.exports = {
|
||||
req.body.note || ""
|
||||
);
|
||||
req.session.flash = { type: "success", message: "OKF version restored." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(req.params.slug)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -125,7 +213,7 @@ module.exports = {
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
}
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin#okf-permissions`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general#okf-permissions`);
|
||||
});
|
||||
|
||||
router.post("/admin/permissions/:id/revoke", requireAdmin, (req, res) => {
|
||||
@ -135,11 +223,15 @@ module.exports = {
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
}
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin#okf-permissions`);
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general#okf-permissions`);
|
||||
});
|
||||
|
||||
router.get("/:slug", requireLogin, (req, res) => {
|
||||
const entry = getEntryBySlug(db, req.params.slug, req.session.user);
|
||||
router.get("/:slug", requireLogin, async (req, res) => {
|
||||
const entry = await resolveOkfEntryPlaceholders(
|
||||
getEntryBySlug(db, req.params.slug, req.session.user),
|
||||
req.session.user,
|
||||
placeholders
|
||||
);
|
||||
if (!entry) {
|
||||
return res.status(404).render("error", {
|
||||
title: "Knowledge entry unavailable",
|
||||
@ -179,16 +271,128 @@ module.exports = {
|
||||
|
||||
function formatAiContext(entries) {
|
||||
if (!entries.length) return [];
|
||||
return entries.map((entry) => [
|
||||
const prepared = preferSpecificKnowledgeChunks(entries).slice(0, 3);
|
||||
const blocks = [];
|
||||
let remaining = 4200;
|
||||
for (const entry of prepared) {
|
||||
const facts = trimForContext(entry.facts, Math.min(1400, remaining));
|
||||
const block = [
|
||||
`OKF entry: ${entry.title}`,
|
||||
`Source: ${entry.source}`,
|
||||
entry.source_metadata ? `Source metadata: ${formatSourceMetadata(entry.source_metadata)}` : "",
|
||||
entry.category ? `Category: ${entry.category}` : "",
|
||||
entry.summary ? `Summary: ${entry.summary}` : "",
|
||||
entry.facts ? `Facts:\n${entry.facts}` : ""
|
||||
].filter(Boolean).join("\n"));
|
||||
entry.summary ? `Summary: ${trimForContext(entry.summary, 360)}` : "",
|
||||
facts ? `Facts:\n${facts}` : ""
|
||||
].filter(Boolean).join("\n");
|
||||
if (!block || remaining <= 0) continue;
|
||||
const trimmedBlock = trimForContext(block, remaining);
|
||||
blocks.push(trimmedBlock);
|
||||
remaining -= trimmedBlock.length + 2;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function preferSpecificKnowledgeChunks(entries) {
|
||||
const hasSpecificRoute = entries.some((entry) => {
|
||||
const heading = String(entry.source_metadata?.heading || "");
|
||||
return /^(GET|POST|PUT|PATCH|DELETE|MOUNT)\s+\//i.test(heading);
|
||||
});
|
||||
if (!hasSpecificRoute) return entries;
|
||||
return entries.filter((entry) => {
|
||||
const heading = String(entry.source_metadata?.heading || "");
|
||||
return !["Routes", "Web Routes", "Route Reference"].includes(heading);
|
||||
});
|
||||
}
|
||||
|
||||
function trimForContext(value, maximum) {
|
||||
const text = String(value || "").trim();
|
||||
const limit = Math.max(0, Number(maximum) || 0);
|
||||
if (!text || !limit) return "";
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, Math.max(0, limit - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function formatSourceMetadata(metadata = {}) {
|
||||
return [
|
||||
metadata.path ? `path=${metadata.path}` : "",
|
||||
metadata.id ? `id=${metadata.id}` : "",
|
||||
metadata.heading ? `heading=${metadata.heading}` : "",
|
||||
Number.isFinite(Number(metadata.score)) ? `score=${Math.round(Number(metadata.score))}` : "",
|
||||
metadata.excerpt ? `excerpt=${String(metadata.excerpt).slice(0, 240)}` : ""
|
||||
].filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
function registerOkfPlaceholderFields(placeholders) {
|
||||
if (!placeholders?.registerFieldPolicy) return;
|
||||
placeholders.registerFieldPolicy({
|
||||
field_id: "okf.markdown",
|
||||
label: "OKF Markdown",
|
||||
field_type: "okf_markdown",
|
||||
output_audience: "user",
|
||||
min_editor_role: "user",
|
||||
allowed_namespaces: [],
|
||||
max_sensitivity: "admin",
|
||||
description: "Role-gated OKF Markdown content."
|
||||
});
|
||||
}
|
||||
|
||||
function validateOkfPlaceholderFields(values, user, placeholders) {
|
||||
if (!placeholders?.validateTemplate) return;
|
||||
const checks = [
|
||||
["user_markdown", "user"],
|
||||
["moderator_markdown", "mod"],
|
||||
["admin_markdown", "admin"],
|
||||
["ai_facts_markdown", "admin"],
|
||||
["body", "user"]
|
||||
];
|
||||
for (const [field, outputAudience] of checks) {
|
||||
if (values[field] === undefined) continue;
|
||||
const result = placeholders.validateTemplate({
|
||||
fieldId: "okf.markdown",
|
||||
template: values[field],
|
||||
outputAudience,
|
||||
user
|
||||
});
|
||||
if (!result.ok) {
|
||||
const tokens = result.errors
|
||||
.map((error) => error.token || error.id || error.reason)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.join(", ");
|
||||
throw new Error(`Unavailable or unauthorized OKF placeholder${tokens ? `: ${tokens}` : "."}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOkfEntryPlaceholders(entry, user, placeholders) {
|
||||
if (!entry || !placeholders?.renderTemplate) return entry;
|
||||
const resolved = { ...entry };
|
||||
const fields = [
|
||||
["user_markdown", "user"],
|
||||
["moderator_markdown", "mod"],
|
||||
["admin_markdown", "admin"],
|
||||
["ai_facts_markdown", "admin"]
|
||||
];
|
||||
for (const [field, outputAudience] of fields) {
|
||||
if (!resolved[field]) continue;
|
||||
const result = await placeholders.renderTemplate({
|
||||
fieldId: "okf.markdown",
|
||||
template: resolved[field],
|
||||
outputAudience,
|
||||
user,
|
||||
runtimeContext: {
|
||||
runtime: true,
|
||||
okf: { entry: resolved }
|
||||
},
|
||||
fallback: "[unavailable]"
|
||||
});
|
||||
resolved[field] = result.rendered;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function renderAdmin(req, res, db) {
|
||||
const activeTab = resolveAdminTab(req.query);
|
||||
const filters = {
|
||||
q: req.query.q || "",
|
||||
status: req.query.status || "",
|
||||
@ -196,25 +400,65 @@ function renderAdmin(req, res, db) {
|
||||
tag: req.query.tag || ""
|
||||
};
|
||||
const entries = listEntries(db, filters, req.session.user, { management: true });
|
||||
const allEntries = listEntries(db, {}, req.session.user, { management: true });
|
||||
const selected = req.query.edit ? getEditableEntry(db, req.query.edit) : null;
|
||||
const communityFiles = listCommunityKnowledgeFiles(process.cwd());
|
||||
const selectedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null;
|
||||
const systemFiles = listSystemKnowledgeFiles(process.cwd());
|
||||
const selectedSystemFile = req.query.system ? findSystemKnowledgeFile(systemFiles, req.query.system) : null;
|
||||
const suggestionEntries = [...allEntries, ...communityFiles];
|
||||
const versions = selected ? listVersions(db, selected.id) : [];
|
||||
res.render(view("admin"), {
|
||||
title: "OKF Management",
|
||||
activeTab,
|
||||
entries,
|
||||
filters,
|
||||
selected,
|
||||
communityFiles,
|
||||
selectedCommunity,
|
||||
systemFiles,
|
||||
selectedSystemFile,
|
||||
versions,
|
||||
permissions: listPermissions(db),
|
||||
levels: PERMISSION_LEVELS,
|
||||
statuses: STATUS_VALUES,
|
||||
visibilityValues: VISIBILITY_VALUES,
|
||||
reviewStates: REVIEW_STATES,
|
||||
categories: categoriesFor(entries),
|
||||
tags: tagsFor(entries),
|
||||
categories: categoriesFor(suggestionEntries),
|
||||
tags: tagsFor(suggestionEntries),
|
||||
renderMarkdown
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAdminTab(query = {}) {
|
||||
if (query.edit) return "general";
|
||||
if (query.community) return "community";
|
||||
if (query.system) return "system";
|
||||
return ["general", "community", "system"].includes(query.tab) ? query.tab : "general";
|
||||
}
|
||||
|
||||
function listSystemKnowledgeFiles(rootDir = process.cwd()) {
|
||||
return loadKnowledgeEntries(rootDir, { includeHidden: true })
|
||||
.filter((entry) => ["core", "plugins"].includes(entry.scope) && (entry.generated || !entry.editable))
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
slug: entry.file_slug,
|
||||
entry_slug: entry.slug,
|
||||
path: `knowledge/${entry.path}`
|
||||
}))
|
||||
.sort((a, b) => a.scope.localeCompare(b.scope) || a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function findSystemKnowledgeFile(files, key) {
|
||||
const value = String(key || "");
|
||||
return files.find((file) =>
|
||||
file.slug === value ||
|
||||
file.entry_slug === value ||
|
||||
file.id === value ||
|
||||
file.path === value
|
||||
) || null;
|
||||
}
|
||||
|
||||
function view(name) {
|
||||
return path.join(__dirname, "views", `${name}.ejs`);
|
||||
}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const Database = require("better-sqlite3");
|
||||
const { PRESERVED_PATHS } = require("../../../src/services/update-repository");
|
||||
const {
|
||||
accessForUser,
|
||||
createEntry,
|
||||
@ -15,6 +19,18 @@ const {
|
||||
setEntryWorkflow,
|
||||
updateEntry
|
||||
} = require("../backend/okf_store");
|
||||
const {
|
||||
ensureKnowledgeDirs,
|
||||
getCommunityKnowledgeFile,
|
||||
listCommunityKnowledgeFiles,
|
||||
loadKnowledgeEntries,
|
||||
listKnowledgePlaceholders,
|
||||
saveCorrectionKnowledgeFile,
|
||||
saveCommunityKnowledgeFile,
|
||||
migrateSingleBracePlaceholders,
|
||||
searchFileKnowledge
|
||||
} = require("../backend/file_knowledge");
|
||||
const { generateKnowledgeFiles } = require("../backend/generate_knowledge");
|
||||
const { renderMarkdown } = require("../backend/markdown");
|
||||
|
||||
const db = new Database(":memory:");
|
||||
@ -39,6 +55,27 @@ const mod = { id: "mod-1", isMod: true };
|
||||
const user = { id: "user-1" };
|
||||
const editor = { id: "editor-1" };
|
||||
|
||||
assert(PRESERVED_PATHS.includes("knowledge/community"));
|
||||
assert(PRESERVED_PATHS.includes("knowledge/corrections"));
|
||||
const adminTemplate = fs.readFileSync(path.join(__dirname, "..", "views", "admin.ejs"), "utf8");
|
||||
assert(adminTemplate.includes("General OKF"));
|
||||
assert(adminTemplate.includes("Community OKF"));
|
||||
assert(adminTemplate.includes("System-generated OKF"));
|
||||
assert(adminTemplate.includes("tab=general"));
|
||||
assert(adminTemplate.includes("tab=community"));
|
||||
assert(adminTemplate.includes("tab=system"));
|
||||
assert(adminTemplate.includes("selectedSystemFile"));
|
||||
assert(adminTemplate.includes("System-generated OKF") && !adminTemplate.includes("Save system file"));
|
||||
assert(adminTemplate.includes("data-okf-file-list"));
|
||||
assert(adminTemplate.includes("data-okf-file-search"));
|
||||
assert(adminTemplate.includes('data-okf-file-filter="category"'));
|
||||
assert(adminTemplate.includes("No community OKF files match these filters."));
|
||||
assert(adminTemplate.includes("No system-generated OKF files match these filters."));
|
||||
const okfPluginSource = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8");
|
||||
assert(okfPluginSource.includes("preferSpecificKnowledgeChunks"));
|
||||
assert(okfPluginSource.includes("trimForContext"));
|
||||
assert(okfPluginSource.includes("remaining = 4200"));
|
||||
|
||||
grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin);
|
||||
assert.equal(accessForUser(db, editor).canEdit, true);
|
||||
assert.equal(listPermissions(db).filter((grant) => !grant.revoked_at).length, 1);
|
||||
@ -124,6 +161,251 @@ assert.equal(searchForAi(db, "admin-only economy", user).length, 0);
|
||||
assert(renderMarkdown("<script>alert(1)</script> **safe**").includes("<script>"));
|
||||
assert.equal(renderMarkdown("[bad](javascript:alert(1))").includes("javascript:"), false);
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-"));
|
||||
ensureKnowledgeDirs(tempRoot);
|
||||
const commandsKnowledgePath = path.join(tempRoot, "knowledge", "core", "commands.md");
|
||||
const currencyKnowledgePath = path.join(tempRoot, "knowledge", "community", "currency.md");
|
||||
const correctionKnowledgePath = path.join(tempRoot, "knowledge", "corrections", "currency.md");
|
||||
fs.writeFileSync(commandsKnowledgePath, [
|
||||
"---",
|
||||
"id: core.commands",
|
||||
"title: Core commands",
|
||||
"scope: core",
|
||||
"status: active",
|
||||
"priority: 1",
|
||||
"visibility: user",
|
||||
"tags: commands, help",
|
||||
"generated: true",
|
||||
"editable: false",
|
||||
"---",
|
||||
"# Commands",
|
||||
"Use !help to list available commands.",
|
||||
"The public currency is {{community.currency.primary_name}}.",
|
||||
"The admin-only reference is {{community.admin-secret.secret_value}}."
|
||||
].join("\n"));
|
||||
fs.writeFileSync(currencyKnowledgePath, [
|
||||
"---",
|
||||
"id: community.currency",
|
||||
"title: Community currency",
|
||||
"scope: community",
|
||||
"status: active",
|
||||
"priority: 5",
|
||||
"visibility: user",
|
||||
"tags: currency, coins",
|
||||
"primary_name: coins",
|
||||
"---",
|
||||
"# Coins",
|
||||
"The community currency is coins, not points."
|
||||
].join("\n"));
|
||||
fs.writeFileSync(correctionKnowledgePath, [
|
||||
"---",
|
||||
"id: correction.currency-name",
|
||||
"title: Currency correction",
|
||||
"scope: corrections",
|
||||
"status: active",
|
||||
"priority: 10",
|
||||
"visibility: user",
|
||||
"tags: currency",
|
||||
"---",
|
||||
"# Currency name",
|
||||
"Always call the currency coins."
|
||||
].join("\n"));
|
||||
fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "admin-secret.md"), [
|
||||
"---",
|
||||
"id: community.admin-secret",
|
||||
"title: Admin secret",
|
||||
"scope: community",
|
||||
"status: active",
|
||||
"visibility: admin",
|
||||
"secret_value: hidden-admin-context",
|
||||
"---",
|
||||
"# Secret",
|
||||
"Only admins should retrieve this."
|
||||
].join("\n"));
|
||||
fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "people.md"), [
|
||||
"---",
|
||||
"id: community.people",
|
||||
"title: Community people",
|
||||
"scope: community",
|
||||
"status: active",
|
||||
"priority: 20",
|
||||
"visibility: user",
|
||||
"tags: people, identity, ookamikuntv, jenni",
|
||||
"---",
|
||||
"# Community people",
|
||||
"## OokamiKunTV",
|
||||
"OokamiKunTV is a known community contact in Lumi's local community knowledge."
|
||||
].join("\n"));
|
||||
|
||||
const fileEntries = loadKnowledgeEntries(tempRoot);
|
||||
assert.equal(fileEntries.length, 5);
|
||||
assert(fileEntries.every((item) => item.chunks.length >= 1));
|
||||
const userPlaceholders = listKnowledgePlaceholders({ user, rootDir: tempRoot, includeHidden: true });
|
||||
assert(userPlaceholders.includes("{{community.currency.primary_name}}"));
|
||||
assert.equal(userPlaceholders.includes("{{community.currency.scope}}"), false);
|
||||
assert.equal(userPlaceholders.includes("{{plugins.plugin-throne-wishlist.scope}}"), false);
|
||||
assert.equal(userPlaceholders.includes("{{community.admin-secret.secret_value}}"), false);
|
||||
const adminPlaceholders = listKnowledgePlaceholders({ user: admin, rootDir: tempRoot, includeHidden: true });
|
||||
assert(adminPlaceholders.includes("{{community.admin-secret.secret_value}}"));
|
||||
const currencyResults = searchFileKnowledge({ query: "currency coins", user, rootDir: tempRoot, limit: 5 });
|
||||
assert.equal(currencyResults[0].id, "correction.currency-name");
|
||||
assert(currencyResults[0].source_metadata.path.endsWith("corrections/currency.md"));
|
||||
assert(currencyResults[0].source_metadata.heading);
|
||||
assert(Number.isFinite(currencyResults[0].source_metadata.score));
|
||||
const identityResults = searchFileKnowledge({ query: "Who is OokamiKunTV?", user, rootDir: tempRoot, limit: 5 });
|
||||
assert.equal(identityResults[0].id, "community.people");
|
||||
assert(identityResults[0].facts.includes("OokamiKunTV is a known community contact"));
|
||||
const commandUserResult = searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0];
|
||||
assert(commandUserResult.facts.includes("The public currency is coins."));
|
||||
assert(commandUserResult.facts.includes("[missing OKF reference]"));
|
||||
const commandAdminResult = searchFileKnowledge({ query: "commands", user: admin, rootDir: tempRoot, limit: 5 })[0];
|
||||
assert(commandAdminResult.facts.includes("The admin-only reference is hidden-admin-context."));
|
||||
assert.equal(searchFileKnowledge({ query: "secret", user, rootDir: tempRoot, limit: 5 }).length, 0);
|
||||
assert.equal(searchFileKnowledge({ query: "secret", user: admin, rootDir: tempRoot, limit: 5 })[0].id, "community.admin-secret");
|
||||
const savedCommunity = saveCommunityKnowledgeFile(tempRoot, {
|
||||
title: "Community roles",
|
||||
slug: "community-roles",
|
||||
id: "community.roles",
|
||||
category: "Community",
|
||||
visibility: "user",
|
||||
status: "active",
|
||||
priority: "7",
|
||||
tags: "roles, community",
|
||||
body: "# Roles\nCommunity roles are documented here."
|
||||
});
|
||||
assert.equal(savedCommunity.id, "community.roles");
|
||||
assert.equal(getCommunityKnowledgeFile(tempRoot, "community-roles").title, "Community roles");
|
||||
assert(listCommunityKnowledgeFiles(tempRoot).some((item) => item.slug === "community-roles"));
|
||||
const renamedCommunity = saveCommunityKnowledgeFile(tempRoot, {
|
||||
existing_slug: "community-roles",
|
||||
title: "Community role guide",
|
||||
slug: "role-guide",
|
||||
id: "community.roles",
|
||||
category: "Community",
|
||||
visibility: "user",
|
||||
status: "active",
|
||||
tags: "roles",
|
||||
body: "# Roles\nRenamed community role guide."
|
||||
});
|
||||
assert.equal(renamedCommunity.slug, "role-guide");
|
||||
assert(fs.existsSync(path.join(tempRoot, "knowledge", "community", "role-guide.md")));
|
||||
assert.equal(fs.existsSync(path.join(tempRoot, "knowledge", "community", "community-roles.md")), false);
|
||||
fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "generated.md"), [
|
||||
"---",
|
||||
"id: community.generated",
|
||||
"title: Generated community file",
|
||||
"scope: community",
|
||||
"generated: true",
|
||||
"editable: false",
|
||||
"---",
|
||||
"# Generated"
|
||||
].join("\n"));
|
||||
assert.throws(() => saveCommunityKnowledgeFile(tempRoot, {
|
||||
existing_slug: "community-generated",
|
||||
title: "Blocked",
|
||||
body: "# Blocked"
|
||||
}), /not editable/);
|
||||
const savedCorrection = saveCorrectionKnowledgeFile(tempRoot, {
|
||||
title: "Feedback correction",
|
||||
slug: "feedback-correction",
|
||||
source_feedback_id: "feedback-1",
|
||||
source_feedback_url: "/admin/feedback?feedback=feedback-1",
|
||||
visibility: "user",
|
||||
priority: "120",
|
||||
body: "# Feedback correction\nUse the corrected behavior from reviewed feedback."
|
||||
});
|
||||
assert.equal(savedCorrection.id, "correction.feedback-correction");
|
||||
assert.equal(savedCorrection.path, "knowledge/corrections/feedback-correction.md");
|
||||
assert.equal(savedCorrection.frontmatter.source_feedback_id, "feedback-1");
|
||||
assert.equal(searchFileKnowledge({ query: "corrected behavior", user, rootDir: tempRoot, limit: 5 })[0].id, "correction.feedback-correction");
|
||||
fs.writeFileSync(currencyKnowledgePath, [
|
||||
"---",
|
||||
"id: community.currency",
|
||||
"title: Community currency",
|
||||
"scope: community",
|
||||
"status: active",
|
||||
"priority: 5",
|
||||
"visibility: user",
|
||||
"tags: currency, coins",
|
||||
"primary_name: gold stars",
|
||||
"---",
|
||||
"# Coins",
|
||||
"The community currency is gold stars, not points."
|
||||
].join("\n"));
|
||||
assert(searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0].facts.includes("The public currency is gold stars."));
|
||||
fs.rmSync(correctionKnowledgePath, { force: true });
|
||||
assert.equal(searchFileKnowledge({ query: "currency coins", user, rootDir: tempRoot, limit: 5 }).some((item) => item.id === "correction.currency-name"), false);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
|
||||
const migrationRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-migrate-"));
|
||||
ensureKnowledgeDirs(migrationRoot);
|
||||
const legacyPlaceholderPath = path.join(migrationRoot, "knowledge", "community", "legacy.md");
|
||||
fs.writeFileSync(legacyPlaceholderPath, [
|
||||
"---",
|
||||
"id: community.legacy",
|
||||
"title: Legacy placeholder",
|
||||
"scope: community",
|
||||
"status: active",
|
||||
"visibility: user",
|
||||
"---",
|
||||
"# Legacy",
|
||||
"The old token is {community.currency.primary_name}.",
|
||||
"The new token stays {{community.currency.primary_name}}."
|
||||
].join("\n"));
|
||||
const migratedPlaceholders = migrateSingleBracePlaceholders(migrationRoot);
|
||||
assert.equal(migratedPlaceholders.changed, 1);
|
||||
const migratedPlaceholderContent = fs.readFileSync(legacyPlaceholderPath, "utf8");
|
||||
assert(migratedPlaceholderContent.includes("The old token is {{community.currency.primary_name}}."));
|
||||
assert(migratedPlaceholderContent.includes("The new token stays {{community.currency.primary_name}}."));
|
||||
fs.rmSync(migrationRoot, { recursive: true, force: true });
|
||||
|
||||
const generatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-generated-"));
|
||||
fs.mkdirSync(path.join(generatedRoot, "plugins", "test-plugin"), { recursive: true });
|
||||
fs.writeFileSync(path.join(generatedRoot, "package.json"), JSON.stringify({
|
||||
name: "lumi-test",
|
||||
version: "1.2.3",
|
||||
description: "Generated test core."
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(generatedRoot, "README.md"), "# Lumi Test\n\nGenerated README.");
|
||||
fs.mkdirSync(path.join(generatedRoot, "src", "web"), { recursive: true });
|
||||
fs.writeFileSync(path.join(generatedRoot, "src", "web", "server.js"), "app.get('/admin/test', handler);\napp.post('/api/test', handler);\n");
|
||||
fs.writeFileSync(path.join(generatedRoot, "plugins", "test-plugin", "plugin.json"), JSON.stringify({
|
||||
id: "test-plugin",
|
||||
name: "Test Plugin",
|
||||
version: "0.0.1",
|
||||
description: "Generated plugin knowledge."
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(generatedRoot, "plugins", "test-plugin", "index.js"), [
|
||||
"router.get('/settings', handler);",
|
||||
"commandRouter.registerCommands('test-plugin', [{ trigger: 'test', description: 'Test command.' }]);"
|
||||
].join("\n"));
|
||||
const generated = generateKnowledgeFiles(generatedRoot);
|
||||
const normalizedGenerated = generated.map((file) => file.replace(/\\/g, "/"));
|
||||
assert(normalizedGenerated.some((file) => file.endsWith("knowledge/core/lumi-core.md")));
|
||||
assert(normalizedGenerated.some((file) => file.endsWith("knowledge/plugins/test-plugin.md")));
|
||||
const generatedCore = fs.readFileSync(path.join(generatedRoot, "knowledge", "core", "lumi-core.md"), "utf8");
|
||||
assert(generatedCore.includes("GET /admin/test"));
|
||||
assert(generatedCore.includes("## Route Reference"));
|
||||
assert(generatedCore.includes("### GET /admin/test"));
|
||||
assert(generatedCore.includes("Response format:"));
|
||||
const generatedPlugin = fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8");
|
||||
assert(generatedPlugin.includes("Test Plugin"));
|
||||
assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings"));
|
||||
assert(generatedPlugin.includes("Inputs:"));
|
||||
assert(generatedPlugin.includes("test"));
|
||||
fs.writeFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), [
|
||||
"---",
|
||||
"id: plugin.test-plugin",
|
||||
"title: Manual Test Plugin",
|
||||
"generated: false",
|
||||
"editable: true",
|
||||
"---",
|
||||
"# Manual"
|
||||
].join("\n"));
|
||||
generateKnowledgeFiles(generatedRoot);
|
||||
assert(fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8").includes("Manual Test Plugin"));
|
||||
fs.rmSync(generatedRoot, { recursive: true, force: true });
|
||||
|
||||
const grant = listPermissions(db).find((row) => row.user_id === "editor-1" && !row.revoked_at);
|
||||
revokePermission(db, grant.id, admin);
|
||||
assert.equal(accessForUser(db, editor).canEdit, false);
|
||||
|
||||
@ -5,7 +5,14 @@
|
||||
pageTitle: "OKF Management",
|
||||
description: "Manage role-gated knowledge entries, review state, version history, and OKF-specific editing permissions."
|
||||
}) %>
|
||||
<nav class="tabs" aria-label="OKF management sections">
|
||||
<a href="/plugins/okf/admin?tab=general" <%= activeTab === "general" ? 'aria-current="page"' : "" %>>General OKF</a>
|
||||
<a href="/plugins/okf/admin?tab=community" <%= activeTab === "community" ? 'aria-current="page"' : "" %>>Community OKF</a>
|
||||
<a href="/plugins/okf/admin?tab=system" <%= activeTab === "system" ? 'aria-current="page"' : "" %>>System-generated OKF</a>
|
||||
</nav>
|
||||
<% if (activeTab === "general") { %>
|
||||
<form method="get" action="/plugins/okf/admin" class="log-controls">
|
||||
<input type="hidden" name="tab" value="general" />
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input name="q" value="<%= filters.q %>" placeholder="Search visible OKF fields" />
|
||||
@ -38,17 +45,36 @@
|
||||
</select>
|
||||
</label>
|
||||
<button class="button subtle" type="submit">Filter</button>
|
||||
<a class="button subtle" href="/plugins/okf/admin">Reset</a>
|
||||
<a class="button subtle" href="/plugins/okf/admin?tab=general">Reset</a>
|
||||
</form>
|
||||
<% } else if (activeTab === "community") { %>
|
||||
<p class="hint">Community files are locally maintained OKF Markdown under <code>knowledge/community</code>.</p>
|
||||
<% } else { %>
|
||||
<p class="hint">System-generated files are read-only OKF Markdown from core and plugin metadata.</p>
|
||||
<% } %>
|
||||
<datalist id="okf-category-suggestions">
|
||||
<% categories.forEach((category) => { %>
|
||||
<option value="<%= category %>"></option>
|
||||
<% }) %>
|
||||
</datalist>
|
||||
<datalist id="okf-tag-suggestions">
|
||||
<% tags.forEach((tag) => { %>
|
||||
<option value="<%= tag %>"></option>
|
||||
<% }) %>
|
||||
</datalist>
|
||||
</section>
|
||||
|
||||
<% if (activeTab === "general") { %>
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Entries</h2>
|
||||
<p class="hint"><%= entries.length %> entr<%= entries.length === 1 ? "y" : "ies" %> shown.</p>
|
||||
</div>
|
||||
<a class="button subtle" href="/plugins/okf">Open OKF</a>
|
||||
<div class="button-group">
|
||||
<button class="button" type="button" data-okf-create-open>Create OKF entry</button>
|
||||
<a class="button subtle" href="/plugins/okf">Open OKF</a>
|
||||
</div>
|
||||
</div>
|
||||
<% if (!entries.length) { %>
|
||||
<div class="empty-state">No OKF entries match this filter.</div>
|
||||
@ -68,7 +94,7 @@
|
||||
<% entries.forEach((entry) => { %>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/plugins/okf/admin?edit=<%= entry.slug %>"><strong><%= entry.title %></strong></a>
|
||||
<a href="/plugins/okf/admin?tab=general&edit=<%= encodeURIComponent(entry.slug) %>"><strong><%= entry.title %></strong></a>
|
||||
<p class="hint"><%= entry.slug %> · <%= entry.category || "General" %></p>
|
||||
</td>
|
||||
<td><span class="badge"><%= entry.status %></span></td>
|
||||
@ -82,18 +108,189 @@
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class="card" id="okf-editor">
|
||||
<% if (activeTab === "community") { %>
|
||||
<section class="card" id="okf-community-files">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2><%= selected ? "Edit OKF entry" : "Create OKF entry" %></h2>
|
||||
<p class="hint">Markdown is sanitized before rendering. User, moderator, and admin content are filtered server-side.</p>
|
||||
<h2>Community OKF files</h2>
|
||||
<p class="hint">Edit community-specific Markdown knowledge stored under <code>knowledge/community</code>. Generated core and plugin files stay separate.</p>
|
||||
</div>
|
||||
<% if (selected) { %>
|
||||
<a class="button subtle" href="/plugins/okf/admin">Create new</a>
|
||||
</div>
|
||||
<div data-okf-file-list>
|
||||
<form class="log-controls" data-okf-file-filters>
|
||||
<label><span>Search files</span><input type="search" data-okf-file-search placeholder="Title, ID, category, tag" /></label>
|
||||
<label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(communityFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Status</span><select data-okf-file-filter="status"><option value="">All statuses</option><% [...new Set(communityFiles.map((file) => file.status).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(communityFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<button class="button subtle" type="reset">Reset</button>
|
||||
</form>
|
||||
<p class="hint" data-okf-file-count><%= communityFiles.length %> file<%= communityFiles.length === 1 ? "" : "s" %> shown.</p>
|
||||
<% if (!communityFiles.length) { %>
|
||||
<div class="empty-state">No community OKF files yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>File</th><th>Category</th><th>Status</th><th>Visibility</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
<% communityFiles.forEach((file) => { %>
|
||||
<tr data-okf-file-row data-category="<%= file.category %>" data-status="<%= file.status %>" data-visibility="<%= file.visibility %>" data-search="<%= [file.title, file.id, file.slug, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
|
||||
<td><a href="/plugins/okf/admin?tab=community&community=<%= encodeURIComponent(file.slug) %>#okf-community-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.slug %>.md</p></td>
|
||||
<td><%= file.category || "Community" %></td>
|
||||
<td><span class="badge"><%= file.status %></span></td>
|
||||
<td><%= file.visibility %></td>
|
||||
<td><%= file.updated_at ? new Date(file.updated_at).toLocaleString() : "Unknown" %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-state" data-okf-file-empty hidden>No community OKF files match these filters.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<form method="post" action="<%= selected ? `/plugins/okf/admin/entries/${selected.slug}` : '/plugins/okf/admin/entries' %>" class="form-grid">
|
||||
<article class="stat-card">
|
||||
<span class="stat-label"><%= selectedCommunity ? "Edit file" : "Create file" %></span>
|
||||
<form method="post" action="<%= selectedCommunity ? `/plugins/okf/admin/community/${encodeURIComponent(selectedCommunity.slug)}` : '/plugins/okf/admin/community' %>" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input name="title" required value="<%= selectedCommunity ? selectedCommunity.title : '' %>" placeholder="Community currency" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input name="slug" value="<%= selectedCommunity ? selectedCommunity.slug : '' %>" placeholder="community-currency" />
|
||||
<span class="hint">Used as the Markdown filename.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>ID</label>
|
||||
<input name="id" value="<%= selectedCommunity ? selectedCommunity.id : '' %>" placeholder="community.currency" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Category</label>
|
||||
<input class="okf-text-suggestion" name="category" list="okf-category-suggestions" value="<%= selectedCommunity ? selectedCommunity.category : 'Community' %>" />
|
||||
<span class="hint">Existing categories appear as suggestions.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select name="status">
|
||||
<% ["active", "published", "draft", "archived", "disabled"].forEach((status) => { %>
|
||||
<option value="<%= status %>" <%= selectedCommunity && selectedCommunity.status === status ? "selected" : "" %>><%= status %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<select name="visibility">
|
||||
<% visibilityValues.forEach((value) => { %>
|
||||
<option value="<%= value %>" <%= selectedCommunity && selectedCommunity.visibility === value ? "selected" : "" %>><%= value %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Priority</label>
|
||||
<input name="priority" type="number" step="1" value="<%= selectedCommunity ? selectedCommunity.priority : 0 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tags</label>
|
||||
<input name="tags" list="okf-tag-suggestions" value="<%= selectedCommunity ? selectedCommunity.tags.join(', ') : '' %>" placeholder="currency, roles, rules" />
|
||||
<span class="hint">Use comma-separated tags. Existing tags appear as suggestions while typing.</span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Markdown body</label>
|
||||
<textarea name="body" rows="14" placeholder="# Community facts" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"><%= selectedCommunity ? selectedCommunity.body : '' %></textarea>
|
||||
<span class="hint">You can reference visible frontmatter values with placeholders such as <code>{{community.currency.primary_name}}</code>.</span>
|
||||
</div>
|
||||
<% if (selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable)) { %>
|
||||
<div class="field full">
|
||||
<p class="notice error">This file is marked generated or non-editable and cannot be saved from this editor.</p>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="field full button-group centered">
|
||||
<button class="button" type="submit" <%= selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable) ? "disabled" : "" %>><%= selectedCommunity ? "Save community file" : "Create community file" %></button>
|
||||
<% if (selectedCommunity) { %>
|
||||
<a class="button subtle" href="/plugins/okf/admin?tab=community#okf-community-files">Create new file</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
<% if (selectedCommunity) { %>
|
||||
<details class="feedback-metadata" open>
|
||||
<summary>Preview</summary>
|
||||
<p class="hint"><%= selectedCommunity.path %> · <%= selectedCommunity.id %></p>
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selectedCommunity.body || "_No Markdown body yet._") %></div>
|
||||
</details>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<% if (activeTab === "system") { %>
|
||||
<section class="card" id="okf-system-files">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>System-generated OKF</h2>
|
||||
<p class="hint">Read-only generated knowledge used by AI support and scoped by user permission level.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div data-okf-file-list>
|
||||
<form class="log-controls" data-okf-file-filters>
|
||||
<label><span>Search files</span><input type="search" data-okf-file-search placeholder="Title, ID, scope, category, tag" /></label>
|
||||
<label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(systemFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Scope</span><select data-okf-file-filter="scope"><option value="">All scopes</option><% [...new Set(systemFiles.map((file) => file.scope).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(systemFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<button class="button subtle" type="reset">Reset</button>
|
||||
</form>
|
||||
<p class="hint" data-okf-file-count><%= systemFiles.length %> file<%= systemFiles.length === 1 ? "" : "s" %> shown. Generated files are rebuilt from repository metadata and are read-only here.</p>
|
||||
<% if (!systemFiles.length) { %>
|
||||
<div class="empty-state">No generated OKF files were found.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>File</th><th>Scope</th><th>Category</th><th>Status</th><th>Visibility</th></tr></thead>
|
||||
<tbody>
|
||||
<% systemFiles.forEach((file) => { %>
|
||||
<tr data-okf-file-row data-category="<%= file.category %>" data-scope="<%= file.scope %>" data-visibility="<%= file.visibility %>" data-search="<%= [file.title, file.id, file.slug, file.scope, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
|
||||
<td><a href="/plugins/okf/admin?tab=system&system=<%= encodeURIComponent(file.slug) %>#okf-system-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.path %></p></td>
|
||||
<td><%= file.scope %></td>
|
||||
<td><%= file.category || "General" %></td>
|
||||
<td><span class="badge"><%= file.status %></span></td>
|
||||
<td><%= file.visibility %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-state" data-okf-file-empty hidden>No system-generated OKF files match these filters.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="callout"><strong>Role-scoped support context</strong><p>User, moderator, and admin visibility still applies during retrieval. Casual requests receive support-level facts; development-focused admin requests may use deeper technical details.</p></div>
|
||||
<% if (selectedSystemFile) { %>
|
||||
<details class="feedback-metadata" open>
|
||||
<summary><%= selectedSystemFile.title %></summary>
|
||||
<p class="hint">
|
||||
<%= selectedSystemFile.path %> · <%= selectedSystemFile.id %> · <%= selectedSystemFile.status %> · <%= selectedSystemFile.visibility %>
|
||||
</p>
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selectedSystemFile.body || "_No Markdown body available._") %></div>
|
||||
</details>
|
||||
<% } else if (systemFiles.length) { %>
|
||||
<div class="empty-state">Select a generated OKF file to preview its current Markdown.</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<% if (selected) { %>
|
||||
<div class="modal-backdrop okf-edit-modal is-open" id="okf-editor" data-okf-edit-modal aria-hidden="false">
|
||||
<div class="modal okf-entry-modal-dialog okf-entry-fullscreen-modal" role="dialog" aria-modal="true" aria-labelledby="okf-edit-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="okf-edit-title">Edit OKF entry</h2>
|
||||
<p class="hint">Markdown is sanitized before rendering. User, moderator, and admin content are filtered server-side.</p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<a class="button subtle" href="/plugins/okf/admin?tab=general">Create new</a>
|
||||
<button class="button subtle" type="button" data-okf-edit-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>" class="form-grid" data-okf-edit-form data-okf-draft-key="lumi.okf.edit.<%= selected.slug %>">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input name="title" required value="<%= selected ? selected.title : '' %>" />
|
||||
@ -104,11 +301,12 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Category</label>
|
||||
<input name="category" value="<%= selected ? selected.category : '' %>" placeholder="General, Support, Commands" />
|
||||
<input name="category" list="okf-category-suggestions" value="<%= selected ? selected.category : '' %>" placeholder="General, Support, Commands" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tags</label>
|
||||
<input name="tags" value="<%= selected ? selected.tags.join(', ') : '' %>" placeholder="Comma-separated tags" />
|
||||
<input name="tags" list="okf-tag-suggestions" value="<%= selected ? selected.tags.join(', ') : '' %>" placeholder="Comma-separated tags" />
|
||||
<span class="hint">Use comma-separated tags. Existing tags appear as suggestions while typing.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
@ -146,19 +344,19 @@
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>User-facing Markdown answer</label>
|
||||
<textarea name="user_markdown" rows="8"><%= selected ? selected.user_markdown : '' %></textarea>
|
||||
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"><%= selected ? selected.user_markdown : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Moderator/support Markdown details</label>
|
||||
<textarea name="moderator_markdown" rows="6"><%= selected ? selected.moderator_markdown : '' %></textarea>
|
||||
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"><%= selected ? selected.moderator_markdown : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Admin/internal Markdown details</label>
|
||||
<textarea name="admin_markdown" rows="6"><%= selected ? selected.admin_markdown : '' %></textarea>
|
||||
<textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"><%= selected ? selected.admin_markdown : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>AI-facing facts/context</label>
|
||||
<textarea name="ai_facts_markdown" rows="6"><%= selected ? selected.ai_facts_markdown : '' %></textarea>
|
||||
<textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"><%= selected ? selected.ai_facts_markdown : '' %></textarea>
|
||||
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
@ -170,11 +368,12 @@
|
||||
<input name="change_note" placeholder="Optional note for version history" />
|
||||
</div>
|
||||
<div class="field full button-group centered">
|
||||
<button class="button" type="submit"><%= selected ? "Save OKF entry" : "Create OKF entry" %></button>
|
||||
<button class="button" type="submit">Save OKF entry</button>
|
||||
<button class="button subtle" type="button" data-okf-edit-reset>Reset draft</button>
|
||||
<button class="button subtle" type="button" data-okf-edit-close>Close</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% if (selected) { %>
|
||||
<details class="feedback-metadata">
|
||||
<summary>Role preview</summary>
|
||||
<div class="stats-grid">
|
||||
@ -248,10 +447,105 @@
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</details>
|
||||
<% } %>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (okfAccess.canManagePermissions) { %>
|
||||
<div class="modal-backdrop okf-create-modal" data-okf-create-modal aria-hidden="true">
|
||||
<div class="modal okf-entry-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="okf-create-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="okf-create-title">Create OKF entry</h2>
|
||||
<p class="hint">Markdown is sanitized before rendering. User, moderator, and admin content are filtered server-side.</p>
|
||||
</div>
|
||||
<button class="button subtle" type="button" data-okf-create-close>Close</button>
|
||||
</div>
|
||||
<form method="post" action="/plugins/okf/admin/entries" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input name="title" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input name="slug" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Category</label>
|
||||
<input name="category" list="okf-category-suggestions" placeholder="General, Support, Commands" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tags</label>
|
||||
<input name="tags" list="okf-tag-suggestions" placeholder="Comma-separated tags" />
|
||||
<span class="hint">Use comma-separated tags. Existing tags appear as suggestions while typing.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<select name="visibility">
|
||||
<% visibilityValues.forEach((value) => { %>
|
||||
<option value="<%= value %>"><%= value %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select name="status" <%= okfAccess.canImplement ? "" : "disabled" %>>
|
||||
<% statuses.forEach((status) => { %>
|
||||
<option value="<%= status %>"><%= status %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<% if (!okfAccess.canImplement) { %><span class="hint">Editors can propose changes; publishing requires implement permission.</span><% } %>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Review state</label>
|
||||
<select name="review_state" <%= okfAccess.canImplement ? "" : "disabled" %>>
|
||||
<% reviewStates.forEach((state) => { %>
|
||||
<option value="<%= state %>"><%= state %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Aliases / related questions</label>
|
||||
<textarea name="aliases" rows="3" placeholder="One related question per line"></textarea>
|
||||
<span class="hint">Use one question per line. Commas are kept as part of the question.</span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Short summary</label>
|
||||
<textarea name="summary" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>User-facing Markdown answer</label>
|
||||
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Moderator/support Markdown details</label>
|
||||
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Admin/internal Markdown details</label>
|
||||
<textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>AI-facing facts/context</label>
|
||||
<textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea>
|
||||
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Source links / references</label>
|
||||
<textarea name="source_links" rows="3" placeholder="One URL or local path per line"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Change note</label>
|
||||
<input name="change_note" placeholder="Optional note for version history" />
|
||||
</div>
|
||||
<div class="field full button-group centered">
|
||||
<button class="button" type="submit">Create OKF entry</button>
|
||||
<button class="button subtle" type="button" data-okf-create-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (activeTab === "general" && okfAccess.canManagePermissions) { %>
|
||||
<section class="card" id="okf-permissions">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
@ -319,4 +613,120 @@
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
<script>
|
||||
document.querySelectorAll("[data-okf-file-list]").forEach((browser) => {
|
||||
const form = browser.querySelector("[data-okf-file-filters]");
|
||||
const search = browser.querySelector("[data-okf-file-search]");
|
||||
const rows = [...browser.querySelectorAll("[data-okf-file-row]")];
|
||||
const count = browser.querySelector("[data-okf-file-count]");
|
||||
const empty = browser.querySelector("[data-okf-file-empty]");
|
||||
const applyFilters = () => {
|
||||
const query = String(search?.value || "").trim().toLowerCase();
|
||||
const filters = [...browser.querySelectorAll("[data-okf-file-filter]")];
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const matchesSearch = !query || String(row.dataset.search || "").includes(query);
|
||||
const matchesFilters = filters.every((field) => !field.value || row.dataset[field.dataset.okfFileFilter] === field.value);
|
||||
row.hidden = !(matchesSearch && matchesFilters);
|
||||
if (!row.hidden) visible += 1;
|
||||
});
|
||||
if (count) count.textContent = `${visible} file${visible === 1 ? "" : "s"} shown.`;
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
};
|
||||
form?.addEventListener("input", applyFilters);
|
||||
form?.addEventListener("change", applyFilters);
|
||||
form?.addEventListener("submit", (event) => event.preventDefault());
|
||||
form?.addEventListener("reset", () => window.setTimeout(applyFilters, 0));
|
||||
});
|
||||
|
||||
(() => {
|
||||
const modal = document.querySelector("[data-okf-create-modal]");
|
||||
const openButtons = document.querySelectorAll("[data-okf-create-open]");
|
||||
const closeButtons = modal?.querySelectorAll("[data-okf-create-close]") || [];
|
||||
const firstInput = modal?.querySelector("input[name='title']");
|
||||
const openModal = () => {
|
||||
if (!modal) return;
|
||||
modal.classList.add("is-open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
window.setTimeout(() => firstInput?.focus(), 0);
|
||||
};
|
||||
const closeModal = () => {
|
||||
if (!modal) return;
|
||||
modal.classList.remove("is-open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
};
|
||||
openButtons.forEach((button) => button.addEventListener("click", openModal));
|
||||
closeButtons.forEach((button) => button.addEventListener("click", closeModal));
|
||||
modal?.addEventListener("click", (event) => {
|
||||
if (event.target === modal) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && modal?.classList.contains("is-open")) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
})();
|
||||
(() => {
|
||||
const modal = document.querySelector("[data-okf-edit-modal]");
|
||||
const form = document.querySelector("[data-okf-edit-form]");
|
||||
if (!modal || !form) return;
|
||||
const storageKey = form.dataset.okfDraftKey || "lumi.okf.edit";
|
||||
const fieldSelector = "input[name], textarea[name], select[name]";
|
||||
const firstInput = form.querySelector("input[name='title']");
|
||||
const draftFields = () => Array.from(form.querySelectorAll(fieldSelector))
|
||||
.filter((field) => field.name && field.type !== "hidden" && !field.disabled);
|
||||
const saveDraft = () => {
|
||||
const draft = {};
|
||||
draftFields().forEach((field) => {
|
||||
draft[field.name] = field.value || "";
|
||||
});
|
||||
try {
|
||||
window.sessionStorage?.setItem(storageKey, JSON.stringify(draft));
|
||||
} catch {}
|
||||
};
|
||||
const loadDraft = () => {
|
||||
try {
|
||||
return JSON.parse(window.sessionStorage?.getItem(storageKey) || "null") || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const clearDraft = () => {
|
||||
try {
|
||||
window.sessionStorage?.removeItem(storageKey);
|
||||
} catch {}
|
||||
};
|
||||
const restoreDraft = () => {
|
||||
const draft = loadDraft();
|
||||
if (!draft) return;
|
||||
draftFields().forEach((field) => {
|
||||
if (Object.prototype.hasOwnProperty.call(draft, field.name)) {
|
||||
field.value = draft[field.name];
|
||||
}
|
||||
});
|
||||
};
|
||||
const closeModal = () => {
|
||||
saveDraft();
|
||||
modal.classList.remove("is-open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
};
|
||||
const openModal = () => {
|
||||
modal.classList.add("is-open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
window.setTimeout(() => firstInput?.focus(), 0);
|
||||
};
|
||||
restoreDraft();
|
||||
openModal();
|
||||
form.addEventListener("input", saveDraft);
|
||||
form.addEventListener("change", saveDraft);
|
||||
form.addEventListener("submit", clearDraft);
|
||||
modal.querySelectorAll("[data-okf-edit-close]").forEach((button) => {
|
||||
button.addEventListener("click", closeModal);
|
||||
});
|
||||
modal.querySelector("[data-okf-edit-reset]")?.addEventListener("click", () => {
|
||||
clearDraft();
|
||||
window.location.reload();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
|
||||
@ -50,14 +50,31 @@ prevent delivery attempts to other enabled destinations.
|
||||
|
||||
Common placeholders:
|
||||
|
||||
`{contract_version}`, `{event_id}`, `{event_type}`,
|
||||
`{webhook_identifier}`, `{webhook_uuid}`, `{received_at}`,
|
||||
`{received_at_iso}`, `{creator_id}`, `{creator_username}`,
|
||||
`{gifter_username}`, `{message}`, `{item_name}`,
|
||||
`{item_thumbnail_url}`, `{price}`, `{amount}`, `{currency}`,
|
||||
`{is_surprise_gift}`, `{price_display}`, `{amount_display}`, `{value}`,
|
||||
`{value_display}`, and `{payload_json}`.
|
||||
`{{plugin.throne_wishlist.contract_version}}`,
|
||||
`{{plugin.throne_wishlist.event_id}}`,
|
||||
`{{plugin.throne_wishlist.event_type}}`,
|
||||
`{{plugin.throne_wishlist.webhook_identifier}}`,
|
||||
`{{plugin.throne_wishlist.webhook_uuid}}`,
|
||||
`{{plugin.throne_wishlist.received_at}}`,
|
||||
`{{plugin.throne_wishlist.received_at_iso}}`,
|
||||
`{{plugin.throne_wishlist.creator_id}}`,
|
||||
`{{plugin.throne_wishlist.creator_username}}`,
|
||||
`{{plugin.throne_wishlist.gifter_username}}`,
|
||||
`{{plugin.throne_wishlist.message}}`,
|
||||
`{{plugin.throne_wishlist.item_name}}`,
|
||||
`{{plugin.throne_wishlist.item_thumbnail_url}}`,
|
||||
`{{plugin.throne_wishlist.price}}`,
|
||||
`{{plugin.throne_wishlist.amount}}`,
|
||||
`{{plugin.throne_wishlist.currency}}`,
|
||||
`{{plugin.throne_wishlist.is_surprise_gift}}`,
|
||||
`{{plugin.throne_wishlist.price_display}}`,
|
||||
`{{plugin.throne_wishlist.amount_display}}`,
|
||||
`{{plugin.throne_wishlist.value}}`, and
|
||||
`{{plugin.throne_wishlist.value_display}}`.
|
||||
|
||||
Documented data fields are also available with a `data.` prefix, such as
|
||||
`{data.item_name}`. Unknown placeholders remain unchanged in rendered messages.
|
||||
The admin page warns when they are saved.
|
||||
`{{plugin.throne_wishlist.data.item_name}}`. Legacy single-brace placeholders
|
||||
still render for existing templates, but new templates should use the namespaced
|
||||
double-brace syntax. Raw payload JSON is intentionally not available to
|
||||
user-visible message templates. Unknown placeholders remain unchanged in
|
||||
rendered messages. The admin page warns when they are saved.
|
||||
|
||||
@ -4,6 +4,7 @@ const path = require("path");
|
||||
const discord = require("discord.js");
|
||||
const { log } = require("../../src/services/logger");
|
||||
const { getPlatformStatus } = require("../../src/services/platforms");
|
||||
const placeholderService = require("../../src/services/placeholders");
|
||||
|
||||
const PLUGIN_ID = "throne_wishlist";
|
||||
const NAMESPACE = "throne";
|
||||
@ -21,11 +22,11 @@ const EVENT_TYPES = [
|
||||
const PLATFORMS = ["discord", "twitch", "youtube", "kick"];
|
||||
const DEFAULT_TEMPLATES = {
|
||||
gift_purchased:
|
||||
"🎁 {gifter_username} bought {item_name} from {creator_username}'s Throne wishlist!",
|
||||
"🎁 {{gifter_username}} bought {{item_name}} from {{creator_username}}'s Throne wishlist!",
|
||||
contribution_purchased:
|
||||
"💝 {gifter_username} contributed {amount_display} toward {item_name} on {creator_username}'s Throne wishlist!",
|
||||
"💝 {{gifter_username}} contributed {{amount_display}} toward {{item_name}} on {{creator_username}}'s Throne wishlist!",
|
||||
gift_crowdfunded:
|
||||
"🎉 {item_name} was fully crowdfunded on {creator_username}'s Throne wishlist!"
|
||||
"🎉 {{item_name}} was fully crowdfunded on {{creator_username}}'s Throne wishlist!"
|
||||
};
|
||||
const PLACEHOLDERS = new Set([
|
||||
"contract_version",
|
||||
@ -68,6 +69,7 @@ module.exports = {
|
||||
init({
|
||||
web,
|
||||
webhooks,
|
||||
placeholders = placeholderService,
|
||||
db,
|
||||
settings,
|
||||
discordClient,
|
||||
@ -76,6 +78,7 @@ module.exports = {
|
||||
}) {
|
||||
ensureTables(db);
|
||||
ensureDefaults(db);
|
||||
registerPlaceholderSupport(placeholders);
|
||||
const webhookApi = webhooks || global.lumiFrameworks?.webhooks || null;
|
||||
registerStoredEndpoints({ db, webhookApi, clients: { discordClient, twitchClient, youtubeClient } });
|
||||
|
||||
@ -256,6 +259,21 @@ module.exports = {
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
const unknown = findUnknownPlaceholders(template);
|
||||
const placeholderValidation = placeholders.validateTemplate({
|
||||
fieldId: "plugin.throne_wishlist.message_template",
|
||||
template,
|
||||
outputAudience: "user",
|
||||
user: req.session.user
|
||||
});
|
||||
if (!placeholderValidation.ok) {
|
||||
req.session.flash = {
|
||||
type: "error",
|
||||
message: `Template contains unavailable or unauthorized placeholder(s): ${
|
||||
placeholderValidation.errors.map((error) => error.token || error.id || error.reason).slice(0, 5).join(", ")
|
||||
}.`
|
||||
};
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE throne_event_message_templates SET enabled = ?, template = ?, updated_at = ? WHERE event_type = ? AND platform = ?"
|
||||
).run(enabled ? 1 : 0, template, Date.now(), eventType, platform);
|
||||
@ -388,6 +406,44 @@ function ensureDefaults(db) {
|
||||
}
|
||||
}
|
||||
|
||||
function registerPlaceholderSupport(placeholders) {
|
||||
if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return;
|
||||
placeholders.registerFieldPolicy({
|
||||
field_id: "plugin.throne_wishlist.message_template",
|
||||
label: "Throne Wishlist event message",
|
||||
field_type: "chat_message",
|
||||
output_audience: "user",
|
||||
min_editor_role: "admin",
|
||||
allowed_namespaces: ["plugin.throne_wishlist"],
|
||||
max_sensitivity: "public_safe"
|
||||
});
|
||||
placeholders.registerPlaceholders(Array.from(PLACEHOLDERS).sort().map((placeholder) => ({
|
||||
id: `plugin.throne_wishlist.${placeholder}`,
|
||||
namespace: "plugin.throne_wishlist",
|
||||
label: humanizePlaceholderLabel(placeholder),
|
||||
description: `Throne event value: ${placeholder}.`,
|
||||
value_type: placeholder === "payload_json" ? "json" : "string",
|
||||
sensitivity: placeholder === "payload_json" ? "admin" : "public_safe",
|
||||
min_editor_role: "admin",
|
||||
min_viewer_role: "user",
|
||||
allowed_field_types: ["chat_message"],
|
||||
plugin_id: PLUGIN_ID,
|
||||
aliases: [placeholder],
|
||||
resolver: ({ runtimeContext }) => {
|
||||
const tokens = runtimeContext?.throneTokens || runtimeContext?.tokens || {};
|
||||
return tokens[placeholder] ?? "";
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
function humanizePlaceholderLabel(value) {
|
||||
return String(value || "")
|
||||
.split(".")
|
||||
.pop()
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function registerStoredEndpoints({ db, webhookApi, clients }) {
|
||||
if (!webhookApi) {
|
||||
log("warn", "Throne Wishlist webhook framework unavailable");
|
||||
@ -579,7 +635,7 @@ async function sendPlatformMessages({ db, payload, endpoint, receivedAt, clients
|
||||
"WHERE t.event_type = ? AND t.enabled = 1 AND d.enabled = 1"
|
||||
).all(payload.event_type);
|
||||
const tasks = rows.map(async (row) => {
|
||||
const message = sanitizePlatformText(renderTemplate(row.template, tokens));
|
||||
const message = sanitizePlatformText(await renderTemplate(row.template, tokens));
|
||||
if (!message.trim()) {
|
||||
throw new Error(`Rendered ${row.platform} message is empty.`);
|
||||
}
|
||||
@ -689,12 +745,23 @@ function buildTokens({ payload, endpoint, receivedAt }) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function renderTemplate(template, tokens) {
|
||||
return (template || "").replace(/\{([^{}]+)\}/g, (full, key) =>
|
||||
Object.prototype.hasOwnProperty.call(tokens, key.trim())
|
||||
? String(tokens[key.trim()])
|
||||
: full
|
||||
);
|
||||
async function renderTemplate(template, tokens) {
|
||||
const rendered = await placeholderService.renderTemplate({
|
||||
fieldId: "plugin.throne_wishlist.message_template",
|
||||
template,
|
||||
outputAudience: "user",
|
||||
user: null,
|
||||
runtimeContext: {
|
||||
runtime: true,
|
||||
throneTokens: tokens
|
||||
}
|
||||
});
|
||||
return (rendered.rendered || "").replace(/\{([^{}]+)\}/g, (full, key) => {
|
||||
const normalizedKey = String(key || "").trim();
|
||||
return Object.prototype.hasOwnProperty.call(tokens, normalizedKey)
|
||||
? String(tokens[normalizedKey])
|
||||
: full;
|
||||
});
|
||||
}
|
||||
|
||||
function formatMoney(value, currency) {
|
||||
@ -981,15 +1048,22 @@ function ensureSidebarNavItem(settings) {
|
||||
|
||||
function findUnknownPlaceholders(template) {
|
||||
const unknown = [];
|
||||
for (const match of (template || "").matchAll(/\{([^{}]+)\}/g)) {
|
||||
const key = match[1].trim();
|
||||
if (!PLACEHOLDERS.has(key) && !unknown.includes(`{${key}}`)) {
|
||||
unknown.push(`{${key}}`);
|
||||
for (const match of (template || "").matchAll(/\{\{\s*([^{}]+?)\s*\}\}|\{([^{}]+)\}/g)) {
|
||||
const full = match[0];
|
||||
const key = normalizeTemplatePlaceholderKey(match[1] || match[2] || "");
|
||||
if (!PLACEHOLDERS.has(key) && !unknown.includes(full)) {
|
||||
unknown.push(full);
|
||||
}
|
||||
}
|
||||
return unknown;
|
||||
}
|
||||
|
||||
function normalizeTemplatePlaceholderKey(value) {
|
||||
const key = String(value || "").trim();
|
||||
const prefix = "plugin.throne_wishlist.";
|
||||
return key.startsWith(prefix) ? key.slice(prefix.length) : key;
|
||||
}
|
||||
|
||||
function headerValue(headers, name) {
|
||||
const value = headers?.[name.toLowerCase()];
|
||||
return Array.isArray(value) ? value[0] : (value || "").toString();
|
||||
|
||||
@ -152,7 +152,7 @@
|
||||
<details class="placeholder-list">
|
||||
<summary>Available placeholders</summary>
|
||||
<div>
|
||||
<% placeholders.forEach((placeholder) => { %><code>{<%= placeholder %>}</code> <% }) %>
|
||||
<% placeholders.forEach((placeholder) => { %><code>{{plugin.throne_wishlist.<%= placeholder %>}}</code> <% }) %>
|
||||
</div>
|
||||
</details>
|
||||
<div class="event-sections">
|
||||
@ -189,7 +189,7 @@
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
|
||||
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
|
||||
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4" data-placeholder-field="plugin.throne_wishlist.message_template" data-placeholder-output-audience="user"><%= template?.template || "" %></textarea>
|
||||
</div>
|
||||
<button type="submit" class="button subtle">Save <%= status?.label || platform %></button>
|
||||
</form>
|
||||
|
||||
@ -132,6 +132,9 @@ async function run() {
|
||||
assert(appScript.includes("${confirmLabel(form, submitter)} in ${remaining}"));
|
||||
assert(appScript.includes('button.disabled = remaining > 0'));
|
||||
assert(appScript.includes('fetch("/api/destructive-confirmations"'));
|
||||
assert(appScript.includes("destructiveToken: timedDestructiveToken"));
|
||||
assert(appScript.includes("destructiveFetch(action"));
|
||||
assert(appScript.includes('headers.set("X-Confirmation-Token", token)'));
|
||||
assert(appScript.includes("event.preventDefault();"));
|
||||
assert(appScript.includes("expiryTimer"));
|
||||
assert(appScript.includes("resetDestructive"));
|
||||
|
||||
@ -51,6 +51,8 @@ try {
|
||||
user_agent: "verification-agent",
|
||||
viewport: "1200x800",
|
||||
screenshot_mode: "target",
|
||||
similar_feedback_confirmation: "distinct_or_additional_context",
|
||||
similar_feedback_ids: "12345678-abcd,invalid id",
|
||||
hidden: "must not persist"
|
||||
}
|
||||
}, { id: "user-1" }, {
|
||||
@ -65,6 +67,8 @@ try {
|
||||
assert.equal(entry.scope_label, "Clicked element: Save settings");
|
||||
assert.equal(entry.target_metadata.secret, undefined);
|
||||
assert.equal(entry.diagnostics.hidden, undefined);
|
||||
assert.equal(entry.diagnostics.similar_feedback_confirmation, "distinct_or_additional_context");
|
||||
assert.equal(entry.diagnostics.similar_feedback_ids, "12345678-abcd");
|
||||
assert.equal(entry.screenshot.mime, "image/png");
|
||||
assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true);
|
||||
assert.equal(feedback.listMyFeedback("user-1").length, 1);
|
||||
@ -174,6 +178,28 @@ try {
|
||||
assert.equal(cleanedScreenshot, "feedback/screenshots/cleanup-shot.png");
|
||||
assert.equal(cleanedAttachment, "feedback/attachments/cleanup-note.txt");
|
||||
|
||||
const compactExport = feedback.buildFeedbackJobExport({ ids: [entry.id, cleanupEntry.id] });
|
||||
assert.equal(compactExport.taskfile.schema, "lumi.feedback.codex_taskfile.v1");
|
||||
assert.equal(Array.isArray(compactExport.taskfile.tasks), true);
|
||||
assert.equal(compactExport.taskfile.source_feedback[0].ref, "F1");
|
||||
assert.equal(compactExport.taskfile.requirements, undefined);
|
||||
assert.equal(compactExport.taskfile.context, undefined);
|
||||
assert.equal(compactExport.taskfile.debug_only_feedback, undefined);
|
||||
assert.equal(JSON.stringify(compactExport.taskfile).includes("http://localhost"), false);
|
||||
assert(compactExport.taskfile.tasks.some((task) => task.context?.some((value) => value.includes("Likely settings form regression"))));
|
||||
assert.equal(compactExport.debug_export.feedback.length, 2);
|
||||
|
||||
feedback.adminUpdateFeedback(cleanupEntry.id, {
|
||||
status: "closed",
|
||||
category: cleanupEntry.category,
|
||||
severity: cleanupEntry.severity,
|
||||
status_note: "Closed for export eligibility verification."
|
||||
}, { id: "admin-1" });
|
||||
const actionableOnlyExport = feedback.buildFeedbackJobExport({ ids: [entry.id, cleanupEntry.id] });
|
||||
assert.equal(actionableOnlyExport.taskfile.source_feedback.length, 1);
|
||||
assert.equal(actionableOnlyExport.taskfile.source_feedback[0].id, entry.id);
|
||||
assert.equal(actionableOnlyExport.debug_export.feedback.length, 2);
|
||||
|
||||
const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs");
|
||||
const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs");
|
||||
const commonLocals = {
|
||||
@ -231,6 +257,12 @@ try {
|
||||
assert(adminRendered.includes("Remove attachments"));
|
||||
assert(adminRendered.includes("Plugin/area"));
|
||||
assert(adminRendered.includes("1 also affected"));
|
||||
const layoutBottom = fs.readFileSync(path.join(root, "src", "web", "views", "partials", "layout-bottom.ejs"), "utf8");
|
||||
const appScript = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
|
||||
assert(layoutBottom.includes("data-feedback-similar-confirm-input"));
|
||||
assert(layoutBottom.includes("Mine is distinct or adds useful context"));
|
||||
assert(appScript.includes("similar_feedback_confirmation"));
|
||||
assert(appScript.includes("similar_feedback_confirmation_required"));
|
||||
|
||||
let deletedScreenshot = "";
|
||||
feedback.deleteFeedback(entry.id, {
|
||||
|
||||
140
scripts/verify-placeholders.js
Normal file
140
scripts/verify-placeholders.js
Normal file
@ -0,0 +1,140 @@
|
||||
const assert = require("assert");
|
||||
const placeholders = require("../src/services/placeholders");
|
||||
const { getSetting, setSetting } = require("../src/services/settings");
|
||||
|
||||
const {
|
||||
placeholders: placeholderMap,
|
||||
fieldPolicies
|
||||
} = placeholders._internals;
|
||||
|
||||
placeholderMap.clear();
|
||||
fieldPolicies.clear();
|
||||
placeholders.registerCorePlaceholders();
|
||||
placeholders.registerFieldPolicy({
|
||||
field_id: "okf.markdown",
|
||||
label: "OKF Markdown",
|
||||
field_type: "okf_markdown",
|
||||
output_audience: "user",
|
||||
min_editor_role: "user",
|
||||
allowed_namespaces: [],
|
||||
max_sensitivity: "admin"
|
||||
});
|
||||
|
||||
const user = { id: "user-1", username: "Viewer", isAdmin: false, isMod: false };
|
||||
const mod = { id: "mod-1", username: "Mod", isAdmin: false, isMod: true };
|
||||
const admin = { id: "admin-1", username: "Admin", isAdmin: true, isMod: true };
|
||||
|
||||
let catalog = placeholders.catalog({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
user: mod,
|
||||
outputAudience: "user"
|
||||
});
|
||||
assert(catalog.placeholders.some((entry) => entry.token === "{{core.main.bot_name}}"));
|
||||
assert(catalog.placeholders.some((entry) => entry.token === "{{user.public.display_name}}"));
|
||||
|
||||
placeholders.registerPlaceholder({
|
||||
id: "core.main.admin_note",
|
||||
namespace: "core.main",
|
||||
label: "Admin note",
|
||||
sensitivity: "admin",
|
||||
min_editor_role: "admin",
|
||||
min_viewer_role: "admin",
|
||||
allowed_field_types: ["command_response"],
|
||||
resolver: () => "hidden"
|
||||
});
|
||||
|
||||
catalog = placeholders.catalog({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
user: admin,
|
||||
outputAudience: "user"
|
||||
});
|
||||
assert.equal(catalog.placeholders.some((entry) => entry.token === "{{core.main.admin_note}}"), false);
|
||||
|
||||
const invalid = placeholders.validateTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: "Hello {{core.main.admin_note}}",
|
||||
user: admin,
|
||||
outputAudience: "user"
|
||||
});
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
|
||||
|
||||
(async () => {
|
||||
const rendered = await placeholders.renderTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: "Hi {{user.public.display_name}}, prefix is {{core.main.command_prefix}}.",
|
||||
user,
|
||||
outputAudience: "user",
|
||||
runtimeContext: {
|
||||
runtime: true,
|
||||
user: { displayName: "SammyCat" }
|
||||
}
|
||||
});
|
||||
assert.equal(rendered.ok, true);
|
||||
assert.match(rendered.rendered, /^Hi SammyCat, prefix is .+\.$/);
|
||||
|
||||
const previousGuildId = getSetting("discord_guild_id", "");
|
||||
try {
|
||||
setSetting("discord_guild_id", "guild-1");
|
||||
placeholders.registerPlatformPlaceholders({
|
||||
discordClient: {
|
||||
guilds: {
|
||||
cache: new Map([
|
||||
["guild-1", {
|
||||
name: "Cozy Carnage",
|
||||
memberCount: 42,
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
channels: { cache: new Map([
|
||||
["text", { type: 0 }],
|
||||
["voice", { type: 2 }]
|
||||
]) },
|
||||
roles: { cache: new Map([["role", {}]]) },
|
||||
emojis: { cache: new Map([["emoji", {}]]) },
|
||||
premiumSubscriptionCount: 3
|
||||
}]
|
||||
])
|
||||
}
|
||||
}
|
||||
});
|
||||
const platformCatalog = placeholders.catalog({
|
||||
fieldId: "okf.markdown",
|
||||
user: admin,
|
||||
outputAudience: "user"
|
||||
});
|
||||
assert(platformCatalog.placeholders.some((entry) => entry.token === "{{platform.discord.guild.member_count}}"));
|
||||
|
||||
const platformRendered = await placeholders.renderTemplate({
|
||||
fieldId: "okf.markdown",
|
||||
template: "Discord has {{platform.discord.guild.member_count}} members.",
|
||||
user: admin,
|
||||
outputAudience: "user",
|
||||
runtimeContext: { runtime: true }
|
||||
});
|
||||
assert.equal(platformRendered.rendered, "Discord has 42 members.");
|
||||
} finally {
|
||||
setSetting("discord_guild_id", previousGuildId);
|
||||
}
|
||||
|
||||
placeholders.registerPlaceholder({
|
||||
id: "okf.admin.secret",
|
||||
namespace: "okf.admin",
|
||||
label: "Admin secret",
|
||||
sensitivity: "admin",
|
||||
min_editor_role: "admin",
|
||||
min_viewer_role: "admin",
|
||||
allowed_field_types: ["okf_markdown"],
|
||||
resolver: () => "secret"
|
||||
});
|
||||
const userVisibleAdminPlaceholder = placeholders.validateTemplate({
|
||||
fieldId: "okf.markdown",
|
||||
template: "{{okf.admin.secret}}",
|
||||
user: admin,
|
||||
outputAudience: "user"
|
||||
});
|
||||
assert.equal(userVisibleAdminPlaceholder.ok, false);
|
||||
assert.equal(userVisibleAdminPlaceholder.errors[0].reason, "viewer_role_forbidden");
|
||||
console.log("Placeholder verification passed.");
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -15,6 +15,7 @@ const { createCommandRouter } = require("./services/command-router");
|
||||
const { registerTopCommand } = require("./services/top");
|
||||
const logger = require("./services/logger");
|
||||
const { isPlatformEnabled } = require("./services/platforms");
|
||||
const { registerCorePlaceholders } = require("./services/placeholders");
|
||||
const {
|
||||
isSafeModeRequested,
|
||||
markStartupVerification
|
||||
@ -23,6 +24,7 @@ const {
|
||||
async function main() {
|
||||
migrate();
|
||||
ensureDefaults();
|
||||
registerCorePlaceholders();
|
||||
logger.hookConsole();
|
||||
const safeModeRequested = isSafeModeRequested();
|
||||
const startupMarker = markStartupVerification();
|
||||
|
||||
@ -6,6 +6,7 @@ const {
|
||||
normalizeCommandResult
|
||||
} = require("./commands");
|
||||
const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms");
|
||||
const placeholders = require("./placeholders");
|
||||
|
||||
function createCommandRouter({ settings }) {
|
||||
const commandMap = new Map();
|
||||
@ -175,7 +176,19 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
||||
await safeReply(reply, "Command ran but returned no output.");
|
||||
}
|
||||
} else {
|
||||
await safeReply(reply, row.response);
|
||||
const rendered = await placeholders.renderTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: row.response,
|
||||
outputAudience: "user",
|
||||
user: {
|
||||
id: ctx.user.id,
|
||||
username: ctx.user.username,
|
||||
isAdmin: false,
|
||||
isMod: false
|
||||
},
|
||||
runtimeContext: { ctx, user: ctx.user, platform, runtime: true }
|
||||
});
|
||||
await safeReply(reply, rendered.rendered);
|
||||
}
|
||||
recordCommandUsage(`custom:${trigger}`);
|
||||
return true;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
714
src/services/placeholders.js
Normal file
714
src/services/placeholders.js
Normal file
@ -0,0 +1,714 @@
|
||||
const { db } = require("./db");
|
||||
const { getSetting } = require("./settings");
|
||||
const { hasAccess } = require("./rbac");
|
||||
|
||||
const ROLE_LEVELS = Object.freeze({
|
||||
public: 0,
|
||||
user: 1,
|
||||
mod: 2,
|
||||
moderator: 2,
|
||||
admin: 3,
|
||||
internal: 4
|
||||
});
|
||||
|
||||
const SENSITIVITY_LEVELS = Object.freeze({
|
||||
public_safe: 0,
|
||||
user: 1,
|
||||
moderator: 2,
|
||||
admin: 3,
|
||||
internal: 4,
|
||||
secret_never_render: 99
|
||||
});
|
||||
|
||||
const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]);
|
||||
const placeholders = new Map();
|
||||
const fieldPolicies = new Map();
|
||||
|
||||
function normalizeRole(value, fallback = "user") {
|
||||
const role = String(value || fallback).trim().toLowerCase();
|
||||
if (role === "moderator") return "mod";
|
||||
return Object.prototype.hasOwnProperty.call(ROLE_LEVELS, role) ? role : fallback;
|
||||
}
|
||||
|
||||
function roleLevel(value) {
|
||||
return ROLE_LEVELS[normalizeRole(value, "public")] ?? 0;
|
||||
}
|
||||
|
||||
function normalizeSensitivity(value, fallback = "public_safe") {
|
||||
const sensitivity = String(value || fallback).trim().toLowerCase();
|
||||
return Object.prototype.hasOwnProperty.call(SENSITIVITY_LEVELS, sensitivity)
|
||||
? sensitivity
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function sensitivityLevel(value) {
|
||||
return SENSITIVITY_LEVELS[normalizeSensitivity(value)] ?? 0;
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/^\{\{\s*|\s*\}\}$/g, "")
|
||||
.replace(/[^A-Za-z0-9_.-]/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.replace(/^\.|\.$/g, "");
|
||||
}
|
||||
|
||||
function tokenFor(id) {
|
||||
return `{{${normalizeId(id)}}}`;
|
||||
}
|
||||
|
||||
function namespaceFor(id) {
|
||||
const parts = normalizeId(id).split(".").filter(Boolean);
|
||||
return parts.length > 1 ? parts.slice(0, -1).join(".") : parts[0] || "";
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
return (Array.isArray(value) ? value : [value])
|
||||
.flatMap((item) => String(item || "").split(/[,\n]/))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeDefinition(definition = {}) {
|
||||
const id = normalizeId(definition.id || definition.token);
|
||||
if (!id) {
|
||||
throw new Error("Placeholder id is required.");
|
||||
}
|
||||
const sensitivity = normalizeSensitivity(definition.sensitivity);
|
||||
if (sensitivity === "secret_never_render") {
|
||||
throw new Error("secret_never_render placeholders must not be registered.");
|
||||
}
|
||||
const namespace = normalizeId(definition.namespace || namespaceFor(id));
|
||||
const aliases = normalizeStringList(definition.aliases || [])
|
||||
.map(normalizeId)
|
||||
.filter((alias) => alias && alias !== id);
|
||||
return {
|
||||
id,
|
||||
token: tokenFor(id),
|
||||
namespace,
|
||||
aliases,
|
||||
label: String(definition.label || id).trim(),
|
||||
description: String(definition.description || "").trim(),
|
||||
value_type: VALUE_TYPES.has(definition.value_type) ? definition.value_type : "string",
|
||||
sensitivity,
|
||||
min_editor_role: normalizeRole(definition.min_editor_role || "user"),
|
||||
min_viewer_role: normalizeRole(definition.min_viewer_role || "user"),
|
||||
allowed_field_types: normalizeStringList(definition.allowed_field_types || []),
|
||||
cache_ttl_seconds: Math.max(0, Number(definition.cache_ttl_seconds) || 0),
|
||||
group: String(definition.group || namespace || "General").trim(),
|
||||
example: definition.example === undefined ? null : String(definition.example),
|
||||
plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null,
|
||||
resolver: typeof definition.resolver === "function" ? definition.resolver : () => "",
|
||||
available: typeof definition.available === "function" ? definition.available : null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePolicy(policy = {}) {
|
||||
const field_id = normalizeId(policy.field_id);
|
||||
if (!field_id) {
|
||||
throw new Error("Placeholder field policy id is required.");
|
||||
}
|
||||
return {
|
||||
field_id,
|
||||
label: String(policy.label || field_id).trim(),
|
||||
field_type: String(policy.field_type || "text").trim(),
|
||||
output_audience: normalizeRole(policy.output_audience || "user"),
|
||||
min_editor_role: normalizeRole(policy.min_editor_role || "user"),
|
||||
allowed_namespaces: normalizeStringList(policy.allowed_namespaces || []),
|
||||
allowed_placeholder_ids: normalizeStringList(policy.allowed_placeholder_ids || []).map(normalizeId),
|
||||
max_sensitivity: normalizeSensitivity(policy.max_sensitivity || "public_safe"),
|
||||
description: String(policy.description || "").trim()
|
||||
};
|
||||
}
|
||||
|
||||
function registerPlaceholder(definition) {
|
||||
const normalized = normalizeDefinition(definition);
|
||||
placeholders.set(normalized.id, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function registerPlaceholders(definitions = []) {
|
||||
return definitions.map(registerPlaceholder);
|
||||
}
|
||||
|
||||
function unregisterPlaceholder(id) {
|
||||
return placeholders.delete(normalizeId(id));
|
||||
}
|
||||
|
||||
function unregisterNamespace(namespace) {
|
||||
const normalized = normalizeId(namespace);
|
||||
for (const id of Array.from(placeholders.keys())) {
|
||||
const definition = placeholders.get(id);
|
||||
if (id === normalized || id.startsWith(`${normalized}.`) || definition?.namespace === normalized || definition?.namespace?.startsWith(`${normalized}.`)) {
|
||||
placeholders.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerFieldPolicy(policy) {
|
||||
const normalized = normalizePolicy(policy);
|
||||
fieldPolicies.set(normalized.field_id, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getFieldPolicy(fieldId) {
|
||||
return fieldPolicies.get(normalizeId(fieldId)) || null;
|
||||
}
|
||||
|
||||
function isPluginEnabled(pluginId) {
|
||||
if (!pluginId) return true;
|
||||
try {
|
||||
const row = db.prepare("SELECT enabled FROM plugins WHERE id = ?").get(pluginId);
|
||||
return !row || Boolean(row.enabled);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function userRole(user) {
|
||||
if (user?.isAdmin) return "admin";
|
||||
if (user?.isMod) return "mod";
|
||||
return user ? "user" : "public";
|
||||
}
|
||||
|
||||
function roleAllows(user, role) {
|
||||
const normalized = normalizeRole(role, "user");
|
||||
if (normalized === "user") return Boolean(user);
|
||||
return hasAccess(user, normalized);
|
||||
}
|
||||
|
||||
function namespaceAllowed(definition, policy) {
|
||||
if (policy.allowed_placeholder_ids.includes(definition.id)) return true;
|
||||
if (!policy.allowed_namespaces.length) return true;
|
||||
return policy.allowed_namespaces.some((namespace) => {
|
||||
const normalized = normalizeId(namespace);
|
||||
return definition.id === normalized ||
|
||||
definition.namespace === normalized ||
|
||||
definition.namespace.startsWith(`${normalized}.`) ||
|
||||
definition.id.startsWith(`${normalized}.`);
|
||||
});
|
||||
}
|
||||
|
||||
function fieldTypeAllowed(definition, policy) {
|
||||
return !definition.allowed_field_types.length ||
|
||||
definition.allowed_field_types.includes(policy.field_type);
|
||||
}
|
||||
|
||||
function availabilityAllows(definition, context) {
|
||||
if (!isPluginEnabled(definition.plugin_id)) return false;
|
||||
if (!definition.available) return true;
|
||||
try {
|
||||
return Boolean(definition.available(context));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkPlaceholderAccess(definition, policy, { user, outputAudience, runtimeContext } = {}) {
|
||||
if (!definition || !policy) {
|
||||
return { allowed: false, reason: "unknown_placeholder" };
|
||||
}
|
||||
const audience = normalizeRole(outputAudience || policy.output_audience, policy.output_audience);
|
||||
if (definition.sensitivity === "secret_never_render") {
|
||||
return { allowed: false, reason: "secret_never_render" };
|
||||
}
|
||||
const runtimeOnly = Boolean(runtimeContext?.runtime);
|
||||
if (!runtimeOnly && (!roleAllows(user, policy.min_editor_role) || !roleAllows(user, definition.min_editor_role))) {
|
||||
return { allowed: false, reason: "editor_role_forbidden" };
|
||||
}
|
||||
if (roleLevel(audience) < roleLevel(definition.min_viewer_role)) {
|
||||
return { allowed: false, reason: "viewer_role_forbidden" };
|
||||
}
|
||||
if (sensitivityLevel(definition.sensitivity) > sensitivityLevel(policy.max_sensitivity)) {
|
||||
return { allowed: false, reason: "sensitivity_forbidden" };
|
||||
}
|
||||
if (!namespaceAllowed(definition, policy)) {
|
||||
return { allowed: false, reason: "namespace_forbidden" };
|
||||
}
|
||||
if (!fieldTypeAllowed(definition, policy)) {
|
||||
return { allowed: false, reason: "field_type_forbidden" };
|
||||
}
|
||||
if (!availabilityAllows(definition, { user, policy, outputAudience: audience, runtimeContext })) {
|
||||
return { allowed: false, reason: "placeholder_unavailable" };
|
||||
}
|
||||
return { allowed: true, reason: "allowed" };
|
||||
}
|
||||
|
||||
function findDefinition(token) {
|
||||
const id = normalizeId(token);
|
||||
if (placeholders.has(id)) return placeholders.get(id);
|
||||
for (const definition of placeholders.values()) {
|
||||
if (definition.aliases.includes(id)) return definition;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePlaceholders(template) {
|
||||
const found = [];
|
||||
const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
||||
let match = null;
|
||||
while ((match = matcher.exec(String(template || "")))) {
|
||||
found.push({
|
||||
token: match[0],
|
||||
id: normalizeId(match[1]),
|
||||
index: match.index
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) {
|
||||
const policy = getFieldPolicy(fieldId);
|
||||
if (!policy) return { policy: null, placeholders: [] };
|
||||
const audience = normalizeRole(outputAudience || policy.output_audience, policy.output_audience);
|
||||
const available = Array.from(placeholders.values())
|
||||
.filter((definition) => checkPlaceholderAccess(definition, policy, {
|
||||
user,
|
||||
outputAudience: audience,
|
||||
runtimeContext
|
||||
}).allowed)
|
||||
.map((definition) => ({
|
||||
id: definition.id,
|
||||
token: definition.token,
|
||||
namespace: definition.namespace,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
value_type: definition.value_type,
|
||||
sensitivity: definition.sensitivity,
|
||||
group: definition.group,
|
||||
example: definition.sensitivity === "public_safe" ? definition.example : null
|
||||
}))
|
||||
.sort((a, b) => a.token.localeCompare(b.token));
|
||||
return {
|
||||
policy: {
|
||||
field_id: policy.field_id,
|
||||
label: policy.label,
|
||||
field_type: policy.field_type,
|
||||
output_audience: audience
|
||||
},
|
||||
placeholders: available
|
||||
};
|
||||
}
|
||||
|
||||
function validateTemplate({ fieldId, template, user, outputAudience, runtimeContext } = {}) {
|
||||
const policy = getFieldPolicy(fieldId);
|
||||
const errors = [];
|
||||
if (!policy) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ token: "", reason: "unknown_field_policy" }]
|
||||
};
|
||||
}
|
||||
for (const token of parsePlaceholders(template)) {
|
||||
const definition = findDefinition(token.id);
|
||||
const access = checkPlaceholderAccess(definition, policy, {
|
||||
user,
|
||||
outputAudience,
|
||||
runtimeContext
|
||||
});
|
||||
if (!access.allowed) {
|
||||
errors.push({
|
||||
token: token.token,
|
||||
id: token.id,
|
||||
reason: access.reason
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
async function renderTemplate({ fieldId, template, user, outputAudience, runtimeContext, fallback = "[unavailable]" } = {}) {
|
||||
const policy = getFieldPolicy(fieldId);
|
||||
if (!policy) {
|
||||
return {
|
||||
ok: false,
|
||||
rendered: String(template || ""),
|
||||
errors: [{ token: "", reason: "unknown_field_policy" }]
|
||||
};
|
||||
}
|
||||
const errors = [];
|
||||
const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => {
|
||||
const id = normalizeId(rawId);
|
||||
const definition = findDefinition(id);
|
||||
const access = checkPlaceholderAccess(definition, policy, {
|
||||
user,
|
||||
outputAudience,
|
||||
runtimeContext
|
||||
});
|
||||
if (!access.allowed) {
|
||||
errors.push({ token: match, id, reason: access.reason });
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const value = await definition.resolver({
|
||||
user,
|
||||
policy,
|
||||
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
|
||||
runtimeContext,
|
||||
token: match,
|
||||
id
|
||||
});
|
||||
return stringifyResolvedValue(value);
|
||||
} catch (error) {
|
||||
errors.push({ token: match, id, reason: "resolver_failed" });
|
||||
return fallback;
|
||||
}
|
||||
});
|
||||
return { ok: errors.length === 0, rendered, errors };
|
||||
}
|
||||
|
||||
function stringifyResolvedValue(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function replaceAsync(value, matcher, replacer) {
|
||||
const matches = [];
|
||||
value.replace(matcher, (...args) => {
|
||||
matches.push(args);
|
||||
return args[0];
|
||||
});
|
||||
return matches.reduce(async (pending, args) => {
|
||||
const current = await pending;
|
||||
const replacement = await replacer(...args);
|
||||
return current.replace(args[0], replacement);
|
||||
}, Promise.resolve(value));
|
||||
}
|
||||
|
||||
function registerCorePlaceholders() {
|
||||
registerFieldPolicy({
|
||||
field_id: "core.custom_commands.static_response",
|
||||
label: "Custom command response",
|
||||
field_type: "command_response",
|
||||
output_audience: "user",
|
||||
min_editor_role: "mod",
|
||||
allowed_namespaces: ["core.main", "user.public"],
|
||||
max_sensitivity: "public_safe"
|
||||
});
|
||||
registerPlaceholders([
|
||||
{
|
||||
id: "core.main.bot_name",
|
||||
namespace: "core.main",
|
||||
label: "Bot name",
|
||||
description: "Current Lumi site/bot display name.",
|
||||
value_type: "string",
|
||||
sensitivity: "public_safe",
|
||||
min_editor_role: "user",
|
||||
min_viewer_role: "user",
|
||||
allowed_field_types: ["chat_message", "command_response", "admin_template", "okf_markdown"],
|
||||
example: "Lumi",
|
||||
resolver: () => getSetting("site_title", "Lumi")
|
||||
},
|
||||
{
|
||||
id: "core.main.command_prefix",
|
||||
namespace: "core.main",
|
||||
label: "Command prefix",
|
||||
description: "Configured chat command prefix.",
|
||||
value_type: "string",
|
||||
sensitivity: "public_safe",
|
||||
min_editor_role: "user",
|
||||
min_viewer_role: "user",
|
||||
allowed_field_types: ["chat_message", "command_response", "admin_template", "okf_markdown"],
|
||||
example: "!",
|
||||
resolver: () => getSetting("command_prefix", "!")
|
||||
},
|
||||
{
|
||||
id: "user.public.display_name",
|
||||
namespace: "user.public",
|
||||
label: "Viewer display name",
|
||||
description: "Display name of the user who triggered the message, when available.",
|
||||
value_type: "string",
|
||||
sensitivity: "public_safe",
|
||||
min_editor_role: "user",
|
||||
min_viewer_role: "user",
|
||||
allowed_field_types: ["chat_message", "command_response", "okf_markdown"],
|
||||
resolver: ({ runtimeContext }) =>
|
||||
runtimeContext?.user?.displayName ||
|
||||
runtimeContext?.user?.username ||
|
||||
runtimeContext?.ctx?.user?.displayName ||
|
||||
runtimeContext?.ctx?.user?.username ||
|
||||
""
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function registerPlatformPlaceholders({ discordClient, getTwitchClient, getYouTubeClient } = {}) {
|
||||
registerPlaceholders([
|
||||
...discordPlatformPlaceholders(discordClient),
|
||||
...twitchPlatformPlaceholders(getTwitchClient),
|
||||
...youtubePlatformPlaceholders(getYouTubeClient)
|
||||
]);
|
||||
}
|
||||
|
||||
function platformPlaceholder(base) {
|
||||
return {
|
||||
namespace: base.namespace,
|
||||
sensitivity: base.sensitivity || "public_safe",
|
||||
min_editor_role: base.min_editor_role || "user",
|
||||
min_viewer_role: base.min_viewer_role || "user",
|
||||
allowed_field_types: base.allowed_field_types || ["okf_markdown", "admin_template"],
|
||||
group: base.group,
|
||||
...base
|
||||
};
|
||||
}
|
||||
|
||||
function discordPlatformPlaceholders(discordClient) {
|
||||
const namespace = "platform.discord.guild";
|
||||
const group = "Discord guild";
|
||||
const guildValue = async (reader) => {
|
||||
const guild = await resolveDiscordGuild(discordClient);
|
||||
if (!guild) return "";
|
||||
try {
|
||||
return reader(guild);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
return [
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.name`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord server name",
|
||||
description: "Name of the configured Discord server.",
|
||||
example: "Cozy Carnage",
|
||||
resolver: () => guildValue((guild) => guild.name || "")
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.member_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord member count",
|
||||
description: "Current member count reported by the Discord server cache/API.",
|
||||
value_type: "number",
|
||||
example: "128",
|
||||
resolver: () => guildValue((guild) => guild.memberCount ?? guild.approximateMemberCount ?? "")
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.created_at`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord server created at",
|
||||
description: "ISO datetime when the configured Discord server was created.",
|
||||
value_type: "date",
|
||||
example: "2024-01-01T12:00:00.000Z",
|
||||
resolver: () => guildValue((guild) => guild.createdAt?.toISOString?.() || (guild.createdTimestamp ? new Date(guild.createdTimestamp).toISOString() : ""))
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.text_channel_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord text channel count",
|
||||
description: "Number of text-like channels currently cached for the configured Discord server.",
|
||||
value_type: "number",
|
||||
example: "12",
|
||||
resolver: () => guildValue((guild) => countDiscordChannels(guild, "text"))
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.voice_channel_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord voice channel count",
|
||||
description: "Number of voice/stage channels currently cached for the configured Discord server.",
|
||||
value_type: "number",
|
||||
example: "4",
|
||||
resolver: () => guildValue((guild) => countDiscordChannels(guild, "voice"))
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.channel_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord channel count",
|
||||
description: "Total number of channels currently cached for the configured Discord server.",
|
||||
value_type: "number",
|
||||
example: "18",
|
||||
resolver: () => guildValue((guild) => collectionSize(guild.channels?.cache))
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.role_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord role count",
|
||||
description: "Number of roles currently cached for the configured Discord server.",
|
||||
value_type: "number",
|
||||
example: "8",
|
||||
resolver: () => guildValue((guild) => collectionSize(guild.roles?.cache))
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.emoji_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord emoji count",
|
||||
description: "Number of custom emojis currently cached for the configured Discord server.",
|
||||
value_type: "number",
|
||||
example: "24",
|
||||
resolver: () => guildValue((guild) => collectionSize(guild.emojis?.cache))
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.boost_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord boost count",
|
||||
description: "Premium subscription count reported by Discord when available.",
|
||||
value_type: "number",
|
||||
example: "3",
|
||||
resolver: () => guildValue((guild) => guild.premiumSubscriptionCount ?? "")
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function twitchPlatformPlaceholders(getTwitchClient) {
|
||||
const namespace = "platform.twitch.channel";
|
||||
const group = "Twitch channel";
|
||||
return [
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.primary_name`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Twitch primary channel",
|
||||
description: "First configured Twitch channel name.",
|
||||
example: "cozycarnage",
|
||||
resolver: () => firstTwitchChannel()
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.configured_count`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Twitch configured channel count",
|
||||
description: "Number of configured Twitch channels.",
|
||||
value_type: "number",
|
||||
example: "1",
|
||||
resolver: () => twitchChannels().length
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.bot_username`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Twitch bot username",
|
||||
description: "Configured Twitch bot username.",
|
||||
example: "lumi_bot",
|
||||
resolver: () => getSetting("twitch_bot_username", "") || ""
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.connected`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Twitch bot connected",
|
||||
description: "Whether the Twitch chat client is currently connected.",
|
||||
value_type: "boolean",
|
||||
example: "true",
|
||||
resolver: () => Boolean(getTwitchClient?.())
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function youtubePlatformPlaceholders(getYouTubeClient) {
|
||||
const namespace = "platform.youtube.channel";
|
||||
const group = "YouTube channel";
|
||||
return [
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.id`,
|
||||
namespace,
|
||||
group,
|
||||
label: "YouTube channel ID",
|
||||
description: "Configured or hydrated YouTube bot channel ID.",
|
||||
example: "UC...",
|
||||
resolver: () => getYouTubeClient?.()?.channelId || getSetting("youtube_bot_channel_id", "") || ""
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.name`,
|
||||
namespace,
|
||||
group,
|
||||
label: "YouTube channel name",
|
||||
description: "Hydrated YouTube bot channel name when the integration is connected.",
|
||||
example: "Cozy Carnage",
|
||||
resolver: () => getYouTubeClient?.()?.channelName || ""
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.live_chat_active`,
|
||||
namespace,
|
||||
group,
|
||||
label: "YouTube live chat active",
|
||||
description: "Whether Lumi currently has an active YouTube live chat ID.",
|
||||
value_type: "boolean",
|
||||
example: "false",
|
||||
resolver: () => Boolean(getYouTubeClient?.()?.liveChatId)
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.connected`,
|
||||
namespace,
|
||||
group,
|
||||
label: "YouTube bot connected",
|
||||
description: "Whether the YouTube chat integration runtime is currently active.",
|
||||
value_type: "boolean",
|
||||
example: "true",
|
||||
resolver: () => Boolean(getYouTubeClient?.())
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
async function resolveDiscordGuild(discordClient) {
|
||||
const guildId = getSetting("discord_guild_id", "");
|
||||
if (!discordClient || !guildId) return null;
|
||||
const cached = discordClient.guilds?.cache?.get?.(guildId);
|
||||
if (cached) return cached;
|
||||
if (typeof discordClient.guilds?.fetch === "function") {
|
||||
return discordClient.guilds.fetch(guildId).catch(() => null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function countDiscordChannels(guild, kind) {
|
||||
const channels = Array.from(guild.channels?.cache?.values?.() || []);
|
||||
return channels.filter((channel) => {
|
||||
const type = channel?.type;
|
||||
if (kind === "voice") {
|
||||
return type === 2 || type === 13 || /voice|stage/i.test(String(type));
|
||||
}
|
||||
return [0, 5, 10, 11, 12, 15, 16].includes(type) || /text|news|announcement|forum|media|thread/i.test(String(type));
|
||||
}).length;
|
||||
}
|
||||
|
||||
function collectionSize(collection) {
|
||||
if (!collection) return "";
|
||||
if (typeof collection.size === "number") return collection.size;
|
||||
if (Array.isArray(collection)) return collection.length;
|
||||
return "";
|
||||
}
|
||||
|
||||
function twitchChannels() {
|
||||
return String(getSetting("twitch_channels", "") || "")
|
||||
.split(/[,\s]+/)
|
||||
.map((entry) => entry.trim().replace(/^#/, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function firstTwitchChannel() {
|
||||
return twitchChannels()[0] || "";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
catalog,
|
||||
checkPlaceholderAccess,
|
||||
getFieldPolicy,
|
||||
parsePlaceholders,
|
||||
registerCorePlaceholders,
|
||||
registerFieldPolicy,
|
||||
registerPlaceholder,
|
||||
registerPlaceholders,
|
||||
registerPlatformPlaceholders,
|
||||
renderTemplate,
|
||||
unregisterNamespace,
|
||||
unregisterPlaceholder,
|
||||
validateTemplate,
|
||||
_internals: {
|
||||
normalizeId,
|
||||
normalizePolicy,
|
||||
normalizeDefinition,
|
||||
placeholders,
|
||||
fieldPolicies
|
||||
}
|
||||
};
|
||||
@ -2,6 +2,7 @@ const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { db } = require("./db");
|
||||
const placeholders = require("./placeholders");
|
||||
|
||||
const pluginsDir = path.join(__dirname, "..", "..", "plugins");
|
||||
const cleanupHandlers = [];
|
||||
@ -235,7 +236,8 @@ function loadEnabled({
|
||||
webhooks,
|
||||
db,
|
||||
plugin,
|
||||
commandRouter
|
||||
commandRouter,
|
||||
placeholders
|
||||
});
|
||||
if (typeof cleanup === "function") {
|
||||
cleanupHandlers.push({ id: plugin.id, cleanup });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -34,6 +34,10 @@
|
||||
return documentObject?.querySelector?.("[data-assistant-panel-slot]") || null;
|
||||
}
|
||||
|
||||
function syncMountedClass() {
|
||||
documentObject?.body?.classList.toggle("has-assistant-panel", mounted.has("lumi_ai"));
|
||||
}
|
||||
|
||||
function register(id, lifecycle) {
|
||||
if (!id || !lifecycle) return;
|
||||
lifecycles.set(id, lifecycle);
|
||||
@ -58,6 +62,7 @@
|
||||
} finally {
|
||||
current.root.remove();
|
||||
mounted.delete(id);
|
||||
syncMountedClass();
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,6 +121,7 @@
|
||||
const root = createRoot(panel);
|
||||
target.appendChild(root);
|
||||
mounted.set(panel.panel_id, { root, version });
|
||||
syncMountedClass();
|
||||
await loadAsset("script", panel.script, version);
|
||||
if (expectedSequence !== refreshSequence || mounted.get(panel.panel_id)?.root !== root) return;
|
||||
mountLifecycle(panel.panel_id, root);
|
||||
@ -152,6 +158,7 @@
|
||||
}
|
||||
}
|
||||
await reportDiagnostics(panels.find((panel) => panel?.panel_id === "lumi_ai")?.debug);
|
||||
syncMountedClass();
|
||||
}
|
||||
|
||||
async function reportDiagnostics(debug) {
|
||||
|
||||
@ -1187,10 +1187,175 @@ input[type="color"] {
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.modal.feedback-export-modal {
|
||||
width: min(72rem, 92vw);
|
||||
max-height: min(82vh, 60rem);
|
||||
}
|
||||
|
||||
.modal.okf-entry-modal-dialog {
|
||||
width: min(72rem, 94vw);
|
||||
max-height: min(86vh, 62rem);
|
||||
}
|
||||
|
||||
.modal.okf-entry-fullscreen-modal {
|
||||
width: min(96vw, 118rem);
|
||||
max-height: calc(100dvh - (var(--lumi-space-4) * 2));
|
||||
min-height: calc(100dvh - (var(--lumi-space-4) * 2));
|
||||
}
|
||||
|
||||
input.okf-text-suggestion::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
input.okf-text-suggestion {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.lumi-placeholder-suggest {
|
||||
position: fixed;
|
||||
z-index: 2400;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: min(15rem, 50vh);
|
||||
overflow: auto;
|
||||
padding: var(--lumi-space-2);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-raised);
|
||||
box-shadow: var(--lumi-shadow-lg);
|
||||
}
|
||||
|
||||
.lumi-placeholder-suggest[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lumi-placeholder-suggest-item {
|
||||
width: 100%;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
border: 0;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--lumi-text);
|
||||
font-family: var(--lumi-font-mono);
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lumi-placeholder-suggest-item:hover,
|
||||
.lumi-placeholder-suggest-item[aria-selected="true"] {
|
||||
background: color-mix(in srgb, var(--lumi-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.lumi-placeholder-suggest-expand {
|
||||
margin-top: var(--lumi-space-1);
|
||||
border-top: 1px solid var(--lumi-border);
|
||||
color: var(--lumi-primary);
|
||||
font-family: var(--lumi-font-display);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-dialog {
|
||||
width: min(58rem, 94vw);
|
||||
max-height: min(82vh, 54rem);
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
max-height: min(54vh, 34rem);
|
||||
overflow: auto;
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-branch,
|
||||
.lumi-placeholder-tree-leaf {
|
||||
font-family: var(--lumi-font-mono);
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-branch > summary,
|
||||
.lumi-placeholder-tree-leaf {
|
||||
min-height: 2.25rem;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
padding-left: calc(var(--lumi-space-3) + (var(--placeholder-depth, 0) * 1.15rem));
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-branch > summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-leaf {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--lumi-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-token {
|
||||
color: var(--lumi-text);
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-muted-token {
|
||||
color: var(--lumi-text-muted);
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-leaf:hover,
|
||||
.lumi-placeholder-tree-branch > summary:hover {
|
||||
background: color-mix(in srgb, var(--lumi-primary) 14%, transparent);
|
||||
}
|
||||
|
||||
.lumi-placeholder-tree-branch.is-muted > summary,
|
||||
.lumi-placeholder-tree-leaf.is-muted {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.placeholder-invalid {
|
||||
background:
|
||||
linear-gradient(color-mix(in srgb, var(--lumi-danger) 10%, transparent), color-mix(in srgb, var(--lumi-danger) 10%, transparent)),
|
||||
var(--lumi-surface) !important;
|
||||
}
|
||||
|
||||
.feedback-modal .modal-actions {
|
||||
justify-content: space-evenly;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feedback-export-modal textarea {
|
||||
width: 100%;
|
||||
min-height: min(52vh, 34rem);
|
||||
font-family: var(--lumi-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.feedback-export-toolbar,
|
||||
.feedback-export-modal .modal-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feedback-export-select {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.compact-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.compact-field input {
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.modal-header h2,
|
||||
.modal-header h3 {
|
||||
margin-bottom: 0;
|
||||
@ -1343,6 +1508,13 @@ details > summary {
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.modal.okf-entry-fullscreen-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - (var(--lumi-space-2) * 2));
|
||||
min-height: calc(100dvh - (var(--lumi-space-2) * 2));
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
@ -1273,6 +1273,10 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.has-assistant-panel .feedback-floating-button {
|
||||
bottom: 96px;
|
||||
}
|
||||
|
||||
.feedback-floating-badge {
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
|
||||
@ -111,9 +111,11 @@ const {
|
||||
publishWebEvent,
|
||||
subscribeWebEvents
|
||||
} = require("../services/web-events");
|
||||
const placeholders = require("../services/placeholders");
|
||||
const {
|
||||
addSubmitterComment,
|
||||
adminUpdateFeedback,
|
||||
buildFeedbackJobExport,
|
||||
cleanupFeedback,
|
||||
createFeedback,
|
||||
deleteFeedback,
|
||||
@ -130,6 +132,7 @@ const {
|
||||
notificationSummary,
|
||||
supportFeedback
|
||||
} = require("../services/feedback");
|
||||
const { saveCorrectionKnowledgeFile } = require("../../plugins/okf/backend/file_knowledge");
|
||||
|
||||
function ensureSessionSecret() {
|
||||
let secret = getSetting("session_secret");
|
||||
@ -263,6 +266,16 @@ function requireRole(role) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatPlaceholderErrors(errors = []) {
|
||||
const tokens = errors
|
||||
.map((error) => error.token || error.id || error.reason)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
return tokens.length
|
||||
? `Unavailable or unauthorized placeholder(s): ${tokens.join(", ")}.`
|
||||
: "Unavailable or unauthorized placeholder.";
|
||||
}
|
||||
|
||||
function storeDiscordUser(user) {
|
||||
const avatar = user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
|
||||
@ -1712,6 +1725,260 @@ function normalizeFeedbackRequestBody(body = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFeedbackExportRequest(body = {}) {
|
||||
const rawIds = Array.isArray(body.ids) ? body.ids.join(",") : body.ids;
|
||||
const ids = String(rawIds || "")
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
const tokenLimit = Number.parseInt(body.token_limit || body.tokenLimit, 10);
|
||||
return {
|
||||
all: body.mode === "all" || body.all === "1" || body.all === true,
|
||||
ids,
|
||||
useAi: body.use_ai === "1" || body.use_ai === "true" || body.useAi === true,
|
||||
tokenLimit: Number.isFinite(tokenLimit) ? tokenLimit : undefined,
|
||||
filters: {
|
||||
status: body.status || "",
|
||||
category: body.category || "",
|
||||
severity: body.severity || "",
|
||||
scope: body.scope || "",
|
||||
area: body.area || "",
|
||||
submitter: body.submitter || "",
|
||||
date_from: body.date_from || "",
|
||||
date_to: body.date_to || "",
|
||||
needs_action: body.needs_action || "",
|
||||
sort: body.sort || "last_activity"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildFeedbackCorrectionPayload(feedback, body = {}) {
|
||||
if (!feedback) {
|
||||
throw new Error("Feedback item was not found.");
|
||||
}
|
||||
const title = cleanAiTaskfileText(body.correction_title || feedback.summary || "Feedback correction", 180);
|
||||
if (!title) {
|
||||
throw new Error("Correction title is required.");
|
||||
}
|
||||
const sourceUrl = `/admin/feedback?feedback=${encodeURIComponent(feedback.id)}`;
|
||||
const correctionBody = cleanCorrectionMarkdown(body.correction_body || [
|
||||
`# ${title}`,
|
||||
"",
|
||||
"## Correction",
|
||||
cleanAiTaskfileText(feedback.expected_behavior || feedback.description || feedback.summary, 1800),
|
||||
"",
|
||||
"## Source feedback",
|
||||
`Feedback ID: ${feedback.id}`,
|
||||
`Category: ${feedback.category_label || feedback.category || "Feedback"}`,
|
||||
`Severity: ${feedback.severity_label || feedback.severity || "Unknown"}`,
|
||||
feedback.scope_label_display ? `Scope: ${cleanAiTaskfileText(feedback.scope_label_display, 400)}` : "",
|
||||
feedback.current_url ? `Route: ${cleanAiTaskfileText(feedback.current_url, 500)}` : "",
|
||||
"",
|
||||
"## Reported behavior",
|
||||
cleanAiTaskfileText(feedback.description || feedback.summary, 2400),
|
||||
"",
|
||||
feedback.actual_behavior ? "## Actual behavior" : "",
|
||||
feedback.actual_behavior ? cleanAiTaskfileText(feedback.actual_behavior, 1200) : "",
|
||||
"",
|
||||
feedback.steps_to_reproduce ? "## Reproduction notes" : "",
|
||||
feedback.steps_to_reproduce ? cleanAiTaskfileText(feedback.steps_to_reproduce, 1200) : ""
|
||||
].filter(Boolean).join("\n"));
|
||||
return {
|
||||
title,
|
||||
slug: body.correction_slug || `feedback-${feedback.id}`,
|
||||
id: body.correction_id || `correction.feedback.${feedback.id}`,
|
||||
category: body.correction_category || "Feedback correction",
|
||||
visibility: body.correction_visibility || "user",
|
||||
status: body.correction_status || "active",
|
||||
priority: body.correction_priority || 100,
|
||||
tags: body.correction_tags || `feedback, correction, ${feedback.category || "feedback"}`,
|
||||
source_feedback_id: feedback.id,
|
||||
source_feedback_url: sourceUrl,
|
||||
body: correctionBody
|
||||
};
|
||||
}
|
||||
|
||||
function cleanCorrectionMarkdown(value) {
|
||||
return String(value || "")
|
||||
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
|
||||
.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]")
|
||||
.replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]")
|
||||
.replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\n{4,}/g, "\n\n\n")
|
||||
.trim()
|
||||
.slice(0, 12000);
|
||||
}
|
||||
|
||||
async function maybeAddAiFeedbackExportSummary(exportPayload, req, useAi) {
|
||||
if (!useAi) return exportPayload;
|
||||
const ai = feedbackExportAi();
|
||||
if (!isFeedbackExportAiAvailable()) {
|
||||
return {
|
||||
...exportPayload,
|
||||
debug_export: {
|
||||
...exportPayload.debug_export,
|
||||
ai_summary: {
|
||||
available: false,
|
||||
reason: "Lumi AI is not available."
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
const promptPayload = {
|
||||
taskfile: exportPayload.taskfile
|
||||
};
|
||||
const prompt =
|
||||
"Improve this compact Lumi feedback Codex taskfile. " +
|
||||
"Return strict JSON only. Allowed top-level keys: objective and tasks. " +
|
||||
"Each task may contain only task_id, title, context, requirements, acceptance_criteria, verification, and risk_notes. " +
|
||||
"Use provided sanitized context notes, including admin replies and private admin work notes, when they clarify the task. " +
|
||||
"Keep existing task_id values. Do not add private note authors, submitter identity, emails, IPs, tokens, session data, local paths, raw diagnostics, screenshots, or invented exact repo paths. " +
|
||||
"Keep output compact and directly actionable for a coding agent. " +
|
||||
`Input JSON:\n${JSON.stringify(promptPayload)}`;
|
||||
try {
|
||||
const result = await ai.generate({
|
||||
message: prompt,
|
||||
user: req.session.user,
|
||||
sessionId: `admin-feedback-export:${req.session.user?.id || "admin"}:${Date.now()}`,
|
||||
scope: "admin_feedback_export",
|
||||
max_tokens: exportPayload.debug_export?.token_context_limit || 8000,
|
||||
allowTools: false
|
||||
});
|
||||
const text = String(result?.text || "").trim();
|
||||
const parsed = parseAiJsonObject(text);
|
||||
const improvedTaskfile = parsed
|
||||
? mergeAiFeedbackTaskfilePatch(exportPayload.taskfile, parsed)
|
||||
: exportPayload.taskfile;
|
||||
return {
|
||||
...exportPayload,
|
||||
taskfile: improvedTaskfile,
|
||||
debug_export: {
|
||||
...exportPayload.debug_export,
|
||||
ai_summary: {
|
||||
available: true,
|
||||
model_id: result?.model_id || null,
|
||||
token_limit: exportPayload.debug_export?.token_context_limit || 8000,
|
||||
content: parsed || text,
|
||||
applied_to_taskfile: Boolean(parsed)
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...exportPayload,
|
||||
debug_export: {
|
||||
...exportPayload.debug_export,
|
||||
ai_summary: {
|
||||
available: false,
|
||||
reason: error.message || "AI summarization failed."
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAiFeedbackTaskfilePatch(taskfile, patch) {
|
||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return taskfile;
|
||||
const next = { ...taskfile };
|
||||
if (typeof patch.objective === "string" && patch.objective.trim().length >= 12) {
|
||||
next.objective = cleanAiTaskfileText(patch.objective, 260);
|
||||
}
|
||||
if (Array.isArray(patch.tasks) && Array.isArray(next.tasks)) {
|
||||
const patches = new Map(patch.tasks
|
||||
.filter((task) => task && typeof task === "object" && !Array.isArray(task))
|
||||
.map((task) => [cleanAiTaskfileText(task.task_id, 100), task])
|
||||
.filter(([taskId]) => taskId));
|
||||
next.tasks = next.tasks.map((task) => {
|
||||
const taskPatch = patches.get(task.task_id);
|
||||
if (!taskPatch) return task;
|
||||
const improved = { ...task };
|
||||
if (typeof taskPatch.title === "string" && taskPatch.title.trim()) {
|
||||
improved.title = cleanAiTaskfileText(taskPatch.title, 180);
|
||||
}
|
||||
for (const [key, maxLength, maxItems] of [
|
||||
["context", 500, 10],
|
||||
["requirements", 700, 14],
|
||||
["acceptance_criteria", 600, 10],
|
||||
["verification", 600, 10],
|
||||
["risk_notes", 400, 6]
|
||||
]) {
|
||||
const values = normalizeAiStringArray(taskPatch[key], maxLength).slice(0, maxItems);
|
||||
if (values.length) improved[key] = values;
|
||||
}
|
||||
return improved;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeAiTaskfileArray(value, maxLength, textKey) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return cleanAiTaskfileText(item, maxLength);
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||
const text = cleanAiTaskfileText(item[textKey] || item.text || item.value || "", maxLength);
|
||||
if (!text) return null;
|
||||
const out = { [textKey]: text };
|
||||
const feedbackId = cleanAiTaskfileText(item.feedback_id || item.feedbackId || "", 80);
|
||||
if (feedbackId) out.feedback_id = feedbackId;
|
||||
return out;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 30);
|
||||
}
|
||||
|
||||
function normalizeAiStringArray(value, maxLength) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => cleanAiTaskfileText(item, maxLength))
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function cleanAiTaskfileText(value, maxLength) {
|
||||
return String(value || "")
|
||||
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
|
||||
.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]")
|
||||
.replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]")
|
||||
.replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function feedbackExportAi() {
|
||||
return global.lumiFrameworks?.ai || global.lumiFrameworks?.lumi_ai || null;
|
||||
}
|
||||
|
||||
function isFeedbackExportAiAvailable() {
|
||||
const ai = feedbackExportAi();
|
||||
if (!ai || typeof ai.generate !== "function") return false;
|
||||
try {
|
||||
const capabilities = typeof ai.capabilities === "function" ? ai.capabilities() : null;
|
||||
if (capabilities && capabilities.enabled === false) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseAiJsonObject(text) {
|
||||
if (!text) return null;
|
||||
const cleaned = text
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBodyJson(value) {
|
||||
if (value && typeof value === "object") return value;
|
||||
try {
|
||||
@ -2468,10 +2735,17 @@ async function verifyYouTubeSettings(settings) {
|
||||
function createWebServer({ loadPlugins, discordClient }) {
|
||||
const app = express();
|
||||
const webhooks = createWebhookService();
|
||||
placeholders.registerCorePlaceholders();
|
||||
placeholders.registerPlatformPlaceholders({
|
||||
discordClient,
|
||||
getTwitchClient,
|
||||
getYouTubeClient
|
||||
});
|
||||
if (!global.lumiFrameworks) {
|
||||
global.lumiFrameworks = {};
|
||||
}
|
||||
global.lumiFrameworks.webhooks = webhooks;
|
||||
global.lumiFrameworks.placeholders = placeholders;
|
||||
const assetVersion = Date.now().toString();
|
||||
const sessionStore = new BetterSqlite3Store({
|
||||
client: db
|
||||
@ -2667,6 +2941,10 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
};
|
||||
},
|
||||
placeholders,
|
||||
registerPlaceholder: placeholders.registerPlaceholder,
|
||||
registerPlaceholders: placeholders.registerPlaceholders,
|
||||
registerPlaceholderField: placeholders.registerFieldPolicy,
|
||||
emitEvent: publishWebEvent
|
||||
};
|
||||
|
||||
@ -2686,6 +2964,37 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
users: searchKnownUsers(req.query.q, { limit: req.query.limit || 30 })
|
||||
});
|
||||
});
|
||||
app.get("/api/placeholders/catalog", requireAuth, (req, res) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
try {
|
||||
const catalog = placeholders.catalog({
|
||||
fieldId: req.query.field_id,
|
||||
outputAudience: req.query.output_audience,
|
||||
user: req.session.user
|
||||
});
|
||||
res.json({ ok: true, ...catalog });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
app.post("/api/placeholders/preview", requireAuth, async (req, res) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
try {
|
||||
const rendered = await placeholders.renderTemplate({
|
||||
fieldId: req.body.field_id,
|
||||
template: req.body.template,
|
||||
outputAudience: req.body.output_audience,
|
||||
user: req.session.user,
|
||||
runtimeContext: {
|
||||
user: req.session.user,
|
||||
preview: true
|
||||
}
|
||||
});
|
||||
res.json({ ok: rendered.ok, rendered: rendered.rendered, errors: rendered.errors });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
app.get("/api/feedback/similar", requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
@ -2713,6 +3022,22 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
throw new Error(req.feedbackUploadError);
|
||||
}
|
||||
const body = normalizeFeedbackRequestBody(req.body);
|
||||
const similarMatches = findSimilarFeedback(body, {
|
||||
userId: req.session.user.id,
|
||||
limit: 5
|
||||
});
|
||||
const similarityConfirmed = body.diagnostics?.similar_feedback_confirmation === "distinct_or_additional_context";
|
||||
if (similarMatches.length && !similarityConfirmed) {
|
||||
[...(req.files?.screenshot || []), ...(req.files?.attachments || [])].forEach((file) => {
|
||||
if (file?.path) fs.rmSync(file.path, { force: true });
|
||||
});
|
||||
return res.status(409).json({
|
||||
ok: false,
|
||||
reason: "similar_feedback_confirmation_required",
|
||||
error: "Review the related feedback, then confirm that this report is distinct or adds useful context.",
|
||||
matches: similarMatches
|
||||
});
|
||||
}
|
||||
const screenshotFile = req.files?.screenshot?.[0] || null;
|
||||
const attachmentFiles = req.files?.attachments || [];
|
||||
const entry = createFeedback(body, req.session.user, {
|
||||
@ -5203,10 +5528,63 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
needs_action: req.query.needs_action || "",
|
||||
sort: req.query.sort || "last_activity"
|
||||
},
|
||||
feedbackOptions: feedbackOptions()
|
||||
feedbackOptions: feedbackOptions(),
|
||||
feedbackExportAiAvailable: isFeedbackExportAiAvailable()
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/feedback/export", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const request = normalizeFeedbackExportRequest(req.body);
|
||||
const exportPayload = buildFeedbackJobExport({
|
||||
ids: request.ids,
|
||||
filters: request.filters,
|
||||
all: request.all,
|
||||
tokenLimit: request.tokenLimit
|
||||
});
|
||||
const payload = await maybeAddAiFeedbackExportSummary(exportPayload, req, request.useAi);
|
||||
res.json({ ok: true, export: payload });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/feedback/:id/export", requireRole("admin"), async (req, res) => {
|
||||
try {
|
||||
const request = normalizeFeedbackExportRequest({ ...req.body, ids: req.params.id });
|
||||
const exportPayload = buildFeedbackJobExport({
|
||||
ids: request.ids,
|
||||
all: false,
|
||||
tokenLimit: request.tokenLimit
|
||||
});
|
||||
const payload = await maybeAddAiFeedbackExportSummary(exportPayload, req, request.useAi);
|
||||
res.json({ ok: true, export: payload });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/feedback/:id/create-okf-correction", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const feedback = getFeedbackForAdmin(req.params.id);
|
||||
const correction = saveCorrectionKnowledgeFile(process.cwd(), buildFeedbackCorrectionPayload(feedback, req.body));
|
||||
if (!correction) {
|
||||
throw new Error("Correction OKF file could not be created.");
|
||||
}
|
||||
const linkedCorrection = correction.path || `knowledge/corrections/${req.params.id}.md`;
|
||||
adminUpdateFeedback(req.params.id, {
|
||||
...req.body,
|
||||
linked_correction: linkedCorrection,
|
||||
work_note: `Created OKF correction: ${linkedCorrection}`,
|
||||
status_note: req.body.status_note || `Created OKF correction: ${linkedCorrection}`
|
||||
}, req.session.user);
|
||||
setFlash(req, "success", `Correction OKF created at ${linkedCorrection}.`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
res.redirect(req.get("referer") || "/admin/feedback");
|
||||
});
|
||||
|
||||
app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const body = { ...req.body };
|
||||
@ -5371,6 +5749,18 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
setFlash(req, "error", "Static commands require a response.");
|
||||
return res.redirect("/admin/commands");
|
||||
}
|
||||
if (mode !== "advanced") {
|
||||
const placeholderValidation = placeholders.validateTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: response,
|
||||
outputAudience: "user",
|
||||
user: req.session.user
|
||||
});
|
||||
if (!placeholderValidation.ok) {
|
||||
setFlash(req, "error", formatPlaceholderErrors(placeholderValidation.errors));
|
||||
return res.redirect("/admin/commands");
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
const preview = isAdmin && mode === "advanced"
|
||||
? await generateCommandPreview({ code, language })
|
||||
@ -5461,6 +5851,18 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
setFlash(req, "error", "Static commands require a response.");
|
||||
return res.redirect("/admin/commands");
|
||||
}
|
||||
if (mode !== "advanced") {
|
||||
const placeholderValidation = placeholders.validateTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: response,
|
||||
outputAudience: "user",
|
||||
user: req.session.user
|
||||
});
|
||||
if (!placeholderValidation.ok) {
|
||||
setFlash(req, "error", formatPlaceholderErrors(placeholderValidation.errors));
|
||||
return res.redirect("/admin/commands");
|
||||
}
|
||||
}
|
||||
const preview = isAdmin && mode === "advanced"
|
||||
? await generateCommandPreview({ code, language })
|
||||
: emptyCommandPreview();
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
<% } %>
|
||||
<div class="field">
|
||||
<label>Response</label>
|
||||
<input name="response" placeholder="Hello there!" class="js-field-response" />
|
||||
<input name="response" placeholder="Hello there!" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
||||
</div>
|
||||
<% if (isAdmin) { %>
|
||||
<div class="field full js-field-code">
|
||||
@ -184,7 +184,7 @@
|
||||
<% } %>
|
||||
<div class="field">
|
||||
<label>Response</label>
|
||||
<input name="response" value="<%= command.response %>" class="js-field-response" />
|
||||
<input name="response" value="<%= command.response %>" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
||||
</div>
|
||||
<% if (isAdmin) { %>
|
||||
<div class="field full js-field-code">
|
||||
|
||||
@ -88,6 +88,23 @@
|
||||
<p class="hint"><%= feedbackItems.length %> item(s) shown.</p>
|
||||
</div>
|
||||
</div>
|
||||
<% if (feedbackItems.length) { %>
|
||||
<div class="button-group feedback-export-toolbar" data-feedback-export-toolbar>
|
||||
<button class="button subtle" type="button" data-feedback-export-selected>Export selected to job</button>
|
||||
<button class="button subtle" type="button" data-feedback-export-all>Export all eligible to job</button>
|
||||
<% if (feedbackExportAiAvailable) { %>
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" data-feedback-export-ai />
|
||||
<span>Use AI compression</span>
|
||||
</label>
|
||||
<label class="compact-field" data-feedback-export-ai-options hidden>
|
||||
<span>Token/context limit</span>
|
||||
<input type="number" min="1000" max="24000" step="500" value="8000" data-feedback-export-token-limit />
|
||||
</label>
|
||||
<% } %>
|
||||
</div>
|
||||
<p class="hint">Exports omit submitter identity, raw diagnostics, local file paths, and screenshot/attachment contents. Private work notes are sanitized and included only as admin Codex context.</p>
|
||||
<% } %>
|
||||
<% if (!feedbackItems.length) { %>
|
||||
<div class="empty-state">No feedback matches this filter.</div>
|
||||
<% } else { %>
|
||||
@ -99,6 +116,10 @@
|
||||
<strong><%= item.summary %></strong>
|
||||
<small><%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %> · <%= item.support_count || 0 %> also affected</small>
|
||||
</span>
|
||||
<label class="checkbox-inline feedback-export-select" data-feedback-export-select-label>
|
||||
<input type="checkbox" value="<%= item.id %>" data-feedback-export-select />
|
||||
<span>Export</span>
|
||||
</label>
|
||||
<span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span>
|
||||
</summary>
|
||||
<div class="feedback-admin-body">
|
||||
@ -201,6 +222,69 @@
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details class="feedback-metadata">
|
||||
<summary>Codex job export</summary>
|
||||
<p class="hint">Build a stripped, Codex-ready JSON taskfile for this feedback item.</p>
|
||||
<button class="button subtle" type="button" data-feedback-export-one="<%= item.id %>">Export to job</button>
|
||||
</details>
|
||||
|
||||
<details class="feedback-metadata">
|
||||
<summary>Create OKF correction</summary>
|
||||
<p class="hint">Create a searchable correction file under <code>knowledge/corrections</code>. Submitter identity, diagnostics, screenshots, and attachments are not copied.</p>
|
||||
<form method="post" action="/admin/feedback/<%= item.id %>/create-okf-correction" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Correction title</label>
|
||||
<input name="correction_title" value="<%= item.summary %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<select name="correction_visibility">
|
||||
<option value="user">User</option>
|
||||
<option value="mod">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Priority</label>
|
||||
<input type="number" name="correction_priority" value="100" min="0" step="1" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tags</label>
|
||||
<input name="correction_tags" value="feedback, correction, <%= item.category %>" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Correction body</label>
|
||||
<textarea name="correction_body" rows="9"># <%= item.summary %>
|
||||
|
||||
## Correction
|
||||
<%= item.expected_behavior || item.description || item.summary %>
|
||||
|
||||
## Source feedback
|
||||
Feedback ID: <%= item.id %>
|
||||
Category: <%= item.category_label %>
|
||||
Severity: <%= item.severity_label %>
|
||||
Scope: <%= item.scope_label_display %>
|
||||
<% if (item.current_url) { %>Route: <%= item.current_url %><% } %>
|
||||
|
||||
## Reported behavior
|
||||
<%= item.description %>
|
||||
<% if (item.actual_behavior) { %>
|
||||
|
||||
## Actual behavior
|
||||
<%= item.actual_behavior %>
|
||||
<% } %>
|
||||
<% if (item.steps_to_reproduce) { %>
|
||||
|
||||
## Reproduction notes
|
||||
<%= item.steps_to_reproduce %>
|
||||
<% } %></textarea>
|
||||
</div>
|
||||
<div class="field full button-group centered">
|
||||
<button class="button subtle" type="submit">Create correction OKF</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<form method="post" action="/admin/feedback/<%= item.id %>" class="form-grid feedback-admin-form" data-confirm-mode="modal">
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
@ -271,4 +355,32 @@
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<div class="modal-backdrop" data-feedback-export-modal hidden aria-hidden="true">
|
||||
<div class="modal feedback-export-modal" role="dialog" aria-modal="true" aria-labelledby="feedback-export-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="feedback-export-title">Feedback export</h2>
|
||||
<p class="hint">Default output is a compact Codex taskfile. Use debug export only for troubleshooting.</p>
|
||||
</div>
|
||||
<button class="button subtle" type="button" data-feedback-export-close>Close</button>
|
||||
</div>
|
||||
<label class="compact-field feedback-export-mode-field">
|
||||
<span>Output</span>
|
||||
<select data-feedback-export-mode>
|
||||
<option value="taskfile">Codex taskfile</option>
|
||||
<option value="debug">Full debug export</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span data-feedback-export-output-label>Codex taskfile JSON</span>
|
||||
<textarea rows="18" readonly spellcheck="false" data-feedback-export-output></textarea>
|
||||
</label>
|
||||
<p class="hint" data-feedback-export-status></p>
|
||||
<div class="modal-actions centered">
|
||||
<button class="button subtle" type="button" data-feedback-export-copy>Copy JSON</button>
|
||||
<button class="button subtle" type="button" data-feedback-export-download>Download JSON</button>
|
||||
<button class="button" type="button" data-feedback-export-close>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -42,8 +42,12 @@
|
||||
</div>
|
||||
<div class="field full feedback-similar" data-feedback-similar hidden>
|
||||
<strong>Possibly related feedback</strong>
|
||||
<p class="hint">If one of these matches your issue, mark that it affects you too instead of creating a duplicate.</p>
|
||||
<p class="hint">If one matches your issue, mark that it affects you too. You may still submit when your report is distinct or adds useful context.</p>
|
||||
<div class="feedback-similar-list" data-feedback-similar-list></div>
|
||||
<label class="checkbox-inline" data-feedback-similar-confirm>
|
||||
<input type="checkbox" data-feedback-similar-confirm-input />
|
||||
<span>I reviewed these reports. Mine is distinct or adds useful context.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Category</label>
|
||||
@ -121,6 +125,7 @@
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="button subtle" data-feedback-close>Cancel</button>
|
||||
<button type="submit" class="button">Submit feedback</button>
|
||||
<button type="button" class="button subtle" data-feedback-reset>Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user