mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 08:22:19 +02:00
merge: reconcile PR 40 with current lab
Integrate lab fff55a78 into PR #40 (cc25d5ba). Lab's modular email backend/frontend, modular settings, split stylesheets (static/style.css stays deleted), procfs compatibility, and request-scoped TurnContract authority win; PR #40's routing classifiers, editor/email/task features, and style.css changes are ported into lab's module and stylesheet homes. Integration fixes: - settings/api.js imports ui.js under its canonical versioned URL - browser observations keep legacy CAPTCHA/access-block evidence - artifact turns do not re-trigger broad-web research recovery - env reference documents PR test-tool variables; page regenerated PR #40 defects surfaced by lab gates and fixed here: - web_fetch generic schema drops top-level anyOf (OpenAI contract); the compact preview contract still requires url or urls - get_weather registered as a brokered network read - new lazy editor modules precached for offline use - SearXNG pin mirrored into GPU standalone compose files - image model picker again skips offline endpoints Tests updated where PR #40 changed behaviour on purpose, and PR tests moved onto lab's document_source helpers.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// Agent settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var supInput = el('set-agentSupervisorLadder');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
try {
|
||||
await _postSettings(payload);
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
if (supInput) supInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Lifted verbatim from static/js/settings.js so extracted panels can post
|
||||
// settings without importing the module they were extracted from.
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { invalidateSettings } from '../appConfig.js';
|
||||
|
||||
export async function postSettings(body) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.status === 403
|
||||
? 'Admin access is required to change these settings.'
|
||||
: 'Failed to save');
|
||||
error.status = response.status;
|
||||
if (response.status === 403) uiModule.showError(error.message);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
invalidateSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Image-model settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
import { modelCaps } from '../editor/ai-models.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
export async function initImageSettings() {
|
||||
const modelSel = el('set-imgModelSelect');
|
||||
const qualSel = el('set-imgQualitySelect');
|
||||
const msg = el('set-imgSettingsMsg');
|
||||
const enabledToggle = el('set-imgEnabledToggle');
|
||||
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
|
||||
try {
|
||||
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const endpoints = await endpointsRes.json();
|
||||
const imageModels = new Set();
|
||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||
if (!endpoint.is_enabled || !endpoint.online) return;
|
||||
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
||||
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
|
||||
});
|
||||
});
|
||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mid;
|
||||
opt.textContent = mid;
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for image settings', e); }
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
if (settings.image_model) modelSel.value = settings.image_model;
|
||||
if (settings.image_quality) qualSel.value = settings.image_quality;
|
||||
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
|
||||
} catch (e) { console.warn('Failed to load settings', e); }
|
||||
|
||||
function syncImgDisabled() {
|
||||
var off = enabledToggle && !enabledToggle.checked;
|
||||
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncImgDisabled();
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
modelSel.addEventListener('change', saveSettings);
|
||||
qualSel.addEventListener('change', saveSettings);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Return path from the Google OAuth2 redirect.
|
||||
//
|
||||
// The provider sends the browser back to the app root with a result in the
|
||||
// query string, so something has to notice on load and put the user back where
|
||||
// they were — the Integrations panel — with the outcome visible. That is
|
||||
// navigation, not an Integrations concern, and it runs once per page load
|
||||
// rather than once per panel open.
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {(tab: string) => void} options.openSettings the Settings open() entry point
|
||||
*/
|
||||
export function handleSettingsOauthReturn({ openSettings } = {}) {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
|
||||
// Strip params from URL without a page reload.
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', clean);
|
||||
const success = sp.has('email_oauth_success');
|
||||
const errMsg = sp.get('email_oauth_error') || '';
|
||||
|
||||
// The caller passes its own open() in, so this never has to wait for a
|
||||
// window-level alias to exist.
|
||||
function _showResult() {
|
||||
openSettings('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? 'Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
||||
} else {
|
||||
_showResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Settings window peek (transparency) chrome.
|
||||
//
|
||||
// The Appearance panel lets the user watch the rest of the UI react to a toggle
|
||||
// without closing Settings. That is window chrome, not panel data, so it lives
|
||||
// here rather than with the Appearance panel's checkbox handling: the fade is
|
||||
// applied to the Settings window's own background via color-mix — never element
|
||||
// opacity, so controls and text stay crisp — and it has to be cleared the
|
||||
// moment the user leaves Appearance or closes the window. Mirrors the Theme
|
||||
// customizer's slider.
|
||||
|
||||
import { byId } from './dom.js';
|
||||
|
||||
const PEEK_OPACITY = 55; // % opacity when the Peek toggle is on
|
||||
const TOGGLE_ID = 'settings-opacity-wrap';
|
||||
|
||||
function _toggleEl() {
|
||||
return byId(TOGGLE_ID);
|
||||
}
|
||||
|
||||
export function applySettingsPeek(modalEl, on) {
|
||||
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
|
||||
if (!content) return;
|
||||
const cards = content.querySelectorAll('.admin-card');
|
||||
if (on) {
|
||||
const bgMix = `color-mix(in srgb, var(--bg) ${PEEK_OPACITY}%, transparent)`;
|
||||
const panelMix = `color-mix(in srgb, var(--panel) ${PEEK_OPACITY}%, transparent)`;
|
||||
content.style.setProperty('background', bgMix, 'important');
|
||||
content.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
cards.forEach(c => {
|
||||
c.style.setProperty('background', panelMix, 'important');
|
||||
c.style.setProperty('backdrop-filter', 'none', 'important');
|
||||
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
|
||||
});
|
||||
} else {
|
||||
content.style.removeProperty('background');
|
||||
content.style.removeProperty('backdrop-filter');
|
||||
content.style.removeProperty('-webkit-backdrop-filter');
|
||||
cards.forEach(c => {
|
||||
c.style.removeProperty('background');
|
||||
c.style.removeProperty('backdrop-filter');
|
||||
c.style.removeProperty('-webkit-backdrop-filter');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
|
||||
export function syncSettingsPeek(modalEl, active) {
|
||||
const toggle = _toggleEl();
|
||||
if (toggle) toggle.classList.toggle('hidden', !active);
|
||||
if (active) {
|
||||
applySettingsPeek(modalEl, toggle ? toggle.classList.contains('active') : false);
|
||||
} else {
|
||||
applySettingsPeek(modalEl, false); // clear the fade off the Appearance tab
|
||||
}
|
||||
}
|
||||
|
||||
export function bindSettingsPeekToggle(modalEl) {
|
||||
const toggle = _toggleEl();
|
||||
if (!toggle || toggle.dataset.bound === '1') return;
|
||||
toggle.dataset.bound = '1';
|
||||
toggle.addEventListener('click', () => {
|
||||
const on = !toggle.classList.contains('active');
|
||||
toggle.classList.toggle('active', on);
|
||||
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
applySettingsPeek(modalEl, on);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Settings shell coordination.
|
||||
//
|
||||
// settings.js still owns every panel's data loading. This module owns the shell
|
||||
// around them: what happens when a panel becomes active, where an
|
||||
// admin-managed tab is handed off to admin.js, which elements are admin-only,
|
||||
// and the public open/close entry points.
|
||||
//
|
||||
// It keeps no panel state. Everything it needs from the panels arrives through
|
||||
// the options below, so panels can move out of settings.js one at a time
|
||||
// without this file changing — and so the shell's behavior is testable without
|
||||
// booting a panel.
|
||||
|
||||
import { activateSettingsPanel, getActiveSettingsTab } from './navigation.js';
|
||||
import { showSettingsModal, hideSettingsModal } from './lifecycle.js';
|
||||
import { isAdminManagedSettingsTab } from './registry.js';
|
||||
import { syncSettingsPeek } from './peek.js';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {() => Element|null} options.getModal the Settings modal, once initialized
|
||||
* @param {() => void} options.ensureInitialized first-open panel initialization
|
||||
* @param {() => void} options.syncAppearanceCheckboxes Appearance panel state refresh
|
||||
* @param {() => void} options.refreshAiModelEndpoints AI panel endpoint refresh
|
||||
* @param {() => boolean} options.isAdmin current admin status
|
||||
* @param {() => object|null} options.getAdminModule the lazily loaded admin module
|
||||
*/
|
||||
export function createSettingsShell(options = {}) {
|
||||
const getModal = options.getModal;
|
||||
const ensureInitialized = options.ensureInitialized;
|
||||
const syncAppearanceCheckboxes = options.syncAppearanceCheckboxes;
|
||||
const refreshAiModelEndpoints = options.refreshAiModelEndpoints;
|
||||
const isAdmin = options.isAdmin;
|
||||
const getAdminModule = options.getAdminModule;
|
||||
|
||||
const _modal = () => (typeof getModal === 'function' ? getModal() : null);
|
||||
const _admin = () => (typeof getAdminModule === 'function' ? getAdminModule() : null);
|
||||
|
||||
function onPanelActivated(tab) {
|
||||
// Appearance keeps its existing transparent preview behavior.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncSettingsPeek(_modal(), tab === 'appearance');
|
||||
|
||||
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
||||
if (tab === 'ai' && typeof refreshAiModelEndpoints === 'function') {
|
||||
refreshAiModelEndpoints();
|
||||
}
|
||||
}
|
||||
|
||||
function openAdminTab(tab) {
|
||||
const admin = _admin();
|
||||
if (admin && typeof admin.open === 'function') {
|
||||
admin.open(tab);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncAdminVisibility() {
|
||||
const modalEl = _modal();
|
||||
if (!modalEl) return;
|
||||
const admin = typeof isAdmin === 'function' ? !!isAdmin() : false;
|
||||
modalEl.querySelectorAll('.admin-only').forEach(el => {
|
||||
el.style.display = admin ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function open(tab) {
|
||||
if (typeof ensureInitialized === 'function') ensureInitialized();
|
||||
|
||||
if (typeof syncAppearanceCheckboxes === 'function') syncAppearanceCheckboxes();
|
||||
|
||||
const modalEl = _modal();
|
||||
showSettingsModal(modalEl);
|
||||
syncAdminVisibility();
|
||||
|
||||
if (tab) {
|
||||
activateSettingsPanel(modalEl, tab);
|
||||
}
|
||||
|
||||
// Preserve existing panel-specific side effects when Settings is opened
|
||||
// directly to a tab as well as when the user navigates there.
|
||||
const activeTab = tab || getActiveSettingsTab(modalEl);
|
||||
onPanelActivated(activeTab);
|
||||
|
||||
// Auto-init admin data if showing an admin tab.
|
||||
const admin = _admin();
|
||||
if (isAdminManagedSettingsTab(activeTab) && admin && !admin._initialized) {
|
||||
admin._initData();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
const modalEl = _modal();
|
||||
if (!modalEl) return;
|
||||
|
||||
// Always clear the Appearance state so the rest of the app does not remain
|
||||
// dimmed if Settings is closed while that panel is active.
|
||||
document.body.classList.remove('settings-appearance-open');
|
||||
syncSettingsPeek(modalEl, false);
|
||||
|
||||
hideSettingsModal(modalEl);
|
||||
}
|
||||
|
||||
return { open, close, onPanelActivated, openAdminTab, syncAdminVisibility };
|
||||
}
|
||||
@@ -121,10 +121,88 @@ export function setSettingsSidebarWidth(modalEl, width, options = {}) {
|
||||
return next;
|
||||
}
|
||||
|
||||
/* ── Build provenance ──
|
||||
The bottom-left of the panel answers "which build am I actually looking
|
||||
at": the version this build registers and the commit its process loaded.
|
||||
Both come from /api/version, which resolves the commit once at import via
|
||||
`git rev-parse HEAD` and reports the string "unknown" when it cannot — a
|
||||
read-only Docker tree with no .git, for instance. */
|
||||
const BUILD_COMMIT_SHORT_LEN = 8;
|
||||
const BUILD_UNKNOWN = 'unknown';
|
||||
const BUILD_COMMIT_SHA = /^[0-9a-f]{7,64}$/i;
|
||||
|
||||
function buildField(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
return text && text.toLowerCase() !== BUILD_UNKNOWN ? text : '';
|
||||
}
|
||||
|
||||
/** Turn an /api/version payload into the two sidebar lines, or null when it
|
||||
* carries nothing worth a footer. */
|
||||
export function formatSettingsBuildInfo(payload) {
|
||||
const version = buildField(payload?.version);
|
||||
const build = buildField(payload?.build);
|
||||
const commit = buildField(payload?.source_commit);
|
||||
|
||||
const parts = [];
|
||||
if (version) parts.push(`v${version}`);
|
||||
// The preview harness versions itself separately from the public semver, so
|
||||
// show both — but never the same number twice.
|
||||
if (build && build !== version) parts.push(`build ${build}`);
|
||||
|
||||
// An ODYSSEUS_SOURCE_COMMIT override need not be a sha; leave those intact
|
||||
// and let the CSS ellipsis deal with the width.
|
||||
const commitLabel = BUILD_COMMIT_SHA.test(commit)
|
||||
? commit.slice(0, BUILD_COMMIT_SHORT_LEN).toLowerCase()
|
||||
: commit;
|
||||
|
||||
if (!parts.length && !commitLabel) return null;
|
||||
|
||||
return {
|
||||
versionLabel: parts.join(' \u00b7 '),
|
||||
commitLabel,
|
||||
commitTitle: commit,
|
||||
};
|
||||
}
|
||||
|
||||
function paintSettingsBuildInfo(modalEl, info) {
|
||||
const host = modalEl?.querySelector('#settings-sidebar-build');
|
||||
if (!host) return;
|
||||
|
||||
const versionEl = host.querySelector('#settings-sidebar-build-version');
|
||||
const commitEl = host.querySelector('#settings-sidebar-build-commit');
|
||||
|
||||
if (!info) {
|
||||
host.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (versionEl) versionEl.textContent = info.versionLabel;
|
||||
if (commitEl) {
|
||||
commitEl.textContent = info.commitLabel;
|
||||
// The short hash is what fits; keep the full one reachable on hover.
|
||||
if (info.commitTitle) commitEl.title = info.commitTitle;
|
||||
}
|
||||
|
||||
host.hidden = false;
|
||||
}
|
||||
|
||||
async function loadSettingsBuildInfo(modalEl) {
|
||||
try {
|
||||
const res = await fetch('/api/version', { credentials: 'same-origin' });
|
||||
if (!res.ok) return;
|
||||
paintSettingsBuildInfo(modalEl, formatSettingsBuildInfo(await res.json()));
|
||||
} catch {
|
||||
// Provenance is informational: a failed probe leaves the footer hidden
|
||||
// rather than putting an error in the middle of the settings nav.
|
||||
}
|
||||
}
|
||||
|
||||
export function bindSettingsSidebar(modalEl) {
|
||||
if (!modalEl || _bound.has(modalEl)) return;
|
||||
_bound.add(modalEl);
|
||||
|
||||
loadSettingsBuildInfo(modalEl);
|
||||
|
||||
const sidebar = modalEl.querySelector('.settings-sidebar');
|
||||
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
|
||||
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// Text-to-speech and speech-to-text settings panels.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initTtsSettings() {
|
||||
var provSel = el('set-ttsProviderSelect');
|
||||
var modelSelect = el('set-ttsModelSelect');
|
||||
var modelInput = el('set-ttsModelInput');
|
||||
var voiceSelect = el('set-ttsVoiceSelect');
|
||||
var voiceInput = el('set-ttsVoiceInput');
|
||||
var modelRow = el('set-ttsModelRow');
|
||||
var voiceRow = el('set-ttsVoiceRow');
|
||||
var speedSelect = el('set-ttsSpeedSelect');
|
||||
var speedRow = el('set-ttsSpeedRow');
|
||||
var ttsMsg = el('set-ttsSettingsMsg');
|
||||
var ttsEnabledToggle = el('set-ttsEnabledToggle');
|
||||
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
|
||||
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
|
||||
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
|
||||
} else {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
var ttsKeywords = ['tts', 'audio'];
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
|
||||
if (!hasTTS) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
|
||||
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.tts_provider) provSel.value = settings.tts_provider;
|
||||
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
|
||||
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
|
||||
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load TTS settings', e); }
|
||||
|
||||
function syncTtsDisabled() {
|
||||
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
|
||||
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
syncTtsDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveTTS() {
|
||||
try {
|
||||
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
|
||||
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
||||
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
||||
} catch (e) { ttsMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
async function saveAndClearCache() {
|
||||
await saveTTS();
|
||||
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() {
|
||||
var prov = provSel.value;
|
||||
if (prov === 'local') voiceInput.value = 'af_heart';
|
||||
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
|
||||
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
|
||||
updateVisibility();
|
||||
saveTTS();
|
||||
});
|
||||
modelSelect.addEventListener('change', saveAndClearCache);
|
||||
modelInput.addEventListener('change', saveTTS);
|
||||
voiceSelect.addEventListener('change', saveAndClearCache);
|
||||
voiceInput.addEventListener('change', saveTTS);
|
||||
speedSelect.addEventListener('change', saveAndClearCache);
|
||||
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
|
||||
|
||||
// Preview / test button
|
||||
var previewBtn = el('set-ttsPreviewBtn');
|
||||
if (previewBtn) {
|
||||
var previewAudio = null;
|
||||
var previewPlaying = false;
|
||||
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
|
||||
|
||||
previewBtn.addEventListener('click', async function() {
|
||||
if (previewPlaying) {
|
||||
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
|
||||
window.speechSynthesis.cancel();
|
||||
resetPreview(); return;
|
||||
}
|
||||
var prov = provSel.value;
|
||||
if (prov === 'disabled') {
|
||||
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
|
||||
}
|
||||
var testText = 'Hello, this is a test of text to speech.';
|
||||
previewPlaying = true; previewBtn.textContent = 'Loading...';
|
||||
try {
|
||||
if (prov === 'browser') {
|
||||
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
|
||||
var utt = new SpeechSynthesisUtterance(testText);
|
||||
var voiceVal = getVoice();
|
||||
if (voiceVal) {
|
||||
var voices = window.speechSynthesis.getVoices();
|
||||
var target = voiceVal.toLowerCase();
|
||||
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
|
||||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
|
||||
if (match) utt.voice = match;
|
||||
}
|
||||
utt.rate = parseFloat(speedSelect.value) || 1;
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
utt.onend = resolve;
|
||||
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
});
|
||||
} else {
|
||||
var res = await fetch('/api/tts/synthesize', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText, format: 'audio' })
|
||||
});
|
||||
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
|
||||
var blob = await res.blob();
|
||||
var url = URL.createObjectURL(blob);
|
||||
previewAudio = new Audio(url);
|
||||
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
|
||||
await new Promise(function(resolve, reject) {
|
||||
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
|
||||
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
|
||||
previewAudio.play().catch(reject);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
|
||||
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
|
||||
} finally {
|
||||
resetPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function initSttSettings() {
|
||||
var provSel = el('set-sttProviderSelect');
|
||||
var modelSelect = el('set-sttModelSelect');
|
||||
var modelInput = el('set-sttModelInput');
|
||||
var modelRow = el('set-sttModelRow');
|
||||
var langRow = el('set-sttLangRow');
|
||||
var langInput = el('set-sttLangInput');
|
||||
var sttMsg = el('set-sttSettingsMsg');
|
||||
var sttEnabledToggle = el('set-sttEnabledToggle');
|
||||
var sttConfigWrap = el('set-sttConfigWrap');
|
||||
// STT was removed from AI Defaults — bail if the UI isn't present.
|
||||
if (!provSel) return;
|
||||
|
||||
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
|
||||
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
|
||||
|
||||
function updateVisibility() {
|
||||
var prov = provSel.value;
|
||||
var showModel = prov === 'local' || prov.startsWith('endpoint:');
|
||||
var showLang = prov !== 'disabled';
|
||||
modelRow.style.display = showModel ? 'flex' : 'none';
|
||||
langRow.style.display = showLang ? 'flex' : 'none';
|
||||
if (isEndpoint()) {
|
||||
modelSelect.style.display = 'none'; modelInput.style.display = '';
|
||||
} else {
|
||||
modelSelect.style.display = ''; modelInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function syncSttDisabled() {
|
||||
var off = sttEnabledToggle && !sttEnabledToggle.checked;
|
||||
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
|
||||
if (card) card.style.opacity = off ? '0.45' : '';
|
||||
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
|
||||
}
|
||||
|
||||
// Effective provider: if toggle is off, treat as disabled regardless of provider select
|
||||
function effectiveProvider() {
|
||||
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
|
||||
return provSel.value;
|
||||
}
|
||||
|
||||
// Add API endpoints that might support STT
|
||||
try {
|
||||
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
var endpoints = await epRes.json();
|
||||
endpoints.forEach(function(ep) {
|
||||
if (!ep.is_enabled) return;
|
||||
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await settingsRes.json();
|
||||
if (settings.stt_provider) provSel.value = settings.stt_provider;
|
||||
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
|
||||
if (settings.stt_language) langInput.value = settings.stt_language;
|
||||
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
|
||||
} catch (e) { console.warn('Failed to load STT settings', e); }
|
||||
|
||||
syncSttDisabled();
|
||||
updateVisibility();
|
||||
|
||||
async function saveSTT() {
|
||||
try {
|
||||
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
||||
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
|
||||
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
|
||||
// Notify voiceRecorder of effective provider and update send button icon
|
||||
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
} catch (e) { sttMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
|
||||
modelSelect.addEventListener('change', saveSTT);
|
||||
modelInput.addEventListener('change', saveSTT);
|
||||
langInput.addEventListener('change', saveSTT);
|
||||
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Document writing-style settings panel.
|
||||
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
|
||||
// functions moved, their bodies did not. settings.js imports them and calls
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
|
||||
export async function initDocumentWritingStyle() {
|
||||
const styleEl = el('set-document-style');
|
||||
const saveBtn = el('set-document-style-save');
|
||||
const extractBtn = el('set-document-style-extract');
|
||||
const fileEl = el('set-document-style-file');
|
||||
const msg = el('set-document-style-msg');
|
||||
if (!styleEl || !saveBtn) return;
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
styleEl.value = String(data.document_writing_style || '');
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to load';
|
||||
}
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const res = await _postSettings({ document_writing_style: styleEl.value });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (msg) msg.textContent = '✓ Saved';
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save';
|
||||
}
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
});
|
||||
extractBtn?.addEventListener('click', () => fileEl?.click());
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
const file = fileEl.files?.[0];
|
||||
if (!file) return;
|
||||
extractBtn.disabled = true;
|
||||
let whirlpool = null;
|
||||
if (msg) {
|
||||
msg.replaceChildren();
|
||||
try {
|
||||
const spinner = window.spinnerModule || (await import('../spinner.js')).default;
|
||||
whirlpool = spinner.createWhirlpool(14);
|
||||
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Analyzing...';
|
||||
msg.append(whirlpool.element, label);
|
||||
} catch (_) {
|
||||
msg.textContent = 'Analyzing...';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const res = await fetch('/api/auth/settings/document-style/extract', {
|
||||
method: 'POST', credentials: 'same-origin', body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
|
||||
styleEl.value = String(data.style);
|
||||
if (msg) msg.textContent = '✓ Extracted — review and save';
|
||||
} catch (error) {
|
||||
if (msg) msg.textContent = error.message || 'Extraction failed';
|
||||
} finally {
|
||||
whirlpool?.destroy?.();
|
||||
extractBtn.disabled = false;
|
||||
fileEl.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user