Lumi/plugins/lumi_ai/views/settings.ejs
2026-06-25 14:10:04 +02:00

803 lines
60 KiB
Plaintext

<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=<%= assetVersion %>" />
<%
const renderPresetOptions = (options, current) => {
const value = Number(current);
const hasValue = options.some((option) => option.value === value);
let html = "";
if (!hasValue && Number.isFinite(value)) {
html += `<option value="${value}" selected disabled>Unsupported current value (${value})</option>`;
}
html += options.map((option) => `<option value="${option.value}" ${option.value === value ? "selected" : ""}>${option.label}</option>`).join("");
return html;
};
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">
<div>
<h1>Lumi AI</h1>
<p>Managed local inference, assistant access, and guarded plugin tools.</p>
</div>
<div class="ai-runtime-badge <%= runtimeStatus.healthy ? 'ready' : 'offline' %>">
<span></span><%= runtimeStatus.healthy ? "Runtime ready" : "Runtime offline" %>
</div>
<button class="button subtle" type="button" data-ai-tools-open>Tools</button>
<a class="button subtle" href="/plugins/lumi_ai/improvement_center">Improvement Center</a>
</section>
<nav class="ai-tabs" aria-label="Lumi AI settings">
<a href="#overview">Overview</a>
<a href="#models">Models</a>
<a href="#runtime">Runtime</a>
<a href="#storage">Storage</a>
<a href="#assistant">Assistant</a>
<a href="#repo-index">Repo index</a>
<a href="#metrics">Metrics</a>
<a href="#logs">Logs</a>
</nav>
<section class="ai-band" id="overview">
<div class="ai-section-heading">
<div><h2>Overview</h2><p>Current installation and host capacity.</p></div>
</div>
<div class="ai-stat-grid">
<div><span>Provider</span><strong>llama.cpp</strong></div>
<div><span>Selected model</span><strong><%= models.find((model) => model.id === config.selected_model_id)?.label || config.selected_model_id %></strong></div>
<div><span>Gate model</span><strong><%= models.find((model) => model.id === config.gate.model_id)?.label || config.gate.model_id %></strong></div>
<div><span>Gate status</span><strong><%= gateStatus.healthy ? "Ready" : gateStatus.state %></strong></div>
<div><span>RAM</span><strong><%= Math.round(hardware.total_ram_mb / 1024) %> GB</strong></div>
<div><span>Free disk</span><strong><%= formatBytes(hardware.free_disk_mb * 1048576) %></strong></div>
<div><span>CPU threads</span><strong><%= hardware.cpu_threads %></strong></div>
<div><span>GPU</span><strong><%= hardware.gpu.present ? hardware.gpu.model : "Not detected" %></strong></div>
<div><span>VRAM</span><strong><%= hardware.gpu.vram_mb ? `${Math.round(hardware.gpu.vram_mb / 1024)} GB` : "Unavailable" %></strong></div>
<div><span>Compute API</span><strong><%= hardware.gpu.compute_api?.length ? hardware.gpu.compute_api.map((api) => api.toUpperCase()).join(", ") : "CPU only" %></strong></div>
<div><span>GPU driver</span><strong><%= hardware.gpu.driver || "Unavailable" %></strong></div>
<div><span>Installed backend</span><strong><%= String(runtimeStatus.runtime_backend || "cpu").toUpperCase() %></strong></div>
<div><span>Recommended backend</span><strong><%= String(hardware.runtime_selection.backend || "cpu").toUpperCase() %></strong></div>
<div><span>Total AI RAM estimate</span><strong><%= formatBytes(resourceEstimate.total_cpu_memory_mb * 1048576) %></strong></div>
<div><span>Total AI VRAM estimate</span><strong><%= formatBytes(resourceEstimate.total_gpu_memory_mb * 1048576) %></strong></div>
</div>
<% resourceEstimate.warnings.forEach((warning) => { %><div class="callout danger"><%= warning %></div><% }) %>
<% sizeDiagnostics.forEach((diagnostic) => { %>
<div class="callout danger"><%= diagnostic.message %></div>
<% }) %>
</section>
<section class="ai-band" id="models">
<div class="ai-section-heading">
<div><h2>Models</h2><p>Pinned GGUF files downloaded directly from Hugging Face and verified by SHA-256.</p></div>
</div>
<div class="ai-model-list">
<% models.forEach((model) => { %>
<article class="ai-model-row">
<div class="ai-model-main">
<strong><%= model.label %></strong>
<span><%= formatBytes(model.size) %> &middot; <%= model.ram_gb %> GB recommended RAM &middot; <%= model.repo %></span>
</div>
<span class="ai-tag <%= model.downloaded ? 'installed' : model.compatible ? '' : 'warning' %>">
<%= model.downloaded ? "Installed" : model.compatible ? "Available" : "Exceeds host" %>
</span>
<div class="ai-inline-form">
<% if (model.downloaded) { %>
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/verify">
<button class="button subtle" type="submit">Verify</button>
</form>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
<%- include("../../../src/web/views/partials/state-button", {
type: "submit",
classes: "subtle",
attrs: `data-ai-download-button data-download-id="model:${model.id}"`,
states: [
{ id: "idle", text: "Redownload" },
{ id: "loading", text: "Downloading", spinner: true },
{ id: "success", text: "Downloaded" },
{ id: "error", text: "Retry" }
]
}) %>
</form>
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model">
<input type="hidden" name="confirm" value="yes" />
<button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button>
</form>
<% } else { %>
<form method="post" action="/plugins/lumi_ai/download/model/<%= model.id %>" data-ai-download-form data-download-id="model:<%= model.id %>">
<% if (!model.compatible) { %>
<label title="Allow download despite detected capacity"><input type="checkbox" name="override_compatibility" /> Override</label>
<% } %>
<%- include("../../../src/web/views/partials/state-button", {
type: "submit",
classes: "subtle",
attrs: `data-ai-download-button data-download-id="model:${model.id}"`,
states: [
{ id: "idle", text: "Download" },
{ id: "loading", text: "Downloading", spinner: true },
{ id: "success", text: "Downloaded" },
{ id: "error", text: "Retry" }
]
}) %>
</form>
<% } %>
</div>
</article>
<% }) %>
</div>
</section>
<section class="ai-band" id="runtime">
<div class="ai-section-heading">
<div><h2>Runtime</h2><p>Official llama.cpp release, bound to localhost and stored inside this plugin.</p></div>
<div class="ai-actions" data-ai-runtime-actions>
<%- include("../../../src/web/views/partials/state-button", {
type: "button",
attrs: "data-runtime-primary",
loadingState: "starting",
successState: "running",
errorState: "error",
defaultState: runtimeStatus.state === "running" ? "running" : "idle",
states: [
{ id: "idle", text: "Start" },
{ id: "starting", text: "Starting", spinner: true },
{ id: "running", text: "Restart" },
{ id: "restarting", text: "Restarting", spinner: true },
{ id: "error", text: "Retry" }
]
}) %>
<button class="button subtle" type="button" data-runtime-action="self-test">Run self-test</button>
<button class="button subtle" type="button" data-runtime-action="verify-runtime">Verify runtime</button>
<button class="button subtle" type="button" data-runtime-action="verify-model">Verify model</button>
<button class="button subtle" type="button" data-runtime-action="verify-gate-model">Verify gate model</button>
<button class="button danger" type="button" data-runtime-action="stop">Stop</button>
</div>
</div>
<div class="ai-runtime-grid">
<div class="ai-diagnostic">
<span>Installed</span><strong><%= runtimeStatus.runtime_installed ? "Yes" : "No" %></strong>
<span>Process</span><strong data-runtime-state><%= runtimeStatus.state %></strong>
<span>Health</span><strong><%= runtimeStatus.healthy ? "Healthy" : "Unavailable" %></strong>
<span>PID</span><strong><%= runtimeStatus.pid || "None" %></strong>
<span>Last stop</span><strong><%= runtimeState.last_stop_reason %></strong>
<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 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>
<span>GPU intent</span><strong><%= runtimeStatus.gpu_allocation_intent_percent || 0 %>%</strong>
<span>GPU actual</span><strong><%= runtimeStatus.gpu_allocation_actual_percent || 0 %>%</strong>
<span>GPU safe maximum</span><strong><%= runtimeStatus.gpu_allocation_max_safe_percent || 0 %>%</strong>
<span>GPU layers</span><strong><%= runtimeStatus.gpu_layers || 0 %></strong>
<span>Total VRAM</span><strong><%= formatBytes((runtimeStatus.total_vram_mb || 0) * 1048576) %></strong>
<span>Free VRAM</span><strong><%= formatBytes((runtimeStatus.free_vram_mb || 0) * 1048576) %></strong>
<span>Managed model VRAM</span><strong><%= formatBytes((runtimeStatus.managed_model_vram_mb || 0) * 1048576) %></strong>
<span>External VRAM estimate</span><strong><%= formatBytes((runtimeStatus.external_vram_estimate_mb || 0) * 1048576) %></strong>
</div>
<div>
<p><strong>Lightweight gate: <%= gateStatus.healthy ? "Ready" : gateStatus.state %></strong></p>
<p class="hint">
<%= gateStatus.model_id || config.gate.model_id %> &middot;
CPU <%= formatBytes((gateStatus.estimated_cpu_memory_mb || 0) * 1048576) %> &middot;
VRAM <%= formatBytes((gateStatus.estimated_gpu_memory_mb || 0) * 1048576) %>
</p>
<% if (gateStatus.last_error) { %><div class="callout"><%= gateStatus.last_error %></div><% } %>
<% if (runtimeTarget) { %>
<p><strong>Managed <%= String(runtimeTarget.backend_variant || runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %></strong></p>
<p class="hint">
<%= runtimeTarget.filename %> &middot; <%= formatBytes(runtimeTarget.size) %>
<% if (runtimeTarget.dependencies?.length) { %>
&middot; plus <%= runtimeTarget.dependencies.length %> dependency archive<%= runtimeTarget.dependencies.length === 1 ? "" : "s" %>
&middot; 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",
classes: "subtle",
attrs: "data-ai-download-button data-download-id=\"runtime\"",
states: [
{ id: "idle", text: runtimeStatus.runtime_installed ? "Reinstall runtime" : "Download runtime" },
{ id: "loading", text: "Downloading", spinner: true },
{ id: "success", text: "Downloaded" },
{ id: "error", text: "Retry" }
]
}) %>
</form>
<% } else { %>
<div class="callout">No managed runtime build is available for this OS and architecture.</div>
<% } %>
<% if (runtimeStatus.last_error) { %><div class="callout danger"><%= runtimeStatus.last_error %></div><% } %>
<% if (runtimeStatus.acceleration_warning) { %><div class="callout"><%= runtimeStatus.acceleration_warning %></div><% } %>
<% tuningHints.forEach((hint) => { %><div class="callout"><%= hint %></div><% }) %>
<% if (hardware.runtime_selection.fallback_to_cpu) { %><div class="callout"><%= hardware.runtime_selection.reason %></div><% } %>
</div>
</div>
<div class="ai-download-status" data-download-status hidden></div>
</section>
<section class="ai-band" id="runtime-diagnostics">
<div class="ai-section-heading">
<div><h2>Runtime diagnostics</h2><p>Latest plugin-local runtime failure and remediation details.</p></div>
<a class="button subtle" href="/plugins/lumi_ai/diagnostics/download">Download diagnostics</a>
</div>
<% if (latestDiagnostic) { %>
<div class="callout danger">
<strong><%= latestDiagnostic.code %>: <%= latestDiagnostic.message %></strong>
<p><%= latestDiagnostic.category %> / <%= latestDiagnostic.severity %></p>
</div>
<% if (latestDiagnostic.remediation_steps?.length) { %>
<ol class="ai-remediation"><% latestDiagnostic.remediation_steps.forEach((step) => { %><li><%= step %></li><% }) %></ol>
<% } %>
<details class="ai-raw-diagnostic">
<summary>Raw diagnostic details</summary>
<pre><%= JSON.stringify(latestDiagnostic, null, 2) %></pre>
</details>
<% } else { %>
<p class="hint">No runtime diagnostic has been recorded.</p>
<% } %>
<% if (hardware.network_path_warning) { %>
<div class="callout">The plugin path may be a mapped or network-like location. A local disk path is more reliable for native runtime DLL loading.</div>
<% } %>
<% if (hardware.long_path_warning) { %><div class="callout">The plugin path is unusually long for Windows native loading. Consider a shorter local installation path.</div><% } %>
</section>
<section class="ai-band" id="storage">
<div class="ai-section-heading">
<div><h2>Storage cleanup</h2><p>Plugin-local files only. Selected models and active runtimes are protected.</p></div>
<strong><%= formatBytes(storageUsage.total) %> total</strong>
</div>
<div class="ai-stat-grid compact">
<% Object.entries(storageUsage.categories).forEach(([category, bytes]) => { %>
<div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div>
<% }) %>
</div>
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected">
<label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label>
<label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label>
<label><input type="checkbox" name="categories" value="logs" /> Old logs</label>
<label><input type="checkbox" name="categories" value="metrics" /> Metrics history</label>
<label><input type="checkbox" name="categories" value="diagnostics" /> Diagnostics</label>
<label><input type="checkbox" name="categories" value="cache" /> Cache</label>
<label><input type="checkbox" name="categories" value="tmp" /> Temporary files</label>
<label><input type="checkbox" name="categories" value="runtime" <%= runtimeStatus.state === "running" ? "disabled" : "" %> /> Extracted runtime</label>
<button class="button danger" type="submit">Clean selected</button>
</form>
</section>
<form method="post" action="/plugins/lumi_ai/settings" data-lumi-settings-form>
<section class="ai-band" id="assistant">
<div class="ai-section-heading">
<div><h2>Assistant</h2><p>Configuration remains admin-only. Visibility controls only the sidebar assistant.</p></div>
<button class="button" type="submit">Save settings</button>
</div>
<div class="ai-settings-groups">
<details class="ai-settings-group" open>
<summary>Model, runtime, and GPU</summary>
<div class="form-grid ai-form">
<div class="field">
<label>AI enabled</label>
<label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= config.enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Available</span></label>
</div>
<div class="field">
<label>Sidebar assistant</label>
<label class="switch"><input class="switch-input" type="checkbox" name="assistant_enabled" <%= config.assistant_enabled !== false ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Enabled</span></label>
</div>
<div class="field">
<label for="selected-model">Selected model</label>
<% if (!selectedModelInstalled) { %>
<div class="callout danger">The currently selected model is not installed. Choose an installed model before saving.</div>
<% } %>
<select id="selected-model" name="selected_model_id" data-gpu-model required>
<% if (!installedModels.length) { %>
<option value="">No installed models available</option>
<% } %>
<% installedModels.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.selected_model_id ? "selected" : "" %>><%= model.label %></option><% }) %>
</select>
</div>
<div class="field">
<label>Context size</label>
<select name="context_size" data-gpu-context>
<% contextOptions.forEach((option) => { %>
<option value="<%= option.value %>" <%= Number(config.context_size) === option.value ? "selected" : "" %>><%= option.label %> - <%= option.description %></option>
<% }) %>
</select>
</div>
<div class="field"><label>CPU threads (0 = auto)</label><input type="number" name="threads" min="0" max="256" value="<%= config.threads %>" /></div>
<div class="field full ai-gpu-control" data-gpu-control data-endpoint="/plugins/lumi_ai/api/gpu-capacity">
<div class="ai-gpu-label">
<label for="gpu-workload">GPU Acceleration</label>
<strong data-gpu-value><%= gpuAllocation.gpu_allocation_intent_percent %>% intent</strong>
</div>
<div class="ai-gpu-slider" style="--gpu-actual: <%= gpuAllocation.gpu_allocation_actual_percent %>%; --gpu-max: <%= gpuAllocation.gpu_allocation_max_safe_percent %>%;" data-gpu-slider title="Maximum safe allocation: <%= gpuAllocation.gpu_allocation_max_safe_percent %>%">
<input id="gpu-workload" type="range" name="gpu_allocation_intent_percent" min="0" max="100" step="1" value="<%= gpuAllocation.gpu_allocation_intent_percent %>" data-gpu-workload />
</div>
<div class="ai-gpu-scale"><span>CPU only</span><span data-gpu-limit>Maximum safe: <%= gpuAllocation.gpu_allocation_max_safe_percent %>%</span><span>Maximum GPU</span></div>
<div class="ai-gpu-summary">
<span>Backend <strong data-gpu-backend><%= String(gpuAllocation.backend).toUpperCase() %></strong></span>
<span>Intended <strong data-gpu-intent><%= gpuAllocation.gpu_allocation_intent_percent %>%</strong></span>
<span>Actual <strong data-gpu-actual><%= gpuAllocation.gpu_allocation_actual_percent %>%</strong></span>
<span>Managed model VRAM <strong data-gpu-memory><%= formatBytes(gpuAllocation.managed_model_vram_mb * 1048576) %></strong></span>
<span>Total VRAM <strong data-gpu-total-vram><%= formatBytes(gpuAllocation.total_vram_mb * 1048576) %></strong></span>
<span>Free VRAM <strong data-gpu-free-vram><%= formatBytes(gpuAllocation.free_vram_mb * 1048576) %></strong></span>
<span>External VRAM <strong data-gpu-external-vram><%= formatBytes(gpuAllocation.external_vram_estimate_mb * 1048576) %></strong></span>
</div>
<p class="hint" data-gpu-warning <%= gpuAllocation.warning ? "" : "hidden" %>><%= gpuAllocation.warning || "" %></p>
</div>
<div class="field"><label>Concurrent requests</label><input type="number" name="concurrency" min="1" max="8" value="<%= config.concurrency %>" /></div>
<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 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>
<div class="field"><label>Admin rate-limit bypass</label><label class="switch"><input class="switch-input" type="checkbox" name="admin_bypass_rate_limit" <%= config.admin_bypass_rate_limit ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Bypass</span></label></div>
</div>
</details>
<details class="ai-settings-group" open>
<summary>Lightweight request gate</summary>
<div class="form-grid ai-form">
<div class="field">
<label>Gate model</label>
<select name="gate_model_id"><% models.forEach((model) => { %><option value="<%= model.id %>" <%= model.id === config.gate.model_id ? "selected" : "" %>><%= model.label %></option><% }) %></select>
<span class="hint">Use the smallest downloaded model that can reliably return JSON classifications.</span>
</div>
<div class="field"><label>Gate context size</label><select name="gate_context_size"><%- renderPresetOptions(gateContextOptions, config.gate.context_size) %></select></div>
<div class="field"><label>Gate CPU threads</label><input type="number" name="gate_threads" min="1" max="16" value="<%= config.gate.threads %>" /></div>
<div class="field"><label>Gate timeout (ms)</label><input type="number" name="gate_timeout_ms" min="1000" max="5000" step="250" value="<%= config.gate.timeout_ms %>" /><span class="hint">Timeout or errors immediately escalate to the main model.</span></div>
<div class="field"><label>High-confidence threshold</label><input type="number" name="gate_high_confidence_threshold" min="0.5" max="0.99" step="0.01" value="<%= config.gate.high_confidence_threshold %>" /></div>
<div class="field"><label>Main LLM threshold</label><input type="number" name="gate_main_llm_threshold" min="0.1" max="0.95" step="0.01" value="<%= config.gate.main_llm_threshold %>" /></div>
<div class="field"><label>Safe cache and predefined answers</label><label class="switch"><input class="switch-input" type="checkbox" name="gate_predefined_enabled" <%= config.gate.predefined_enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Enabled</span></label></div>
<div class="field"><label>Cache TTL (seconds)</label><input type="number" name="gate_cache_ttl_seconds" min="30" max="604800" value="<%= config.gate.cache_ttl_seconds %>" /></div>
<div class="field"><label>Repeat force window (seconds)</label><input type="number" name="gate_repeat_force_window_seconds" min="0" max="3600" value="<%= config.gate.repeat_force_window_seconds %>" /></div>
<div class="field"><label>Similarity threshold</label><input type="number" name="gate_similarity_threshold" min="0.5" max="1" step="0.01" value="<%= config.gate.similarity_threshold %>" /></div>
<div class="field"><label>Explicit force prefix</label><input name="gate_force_prefix" maxlength="40" value="<%= config.gate.force_prefix %>" placeholder="force ai:" /></div>
<div class="field full"><span class="hint">The gate cannot execute tools. Permission-sensitive, ambiguous, complex, low-confidence, and repeated requests are sent to the main model.</span></div>
</div>
</details>
<details class="ai-settings-group" open>
<summary>Assistant visibility and diagnostics</summary>
<div class="form-grid ai-form">
<fieldset class="field full ai-fieldset">
<legend>Sidebar visibility</legend>
<label><input type="checkbox" name="visibility_admins" <%= config.assistant_visibility.admins ? "checked" : "" %> /> Administrators</label>
<label><input type="checkbox" name="visibility_mods" <%= config.assistant_visibility.mods ? "checked" : "" %> /> Moderators</label>
<label><input type="checkbox" name="visibility_users" <%= config.assistant_visibility.users ? "checked" : "" %> /> Users</label>
</fieldset>
<div class="field full ai-assistant-diagnostic" data-assistant-diagnostics data-endpoint="/api/lumi-ai/assistant/visibility-debug">
<div class="ai-diagnostic-summary">
<strong>Assistant pill: <span data-assistant-status><%= visibilityDiagnostics.available ? "Backend ready" : "Hidden" %></span></strong>
<span data-assistant-reason><%= assistantReason %></span>
</div>
<div class="ai-condition-grid" data-assistant-conditions>
<% visibilityDiagnostics.conditions.forEach((condition) => { %>
<div data-condition="<%= condition.key %>">
<span><%= condition.key.replaceAll("_", " ") %></span>
<strong class="<%= condition.passed ? 'pass' : 'fail' %>"><%= condition.passed ? "Pass" : "Fail" %></strong>
</div>
<% }) %>
</div>
<div class="ai-panel-render-diagnostic">
<span>User ID <strong><%= visibilityDiagnostics.permission.debug_details.resolved_user_id || "None" %></strong></span>
<span>Role <strong><%= visibilityDiagnostics.permission.normalized_role %></strong></span>
<span>Role source <strong><%= visibilityDiagnostics.permission.debug_details.role_source %></strong></span>
<span>Allowed roles <strong><%= visibilityDiagnostics.permission.debug_details.allowed_roles.join(", ") || "None" %></strong></span>
<span>Origin <strong><%= visibilityDiagnostics.permission.debug_details.origin %></strong></span>
<span>Endpoint status <strong data-panel-endpoint-status><%= panelDiagnostics.panel_endpoint_status || "Not requested" %></strong></span>
<span>HTML length <strong data-panel-html-length><%= panelDiagnostics.panel_html_length || 0 %></strong></span>
<span>Template <strong><%= panelDiagnostics.panel_template_path %></strong></span>
<span>Missing locals <strong><%= panelDiagnostics.missing_locals.length ? panelDiagnostics.missing_locals.join(", ") : "None" %></strong></span>
<span>HTML error <strong data-panel-html-error><%= panelDiagnostics.panel_html_error || "None" %></strong></span>
<span>Mount error <strong data-panel-mount-error><%= panelDiagnostics.mount_error || "None" %></strong></span>
</div>
</div>
</div>
</details>
<details class="ai-settings-group">
<summary>Improvement Center</summary>
<div class="form-grid ai-form">
<div class="field">
<label>Moderator response review</label>
<label class="switch"><input class="switch-input" type="checkbox" name="allow_moderators_to_review_responses" <%= config.improvement.allow_moderators_to_review_responses ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Enabled</span></label>
</div>
<div class="field">
<label>Approved corrections</label>
<label class="switch"><input class="switch-input" type="checkbox" name="corrections_enabled" <%= config.improvement.corrections_enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Enabled</span></label>
</div>
<div class="field full">
<label>Trusted moderator reviewer IDs</label>
<textarea name="trusted_moderator_reviewers" rows="2" placeholder="One internal user ID per line"><%= config.improvement.trusted_moderator_reviewers.join("\n") %></textarea>
<span class="hint">Trusted moderators can verify reviews. Only administrators can approve, edit, delete, promote, export, or run evals.</span>
</div>
<div class="field full"><a class="button subtle" href="/plugins/lumi_ai/improvement_center">Open Improvement Center</a></div>
</div>
</details>
<details class="ai-settings-group">
<summary>Assistant identity and scope</summary>
<div class="form-grid ai-form">
<div class="field full ai-identity-preview"><strong>Lumi Assistant</strong><span>Built-in AI assistant for Lumi. This identity is fixed and cannot be replaced by model branding.</span></div>
<div class="field full"><label>Allowed topics</label><textarea name="allowed_topics" rows="3"><%= config.support_scope.allowed_topics %></textarea></div>
<div class="field full"><label>Allowed support domains</label><textarea name="allowed_support_domains" rows="3"><%= config.support_scope.allowed_support_domains %></textarea></div>
<div class="field full"><label>Answer style</label><textarea name="answer_style" rows="2"><%= config.support_scope.answer_style %></textarea></div>
<div class="field full"><label>Linking behavior</label><textarea name="linking_behavior" rows="2"><%= config.support_scope.linking_behavior %></textarea></div>
<div class="field full"><label>Clarification behavior</label><textarea name="clarification_behavior" rows="2"><%= config.support_scope.clarification_behavior %></textarea></div>
<div class="field full"><label>Out-of-scope response</label><textarea name="out_of_scope_response" rows="2"><%= config.instructions.out_of_scope_response %></textarea></div>
<div class="field"><label>Preferred final answer length</label><input type="number" name="maximum_answer_length" min="100" max="4000" value="<%= config.support_scope.max_answer_length %>" /><span class="hint">Style guidance for the final answer. It does not limit prompt context or reasoning.</span></div>
<div class="field"><label>Internal generation character budget</label><input type="number" name="internal_generation_char_budget" min="2000" max="64000" step="1000" value="<%= config.internal_generation_char_budget %>" /><span class="hint">Controls model output capacity, not prompt or context length.</span></div>
<div class="field"><label>Roleplay intensity (0-10)</label><input type="number" name="roleplay_intensity" min="0" max="10" value="<%= config.instructions.roleplay_intensity || 0 %>" /></div>
<div class="field full"><label>Community tone</label><textarea name="community_tone" rows="2"><%= config.instructions.community_tone %></textarea></div>
<div class="field full"><label>Administrator scope override</label><textarea name="scope_admin" rows="2"><%= config.support_scope.role_overrides.admin %></textarea></div>
<div class="field full"><label>Moderator scope override</label><textarea name="scope_mod" rows="2"><%= config.support_scope.role_overrides.mod %></textarea></div>
<div class="field full"><label>User scope override</label><textarea name="scope_user" rows="2"><%= config.support_scope.role_overrides.user %></textarea></div>
<div class="field full"><label>Admin custom instructions</label><textarea name="admin_custom" rows="4"><%= config.instructions.admin_custom %></textarea><span class="hint">Hard scope, role, tool, and confirmation rules cannot be overridden.</span></div>
<div class="field full ai-hard-scope">
<strong>Hard scope</strong>
<ul><% hardRules.forEach((rule) => { %><li><%= rule %></li><% }) %></ul>
</div>
</div>
</details>
<details class="ai-settings-group">
<summary>Platform commands</summary>
<div class="form-grid ai-form">
<div class="field"><label>Commands</label><label class="switch"><input class="switch-input" type="checkbox" name="command_enabled" <%= config.commands.enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text">Enabled</span></label></div>
<div class="field"><label>Triggers and aliases</label><input name="command_triggers" value="<%= config.commands.triggers.join(', ') %>" /></div>
<fieldset class="field full ai-fieldset"><legend>Platforms</legend>
<% Object.entries(config.commands.platforms).forEach(([platform, enabled]) => { %>
<label><input type="checkbox" name="command_platform_<%= platform %>" <%= enabled ? "checked" : "" %> /> <%= platform %></label>
<% }) %>
</fieldset>
<fieldset class="field full ai-fieldset"><legend>Roles</legend>
<label><input type="checkbox" name="command_role_admins" <%= config.commands.roles.admins ? "checked" : "" %> /> Administrators</label>
<label><input type="checkbox" name="command_role_mods" <%= config.commands.roles.mods ? "checked" : "" %> /> Moderators</label>
<label><input type="checkbox" name="command_role_users" <%= config.commands.roles.users ? "checked" : "" %> /> Users</label>
</fieldset>
<div class="field full"><label>Runtime unavailable reply</label><input name="command_unavailable_message" value="<%= config.commands.unavailable_message %>" /></div>
<div class="field full"><label>Access denied reply</label><input name="command_denied_message" value="<%= config.commands.denied_message %>" /></div>
</div>
</details>
<details class="ai-settings-group">
<summary>Rate limits</summary>
<div class="ai-limit-grid">
<% [
["limit_role_admin","Administrator role",config.rate_limits.roles.admin],
["limit_role_mod","Moderator role",config.rate_limits.roles.mod],
["limit_role_user","User role",config.rate_limits.roles.user],
["limit_platform_webui","WebUI",config.rate_limits.platforms.webui],
["limit_platform_discord","Discord",config.rate_limits.platforms.discord],
["limit_platform_twitch","Twitch",config.rate_limits.platforms.twitch],
["limit_platform_youtube","YouTube",config.rate_limits.platforms.youtube],
["limit_platform_kick","Kick",config.rate_limits.platforms.kick],
["limit_platform_other","Other platform",config.rate_limits.platforms.other],
["limit_user","Per user",config.rate_limits.per_user],
["limit_channel","Per channel/server",config.rate_limits.per_channel]
].forEach(([key,label,limit]) => { %>
<div><strong><%= label %></strong><label>Requests <input type="number" min="0" max="10000" name="<%= key %>_requests" value="<%= limit.requests %>" /></label><label>Window seconds <input type="number" min="1" max="86400" name="<%= key %>_window" value="<%= limit.window_seconds %>" /></label></div>
<% }) %>
</div>
</details>
<details class="ai-settings-group">
<summary>Support diagnostics and logging</summary>
<div class="form-grid ai-form">
<fieldset class="field full ai-fieldset">
<legend>Support diagnostics</legend>
<label><input type="checkbox" name="repo_lookup_enabled" <%= config.support_scope.repo_lookup_enabled ? "checked" : "" %> /> Use local repository index</label>
<label><input type="checkbox" name="allow_deterministic_help_shortcuts" <%= config.support_scope.allow_deterministic_help_shortcuts ? "checked" : "" %> /> Allow exact deterministic navigation shortcuts</label>
<label><input type="checkbox" name="allow_moderator_code_help" <%= config.support_scope.allow_moderator_code_help ? "checked" : "" %> /> Allow moderators to receive code and custom JavaScript help</label>
<label><input type="checkbox" name="assistant_debug_logging" <%= config.assistant_debug_logging ? "checked" : "" %> /> Browser console diagnostics</label>
</fieldset>
<fieldset class="field full ai-fieldset">
<legend>Logging</legend>
<% [["log_prompts","Prompts"],["log_responses","Responses"],["log_tool_calls","Tool calls"],["log_metrics","Metrics"],["log_internal_audit","Internal audit"]].forEach(([key,label]) => { %>
<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>
</section>
</form>
<section class="ai-band" id="ai-access">
<div class="ai-section-heading"><div><h2>User AI access</h2><p>Bans and timeouts apply to WebUI and platform commands.</p></div></div>
<form method="post" action="/plugins/lumi_ai/access-control" class="ai-access-form" data-ai-access-form>
<div class="field ai-user-picker lumi-user-lookup" data-user-lookup>
<label>User</label>
<input type="search" autocomplete="off" placeholder="Search name, username, platform ID, or user ID" data-user-lookup-search />
<input type="hidden" name="user_id" required data-user-lookup-id />
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
</div>
<div class="field"><label>Action</label><select name="action" data-access-action><option value="ban">Ban</option><option value="timeout">Timeout</option><option value="remove">Remove restriction</option></select></div>
<div class="field" data-timeout-field hidden><label>Timeout until</label><input type="datetime-local" name="timeout_until" /></div>
<div class="field"><label>Reason</label><input name="reason" /></div>
<div class="field"><label><input type="checkbox" name="silent" /> Silently ignore platform commands</label></div>
<div class="field"><button class="button" type="submit">Update access</button></div>
</form>
<div class="table-tools"><input type="search" class="table-search" placeholder="Search restrictions" data-table-filter="ai-restrictions" /></div>
<div class="table-wrap"><table class="table" data-table="ai-restrictions"><thead><tr><th>User</th><th>Restriction</th><th>Until</th><th>Reason</th></tr></thead><tbody>
<% activeAiRestrictions.forEach((entry) => { %><tr data-search="<%= `${entry.user_id} ${entry.banned ? "banned" : "timed out"} ${entry.reason || ""}`.toLowerCase() %>"><td><%= entry.user_id %></td><td><%= entry.banned ? "Banned" : "Timed out" %></td><td><%= entry.timeout_until ? formatDate(entry.timeout_until) : "-" %></td><td><%= entry.reason || "-" %></td></tr><% }) %>
<% if (!activeAiRestrictions.length) { %><tr><td colspan="4">No active AI restrictions.</td></tr><% } %>
</tbody></table></div>
<div class="table-pagination">
<a class="button subtle <%= accessPage.page <= 1 ? 'disabled' : '' %>" href="?access_page=<%= Math.max(1, accessPage.page - 1) %>#ai-access">Previous</a>
<span class="table-page-label">Page <%= accessPage.page %> of <%= accessPage.pages %> (<%= accessPage.total %> entries)</span>
<a class="button subtle <%= accessPage.page >= accessPage.pages ? 'disabled' : '' %>" href="?access_page=<%= Math.min(accessPage.pages, accessPage.page + 1) %>#ai-access">Next</a>
</div>
<details class="ai-settings-group"><summary>Recent rate-limit denials</summary>
<div class="table-wrap"><table class="table"><thead><tr><th>Time</th><th>User</th><th>Platform</th><th>Bucket</th><th>Retry</th></tr></thead><tbody>
<% recentRateLimitDenials.forEach((entry) => { %><tr><td><%= formatDate(entry.at) %></td><td><%= entry.user_id %></td><td><%= entry.platform %></td><td><%= entry.bucket %></td><td><%= entry.retry_after_seconds %>s</td></tr><% }) %>
<% if (!recentRateLimitDenials.length) { %><tr><td colspan="5">No recent rate-limit denials.</td></tr><% } %>
</tbody></table></div>
</details>
</section>
<section class="ai-band" id="repo-index">
<div class="ai-section-heading">
<div><h2>Repository support index</h2><p>Local Lumi routes, settings pages, plugin manifests, commands, and documentation.</p></div>
<div class="ai-actions">
<form method="post" action="/plugins/lumi_ai/repo-index/refresh">
<input type="hidden" name="source" value="local" />
<button class="button subtle" type="submit">Refresh local</button>
</form>
<form method="post" action="/plugins/lumi_ai/repo-index/refresh" data-confirm-title="Refresh public repository index" data-confirm-text="Download and index the approved public Lumi repository?" data-confirm-label="Refresh public">
<input type="hidden" name="source" value="public" />
<button class="button subtle" type="submit">Refresh public</button>
</form>
</div>
</div>
<div class="ai-stat-grid compact">
<div><span>Status</span><strong><%= repoIndexStatus.present ? repoIndexStatus.stale ? "Stale" : "Ready" : "Missing" %></strong></div>
<div><span>Last indexed</span><strong><%= repoIndexStatus.indexed_at ? formatDate(repoIndexStatus.indexed_at) : "Never" %></strong></div>
<div><span>Commit</span><strong><%= repoIndexStatus.commit ? repoIndexStatus.commit.slice(0, 12) : "Unavailable" %></strong></div>
<div><span>Routes</span><strong><%= repoIndexStatus.route_count %></strong></div>
<div><span>Plugins</span><strong><%= repoIndexStatus.plugin_count %></strong></div>
<div><span>Commands</span><strong><%= repoIndexStatus.command_count %></strong></div>
</div>
</section>
<section class="ai-band" id="test-console">
<div class="ai-section-heading"><div><h2>Test console</h2><p>Run a request as a simulated role without changing the logged-in actor.</p></div></div>
<form class="form-grid ai-form" data-ai-test-form>
<div class="field"><label>Simulated role</label><select name="role"><option value="admin">Admin</option><option value="mod">Moderator</option><option value="user">User</option></select></div>
<div class="field"><label>Origin</label><select name="origin"><option value="webui">WebUI</option><option value="discord">Discord</option><option value="twitch">Twitch</option><option value="youtube">YouTube</option><option value="kick">Kick</option><option value="other">Other</option></select></div>
<div class="field full"><label>Message</label><textarea name="message" rows="3" required>Where can I find Twitch configuration?</textarea></div>
<div class="field full ai-fieldset">
<label><input type="checkbox" name="allow_tools" /> Allow registered tools through the normal generation pipeline</label>
<label><input type="checkbox" name="show_raw_prompt" /> Show assembled prompt</label>
<label><input type="checkbox" name="show_raw_output" /> Show raw model response</label>
</div>
<div class="field full"><p class="hint" data-ai-test-tools-notice>Tools are disabled for this test unless explicitly enabled above.</p></div>
<div class="field full"><button class="button" type="submit">Run test</button></div>
</form>
<pre class="ai-test-output" data-ai-test-output hidden></pre>
</section>
<section class="ai-band" id="metrics">
<div class="ai-section-heading"><div><h2>Metrics</h2><p>Plugin-local operational counters and recent requests.</p></div></div>
<div class="ai-stat-grid compact">
<div><span>Requests</span><strong><%= metrics.total_requests %></strong></div>
<div><span>Successful</span><strong><%= metrics.successful %></strong></div>
<div><span>Failed</span><strong><%= metrics.failed %></strong></div>
<div><span>Refused</span><strong><%= metrics.refusals %></strong></div>
<div><span>Gate decisions</span><strong><%= metrics.gate_decisions || 0 %></strong></div>
<div><span>Average</span><strong><%= formatDuration(metrics.average_response_ms) %></strong></div>
<div><span>Median</span><strong><%= formatDuration(metrics.median_response_ms) %></strong></div>
<div><span>Avg gate</span><strong><%= formatDuration(metrics.average_stage_ms?.gate_ms || 0) %></strong></div>
<div><span>Avg main generation</span><strong><%= formatDuration(metrics.average_stage_ms?.main_generate_ms || 0) %></strong></div>
</div>
<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>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>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>
<div class="table-pagination">
<a class="button subtle <%= slowRequestsPage.page <= 1 ? 'disabled' : '' %>" href="?slow_page=<%= Math.max(1, slowRequestsPage.page - 1) %>#metrics">Previous slow requests</a>
<span class="table-page-label">Page <%= slowRequestsPage.page %> of <%= slowRequestsPage.pages %> (<%= slowRequestsPage.total %> slow requests)</span>
<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 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 <%= 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>
<section class="ai-band" id="logs">
<div class="ai-section-heading"><div><h2>Runtime logs</h2><p>Open a tail view without loading entire large files.</p></div></div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Filename</th><th>Size</th><th>Modified</th><th>Actions</th></tr></thead>
<tbody>
<% logFiles.forEach((file) => { %>
<tr>
<td><%= file.name %></td>
<td><%= formatBytes(file.size) %></td>
<td><%= formatDate(file.modified_at) %></td>
<td class="ai-table-actions">
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a>
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a>
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form>
</td>
</tr>
<% }) %>
<% if (!logFiles.length) { %><tr><td colspan="4">No runtime logs found.</td></tr><% } %>
</tbody>
</table>
</div>
<div class="table-pagination">
<a class="button subtle <%= logPage.page <= 1 ? 'disabled' : '' %>" href="?logs_page=<%= Math.max(1, logPage.page - 1) %>#logs">Previous</a>
<span class="table-page-label">Page <%= logPage.page %> of <%= logPage.pages %> (<%= logPage.total %> logs)</span>
<a class="button subtle <%= logPage.page >= logPage.pages ? 'disabled' : '' %>" href="?logs_page=<%= Math.min(logPage.pages, logPage.page + 1) %>#logs">Next</a>
</div>
</section>
<section class="ai-band">
<div class="ai-section-heading"><div><h2>Privacy and troubleshooting</h2><p>Local inference remains on this host.</p></div></div>
<div class="callout">
Models are downloaded from pinned Hugging Face revisions. The managed runtime is downloaded from the official llama.cpp release and verified by SHA-256. No cloud inference is used. Prompt and response logging are off by default.
</div>
<p class="hint">If startup fails, confirm that the runtime and selected model show as installed, the plugin directory is writable, and enough RAM and disk are available. Runtime logs are stored under <code>plugins/lumi_ai/data/logs/</code>.</p>
</section>
<%- include("tool-modal") %>
<script src="/plugins/lumi_ai/assets/settings.js?v=<%= assetVersion %>" defer></script>
<script src="/plugins/lumi_ai/assets/tool-manager.js?v=<%= assetVersion %>" defer></script>
<%- include("../../../src/web/views/partials/layout-bottom") %>