From ae740ef5842d685d5fe76a31dd186c64368c4262 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 7 Aug 2026 02:40:05 +0800 Subject: [PATCH 1/3] fix(config): recover startup from incompatible model settings - Make model normalization and validation capability-aware so speech, image, and embedding models do not inherit text-generation constraints. - Prevent pure speech model sentinels such as context_window=0 and max_tokens=0 from aborting application startup. - Isolate recoverable invalid models, reconcile model references, and preserve structured diagnostics. - Add schema versioning, pre-repair backups, default recovery for malformed configuration, and strict atomic persistence. - Save cloud speech model, speech default, and voice-input settings in one atomic operation. - Expose the new configuration APIs across Desktop, App Server, WebSocket, and Web UI. - Add the reusable bitfun-config-loader and focused regression tests. --- Cargo.lock | 1 + scripts/check-core-boundaries.test.mjs | 9 + .../cargo-dependency-boundaries.mjs | 1 + .../core-boundaries/rules/feature-rules.mjs | 18 +- .../rules/source/required-rules.mjs | 4 +- scripts/core-boundaries/self-test.mjs | 4 +- src/apps/desktop/src/api/config_api.rs | 26 + .../src/api/remote_workspace_policy.rs | 4 + src/apps/desktop/src/lib.rs | 42 ++ src/crates/assembly/core/Cargo.toml | 2 +- .../core/src/service/config/manager.rs | 324 ++++++---- .../assembly/core/src/service/config/mod.rs | 6 + .../core/src/service/config/normalization.rs | 514 ++++++++++++++++ .../core/src/service/config/providers.rs | 242 +++++++- .../core/src/service/config/service.rs | 568 +++++++++++------- .../assembly/core/src/service/config/types.rs | 105 +++- .../assembly/core/src/util/types/config.rs | 22 +- .../interfaces/app-server-client/Cargo.toml | 1 + .../interfaces/app-server-client/src/lib.rs | 24 + .../app-server-protocol/src/config.rs | 72 +++ .../interfaces/app-server-protocol/src/lib.rs | 1 + .../app-server/src/schema/config.rs | 5 + .../app-server/src/server/handlers/app.rs | 2 + .../app-server/src/server/handlers/config.rs | 44 ++ src/crates/services/services-core/AGENTS.md | 4 +- src/crates/services/services-core/Cargo.toml | 8 + src/crates/services/services-core/src/lib.rs | 2 +- src/web-ui/src/app/App.tsx | 48 ++ .../api/adapters/websocket-adapter.test.ts | 9 +- .../api/adapters/websocket-adapter.ts | 2 + .../api/service-api/ConfigAPI.test.ts | 24 + .../api/service-api/ConfigAPI.ts | 28 + .../config/components/VoiceInputConfig.tsx | 53 +- .../config/services/ConfigManager.ts | 21 +- .../src/infrastructure/config/types/index.ts | 9 + .../src/locales/en-US/settings/basics.json | 4 + .../src/locales/zh-CN/settings/basics.json | 4 + .../src/locales/zh-TW/settings/basics.json | 4 + 38 files changed, 1830 insertions(+), 431 deletions(-) create mode 100644 src/crates/assembly/core/src/service/config/normalization.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 873fa7956f..8a9022b5fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -926,6 +926,7 @@ dependencies = [ "agent-client-protocol", "anyhow", "bitfun-app-server-protocol", + "serde_json", "tokio", ] diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 0a8d2f133e..62c59a7689 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -1853,6 +1853,14 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:sha2', 'tokio/fs', ]); + assert.deepEqual(profiles.get('json-io'), [ + 'dep:fs2', + 'dep:windows', + 'tokio/fs', + 'tokio/sync', + 'windows/Win32_Foundation', + 'windows/Win32_Storage_FileSystem', + ]); assert.deepEqual(profiles.get('local-storage'), [ 'dep:bitfun-core-types', 'dep:bitfun-events', @@ -1967,6 +1975,7 @@ test('services-core Tokio capabilities stay owner-scoped', () => { ], features: { filesystem: [], + 'json-io': [], 'local-storage': [], 'process-runtime': [], 'workspace-instructions': [], diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 02d67bf762..0c00d7ca84 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -147,6 +147,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ const SERVICES_CORE_TOKIO_FEATURES = new Map([ ['filesystem', ['fs']], + ['json-io', ['fs', 'sync']], ['local-storage', ['fs', 'sync']], ['process-runtime', ['io-util', 'process']], ['workspace-instructions', ['fs', 'io-util']], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index c6ba62c96a..1e0cdc078d 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -29,7 +29,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-runtime-ports', ownerFeatures: ['permission', 'workspace-runtime'] }, { depName: 'chrono', ownerFeatures: ['filesystem', 'local-storage'] }, { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-identity', 'workspace-runtime'] }, - { depName: 'fs2', ownerFeatures: ['local-storage', 'runtime-ownership'] }, + { depName: 'fs2', ownerFeatures: ['json-io', 'local-storage', 'runtime-ownership'] }, { depName: 'git2', ownerFeatures: ['session-git'] }, { depName: 'globset', ownerFeatures: ['workspace-instructions'] }, { depName: 'ignore', ownerFeatures: ['filesystem'] }, @@ -49,7 +49,7 @@ export const optionalDependencyFeatureOwnerRules = [ }, { depName: 'which', ownerFeatures: ['process-runtime'] }, { depName: 'win32job', ownerFeatures: ['process-runtime'] }, - { depName: 'windows', ownerFeatures: ['local-storage', 'process-runtime'] }, + { depName: 'windows', ownerFeatures: ['json-io', 'local-storage', 'process-runtime'] }, { depName: 'zip', ownerFeatures: ['lsp'] }, ], }, @@ -396,6 +396,20 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core filesystem must own only local file operations and recursive search dependencies', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'json-io', + requiredFeatureRefs: [ + 'dep:fs2', + 'dep:windows', + 'tokio/fs', + 'tokio/sync', + 'windows/Win32_Foundation', + 'windows/Win32_Storage_FileSystem', + ], + exact: true, + reason: 'services-core json-io must own only generic locked and atomic JSON file IO', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'local-storage', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 73447c3e3c..983c01ed1b 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -43,8 +43,8 @@ export const requiredContentRules = [ message: 'missing filesystem capability source gate', }, { - regex: /#\[cfg\(feature = "local-storage"\)\]\s*pub mod json_store;/, - message: 'missing local-storage JSON owner source gate', + regex: /#\[cfg\(any\(feature = "json-io", feature = "local-storage"\)\)\]\s*pub mod json_store;/, + message: 'missing json-io/local-storage JSON owner source gate', }, { regex: /#\[cfg\(feature = "local-storage"\)\]\s*pub mod persistence;/, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 782ac5d1f7..7d9b3d37cf 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -937,7 +937,7 @@ export function runManifestParserSelfTest({ ['bitfun-core-types', ['local-storage', 'lsp']], ['bitfun-events', ['local-storage']], ['chrono', ['filesystem', 'local-storage']], - ['fs2', ['local-storage', 'runtime-ownership']], + ['fs2', ['json-io', 'local-storage', 'runtime-ownership']], ['git2', ['session-git']], ['globset', ['workspace-instructions']], ['ignore', ['filesystem']], @@ -957,7 +957,7 @@ export function runManifestParserSelfTest({ ], ['which', ['process-runtime']], ['win32job', ['process-runtime']], - ['windows', ['local-storage', 'process-runtime']], + ['windows', ['json-io', 'local-storage', 'process-runtime']], ['zip', ['lsp']], ]); for (const [dependencyName, ownerFeatures] of expectedServicesCoreOwners) { diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 8485f94ad3..c2f71aabf8 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -2,6 +2,7 @@ use crate::api::app_state::AppState; use crate::startup_trace::DesktopStartupTrace; +use bitfun_core::service::config::{SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResult}; use bitfun_core::util::errors::BitFunError; use log::{error, info}; use serde::{Deserialize, Serialize}; @@ -224,6 +225,31 @@ pub async fn set_config( result } +#[tauri::command] +pub async fn save_cloud_speech_config( + state: State<'_, AppState>, + request: SaveCloudSpeechConfigRequest, +) -> Result { + match state.config_service.save_cloud_speech_config(request).await { + Ok(result) => { + state.ai_client_factory.invalidate_cache(); + crate::api::remote_connect_api::notify_settings_changed(); + info!( + "Cloud speech configuration saved atomically: model_id={}, created={}", + result.model_id, result.created + ); + Ok(result) + } + Err(error) => { + error!("Failed to save cloud speech configuration: {}", error); + Err(format!( + "Failed to save cloud speech configuration: {}", + error + )) + } + } +} + #[tauri::command] pub async fn reset_config( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 2d772df151..119858ac6d 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1638,6 +1638,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("save_canvas_state", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "save_cloud_speech_config", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "save_git_repo_history", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 912032e0f7..af8fc576fa 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -113,6 +113,34 @@ static MAIN_WINDOW_CLOSE_PENDING_ON_MACOS: AtomicBool = AtomicBool::new(false); const MAIN_WINDOW_CLOSE_REQUESTED_EVENT: &str = "bitfun_main_window_close_requested"; const BROWSER_WEBVIEW_PAGE_LOAD_EVENT: &str = "browser-webview-page-load"; + +#[cfg(target_os = "windows")] +fn show_fatal_startup_error(message: &str) { + use windows::core::PCWSTR; + use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; + + let title = "BitFun startup error" + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let message = message + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + let _ = MessageBoxW( + None, + PCWSTR(message.as_ptr()), + PCWSTR(title.as_ptr()), + MB_OK | MB_ICONERROR, + ); + } +} + +#[cfg(not(target_os = "windows"))] +fn show_fatal_startup_error(message: &str) { + eprintln!("BitFun startup error: {message}"); +} const CRON_DESKTOP_START_FALLBACK_DELAY: Duration = Duration::from_secs(120); pub(crate) const MAIN_WINDOW_DEFAULT_WIDTH: f64 = 1200.0; pub(crate) const MAIN_WINDOW_DEFAULT_HEIGHT: f64 = 800.0; @@ -468,8 +496,21 @@ pub async fn run() { let step_started = Instant::now(); if let Err(e) = bitfun_core::service::config::initialize_global_config().await { log::error!("Failed to initialize global config service: {}", e); + show_fatal_startup_error(&format!( + "BitFun could not initialize its configuration and cannot continue.\n\n{e}\n\nSee early-startup.log for details." + )); return; } + if let Ok(config_service) = bitfun_core::service::config::get_global_config_service().await { + for diagnostic in config_service.load_diagnostics().await { + log::warn!( + "Startup configuration diagnostic: code={}, path={}, recoverability={:?}", + diagnostic.code, + diagnostic.path, + diagnostic.recoverability + ); + } + } startup_timings.record_elapsed("initialize_global_config", step_started); startup_trace.record_elapsed_step("native_pre_tauri", "initialize_global_config", step_started); @@ -1291,6 +1332,7 @@ pub async fn run() { computer_use_request_permissions, computer_use_open_system_settings, set_config, + save_cloud_speech_config, reset_config, export_config, import_config, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 015311b2c7..f17b2ba7d8 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -87,7 +87,7 @@ bitfun-agent-tools = { path = "../../execution/tool-contracts" } bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true } # Core service owner crate -bitfun-services-core = { path = "../../services/services-core", default-features = false } +bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["json-io"] } # Integration service owner crate bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, optional = true } diff --git a/src/crates/assembly/core/src/service/config/manager.rs b/src/crates/assembly/core/src/service/config/manager.rs index 779117b520..f2fb4c5375 100644 --- a/src/crates/assembly/core/src/service/config/manager.rs +++ b/src/crates/assembly/core/src/service/config/manager.rs @@ -2,10 +2,15 @@ //! //! A complete configuration management system based on the Provider mechanism. +use super::normalization::{ + isolate_invalid_ai_models, normalize_config_value, normalize_typed_config, + reconcile_model_references, reject_unsupported_schema, +}; use super::providers::ConfigProviderRegistry; use super::types::*; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::util::errors::*; +use bitfun_services_core::json_store::JsonFileStore; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; @@ -14,9 +19,6 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::fs; -type ConfigMigrationFn = fn(Value) -> BitFunResult; -type ConfigMigration = (&'static str, &'static str, ConfigMigrationFn); - fn invalid_config_error(context: &str, result: &ConfigValidationResult) -> BitFunError { let messages = result .errors @@ -143,12 +145,6 @@ pub(crate) fn strip_removed_model_reasoning_fields(mut config: Value) -> Value { config } -fn normalize_legacy_config_value(config: Value) -> Value { - strip_removed_model_reasoning_fields(normalize_legacy_tool_permissions_config_value( - normalize_legacy_agent_model_defaults_config_value(config), - )) -} - fn config_value_for_persistence(config: &GlobalConfig) -> BitFunResult { let mut value = serde_json::to_value(config) .map_err(|e| BitFunError::config(format!("Failed to serialize config: {}", e)))?; @@ -204,6 +200,8 @@ pub struct ConfigManager { providers: ConfigProviderRegistry, config_file: PathBuf, path_manager: Arc, + backup_count: usize, + load_diagnostics: Vec, } /// Configuration manager settings. @@ -238,6 +236,7 @@ impl ConfigManager { let config_file = path_manager.app_config_file(); let providers = ConfigProviderRegistry::new(); + let backup_count = settings.backup_count; let mut manager = Self { config_dir, @@ -245,6 +244,8 @@ impl ConfigManager { providers, config_file, path_manager, + backup_count, + load_diagnostics: Vec::new(), }; manager.load_or_create_config().await?; @@ -290,12 +291,27 @@ impl ConfigManager { .await .map_err(|e| BitFunError::config(format!("Failed to read config file: {}", e)))?; - let mut config_value: Value = serde_json::from_str(&content).map_err(|e| { - BitFunError::config(format!("Failed to parse config file as JSON: {}", e)) - })?; - let normalized_config_value = normalize_legacy_config_value(config_value.clone()); - let legacy_config_normalized = normalized_config_value != config_value; - config_value = normalized_config_value; + let config_value: Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(error) => { + return self + .activate_default_recovery( + &content, + "invalid-json", + format!("Failed to parse config file as JSON: {error}"), + ) + .await; + } + }; + let normalized = normalize_config_value(config_value); + if let Err(error) = reject_unsupported_schema(&normalized.diagnostics) { + return self + .activate_default_recovery(&content, "unsupported-schema", error.to_string()) + .await; + } + let mut config_value = normalized.value; + let mut load_diagnostics = normalized.diagnostics; + let compatibility_normalized = normalized.changed; let file_version = config_value .get("version") @@ -305,16 +321,12 @@ impl ConfigManager { let current_version = env!("CARGO_PKG_VERSION").to_string(); - let needs_migration = !versions_match(&file_version, ¤t_version); - if needs_migration { + let app_version_changed = !versions_match(&file_version, ¤t_version); + if app_version_changed { info!( - "Config version change detected: {} -> {}", + "Config application version updated: {} -> {}", file_version, current_version ); - config_value = self - .migrate_config_version(&file_version, config_value) - .await?; - if let Some(obj) = config_value.as_object_mut() { obj.insert( "version".to_string(), @@ -325,9 +337,12 @@ impl ConfigManager { match serde_json::from_value::(config_value.clone()) { Ok(mut config) => { - Self::ensure_models_config(&mut config.ai.models); + load_diagnostics.extend(normalize_typed_config(&mut config)); Self::add_default_func_agent_models_config(&mut config.ai.func_agent_models); + load_diagnostics.extend(isolate_invalid_ai_models(&mut config).await?); + load_diagnostics.extend(reconcile_model_references(&mut config).diagnostics); + self.config = config; let validation_result = self.validate_config().await?; @@ -338,14 +353,23 @@ impl ConfigManager { )); } - if needs_migration || legacy_config_normalized { + if compatibility_normalized || !load_diagnostics.is_empty() { + self.backup_raw_config(&content, "startup-normalization") + .await?; + } + if app_version_changed || compatibility_normalized || !load_diagnostics.is_empty() { self.config.version = current_version; self.save_config().await?; - info!("Config normalized and saved"); + info!( + "Config normalized and saved: diagnostics={}", + load_diagnostics.len() + ); } else { debug!("Loaded config from file"); } + self.load_diagnostics = load_diagnostics; + Ok(()) } Err(e) => { @@ -353,15 +377,42 @@ impl ConfigManager { "Config file deserialization failed, starting smart merge: {}", e ); - - self.smart_merge_config_from_value(config_value).await + self.backup_raw_config(&content, "pre-smart-merge").await?; + + match self.smart_merge_config_from_value(config_value).await { + Ok(()) => { + self.load_diagnostics.insert( + 0, + ConfigDiagnostic { + path: "$".to_string(), + message: format!( + "Repaired an incompatible configuration shape after typed deserialization failed: {e}" + ), + code: "CONFIG_SHAPE_REPAIRED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }, + ); + Ok(()) + } + Err(merge_error) => { + self.activate_default_recovery( + &content, + "invalid-shape", + format!( + "Config deserialization and smart merge failed: deserialize={e}; merge={merge_error}" + ), + ) + .await + } + } } } } /// Performs a smart merge from a JSON value. async fn smart_merge_config_from_value(&mut self, user_value: Value) -> BitFunResult<()> { - let user_value = normalize_legacy_config_value(user_value); + let user_value = normalize_config_value(user_value).value; let base_config = self.providers.get_default_config(); let base_value = serde_json::to_value(&base_config).map_err(|e| { @@ -373,8 +424,10 @@ impl ConfigManager { BitFunError::config(format!("Failed to deserialize merged config: {}", e)) })?; - Self::ensure_models_config(&mut config.ai.models); + let mut load_diagnostics = normalize_typed_config(&mut config); Self::add_default_func_agent_models_config(&mut config.ai.func_agent_models); + load_diagnostics.extend(isolate_invalid_ai_models(&mut config).await?); + load_diagnostics.extend(reconcile_model_references(&mut config).diagnostics); self.config = config; @@ -388,21 +441,39 @@ impl ConfigManager { self.config.version = env!("CARGO_PKG_VERSION").to_string(); self.save_config().await?; + self.load_diagnostics = load_diagnostics; info!("Config automatically fixed and saved"); Ok(()) } - /// Auto-completes missing fields in model configuration (backward compatible). - /// Ensures older configurations won't panic. - fn ensure_models_config(models: &mut [AIModelConfig]) { - for model in models.iter_mut() { - model.ensure_category_and_capabilities(); - } - debug!( - "Auto-completed category and capabilities for {} models", - models.len() + async fn activate_default_recovery( + &mut self, + raw_content: &str, + reason: &str, + message: String, + ) -> BitFunResult<()> { + let backup_path = self.backup_raw_config(raw_content, reason).await?; + self.config = self.providers.get_default_config(); + Self::add_default_func_agent_models_config(&mut self.config.ai.func_agent_models); + self.config.version = env!("CARGO_PKG_VERSION").to_string(); + self.config.schema_version = CURRENT_CONFIG_SCHEMA_VERSION; + self.load_diagnostics = vec![ConfigDiagnostic { + path: "$".to_string(), + message: format!( + "{message}. Started with in-memory defaults; original configuration was preserved at {}", + backup_path.display() + ), + code: "CONFIG_DEFAULT_RECOVERY".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::DefaultsUsed, + }]; + warn!( + "Configuration recovery activated: reason={}, backup_path={}", + reason, + backup_path.display() ); + Ok(()) } /// Adds default configuration for functional agents (`func_agent_models`). @@ -422,27 +493,6 @@ impl ConfigManager { } } - /// Migrates configuration versions. - async fn migrate_config_version( - &self, - from_version: &str, - mut config: Value, - ) -> BitFunResult { - let migrations: Vec = vec![("0.0.0", "1.0.0", migrate_0_0_0_to_1_0_0)]; - - let mut current_version = from_version.to_string(); - - for (from, to, migrate_fn) in migrations { - if version_gte(¤t_version, from) && version_lt(¤t_version, to) { - debug!("Executing migration: {} -> {}", from, to); - config = migrate_fn(config)?; - current_version = to.to_string(); - } - } - - Ok(config) - } - /// Saves the configuration file. async fn save_config(&self) -> BitFunResult<()> { let content = serde_json::to_string_pretty(&config_value_for_persistence(&self.config)?) @@ -459,12 +509,75 @@ impl ConfigManager { } } - fs::write(&self.config_file, content).await.map_err(|e| { - BitFunError::config(format!( - "Failed to write config file {:?}: {}", - self.config_file, e - )) - })?; + JsonFileStore + .write_text_atomic_strict(&self.config_file, &content) + .await + .map_err(|e| { + BitFunError::config(format!( + "Failed to atomically write config file {:?}: {}", + self.config_file, e + )) + })?; + Ok(()) + } + + async fn backup_raw_config(&self, content: &str, reason: &str) -> BitFunResult { + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f"); + let backup_dir = self.config_dir.join("backups"); + fs::create_dir_all(&backup_dir) + .await + .map_err(|e| BitFunError::config(format!("Failed to create backup directory: {e}")))?; + let backup_file = backup_dir.join(format!("app_{reason}_{timestamp}.json")); + fs::write(&backup_file, content) + .await + .map_err(|e| BitFunError::config(format!("Failed to write config backup: {e}")))?; + self.prune_backups(&backup_dir).await?; + info!( + "Created pre-repair config backup: path={}", + backup_file.display() + ); + Ok(backup_file) + } + + async fn prune_backups(&self, backup_dir: &std::path::Path) -> BitFunResult<()> { + if self.backup_count == 0 { + return Ok(()); + } + let mut entries = fs::read_dir(backup_dir) + .await + .map_err(|e| BitFunError::config(format!("Failed to read backup directory: {e}")))?; + let mut files = Vec::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| BitFunError::config(format!("Failed to enumerate backups: {e}")))? + { + let is_repair_backup = entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("app_") && name.ends_with(".json")); + if !is_repair_backup { + continue; + } + let metadata = entry + .metadata() + .await + .map_err(|e| BitFunError::config(format!("Failed to inspect backup: {e}")))?; + if metadata.is_file() { + files.push((metadata.modified().ok(), entry.path())); + } + } + files.sort_by_key(|(modified, _)| *modified); + let remove_count = files.len().saturating_sub(self.backup_count); + for (_, path) in files.into_iter().take(remove_count) { + if let Err(error) = fs::remove_file(&path).await { + warn!( + "Failed to prune old config backup: path={}, error={}", + path.display(), + error + ); + } + } Ok(()) } @@ -494,6 +607,9 @@ impl ConfigManager { let path = canonical_config_path(path); self.set_value_by_path(path, json_value)?; + // Apply capability-driven canonicalization before validation and persistence. + // Speech/embedding/image-only models must never carry text-generation sentinels. + normalize_typed_config(&mut self.config); self.config.last_modified = chrono::Utc::now(); let validation_result = match self.validate_config().await { @@ -511,7 +627,14 @@ impl ConfigManager { )); } - self.notify_config_changed(path, &old_config).await?; + if path.is_empty() { + for provider_name in self.providers.get_provider_names() { + self.notify_config_changed(&provider_name, &old_config) + .await?; + } + } else { + self.notify_config_changed(path, &old_config).await?; + } self.save_config().await?; @@ -568,6 +691,10 @@ impl ConfigManager { &self.config } + pub fn load_diagnostics(&self) -> &[ConfigDiagnostic] { + &self.load_diagnostics + } + /// Validates configuration. pub async fn validate_config(&self) -> BitFunResult { self.providers.validate_config(&self.config).await @@ -582,11 +709,18 @@ impl ConfigManager { /// Imports configuration. pub async fn import_config(&mut self, config_data: serde_json::Value) -> BitFunResult<()> { let old_config = self.config.clone(); - let config_data = normalize_legacy_config_value(config_data); + let normalized = normalize_config_value(config_data); + reject_unsupported_schema(&normalized.diagnostics)?; + let config_data = normalized.value; - let imported_config: GlobalConfig = serde_json::from_value(config_data) + let mut imported_config: GlobalConfig = serde_json::from_value(config_data) .map_err(|e| BitFunError::config(format!("Failed to parse imported config: {}", e)))?; + let mut import_diagnostics = normalized.diagnostics; + import_diagnostics.extend(normalize_typed_config(&mut imported_config)); + import_diagnostics.extend(isolate_invalid_ai_models(&mut imported_config).await?); + import_diagnostics.extend(reconcile_model_references(&mut imported_config).diagnostics); + let validation_result = self.providers.validate_config(&imported_config).await?; if !validation_result.valid { return Err(invalid_config_error( @@ -596,6 +730,7 @@ impl ConfigManager { } self.config = imported_config; + self.load_diagnostics = import_diagnostics; self.config.last_modified = chrono::Utc::now(); for provider_name in self.providers.get_provider_names() { @@ -852,59 +987,6 @@ pub(crate) fn versions_match(v1: &str, v2: &str) -> bool { v1 == v2 } -/// Returns whether `v1 >= v2`. -pub(crate) fn version_gte(v1: &str, v2: &str) -> bool { - parse_version(v1) >= parse_version(v2) -} - -/// Returns whether `v1 < v2`. -pub(crate) fn version_lt(v1: &str, v2: &str) -> bool { - parse_version(v1) < parse_version(v2) -} - -/// Parses a version string into a tuple `(major, minor, patch)`. -pub(crate) fn parse_version(version: &str) -> (u32, u32, u32) { - let parts: Vec<&str> = version.split('.').collect(); - let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); - let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); - let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); - (major, minor, patch) -} - -/// Migration function: `0.0.0 -> 1.0.0`. -/// -/// This migration is an example showing how to handle configuration upgrades. -pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> BitFunResult { - debug!("Executing config migration: 0.0.0 -> 1.0.0"); - - if let Some(app) = config.get_mut("app").and_then(|v| v.as_object_mut()) { - if !app.contains_key("ai_experience") { - app.insert( - "ai_experience".to_string(), - serde_json::json!({ - "enable_session_title_generation": true, - "enable_welcome_panel_ai_analysis": false - }), - ); - } - } - - if let Some(ai) = config.get_mut("ai").and_then(|v| v.as_object_mut()) { - if !ai.contains_key("super_agent_models") { - ai.insert( - "super_agent_models".to_string(), - Value::Object(serde_json::Map::new()), - ); - } - if !ai.contains_key("sub_agent_models") { - ai.insert("sub_agent_models".to_string(), serde_json::json!({})); - } - } - - debug!("Migration 0.0.0 -> 1.0.0 completed"); - Ok(config) -} - #[cfg(test)] mod tests { use super::{ diff --git a/src/crates/assembly/core/src/service/config/mod.rs b/src/crates/assembly/core/src/service/config/mod.rs index 94d2f02940..4fb9ccaf25 100644 --- a/src/crates/assembly/core/src/service/config/mod.rs +++ b/src/crates/assembly/core/src/service/config/mod.rs @@ -10,6 +10,7 @@ pub mod global; pub mod manager; #[cfg(feature = "agent-runtime")] pub mod mode_config_canonicalizer; +pub mod normalization; pub mod project_permission_store; pub mod providers; pub mod service; @@ -29,6 +30,11 @@ pub use mode_config_canonicalizer::{ canonicalize_agent_profile_configs, AgentProfileConfigCanonicalizationReport, AgentProfileConfigUpdateInfo, }; +pub use normalization::{ + isolate_invalid_ai_models, normalize_config_value, normalize_typed_config, + reconcile_model_references, reject_unsupported_schema, ConfigNormalizationResult, + ModelReferenceReconcileResult, +}; pub use providers::ConfigProviderRegistry; pub use service::{ConfigExport, ConfigHealthStatus, ConfigImportResult, ConfigService}; pub use types::*; diff --git a/src/crates/assembly/core/src/service/config/normalization.rs b/src/crates/assembly/core/src/service/config/normalization.rs new file mode 100644 index 0000000000..3d776c04bc --- /dev/null +++ b/src/crates/assembly/core/src/service/config/normalization.rs @@ -0,0 +1,514 @@ +use super::manager::{ + normalize_legacy_agent_model_defaults_config_value, + normalize_legacy_tool_permissions_config_value, strip_removed_model_reasoning_fields, +}; +use super::providers::AIConfigProvider; +use super::types::{ + ConfigDiagnostic, ConfigDiagnosticRecoverability, ConfigDiagnosticSeverity, ConfigProvider, + GlobalConfig, ModelCapability, SubagentModelSelection, CURRENT_CONFIG_SCHEMA_VERSION, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use serde_json::Value; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct ConfigNormalizationResult { + pub value: Value, + pub diagnostics: Vec, + pub changed: bool, +} + +/// Applies deterministic, credential-preserving compatibility normalization +/// before typed deserialization and strict semantic validation. +pub fn normalize_config_value(config: Value) -> ConfigNormalizationResult { + let original = config.clone(); + let mut diagnostics = Vec::new(); + let mut value = + strip_removed_model_reasoning_fields(normalize_legacy_tool_permissions_config_value( + normalize_legacy_agent_model_defaults_config_value(config), + )); + + let previous_schema = value + .get("schema_version") + .and_then(Value::as_u64) + .unwrap_or(0); + if previous_schema > u64::from(CURRENT_CONFIG_SCHEMA_VERSION) { + diagnostics.push(ConfigDiagnostic { + path: "schema_version".to_string(), + message: format!( + "Configuration schema {previous_schema} is newer than supported schema {CURRENT_CONFIG_SCHEMA_VERSION}" + ), + code: "CONFIG_SCHEMA_TOO_NEW".to_string(), + severity: ConfigDiagnosticSeverity::Error, + recoverability: ConfigDiagnosticRecoverability::None, + }); + return ConfigNormalizationResult { + changed: value != original, + value, + diagnostics, + }; + } + if previous_schema < u64::from(CURRENT_CONFIG_SCHEMA_VERSION) { + if let Some(root) = value.as_object_mut() { + root.insert( + "schema_version".to_string(), + Value::from(CURRENT_CONFIG_SCHEMA_VERSION), + ); + } + diagnostics.push(ConfigDiagnostic { + path: "schema_version".to_string(), + message: format!( + "Configuration schema upgraded from {previous_schema} to {CURRENT_CONFIG_SCHEMA_VERSION}" + ), + code: "CONFIG_SCHEMA_UPGRADED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }); + } + + ConfigNormalizationResult { + changed: value != original, + value, + diagnostics, + } +} + +pub fn reject_unsupported_schema(diagnostics: &[ConfigDiagnostic]) -> BitFunResult<()> { + if let Some(diagnostic) = diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "CONFIG_SCHEMA_TOO_NEW") + { + return Err(BitFunError::validation(diagnostic.message.clone())); + } + Ok(()) +} + +/// Canonicalizes typed model fields whose meaning is capability-dependent. +pub fn normalize_typed_config(config: &mut GlobalConfig) -> Vec { + let mut diagnostics = Vec::new(); + config.schema_version = CURRENT_CONFIG_SCHEMA_VERSION; + + for (index, model) in config.ai.models.iter_mut().enumerate() { + model.ensure_category_and_capabilities(); + let model_id = model.id.clone(); + for field in model.normalize_inapplicable_generation_fields() { + diagnostics.push(ConfigDiagnostic { + path: format!("ai.models[{index}].{field}"), + message: format!( + "Cleared text-generation-only field from model '{}' because it does not support text_chat", + model_id + ), + code: "MODEL_FIELD_NOT_APPLICABLE".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }); + } + } + + diagnostics +} + +/// Disables only individually invalid model entries so a local model mistake +/// cannot prevent the rest of the product from starting. Cross-model/default +/// integrity is repaired separately by the model reconciliation pass. +pub async fn isolate_invalid_ai_models( + config: &mut GlobalConfig, +) -> BitFunResult> { + let mut diagnostics = Vec::new(); + + for index in 0..config.ai.models.len() { + if !config.ai.models[index].enabled { + continue; + } + + let mut isolated_ai = super::types::AIConfig::default(); + isolated_ai.models = vec![config.ai.models[index].clone()]; + + let validation = AIConfigProvider + .validate_config(&serde_json::to_value(isolated_ai)?) + .await; + if let Err(error) = validation { + let error_message = error.to_string(); + // Reasoning schemas are cross-cutting runtime contracts. Keep these + // as hard failures so a malformed preset is not silently hidden. + if error_message.to_ascii_lowercase().contains("reasoning") { + return Err(error); + } + let model_id = config.ai.models[index].id.clone(); + config.ai.models[index].enabled = false; + diagnostics.push(ConfigDiagnostic { + path: format!("ai.models[{index}]"), + message: format!( + "Disabled invalid model '{}' during configuration recovery", + model_id + ), + code: "INVALID_MODEL_DISABLED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::ModelDisabled, + }); + log::warn!( + "Disabled invalid model during configuration recovery: model_id={}, error={}", + model_id, + error_message + ); + } + } + + Ok(diagnostics) +} + +#[derive(Debug, Clone, Default)] +pub struct ModelReferenceReconcileResult { + pub invalidated_model_ids: Vec, + pub default_models_changed: bool, + pub func_agent_models_changed: bool, + pub agent_model_defaults_changed: bool, + pub diagnostics: Vec, +} + +impl ModelReferenceReconcileResult { + pub fn is_noop(&self) -> bool { + !self.default_models_changed + && !self.func_agent_models_changed + && !self.agent_model_defaults_changed + } +} + +fn enabled_model_with_capability( + config: &GlobalConfig, + model_id: &str, + capability: ModelCapability, +) -> bool { + config.ai.models.iter().any(|model| { + model.enabled && model.id == model_id && model.supports_capability(capability.clone()) + }) +} + +fn first_enabled_model_with_capability( + config: &GlobalConfig, + capability: ModelCapability, +) -> Option { + config + .ai + .models + .iter() + .find(|model| model.enabled && model.supports_capability(capability.clone())) + .map(|model| model.id.clone()) +} + +fn diagnose_reference_repair( + diagnostics: &mut Vec, + path: &str, + previous: Option<&str>, + replacement: Option<&str>, +) { + diagnostics.push(ConfigDiagnostic { + path: path.to_string(), + message: format!( + "Repaired model reference from {:?} to {:?} to match the slot capability", + previous, replacement + ), + code: "MODEL_REFERENCE_REPAIRED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }); +} + +/// Reconciles every product model reference against both enablement and the +/// capability required by its consumer. +pub fn reconcile_model_references(config: &mut GlobalConfig) -> ModelReferenceReconcileResult { + let snapshot = config.clone(); + let mut result = ModelReferenceReconcileResult::default(); + let mut invalidated = HashSet::new(); + + let direct_text_reference_is_valid = |reference: &str| { + matches!(reference, "auto" | "primary" | "fast") + || enabled_model_with_capability(&snapshot, reference, ModelCapability::TextChat) + }; + + config.ai.func_agent_models.retain(|agent, model_ref| { + let valid = direct_text_reference_is_valid(model_ref); + if !valid { + invalidated.insert(model_ref.clone()); + result.func_agent_models_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + &format!("ai.func_agent_models.{agent}"), + Some(model_ref), + None, + ); + } + valid + }); + + if !direct_text_reference_is_valid(&config.ai.agent_model_defaults.mode) { + invalidated.insert(config.ai.agent_model_defaults.mode.clone()); + let previous = + std::mem::replace(&mut config.ai.agent_model_defaults.mode, "auto".to_string()); + result.agent_model_defaults_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + "ai.agent_model_defaults.mode", + Some(&previous), + Some("auto"), + ); + } + + if config + .ai + .agent_model_defaults + .subagents + .default_selection + .fixed_model_id() + .is_some_and(|model_id| !direct_text_reference_is_valid(model_id)) + { + let previous = config + .ai + .agent_model_defaults + .subagents + .default_selection + .fixed_model_id() + .map(str::to_string); + if let Some(previous) = previous.as_ref() { + invalidated.insert(previous.clone()); + } + config.ai.agent_model_defaults.subagents.default_selection = + SubagentModelSelection::fixed("fast"); + result.agent_model_defaults_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + "ai.agent_model_defaults.subagents.default", + previous.as_deref(), + Some("fast"), + ); + } + + config + .ai + .agent_model_defaults + .subagents + .builtin + .retain(|subagent_id, selection| { + let invalid = selection + .fixed_model_id() + .is_some_and(|model_id| !direct_text_reference_is_valid(model_id)); + if invalid { + if let Some(model_id) = selection.fixed_model_id() { + invalidated.insert(model_id.to_string()); + diagnose_reference_repair( + &mut result.diagnostics, + &format!("ai.agent_model_defaults.subagents.builtin.{subagent_id}"), + Some(model_id), + None, + ); + } + result.agent_model_defaults_changed = true; + } + !invalid + }); + + if config + .ai + .agent_model_defaults + .subagents + .fork + .fixed_model_id() + .is_some_and(|model_id| !direct_text_reference_is_valid(model_id)) + { + let previous = config + .ai + .agent_model_defaults + .subagents + .fork + .fixed_model_id() + .map(str::to_string); + if let Some(previous) = previous.as_ref() { + invalidated.insert(previous.clone()); + } + config.ai.agent_model_defaults.subagents.fork = SubagentModelSelection::Inherit; + result.agent_model_defaults_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + "ai.agent_model_defaults.subagents.fork", + previous.as_deref(), + Some("inherit"), + ); + } + + let mut reconcile_slot = |slot: &mut Option, + path: &str, + capability: ModelCapability, + fill_when_missing: bool| { + let previous = slot.clone(); + let valid = previous + .as_deref() + .is_some_and(|id| enabled_model_with_capability(&snapshot, id, capability.clone())); + if valid || (previous.is_none() && !fill_when_missing) { + return; + } + let replacement = first_enabled_model_with_capability(&snapshot, capability); + if replacement == previous { + return; + } + if let Some(previous) = previous.as_ref().filter(|id| !id.is_empty()) { + invalidated.insert(previous.clone()); + } + *slot = replacement; + result.default_models_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + path, + previous.as_deref(), + slot.as_deref(), + ); + }; + + reconcile_slot( + &mut config.ai.default_models.primary, + "ai.default_models.primary", + ModelCapability::TextChat, + true, + ); + reconcile_slot( + &mut config.ai.default_models.fast, + "ai.default_models.fast", + ModelCapability::TextChat, + true, + ); + reconcile_slot( + &mut config.ai.default_models.image_understanding, + "ai.default_models.image_understanding", + ModelCapability::ImageUnderstanding, + false, + ); + reconcile_slot( + &mut config.ai.default_models.image_generation, + "ai.default_models.image_generation", + ModelCapability::ImageGeneration, + false, + ); + reconcile_slot( + &mut config.ai.default_models.search, + "ai.default_models.search", + ModelCapability::Search, + false, + ); + reconcile_slot( + &mut config.ai.default_models.speech_recognition, + "ai.default_models.speech_recognition", + ModelCapability::SpeechRecognition, + false, + ); + + result.invalidated_model_ids = invalidated.into_iter().collect(); + result.invalidated_model_ids.sort(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::config::types::{AIModelConfig, ModelCapability, ModelCategory}; + + #[test] + fn pure_speech_models_drop_text_generation_sentinels() { + let mut config = GlobalConfig::default(); + config.ai.models.push(AIModelConfig { + id: "speech-cloud".to_string(), + name: "Qwen ASR".to_string(), + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + context_window: Some(0), + max_tokens: Some(0), + enabled: true, + ..AIModelConfig::default() + }); + + let diagnostics = normalize_typed_config(&mut config); + + assert_eq!(config.ai.models[0].context_window, None); + assert_eq!(config.ai.models[0].max_tokens, None); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics + .iter() + .all(|diagnostic| diagnostic.code == "MODEL_FIELD_NOT_APPLICABLE")); + } + + #[test] + fn mixed_text_and_speech_models_keep_generation_fields() { + let mut config = GlobalConfig::default(); + config.ai.models.push(AIModelConfig { + id: "mixed".to_string(), + category: ModelCategory::GeneralChat, + capabilities: vec![ + ModelCapability::TextChat, + ModelCapability::SpeechRecognition, + ], + context_window: Some(64_000), + max_tokens: Some(8_000), + ..AIModelConfig::default() + }); + + assert!(normalize_typed_config(&mut config).is_empty()); + assert_eq!(config.ai.models[0].context_window, Some(64_000)); + assert_eq!(config.ai.models[0].max_tokens, Some(8_000)); + } + + #[test] + fn default_slots_reconcile_by_capability() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![ + AIModelConfig { + id: "speech".to_string(), + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + ..AIModelConfig::default() + }, + AIModelConfig { + id: "text".to_string(), + enabled: true, + category: ModelCategory::GeneralChat, + capabilities: vec![ModelCapability::TextChat], + ..AIModelConfig::default() + }, + ]; + config.ai.default_models.primary = Some("speech".to_string()); + config.ai.default_models.fast = Some("speech".to_string()); + config.ai.default_models.speech_recognition = Some("text".to_string()); + + let result = reconcile_model_references(&mut config); + + assert_eq!(config.ai.default_models.primary.as_deref(), Some("text")); + assert_eq!(config.ai.default_models.fast.as_deref(), Some("text")); + assert_eq!( + config.ai.default_models.speech_recognition.as_deref(), + Some("speech") + ); + assert!(result.default_models_changed); + } + + #[tokio::test] + async fn global_ai_errors_do_not_disable_individually_valid_models() { + let mut config = GlobalConfig::default(); + config.ai.stream_idle_timeout_secs = Some(0); + config.ai.models.push(AIModelConfig { + id: "valid-text".to_string(), + name: "Valid text model".to_string(), + provider: "openai".to_string(), + model_name: "text-model".to_string(), + base_url: "https://example.com/v1".to_string(), + enabled: true, + capabilities: vec![ModelCapability::TextChat], + context_window: Some(64_000), + ..AIModelConfig::default() + }); + + let diagnostics = isolate_invalid_ai_models(&mut config) + .await + .expect("model isolation should succeed"); + + assert!(diagnostics.is_empty()); + assert!(config.ai.models[0].enabled); + } +} diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index 416186d2b5..81391b2b6b 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -34,6 +34,83 @@ fn serialize_default_config(section: &str, value: impl serde::Serialize) -> serd /// AI configuration provider. pub struct AIConfigProvider; +fn ai_validation_error_location(message: &str) -> (String, String) { + let model_field = if message.contains("Model name is required") { + Some(("name", "MODEL_NAME_INVALID")) + } else if message.contains("Model provider is required") { + Some(("provider", "MODEL_PROVIDER_INVALID")) + } else if message.contains("context_window") { + Some(("context_window", "MODEL_CONTEXT_WINDOW_INVALID")) + } else if message.contains("max_tokens") { + Some(("max_tokens", "MODEL_MAX_TOKENS_INVALID")) + } else if message.contains("reasoning config") { + Some(("reasoning", "MODEL_REASONING_INVALID")) + } else if message.contains("reasoning default preset") { + Some(( + "reasoning.default_preset", + "MODEL_REASONING_DEFAULT_INVALID", + )) + } else if message.contains("reasoning target") { + Some(("reasoning", "MODEL_REASONING_TARGET_INVALID")) + } else if message.contains("reasoning preset") { + Some(("reasoning.presets", "MODEL_REASONING_PRESET_INVALID")) + } else { + None + }; + + if let Some((field, code)) = model_field { + if let Some(index) = message + .rsplit_once(" at index ") + .and_then(|(_, suffix)| suffix.split(':').next()) + .and_then(|value| value.parse::().ok()) + { + return (format!("ai.models[{index}].{field}"), code.to_string()); + } + } + + if message.contains("stream_idle_timeout_secs") { + return ( + "ai.stream_idle_timeout_secs".to_string(), + "AI_STREAM_IDLE_TIMEOUT_INVALID".to_string(), + ); + } + if message.contains("stream_ttft_timeout_secs") { + return ( + "ai.stream_ttft_timeout_secs".to_string(), + "AI_STREAM_TTFT_TIMEOUT_INVALID".to_string(), + ); + } + if message.starts_with("Function Agent '") { + if let Some((_, suffix)) = message.split_once("Function Agent '") { + if let Some((agent, _)) = suffix.split_once('\'') { + return ( + format!("ai.func_agent_models.{agent}"), + "FUNC_AGENT_MODEL_INVALID".to_string(), + ); + } + } + } + + ("ai".to_string(), "VALIDATION_ERROR".to_string()) +} + +fn ai_validation_warning_location(config: &GlobalConfig, message: &str) -> String { + let Some(model_name) = message + .strip_prefix("Model '") + .and_then(|value| value.split_once("' has empty API key")) + .map(|(name, _)| name) + else { + return "ai".to_string(); + }; + config + .ai + .models + .iter() + .position(|model| model.name == model_name) + .map(|index| format!("ai.models[{index}].api_key")) + .unwrap_or_else(|| "ai".to_string()) +} + #[async_trait] impl ConfigProvider for AIConfigProvider { fn name(&self) -> &str { @@ -76,6 +153,9 @@ impl ConfigProvider for AIConfigProvider { } for (index, model) in ai_config.models.iter().enumerate() { + if !model.enabled { + continue; + } if model.name.trim().is_empty() { return Err(BitFunError::validation(format!( "Model name is required at index {}", @@ -91,28 +171,30 @@ impl ConfigProvider for AIConfigProvider { if model.api_key.trim().is_empty() { warnings.push(format!("Model '{}' has empty API key", model.name)); } - if let Some(context_window) = model.context_window { - if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS { - return Err(BitFunError::validation(format!( - "Model '{}' context_window must be at least {}", - model.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS - ))); + if model.supports_text_generation() { + if let Some(context_window) = model.context_window { + if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS { + return Err(BitFunError::validation(format!( + "Model '{}' context_window must be at least {} at index {}", + model.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS, index + ))); + } } - } - if let Some(max_tokens) = model.max_tokens { - if max_tokens == 0 { - return Err(BitFunError::validation(format!( - "Model '{}' max_tokens must be greater than 0", - model.name - ))); + if let Some(max_tokens) = model.max_tokens { + if max_tokens == 0 { + return Err(BitFunError::validation(format!( + "Model '{}' max_tokens must be greater than 0 at index {}", + model.name, index + ))); + } } - } - if let Some(temperature) = model.temperature { - if !temperature.is_nan() && !(0.0..=2.0).contains(&temperature) { - warnings.push(format!( - "Model '{}' temperature should be between 0 and 2", - model.name - )); + if let Some(temperature) = model.temperature { + if !temperature.is_nan() && !(0.0..=2.0).contains(&temperature) { + warnings.push(format!( + "Model '{}' temperature should be between 0 and 2", + model.name + )); + } } } @@ -198,7 +280,10 @@ impl ConfigProvider for AIConfigProvider { } for (func_agent_name, model_id) in &ai_config.func_agent_models { - if !ai_config.models.iter().any(|m| m.id == *model_id) + if !ai_config + .models + .iter() + .any(|m| m.enabled && m.id == *model_id) && model_id != "primary" && model_id != "fast" { @@ -625,24 +710,53 @@ impl ConfigProviderRegistry { Ok(provider_warnings) => { warnings.extend(provider_warnings.into_iter().map(|msg| { ConfigValidationWarning { - path: provider_name.to_string(), + path: if provider_name == "ai" { + ai_validation_warning_location(config, &msg) + } else { + provider_name.to_string() + }, message: msg, code: "VALIDATION_WARNING".to_string(), severity: "warning".to_string(), } })) } - Err(e) => errors.push(ConfigValidationError { - path: provider_name.to_string(), - message: e.to_string(), - code: "VALIDATION_ERROR".to_string(), - severity: "error".to_string(), - }), + Err(e) => { + let message = e.to_string(); + let (path, code) = if provider_name == "ai" { + ai_validation_error_location(&message) + } else { + (provider_name.to_string(), "VALIDATION_ERROR".to_string()) + }; + errors.push(ConfigValidationError { + path, + message, + code, + severity: "error".to_string(), + }); + } } } Ok(ConfigValidationResult { valid: errors.is_empty(), + diagnostics: errors + .iter() + .map(|error| ConfigDiagnostic { + path: error.path.clone(), + message: error.message.clone(), + code: error.code.clone(), + severity: ConfigDiagnosticSeverity::Error, + recoverability: ConfigDiagnosticRecoverability::None, + }) + .chain(warnings.iter().map(|warning| ConfigDiagnostic { + path: warning.path.clone(), + message: warning.message.clone(), + code: warning.code.clone(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::None, + })) + .collect(), errors, warnings, }) @@ -726,6 +840,7 @@ mod tests { name: "Test model".to_string(), provider: "openai".to_string(), context_window: Some(MIN_MODEL_CONTEXT_WINDOW_TOKENS - 1), + enabled: true, ..AIModelConfig::default() }); let value = serde_json::to_value(config).expect("AI config should serialize"); @@ -740,6 +855,75 @@ mod tests { .contains("context_window must be at least 32000")); } + #[tokio::test] + async fn accepts_generation_sentinels_on_pure_speech_models() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + name: "Qwen ASR".to_string(), + provider: "openai".to_string(), + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + context_window: Some(0), + max_tokens: Some(0), + ..AIModelConfig::default() + }); + + AIConfigProvider + .validate_config(&serde_json::to_value(config).unwrap()) + .await + .expect("pure speech models do not use generation token fields"); + } + + #[tokio::test] + async fn mixed_text_and_speech_models_still_require_a_valid_context_window() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + name: "Mixed model".to_string(), + provider: "openai".to_string(), + enabled: true, + capabilities: vec![ + ModelCapability::TextChat, + ModelCapability::SpeechRecognition, + ], + context_window: Some(0), + ..AIModelConfig::default() + }); + + let error = AIConfigProvider + .validate_config(&serde_json::to_value(config).unwrap()) + .await + .expect_err("text-capable models must retain generation validation"); + assert!(error + .to_string() + .contains("context_window must be at least")); + } + + #[tokio::test] + async fn registry_reports_precise_model_validation_paths_and_codes() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + id: "broken".to_string(), + name: "Broken model".to_string(), + provider: "openai".to_string(), + enabled: true, + context_window: Some(0), + ..AIModelConfig::default() + }); + + let result = ConfigProviderRegistry::new() + .validate_config(&GlobalConfig { + ai: config, + ..GlobalConfig::default() + }) + .await + .expect("validation result"); + + assert_eq!(result.errors[0].path, "ai.models[0].context_window"); + assert_eq!(result.errors[0].code, "MODEL_CONTEXT_WINDOW_INVALID"); + assert_eq!(result.diagnostics[0].path, "ai.models[0].context_window"); + } + #[tokio::test] async fn rejects_invalid_canonical_reasoning_actions() { for (action, expected) in [ @@ -851,7 +1035,7 @@ mod tests { .expect("registry validation result"); assert!(!validation.valid); - assert_eq!(validation.errors[0].path, "ai"); + assert_eq!(validation.errors[0].path, "ai.models[0].reasoning"); assert!(validation.errors[0] .message .contains("budget_tokens value must be greater than 0")); diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 3068a02506..46053b0e77 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -6,8 +6,6 @@ use super::manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; use super::types::*; use crate::util::errors::*; use log::{info, warn}; -use std::collections::HashSet; - use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; @@ -63,8 +61,15 @@ impl ConfigService { manager: Arc::new(RwLock::new(manager)), }; - if let Err(e) = service.reconcile_models("startup").await { - warn!("Model reconcile at startup failed: {}", e); + let recovered_with_defaults = service + .load_diagnostics() + .await + .iter() + .any(|diagnostic| diagnostic.code == "CONFIG_DEFAULT_RECOVERY"); + if !recovered_with_defaults { + if let Err(e) = service.reconcile_models("startup").await { + warn!("Model reconcile at startup failed: {}", e); + } } Ok(service) @@ -182,7 +187,15 @@ impl ConfigService { /// Validates configuration. pub async fn validate_config(&self) -> BitFunResult { let manager = self.manager.read().await; - manager.validate_config().await + let mut result = manager.validate_config().await?; + result + .diagnostics + .extend(manager.load_diagnostics().iter().cloned()); + Ok(result) + } + + pub async fn load_diagnostics(&self) -> Vec { + self.manager.read().await.load_diagnostics().to_vec() } /// Exports configuration. @@ -377,6 +390,114 @@ impl ConfigService { self.set_config("ai.models", &config.ai.models).await } + /// Atomically upserts a pure speech-recognition model, selects it as the + /// speech default, and switches voice input to the cloud provider. + pub async fn save_cloud_speech_config( + &self, + request: SaveCloudSpeechConfigRequest, + ) -> BitFunResult { + let name = request.name.trim(); + let base_url = request.base_url.trim().trim_end_matches('/'); + let model_name = request.model_name.trim(); + let api_key = request.api_key.trim(); + if name.is_empty() || base_url.is_empty() || model_name.is_empty() || api_key.is_empty() { + return Err(BitFunError::validation( + "Cloud speech name, base URL, model name, and API key are required".to_string(), + )); + } + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + return Err(BitFunError::validation( + "Cloud speech base URL must use http or https".to_string(), + )); + } + + let request_url = request + .request_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + if base_url.ends_with("/audio/transcriptions") { + base_url.to_string() + } else { + format!("{base_url}/audio/transcriptions") + } + }); + if !request_url.starts_with("http://") && !request_url.starts_with("https://") { + return Err(BitFunError::validation( + "Cloud speech request URL must use http or https".to_string(), + )); + } + + let mut manager = self.manager.write().await; + let mut config = manager.get_config().clone(); + let model_id = request + .config_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("speech_cloud_{}", uuid::Uuid::new_v4().simple())); + let existing_index = config + .ai + .models + .iter() + .position(|model| model.id == model_id); + let created = existing_index.is_none(); + let existing_metadata = existing_index + .and_then(|index| config.ai.models[index].metadata.clone()) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + let mut metadata = existing_metadata; + metadata.insert( + "speech_provider_preset".to_string(), + serde_json::Value::String(request.preset.trim().to_string()), + ); + let model = AIModelConfig { + id: model_id.clone(), + name: name.to_string(), + provider: "openai".to_string(), + model_name: model_name.to_string(), + base_url: base_url.to_string(), + request_url: Some(request_url), + api_key: api_key.to_string(), + context_window: None, + max_tokens: None, + temperature: None, + top_p: None, + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + recommended_for: vec!["voice_input".to_string()], + metadata: Some(serde_json::Value::Object(metadata)), + auth: AuthConfig::ApiKey, + ..AIModelConfig::default() + }; + match existing_index { + Some(index) => config.ai.models[index] = model, + None => config.ai.models.push(model), + } + config.ai.default_models.speech_recognition = Some(model_id.clone()); + config.app.ai_experience.voice_input.provider = "cloud".to_string(); + config.app.ai_experience.voice_input.model_id = model_id.clone(); + + // The caller may be updating an existing model id. Reconcile all + // capability-specific slots before the single persistence operation so + // replacing a text model with a speech-only model cannot leave primary, + // fast, or agent references pointing at a non-text runtime target. + super::normalization::reconcile_model_references(&mut config); + manager.set("", &config).await?; + drop(manager); + + super::global::GlobalConfigManager::broadcast_update( + super::global::ConfigUpdateEvent::ModelConfigurationUpdated, + ) + .await; + + Ok(SaveCloudSpeechConfigResult { model_id, created }) + } + /// Bring `ai.default_models`, `ai.agent_model_defaults`, and /// `ai.func_agent_models` back into a consistent state with `ai.models`. /// @@ -396,229 +517,17 @@ impl ConfigService { /// `caller` is logged for diagnostics (e.g. `set_config`, `update_ai_model`). pub async fn reconcile_models(&self, caller: &str) -> BitFunResult { let mut config: GlobalConfig = self.get_config(None).await?; + let reconciliation = super::normalization::reconcile_model_references(&mut config); - let enabled_ids: HashSet = config - .ai - .models - .iter() - .filter(|m| m.enabled) - .map(|m| m.id.clone()) - .collect(); - let is_active = |reference: &str| -> bool { - // Special selectors are always considered active; their actual - // resolution happens at runtime against the (already reconciled) - // default slots. - matches!(reference, "auto" | "primary" | "fast") || enabled_ids.contains(reference) - }; - - let classify_invalid = |reference: &str, invalidated: &mut HashSet| -> bool { - if is_active(reference) { - return false; - } - invalidated.insert(reference.to_string()); - true - }; - - let mut invalidated: HashSet = HashSet::new(); - let mut func_agent_models_changed = false; - let mut agent_model_defaults_changed = false; - let mut default_models_changed = false; - - // 1. func_agent_models - let func_keys_to_remove: Vec = config - .ai - .func_agent_models - .iter() - .filter_map(|(agent, model_ref)| { - if classify_invalid(model_ref, &mut invalidated) { - Some(agent.clone()) - } else { - None - } - }) - .collect(); - for agent in func_keys_to_remove { - warn!( - "Reconcile ({caller}): clearing ai.func_agent_models[{agent}] because target model is missing or disabled" - ); - config.ai.func_agent_models.remove(&agent); - func_agent_models_changed = true; - } - - // 2. future mode and delegated-subagent defaults - if classify_invalid( - config.ai.agent_model_defaults.mode.as_str(), - &mut invalidated, - ) { - warn!( - "Reconcile ({caller}): resetting ai.agent_model_defaults.mode because target model is missing or disabled" - ); - config.ai.agent_model_defaults.mode = "auto".to_string(); - agent_model_defaults_changed = true; - } - - if config - .ai - .agent_model_defaults - .subagents - .default_selection - .fixed_model_id() - .is_some_and(|model_id| classify_invalid(model_id, &mut invalidated)) - { - warn!( - "Reconcile ({caller}): resetting ai.agent_model_defaults.subagents.default because target model is missing or disabled" - ); - config.ai.agent_model_defaults.subagents.default_selection = - SubagentModelSelection::fixed("fast"); - agent_model_defaults_changed = true; - } - - let builtin_keys_to_remove: Vec = config - .ai - .agent_model_defaults - .subagents - .builtin - .iter() - .filter_map(|(subagent_id, selection)| { - selection - .fixed_model_id() - .filter(|model_id| classify_invalid(model_id, &mut invalidated)) - .map(|_| subagent_id.clone()) - }) - .collect(); - for subagent_id in builtin_keys_to_remove { - warn!( - "Reconcile ({caller}): clearing ai.agent_model_defaults.subagents.builtin[{subagent_id}] because target model is missing or disabled" - ); - config - .ai - .agent_model_defaults - .subagents - .builtin - .remove(&subagent_id); - agent_model_defaults_changed = true; - } - - if config - .ai - .agent_model_defaults - .subagents - .fork - .fixed_model_id() - .is_some_and(|model_id| classify_invalid(model_id, &mut invalidated)) - { - warn!( - "Reconcile ({caller}): resetting ai.agent_model_defaults.subagents.fork because target model is missing or disabled" - ); - config.ai.agent_model_defaults.subagents.fork = SubagentModelSelection::Inherit; - agent_model_defaults_changed = true; - } - - // 3. default model slots - let fallback_id = config.ai.first_enabled_model_id(); - let image_understanding_fallback_id = config - .ai - .models - .iter() - .find(|model| model.enabled && model.supports_image_understanding()) - .map(|model| model.id.clone()); - let mut repoint_default_slot = |slot: &mut Option, slot_name: &str| { - let needs_fix = match slot.as_deref() { - Some("") => true, - Some(value) => !is_active(value), - None => false, - }; - if !needs_fix { - return; - } - - if let Some(current) = slot.as_deref() { - classify_invalid(current, &mut invalidated); - } - - match fallback_id.as_ref() { - Some(new_id) => { - info!( - "Reconcile ({caller}): default_models.{slot_name} repointed: {:?} -> {}", - slot, new_id - ); - *slot = Some(new_id.clone()); - } - None => { - info!( - "Reconcile ({caller}): default_models.{slot_name} cleared (no enabled model available); previous={:?}", - slot - ); - *slot = None; - } - } - default_models_changed = true; - }; - - repoint_default_slot(&mut config.ai.default_models.primary, "primary"); - repoint_default_slot(&mut config.ai.default_models.fast, "fast"); - - let image_understanding_needs_fix = - match config.ai.default_models.image_understanding.as_deref() { - Some("") => true, - Some(value) => !config.ai.models.iter().any(|model| { - model.enabled && model.supports_image_understanding() && model.id == value - }), - None => false, - }; - if image_understanding_needs_fix { - if let Some(current) = config.ai.default_models.image_understanding.as_deref() { - classify_invalid(current, &mut invalidated); - } - - match image_understanding_fallback_id.as_ref() { - Some(new_id) => { - info!( - "Reconcile ({caller}): default_models.image_understanding repointed: {:?} -> {}", - config.ai.default_models.image_understanding, new_id - ); - config.ai.default_models.image_understanding = Some(new_id.clone()); - } - None => { - info!( - "Reconcile ({caller}): default_models.image_understanding cleared (no enabled capable model available); previous={:?}", - config.ai.default_models.image_understanding - ); - config.ai.default_models.image_understanding = None; - } - } - default_models_changed = true; - } - - // Ensure `invalidated` doesn't contain a still-existing-and-enabled ID. - invalidated.retain(|id| !enabled_ids.contains(id)); - - // Persist any changes. We deliberately use the inner manager (and not - // `self.set_config`) to avoid triggering a recursive reconcile pass. - if func_agent_models_changed { - let mut manager = self.manager.write().await; - manager - .set("ai.func_agent_models", &config.ai.func_agent_models) - .await?; - } - if agent_model_defaults_changed { - let mut manager = self.manager.write().await; - manager - .set("ai.agent_model_defaults", &config.ai.agent_model_defaults) - .await?; - } - if default_models_changed { - let mut manager = self.manager.write().await; - manager - .set("ai.default_models", &config.ai.default_models) - .await?; + if !reconciliation.is_noop() { + self.manager.write().await.set("", &config).await?; } let report = ReconcileModelsReport { - invalidated_model_ids: invalidated.into_iter().collect(), - default_models_changed, - func_agent_models_changed, - agent_model_defaults_changed, + invalidated_model_ids: reconciliation.invalidated_model_ids, + default_models_changed: reconciliation.default_models_changed, + func_agent_models_changed: reconciliation.func_agent_models_changed, + agent_model_defaults_changed: reconciliation.agent_model_defaults_changed, }; if report.is_noop() { @@ -755,6 +664,217 @@ mod tests { assert!(current["mcpServers"].get("stale").is_none()); } + #[tokio::test] + async fn startup_repairs_speech_sentinels_and_creates_a_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let user_root = dir.path().join("speech-startup-repair"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(user_root)); + path_manager + .initialize_user_directories() + .await + .expect("user directories"); + let mut config = GlobalConfig::default(); + config.ai.models.push(AIModelConfig { + id: "speech".to_string(), + name: "Qwen ASR".to_string(), + provider: "openai".to_string(), + model_name: "qwen-asr".to_string(), + base_url: "https://example.com/v1".to_string(), + api_key: "secret".to_string(), + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + context_window: Some(0), + max_tokens: Some(0), + ..Default::default() + }); + config.ai.default_models.speech_recognition = Some("speech".to_string()); + tokio::fs::write( + path_manager.app_config_file(), + serde_json::to_vec_pretty(&config).expect("serialize config"), + ) + .await + .expect("seed config"); + + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 5, + }) + .await + .expect("config service should recover"); + + let repaired: GlobalConfig = service.get_config(None).await.expect("repaired config"); + let speech = repaired + .ai + .models + .iter() + .find(|model| model.id == "speech") + .expect("speech model"); + assert_eq!(speech.context_window, None); + assert_eq!(speech.max_tokens, None); + assert_eq!( + repaired.ai.default_models.speech_recognition.as_deref(), + Some("speech") + ); + assert!(service + .load_diagnostics() + .await + .iter() + .any(|diagnostic| diagnostic.code == "MODEL_FIELD_NOT_APPLICABLE")); + let backups = std::fs::read_dir(path_manager.user_config_dir().join("backups")) + .expect("backup directory") + .collect::, _>>() + .expect("backup entries"); + assert_eq!(backups.len(), 1); + } + + #[tokio::test] + async fn malformed_json_uses_in_memory_defaults_and_preserves_the_original() { + let dir = tempfile::tempdir().expect("tempdir"); + let user_root = dir.path().join("invalid-json-recovery"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(user_root)); + path_manager + .initialize_user_directories() + .await + .expect("user directories"); + let broken = "{\"ai\": {\"models\": ["; + tokio::fs::write(path_manager.app_config_file(), broken) + .await + .expect("seed broken config"); + + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 5, + }) + .await + .expect("startup should use defaults"); + + assert_eq!( + tokio::fs::read_to_string(path_manager.app_config_file()) + .await + .expect("original config"), + broken + ); + let diagnostic = service + .load_diagnostics() + .await + .into_iter() + .find(|diagnostic| diagnostic.code == "CONFIG_DEFAULT_RECOVERY") + .expect("recovery diagnostic"); + assert!(!diagnostic.message.contains("api_key")); + let backup = std::fs::read_dir(path_manager.user_config_dir().join("backups")) + .expect("backup directory") + .next() + .expect("backup entry") + .expect("backup path") + .path(); + assert_eq!( + tokio::fs::read_to_string(backup) + .await + .expect("backup content"), + broken + ); + } + + #[tokio::test] + async fn cloud_speech_save_updates_all_owned_fields_in_one_persisted_config() { + let test_name = "atomic-cloud-speech"; + let (service, dir) = test_service(test_name).await; + let result = service + .save_cloud_speech_config(SaveCloudSpeechConfigRequest { + config_id: Some("speech-cloud".to_string()), + preset: "qwen".to_string(), + name: "Qwen ASR".to_string(), + base_url: "https://example.com/v1/".to_string(), + request_url: None, + model_name: "qwen-asr".to_string(), + api_key: "secret".to_string(), + }) + .await + .expect("speech config should save"); + assert!(result.created); + + let path_manager = PathManager::with_user_root_for_tests(dir.path().join(test_name)); + let persisted: GlobalConfig = serde_json::from_slice( + &tokio::fs::read(path_manager.app_config_file()) + .await + .expect("persisted config"), + ) + .expect("valid persisted config"); + let model = persisted + .ai + .models + .iter() + .find(|model| model.id == "speech-cloud") + .expect("speech model"); + assert_eq!(model.context_window, None); + assert_eq!(model.max_tokens, None); + assert_eq!( + model.request_url.as_deref(), + Some("https://example.com/v1/audio/transcriptions") + ); + assert_eq!( + persisted.ai.default_models.speech_recognition.as_deref(), + Some("speech-cloud") + ); + assert_eq!(persisted.app.ai_experience.voice_input.provider, "cloud"); + assert_eq!( + persisted.app.ai_experience.voice_input.model_id, + "speech-cloud" + ); + } + + #[tokio::test] + async fn cloud_speech_save_reconciles_text_references_when_reusing_a_model_id() { + let (service, _dir) = test_service("cloud-speech-reused-id").await; + service + .set_config( + "ai.models", + vec![model("reused-model", true, ModelCategory::GeneralChat)], + ) + .await + .expect("text model should save"); + + let before: GlobalConfig = service.get_config(None).await.expect("config before save"); + assert_eq!( + before.ai.default_models.primary.as_deref(), + Some("reused-model") + ); + assert_eq!( + before.ai.default_models.fast.as_deref(), + Some("reused-model") + ); + + let result = service + .save_cloud_speech_config(SaveCloudSpeechConfigRequest { + config_id: Some("reused-model".to_string()), + preset: "custom".to_string(), + name: "Speech replacement".to_string(), + base_url: "https://example.com/v1".to_string(), + request_url: None, + model_name: "speech-model".to_string(), + api_key: "secret".to_string(), + }) + .await + .expect("speech replacement should save"); + assert!(!result.created); + + let after: GlobalConfig = service.get_config(None).await.expect("config after save"); + assert_eq!(after.ai.default_models.primary, None); + assert_eq!(after.ai.default_models.fast, None); + assert_eq!( + after.ai.default_models.speech_recognition.as_deref(), + Some("reused-model") + ); + assert!(after + .ai + .func_agent_models + .values() + .all(|model_id| { !matches!(model_id.as_str(), "reused-model") })); + } + #[tokio::test] async fn set_config_rejects_invalid_reasoning_and_rolls_back() { let (service, _dir) = test_service("invalid-reasoning-set").await; diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 725e9c952e..cfb540d906 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -77,6 +77,10 @@ pub struct GlobalConfig { /// Web UI font size preferences (`get_config` / `set_config` path `font`). #[serde(skip_serializing_if = "Option::is_none")] pub font: Option, + /// Version of the persisted configuration schema. This is intentionally + /// independent from the BitFun application version stored in `version`. + #[serde(default = "default_config_schema_version")] + pub schema_version: u32, pub version: String, #[serde(with = "chrono::serde::ts_milliseconds")] pub last_modified: chrono::DateTime, @@ -336,6 +340,32 @@ impl Default for VoiceInputConfig { } } +/// Domain request for atomically saving a cloud speech-recognition model and +/// selecting it for voice input. Text-generation fields are intentionally not +/// part of this contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_id: Option, + pub preset: String, + pub name: String, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_url: Option, + pub model_name: String, + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigResult { + pub model_id: String, + pub created: bool, +} + /// AI experience configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -519,6 +549,12 @@ pub enum ModelCapability { SpeechRecognition, } +pub const CURRENT_CONFIG_SCHEMA_VERSION: u32 = 1; + +fn default_config_schema_version() -> u32 { + CURRENT_CONFIG_SCHEMA_VERSION +} + /// Model category (for UI display and filtering). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] @@ -1578,6 +1614,33 @@ pub struct ConfigValidationResult { pub valid: bool, pub errors: Vec, pub warnings: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConfigDiagnosticSeverity { + Error, + Warning, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConfigDiagnosticRecoverability { + None, + AutoFix, + ModelDisabled, + DefaultsUsed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConfigDiagnostic { + pub path: String, + pub message: String, + pub code: String, + pub severity: ConfigDiagnosticSeverity, + pub recoverability: ConfigDiagnosticRecoverability, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1611,6 +1674,7 @@ impl Default for GlobalConfig { acp_clients: None, appearance: AppearanceConfig::default(), font: None, + schema_version: CURRENT_CONFIG_SCHEMA_VERSION, version: "1.0.0".to_string(), last_modified: chrono::Utc::now(), } @@ -1881,11 +1945,44 @@ impl Default for MinimapConfig { } impl AIModelConfig { + pub fn supports_capability(&self, capability: ModelCapability) -> bool { + if self.capabilities.is_empty() { + self.default_capabilities_for_category() + .contains(&capability) + } else { + self.capabilities.contains(&capability) + } + } + + pub fn supports_text_generation(&self) -> bool { + self.supports_capability(ModelCapability::TextChat) + } + + /// Canonicalizes fields that only have meaning for text-generation + /// requests. Returns the names of fields that were cleared. + pub fn normalize_inapplicable_generation_fields(&mut self) -> Vec<&'static str> { + if self.supports_text_generation() { + return Vec::new(); + } + + let mut cleared = Vec::new(); + if self.context_window.take().is_some() { + cleared.push("context_window"); + } + if self.max_tokens.take().is_some() { + cleared.push("max_tokens"); + } + if self.temperature.take().is_some() { + cleared.push("temperature"); + } + if self.top_p.take().is_some() { + cleared.push("top_p"); + } + cleared + } + pub fn supports_image_understanding(&self) -> bool { - self.capabilities - .iter() - .any(|cap| matches!(cap, ModelCapability::ImageUnderstanding)) - || matches!(self.category, ModelCategory::Multimodal) + self.supports_capability(ModelCapability::ImageUnderstanding) } /// Legacy helper that infers the model category from the model name and provider. diff --git a/src/crates/assembly/core/src/util/types/config.rs b/src/crates/assembly/core/src/util/types/config.rs index 0dc0960c23..f959aa6418 100644 --- a/src/crates/assembly/core/src/util/types/config.rs +++ b/src/crates/assembly/core/src/util/types/config.rs @@ -74,6 +74,13 @@ impl TryFrom for AIConfig { type Error = String; fn try_from(other: AIModelConfig) -> Result { + if !other.supports_text_generation() { + return Err(format!( + "Model '{}' does not support text_chat and cannot be used for text generation", + other.name + )); + } + let custom_request_body = if let Some(body_str) = &other.custom_request_body { match serde_json::from_str::(body_str) { Ok(value) => Some(value), @@ -156,7 +163,7 @@ impl TryFrom for AIConfig { #[cfg(test)] mod tests { use super::{resolve_request_url, AIConfig}; - use crate::service::config::types::{AIModelConfig, ModelCategory}; + use crate::service::config::types::{AIModelConfig, ModelCapability, ModelCategory}; #[test] fn resolves_openai_request_url() { @@ -313,4 +320,17 @@ mod tests { assert!(error.contains("at least 32000")); } + + #[test] + fn rejects_pure_speech_models_at_the_text_generation_boundary() { + let mut model = base_model_config(); + model.category = ModelCategory::SpeechRecognition; + model.capabilities = vec![ModelCapability::SpeechRecognition]; + model.context_window = None; + model.max_tokens = None; + + let error = AIConfig::try_from(model).expect_err("speech model is not a chat model"); + + assert!(error.contains("does not support text_chat")); + } } diff --git a/src/crates/interfaces/app-server-client/Cargo.toml b/src/crates/interfaces/app-server-client/Cargo.toml index 0b4e6131dc..c9cbbd936a 100644 --- a/src/crates/interfaces/app-server-client/Cargo.toml +++ b/src/crates/interfaces/app-server-client/Cargo.toml @@ -12,6 +12,7 @@ name = "bitfun_app_server_client" agent-client-protocol = { workspace = true } anyhow = { workspace = true } bitfun-app-server-protocol = { path = "../app-server-protocol" } +serde_json = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } [lints] diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index e8e638b796..e36783f081 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -7,6 +7,10 @@ use agent_client_protocol::{ConnectTo, ConnectionTo, JsonRpcResponse, SentReques use bitfun_app_server_protocol::app::{ HealthRequest, HealthResponse, InitializeRequest, InitializeResponse, }; +use bitfun_app_server_protocol::config::{ + SaveCloudSpeechConfigMessage, SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResponse, + SaveCloudSpeechConfigResult, ValidateConfigMessage, ValidateConfigResponse, +}; use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use bitfun_app_server_protocol::event::{ AgentEventNotification, ConfigEventNotification, EventStreamStateNotification, @@ -68,6 +72,26 @@ impl AppServerClient { self.rpc(|cx| Ok(cx.send_request(HealthRequest {}))).await } + pub async fn save_cloud_speech_config( + &self, + request: SaveCloudSpeechConfigRequest, + ) -> Result { + let SaveCloudSpeechConfigResponse(result) = self + .request_with_timeout( + |cx| Ok(cx.send_request(SaveCloudSpeechConfigMessage { request })), + SIDE_EFFECT_TIMEOUT, + ) + .await?; + Ok(result) + } + + pub async fn validate_config(&self) -> agent_client_protocol::Result { + let ValidateConfigResponse(result) = self + .rpc(|cx| Ok(cx.send_request(ValidateConfigMessage {}))) + .await?; + Ok(result) + } + pub async fn tui_model_catalog( &self, ) -> agent_client_protocol::Result { diff --git a/src/crates/interfaces/app-server-protocol/src/config.rs b/src/crates/interfaces/app-server-protocol/src/config.rs new file mode 100644 index 0000000000..c30fc57dba --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/config.rs @@ -0,0 +1,72 @@ +//! Configuration wire contracts shared by App Server hosts and clients. +//! +//! These payloads intentionally contain only wire-owned data. Server adapters +//! translate them to the configuration service's domain request/result types. + +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_id: Option, + pub preset: String, + pub name: String, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_url: Option, + pub model_name: String, + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/saveCloudSpeechConfig", response = SaveCloudSpeechConfigResponse)] +pub struct SaveCloudSpeechConfigMessage { + pub request: SaveCloudSpeechConfigRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigResult { + pub model_id: String, + pub created: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct SaveCloudSpeechConfigResponse(pub SaveCloudSpeechConfigResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "config/validateConfig", response = ValidateConfigResponse)] +pub struct ValidateConfigMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct ValidateConfigResponse(pub serde_json::Value); + +#[cfg(test)] +mod tests { + use super::SaveCloudSpeechConfigRequest; + + #[test] + fn cloud_speech_request_uses_the_camel_case_wire_shape() { + let value = serde_json::to_value(SaveCloudSpeechConfigRequest { + config_id: Some("speech".to_string()), + preset: "custom".to_string(), + name: "Speech".to_string(), + base_url: "https://example.com/v1".to_string(), + request_url: None, + model_name: "speech-model".to_string(), + api_key: "secret".to_string(), + }) + .expect("request should serialize"); + + assert_eq!(value["configId"], "speech"); + assert_eq!(value["baseUrl"], "https://example.com/v1"); + assert_eq!(value["modelName"], "speech-model"); + assert!(value.get("requestUrl").is_none()); + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/lib.rs b/src/crates/interfaces/app-server-protocol/src/lib.rs index 221d464ff0..097354530d 100644 --- a/src/crates/interfaces/app-server-protocol/src/lib.rs +++ b/src/crates/interfaces/app-server-protocol/src/lib.rs @@ -5,6 +5,7 @@ //! these wire DTOs to owner types at the interface boundary. pub mod app; +pub mod config; pub mod error; pub mod event; pub mod method; diff --git a/src/crates/interfaces/app-server/src/schema/config.rs b/src/crates/interfaces/app-server/src/schema/config.rs index 5827c087cb..20b3ac4c84 100644 --- a/src/crates/interfaces/app-server/src/schema/config.rs +++ b/src/crates/interfaces/app-server/src/schema/config.rs @@ -1,6 +1,11 @@ use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; +pub use bitfun_app_server_protocol::config::{ + SaveCloudSpeechConfigMessage, SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResponse, + SaveCloudSpeechConfigResult, ValidateConfigMessage, ValidateConfigResponse, +}; + #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[request(method = "config/getAgentProfileConfigs", response = GetAgentProfileConfigsResponse)] diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs index 372c110fdd..a33e7fee8e 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/app.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -159,6 +159,8 @@ fn registered_capabilities() -> Vec { "config/getConfig", "config/getConfigs", "config/setConfig", + "config/saveCloudSpeechConfig", + "config/validateConfig", "config/setAgentProfileConfig", "config/resetAgentProfileConfig", ], diff --git a/src/crates/interfaces/app-server/src/server/handlers/config.rs b/src/crates/interfaces/app-server/src/server/handlers/config.rs index e3aa7115e9..bbd13d5500 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/config.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/config.rs @@ -94,6 +94,50 @@ pub(in crate::server) fn builder() -> Builder { + if (!interactiveShellReady) { + return; + } + + let cancelled = false; + void (async () => { + try { + const { configAPI } = await import('@/infrastructure/api'); + const validation = await configAPI.validateConfig(); + const recoveryDiagnostics = (validation.diagnostics || []).filter(diagnostic => + diagnostic.code === 'CONFIG_DEFAULT_RECOVERY' || + diagnostic.code === 'CONFIG_SHAPE_REPAIRED' || + diagnostic.code === 'INVALID_MODEL_DISABLED' || + diagnostic.code === 'MODEL_FIELD_NOT_APPLICABLE' || + diagnostic.code === 'MODEL_REFERENCE_REPAIRED' + ); + if (cancelled || recoveryDiagnostics.length === 0) { + return; + } + const recoveryKey = `bitfun:config-recovery-notice:${recoveryDiagnostics + .map(diagnostic => `${diagnostic.code}:${diagnostic.path}`) + .join('|')}`; + if (sessionStorage.getItem(recoveryKey) === 'shown') { + return; + } + sessionStorage.setItem(recoveryKey, 'shown'); + notificationService.warning(t('logging.configRecovery.message', { + count: recoveryDiagnostics.length, + }), { + title: t('logging.configRecovery.title'), + duration: 0, + metadata: { + source: 'config-startup-recovery', + diagnosticCodes: recoveryDiagnostics.map(diagnostic => diagnostic.code), + diagnosticPaths: recoveryDiagnostics.map(diagnostic => diagnostic.path), + }, + }); + } catch (error) { + log.warn('Failed to check configuration recovery status', error); + } + })(); + + return () => { + cancelled = true; + }; + }, [interactiveShellReady, t]); + // Unified layout via a single AppLayout return ( diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts index dc16e2f65d..b687def46a 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts @@ -108,6 +108,10 @@ describe('resolveWsMethod', () => { 'config/resetAgentProfileConfig' ); expect(resolveWsMethod('set_config')).toBe('config/setConfig'); + expect(resolveWsMethod('save_cloud_speech_config')).toBe( + 'config/saveCloudSpeechConfig' + ); + expect(resolveWsMethod('validate_config')).toBe('config/validateConfig'); expect(resolveWsMethod('i18n_get_current_language')).toBe( 'i18n/getCurrentLanguage' ); @@ -134,11 +138,12 @@ describe('resolveWsMethod', () => { // Runtime sanity: the schema entry carries the method string and the table // covers the schema methods (key count is stable; ordering is not pinned // because the table is a plain object). Track B Batch 1 added config write + - // i18n and the P0 Session/Config control plane, raising the count to 31. + // i18n and the P0 Session/Config control plane. Atomic cloud-speech save + // and config validation raise the count to 33. expect(AGENT_COMMAND_SCHEMA.start_dialog_turn.method).toBe( 'agent/submitDialogTurn' ); - expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(31); + expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(33); // Touch the locals so noUnusedLocals does not flag them under vitest's // transformed build (tsc --noEmit is the real gate; this is belt-and-suspenders). diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts index 122ebc7829..98194d740e 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -171,6 +171,8 @@ export const AGENT_COMMAND_SCHEMA = { response: null as unknown as ResetAgentProfileConfigResponse, }, set_config: { method: 'config/setConfig' }, + save_cloud_speech_config: { method: 'config/saveCloudSpeechConfig' }, + validate_config: { method: 'config/validateConfig' }, i18n_get_current_language: { method: 'i18n/getCurrentLanguage' }, i18n_set_language: { method: 'i18n/setLanguage' }, i18n_get_config: { method: 'i18n/getConfig' }, diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts index d5328ffdc8..d07b621601 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts @@ -71,4 +71,28 @@ describe('ConfigAPI batch config reads', () => { }, }, undefined); }); + + it('saves cloud speech configuration through one domain command', async () => { + invokeMock.mockResolvedValueOnce({ modelId: 'speech-1', created: true }); + + await expect(configAPI.saveCloudSpeechConfig({ + preset: 'qwen', + name: 'Qwen ASR', + baseUrl: 'https://example.com/v1', + requestUrl: 'https://example.com/v1/audio/transcriptions', + modelName: 'qwen-asr', + apiKey: 'secret', + })).resolves.toEqual({ modelId: 'speech-1', created: true }); + + expect(invokeMock).toHaveBeenCalledWith('save_cloud_speech_config', { + request: { + preset: 'qwen', + name: 'Qwen ASR', + baseUrl: 'https://example.com/v1', + requestUrl: 'https://example.com/v1/audio/transcriptions', + modelName: 'qwen-asr', + apiKey: 'secret', + }, + }); + }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts index cd953cbb27..c25e5ecf16 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts @@ -8,12 +8,19 @@ import type { GlobalSkillSettings, ModeSkillInfo, RuntimeLoggingInfo, + ConfigValidationResult, SkillInfo, SkillLevel, SkillMarketDownloadResult, SkillMarketItem, SkillValidationResult, } from '../../config/types'; +import type { + SaveCloudSpeechConfigRequest, + SaveCloudSpeechConfigResult, +} from '@/generated/api'; + +export type { SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResult } from '@/generated/api'; export interface GetSkillConfigsParams { forceRefresh?: boolean; @@ -136,6 +143,27 @@ export class ConfigAPI { } } + async saveCloudSpeechConfig( + request: SaveCloudSpeechConfigRequest + ): Promise { + try { + return await api.invoke('save_cloud_speech_config', { request }); + } catch (error) { + throw createTauriCommandError('save_cloud_speech_config', error, { + ...request, + apiKey: request.apiKey ? '[redacted]' : '', + }); + } + } + + async validateConfig(): Promise { + try { + return await api.invoke('validate_config'); + } catch (error) { + throw createTauriCommandError('validate_config', error); + } + } + async resetConfig(path?: string): Promise { try { diff --git a/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx b/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx index 5425582e46..c43e093da2 100644 --- a/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx @@ -351,51 +351,30 @@ const VoiceInputConfig: React.FC = () => { setBusyAction('saveCloudModel'); try { - const allModels = await configManager.getConfig('ai.models') || []; const modelId = cloudDraft.configId || selectedCloudModel?.id || `speech_cloud_${Date.now()}`; - const nextModel: AIModelConfig = { - id: modelId, + const result = await configManager.saveCloudSpeechConfig({ + configId: modelId, + preset: cloudDraft.preset, name, - provider: 'openai', - api_key: apiKey, - base_url: baseUrl, - request_url: resolveTranscriptionRequestUrl(baseUrl), - model_name: modelName, - context_window: 0, - max_tokens: 0, - enabled: true, - category: 'speech_recognition', - capabilities: ['speech_recognition'], - recommended_for: ['voice_input'], - metadata: { - ...(selectedCloudModel?.metadata || {}), - speech_provider_preset: cloudDraft.preset, - }, - auth: { type: 'api_key' }, - }; - const replaced = allModels.some(model => model.id === modelId); - const nextModels = replaced - ? allModels.map(model => model.id === modelId ? nextModel : model) - : [...allModels, nextModel]; - const currentDefaultModels = await configManager.getConfig('ai.default_models') || {}; - - await configManager.setConfig('ai.models', nextModels); - await configManager.setConfig('ai.default_models', { - ...currentDefaultModels, - speech_recognition: modelId, + baseUrl, + requestUrl: resolveTranscriptionRequestUrl(baseUrl), + modelName, + apiKey, }); - await updateVoiceInput({ - provider: 'cloud', - model_id: modelId, - }, { silent: true }); - setCloudDraft(createCloudSpeechDraftFromModel(nextModel)); - setCloudModels(nextModels.filter(model => { + const [nextModels, nextDefaultModels] = await Promise.all([ + configManager.getConfig('ai.models'), + configManager.getConfig('ai.default_models'), + ]); + const savedModel = (nextModels || []).find(model => model.id === result.modelId); + setCloudDraft(createCloudSpeechDraftFromModel(savedModel)); + setCloudModels((nextModels || []).filter(model => { const capabilities = Array.isArray(model.capabilities) ? model.capabilities : []; return !!model.enabled && ( model.category === 'speech_recognition' || capabilities.includes('speech_recognition') ); })); + setDefaultModels(nextDefaultModels || {}); notificationService.success(t('cloudConfig.messages.saveSuccess')); } catch (error) { log.error('Failed to save cloud speech model', { error }); @@ -403,7 +382,7 @@ const VoiceInputConfig: React.FC = () => { } finally { setBusyAction(null); } - }, [cloudDraft, selectedCloudModel, t, updateVoiceInput]); + }, [cloudDraft, selectedCloudModel, t]); const handleDownload = useCallback((model: SpeechModelStatus) => { if (model.state === 'downloading') return; diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts index cb6acd9991..9d7f17d1cd 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts @@ -603,6 +603,22 @@ class ConfigManagerImpl implements IConfigManager { } } + async saveCloudSpeechConfig( + request: import('@/infrastructure/api/service-api/ConfigAPI').SaveCloudSpeechConfigRequest + ): Promise { + let result: import('@/infrastructure/api/service-api/ConfigAPI').SaveCloudSpeechConfigResult | undefined; + await this.runMutation(undefined, async () => { + result = await configAPI.saveCloudSpeechConfig(request); + }, () => { + this.notifyConfigChange('ai', undefined, undefined); + this.notifyConfigChange('app.ai_experience', undefined, undefined); + }); + if (!result) { + throw new Error('Cloud speech configuration save returned no result'); + } + return result; + } + async resetConfig(path?: string): Promise { try { await this.runMutation(path, () => configAPI.resetConfig(path)); @@ -614,10 +630,7 @@ class ConfigManagerImpl implements IConfigManager { async validateConfig(): Promise { try { - - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke('validate_config'); - return result; + return await configAPI.validateConfig(); } catch (error) { log.error('Failed to validate config', error); return { diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 631e8f4661..330e56338d 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -658,6 +658,15 @@ export interface ConfigValidationResult { valid: boolean; errors: ConfigValidationError[]; warnings: ConfigValidationWarning[]; + diagnostics?: ConfigDiagnostic[]; +} + +export interface ConfigDiagnostic { + path: string; + message: string; + code: string; + severity: 'error' | 'warning'; + recoverability: 'none' | 'auto_fix' | 'model_disabled' | 'defaults_used'; } export interface ConfigValidationError { diff --git a/src/web-ui/src/locales/en-US/settings/basics.json b/src/web-ui/src/locales/en-US/settings/basics.json index 7f7d38d3eb..ddf7d13fd4 100644 --- a/src/web-ui/src/locales/en-US/settings/basics.json +++ b/src/web-ui/src/locales/en-US/settings/basics.json @@ -147,6 +147,10 @@ "title": "Previous session ended unexpectedly", "message": "BitFun found a crash report from the previous session. You can export diagnostics if you want to report the issue." }, + "configRecovery": { + "title": "Configuration recovered", + "message": "BitFun repaired or isolated {{count}} configuration issue(s) so the app could start. Review your model settings before continuing." + }, "levels": { "trace": "Trace", "debug": "Debug", diff --git a/src/web-ui/src/locales/zh-CN/settings/basics.json b/src/web-ui/src/locales/zh-CN/settings/basics.json index 8f26d5a369..2df39b45d0 100644 --- a/src/web-ui/src/locales/zh-CN/settings/basics.json +++ b/src/web-ui/src/locales/zh-CN/settings/basics.json @@ -147,6 +147,10 @@ "title": "上次会话异常结束", "message": "BitFun 发现上次会话留下了崩溃报告。如需反馈问题,可以导出诊断包。" }, + "configRecovery": { + "title": "配置已恢复", + "message": "BitFun 已修复或隔离 {{count}} 个配置问题,应用得以继续启动。请检查模型设置后再继续使用。" + }, "levels": { "trace": "Trace", "debug": "Debug", diff --git a/src/web-ui/src/locales/zh-TW/settings/basics.json b/src/web-ui/src/locales/zh-TW/settings/basics.json index 95655053fc..74cfb41d20 100644 --- a/src/web-ui/src/locales/zh-TW/settings/basics.json +++ b/src/web-ui/src/locales/zh-TW/settings/basics.json @@ -133,6 +133,10 @@ "title": "上次會話異常結束", "message": "BitFun 發現上次會話留下了崩潰報告。如需回報問題,可以匯出診斷包。" }, + "configRecovery": { + "title": "設定已復原", + "message": "BitFun 已修復或隔離 {{count}} 個設定問題,應用程式得以繼續啟動。請檢查模型設定後再繼續使用。" + }, "levels": { "trace": "Trace", "debug": "Debug", From 059e8b94a58ce23bf8418e2779699a804a4bc550 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 7 Aug 2026 10:59:17 +0800 Subject: [PATCH 2/3] fix(desktop): prevent Windows debug startup stack overflow - Reserve an 8 MiB stack for the Windows desktop process entry thread. - Set RUST_MIN_STACK before constructing the Tokio runtime. - Preserve configuration recovery and cloud speech command behavior. --- src/apps/desktop/build.rs | 5 +++++ src/apps/desktop/src/main.rs | 14 ++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index 261851f6b6..d84e5ec5db 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -1,3 +1,8 @@ fn main() { + // The Windows primary thread keeps the Tauri event loop and native window + // creation stack. Reserve the same headroom as the Tokio workers so a + // large debug invoke dispatcher cannot exhaust the default 1 MiB stack. + #[cfg(target_os = "windows")] + println!("cargo:rustc-link-arg-bins=/STACK:8388608"); tauri_build::build(); } diff --git a/src/apps/desktop/src/main.rs b/src/apps/desktop/src/main.rs index d230b69a75..af5c4c0261 100644 --- a/src/apps/desktop/src/main.rs +++ b/src/apps/desktop/src/main.rs @@ -2,8 +2,14 @@ // plugin redirects them back to the existing desktop process. #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] -#[tokio::main(flavor = "multi_thread", worker_threads = 4)] -async fn main() { - std::env::set_var("RUST_MIN_STACK", "8388608"); // 8MB - bitfun_desktop_lib::run().await +fn main() { + // Tokio reads this value while creating its worker threads. Setting it in + // the async body is too late, because the runtime has already been built. + std::env::set_var("RUST_MIN_STACK", "8388608"); // 8 MiB worker stacks + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .expect("failed to build Tokio runtime"); + runtime.block_on(bitfun_desktop_lib::run()); } From 9b88a9415de570294493bf1916f6459a30700743 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 7 Aug 2026 11:33:25 +0800 Subject: [PATCH 3/3] fix(web): generate app-server protocol bindings in CI - Export TypeScript bindings from bitfun-app-server-protocol before app-server bindings. - Require SaveCloudSpeechConfigRequest and SaveCloudSpeechConfigResult in the API barrel. - Prevent stale local generated files from masking missing CI bindings. --- src/web-ui/package.json | 2 +- src/web-ui/scripts/gen-api-barrel.mjs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 664c19c6ba..86a5b8dc6e 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "dev:force": "vite --force", - "gen:types": "cargo test --package bitfun-app-server --features ts --no-default-features export -- --nocapture && node scripts/gen-api-barrel.mjs", + "gen:types": "cargo test --package bitfun-app-server-protocol --features ts export -- --nocapture && cargo test --package bitfun-app-server --features ts --no-default-features export -- --nocapture && node scripts/gen-api-barrel.mjs", "build": "vite build", "build:desktop": "vite build --mode desktop", "build:web": "vite build --mode web", diff --git a/src/web-ui/scripts/gen-api-barrel.mjs b/src/web-ui/scripts/gen-api-barrel.mjs index d49b1e5d2f..7f32f0423d 100644 --- a/src/web-ui/scripts/gen-api-barrel.mjs +++ b/src/web-ui/scripts/gen-api-barrel.mjs @@ -23,7 +23,11 @@ const files = (await readdir(dir, { withFileTypes: true })) .map((e) => basename(e.name, extname(e.name))) .sort(); -const requiredTypes = ['ConfigUpdate']; +const requiredTypes = [ + 'ConfigUpdate', + 'SaveCloudSpeechConfigRequest', + 'SaveCloudSpeechConfigResult', +]; const missingTypes = requiredTypes.filter((typeName) => !files.includes(typeName)); if (missingTypes.length > 0) { throw new Error(