mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: split settings into LLMs and API Keys tabs, fix keychain vault and Ollama UX
- Split settings window into 3 tabs: LLMs (Ollama/Groq/OpenRouter), API Keys (data feeds), and Debug & Logs - Add featureFilter option to RuntimeConfigPanel for rendering subsets - Consolidate keychain to single JSON vault entry (1 macOS prompt vs 20) - Add Ollama model discovery with /api/tags + /v1/models fallback - Strip <think> reasoning tokens from Ollama responses - Suppress thinking with think:false in Ollama request body - Parallel secret verification with 15s global timeout - Fix manual model input overlapping dropdown (CSS grid-area + hidden-input class) - Add loading spinners to settings tab panels - Suppress notification popups when settings window is open - Filter embed models from Ollama dropdown - Fix settings window black screen flash with inline dark background
This commit is contained in:
@@ -149,6 +149,7 @@ Rules:
|
||||
* @property {string} apiUrl - Full chat-completions endpoint URL
|
||||
* @property {string} model - Model identifier
|
||||
* @property {Record<string, string>} headers - Request headers (incl. auth)
|
||||
* @property {Record<string, unknown>} [extraBody] - Extra request body fields (e.g. think: false)
|
||||
*
|
||||
* @typedef {Object} ProviderConfig
|
||||
* @property {string} name - Provider label in responses (e.g. 'groq')
|
||||
@@ -195,7 +196,7 @@ export function createSummarizeHandler(providerConfig) {
|
||||
});
|
||||
}
|
||||
|
||||
const { apiUrl, model, headers: providerHeaders } = credentials;
|
||||
const { apiUrl, model, headers: providerHeaders, extraBody } = credentials;
|
||||
|
||||
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
|
||||
if (contentLength > 51200) {
|
||||
@@ -247,6 +248,7 @@ export function createSummarizeHandler(providerConfig) {
|
||||
temperature: 0.3,
|
||||
max_tokens: 150,
|
||||
top_p: 0.9,
|
||||
...extraBody,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -270,10 +272,18 @@ export function createSummarizeHandler(providerConfig) {
|
||||
|
||||
const data = await response.json();
|
||||
const message = data.choices?.[0]?.message;
|
||||
const summary = (
|
||||
(typeof message?.content === 'string' ? message.content.trim() : '')
|
||||
|| (typeof message?.reasoning === 'string' ? message.reasoning.trim() : '')
|
||||
);
|
||||
let rawContent = (typeof message?.content === 'string' ? message.content.trim() : '')
|
||||
|| (typeof message?.reasoning === 'string' ? message.reasoning.trim() : '');
|
||||
|
||||
// Strip <think>...</think> reasoning tokens (common in DeepSeek-R1, QwQ, etc.)
|
||||
rawContent = rawContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||||
|
||||
// Some models output unterminated <think> blocks — strip from <think> to end if no closing tag
|
||||
if (rawContent.includes('<think>') && !rawContent.includes('</think>')) {
|
||||
rawContent = rawContent.replace(/<think>[\s\S]*/gi, '').trim();
|
||||
}
|
||||
|
||||
const summary = rawContent;
|
||||
|
||||
if (!summary) {
|
||||
return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), {
|
||||
|
||||
@@ -29,6 +29,7 @@ export default createSummarizeHandler({
|
||||
apiUrl: new URL('/v1/chat/completions', baseUrl).toString(),
|
||||
model: process.env.OLLAMA_MODEL || DEFAULT_MODEL,
|
||||
headers,
|
||||
extraBody: { think: false },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,16 +6,20 @@
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<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="tabPanelKeys" data-tab="keys">API Keys</button>
|
||||
<button class="settings-tab active" role="tab" aria-selected="true" 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 & Logs</button>
|
||||
</div>
|
||||
<p id="settingsActionStatus" class="settings-action-status" aria-live="polite"></p>
|
||||
<div class="settings-tab-panels">
|
||||
<div id="tabPanelKeys" class="settings-tab-panel active" role="tabpanel">
|
||||
<main id="settingsApp" class="settings-content"></main>
|
||||
<div id="tabPanelLLMs" class="settings-tab-panel active" 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">
|
||||
|
||||
@@ -872,7 +872,8 @@ export async function createLocalApiServer(options = {}) {
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const skipRecord = requestUrl.pathname === '/api/local-traffic-log'
|
||||
const skipRecord = req.method === 'OPTIONS'
|
||||
|| requestUrl.pathname === '/api/local-traffic-log'
|
||||
|| requestUrl.pathname === '/api/local-debug-toggle'
|
||||
|| requestUrl.pathname === '/api/local-env-update'
|
||||
|| requestUrl.pathname === '/api/local-validate-secret';
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::env;
|
||||
@@ -53,17 +54,77 @@ struct LocalApiState {
|
||||
token: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
/// In-memory cache for keychain secrets. Populated once at startup to avoid
|
||||
/// repeated macOS Keychain prompts (each `Entry::get_password()` triggers one).
|
||||
struct SecretsCache {
|
||||
secrets: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl SecretsCache {
|
||||
fn load_from_keychain() -> Self {
|
||||
// Try consolidated vault first — single keychain prompt
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") {
|
||||
if let Ok(json) = entry.get_password() {
|
||||
if let Ok(map) = serde_json::from_str::<HashMap<String, String>>(&json) {
|
||||
let secrets: HashMap<String, String> = map
|
||||
.into_iter()
|
||||
.filter(|(k, v)| {
|
||||
SUPPORTED_SECRET_KEYS.contains(&k.as_str()) && !v.trim().is_empty()
|
||||
})
|
||||
.map(|(k, v)| (k, v.trim().to_string()))
|
||||
.collect();
|
||||
return SecretsCache { secrets: Mutex::new(secrets) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: read individual keys (old format), consolidate into vault.
|
||||
// This triggers one keychain prompt per key — happens only once.
|
||||
let mut secrets = HashMap::new();
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
if let Ok(value) = entry.get_password() {
|
||||
let trimmed = value.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
secrets.insert((*key).to_string(), trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write consolidated vault and clean up individual entries
|
||||
if !secrets.is_empty() {
|
||||
if let Ok(json) = serde_json::to_string(&secrets) {
|
||||
if let Ok(vault_entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") {
|
||||
if vault_entry.set_password(&json).is_ok() {
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SecretsCache { secrets: Mutex::new(secrets) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DesktopRuntimeInfo {
|
||||
os: String,
|
||||
arch: String,
|
||||
}
|
||||
|
||||
fn secret_entry(key: &str) -> Result<Entry, String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
Entry::new(KEYRING_SERVICE, key).map_err(|e| format!("Keyring init failed: {e}"))
|
||||
fn save_vault(cache: &HashMap<String, String>) -> Result<(), String> {
|
||||
let json = serde_json::to_string(cache)
|
||||
.map_err(|e| format!("Failed to serialize vault: {e}"))?;
|
||||
let entry = Entry::new(KEYRING_SERVICE, "secrets-vault")
|
||||
.map_err(|e| format!("Keyring init failed: {e}"))?;
|
||||
entry.set_password(&json)
|
||||
.map_err(|e| format!("Failed to write vault: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_local_token() -> String {
|
||||
@@ -106,46 +167,49 @@ fn list_supported_secret_keys() -> Vec<String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_secret(key: String) -> Result<Option<String>, String> {
|
||||
let entry = secret_entry(&key)?;
|
||||
match entry.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Failed to read keyring secret: {err}")),
|
||||
fn get_secret(key: String, cache: tauri::State<'_, SecretsCache>) -> Result<Option<String>, String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
let secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
|
||||
Ok(secrets.get(&key).cloned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_all_secrets() -> std::collections::HashMap<String, String> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
if let Ok(value) = entry.get_password() {
|
||||
if !value.trim().is_empty() {
|
||||
result.insert((*key).to_string(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
fn get_all_secrets(cache: tauri::State<'_, SecretsCache>) -> HashMap<String, String> {
|
||||
cache.secrets.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_secret(key: String, value: String) -> Result<(), String> {
|
||||
let entry = secret_entry(&key)?;
|
||||
entry
|
||||
.set_password(&value)
|
||||
.map_err(|e| format!("Failed to write keyring secret: {e}"))
|
||||
fn set_secret(key: String, value: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
let mut secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
|
||||
let trimmed = value.trim().to_string();
|
||||
// Build proposed state, persist first, then commit to cache
|
||||
let mut proposed = secrets.clone();
|
||||
if trimmed.is_empty() {
|
||||
proposed.remove(&key);
|
||||
} else {
|
||||
proposed.insert(key, trimmed);
|
||||
}
|
||||
save_vault(&proposed)?;
|
||||
*secrets = proposed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_secret(key: String) -> Result<(), String> {
|
||||
let entry = secret_entry(&key)?;
|
||||
match entry.delete_credential() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Failed to delete keyring secret: {err}")),
|
||||
fn delete_secret(key: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
let mut secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
|
||||
let mut proposed = secrets.clone();
|
||||
proposed.remove(&key);
|
||||
save_vault(&proposed)?;
|
||||
*secrets = proposed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cache_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -360,7 +424,6 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
|
||||
.inner_size(980.0, 760.0)
|
||||
.min_inner_size(820.0, 620.0)
|
||||
.resizable(true)
|
||||
.visible(false)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create settings window: {e}"))?;
|
||||
|
||||
@@ -658,16 +721,13 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
|
||||
cmd.current_dir(parent);
|
||||
}
|
||||
|
||||
// Pass keychain secrets to sidecar as env vars
|
||||
// Pass cached keychain secrets to sidecar as env vars (no keychain re-read)
|
||||
let mut secret_count = 0u32;
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
if let Ok(value) = entry.get_password() {
|
||||
if !value.trim().is_empty() {
|
||||
cmd.env(key, value.trim());
|
||||
secret_count += 1;
|
||||
}
|
||||
}
|
||||
let secrets_cache = app.state::<SecretsCache>();
|
||||
if let Ok(secrets) = secrets_cache.secrets.lock() {
|
||||
for (key, value) in secrets.iter() {
|
||||
cmd.env(key, value);
|
||||
secret_count += 1;
|
||||
}
|
||||
}
|
||||
append_desktop_log(app, "INFO", &format!("injected {secret_count} keychain secrets into sidecar env"));
|
||||
@@ -696,6 +756,7 @@ fn main() {
|
||||
.menu(build_app_menu)
|
||||
.on_menu_event(handle_menu_event)
|
||||
.manage(LocalApiState::default())
|
||||
.manage(SecretsCache::load_from_keychain())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_supported_secret_keys,
|
||||
get_secret,
|
||||
|
||||
@@ -348,10 +348,12 @@ export class App {
|
||||
this.findingsBadge = new IntelligenceGapBadge();
|
||||
this.findingsBadge.setOnSignalClick((signal) => {
|
||||
if (this.countryBriefPage?.isVisible()) return;
|
||||
if (localStorage.getItem('wm-settings-open') === '1') return;
|
||||
this.signalModal?.showSignal(signal);
|
||||
});
|
||||
this.findingsBadge.setOnAlertClick((alert) => {
|
||||
if (this.countryBriefPage?.isVisible()) return;
|
||||
if (localStorage.getItem('wm-settings-open') === '1') return;
|
||||
this.signalModal?.showAlert(alert);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,10 +185,11 @@ export class LiveNewsPanel extends Panel {
|
||||
// Track user activity to detect idle (pauses after 5 min inactivity)
|
||||
this.boundIdleResetHandler = () => {
|
||||
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
||||
this.resumeFromIdle();
|
||||
this.idleTimeout = setTimeout(() => this.pauseForIdle(), this.IDLE_PAUSE_MS);
|
||||
};
|
||||
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
|
||||
document.addEventListener(event, this.boundIdleResetHandler, { passive: true });
|
||||
});
|
||||
|
||||
@@ -265,7 +266,12 @@ export class LiveNewsPanel extends Panel {
|
||||
this.isPlaying = !this.isPlaying;
|
||||
this.wasPlayingBeforeIdle = this.isPlaying;
|
||||
this.updateLiveIndicator();
|
||||
this.syncPlayerState();
|
||||
if (this.isPlaying && !this.player && !this.desktopEmbedIframe) {
|
||||
this.ensurePlayerContainer();
|
||||
void this.initializePlayer();
|
||||
} else {
|
||||
this.syncPlayerState();
|
||||
}
|
||||
}
|
||||
|
||||
private createMuteButton(): void {
|
||||
@@ -684,7 +690,7 @@ export class LiveNewsPanel extends Panel {
|
||||
|
||||
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
|
||||
window.removeEventListener('message', this.boundMessageHandler);
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
|
||||
document.removeEventListener(event, this.boundIdleResetHandler);
|
||||
});
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ export class LiveWebcamsPanel extends Panel {
|
||||
}, this.IDLE_PAUSE_MS);
|
||||
};
|
||||
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
|
||||
document.addEventListener(event, this.boundIdleResetHandler, { passive: true });
|
||||
});
|
||||
|
||||
@@ -330,7 +330,7 @@ export class LiveWebcamsPanel extends Panel {
|
||||
this.idleTimeout = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => {
|
||||
document.removeEventListener(event, this.boundIdleResetHandler);
|
||||
});
|
||||
this.observer?.disconnect();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
validateSecret,
|
||||
verifySecretWithApi,
|
||||
type RuntimeFeatureDefinition,
|
||||
type RuntimeFeatureId,
|
||||
type RuntimeSecretKey,
|
||||
} from '@/services/runtime-config';
|
||||
import { invokeTauri } from '@/services/tauri-bridge';
|
||||
@@ -52,12 +53,14 @@ const MASKED_SENTINEL = '__WM_MASKED__';
|
||||
interface RuntimeConfigPanelOptions {
|
||||
mode?: 'full' | 'alert';
|
||||
buffered?: boolean;
|
||||
featureFilter?: RuntimeFeatureId[];
|
||||
}
|
||||
|
||||
export class RuntimeConfigPanel extends Panel {
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private readonly mode: 'full' | 'alert';
|
||||
private readonly buffered: boolean;
|
||||
private readonly featureFilter?: RuntimeFeatureId[];
|
||||
private pendingSecrets = new Map<RuntimeSecretKey, string>();
|
||||
private validatedKeys = new Map<RuntimeSecretKey, boolean>();
|
||||
private validationMessages = new Map<RuntimeSecretKey, string>();
|
||||
@@ -66,6 +69,7 @@ export class RuntimeConfigPanel extends Panel {
|
||||
super({ id: 'runtime-config', title: t('modals.runtimeConfig.title'), showCount: false });
|
||||
this.mode = options.mode ?? (isDesktopRuntime() ? 'alert' : 'full');
|
||||
this.buffered = options.buffered ?? false;
|
||||
this.featureFilter = options.featureFilter;
|
||||
this.unsubscribe = subscribeRuntimeConfig(() => this.render());
|
||||
this.render();
|
||||
}
|
||||
@@ -94,6 +98,29 @@ export class RuntimeConfigPanel extends Panel {
|
||||
return this.pendingSecrets.size > 0;
|
||||
}
|
||||
|
||||
private getFilteredFeatures(): RuntimeFeatureDefinition[] {
|
||||
return this.featureFilter
|
||||
? RUNTIME_FEATURES.filter(f => this.featureFilter!.includes(f.id))
|
||||
: RUNTIME_FEATURES;
|
||||
}
|
||||
|
||||
/** Returns missing required secrets for enabled features that have at least one pending key. */
|
||||
public getMissingRequiredSecrets(): string[] {
|
||||
const missing: string[] = [];
|
||||
for (const feature of this.getFilteredFeatures()) {
|
||||
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;
|
||||
}
|
||||
|
||||
public getValidationErrors(): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const [key, value] of this.pendingSecrets) {
|
||||
@@ -108,22 +135,40 @@ export class RuntimeConfigPanel extends Panel {
|
||||
const errors: string[] = [];
|
||||
const context = Object.fromEntries(this.pendingSecrets.entries()) as Partial<Record<RuntimeSecretKey, string>>;
|
||||
|
||||
// Split into local-only failures vs keys needing remote verification
|
||||
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'}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const verifyResult = await verifySecretWithApi(key, value, context);
|
||||
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);
|
||||
toVerifyRemotely.push([key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all remote verifications in parallel with a 15s global timeout
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +198,14 @@ export class RuntimeConfigPanel extends Panel {
|
||||
this.validationMessages.set(key, result.hint || 'Invalid format');
|
||||
}
|
||||
});
|
||||
// Capture model from select or manual input
|
||||
const modelSelect = this.content.querySelector<HTMLSelectElement>('select[data-model-select]');
|
||||
const modelManual = this.content.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);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): void {
|
||||
@@ -160,6 +213,8 @@ export class RuntimeConfigPanel extends Panel {
|
||||
const snapshot = getRuntimeConfigSnapshot();
|
||||
const desktop = isDesktopRuntime();
|
||||
|
||||
const features = this.getFilteredFeatures();
|
||||
|
||||
if (desktop && this.mode === 'alert') {
|
||||
const totalFeatures = RUNTIME_FEATURES.length;
|
||||
const availableFeatures = RUNTIME_FEATURES.filter((feature) => isFeatureAvailable(feature.id)).length;
|
||||
@@ -194,10 +249,10 @@ export class RuntimeConfigPanel extends Panel {
|
||||
|
||||
this.content.innerHTML = `
|
||||
<div class="runtime-config-summary">
|
||||
${desktop ? t('modals.runtimeConfig.summary.desktop') : t('modals.runtimeConfig.summary.web')} · ${Object.keys(snapshot.secrets).length} ${t('modals.runtimeConfig.summary.secrets')} · ${RUNTIME_FEATURES.filter(f => isFeatureAvailable(f.id)).length}/${RUNTIME_FEATURES.length} ${t('modals.runtimeConfig.summary.available')}
|
||||
${desktop ? t('modals.runtimeConfig.summary.desktop') : t('modals.runtimeConfig.summary.web')} · ${features.filter(f => isFeatureAvailable(f.id)).length}/${features.length} ${t('modals.runtimeConfig.summary.available')}
|
||||
</div>
|
||||
<div class="runtime-config-list">
|
||||
${RUNTIME_FEATURES.map(feature => this.renderFeature(feature)).join('')}
|
||||
${features.map(feature => this.renderFeature(feature)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -238,7 +293,7 @@ export class RuntimeConfigPanel extends Panel {
|
||||
const pendingValid = pending ? this.validatedKeys.get(key) : undefined;
|
||||
const status = pending
|
||||
? (pendingValid === false ? t('modals.runtimeConfig.status.invalid') : t('modals.runtimeConfig.status.staged'))
|
||||
: !state.present ? t('modals.runtimeConfig.status.missing') : state.valid ? `${t('modals.runtimeConfig.status.valid')} (${state.source})` : t('modals.runtimeConfig.status.looksInvalid');
|
||||
: !state.present ? t('modals.runtimeConfig.status.missing') : state.valid ? t('modals.runtimeConfig.status.valid') : t('modals.runtimeConfig.status.looksInvalid');
|
||||
const statusClass = pending
|
||||
? (pendingValid === false ? 'warn' : 'staged')
|
||||
: state.valid ? 'ok' : 'warn';
|
||||
@@ -256,13 +311,32 @@ export class RuntimeConfigPanel extends Panel {
|
||||
? (this.validationMessages.get(key) || validateSecret(key, this.pendingSecrets.get(key) || '').hint || 'Invalid value')
|
||||
: null;
|
||||
|
||||
if (key === 'OLLAMA_MODEL') {
|
||||
const storedModel = pending
|
||||
? this.pendingSecrets.get(key) || ''
|
||||
: getRuntimeConfigSnapshot().secrets[key]?.value || '';
|
||||
return `
|
||||
<div class="runtime-secret-row">
|
||||
<div class="runtime-secret-key"><code>${escapeHtml(key)}</code></div>
|
||||
<span class="runtime-secret-status ${statusClass}">${escapeHtml(status)}</span>
|
||||
<span class="runtime-secret-check ${checkClass}">✓</span>
|
||||
${helpText ? `<div class="runtime-secret-meta">${escapeHtml(helpText)}</div>` : ''}
|
||||
<select data-model-select class="${inputClass}" ${isDesktopRuntime() ? '' : 'disabled'}>
|
||||
${storedModel ? `<option value="${escapeHtml(storedModel)}" selected>${escapeHtml(storedModel)}</option>` : '<option value="" selected disabled>Loading models...</option>'}
|
||||
</select>
|
||||
<input type="text" data-model-manual class="${inputClass} hidden-input" placeholder="Or type model name" autocomplete="off" ${isDesktopRuntime() ? '' : 'disabled'} ${storedModel ? `value="${escapeHtml(storedModel)}"` : ''}>
|
||||
${hintText ? `<span class="runtime-secret-hint">${escapeHtml(hintText)}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="runtime-secret-row">
|
||||
<div class="runtime-secret-key"><code>${escapeHtml(key)}</code>${linkHtml}</div>
|
||||
<span class="runtime-secret-status ${statusClass}">${escapeHtml(status)}</span>
|
||||
<span class="runtime-secret-check ${checkClass}">✓</span>
|
||||
${helpText ? `<div class="runtime-secret-meta">${escapeHtml(helpText)}</div>` : ''}
|
||||
<input type="${PLAINTEXT_KEYS.has(key) ? 'text' : 'password'}" data-secret="${key}" placeholder="${pending ? t('modals.runtimeConfig.placeholder.staged') : t('modals.runtimeConfig.placeholder.setSecret')}" autocomplete="off" ${isDesktopRuntime() ? '' : 'disabled'} class="${inputClass}" ${pending ? `value="${PLAINTEXT_KEYS.has(key) ? escapeHtml(this.pendingSecrets.get(key) || '') : MASKED_SENTINEL}"` : ''}>
|
||||
<input type="${PLAINTEXT_KEYS.has(key) ? 'text' : 'password'}" data-secret="${key}" placeholder="${pending ? t('modals.runtimeConfig.placeholder.staged') : t('modals.runtimeConfig.placeholder.setSecret')}" autocomplete="off" ${isDesktopRuntime() ? '' : 'disabled'} class="${inputClass}" ${pending ? `value="${PLAINTEXT_KEYS.has(key) ? escapeHtml(this.pendingSecrets.get(key) || '') : MASKED_SENTINEL}"` : (PLAINTEXT_KEYS.has(key) && state.present ? `value="${escapeHtml(getRuntimeConfigSnapshot().secrets[key]?.value || '')}"` : '')}>
|
||||
${hintText ? `<span class="runtime-secret-hint">${escapeHtml(hintText)}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
@@ -293,6 +367,22 @@ export class RuntimeConfigPanel extends Panel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ollama model dropdown: fetch models and handle selection
|
||||
const modelSelect = this.content.querySelector<HTMLSelectElement>('select[data-model-select]');
|
||||
if (modelSelect) {
|
||||
modelSelect.addEventListener('change', () => {
|
||||
const model = modelSelect.value;
|
||||
if (model && this.buffered) {
|
||||
this.pendingSecrets.set('OLLAMA_MODEL', model);
|
||||
this.validatedKeys.set('OLLAMA_MODEL', true);
|
||||
modelSelect.classList.remove('invalid');
|
||||
modelSelect.classList.add('valid-staged');
|
||||
this.updateFeatureCardStatus('OLLAMA_MODEL');
|
||||
}
|
||||
});
|
||||
void this.fetchOllamaModels(modelSelect);
|
||||
}
|
||||
|
||||
this.content.querySelectorAll<HTMLInputElement>('input[data-toggle]').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
const featureId = input.dataset.toggle as RuntimeFeatureDefinition['id'] | undefined;
|
||||
@@ -366,6 +456,7 @@ export class RuntimeConfigPanel extends Panel {
|
||||
row?.appendChild(hint);
|
||||
}
|
||||
}
|
||||
this.updateFeatureCardStatus(key);
|
||||
} else {
|
||||
void setSecretValue(key, raw);
|
||||
input.value = '';
|
||||
@@ -373,4 +464,119 @@ export class RuntimeConfigPanel extends Panel {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateFeatureCardStatus(secretKey: RuntimeSecretKey): void {
|
||||
const feature = RUNTIME_FEATURES.find(f => getEffectiveSecrets(f).includes(secretKey));
|
||||
if (!feature) return;
|
||||
const section = Array.from(this.content.querySelectorAll('.runtime-feature')).find(el => {
|
||||
const toggle = el.querySelector<HTMLInputElement>(`input[data-toggle="${feature.id}"]`);
|
||||
return !!toggle;
|
||||
});
|
||||
if (!section) return;
|
||||
const available = isFeatureAvailable(feature.id);
|
||||
const effectiveSecrets = getEffectiveSecrets(feature);
|
||||
const allStaged = !available && effectiveSecrets.every(
|
||||
(k) => getSecretState(k).valid || (this.pendingSecrets.has(k) && this.validatedKeys.get(k) !== false)
|
||||
);
|
||||
section.className = `runtime-feature ${available ? 'available' : allStaged ? 'staged' : 'degraded'}`;
|
||||
const pill = section.querySelector('.runtime-pill');
|
||||
if (pill) {
|
||||
pill.className = `runtime-pill ${available ? 'ok' : allStaged ? 'staged' : 'warn'}`;
|
||||
pill.textContent = available ? t('modals.runtimeConfig.status.ready') : allStaged ? t('modals.runtimeConfig.status.staged') : t('modals.runtimeConfig.status.needsKeys');
|
||||
}
|
||||
const fallback = section.querySelector('.runtime-feature-fallback');
|
||||
if (available || allStaged) {
|
||||
fallback?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private static makeTimeout(ms: number): AbortSignal {
|
||||
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms);
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), ms);
|
||||
return ctrl.signal;
|
||||
}
|
||||
|
||||
private showManualModelInput(select: HTMLSelectElement): void {
|
||||
const manual = select.parentElement?.querySelector<HTMLInputElement>('input[data-model-manual]');
|
||||
if (!manual) return;
|
||||
select.style.display = 'none';
|
||||
manual.classList.remove('hidden-input');
|
||||
manual.addEventListener('blur', () => {
|
||||
const model = manual.value.trim();
|
||||
if (model && this.buffered) {
|
||||
this.pendingSecrets.set('OLLAMA_MODEL', model);
|
||||
this.validatedKeys.set('OLLAMA_MODEL', true);
|
||||
manual.classList.remove('invalid');
|
||||
manual.classList.add('valid-staged');
|
||||
this.updateFeatureCardStatus('OLLAMA_MODEL');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchOllamaModels(select: HTMLSelectElement): Promise<void> {
|
||||
const snapshot = getRuntimeConfigSnapshot();
|
||||
const ollamaUrl = this.pendingSecrets.get('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 = this.pendingSecrets.get('OLLAMA_MODEL')
|
||||
|| snapshot.secrets['OLLAMA_MODEL']?.value
|
||||
|| '';
|
||||
|
||||
try {
|
||||
// Try Ollama-native /api/tags first, fall back to OpenAI-compatible /v1/models
|
||||
let models: string[] = [];
|
||||
try {
|
||||
const res = await fetch(new URL('/api/tags', ollamaUrl).toString(), {
|
||||
signal: RuntimeConfigPanel.makeTimeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { models?: Array<{ name: string }> };
|
||||
models = (data.models?.map(m => m.name) || []).filter(n => !n.includes('embed'));
|
||||
}
|
||||
} catch { /* Ollama endpoint not available, try OpenAI format */ }
|
||||
|
||||
if (models.length === 0) {
|
||||
try {
|
||||
const res = await fetch(new URL('/v1/models', ollamaUrl).toString(), {
|
||||
signal: RuntimeConfigPanel.makeTimeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { data?: Array<{ id: string }> };
|
||||
models = (data.data?.map(m => m.id) || []).filter(n => !n.includes('embed'));
|
||||
}
|
||||
} catch { /* OpenAI endpoint also unavailable */ }
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
// No models discovered — show manual text input as fallback
|
||||
this.showManualModelInput(select);
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = models.map(name =>
|
||||
`<option value="${escapeHtml(name)}" ${name === currentModel ? 'selected' : ''}>${escapeHtml(name)}</option>`
|
||||
).join('');
|
||||
|
||||
// Auto-select first model if none stored
|
||||
if (!currentModel && models.length > 0) {
|
||||
const first = models[0]!;
|
||||
select.value = first;
|
||||
if (this.buffered) {
|
||||
this.pendingSecrets.set('OLLAMA_MODEL', first);
|
||||
this.validatedKeys.set('OLLAMA_MODEL', true);
|
||||
select.classList.add('valid-staged');
|
||||
this.updateFeatureCardStatus('OLLAMA_MODEL');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Complete failure — fall back to manual input
|
||||
this.showManualModelInput(select);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove('no-transition');
|
||||
});
|
||||
|
||||
// Clear stale settings-open flag (survives ungraceful shutdown)
|
||||
localStorage.removeItem('wm-settings-open');
|
||||
|
||||
const app = new App('app');
|
||||
app
|
||||
.init()
|
||||
|
||||
@@ -86,7 +86,7 @@ export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
|
||||
id: 'aiOllama',
|
||||
name: 'Ollama local summarization',
|
||||
description: 'Local LLM provider via OpenAI-compatible endpoint (Ollama or LM Studio, desktop-first).',
|
||||
requiredSecrets: ['OLLAMA_API_URL'],
|
||||
requiredSecrets: ['OLLAMA_API_URL', 'OLLAMA_MODEL'],
|
||||
fallback: 'Falls back to Groq, then OpenRouter, then local browser model.',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './styles/main.css';
|
||||
import './styles/settings-window.css';
|
||||
import { RuntimeConfigPanel } from '@/components/RuntimeConfigPanel';
|
||||
import { loadDesktopSecrets } from '@/services/runtime-config';
|
||||
import { RUNTIME_FEATURES, loadDesktopSecrets } from '@/services/runtime-config';
|
||||
import { tryInvokeTauri } from '@/services/tauri-bridge';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { initI18n, t } from '@/services/i18n';
|
||||
@@ -63,41 +63,60 @@ 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(); // Initialize i18n first
|
||||
await initI18n();
|
||||
applyStoredTheme();
|
||||
|
||||
// Remove no-transition class after first paint to enable smooth theme transitions
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove('no-transition');
|
||||
});
|
||||
await loadDesktopSecrets();
|
||||
|
||||
const mount = document.getElementById('settingsApp');
|
||||
if (!mount) return;
|
||||
const llmMount = document.getElementById('llmApp');
|
||||
const apiMount = document.getElementById('apiKeysApp');
|
||||
if (!llmMount || !apiMount) return;
|
||||
|
||||
const panel = new RuntimeConfigPanel({ mode: 'full', buffered: true });
|
||||
const panelElement = panel.getElement();
|
||||
panelElement.classList.remove('resized', 'span-2', 'span-3', 'span-4');
|
||||
panelElement.classList.add('settings-runtime-panel');
|
||||
mount.appendChild(panelElement);
|
||||
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),
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => panel.destroy());
|
||||
mountPanel(llmPanel, llmMount);
|
||||
mountPanel(apiPanel, apiMount);
|
||||
|
||||
const panels = [llmPanel, apiPanel];
|
||||
|
||||
window.addEventListener('beforeunload', () => panels.forEach(p => p.destroy()));
|
||||
|
||||
document.getElementById('okBtn')?.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (!panel.hasPendingChanges()) {
|
||||
if (!panels.some(p => p.hasPendingChanges())) {
|
||||
closeSettingsWindow();
|
||||
return;
|
||||
}
|
||||
setActionStatus(t('modals.settingsWindow.validating'), 'ok');
|
||||
const errors = await panel.verifyPendingSecrets();
|
||||
console.log('[settings] verify done, errors:', errors.length, errors);
|
||||
await panel.commitVerifiedSecrets();
|
||||
console.log('[settings] commit done, remaining pending:', panel.hasPendingChanges());
|
||||
if (errors.length > 0) {
|
||||
setActionStatus(t('modals.settingsWindow.verifyFailed', { errors: errors.join(', ') }), 'error');
|
||||
const missingRequired = panels.flatMap(p => p.getMissingRequiredSecrets());
|
||||
if (missingRequired.length > 0) {
|
||||
setActionStatus(`Missing required: ${missingRequired.join(', ')}`, 'error');
|
||||
return;
|
||||
}
|
||||
const allErrors = (await Promise.all(panels.map(p => p.verifyPendingSecrets()))).flat();
|
||||
await Promise.all(panels.map(p => p.commitVerifiedSecrets()));
|
||||
if (allErrors.length > 0) {
|
||||
setActionStatus(t('modals.settingsWindow.verifyFailed', { errors: allErrors.join(', ') }), 'error');
|
||||
} else {
|
||||
setActionStatus(t('modals.settingsWindow.saved'), 'ok');
|
||||
closeSettingsWindow();
|
||||
@@ -109,18 +128,15 @@ async function initSettingsWindow(): Promise<void> {
|
||||
})();
|
||||
});
|
||||
|
||||
// Cancel: discard pending, close
|
||||
document.getElementById('cancelBtn')?.addEventListener('click', () => {
|
||||
closeSettingsWindow();
|
||||
});
|
||||
|
||||
const openLogsBtn = document.getElementById('openLogsBtn');
|
||||
openLogsBtn?.addEventListener('click', () => {
|
||||
document.getElementById('openLogsBtn')?.addEventListener('click', () => {
|
||||
void invokeDesktopAction('open_logs_folder', t('modals.settingsWindow.openLogs'));
|
||||
});
|
||||
|
||||
const openSidecarLogBtn = document.getElementById('openSidecarLogBtn');
|
||||
openSidecarLogBtn?.addEventListener('click', () => {
|
||||
document.getElementById('openSidecarLogBtn')?.addEventListener('click', () => {
|
||||
void invokeDesktopAction('open_sidecar_log_file', t('modals.settingsWindow.openApiLog'));
|
||||
});
|
||||
|
||||
@@ -221,7 +237,8 @@ function initDiagnostics(): void {
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
void initSettingsWindow().finally(() => {
|
||||
void tryInvokeTauri<void>('plugin:window|show', { label: 'settings' });
|
||||
void tryInvokeTauri<void>('plugin:window|set_focus', { label: 'settings' });
|
||||
});
|
||||
// Signal main window that settings is open (suppresses alert popups)
|
||||
localStorage.setItem('wm-settings-open', '1');
|
||||
window.addEventListener('beforeunload', () => localStorage.removeItem('wm-settings-open'));
|
||||
|
||||
void initSettingsWindow();
|
||||
|
||||
@@ -13374,7 +13374,8 @@ body.has-critical-banner .panels-grid {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.runtime-secret-row input {
|
||||
.runtime-secret-row input,
|
||||
.runtime-secret-row select {
|
||||
grid-area: input;
|
||||
width: 100%;
|
||||
background: var(--overlay-medium);
|
||||
@@ -13385,6 +13386,10 @@ body.has-critical-banner .panels-grid {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.runtime-secret-row input.hidden-input {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.runtime-secret-check {
|
||||
grid-area: check;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
/* Native-style settings window */
|
||||
/* Palantir-style settings window — dark, readable, color-coded */
|
||||
.settings-shell {
|
||||
--settings-bg: var(--bg-secondary);
|
||||
--settings-surface: var(--surface-hover);
|
||||
--settings-surface-inset: var(--surface);
|
||||
--settings-border: var(--overlay-medium);
|
||||
--settings-border-strong: var(--overlay-heavy);
|
||||
--settings-text: var(--text);
|
||||
--settings-text-secondary: var(--text-dim);
|
||||
--settings-text: #e8eaed;
|
||||
--settings-text-secondary: #9aa0a6;
|
||||
--settings-accent: var(--semantic-info);
|
||||
--settings-green: var(--status-live);
|
||||
--settings-yellow: var(--yellow);
|
||||
--settings-green: #34d399;
|
||||
--settings-yellow: #fbbf24;
|
||||
--settings-red: var(--semantic-critical);
|
||||
--settings-blue: #60a5fa;
|
||||
|
||||
height: 100vh;
|
||||
background: var(--settings-bg);
|
||||
color: var(--settings-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Tab bar — macOS segmented control style */
|
||||
/* ── Tab bar ── */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 12px 16px 0;
|
||||
padding: 14px 20px 0;
|
||||
background: var(--settings-bg);
|
||||
border-bottom: 1px solid var(--settings-border);
|
||||
}
|
||||
@@ -41,12 +43,13 @@
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: var(--settings-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 7px 20px;
|
||||
padding: 9px 24px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
margin-bottom: -1px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
@@ -57,16 +60,17 @@
|
||||
.settings-tab.active {
|
||||
color: var(--settings-text);
|
||||
background: var(--settings-surface);
|
||||
border-bottom-color: var(--settings-surface);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--settings-accent);
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
/* ── Status bar ── */
|
||||
.settings-action-status {
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
min-height: 20px;
|
||||
line-height: 20px;
|
||||
font-size: 11px;
|
||||
padding: 0 24px;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--settings-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -76,12 +80,31 @@
|
||||
.settings-action-status.ok { color: var(--settings-green); }
|
||||
.settings-action-status.error { color: var(--settings-red); }
|
||||
|
||||
/* Tab panels */
|
||||
/* ── Tab panels ── */
|
||||
.settings-tab-panels {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 20px;
|
||||
padding: 16px 24px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.12) transparent;
|
||||
}
|
||||
|
||||
.settings-tab-panels::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.settings-tab-panels::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-tab-panels::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.settings-tab-panels::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.settings-tab-panel {
|
||||
@@ -96,7 +119,7 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Runtime config panel overrides for settings window */
|
||||
/* ── Runtime config panel overrides ── */
|
||||
.settings-runtime-panel {
|
||||
cursor: default;
|
||||
min-height: 0;
|
||||
@@ -123,68 +146,91 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Summary bar ── */
|
||||
.settings-runtime-panel .runtime-config-summary {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--settings-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--settings-surface);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--settings-accent);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Feature card grid ── */
|
||||
.settings-runtime-panel .runtime-config-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Feature cards ── */
|
||||
.settings-runtime-panel .runtime-feature {
|
||||
margin: 0;
|
||||
background: var(--settings-surface);
|
||||
border: 1px solid var(--settings-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
padding: 14px 16px;
|
||||
border-left: 3px solid var(--settings-border);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-feature.available {
|
||||
border-color: var(--settings-border);
|
||||
border-left-color: var(--settings-green);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-feature.staged {
|
||||
border-left-color: var(--settings-blue);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-feature.degraded {
|
||||
border-left-color: var(--settings-yellow);
|
||||
}
|
||||
|
||||
/* ── Feature header labels ── */
|
||||
.settings-runtime-panel .runtime-feature-header label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--settings-text);
|
||||
}
|
||||
|
||||
/* ── Status pills ── */
|
||||
.settings-runtime-panel .runtime-pill {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-pill.ok {
|
||||
color: var(--settings-green);
|
||||
border-color: color-mix(in srgb, var(--settings-green) 30%, transparent);
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
border-color: rgba(52, 211, 153, 0.25);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-pill.warn {
|
||||
color: var(--settings-yellow);
|
||||
border-color: color-mix(in srgb, var(--settings-yellow) 30%, transparent);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border-color: rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-pill.staged {
|
||||
color: var(--settings-green);
|
||||
border-color: color-mix(in srgb, var(--settings-green) 30%, transparent);
|
||||
opacity: 0.7;
|
||||
color: var(--settings-blue);
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
border-color: rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
/* ── Secret row inputs ── */
|
||||
.settings-runtime-panel .runtime-secret-row input {
|
||||
background: var(--settings-surface-inset);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--settings-border-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--settings-text);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
@@ -192,28 +238,75 @@
|
||||
.settings-runtime-panel .runtime-secret-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--settings-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--settings-accent) 25%, transparent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-row input::placeholder {
|
||||
color: var(--text-ghost);
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-row select[data-model-select] {
|
||||
grid-area: input;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--settings-border-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--settings-text);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%239aa0a6'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
/* Hide manual model input until activated */
|
||||
.settings-runtime-panel .runtime-secret-row input[data-model-manual].hidden-input {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-row select[data-model-select]:focus {
|
||||
outline: none;
|
||||
border-color: var(--settings-accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-row select[data-model-select].valid-staged {
|
||||
border-color: rgba(52, 211, 153, 0.5);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-row select[data-model-select] option {
|
||||
background: #1e1e2e;
|
||||
color: var(--settings-text);
|
||||
}
|
||||
|
||||
/* ── Secret meta & status ── */
|
||||
.settings-runtime-panel .runtime-secret-meta {
|
||||
color: var(--text-faint);
|
||||
color: var(--settings-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-status.ok {
|
||||
color: var(--settings-green);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-status.warn {
|
||||
color: var(--settings-yellow);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-status.staged {
|
||||
color: var(--settings-green);
|
||||
opacity: 0.7;
|
||||
color: var(--settings-blue);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-feature-fallback.fallback {
|
||||
@@ -224,10 +317,10 @@
|
||||
accent-color: var(--settings-accent);
|
||||
}
|
||||
|
||||
/* Validation feedback */
|
||||
/* ── Validation feedback ── */
|
||||
.runtime-secret-check {
|
||||
color: var(--settings-green);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
margin-left: 6px;
|
||||
display: none;
|
||||
}
|
||||
@@ -238,29 +331,29 @@
|
||||
|
||||
.runtime-secret-hint {
|
||||
color: var(--settings-red);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-row input.valid-staged {
|
||||
border-color: color-mix(in srgb, var(--settings-green) 50%, transparent);
|
||||
border-color: rgba(52, 211, 153, 0.5);
|
||||
}
|
||||
|
||||
.settings-runtime-panel .runtime-secret-row input.invalid {
|
||||
border-color: var(--settings-red);
|
||||
}
|
||||
|
||||
/* Signup link icon */
|
||||
/* ── Signup link icon ── */
|
||||
.runtime-secret-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
grid-area: key;
|
||||
}
|
||||
|
||||
.runtime-secret-link {
|
||||
color: var(--settings-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
@@ -271,13 +364,13 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Footer with OK / Cancel */
|
||||
/* ── Footer ── */
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
gap: 10px;
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid var(--settings-border);
|
||||
background: var(--settings-bg);
|
||||
flex-shrink: 0;
|
||||
@@ -286,41 +379,48 @@
|
||||
.settings-btn {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 20px;
|
||||
font-weight: 600;
|
||||
padding: 8px 24px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
min-width: 70px;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.settings-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.settings-btn-secondary {
|
||||
background: var(--settings-surface);
|
||||
background: transparent;
|
||||
border: 1px solid var(--settings-border-strong);
|
||||
color: var(--settings-text);
|
||||
color: var(--settings-text-secondary);
|
||||
}
|
||||
|
||||
.settings-btn-secondary:hover {
|
||||
background: var(--overlay-medium);
|
||||
background: var(--overlay-subtle);
|
||||
color: var(--settings-text);
|
||||
border-color: var(--settings-text-secondary);
|
||||
}
|
||||
|
||||
.settings-btn-primary {
|
||||
background: var(--settings-accent);
|
||||
border: 1px solid var(--settings-accent);
|
||||
color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-btn-primary:hover {
|
||||
background: color-mix(in srgb, var(--settings-accent) 85%, black);
|
||||
border-color: color-mix(in srgb, var(--settings-accent) 85%, black);
|
||||
background: color-mix(in srgb, var(--settings-accent) 85%, white);
|
||||
border-color: color-mix(in srgb, var(--settings-accent) 85%, white);
|
||||
}
|
||||
|
||||
/* Debug tab */
|
||||
/* ── Debug tab ── */
|
||||
.debug-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.debug-actions button {
|
||||
@@ -328,8 +428,8 @@
|
||||
background: var(--settings-surface);
|
||||
color: var(--settings-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
@@ -339,37 +439,38 @@
|
||||
background: var(--overlay-medium);
|
||||
}
|
||||
|
||||
/* Diagnostics */
|
||||
/* ── Diagnostics ── */
|
||||
.settings-diagnostics {
|
||||
border: 1px solid var(--settings-border);
|
||||
background: var(--settings-surface);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.diag-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.diag-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--settings-text);
|
||||
}
|
||||
|
||||
.diag-toggles {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.diag-toggles label {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--settings-text-secondary);
|
||||
}
|
||||
@@ -382,13 +483,13 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.diag-traffic-bar h3 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--settings-text-secondary);
|
||||
}
|
||||
|
||||
@@ -398,15 +499,15 @@
|
||||
|
||||
.diag-traffic-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.diag-traffic-controls label {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
color: var(--settings-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -416,8 +517,8 @@
|
||||
background: var(--settings-surface-inset);
|
||||
color: var(--settings-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
@@ -431,37 +532,55 @@
|
||||
.diag-traffic-log {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.12) transparent;
|
||||
}
|
||||
|
||||
.diag-traffic-log::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.diag-traffic-log::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.diag-traffic-log::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.diag-empty {
|
||||
color: var(--settings-text-secondary);
|
||||
font-style: italic;
|
||||
margin: 10px 0;
|
||||
font-size: 12px;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Diagnostics table ── */
|
||||
.diag-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diag-table th {
|
||||
text-align: left;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--settings-border);
|
||||
color: var(--settings-text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--settings-border-strong);
|
||||
color: var(--settings-text);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--settings-surface);
|
||||
}
|
||||
|
||||
.diag-table td {
|
||||
padding: 3px 8px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--overlay-subtle);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user