feat(settings): redesign settings window with VS Code-style sidebar layout (#461)

Replace the 4-tab horizontal layout with a sidebar + content panel design:
- Overview section with SVG progress ring showing feature readiness
- 5 logical category sections (AI, Economy, Markets, Security, Tracking)
- Debug & Logs section with diagnostics
- Expandable feature cards with toggle switches and status pills
- Debounced search with <mark> highlighting across all features
- Buffered secret management extracted to SettingsManager service
- Shared constants (HUMAN_LABELS, SIGNUP_URLS, CATEGORIES) extracted
- Ollama model fetching extracted to standalone service
- ARIA roles for sidebar navigation accessibility
- Content area fade transitions between sections
- Responsive layout at 860px breakpoint

Removed deprecated WorldMonitorTab (zero imports, functionality in Overview).
RuntimeConfigPanel alert mode in main window unchanged.
This commit is contained in:
Elie Habib
2026-02-27 16:26:49 +04:00
committed by GitHub
parent 1bcd705e70
commit 74de5f3ba2
8 changed files with 1638 additions and 666 deletions

View File

@@ -5,55 +5,28 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>World Monitor Settings</title>
<script>(function(){try{var t=localStorage.getItem('worldmonitor-theme');if(t==='light')document.documentElement.dataset.theme='light';}catch(e){}document.documentElement.classList.add('no-transition');})()</script>
<style>.settings-tab-panel{display:none}.settings-tab-panel.active{display:block}.settings-shell{height:100vh;display:flex;flex-direction:column}.settings-tab-panels{flex:1;min-height:0;overflow-y:auto;padding:20px 24px}.settings-footer{display:flex;justify-content:flex-end;gap:10px;padding:12px 24px;border-top:1px solid rgba(255,255,255,0.08)}</style>
<style>.settings-shell{height:100vh;display:flex;flex-direction:column}.settings-main{display:flex;flex:1;min-height:0}.settings-sidebar{width:220px;flex-shrink:0;border-right:1px solid rgba(255,255,255,0.08);display:flex;flex-direction:column}.settings-content{flex:1;overflow-y:auto;padding:20px 24px}</style>
</head>
<body style="background:#1a1c1e;color:#e8eaed;margin:0">
<div class="settings-shell">
<div class="settings-tabs" role="tablist">
<button class="settings-tab active" role="tab" aria-selected="true" aria-controls="tabPanelWorldMonitor" data-tab="worldmonitor">World Monitor</button>
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelLLMs" data-tab="llms">LLMs</button>
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelKeys" data-tab="keys">API Keys</button>
<button class="settings-tab" role="tab" aria-selected="false" aria-controls="tabPanelDebug" data-tab="debug">Debug &amp; Logs</button>
<div class="settings-header">
<svg class="settings-header-icon" viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
<span class="settings-header-title">World Monitor Settings</span>
<span class="settings-header-badge" id="versionBadge"></span>
</div>
<p id="settingsActionStatus" class="settings-action-status" aria-live="polite"></p>
<div class="settings-tab-panels">
<div id="tabPanelWorldMonitor" class="settings-tab-panel active" role="tabpanel">
<main id="worldmonitorApp" class="settings-content"></main>
</div>
<div id="tabPanelLLMs" class="settings-tab-panel" role="tabpanel">
<main id="llmApp" class="settings-content"><div style="display:flex;align-items:center;justify-content:center;padding:60px 0;color:#9aa0a6;font-size:14px;gap:10px"><svg width="20" height="20" viewBox="0 0 24 24" style="animation:spin 1s linear infinite"><style>@keyframes spin{to{transform:rotate(360deg)}}</style><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31 31"/></svg>Loading...</div></main>
</div>
<div id="tabPanelKeys" class="settings-tab-panel" role="tabpanel">
<main id="apiKeysApp" class="settings-content"><div style="display:flex;align-items:center;justify-content:center;padding:60px 0;color:#9aa0a6;font-size:14px;gap:10px"><svg width="20" height="20" viewBox="0 0 24 24" style="animation:spin 1s linear infinite"><style>@keyframes spin{to{transform:rotate(360deg)}}</style><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31 31"/></svg>Loading...</div></main>
</div>
<div id="tabPanelDebug" class="settings-tab-panel" role="tabpanel">
<div class="debug-actions">
<button id="openLogsBtn" type="button">Open Logs Folder</button>
<button id="openSidecarLogBtn" type="button">Open API Log</button>
<div class="settings-main">
<div class="settings-sidebar">
<div class="settings-sidebar-search">
<input id="settingsSearch" type="text" placeholder="Search settings..." autocomplete="off" />
</div>
<section class="settings-diagnostics" id="diagnosticsSection">
<header class="diag-header">
<h2>Diagnostics</h2>
<div class="diag-toggles">
<label><input type="checkbox" id="verboseApiLog"> Verbose Sidecar Log</label>
<label><input type="checkbox" id="fetchDebugLog"> Frontend Fetch Debug</label>
</div>
</header>
<div class="diag-traffic-bar">
<h3>API Traffic <span id="trafficCount"></span></h3>
<div class="diag-traffic-controls">
<label><input type="checkbox" id="autoRefreshLog" checked> Auto</label>
<button id="refreshLogBtn" type="button">Refresh</button>
<button id="clearLogBtn" type="button">Clear</button>
</div>
</div>
<div id="trafficLog" class="diag-traffic-log"></div>
</section>
<nav class="settings-sidebar-nav" id="sidebarNav" role="tablist" aria-label="Settings sections"></nav>
</div>
<div class="settings-content" id="contentArea" role="tabpanel"></div>
</div>
<footer class="settings-footer">
<p id="settingsActionStatus" class="settings-action-status" aria-live="polite"></p>
<button id="cancelBtn" type="button" class="settings-btn settings-btn-secondary">Cancel</button>
<button id="okBtn" type="button" class="settings-btn settings-btn-primary">OK</button>
<button id="okBtn" type="button" class="settings-btn settings-btn-primary">Save &amp; Close</button>
</footer>
</div>

View File

@@ -20,37 +20,7 @@ import { escapeHtml } from '@/utils/sanitize';
import { isDesktopRuntime } from '@/services/runtime';
import { t } from '@/services/i18n';
import { trackFeatureToggle } from '@/services/analytics';
const SIGNUP_URLS: Partial<Record<RuntimeSecretKey, string>> = {
GROQ_API_KEY: 'https://console.groq.com/keys',
OPENROUTER_API_KEY: 'https://openrouter.ai/settings/keys',
FRED_API_KEY: 'https://fred.stlouisfed.org/docs/api/api_key.html',
EIA_API_KEY: 'https://www.eia.gov/opendata/register.php',
CLOUDFLARE_API_TOKEN: 'https://dash.cloudflare.com/profile/api-tokens',
ACLED_ACCESS_TOKEN: 'https://developer.acleddata.com/',
URLHAUS_AUTH_KEY: 'https://auth.abuse.ch/',
OTX_API_KEY: 'https://otx.alienvault.com/',
ABUSEIPDB_API_KEY: 'https://www.abuseipdb.com/login',
WINGBITS_API_KEY: 'https://wingbits.com/register',
AISSTREAM_API_KEY: 'https://aisstream.io/authenticate',
OPENSKY_CLIENT_ID: 'https://opensky-network.org/login?view=registration',
OPENSKY_CLIENT_SECRET: 'https://opensky-network.org/login?view=registration',
FINNHUB_API_KEY: 'https://finnhub.io/register',
NASA_FIRMS_API_KEY: 'https://firms.modaps.eosdis.nasa.gov/api/area/',
UC_DP_KEY: 'https://ucdp.uu.se/downloads/',
OLLAMA_API_URL: 'https://ollama.com/download',
OLLAMA_MODEL: 'https://ollama.com/library',
WTO_API_KEY: 'https://apiportal.wto.org/',
};
const PLAINTEXT_KEYS = new Set<RuntimeSecretKey>([
'OLLAMA_API_URL',
'OLLAMA_MODEL',
'WS_RELAY_URL',
'VITE_OPENSKY_RELAY_URL',
]);
const MASKED_SENTINEL = '__WM_MASKED__';
import { SIGNUP_URLS, PLAINTEXT_KEYS, MASKED_SENTINEL } from '@/services/settings-constants';
interface RuntimeConfigPanelOptions {
mode?: 'full' | 'alert';

View File

@@ -1,154 +0,0 @@
import { getSecretState, setSecretValue, type RuntimeSecretKey } from '@/services/runtime-config';
import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime';
import { t } from '@/services/i18n';
const WM_KEY: RuntimeSecretKey = 'WORLDMONITOR_API_KEY';
const REG_STORAGE_KEY = 'wm-waitlist-registered';
export class WorldMonitorTab {
private el: HTMLElement;
private keyInput!: HTMLInputElement;
private emailInput!: HTMLInputElement;
private regStatus!: HTMLElement;
private keyBadge!: HTMLElement;
private pendingKeyValue: string | null = null;
constructor() {
this.el = document.createElement('div');
this.el.className = 'wm-tab';
this.render();
}
private render(): void {
const state = getSecretState(WM_KEY);
const statusText = state.present
? t('modals.settingsWindow.worldMonitor.apiKey.statusValid')
: t('modals.settingsWindow.worldMonitor.apiKey.statusMissing');
const statusClass = state.present ? 'ok' : 'warn';
const alreadyRegistered = localStorage.getItem(REG_STORAGE_KEY) === '1';
this.el.innerHTML = `
<div class="wm-hero">
<h2 class="wm-hero-title">${t('modals.settingsWindow.worldMonitor.heroTitle')}</h2>
<p class="wm-hero-desc">${t('modals.settingsWindow.worldMonitor.heroDescription')}</p>
</div>
<section class="wm-section">
<h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.apiKey.title')}</h2>
<p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.apiKey.description')}</p>
<div class="wm-key-row">
<div class="wm-input-wrap">
<input type="password" class="wm-input" data-wm-key-input
placeholder="${t('modals.settingsWindow.worldMonitor.apiKey.placeholder')}" autocomplete="off" spellcheck="false" />
<button type="button" class="wm-toggle-vis" data-wm-toggle title="Show/hide">&#x1f441;</button>
</div>
<span class="wm-badge ${statusClass}" data-wm-badge>${statusText}</span>
</div>
</section>
<div class="wm-divider"><span>${t('modals.settingsWindow.worldMonitor.dividerOr')}</span></div>
<section class="wm-section">
<h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.register.title')}</h2>
<p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.register.description')}</p>
${alreadyRegistered ? `
<p class="wm-reg-status ok">${t('modals.settingsWindow.worldMonitor.register.alreadyRegistered')}</p>
` : `
<div class="wm-register-row">
<input type="email" class="wm-input wm-email" data-wm-email
placeholder="${t('modals.settingsWindow.worldMonitor.register.emailPlaceholder')}" />
<button type="button" class="wm-submit-btn" data-wm-register>${t('modals.settingsWindow.worldMonitor.register.submitBtn')}</button>
</div>
<p class="wm-reg-status" data-wm-reg-status></p>
`}
</section>
<div class="wm-byok">
<h3 class="wm-byok-title">${t('modals.settingsWindow.worldMonitor.byokTitle')}</h3>
<p class="wm-byok-desc">${t('modals.settingsWindow.worldMonitor.byokDescription')}</p>
</div>
`;
this.keyInput = this.el.querySelector('[data-wm-key-input]')!;
this.keyBadge = this.el.querySelector('[data-wm-badge]')!;
if (!alreadyRegistered) {
this.emailInput = this.el.querySelector('[data-wm-email]')!;
this.regStatus = this.el.querySelector('[data-wm-reg-status]')!;
this.el.querySelector('[data-wm-register]')!.addEventListener('click', () => {
void this.submitRegistration();
});
}
this.keyInput.addEventListener('input', () => {
this.pendingKeyValue = this.keyInput.value;
});
this.el.querySelector('[data-wm-toggle]')!.addEventListener('click', () => {
this.keyInput.type = this.keyInput.type === 'password' ? 'text' : 'password';
});
}
private async submitRegistration(): Promise<void> {
const email = this.emailInput.value.trim();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.invalidEmail');
this.regStatus.className = 'wm-reg-status error';
return;
}
const btn = this.el.querySelector('[data-wm-register]') as HTMLButtonElement;
btn.disabled = true;
btn.textContent = t('modals.settingsWindow.worldMonitor.register.submitting');
try {
const base = isDesktopRuntime() ? getRemoteApiBaseUrl() : '';
const res = await fetch(`${base}/api/register-interest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, source: 'desktop-settings' }),
});
const data = await res.json() as { status?: string; error?: string };
if (data.status === 'already_registered' || data.status === 'registered') {
localStorage.setItem(REG_STORAGE_KEY, '1');
this.regStatus.textContent = data.status === 'already_registered'
? t('modals.settingsWindow.worldMonitor.register.alreadyRegistered')
: t('modals.settingsWindow.worldMonitor.register.success');
this.regStatus.className = 'wm-reg-status ok';
} else {
this.regStatus.textContent = data.error || t('modals.settingsWindow.worldMonitor.register.error');
this.regStatus.className = 'wm-reg-status error';
}
} catch {
this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.error');
this.regStatus.className = 'wm-reg-status error';
} finally {
btn.disabled = false;
btn.textContent = t('modals.settingsWindow.worldMonitor.register.submitBtn');
}
}
hasPendingChanges(): boolean {
return this.pendingKeyValue !== null && this.pendingKeyValue.length > 0;
}
async save(): Promise<void> {
if (this.pendingKeyValue === null) return;
await setSecretValue(WM_KEY, this.pendingKeyValue);
this.pendingKeyValue = null;
const state = getSecretState(WM_KEY);
this.keyBadge.textContent = state.present
? t('modals.settingsWindow.worldMonitor.apiKey.statusValid')
: t('modals.settingsWindow.worldMonitor.apiKey.statusMissing');
this.keyBadge.className = `wm-badge ${state.present ? 'ok' : 'warn'}`;
}
refresh(): void {
this.render();
}
getElement(): HTMLElement {
return this.el;
}
destroy(): void {
this.el.innerHTML = '';
}
}

View File

@@ -0,0 +1,33 @@
function makeTimeout(ms: number): AbortSignal {
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms);
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), ms);
return ctrl.signal;
}
export async function fetchOllamaModels(ollamaUrl: string): Promise<string[]> {
if (!ollamaUrl) return [];
try {
const res = await fetch(new URL('/api/tags', ollamaUrl).toString(), {
signal: makeTimeout(5000),
});
if (res.ok) {
const data = await res.json() as { models?: Array<{ name: string }> };
const models = (data.models?.map(m => m.name) || []).filter(n => !n.includes('embed'));
if (models.length > 0) return models;
}
} catch { /* Ollama endpoint not available */ }
try {
const res = await fetch(new URL('/v1/models', ollamaUrl).toString(), {
signal: makeTimeout(5000),
});
if (res.ok) {
const data = await res.json() as { data?: Array<{ id: string }> };
return (data.data?.map(m => m.id) || []).filter(n => !n.includes('embed'));
}
} catch { /* OpenAI endpoint also unavailable */ }
return [];
}

View File

@@ -0,0 +1,91 @@
import type { RuntimeSecretKey, RuntimeFeatureId } from './runtime-config';
export const SIGNUP_URLS: Partial<Record<RuntimeSecretKey, string>> = {
GROQ_API_KEY: 'https://console.groq.com/keys',
OPENROUTER_API_KEY: 'https://openrouter.ai/settings/keys',
FRED_API_KEY: 'https://fred.stlouisfed.org/docs/api/api_key.html',
EIA_API_KEY: 'https://www.eia.gov/opendata/register.php',
CLOUDFLARE_API_TOKEN: 'https://dash.cloudflare.com/profile/api-tokens',
ACLED_ACCESS_TOKEN: 'https://developer.acleddata.com/',
URLHAUS_AUTH_KEY: 'https://auth.abuse.ch/',
OTX_API_KEY: 'https://otx.alienvault.com/',
ABUSEIPDB_API_KEY: 'https://www.abuseipdb.com/login',
WINGBITS_API_KEY: 'https://wingbits.com/register',
AISSTREAM_API_KEY: 'https://aisstream.io/authenticate',
OPENSKY_CLIENT_ID: 'https://opensky-network.org/login?view=registration',
OPENSKY_CLIENT_SECRET: 'https://opensky-network.org/login?view=registration',
FINNHUB_API_KEY: 'https://finnhub.io/register',
NASA_FIRMS_API_KEY: 'https://firms.modaps.eosdis.nasa.gov/api/area/',
UC_DP_KEY: 'https://ucdp.uu.se/downloads/',
OLLAMA_API_URL: 'https://ollama.com/download',
OLLAMA_MODEL: 'https://ollama.com/library',
WTO_API_KEY: 'https://apiportal.wto.org/',
};
export const PLAINTEXT_KEYS = new Set<RuntimeSecretKey>([
'OLLAMA_API_URL',
'OLLAMA_MODEL',
'WS_RELAY_URL',
'VITE_OPENSKY_RELAY_URL',
]);
export const MASKED_SENTINEL = '__WM_MASKED__';
export const HUMAN_LABELS: Record<RuntimeSecretKey, string> = {
GROQ_API_KEY: 'Groq API Key',
OPENROUTER_API_KEY: 'OpenRouter API Key',
FRED_API_KEY: 'FRED API Key',
EIA_API_KEY: 'EIA API Key',
CLOUDFLARE_API_TOKEN: 'Cloudflare API Token',
ACLED_ACCESS_TOKEN: 'ACLED Access Token',
URLHAUS_AUTH_KEY: 'URLhaus Auth Key',
OTX_API_KEY: 'AlienVault OTX Key',
ABUSEIPDB_API_KEY: 'AbuseIPDB API Key',
WINGBITS_API_KEY: 'Wingbits API Key',
WS_RELAY_URL: 'WebSocket Relay URL',
VITE_OPENSKY_RELAY_URL: 'OpenSky Relay URL',
OPENSKY_CLIENT_ID: 'OpenSky Client ID',
OPENSKY_CLIENT_SECRET: 'OpenSky Client Secret',
AISSTREAM_API_KEY: 'AISStream API Key',
FINNHUB_API_KEY: 'Finnhub API Key',
NASA_FIRMS_API_KEY: 'NASA FIRMS API Key',
UC_DP_KEY: 'UCDP API Key',
OLLAMA_API_URL: 'Ollama Server URL',
OLLAMA_MODEL: 'Ollama Model',
WORLDMONITOR_API_KEY: 'World Monitor License Key',
WTO_API_KEY: 'WTO API Key',
};
export interface SettingsCategory {
id: string;
label: string;
features: RuntimeFeatureId[];
}
export const SETTINGS_CATEGORIES: SettingsCategory[] = [
{
id: 'ai',
label: 'AI & Summarization',
features: ['aiOllama', 'aiGroq', 'aiOpenRouter'],
},
{
id: 'economy',
label: 'Economic & Energy',
features: ['economicFred', 'energyEia', 'supplyChain'],
},
{
id: 'markets',
label: 'Markets & Trade',
features: ['finnhubMarkets', 'wtoTrade'],
},
{
id: 'security',
label: 'Security & Threats',
features: ['internetOutages', 'acledConflicts', 'abuseChThreatIntel', 'alienvaultOtxThreatIntel', 'abuseIpdbThreatIntel'],
},
{
id: 'tracking',
label: 'Tracking & Sensing',
features: ['aisRelay', 'openskyRelay', 'wingbitsEnrichment', 'nasaFirms'],
},
];

View File

@@ -0,0 +1,166 @@
import {
RUNTIME_FEATURES,
getEffectiveSecrets,
getRuntimeConfigSnapshot,
getSecretState,
isFeatureEnabled,
setSecretValue,
validateSecret,
verifySecretWithApi,
type RuntimeSecretKey,
} from './runtime-config';
import { PLAINTEXT_KEYS, MASKED_SENTINEL } from './settings-constants';
export class SettingsManager {
private pendingSecrets = new Map<RuntimeSecretKey, string>();
private validatedKeys = new Map<RuntimeSecretKey, boolean>();
private validationMessages = new Map<RuntimeSecretKey, string>();
captureUnsavedInputs(container: HTMLElement): void {
container.querySelectorAll<HTMLInputElement>('input[data-secret]').forEach((input) => {
const key = input.dataset.secret as RuntimeSecretKey | undefined;
if (!key) return;
const raw = input.value.trim();
if (!raw || raw === MASKED_SENTINEL) return;
if (PLAINTEXT_KEYS.has(key) && !this.pendingSecrets.has(key)) {
const stored = getRuntimeConfigSnapshot().secrets[key]?.value || '';
if (raw === stored) return;
}
this.pendingSecrets.set(key, raw);
const result = validateSecret(key, raw);
if (!result.valid) {
this.validatedKeys.set(key, false);
this.validationMessages.set(key, result.hint || 'Invalid format');
}
});
const modelSelect = container.querySelector<HTMLSelectElement>('select[data-model-select]');
const modelManual = container.querySelector<HTMLInputElement>('input[data-model-manual]');
const modelValue = (modelManual && !modelManual.classList.contains('hidden-input') ? modelManual.value.trim() : modelSelect?.value) || '';
if (modelValue && !this.pendingSecrets.has('OLLAMA_MODEL')) {
this.pendingSecrets.set('OLLAMA_MODEL', modelValue);
this.validatedKeys.set('OLLAMA_MODEL', true);
}
}
hasPendingChanges(): boolean {
return this.pendingSecrets.size > 0;
}
getMissingRequiredSecrets(): string[] {
const missing: string[] = [];
for (const feature of RUNTIME_FEATURES) {
if (!isFeatureEnabled(feature.id)) continue;
const secrets = getEffectiveSecrets(feature);
const hasPending = secrets.some(k => this.pendingSecrets.has(k));
if (!hasPending) continue;
for (const key of secrets) {
if (!getSecretState(key).valid && !this.pendingSecrets.has(key)) {
missing.push(key);
}
}
}
return missing;
}
getValidationErrors(): string[] {
const errors: string[] = [];
for (const [key, value] of this.pendingSecrets) {
const result = validateSecret(key, value);
if (!result.valid) errors.push(`${key}: ${result.hint || 'Invalid format'}`);
}
return errors;
}
async verifyPendingSecrets(): Promise<string[]> {
const errors: string[] = [];
const context = Object.fromEntries(this.pendingSecrets.entries()) as Partial<Record<RuntimeSecretKey, string>>;
const toVerifyRemotely: Array<[RuntimeSecretKey, string]> = [];
for (const [key, value] of this.pendingSecrets) {
const localResult = validateSecret(key, value);
if (!localResult.valid) {
this.validatedKeys.set(key, false);
this.validationMessages.set(key, localResult.hint || 'Invalid format');
errors.push(`${key}: ${localResult.hint || 'Invalid format'}`);
} else {
toVerifyRemotely.push([key, value]);
}
}
if (toVerifyRemotely.length > 0) {
const results = await Promise.race([
Promise.all(toVerifyRemotely.map(async ([key, value]) => {
const result = await verifySecretWithApi(key, value, context);
return { key, result };
})),
new Promise<Array<{ key: RuntimeSecretKey; result: { valid: boolean; message?: string } }>>(resolve =>
setTimeout(() => resolve(toVerifyRemotely.map(([key]) => ({
key, result: { valid: true, message: 'Saved (verification timed out)' },
}))), 15000)
),
]);
for (const { key, result: verifyResult } of results) {
this.validatedKeys.set(key, verifyResult.valid);
if (!verifyResult.valid) {
this.validationMessages.set(key, verifyResult.message || 'Verification failed');
errors.push(`${key}: ${verifyResult.message || 'Verification failed'}`);
} else {
this.validationMessages.delete(key);
}
}
}
return errors;
}
async commitVerifiedSecrets(): Promise<void> {
for (const [key, value] of this.pendingSecrets) {
if (this.validatedKeys.get(key) !== false) {
await setSecretValue(key, value);
this.pendingSecrets.delete(key);
this.validatedKeys.delete(key);
this.validationMessages.delete(key);
}
}
}
setPending(key: RuntimeSecretKey, value: string): void {
this.pendingSecrets.set(key, value);
}
getPending(key: RuntimeSecretKey): string | undefined {
return this.pendingSecrets.get(key);
}
hasPending(key: RuntimeSecretKey): boolean {
return this.pendingSecrets.has(key);
}
deletePending(key: RuntimeSecretKey): void {
this.pendingSecrets.delete(key);
this.validatedKeys.delete(key);
this.validationMessages.delete(key);
}
setValidation(key: RuntimeSecretKey, valid: boolean, message?: string): void {
this.validatedKeys.set(key, valid);
if (message) {
this.validationMessages.set(key, message);
} else {
this.validationMessages.delete(key);
}
}
getValidationState(key: RuntimeSecretKey): { validated?: boolean; message?: string } {
return {
validated: this.validatedKeys.get(key),
message: this.validationMessages.get(key),
};
}
destroy(): void {
this.pendingSecrets.clear();
this.validatedKeys.clear();
this.validationMessages.clear();
}
}

View File

@@ -1,20 +1,44 @@
import './styles/main.css';
import './styles/settings-window.css';
import { RuntimeConfigPanel } from '@/components/RuntimeConfigPanel';
import { WorldMonitorTab } from '@/components/WorldMonitorTab';
import { RUNTIME_FEATURES, loadDesktopSecrets } from '@/services/runtime-config';
import { getApiBaseUrl, resolveLocalApiPort } from '@/services/runtime';
import { tryInvokeTauri } from '@/services/tauri-bridge';
import { SettingsManager } from '@/services/settings-manager';
import {
SETTINGS_CATEGORIES,
HUMAN_LABELS,
SIGNUP_URLS,
PLAINTEXT_KEYS,
MASKED_SENTINEL,
type SettingsCategory,
} from '@/services/settings-constants';
import { fetchOllamaModels } from '@/services/ollama-models';
import {
RUNTIME_FEATURES,
getEffectiveSecrets,
getRuntimeConfigSnapshot,
getSecretState,
isFeatureAvailable,
isFeatureEnabled,
setFeatureToggle,
setSecretValue,
validateSecret,
loadDesktopSecrets,
type RuntimeFeatureDefinition,
type RuntimeFeatureId,
type RuntimeSecretKey,
} from '@/services/runtime-config';
import { getApiBaseUrl, getRemoteApiBaseUrl, isDesktopRuntime, resolveLocalApiPort } from '@/services/runtime';
import { tryInvokeTauri, invokeTauri } from '@/services/tauri-bridge';
import { escapeHtml } from '@/utils/sanitize';
import { initI18n, t } from '@/services/i18n';
import { applyStoredTheme } from '@/utils/theme-manager';
import { trackFeatureToggle } from '@/services/analytics';
let diagnosticsInitialized = false;
let activeSection = 'overview';
let settingsManager: SettingsManager;
let _diagCleanup: (() => void) | null = null;
function setActionStatus(message: string, tone: 'ok' | 'error' = 'ok'): void {
const statusEl = document.getElementById('settingsActionStatus');
if (!statusEl) return;
statusEl.textContent = message;
statusEl.classList.remove('ok', 'error');
statusEl.classList.add(tone);
@@ -26,155 +50,15 @@ async function invokeDesktopAction(command: string, successLabel: string): Promi
setActionStatus(`${successLabel}: ${result}`, 'ok');
return;
}
setActionStatus(t('modals.settingsWindow.invokeFail', { command }), 'error');
}
function initTabs(): void {
const tabs = document.querySelectorAll<HTMLButtonElement>('.settings-tab');
const panels = document.querySelectorAll<HTMLElement>('.settings-tab-panel');
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
if (!target) return;
tabs.forEach((t) => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
panels.forEach((p) => p.classList.remove('active'));
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
const panelId = tab.getAttribute('aria-controls');
if (panelId) {
document.getElementById(panelId)?.classList.add('active');
}
if (target === 'debug' && !diagnosticsInitialized) {
diagnosticsInitialized = true;
initDiagnostics();
}
});
});
}
function closeSettingsWindow(): void {
void tryInvokeTauri<void>('close_settings_window').then(() => { }, () => window.close());
}
const LLM_FEATURES: Array<import('@/services/runtime-config').RuntimeFeatureId> = ['aiOllama', 'aiGroq', 'aiOpenRouter'];
function mountPanel(panel: RuntimeConfigPanel, container: HTMLElement): void {
container.innerHTML = '';
const el = panel.getElement();
el.classList.remove('resized', 'span-2', 'span-3', 'span-4');
el.classList.add('settings-runtime-panel');
container.appendChild(el);
}
async function initSettingsWindow(): Promise<void> {
await initI18n();
applyStoredTheme();
// Prime sidecar port before any diagnostics or sidecar calls.
// This sets _resolvedPort in runtime.ts so getApiBaseUrl() returns the
// correct port for all callers (including runtime-config.ts).
try { await resolveLocalApiPort(); } catch { /* use default */ }
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
const llmMount = document.getElementById('llmApp');
const apiMount = document.getElementById('apiKeysApp');
const wmMount = document.getElementById('worldmonitorApp');
if (!llmMount || !apiMount) return;
// Mount WorldMonitor tab immediately — it doesn't depend on secrets
const wmTab = new WorldMonitorTab();
if (wmMount) {
wmMount.innerHTML = '';
wmMount.appendChild(wmTab.getElement());
}
// Load secrets then refresh WorldMonitor tab to reflect actual key status
await loadDesktopSecrets();
wmTab.refresh();
const llmPanel = new RuntimeConfigPanel({ mode: 'full', buffered: true, featureFilter: LLM_FEATURES });
const apiPanel = new RuntimeConfigPanel({
mode: 'full',
buffered: true,
featureFilter: RUNTIME_FEATURES.filter(f => !LLM_FEATURES.includes(f.id)).map(f => f.id),
});
mountPanel(llmPanel, llmMount);
mountPanel(apiPanel, apiMount);
const panels = [llmPanel, apiPanel];
window.addEventListener('beforeunload', () => {
panels.forEach(p => p.destroy());
wmTab.destroy();
});
document.getElementById('okBtn')?.addEventListener('click', () => {
void (async () => {
try {
const hasWmChanges = wmTab.hasPendingChanges();
const dirtyPanels = panels.filter(p => p.hasPendingChanges());
if (dirtyPanels.length === 0 && !hasWmChanges) {
closeSettingsWindow();
return;
}
if (hasWmChanges) await wmTab.save();
if (dirtyPanels.length > 0) {
setActionStatus(t('modals.settingsWindow.validating'), 'ok');
const missingRequired = dirtyPanels.flatMap(p => p.getMissingRequiredSecrets());
if (missingRequired.length > 0) {
setActionStatus(`Missing required: ${missingRequired.join(', ')}`, 'error');
return;
}
const allErrors = (await Promise.all(dirtyPanels.map(p => p.verifyPendingSecrets()))).flat();
await Promise.all(dirtyPanels.map(p => p.commitVerifiedSecrets()));
if (allErrors.length > 0) {
setActionStatus(t('modals.settingsWindow.verifyFailed', { errors: allErrors.join(', ') }), 'error');
return;
}
}
setActionStatus(t('modals.settingsWindow.saved'), 'ok');
closeSettingsWindow();
} catch (err) {
console.error('[settings] save error:', err);
setActionStatus(t('modals.settingsWindow.failed', { error: String(err) }), 'error');
}
})();
});
document.getElementById('cancelBtn')?.addEventListener('click', () => {
closeSettingsWindow();
});
document.getElementById('openLogsBtn')?.addEventListener('click', () => {
void invokeDesktopAction('open_logs_folder', t('modals.settingsWindow.openLogs'));
});
document.getElementById('openSidecarLogBtn')?.addEventListener('click', () => {
void invokeDesktopAction('open_sidecar_log_file', t('modals.settingsWindow.openApiLog'));
});
initTabs();
}
function getSidecarBase(): string {
return getApiBaseUrl() || 'http://127.0.0.1:46123';
return getApiBaseUrl() || '';
}
let _diagToken: string | null = null;
@@ -190,6 +74,593 @@ async function diagFetch(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${getSidecarBase()}${path}`, { ...init, headers });
}
// ── Sidebar icons ──
const SIDEBAR_ICONS: Record<string, string> = {
overview: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95a15.65 15.65 0 00-1.38-3.56A8.03 8.03 0 0118.92 8zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2 0 .68.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56A7.987 7.987 0 015.08 16zm2.95-8H5.08a7.987 7.987 0 014.33-3.56A15.65 15.65 0 008.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2 0-.68.07-1.35.16-2h4.68c.09.65.16 1.32.16 2 0 .68-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 01-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2 0-.68-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/></svg>',
ai: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M21 10.12h-6.78l2.74-2.82c-2.73-2.7-7.15-2.8-9.88-.1s-2.73 7.08 0 9.79 7.15 2.71 9.88 0C18.32 15.65 19 14.08 19 12.1h2c0 1.98-.88 4.55-2.64 6.29-3.51 3.48-9.21 3.48-12.72 0-3.5-3.47-3.53-9.11-.02-12.58s9.14-3.49 12.65 0L21 3v7.12zM12.5 8v4.25l3.5 2.08-.72 1.21L11 13V8h1.5z"/></svg>',
economy: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/></svg>',
markets: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M5 9.2h3V19H5V9.2zM10.6 5h2.8v14h-2.8V5zm5.6 8H19v6h-2.8v-6z"/></svg>',
security: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>',
tracking: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>',
debug: '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></svg>',
};
// ── Sidebar ──
function getFeatureStatusCounts(cat: SettingsCategory): { ready: number; total: number } {
let ready = 0;
for (const fid of cat.features) {
if (isFeatureAvailable(fid)) ready++;
}
return { ready, total: cat.features.length };
}
function getTotalProgress(): { ready: number; total: number } {
let ready = 0;
for (const f of RUNTIME_FEATURES) {
if (isFeatureAvailable(f.id)) ready++;
}
return { ready, total: RUNTIME_FEATURES.length };
}
function renderSidebar(): void {
const nav = document.getElementById('sidebarNav');
if (!nav) return;
const items: string[] = [];
const progress = getTotalProgress();
const overviewDotClass = progress.ready === progress.total ? 'dot-ok' : progress.ready > 0 ? 'dot-partial' : 'dot-warn';
items.push(`
<button class="settings-nav-item${activeSection === 'overview' ? ' active' : ''}" data-section="overview" role="tab" aria-selected="${activeSection === 'overview'}">
${SIDEBAR_ICONS.overview}
<span class="settings-nav-label">Overview</span>
<span class="settings-nav-dot ${overviewDotClass}"></span>
</button>
`);
items.push('<div class="settings-nav-sep"></div>');
for (const cat of SETTINGS_CATEGORIES) {
const { ready, total } = getFeatureStatusCounts(cat);
const dotClass = ready === total ? 'dot-ok' : ready > 0 ? 'dot-partial' : 'dot-warn';
items.push(`
<button class="settings-nav-item${activeSection === cat.id ? ' active' : ''}" data-section="${cat.id}" role="tab" aria-selected="${activeSection === cat.id}">
${SIDEBAR_ICONS[cat.id] || ''}
<span class="settings-nav-label">${escapeHtml(cat.label)}</span>
<span class="settings-nav-count">${ready}/${total}</span>
<span class="settings-nav-dot ${dotClass}"></span>
</button>
`);
}
items.push('<div class="settings-nav-sep"></div>');
items.push(`
<button class="settings-nav-item${activeSection === 'debug' ? ' active' : ''}" data-section="debug" role="tab" aria-selected="${activeSection === 'debug'}">
${SIDEBAR_ICONS.debug}
<span class="settings-nav-label">Debug &amp; Logs</span>
</button>
`);
nav.innerHTML = items.join('');
}
// ── Section rendering ──
function renderSection(sectionId: string): void {
const area = document.getElementById('contentArea');
if (!area) return;
if (_diagCleanup) { _diagCleanup(); _diagCleanup = null; }
activeSection = sectionId;
renderSidebar();
area.classList.add('fade-out');
area.classList.remove('fade-in');
requestAnimationFrame(() => {
if (sectionId === 'overview') {
renderOverview(area);
} else if (sectionId === 'debug') {
renderDebug(area);
} else {
const cat = SETTINGS_CATEGORIES.find(c => c.id === sectionId);
if (cat) renderFeatureSection(area, cat);
}
requestAnimationFrame(() => {
area.classList.remove('fade-out');
area.classList.add('fade-in');
});
});
}
// ── Overview ──
function renderOverview(area: HTMLElement): void {
const { ready, total } = getTotalProgress();
const pct = total > 0 ? (ready / total) * 100 : 0;
const circumference = 2 * Math.PI * 40;
const dashOffset = circumference - (pct / 100) * circumference;
const ringColor = ready === total ? 'var(--settings-green)' : ready > 0 ? 'var(--settings-blue)' : 'var(--settings-yellow)';
const wmState = getSecretState('WORLDMONITOR_API_KEY');
const wmStatusText = wmState.present ? 'Active' : 'Not set';
const wmStatusClass = wmState.present ? 'ok' : 'warn';
const alreadyRegistered = localStorage.getItem('wm-waitlist-registered') === '1';
const catCards = SETTINGS_CATEGORIES.map(cat => {
const { ready: catReady, total: catTotal } = getFeatureStatusCounts(cat);
const cls = catReady === catTotal ? 'ov-cat-ok' : catReady > 0 ? 'ov-cat-partial' : 'ov-cat-warn';
return `<button class="settings-ov-cat ${cls}" data-section="${cat.id}">
<span class="settings-ov-cat-label">${escapeHtml(cat.label)}</span>
<span class="settings-ov-cat-count">${catReady}/${catTotal} ready</span>
</button>`;
}).join('');
area.innerHTML = `
<div class="settings-overview">
<div class="settings-ov-progress">
<svg class="settings-ov-ring" viewBox="0 0 100 100" width="120" height="120">
<circle cx="50" cy="50" r="40" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="8"/>
<circle cx="50" cy="50" r="40" fill="none" stroke="${ringColor}" stroke-width="8"
stroke-linecap="round" stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}"
transform="rotate(-90 50 50)" style="transition:stroke-dashoffset 0.6s ease"/>
</svg>
<div class="settings-ov-ring-text">
<span class="settings-ov-ring-num">${ready}</span>
<span class="settings-ov-ring-label">of ${total} ready</span>
</div>
</div>
<div class="settings-ov-cats">${catCards}</div>
</div>
<div class="settings-ov-license">
<section class="wm-section">
<h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.apiKey.title')}</h2>
<p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.apiKey.description')}</p>
<div class="wm-key-row">
<div class="wm-input-wrap">
<input type="password" class="wm-input" data-wm-key-input
placeholder="${t('modals.settingsWindow.worldMonitor.apiKey.placeholder')}"
autocomplete="off" spellcheck="false"
${wmState.present ? `value="${MASKED_SENTINEL}"` : ''} />
<button type="button" class="wm-toggle-vis" data-wm-toggle title="Show/hide">&#x1f441;</button>
</div>
<span class="wm-badge ${wmStatusClass}">${wmStatusText}</span>
</div>
</section>
<div class="wm-divider"><span>${t('modals.settingsWindow.worldMonitor.dividerOr')}</span></div>
<section class="wm-section">
<h2 class="wm-section-title">${t('modals.settingsWindow.worldMonitor.register.title')}</h2>
<p class="wm-section-desc">${t('modals.settingsWindow.worldMonitor.register.description')}</p>
${alreadyRegistered ? `
<p class="wm-reg-status ok">${t('modals.settingsWindow.worldMonitor.register.alreadyRegistered')}</p>
` : `
<div class="wm-register-row">
<input type="email" class="wm-input wm-email" data-wm-email
placeholder="${t('modals.settingsWindow.worldMonitor.register.emailPlaceholder')}" />
<button type="button" class="wm-submit-btn" data-wm-register>
${t('modals.settingsWindow.worldMonitor.register.submitBtn')}
</button>
</div>
<p class="wm-reg-status" data-wm-reg-status></p>
`}
</section>
</div>
`;
initOverviewListeners(area);
}
function initOverviewListeners(area: HTMLElement): void {
area.querySelector('[data-wm-toggle]')?.addEventListener('click', () => {
const input = area.querySelector<HTMLInputElement>('[data-wm-key-input]');
if (input) input.type = input.type === 'password' ? 'text' : 'password';
});
area.querySelector<HTMLInputElement>('[data-wm-key-input]')?.addEventListener('input', (e) => {
const input = e.target as HTMLInputElement;
if (input.value.startsWith(MASKED_SENTINEL)) {
input.value = input.value.slice(MASKED_SENTINEL.length);
}
});
area.querySelector('[data-wm-register]')?.addEventListener('click', async () => {
const emailInput = area.querySelector<HTMLInputElement>('[data-wm-email]');
const regStatus = area.querySelector<HTMLElement>('[data-wm-reg-status]');
const btn = area.querySelector<HTMLButtonElement>('[data-wm-register]');
if (!emailInput || !regStatus || !btn) return;
const email = emailInput.value.trim();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.invalidEmail');
regStatus.className = 'wm-reg-status error';
return;
}
btn.disabled = true;
btn.textContent = t('modals.settingsWindow.worldMonitor.register.submitting');
try {
const base = isDesktopRuntime() ? getRemoteApiBaseUrl() : '';
const res = await fetch(`${base}/api/register-interest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, source: 'desktop-settings' }),
});
const data = await res.json() as { status?: string; error?: string };
if (data.status === 'already_registered' || data.status === 'registered') {
localStorage.setItem('wm-waitlist-registered', '1');
regStatus.textContent = data.status === 'already_registered'
? t('modals.settingsWindow.worldMonitor.register.alreadyRegistered')
: t('modals.settingsWindow.worldMonitor.register.success');
regStatus.className = 'wm-reg-status ok';
} else {
regStatus.textContent = data.error || t('modals.settingsWindow.worldMonitor.register.error');
regStatus.className = 'wm-reg-status error';
}
} catch {
regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.error');
regStatus.className = 'wm-reg-status error';
} finally {
btn.disabled = false;
btn.textContent = t('modals.settingsWindow.worldMonitor.register.submitBtn');
}
});
area.querySelectorAll<HTMLButtonElement>('.settings-ov-cat[data-section]').forEach(btn => {
btn.addEventListener('click', () => {
const section = btn.dataset.section;
if (section) renderSection(section);
});
});
}
// ── Feature sections ──
function renderFeatureSection(area: HTMLElement, cat: SettingsCategory): void {
const features = cat.features
.map(fid => RUNTIME_FEATURES.find(f => f.id === fid))
.filter(Boolean) as RuntimeFeatureDefinition[];
const featureCards = features.map(feature => {
const enabled = isFeatureEnabled(feature.id);
const available = isFeatureAvailable(feature.id);
const effectiveSecrets = getEffectiveSecrets(feature);
const allStaged = !available && effectiveSecrets.every(
k => getSecretState(k).valid || (settingsManager.hasPending(k) && settingsManager.getValidationState(k).validated !== false)
);
const borderClass = available ? 'ready' : allStaged ? 'staged' : 'needs';
const pillClass = available ? 'ok' : allStaged ? 'staged' : 'warn';
const pillLabel = available ? 'Ready' : allStaged ? 'Staged' : 'Needs keys';
const secretRows = effectiveSecrets.map(key => renderSecretInput(key, feature.id)).join('');
const fallbackHtml = (available || allStaged) ? '' : `<p class="settings-feat-fallback">${escapeHtml(feature.fallback)}</p>`;
return `
<div class="settings-feat ${borderClass}" data-feature-id="${feature.id}">
<div class="settings-feat-header" data-feat-toggle-expand="${feature.id}">
<label class="settings-feat-toggle-label" data-click-stop>
<div class="settings-feat-switch">
<input type="checkbox" data-toggle="${feature.id}" ${enabled ? 'checked' : ''} />
<span class="settings-feat-slider"></span>
</div>
</label>
<div class="settings-feat-info">
<span class="settings-feat-name">${escapeHtml(feature.name)}</span>
<span class="settings-feat-desc">${escapeHtml(feature.description)}</span>
</div>
<span class="settings-feat-pill ${pillClass}">${pillLabel}</span>
<span class="settings-feat-chevron">&#x25B8;</span>
</div>
<div class="settings-feat-body">
${secretRows}
${fallbackHtml}
</div>
</div>
`;
}).join('');
area.innerHTML = `
<div class="settings-section-header">
<h2>${escapeHtml(cat.label)}</h2>
</div>
<div class="settings-feat-list">${featureCards}</div>
`;
initFeatureSectionListeners(area);
}
function renderSecretInput(key: RuntimeSecretKey, _featureId: RuntimeFeatureId): string {
const state = getSecretState(key);
const pending = settingsManager.hasPending(key);
const { validated, message } = settingsManager.getValidationState(key);
const label = HUMAN_LABELS[key] || key;
const signupUrl = SIGNUP_URLS[key];
const isPlaintext = PLAINTEXT_KEYS.has(key);
const showGetKey = signupUrl && !state.present && !pending;
const statusText = pending
? (validated === false ? 'Invalid' : 'Staged')
: !state.present ? 'Missing' : state.valid ? 'Valid' : 'Looks invalid';
const statusClass = pending
? (validated === false ? 'warn' : 'staged')
: state.valid ? 'ok' : 'warn';
const inputClass = pending ? (validated === false ? 'invalid' : 'valid-staged') : '';
const hintText = pending && validated === false ? (message || 'Invalid value') : null;
if (key === 'OLLAMA_MODEL') {
const storedModel = pending
? settingsManager.getPending(key) || ''
: getRuntimeConfigSnapshot().secrets[key]?.value || '';
return `
<div class="settings-secret-row">
<div class="settings-secret-label">${escapeHtml(label)}</div>
<span class="settings-secret-status ${statusClass}">${escapeHtml(statusText)}</span>
<select data-model-select data-feature="${_featureId}" class="${inputClass}">
${storedModel ? `<option value="${escapeHtml(storedModel)}" selected>${escapeHtml(storedModel)}</option>` : '<option value="" selected disabled>Loading models...</option>'}
</select>
<input type="text" data-model-manual data-feature="${_featureId}" class="${inputClass} hidden-input"
placeholder="Or type model name" autocomplete="off"
${storedModel ? `value="${escapeHtml(storedModel)}"` : ''}>
${hintText ? `<span class="settings-secret-hint">${escapeHtml(hintText)}</span>` : ''}
</div>
`;
}
const getKeyHtml = showGetKey
? `<a href="#" data-signup-url="${signupUrl}" class="settings-secret-link">Get key</a>`
: '';
return `
<div class="settings-secret-row">
<div class="settings-secret-label">${escapeHtml(label)}</div>
<span class="settings-secret-status ${statusClass}">${escapeHtml(statusText)}</span>
<div class="settings-input-wrapper${showGetKey ? ' has-suffix' : ''}">
<input type="${isPlaintext ? 'text' : 'password'}" data-secret="${key}" data-feature="${_featureId}"
placeholder="${pending ? 'Staged' : 'Enter value...'}" autocomplete="off" class="${inputClass}"
${pending ? `value="${isPlaintext ? escapeHtml(settingsManager.getPending(key) || '') : MASKED_SENTINEL}"` : (isPlaintext && state.present ? `value="${escapeHtml(getRuntimeConfigSnapshot().secrets[key]?.value || '')}"` : '')}>
${getKeyHtml}
</div>
${hintText ? `<span class="settings-secret-hint">${escapeHtml(hintText)}</span>` : ''}
</div>
`;
}
function initFeatureSectionListeners(area: HTMLElement): void {
area.querySelectorAll<HTMLElement>('[data-feat-toggle-expand]').forEach(header => {
header.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('[data-click-stop]')) return;
const card = header.closest('.settings-feat');
card?.classList.toggle('expanded');
});
});
area.querySelectorAll<HTMLInputElement>('input[data-toggle]').forEach(input => {
input.addEventListener('change', () => {
const featureId = input.dataset.toggle as RuntimeFeatureId;
if (!featureId) return;
trackFeatureToggle(featureId, input.checked);
setFeatureToggle(featureId, input.checked);
renderSidebar();
});
});
area.querySelectorAll<HTMLInputElement>('input[data-secret]').forEach(input => {
input.addEventListener('input', () => {
const key = input.dataset.secret as RuntimeSecretKey;
if (!key) return;
if (settingsManager.hasPending(key) && input.value.startsWith(MASKED_SENTINEL)) {
input.value = input.value.slice(MASKED_SENTINEL.length);
}
settingsManager.setValidation(key, true);
input.classList.remove('valid-staged', 'invalid');
const hint = input.closest('.settings-secret-row')?.querySelector('.settings-secret-hint');
if (hint) hint.remove();
});
input.addEventListener('blur', () => {
const key = input.dataset.secret as RuntimeSecretKey;
if (!key) return;
const raw = input.value.trim();
if (!raw) {
if (settingsManager.hasPending(key)) {
settingsManager.deletePending(key);
renderSection(activeSection);
}
return;
}
if (raw === MASKED_SENTINEL) return;
settingsManager.setPending(key, raw);
const result = validateSecret(key, raw);
if (result.valid) {
settingsManager.setValidation(key, true);
} else {
settingsManager.setValidation(key, false, result.hint || 'Invalid format');
}
if (PLAINTEXT_KEYS.has(key)) {
input.value = raw;
} else {
input.type = 'password';
input.value = MASKED_SENTINEL;
}
input.classList.remove('valid-staged', 'invalid');
input.classList.add(result.valid ? 'valid-staged' : 'invalid');
const statusEl = input.closest('.settings-secret-row')?.querySelector('.settings-secret-status');
if (statusEl) {
statusEl.textContent = result.valid ? 'Staged' : 'Invalid';
statusEl.className = `settings-secret-status ${result.valid ? 'staged' : 'warn'}`;
}
const row = input.closest('.settings-secret-row');
const existingHint = row?.querySelector('.settings-secret-hint');
if (existingHint) existingHint.remove();
if (!result.valid && result.hint) {
const hint = document.createElement('span');
hint.className = 'settings-secret-hint';
hint.textContent = result.hint;
row?.appendChild(hint);
}
updateFeatureCardStatus(input.dataset.feature as RuntimeFeatureId);
if (key === 'OLLAMA_API_URL' && result.valid) {
const modelSelect = area.querySelector<HTMLSelectElement>('select[data-model-select]');
if (modelSelect) void loadOllamaModelsIntoSelect(modelSelect);
}
renderSidebar();
});
});
area.querySelectorAll<HTMLAnchorElement>('a[data-signup-url]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const url = link.dataset.signupUrl;
if (!url) return;
if (isDesktopRuntime()) {
void invokeTauri<void>('open_url', { url }).catch(() => window.open(url, '_blank'));
} else {
window.open(url, '_blank');
}
});
});
const modelSelect = area.querySelector<HTMLSelectElement>('select[data-model-select]');
if (modelSelect) {
modelSelect.addEventListener('change', () => {
const model = modelSelect.value;
if (model) {
settingsManager.setPending('OLLAMA_MODEL', model);
settingsManager.setValidation('OLLAMA_MODEL', true);
modelSelect.classList.remove('invalid');
modelSelect.classList.add('valid-staged');
updateFeatureCardStatus('aiOllama');
renderSidebar();
}
});
void loadOllamaModelsIntoSelect(modelSelect);
}
}
function updateFeatureCardStatus(featureId: RuntimeFeatureId): void {
const card = document.querySelector<HTMLElement>(`.settings-feat[data-feature-id="${featureId}"]`);
if (!card) return;
const feature = RUNTIME_FEATURES.find(f => f.id === featureId);
if (!feature) return;
const available = isFeatureAvailable(featureId);
const effectiveSecrets = getEffectiveSecrets(feature);
const allStaged = !available && effectiveSecrets.every(
k => getSecretState(k).valid || (settingsManager.hasPending(k) && settingsManager.getValidationState(k).validated !== false)
);
const wasExpanded = card.classList.contains('expanded');
card.className = `settings-feat ${available ? 'ready' : allStaged ? 'staged' : 'needs'}${wasExpanded ? ' expanded' : ''}`;
const pill = card.querySelector('.settings-feat-pill');
if (pill) {
pill.className = `settings-feat-pill ${available ? 'ok' : allStaged ? 'staged' : 'warn'}`;
pill.textContent = available ? 'Ready' : allStaged ? 'Staged' : 'Needs keys';
}
}
async function loadOllamaModelsIntoSelect(select: HTMLSelectElement): Promise<void> {
const snapshot = getRuntimeConfigSnapshot();
const ollamaUrl = settingsManager.getPending('OLLAMA_API_URL')
|| snapshot.secrets['OLLAMA_API_URL']?.value
|| '';
if (!ollamaUrl) {
select.innerHTML = '<option value="" disabled selected>Set Ollama URL first</option>';
return;
}
const currentModel = settingsManager.getPending('OLLAMA_MODEL')
|| snapshot.secrets['OLLAMA_MODEL']?.value
|| '';
const models = await fetchOllamaModels(ollamaUrl);
if (models.length === 0) {
const manual = select.parentElement?.querySelector<HTMLInputElement>('input[data-model-manual]');
if (manual) {
select.style.display = 'none';
manual.classList.remove('hidden-input');
if (!manual.dataset.listenerAttached) {
manual.dataset.listenerAttached = '1';
manual.addEventListener('blur', () => {
const model = manual.value.trim();
if (model) {
settingsManager.setPending('OLLAMA_MODEL', model);
settingsManager.setValidation('OLLAMA_MODEL', true);
manual.classList.remove('invalid');
manual.classList.add('valid-staged');
updateFeatureCardStatus('aiOllama');
renderSidebar();
}
});
}
}
return;
}
const options = currentModel ? '' : '<option value="" selected disabled>Select a model...</option>';
select.innerHTML = options + models.map(name =>
`<option value="${escapeHtml(name)}" ${name === currentModel ? 'selected' : ''}>${escapeHtml(name)}</option>`
).join('');
}
// ── Debug section ──
function renderDebug(area: HTMLElement): void {
area.innerHTML = `
<div class="settings-section-header">
<h2>Debug &amp; Logs</h2>
</div>
<div class="debug-actions">
<button id="openLogsBtn" type="button">Open Logs Folder</button>
<button id="openSidecarLogBtn" type="button">Open API Log</button>
</div>
<section class="settings-diagnostics" id="diagnosticsSection">
<header class="diag-header">
<h2>Diagnostics</h2>
<div class="diag-toggles">
<label><input type="checkbox" id="verboseApiLog"> Verbose Sidecar Log</label>
<label><input type="checkbox" id="fetchDebugLog"> Frontend Fetch Debug</label>
</div>
</header>
<div class="diag-traffic-bar">
<h3>API Traffic <span id="trafficCount"></span></h3>
<div class="diag-traffic-controls">
<label><input type="checkbox" id="autoRefreshLog" checked> Auto</label>
<button id="refreshLogBtn" type="button">Refresh</button>
<button id="clearLogBtn" type="button">Clear</button>
</div>
</div>
<div id="trafficLog" class="diag-traffic-log"></div>
</section>
`;
area.querySelector('#openLogsBtn')?.addEventListener('click', () => {
void invokeDesktopAction('open_logs_folder', t('modals.settingsWindow.openLogs'));
});
area.querySelector('#openSidecarLogBtn')?.addEventListener('click', () => {
void invokeDesktopAction('open_sidecar_log_file', t('modals.settingsWindow.openApiLog'));
});
initDiagnostics();
}
function initDiagnostics(): void {
const verboseToggle = document.getElementById('verboseApiLog') as HTMLInputElement | null;
const fetchDebugToggle = document.getElementById('fetchDebugLog') as HTMLInputElement | null;
@@ -256,9 +727,7 @@ function initDiagnostics(): void {
refreshBtn?.addEventListener('click', () => void refreshTrafficLog());
clearBtn?.addEventListener('click', async () => {
try {
await diagFetch('/api/local-traffic-log', { method: 'DELETE' });
} catch { /* ignore */ }
try { await diagFetch('/api/local-traffic-log', { method: 'DELETE' }); } catch { /* ignore */ }
if (trafficLogEl) trafficLogEl.innerHTML = `<p class="diag-empty">${t('modals.settingsWindow.logCleared')}</p>`;
if (trafficCount) trafficCount.textContent = '(0)';
});
@@ -280,9 +749,182 @@ function initDiagnostics(): void {
void refreshTrafficLog();
startAutoRefresh();
_diagCleanup = stopAutoRefresh;
}
// ── Search ──
function highlightMatch(text: string, query: string): string {
const escaped = escapeHtml(text);
const qEscaped = escapeHtml(query);
if (!qEscaped) return escaped;
const regex = new RegExp(`(${qEscaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return escaped.replace(regex, '<mark>$1</mark>');
}
function handleSearch(query: string): void {
const area = document.getElementById('contentArea');
if (!area) return;
if (!query.trim()) {
renderSection(activeSection);
return;
}
const q = query.toLowerCase();
const matches: Array<{ feature: RuntimeFeatureDefinition; catLabel: string }> = [];
for (const cat of SETTINGS_CATEGORIES) {
for (const fid of cat.features) {
const feature = RUNTIME_FEATURES.find(f => f.id === fid);
if (!feature) continue;
const searchable = [
feature.name,
feature.description,
...getEffectiveSecrets(feature).map(k => HUMAN_LABELS[k] || k),
].join(' ').toLowerCase();
if (searchable.includes(q)) {
matches.push({ feature, catLabel: cat.label });
}
}
}
if (matches.length === 0) {
area.innerHTML = `<div class="settings-search-empty"><p>No features match "${escapeHtml(query)}"</p></div>`;
return;
}
const cards = matches.map(({ feature, catLabel }) => {
const enabled = isFeatureEnabled(feature.id);
const available = isFeatureAvailable(feature.id);
const effectiveSecrets = getEffectiveSecrets(feature);
const allStaged = !available && effectiveSecrets.every(
k => getSecretState(k).valid || (settingsManager.hasPending(k) && settingsManager.getValidationState(k).validated !== false)
);
const borderClass = available ? 'ready' : allStaged ? 'staged' : 'needs';
const pillClass = available ? 'ok' : allStaged ? 'staged' : 'warn';
const pillLabel = available ? 'Ready' : allStaged ? 'Staged' : 'Needs keys';
const secretRows = effectiveSecrets.map(key => renderSecretInput(key, feature.id)).join('');
return `
<div class="settings-feat ${borderClass} expanded" data-feature-id="${feature.id}">
<div class="settings-feat-header" data-feat-toggle-expand="${feature.id}">
<label class="settings-feat-toggle-label" data-click-stop>
<div class="settings-feat-switch">
<input type="checkbox" data-toggle="${feature.id}" ${enabled ? 'checked' : ''} />
<span class="settings-feat-slider"></span>
</div>
</label>
<div class="settings-feat-info">
<span class="settings-feat-name">${highlightMatch(feature.name, query)}</span>
<span class="settings-feat-desc">${highlightMatch(feature.description, query)}</span>
</div>
<span class="settings-feat-pill ${pillClass}">${pillLabel}</span>
<span class="settings-feat-chevron">&#x25B8;</span>
</div>
<div class="settings-feat-body">
<div class="settings-feat-cat-tag">${escapeHtml(catLabel)}</div>
${secretRows}
</div>
</div>
`;
}).join('');
area.innerHTML = `
<div class="settings-section-header">
<h2>Search results for "${escapeHtml(query)}"</h2>
</div>
<div class="settings-feat-list">${cards}</div>
`;
initFeatureSectionListeners(area);
}
// ── Init ──
async function initSettingsWindow(): Promise<void> {
await initI18n();
applyStoredTheme();
try { await resolveLocalApiPort(); } catch { /* use default */ }
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
await loadDesktopSecrets();
settingsManager = new SettingsManager();
renderSection('overview');
document.getElementById('sidebarNav')?.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-section]');
if (btn?.dataset.section) {
renderSection(btn.dataset.section);
}
});
const searchInput = document.getElementById('settingsSearch') as HTMLInputElement | null;
let searchTimeout: ReturnType<typeof setTimeout>;
searchInput?.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => handleSearch(searchInput.value), 200);
});
document.getElementById('okBtn')?.addEventListener('click', () => {
void (async () => {
try {
const wmKeyInput = document.querySelector<HTMLInputElement>('[data-wm-key-input]');
const wmKeyValue = wmKeyInput?.value.trim();
const hasWmKeyChange = !!(wmKeyValue && wmKeyValue !== MASKED_SENTINEL && wmKeyValue.length > 0);
const contentArea = document.getElementById('contentArea');
if (contentArea) settingsManager.captureUnsavedInputs(contentArea);
const hasPending = settingsManager.hasPendingChanges();
if (!hasPending && !hasWmKeyChange) {
closeSettingsWindow();
return;
}
if (hasWmKeyChange && wmKeyValue) {
await setSecretValue('WORLDMONITOR_API_KEY', wmKeyValue);
}
if (hasPending) {
setActionStatus(t('modals.settingsWindow.validating'), 'ok');
const missingRequired = settingsManager.getMissingRequiredSecrets();
if (missingRequired.length > 0) {
setActionStatus(`Missing required: ${missingRequired.join(', ')}`, 'error');
return;
}
const errors = await settingsManager.verifyPendingSecrets();
if (errors.length > 0) {
setActionStatus(t('modals.settingsWindow.verifyFailed', { errors: errors.join(', ') }), 'error');
return;
}
await settingsManager.commitVerifiedSecrets();
}
setActionStatus(t('modals.settingsWindow.saved'), 'ok');
closeSettingsWindow();
} catch (err) {
console.error('[settings] save error:', err);
setActionStatus(t('modals.settingsWindow.failed', { error: String(err) }), 'error');
}
})();
});
document.getElementById('cancelBtn')?.addEventListener('click', () => {
closeSettingsWindow();
});
window.addEventListener('beforeunload', () => {
settingsManager.destroy();
});
}
// Signal main window that settings is open (suppresses alert popups)
localStorage.setItem('wm-settings-open', '1');
window.addEventListener('beforeunload', () => localStorage.removeItem('wm-settings-open'));

File diff suppressed because it is too large Load Diff