From 329fb7d0239e09b8b7619499a00382f35f477e98 Mon Sep 17 00:00:00 2001 From: Sam Hoang Van Date: Mon, 23 Jun 2025 12:20:05 +0700 Subject: [PATCH 01/17] Feat: auto restart mcp (#5226) * feat: implement retry mechanism for MCP server activation with exponential backoff feat: enhance MCP server activation with configurable retry attempts feat: implement MCP server restart monitoring and cleanup functionality feat: enhance MCP server restart logic with improved monitoring and configuration handling feat: add manual deactivation for MCP servers to prevent automatic restarts * feat: enhance MCP server startup with initial attempt tracking and health monitoring --- src-tauri/src/core/mcp.rs | 897 +++++++++++++++++++++++++++++------- src-tauri/src/core/setup.rs | 32 +- src-tauri/src/core/state.rs | 3 + src-tauri/src/lib.rs | 5 + 4 files changed, 780 insertions(+), 157 deletions(-) diff --git a/src-tauri/src/core/mcp.rs b/src-tauri/src/core/mcp.rs index f9509c8e5..e340a9aaa 100644 --- a/src-tauri/src/core/mcp.rs +++ b/src-tauri/src/core/mcp.rs @@ -3,8 +3,12 @@ use rmcp::{service::RunningService, transport::TokioChildProcess, RoleClient, Se use serde_json::{Map, Value}; use std::fs; use std::{collections::HashMap, env, sync::Arc, time::Duration}; -use tauri::{AppHandle, Emitter, Runtime, State}; -use tokio::{process::Command, sync::Mutex, time::timeout}; +use tauri::{AppHandle, Emitter, Manager, Runtime, State}; +use tokio::{ + process::Command, + sync::Mutex, + time::{sleep, timeout}, +}; use super::{cmd::get_jan_data_folder_path, state::AppState}; @@ -51,6 +55,58 @@ const DEFAULT_MCP_CONFIG: &str = r#"{ // Timeout for MCP tool calls (30 seconds) const MCP_TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(30); +// MCP server restart configuration with exponential backoff +const MCP_BASE_RESTART_DELAY_MS: u64 = 1000; // Start with 1 second +const MCP_MAX_RESTART_DELAY_MS: u64 = 30000; // Cap at 30 seconds +const MCP_BACKOFF_MULTIPLIER: f64 = 2.0; // Double the delay each time + +/// Calculate exponential backoff delay with jitter +/// +/// # Arguments +/// * `attempt` - The current restart attempt number (1-based) +/// +/// # Returns +/// * `u64` - Delay in milliseconds, capped at MCP_MAX_RESTART_DELAY_MS +fn calculate_exponential_backoff_delay(attempt: u32) -> u64 { + use std::cmp; + + // Calculate base exponential delay: base_delay * multiplier^(attempt-1) + let exponential_delay = (MCP_BASE_RESTART_DELAY_MS as f64) + * MCP_BACKOFF_MULTIPLIER.powi((attempt - 1) as i32); + + // Cap the delay at maximum + let capped_delay = cmp::min(exponential_delay as u64, MCP_MAX_RESTART_DELAY_MS); + + // Add jitter (±25% randomness) to prevent thundering herd + let jitter_range = (capped_delay as f64 * 0.25) as u64; + let jitter = if jitter_range > 0 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + // Use attempt number as seed for deterministic but varied jitter + let mut hasher = DefaultHasher::new(); + attempt.hash(&mut hasher); + let hash = hasher.finish(); + + // Convert hash to jitter value in range [-jitter_range, +jitter_range] + let jitter_offset = (hash % (jitter_range * 2)) as i64 - jitter_range as i64; + jitter_offset + } else { + 0 + }; + + // Apply jitter while ensuring delay stays positive and within bounds + let final_delay = cmp::max( + 100, // Minimum 100ms delay + cmp::min( + MCP_MAX_RESTART_DELAY_MS, + (capped_delay as i64 + jitter) as u64 + ) + ); + + final_delay +} + /// Runs MCP commands by reading configuration from a JSON file and initializing servers /// /// # Arguments @@ -70,46 +126,361 @@ pub async fn run_mcp_commands( "Load MCP configs from {}", app_path_str.clone() + "/mcp_config.json" ); - let config_content = std::fs::read_to_string(app_path_str.clone() + "/mcp_config.json") - .map_err(|e| format!("Failed to read config file: {}", e))?; + let config_content = std::fs::read_to_string(app_path_str + "/mcp_config.json") + .map_err(|e| format!("Failed to read config file: {e}"))?; let mcp_servers: serde_json::Value = serde_json::from_str(&config_content) - .map_err(|e| format!("Failed to parse config: {}", e))?; + .map_err(|e| format!("Failed to parse config: {e}"))?; - if let Some(server_map) = mcp_servers.get("mcpServers").and_then(Value::as_object) { - log::trace!("MCP Servers: {server_map:#?}"); + let server_map = mcp_servers + .get("mcpServers") + .and_then(Value::as_object) + .ok_or("No mcpServers found in config")?; - for (name, config) in server_map { - if let Some(false) = extract_active_status(config) { - log::trace!("Server {name} is not active, skipping."); - continue; + log::trace!("MCP Servers: {server_map:#?}"); + + // Collect handles for initial server startup + let mut startup_handles = Vec::new(); + + for (name, config) in server_map { + if extract_active_status(config) == Some(false) { + log::trace!("Server {name} is not active, skipping."); + continue; + } + + let app_clone = app.clone(); + let servers_clone = servers_state.clone(); + let name_clone = name.clone(); + let config_clone = config.clone(); + + // Spawn task for initial startup attempt + let handle = tokio::spawn(async move { + // Only wait for the initial startup attempt, not the monitoring + let result = start_mcp_server_with_restart( + app_clone.clone(), + servers_clone.clone(), + name_clone.clone(), + config_clone.clone(), + Some(3), // Default max restarts for startup + ).await; + + // If initial startup failed, we still want to continue with other servers + if let Err(e) = &result { + log::error!("Initial startup failed for MCP server {}: {}", name_clone, e); } - match start_mcp_server( - app.clone(), - servers_state.clone(), - name.clone(), - config.clone(), - ) - .await - { - Ok(_) => { - log::info!("Server {name} activated successfully."); - } - Err(e) => { - let _ = app.emit( - "mcp-error", - format!("Failed to activate MCP server {name}: {e}"), - ); - log::error!("Failed to activate server {name}: {e}"); - continue; // Skip to the next server + + (name_clone, result) + }); + + startup_handles.push(handle); + } + + // Wait for all initial startup attempts to complete + let mut successful_count = 0; + let mut failed_count = 0; + + for handle in startup_handles { + match handle.await { + Ok((name, result)) => { + match result { + Ok(_) => { + log::info!("MCP server {} initialized successfully", name); + successful_count += 1; + } + Err(e) => { + log::error!("MCP server {} failed to initialize: {}", name, e); + failed_count += 1; + } } } + Err(e) => { + log::error!("Failed to join startup task: {}", e); + failed_count += 1; + } } } + + log::info!( + "MCP server initialization complete: {} successful, {} failed", + successful_count, + failed_count + ); Ok(()) } +/// Monitor MCP server health without removing it from the HashMap +async fn monitor_mcp_server_handle( + servers_state: Arc>>>, + name: String, +) -> Option { + log::info!("Monitoring MCP server {} health", name); + + // Monitor server health with periodic checks + loop { + // Small delay between health checks + sleep(Duration::from_secs(5)).await; + + // Check if server is still healthy by trying to list tools + let health_check_result = { + let servers = servers_state.lock().await; + if let Some(service) = servers.get(&name) { + // Try to list tools as a health check with a short timeout + match timeout(Duration::from_secs(2), service.list_all_tools()).await { + Ok(Ok(_)) => { + // Server responded successfully + true + } + Ok(Err(e)) => { + log::warn!("MCP server {} health check failed: {}", name, e); + false + } + Err(_) => { + log::warn!("MCP server {} health check timed out", name); + false + } + } + } else { + // Server was removed from HashMap (e.g., by deactivate_mcp_server) + log::info!("MCP server {} no longer in running services", name); + return Some(rmcp::service::QuitReason::Closed); + } + }; + + if !health_check_result { + // Server failed health check - remove it and return + log::error!("MCP server {} failed health check, removing from active servers", name); + let mut servers = servers_state.lock().await; + if let Some(service) = servers.remove(&name) { + // Try to cancel the service gracefully + let _ = service.cancel().await; + } + return Some(rmcp::service::QuitReason::Closed); + } + } +} + +/// Starts an MCP server with restart monitoring (similar to cortex restart) +/// Returns the result of the first start attempt, then continues with restart monitoring +async fn start_mcp_server_with_restart( + app: AppHandle, + servers_state: Arc>>>, + name: String, + config: Value, + max_restarts: Option, +) -> Result<(), String> { + let app_state = app.state::(); + let restart_counts = app_state.mcp_restart_counts.clone(); + let active_servers_state = app_state.mcp_active_servers.clone(); + let successfully_connected = app_state.mcp_successfully_connected.clone(); + + // Store active server config for restart purposes + store_active_server_config(&active_servers_state, &name, &config).await; + + let max_restarts = max_restarts.unwrap_or(5); + + // Try the first start attempt and return its result + log::info!("Starting MCP server {} (Initial attempt)", name); + let first_start_result = schedule_mcp_start_task( + app.clone(), + servers_state.clone(), + name.clone(), + config.clone(), + ).await; + + match first_start_result { + Ok(_) => { + log::info!("MCP server {} started successfully on first attempt", name); + reset_restart_count(&restart_counts, &name).await; + + // Check if server was marked as successfully connected (passed verification) + let was_verified = { + let connected = successfully_connected.lock().await; + connected.get(&name).copied().unwrap_or(false) + }; + + if was_verified { + // Only spawn monitoring task if server passed verification + spawn_server_monitoring_task( + app, + servers_state, + name, + config, + max_restarts, + restart_counts, + successfully_connected, + ).await; + + Ok(()) + } else { + // Server failed verification, don't monitor for restarts + log::error!("MCP server {} failed verification after startup", name); + Err(format!("MCP server {} failed verification after startup", name)) + } + } + Err(e) => { + log::error!("Failed to start MCP server {} on first attempt: {}", name, e); + Err(e) + } + } +} + +/// Helper function to handle the restart loop logic +async fn start_restart_loop( + app: AppHandle, + servers_state: Arc>>>, + name: String, + config: Value, + max_restarts: u32, + restart_counts: Arc>>, + successfully_connected: Arc>>, +) { + loop { + let current_restart_count = { + let mut counts = restart_counts.lock().await; + let count = counts.entry(name.clone()).or_insert(0); + *count += 1; + *count + }; + + if current_restart_count > max_restarts { + log::error!( + "MCP server {} reached maximum restart attempts ({}). Giving up.", + name, + max_restarts + ); + if let Err(e) = app.emit("mcp_max_restarts_reached", + serde_json::json!({ + "server": name, + "max_restarts": max_restarts + }) + ) { + log::error!("Failed to emit mcp_max_restarts_reached event: {e}"); + } + break; + } + + log::info!( + "Restarting MCP server {} (Attempt {}/{})", + name, + current_restart_count, + max_restarts + ); + + // Calculate exponential backoff delay + let delay_ms = calculate_exponential_backoff_delay(current_restart_count); + log::info!( + "Waiting {}ms before restart attempt {} for MCP server {}", + delay_ms, + current_restart_count, + name + ); + sleep(Duration::from_millis(delay_ms)).await; + + // Attempt to restart the server + let start_result = schedule_mcp_start_task( + app.clone(), + servers_state.clone(), + name.clone(), + config.clone(), + ).await; + + match start_result { + Ok(_) => { + log::info!("MCP server {} restarted successfully.", name); + + // Check if server passed verification (was marked as successfully connected) + let passed_verification = { + let connected = successfully_connected.lock().await; + connected.get(&name).copied().unwrap_or(false) + }; + + if !passed_verification { + log::error!( + "MCP server {} failed verification after restart - stopping permanently", + name + ); + break; + } + + // Reset restart count on successful restart with verification + { + let mut counts = restart_counts.lock().await; + if let Some(count) = counts.get_mut(&name) { + if *count > 0 { + log::info!( + "MCP server {} restarted successfully, resetting restart count from {} to 0.", + name, + *count + ); + *count = 0; + } + } + } + + // Monitor the server again + let quit_reason = monitor_mcp_server_handle( + servers_state.clone(), + name.clone(), + ).await; + + log::info!("MCP server {} quit with reason: {:?}", name, quit_reason); + + // Check if server was marked as successfully connected + let was_connected = { + let connected = successfully_connected.lock().await; + connected.get(&name).copied().unwrap_or(false) + }; + + // Only continue restart loop if server was previously connected + if !was_connected { + log::error!( + "MCP server {} failed before establishing successful connection - stopping permanently", + name + ); + break; + } + + // Determine if we should restart based on quit reason + let should_restart = match quit_reason { + Some(reason) => { + log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason); + true + } + None => { + log::info!("MCP server {} was manually stopped - not restarting", name); + false + } + }; + + if !should_restart { + break; + } + // Continue the loop for another restart attempt + } + Err(e) => { + log::error!("Failed to restart MCP server {}: {}", name, e); + + // Check if server was marked as successfully connected before + let was_connected = { + let connected = successfully_connected.lock().await; + connected.get(&name).copied().unwrap_or(false) + }; + + // Only continue restart attempts if server was previously connected + if !was_connected { + log::error!( + "MCP server {} failed restart and was never successfully connected - stopping permanently", + name + ); + break; + } + // Continue the loop for another restart attempt + } + } + } +} + #[tauri::command] pub async fn activate_mcp_server( app: tauri::AppHandle, @@ -119,10 +490,12 @@ pub async fn activate_mcp_server( ) -> Result<(), String> { let servers: Arc>>> = state.mcp_servers.clone(); - start_mcp_server(app, servers, name, config).await + + // Use the modified start_mcp_server_with_restart that returns first attempt result + start_mcp_server_with_restart(app, servers, name, config, Some(3)).await } -async fn start_mcp_server( +async fn schedule_mcp_start_task( app: tauri::AppHandle, servers: Arc>>>, name: String, @@ -134,113 +507,159 @@ async fn start_mcp_server( .parent() .expect("Executable must have a parent directory"); let bin_path = exe_parent_path.to_path_buf(); - if let Some((command, args, envs)) = extract_command_args(&config) { - let mut cmd = Command::new(command.clone()); - if command.clone() == "npx" { - let mut cache_dir = app_path.clone(); - cache_dir.push(".npx"); - let bun_x_path = format!("{}/bun", bin_path.display()); - cmd = Command::new(bun_x_path); - cmd.arg("x"); - cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string()); - } + + let (command, args, envs) = extract_command_args(&config) + .ok_or_else(|| format!("Failed to extract command args from config for {name}"))?; - if command.clone() == "uvx" { - let mut cache_dir = app_path.clone(); - cache_dir.push(".uvx"); - let bun_x_path = format!("{}/uv", bin_path.display()); - cmd = Command::new(bun_x_path); - cmd.arg("tool"); - cmd.arg("run"); - cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string()); - } - #[cfg(windows)] - { - cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows - } - let app_path_str = app_path.to_str().unwrap().to_string(); - let log_file_path = format!("{}/logs/app.log", app_path_str); - match std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_file_path) - { - Ok(file) => { - cmd.stderr(std::process::Stdio::from(file)); - } - Err(err) => { - log::error!("Failed to open log file: {}", err); - } - }; - - cmd.kill_on_drop(true); - - log::trace!("Command: {cmd:#?}"); - - args.iter().filter_map(Value::as_str).for_each(|arg| { - cmd.arg(arg); - }); - envs.iter().for_each(|(k, v)| { - if let Some(v_str) = v.as_str() { - cmd.env(k, v_str); - } - }); - - let process = TokioChildProcess::new(cmd); - match process { - Ok(p) => { - let service = ().serve(p).await; - - match service { - Ok(running_service) => { - // Get peer info and clone the needed values before moving the service - let (server_name, server_version) = { - let server_info = running_service.peer_info(); - log::trace!("Connected to server: {server_info:#?}"); - ( - server_info.server_info.name.clone(), - server_info.server_info.version.clone(), - ) - }; - - // Now move the service into the HashMap - servers.lock().await.insert(name.clone(), running_service); - log::info!("Server {name} started successfully."); - - // Emit event to the frontend - let event = format!("mcp-connected"); - let payload = serde_json::json!({ - "name": server_name, - "version": server_version, - }); - app.emit(&event, payload) - .map_err(|e| format!("Failed to emit event: {}", e))?; - } - Err(e) => { - return Err(format!("Failed to start MCP server {name}: {e}")); - } - } - } - Err(e) => { - log::error!("Failed to run command {name}: {e}"); - return Err(format!("Failed to run command {name}: {e}")); - } - } + let mut cmd = Command::new(command.clone()); + + if command == "npx" { + let mut cache_dir = app_path.clone(); + cache_dir.push(".npx"); + let bun_x_path = format!("{}/bun", bin_path.display()); + cmd = Command::new(bun_x_path); + cmd.arg("x"); + cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string()); } + + if command == "uvx" { + let mut cache_dir = app_path.clone(); + cache_dir.push(".uvx"); + let bun_x_path = format!("{}/uv", bin_path.display()); + cmd = Command::new(bun_x_path); + cmd.arg("tool"); + cmd.arg("run"); + cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string()); + } + + #[cfg(windows)] + { + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows + } + + let app_path_str = app_path.to_str().unwrap().to_string(); + let log_file_path = format!("{}/logs/app.log", app_path_str); + match std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_file_path) + { + Ok(file) => { + cmd.stderr(std::process::Stdio::from(file)); + } + Err(err) => { + log::error!("Failed to open log file: {}", err); + } + }; + + cmd.kill_on_drop(true); + log::trace!("Command: {cmd:#?}"); + + args.iter().filter_map(Value::as_str).for_each(|arg| { + cmd.arg(arg); + }); + envs.iter().for_each(|(k, v)| { + if let Some(v_str) = v.as_str() { + cmd.env(k, v_str); + } + }); + + let process = TokioChildProcess::new(cmd) + .map_err(|e| { + log::error!("Failed to run command {name}: {e}"); + format!("Failed to run command {name}: {e}") + })?; + + let service = ().serve(process).await + .map_err(|e| format!("Failed to start MCP server {name}: {e}"))?; + + // Get peer info and clone the needed values before moving the service + let (server_name, server_version) = { + let server_info = service.peer_info(); + log::trace!("Connected to server: {server_info:#?}"); + ( + server_info.server_info.name.clone(), + server_info.server_info.version.clone(), + ) + }; + + // Now move the service into the HashMap + servers.lock().await.insert(name.clone(), service); + log::info!("Server {name} started successfully."); + + // Wait a short time to verify the server is stable before marking as connected + // This prevents race conditions where the server quits immediately + let verification_delay = Duration::from_millis(500); + sleep(verification_delay).await; + + // Check if server is still running after the verification delay + let server_still_running = { + let servers_map = servers.lock().await; + servers_map.contains_key(&name) + }; + + if !server_still_running { + return Err(format!("MCP server {} quit immediately after starting", name)); + } + + // Mark server as successfully connected (for restart policy) + { + let app_state = app.state::(); + let mut connected = app_state.mcp_successfully_connected.lock().await; + connected.insert(name.clone(), true); + log::info!("Marked MCP server {} as successfully connected", name); + } + + // Emit event to the frontend + let event = format!("mcp-connected"); + let payload = serde_json::json!({ + "name": server_name, + "version": server_version, + }); + app.emit(&event, payload) + .map_err(|e| format!("Failed to emit event: {}", e))?; + Ok(()) } #[tauri::command] pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) -> Result<(), String> { + log::info!("Deactivating MCP server: {}", name); + + // First, mark server as manually deactivated to prevent restart + // Remove from active servers list to prevent restart + { + let mut active_servers = state.mcp_active_servers.lock().await; + active_servers.remove(&name); + log::info!("Removed MCP server {} from active servers list", name); + } + + // Mark as not successfully connected to prevent restart logic + { + let mut connected = state.mcp_successfully_connected.lock().await; + connected.insert(name.clone(), false); + log::info!("Marked MCP server {} as not successfully connected", name); + } + + // Reset restart count + { + let mut counts = state.mcp_restart_counts.lock().await; + counts.remove(&name); + log::info!("Reset restart count for MCP server {}", name); + } + + // Now remove and stop the server let servers = state.mcp_servers.clone(); let mut servers_map = servers.lock().await; - if let Some(service) = servers_map.remove(&name) { - service.cancel().await.map_err(|e| e.to_string())?; - log::info!("Server {name} stopped successfully."); - } else { - return Err(format!("Server {} not found", name)); - } + let service = servers_map.remove(&name) + .ok_or_else(|| format!("Server {} not found", name))?; + + // Release the lock before calling cancel + drop(servers_map); + + service.cancel().await.map_err(|e| e.to_string())?; + log::info!("Server {name} stopped successfully and marked as deactivated."); Ok(()) } @@ -270,11 +689,83 @@ pub async fn restart_mcp_servers(app: AppHandle, state: State<'_, AppState>) -> // Stop the servers stop_mcp_servers(state.mcp_servers.clone()).await?; - // Restart the servers - run_mcp_commands(&app, servers).await?; + // Restart only previously active servers (like cortex) + restart_active_mcp_servers(&app, servers).await?; app.emit("mcp-update", "MCP servers updated") - .map_err(|e| format!("Failed to emit event: {}", e)) + .map_err(|e| format!("Failed to emit event: {}", e))?; + + Ok(()) +} + +/// Restart only servers that were previously active (like cortex restart behavior) +pub async fn restart_active_mcp_servers( + app: &AppHandle, + servers_state: Arc>>>, +) -> Result<(), String> { + let app_state = app.state::(); + let active_servers = app_state.mcp_active_servers.lock().await; + + log::info!("Restarting {} previously active MCP servers", active_servers.len()); + + for (name, config) in active_servers.iter() { + log::info!("Restarting MCP server: {}", name); + + // Start server with restart monitoring - spawn async task + let app_clone = app.clone(); + let servers_clone = servers_state.clone(); + let name_clone = name.clone(); + let config_clone = config.clone(); + + tauri::async_runtime::spawn(async move { + let _ = start_mcp_server_with_restart( + app_clone, + servers_clone, + name_clone, + config_clone, + Some(3), // Default max restarts for startup + ).await; + }); + } + + Ok(()) +} + +/// Handle app quit - stop all MCP servers cleanly (like cortex cleanup) +pub async fn handle_app_quit(state: &AppState) -> Result<(), String> { + log::info!("App quitting - stopping all MCP servers cleanly"); + + // Stop all running MCP servers + stop_mcp_servers(state.mcp_servers.clone()).await?; + + // Clear active servers and restart counts + { + let mut active_servers = state.mcp_active_servers.lock().await; + active_servers.clear(); + } + { + let mut restart_counts = state.mcp_restart_counts.lock().await; + restart_counts.clear(); + } + + log::info!("All MCP servers stopped cleanly"); + Ok(()) +} + +/// Reset MCP restart count for a specific server (like cortex reset) +#[tauri::command] +pub async fn reset_mcp_restart_count(state: State<'_, AppState>, server_name: String) -> Result<(), String> { + let mut counts = state.mcp_restart_counts.lock().await; + + let count = match counts.get_mut(&server_name) { + Some(count) => count, + None => return Ok(()), // Server not found, nothing to reset + }; + + let old_count = *count; + *count = 0; + log::info!("MCP server {} restart count reset from {} to 0.", server_name, old_count); + Ok(()) } pub async fn stop_mcp_servers( @@ -290,6 +781,7 @@ pub async fn stop_mcp_servers( drop(servers_map); // Release the lock after stopping Ok(()) } + #[tauri::command] pub async fn get_connected_servers( _app: AppHandle, @@ -366,31 +858,31 @@ pub async fn call_tool( // Iterate through servers and find the first one that contains the tool for (_, service) in servers.iter() { - if let Ok(tools) = service.list_all_tools().await { - if tools.iter().any(|t| t.name == tool_name) { - println!("Found tool {} in server", tool_name); + let tools = match service.list_all_tools().await { + Ok(tools) => tools, + Err(_) => continue, // Skip this server if we can't list tools + }; - // Call the tool with timeout - let tool_call = service.call_tool(CallToolRequestParam { - name: tool_name.clone().into(), - arguments, - }); - - return match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await { - Ok(result) => { - match result { - Ok(ok_result) => Ok(ok_result), - Err(e) => Err(e.to_string()), - } - } - Err(_) => Err(format!( - "Tool call '{}' timed out after {} seconds", - tool_name, - MCP_TOOL_CALL_TIMEOUT.as_secs() - )), - }; - } + if !tools.iter().any(|t| t.name == tool_name) { + continue; // Tool not found in this server, try next } + + println!("Found tool {} in server", tool_name); + + // Call the tool with timeout + let tool_call = service.call_tool(CallToolRequestParam { + name: tool_name.clone().into(), + arguments, + }); + + return match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await { + Ok(result) => result.map_err(|e| e.to_string()), + Err(_) => Err(format!( + "Tool call '{}' timed out after {} seconds", + tool_name, + MCP_TOOL_CALL_TIMEOUT.as_secs() + )), + }; } Err(format!("Tool {} not found", tool_name)) @@ -409,8 +901,7 @@ pub async fn get_mcp_configs(app: AppHandle) -> Result { .map_err(|e| format!("Failed to create default MCP config: {}", e))?; } - let contents = fs::read_to_string(path).map_err(|e| e.to_string())?; - return Ok(contents); + fs::read_to_string(path).map_err(|e| e.to_string()) } #[tauri::command] @@ -422,6 +913,100 @@ pub async fn save_mcp_configs(app: AppHandle, configs: String) -> Result<(), Str fs::write(path, configs).map_err(|e| e.to_string()) } +/// Store active server configuration for restart purposes +async fn store_active_server_config( + active_servers_state: &Arc>>, + name: &str, + config: &Value, +) { + let mut active_servers = active_servers_state.lock().await; + active_servers.insert(name.to_string(), config.clone()); +} + + +/// Reset restart count for a server +async fn reset_restart_count( + restart_counts: &Arc>>, + name: &str, +) { + let mut counts = restart_counts.lock().await; + counts.insert(name.to_string(), 0); +} + +/// Spawn the server monitoring task for handling restarts +async fn spawn_server_monitoring_task( + app: AppHandle, + servers_state: Arc>>>, + name: String, + config: Value, + max_restarts: u32, + restart_counts: Arc>>, + successfully_connected: Arc>>, +) { + let app_clone = app.clone(); + let servers_clone = servers_state.clone(); + let name_clone = name.clone(); + let config_clone = config.clone(); + + tauri::async_runtime::spawn(async move { + // Monitor the server using RunningService's JoinHandle + let quit_reason = monitor_mcp_server_handle( + servers_clone.clone(), + name_clone.clone(), + ).await; + + log::info!("MCP server {} quit with reason: {:?}", name_clone, quit_reason); + + // Check if we should restart based on connection status and quit reason + if should_restart_server(&successfully_connected, &name_clone, &quit_reason).await { + // Start the restart loop + start_restart_loop( + app_clone, + servers_clone, + name_clone, + config_clone, + max_restarts, + restart_counts, + successfully_connected, + ).await; + } + }); +} + +/// Determine if a server should be restarted based on its connection status and quit reason +async fn should_restart_server( + successfully_connected: &Arc>>, + name: &str, + quit_reason: &Option, +) -> bool { + // Check if server was marked as successfully connected + let was_connected = { + let connected = successfully_connected.lock().await; + connected.get(name).copied().unwrap_or(false) + }; + + // Only restart if server was previously connected + if !was_connected { + log::error!( + "MCP server {} failed before establishing successful connection - stopping permanently", + name + ); + return false; + } + + // Determine if we should restart based on quit reason + match quit_reason { + Some(reason) => { + log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason); + true + } + None => { + log::info!("MCP server {} was manually stopped - not restarting", name); + false + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/core/setup.rs b/src-tauri/src/core/setup.rs index c2d3499f3..8d8a3d557 100644 --- a/src-tauri/src/core/setup.rs +++ b/src-tauri/src/core/setup.rs @@ -197,9 +197,39 @@ fn extract_extension_manifest( } pub fn setup_mcp(app: &App) { - let state = app.state::().inner(); + let state = app.state::(); let servers = state.mcp_servers.clone(); let app_handle: tauri::AppHandle = app.handle().clone(); + + // Setup kill-mcp-servers event listener (similar to cortex kill-sidecar) + let app_handle_for_kill = app_handle.clone(); + app_handle.listen("kill-mcp-servers", move |_event| { + let app_handle = app_handle_for_kill.clone(); + tauri::async_runtime::spawn(async move { + log::info!("Received kill-mcp-servers event - cleaning up MCP servers"); + + let app_state = app_handle.state::(); + + // Stop all running MCP servers + if let Err(e) = super::mcp::stop_mcp_servers(app_state.mcp_servers.clone()).await { + log::error!("Failed to stop MCP servers: {}", e); + return; + } + + // Clear active servers and restart counts + { + let mut active_servers = app_state.mcp_active_servers.lock().await; + active_servers.clear(); + } + { + let mut restart_counts = app_state.mcp_restart_counts.lock().await; + restart_counts.clear(); + } + + log::info!("MCP servers cleaned up successfully"); + }); + }); + tauri::async_runtime::spawn(async move { if let Err(e) = run_mcp_commands(&app_handle, servers).await { log::error!("Failed to run mcp commands: {}", e); diff --git a/src-tauri/src/core/state.rs b/src-tauri/src/core/state.rs index 9957ba92e..dab29aa85 100644 --- a/src-tauri/src/core/state.rs +++ b/src-tauri/src/core/state.rs @@ -16,6 +16,9 @@ pub struct AppState { pub download_manager: Arc>, pub cortex_restart_count: Arc>, pub cortex_killed_intentionally: Arc>, + pub mcp_restart_counts: Arc>>, + pub mcp_active_servers: Arc>>, + pub mcp_successfully_connected: Arc>>, pub server_handle: Arc>>, } pub fn generate_app_token() -> String { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4ed6ecee7..0ef6f059a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -68,6 +68,7 @@ pub fn run() { core::mcp::get_mcp_configs, core::mcp::activate_mcp_server, core::mcp::deactivate_mcp_server, + core::mcp::reset_mcp_restart_count, // Threads core::threads::list_threads, core::threads::create_thread, @@ -93,6 +94,9 @@ pub fn run() { download_manager: Arc::new(Mutex::new(DownloadManagerState::default())), cortex_restart_count: Arc::new(Mutex::new(0)), cortex_killed_intentionally: Arc::new(Mutex::new(false)), + mcp_restart_counts: Arc::new(Mutex::new(HashMap::new())), + mcp_active_servers: Arc::new(Mutex::new(HashMap::new())), + mcp_successfully_connected: Arc::new(Mutex::new(HashMap::new())), server_handle: Arc::new(Mutex::new(None)), }) .setup(|app| { @@ -124,6 +128,7 @@ pub fn run() { tauri::WindowEvent::CloseRequested { .. } => { if window.label() == "main" { window.emit("kill-sidecar", ()).unwrap(); + window.emit("kill-mcp-servers", ()).unwrap(); clean_up(); } } From 8fdb65eba57efa1ce5bf9619c093095c2e7a0cc8 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Sat, 21 Jun 2025 00:03:04 +0700 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=90=9Bfix:=20prevent=20render=20err?= =?UTF-8?q?or=20when=20additional=20information=20missing=20from=20hardwar?= =?UTF-8?q?e=20(#5413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-app/src/routes/settings/hardware.tsx | 6 +++--- web-app/src/routes/system-monitor.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web-app/src/routes/settings/hardware.tsx b/web-app/src/routes/settings/hardware.tsx index 23f4eafef..d2dd65160 100644 --- a/web-app/src/routes/settings/hardware.tsx +++ b/web-app/src/routes/settings/hardware.tsx @@ -98,7 +98,7 @@ function SortableGPUItem({ gpu, index }: { gpu: GPU; index: number }) { title="Driver Version" actions={ - {gpu.additional_information?.driver_version} + {gpu.additional_information?.driver_version || '-'} } /> @@ -106,7 +106,7 @@ function SortableGPUItem({ gpu, index }: { gpu: GPU; index: number }) { title="Compute Capability" actions={ - {gpu.additional_information?.compute_cap} + {gpu.additional_information?.compute_cap || '-'} } /> @@ -157,7 +157,7 @@ function Hardware() { } useEffect(() => { - if (pollingPaused) return; + if (pollingPaused) return const intervalId = setInterval(() => { getHardwareInfo().then((data) => { updateCPUUsage(data.cpu.usage) diff --git a/web-app/src/routes/system-monitor.tsx b/web-app/src/routes/system-monitor.tsx index fed013ed6..30ee0bda5 100644 --- a/web-app/src/routes/system-monitor.tsx +++ b/web-app/src/routes/system-monitor.tsx @@ -224,7 +224,7 @@ function SystemMonitor() { Driver Version: - {gpu.additional_information.driver_version} + {gpu.additional_information?.driver_version || '-'}
@@ -232,7 +232,7 @@ function SystemMonitor() { Compute Capability: - {gpu.additional_information.compute_cap} + {gpu.additional_information?.compute_cap || '-'}
From c463090edb4693a6acaa535469072fd6f61fd279 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 25 Jun 2025 13:49:55 +0700 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=90=9Bfix:=20delete=20pre=20populat?= =?UTF-8?q?e=20remote=20models=20(#5516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/containers/dialogs/DeleteModel.tsx | 11 ++++- web-app/src/hooks/useModelProvider.ts | 41 +++++++++++++------ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/web-app/src/containers/dialogs/DeleteModel.tsx b/web-app/src/containers/dialogs/DeleteModel.tsx index 60e86debe..b7342e7d3 100644 --- a/web-app/src/containers/dialogs/DeleteModel.tsx +++ b/web-app/src/containers/dialogs/DeleteModel.tsx @@ -33,7 +33,16 @@ export const DialogDeleteModel = ({ const removeModel = async () => { deleteModelCache(selectedModelId) deleteModel(selectedModelId).then(() => { - getProviders().then(setProviders) + getProviders().then((providers) => { + // Filter out the deleted model from all providers + const filteredProviders = providers.map((provider) => ({ + ...provider, + models: provider.models.filter( + (model) => model.id !== selectedModelId + ), + })) + setProviders(filteredProviders) + }) toast.success('Delete Model', { id: `delete-model-${selectedModel?.id}`, description: `Model ${selectedModel?.id} has been permanently deleted.`, diff --git a/web-app/src/hooks/useModelProvider.ts b/web-app/src/hooks/useModelProvider.ts index c87645a1e..e2f26b1f7 100644 --- a/web-app/src/hooks/useModelProvider.ts +++ b/web-app/src/hooks/useModelProvider.ts @@ -6,6 +6,7 @@ type ModelProviderState = { providers: ModelProvider[] selectedProvider: string selectedModel: Model | null + deletedModels: string[] getModelBy: (modelId: string) => Model | undefined setProviders: (providers: ModelProvider[]) => void getProviderByName: (providerName: string) => ModelProvider | undefined @@ -25,6 +26,7 @@ export const useModelProvider = create()( providers: [], selectedProvider: 'llama.cpp', selectedModel: null, + deletedModels: [], getModelBy: (modelId: string) => { const provider = get().providers.find( (provider) => provider.provider === get().selectedProvider @@ -35,6 +37,11 @@ export const useModelProvider = create()( setProviders: (providers) => set((state) => { const existingProviders = state.providers + // Ensure deletedModels is always an array + const currentDeletedModels = Array.isArray(state.deletedModels) + ? state.deletedModels + : [] + const updatedProviders = providers.map((provider) => { const existingProvider = existingProviders.find( (x) => x.provider === provider.provider @@ -43,7 +50,9 @@ export const useModelProvider = create()( const mergedModels = [ ...models, ...(provider?.models ?? []).filter( - (e) => !models.some((m) => m.id === e.id) + (e) => + !models.some((m) => m.id === e.id) && + !currentDeletedModels.includes(e.id) ), ] return { @@ -118,17 +127,25 @@ export const useModelProvider = create()( return modelObject }, deleteModel: (modelId: string) => { - set((state) => ({ - providers: state.providers.map((provider) => { - const models = provider.models.filter( - (model) => model.id !== modelId - ) - return { - ...provider, - models, - } - }), - })) + set((state) => { + // Ensure deletedModels is always an array + const currentDeletedModels = Array.isArray(state.deletedModels) + ? state.deletedModels + : [] + + return { + providers: state.providers.map((provider) => { + const models = provider.models.filter( + (model) => model.id !== modelId + ) + return { + ...provider, + models, + } + }), + deletedModels: [...currentDeletedModels, modelId], + } + }) }, addProvider: (provider: ModelProvider) => { set((state) => ({ From 52d15802d927f7ada697b09f188b0faa0b1b62ca Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 25 Jun 2025 14:10:54 +0700 Subject: [PATCH 04/17] =?UTF-8?q?=E2=9C=A8enhancement:=20experimental=20fe?= =?UTF-8?q?ature=20toggle=20(#5514)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-app/src/containers/SettingsMenu.tsx | 91 +++++++++++++------------ web-app/src/hooks/useGeneralSetting.ts | 4 ++ web-app/src/routes/settings/general.tsx | 17 ++++- 3 files changed, 66 insertions(+), 46 deletions(-) diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index c23ed6acf..5c3c0768f 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -2,55 +2,12 @@ import { Link, useMatches } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useTranslation } from 'react-i18next' import { useModelProvider } from '@/hooks/useModelProvider' -import { isProd } from '@/lib/version' - -const menuSettings = [ - { - title: 'common.general', - route: route.settings.general, - }, - { - title: 'common.appearance', - route: route.settings.appearance, - }, - { - title: 'common.privacy', - route: route.settings.privacy, - }, - { - title: 'common.keyboardShortcuts', - route: route.settings.shortcuts, - }, - { - title: 'Hardware', - route: route.settings.hardware, - }, - // Only show MCP Servers in non-production environment - ...(!isProd - ? [ - { - title: 'MCP Servers', - route: route.settings.mcp_servers, - }, - ] - : []), - { - title: 'Local API Server', - route: route.settings.local_api_server, - }, - { - title: 'HTTPS Proxy', - route: route.settings.https_proxy, - }, - { - title: 'Extensions', - route: route.settings.extensions, - }, -] +import { useGeneralSetting } from '@/hooks/useGeneralSetting' const SettingsMenu = () => { const { t } = useTranslation() const { providers } = useModelProvider() + const { experimentalFeatures } = useGeneralSetting() const firstItemProvider = providers.length > 0 ? providers[0].provider : 'llama.cpp' const matches = useMatches() @@ -60,6 +17,50 @@ const SettingsMenu = () => { 'providerName' in match.params ) + const menuSettings = [ + { + title: 'common.general', + route: route.settings.general, + }, + { + title: 'common.appearance', + route: route.settings.appearance, + }, + { + title: 'common.privacy', + route: route.settings.privacy, + }, + { + title: 'common.keyboardShortcuts', + route: route.settings.shortcuts, + }, + { + title: 'Hardware', + route: route.settings.hardware, + }, + // Only show MCP Servers when experimental features are enabled + ...(experimentalFeatures + ? [ + { + title: 'MCP Servers', + route: route.settings.mcp_servers, + }, + ] + : []), + { + title: 'Local API Server', + route: route.settings.local_api_server, + }, + { + title: 'HTTPS Proxy', + route: route.settings.https_proxy, + }, + { + title: 'Extensions', + route: route.settings.extensions, + }, + ] + return (
diff --git a/web-app/src/hooks/useGeneralSetting.ts b/web-app/src/hooks/useGeneralSetting.ts index 5335f245c..6f4a36fa4 100644 --- a/web-app/src/hooks/useGeneralSetting.ts +++ b/web-app/src/hooks/useGeneralSetting.ts @@ -5,6 +5,8 @@ import { localStorageKey } from '@/constants/localStorage' type LeftPanelStoreState = { currentLanguage: Language spellCheckChatInput: boolean + experimentalFeatures: boolean + setExperimentalFeatures: (value: boolean) => void setSpellCheckChatInput: (value: boolean) => void setCurrentLanguage: (value: Language) => void } @@ -14,6 +16,8 @@ export const useGeneralSetting = create()( (set) => ({ currentLanguage: 'en', spellCheckChatInput: true, + experimentalFeatures: false, + setExperimentalFeatures: (value) => set({ experimentalFeatures: value }), setSpellCheckChatInput: (value) => set({ spellCheckChatInput: value }), setCurrentLanguage: (value) => set({ currentLanguage: value }), }), diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index cfdd4f298..7a33c5308 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -64,7 +64,12 @@ const openFileTitle = (): string => { function General() { const { t } = useTranslation() - const { spellCheckChatInput, setSpellCheckChatInput } = useGeneralSetting() + const { + spellCheckChatInput, + setSpellCheckChatInput, + experimentalFeatures, + setExperimentalFeatures, + } = useGeneralSetting() const { checkForUpdate } = useAppUpdater() const [janDataFolder, setJanDataFolder] = useState() const [isCopied, setIsCopied] = useState(false) @@ -390,6 +395,16 @@ function General() { /> } /> + setExperimentalFeatures(e)} + /> + } + /> Date: Wed, 25 Jun 2025 15:42:14 +0700 Subject: [PATCH 05/17] feat: improve local provider connectivity with CORS bypass (#5458) * feat: improve local provider connectivity with CORS bypass - Add @tauri-apps/plugin-http dependency - Implement dual fetch strategy for local vs remote providers - Auto-detect local providers (localhost, Ollama:11434, LM Studio:1234) - Make API key optional for local providers - Add comprehensive test coverage for provider fetching refactor: simplify fetchModelsFromProvider by removing preflight check logic * feat: extend config options to include custom fetch function for CORS handling * feat: conditionally use Tauri's fetch for openai-compatible providers to handle CORS --- web-app/package.json | 1 + web-app/src/lib/completion.ts | 11 +++++++++- web-app/src/services/providers.ts | 35 ++++++++++++++++++++++++------- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/web-app/package.json b/web-app/package.json index 8b3193817..44d027623 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -32,6 +32,7 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "^2.2.1", + "@tauri-apps/plugin-http": "^2.2.1", "@tauri-apps/plugin-opener": "^2.2.7", "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-updater": "^2.7.1", diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index 24daec3cd..5ffd4fa4b 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -7,6 +7,7 @@ import { ModelManager, } from '@janhq/core' import { invoke } from '@tauri-apps/api/core' +import { fetch as fetchTauri } from '@tauri-apps/plugin-http' import { ChatCompletionMessageParam, ChatCompletionTool, @@ -15,7 +16,13 @@ import { models, StreamCompletionResponse, TokenJS, + ConfigOptions, } from 'token.js' + +// Extended config options to include custom fetch function +type ExtendedConfigOptions = ConfigOptions & { + fetch?: typeof fetch +} import { ulid } from 'ulidx' import { normalizeProvider } from './models' import { MCPTool } from '@/types/completion' @@ -129,7 +136,9 @@ export const sendCompletion = async ( apiKey: provider.api_key ?? (await invoke('app_token')), // TODO: Retrieve from extension settings baseURL: provider.base_url, - }) + // Use Tauri's fetch to avoid CORS issues only for openai-compatible provider + ...(providerName === 'openai-compatible' && { fetch: fetchTauri }), + } as ExtendedConfigOptions) if ( thread.model.id && !(thread.model.id in Object.values(models).flat()) && diff --git a/web-app/src/services/providers.ts b/web-app/src/services/providers.ts index 6bd2b63f0..358d06a72 100644 --- a/web-app/src/services/providers.ts +++ b/web-app/src/services/providers.ts @@ -13,6 +13,8 @@ import { import { modelSettings } from '@/lib/predefined' import { fetchModels } from './models' import { ExtensionManager } from '@/lib/extension' +import { fetch as fetchTauri } from '@tauri-apps/plugin-http' + export const getProviders = async (): Promise => { const engines = !localStorage.getItem('migration_completed') @@ -163,26 +165,35 @@ export const getProviders = async (): Promise => { return runtimeProviders.concat(builtinProviders as ModelProvider[]) } + /** * Fetches models from a provider's API endpoint + * Always uses Tauri's HTTP client to bypass CORS issues * @param provider The provider object containing base_url and api_key * @returns Promise Array of model IDs */ export const fetchModelsFromProvider = async ( provider: ModelProvider ): Promise => { - if (!provider.base_url || !provider.api_key) { - throw new Error('Provider must have base_url and api_key configured') + if (!provider.base_url) { + throw new Error('Provider must have base_url configured') } try { - const response = await fetch(`${provider.base_url}/models`, { + const headers: Record = { + 'Content-Type': 'application/json', + } + + // Only add authentication headers if API key is provided + if (provider.api_key) { + headers['x-api-key'] = provider.api_key + headers['Authorization'] = `Bearer ${provider.api_key}` + } + + // Always use Tauri's fetch to avoid CORS issues + const response = await fetchTauri(`${provider.base_url}/models`, { method: 'GET', - headers: { - 'x-api-key': provider.api_key, - 'Authorization': `Bearer ${provider.api_key}`, - 'Content-Type': 'application/json', - }, + headers, }) if (!response.ok) { @@ -213,6 +224,14 @@ export const fetchModelsFromProvider = async ( } } catch (error) { console.error('Error fetching models from provider:', error) + + // Provide helpful error message + if (error instanceof Error && error.message.includes('fetch')) { + throw new Error( + `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` + ) + } + throw error } } From f5cfe8a5378210739abb3ad720f4085843a16b20 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 25 Jun 2025 15:56:38 +0700 Subject: [PATCH 06/17] =?UTF-8?q?=E2=9C=A8enhancement:=20Added=20jan-nano-?= =?UTF-8?q?128k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/model-extension/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index f0f0589df..669051114 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -28,7 +28,7 @@ type Data = { /** * Defaul mode sources */ -const defaultModelSources = ['Menlo/Jan-nano-gguf'] +const defaultModelSources = ['Menlo/Jan-nano-gguf', 'Menlo/Jan-nano-128k-gguf'] /** * A extension for models From 3eb31be62e342cca097aaa6ea0ea3f710f96d14b Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 25 Jun 2025 17:10:02 +0700 Subject: [PATCH 07/17] =?UTF-8?q?=E2=9C=A8enhancement:=20adjust=20placemen?= =?UTF-8?q?t=20exp=20toggle=20(#5525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨enhancement: adjust placement exp toggle * Update web-app/src/routes/settings/general.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- web-app/src/routes/settings/general.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index 7a33c5308..f505719b0 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -248,6 +248,20 @@ function General() { )} + {/* Advanced */} + + setExperimentalFeatures(e)} + /> + } + /> + + {/* Data folder */} } /> - setExperimentalFeatures(e)} - /> - } - /> Date: Wed, 25 Jun 2025 19:36:34 +0700 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=90=9Bfix:=20default=20model=20sett?= =?UTF-8?q?ings=20for=20jan-nano-128k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/browser/models/utils.ts | 10 ++++++++++ core/src/types/model/modelEntity.ts | 8 ++++++++ .../containers/dynamicControllerSetting/index.tsx | 14 ++++++++++++-- web-app/src/services/providers.ts | 7 +++++-- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/core/src/browser/models/utils.ts b/core/src/browser/models/utils.ts index 2ac243b6a..d088e66ad 100644 --- a/core/src/browser/models/utils.ts +++ b/core/src/browser/models/utils.ts @@ -15,6 +15,8 @@ export const validationRules: { [key: string]: (value: any) => boolean } = { stop: (value: any) => Array.isArray(value) && value.every((v) => typeof v === 'string'), frequency_penalty: (value: any) => typeof value === 'number' && value >= 0 && value <= 1, presence_penalty: (value: any) => typeof value === 'number' && value >= 0 && value <= 1, + repeat_last_n: (value: any) => typeof value === 'number', + repeat_penalty: (value: any) => typeof value === 'number', ctx_len: (value: any) => Number.isInteger(value) && value >= 0, ngl: (value: any) => Number.isInteger(value), @@ -126,6 +128,14 @@ export const extractModelLoadParams = ( vision_model: undefined, text_model: undefined, engine: undefined, + top_p: undefined, + top_k: undefined, + min_p: undefined, + temperature: undefined, + repeat_penalty: undefined, + repeat_last_n: undefined, + presente_penalty: undefined, + frequency_penalty: undefined, } const settingParams: ModelSettingParams = {} diff --git a/core/src/types/model/modelEntity.ts b/core/src/types/model/modelEntity.ts index 83d8a864c..1910aeb87 100644 --- a/core/src/types/model/modelEntity.ts +++ b/core/src/types/model/modelEntity.ts @@ -121,6 +121,14 @@ export type ModelSettingParams = { vision_model?: boolean text_model?: boolean engine?: boolean + top_p?: number + top_k?: number + min_p?: number + temperature?: number + repeat_penalty?: number + repeat_last_n?: number + presente_penalty?: number + frequency_penalty?: number } /** diff --git a/web-app/src/containers/dynamicControllerSetting/index.tsx b/web-app/src/containers/dynamicControllerSetting/index.tsx index d0c7ee30a..4c2115399 100644 --- a/web-app/src/containers/dynamicControllerSetting/index.tsx +++ b/web-app/src/containers/dynamicControllerSetting/index.tsx @@ -10,7 +10,13 @@ type DynamicControllerProps = { title?: string className?: string description?: string - controllerType: 'input' | 'checkbox' | 'dropdown' | 'textarea' | 'slider' | string + controllerType: + | 'input' + | 'checkbox' + | 'dropdown' + | 'textarea' + | 'slider' + | string controllerProps: { value?: string | boolean | number placeholder?: string @@ -36,7 +42,11 @@ export function DynamicControllerSetting({ onChange(newValue)} diff --git a/web-app/src/services/providers.ts b/web-app/src/services/providers.ts index 358d06a72..c279620f2 100644 --- a/web-app/src/services/providers.ts +++ b/web-app/src/services/providers.ts @@ -150,7 +150,7 @@ export const getProviders = async (): Promise => { ...setting, controller_props: { ...setting.controller_props, - value: value ?? setting.controller_props.value, + value: value, }, } return acc @@ -254,7 +254,10 @@ export const updateSettings = async ( ...setting, controllerProps: { ...setting.controller_props, - value: setting.controller_props.value ?? '', + value: + setting.controller_props.value !== undefined + ? setting.controller_props.value + : '', }, controllerType: setting.controller_type, })) as SettingComponentProps[] From d407ebc4e972845b741f8b580e3ea5ce1c9766fa Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 25 Jun 2025 19:42:22 +0700 Subject: [PATCH 09/17] fix: typo --- core/src/browser/models/utils.ts | 2 +- core/src/types/model/modelEntity.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/browser/models/utils.ts b/core/src/browser/models/utils.ts index d088e66ad..192b838da 100644 --- a/core/src/browser/models/utils.ts +++ b/core/src/browser/models/utils.ts @@ -134,7 +134,7 @@ export const extractModelLoadParams = ( temperature: undefined, repeat_penalty: undefined, repeat_last_n: undefined, - presente_penalty: undefined, + presence_penalty: undefined, frequency_penalty: undefined, } const settingParams: ModelSettingParams = {} diff --git a/core/src/types/model/modelEntity.ts b/core/src/types/model/modelEntity.ts index 1910aeb87..beb529c40 100644 --- a/core/src/types/model/modelEntity.ts +++ b/core/src/types/model/modelEntity.ts @@ -127,7 +127,7 @@ export type ModelSettingParams = { temperature?: number repeat_penalty?: number repeat_last_n?: number - presente_penalty?: number + presence_penalty?: number frequency_penalty?: number } From 63761efca2393a039c6de4009a885baae2a2ecc6 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 26 Jun 2025 10:01:42 +0700 Subject: [PATCH 10/17] config: remove MCP and tool use production gate --- web-app/src/containers/DropdownModelProvider.tsx | 3 +-- .../routes/settings/providers/$providerName.tsx | 15 +++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index 0747a1ad1..09ab74e1a 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -16,7 +16,6 @@ import { ModelSetting } from '@/containers/ModelSetting' import ProvidersAvatar from '@/containers/ProvidersAvatar' import { Fzf } from 'fzf' import { localStorageKey } from '@/constants/localStorage' -import { isProd } from '@/lib/version' type DropdownModelProviderProps = { model?: ThreadModel @@ -396,7 +395,7 @@ const DropdownModelProvider = ({
- {!isProd && capabilities.length > 0 && ( + {capabilities.length > 0 && (
diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 2daa496b7..7ed4e3969 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -39,7 +39,6 @@ import { toast } from 'sonner' import { ActiveModel } from '@/types/models' import { useEffect, useState } from 'react' import { predefinedProviders } from '@/mock/data' -import { isProd } from '@/lib/version' // as route.threadsDetail export const Route = createFileRoute('/settings/providers/$providerName')({ @@ -455,19 +454,15 @@ function ProviderDetail() { title={

{model.id}

- {!isProd && ( - - )} +
} actions={
- {!isProd && ( - - )} + {model.settings && ( Date: Thu, 26 Jun 2025 16:40:55 +0700 Subject: [PATCH 11/17] fix: increase context size window does not popup first time --- web-app/src/containers/ChatInput.tsx | 6 +- web-app/src/containers/ThreadContent.tsx | 17 +- .../containers/dialogs/OutOfContextDialog.tsx | 166 +++++++----------- web-app/src/hooks/useChat.ts | 15 +- web-app/src/hooks/useModelContextApproval.ts | 53 ++++++ web-app/src/routes/__root.tsx | 2 + web-app/src/routes/threads/$threadId.tsx | 7 - 7 files changed, 133 insertions(+), 133 deletions(-) create mode 100644 web-app/src/hooks/useModelContextApproval.ts diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 7737652eb..35a71912d 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -35,7 +35,6 @@ import { ModelLoader } from '@/containers/loaders/ModelLoader' import DropdownToolsAvailable from '@/containers/DropdownToolsAvailable' import { getConnectedServers } from '@/services/mcp' import { stopAllModels } from '@/services/models' -import { useOutOfContextPromiseModal } from './dialogs/OutOfContextDialog' type ChatInputProps = { className?: string @@ -55,8 +54,6 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const { t } = useTranslation() const { spellCheckChatInput } = useGeneralSetting() - const { showModal, PromiseModal: OutOfContextModal } = - useOutOfContextPromiseModal() const maxRows = 10 const { selectedModel } = useModelProvider() @@ -107,7 +104,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { return } setMessage('') - sendMessage(prompt, showModal) + sendMessage(prompt) } useEffect(() => { @@ -599,7 +596,6 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
)} -
) } diff --git a/web-app/src/containers/ThreadContent.tsx b/web-app/src/containers/ThreadContent.tsx index 076327ea6..0ee166b7d 100644 --- a/web-app/src/containers/ThreadContent.tsx +++ b/web-app/src/containers/ThreadContent.tsx @@ -83,7 +83,6 @@ export const ThreadContent = memo( // eslint-disable-next-line @typescript-eslint/no-explicit-any streamTools?: any contextOverflowModal?: React.ReactNode | null - showContextOverflowModal?: () => Promise } ) => { const [message, setMessage] = useState(item.content?.[0]?.text?.value || '') @@ -134,10 +133,7 @@ export const ThreadContent = memo( } if (toSendMessage) { deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') - sendMessage( - toSendMessage.content?.[0]?.text?.value || '', - item.showContextOverflowModal - ) + sendMessage(toSendMessage.content?.[0]?.text?.value || '') } }, [deleteMessage, getMessages, item, sendMessage]) @@ -179,16 +175,9 @@ export const ThreadContent = memo( deleteMessage(threadMessages[i].thread_id, threadMessages[i].id) } - sendMessage(message, item.showContextOverflowModal) + sendMessage(message) }, - [ - deleteMessage, - getMessages, - item.thread_id, - message, - sendMessage, - item.showContextOverflowModal, - ] + [deleteMessage, getMessages, item.thread_id, message, sendMessage] ) const isToolCalls = diff --git a/web-app/src/containers/dialogs/OutOfContextDialog.tsx b/web-app/src/containers/dialogs/OutOfContextDialog.tsx index 92e72950a..d1d6317a8 100644 --- a/web-app/src/containers/dialogs/OutOfContextDialog.tsx +++ b/web-app/src/containers/dialogs/OutOfContextDialog.tsx @@ -8,108 +8,76 @@ import { DialogTitle, } from '@/components/ui/dialog' -import { ReactNode, useCallback, useState } from 'react' import { Button } from '@/components/ui/button' +import { useContextSizeApproval } from '@/hooks/useModelContextApproval' -export function useOutOfContextPromiseModal() { - const [isOpen, setIsOpen] = useState(false) - const [modalProps, setModalProps] = useState<{ - resolveRef: - | ((value: 'ctx_len' | 'context_shift' | undefined) => void) - | null - }>({ - resolveRef: null, - }) - // Function to open the modal and return a Promise - const showModal = useCallback(() => { - return new Promise((resolve) => { - setModalProps({ - resolveRef: resolve, - }) - setIsOpen(true) - }) - }, []) +export default function OutOfContextPromiseModal() { + const { isModalOpen, modalProps, setModalOpen } = useContextSizeApproval() + if (!modalProps) { + return null + } + const { onApprove, onDeny } = modalProps - const PromiseModal = useCallback((): ReactNode => { - if (!isOpen) { - return null + const handleContextLength = () => { + onApprove('ctx_len') + } + + const handleContextShift = () => { + onApprove('context_shift') + } + + const handleDialogOpen = (open: boolean) => { + setModalOpen(open) + if (!open) { + onDeny() } + } - const handleContextLength = () => { - setIsOpen(false) - if (modalProps.resolveRef) { - modalProps.resolveRef('ctx_len') - } - } - - const handleContextShift = () => { - setIsOpen(false) - if (modalProps.resolveRef) { - modalProps.resolveRef('context_shift') - } - } - const handleCancel = () => { - setIsOpen(false) - if (modalProps.resolveRef) { - modalProps.resolveRef(undefined) - } - } - - return ( - { - setIsOpen(open) - if (!open) handleCancel() - }} - > - - - - {t('outOfContextError.title', 'Out of context error')} - - - - {t( - 'outOfContextError.description', - 'This chat is reaching the AI’s memory limit, like a whiteboard filling up. We can expand the memory window (called context size) so it remembers more, but it may use more of your computer’s memory. We can also truncate the input, which means it will forget some of the chat history to make room for new messages.' - )} -
-
- {t( - 'outOfContextError.increaseContextSizeDescription', - 'Do you want to increase the context size?' - )} -
- - - - -
-
- ) - }, [isOpen, modalProps]) - return { showModal, PromiseModal } + return ( + + + + + {t('outOfContextError.title', 'Out of context error')} + + + + {t( + 'outOfContextError.description', + 'This chat is reaching the AI’s memory limit, like a whiteboard filling up. We can expand the memory window (called context size) so it remembers more, but it may use more of your computer’s memory. We can also truncate the input, which means it will forget some of the chat history to make room for new messages.' + )} +
+
+ {t( + 'outOfContextError.increaseContextSizeDescription', + 'Do you want to increase the context size?' + )} +
+ + + + +
+
+ ) } diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 2c8f9fd2a..3e9dd6363 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -30,6 +30,7 @@ import { useToolApproval } from '@/hooks/useToolApproval' import { useToolAvailable } from '@/hooks/useToolAvailable' import { OUT_OF_CONTEXT_SIZE } from '@/utils/error' import { updateSettings } from '@/services/providers' +import { useContextSizeApproval } from './useModelContextApproval' export const useChat = () => { const { prompt, setPrompt } = usePrompt() @@ -47,6 +48,8 @@ export const useChat = () => { const { approvedTools, showApprovalModal, allowAllMCPPermissions } = useToolApproval() + const { showApprovalModal: showIncreaseContextSizeModal } = + useContextSizeApproval() const { getDisabledToolsForThread } = useToolAvailable() const { getProviderByName, selectedModel, selectedProvider } = @@ -223,11 +226,7 @@ export const useChat = () => { ) const sendMessage = useCallback( - async ( - message: string, - showModal?: () => Promise, - troubleshooting = true - ) => { + async (message: string, troubleshooting = true) => { const activeThread = await getCurrentThread() resetTokenSpeed() @@ -361,7 +360,7 @@ export const useChat = () => { selectedModel && troubleshooting ) { - const method = await showModal?.() + const method = await showIncreaseContextSizeModal() if (method === 'ctx_len') { /// Increase context size activeProvider = await increaseModelContextSize( @@ -447,8 +446,7 @@ export const useChat = () => { updateThreadTimestamp, setPrompt, selectedModel, - currentAssistant?.instructions, - currentAssistant.parameters, + currentAssistant, tools, updateLoadingModel, getDisabledToolsForThread, @@ -456,6 +454,7 @@ export const useChat = () => { allowAllMCPPermissions, showApprovalModal, updateTokenSpeed, + showIncreaseContextSizeModal, increaseModelContextSize, toggleOnContextShifting, ] diff --git a/web-app/src/hooks/useModelContextApproval.ts b/web-app/src/hooks/useModelContextApproval.ts new file mode 100644 index 000000000..92abe6ba6 --- /dev/null +++ b/web-app/src/hooks/useModelContextApproval.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand' + +export type ApprovalModalProps = { + onApprove: (method: 'ctx_len' | 'context_shift') => void + onDeny: () => void +} + +type ApprovalState = { + // Modal state + isModalOpen: boolean + modalProps: ApprovalModalProps | null + + showApprovalModal: () => Promise<'ctx_len' | 'context_shift' | undefined> + closeModal: () => void + setModalOpen: (open: boolean) => void +} + +export const useContextSizeApproval = create()((set, get) => ({ + isModalOpen: false, + modalProps: null, + + showApprovalModal: async () => { + return new Promise<'ctx_len' | 'context_shift' | undefined>((resolve) => { + set({ + isModalOpen: true, + modalProps: { + onApprove: (method) => { + get().closeModal() + resolve(method) + }, + onDeny: () => { + get().closeModal() + resolve(undefined) + }, + }, + }) + }) + }, + + closeModal: () => { + set({ + isModalOpen: false, + modalProps: null, + }) + }, + + setModalOpen: (open: boolean) => { + set({ isModalOpen: open }) + if (!open) { + get().closeModal() + } + }, +})) diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 67e88ed90..c4efdbf72 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { AnalyticProvider } from '@/providers/AnalyticProvider' import { useLeftPanel } from '@/hooks/useLeftPanel' import { cn } from '@/lib/utils' import ToolApproval from '@/containers/dialogs/ToolApproval' +import OutOfContextPromiseModal from '@/containers/dialogs/OutOfContextDialog' export const Route = createRootRoute({ component: RootLayout, @@ -94,6 +95,7 @@ function RootLayout() { {/* */} + ) } diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index bb8e9a72c..ae426af0b 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -18,7 +18,6 @@ import { useAppState } from '@/hooks/useAppState' import DropdownAssistant from '@/containers/DropdownAssistant' import { useAssistant } from '@/hooks/useAssistant' import { useAppearance } from '@/hooks/useAppearance' -import { useOutOfContextPromiseModal } from '@/containers/dialogs/OutOfContextDialog' // as route.threadsDetail export const Route = createFileRoute('/threads/$threadId')({ @@ -48,8 +47,6 @@ function ThreadDetail() { const scrollContainerRef = useRef(null) const isFirstRender = useRef(true) const messagesCount = useMemo(() => messages?.length ?? 0, [messages]) - const { showModal, PromiseModal: OutOfContextModal } = - useOutOfContextPromiseModal() // Function to check scroll position and scrollbar presence const checkScrollState = () => { @@ -196,8 +193,6 @@ function ThreadDetail() { if (!messages || !threadModel) return null - const contextOverflowModalComponent = - return (
@@ -243,8 +238,6 @@ function ThreadDetail() { )) } index={index} - showContextOverflowModal={showModal} - contextOverflowModal={contextOverflowModalComponent} />
) From 2ae51bcf4e90011c214236585bc0e8926841771a Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 26 Jun 2025 18:45:14 +0700 Subject: [PATCH 12/17] =?UTF-8?q?=F0=9F=90=9Bfix:=20modal=20action=20light?= =?UTF-8?q?=20mode=20(#5545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-app/src/containers/dialogs/CortexFailureDialog.tsx | 2 +- web-app/src/containers/dialogs/OutOfContextDialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web-app/src/containers/dialogs/CortexFailureDialog.tsx b/web-app/src/containers/dialogs/CortexFailureDialog.tsx index bdd2aff2e..b28281f54 100644 --- a/web-app/src/containers/dialogs/CortexFailureDialog.tsx +++ b/web-app/src/containers/dialogs/CortexFailureDialog.tsx @@ -65,7 +65,7 @@ export function CortexFailureDialog() { diff --git a/web-app/src/locales/en/model-errors.json b/web-app/src/locales/en/model-errors.json new file mode 100644 index 000000000..307d4ee3d --- /dev/null +++ b/web-app/src/locales/en/model-errors.json @@ -0,0 +1,7 @@ +{ + "title": "Out of context error", + "description": "This chat is reaching the AI’s memory limit, like a whiteboard filling up. We can expand the memory window (called context size) so it remembers more, but it may use more of your computer’s memory. We can also truncate the input, which means it will forget some of the chat history to make room for new messages.", + "increaseContextSizeDescription": "Do you want to increase the context size?", + "truncateInput": "Truncate Input", + "increaseContextSize": "Increase Context Size" +} diff --git a/web-app/src/locales/id/model-errors.json b/web-app/src/locales/id/model-errors.json new file mode 100644 index 000000000..869e935ee --- /dev/null +++ b/web-app/src/locales/id/model-errors.json @@ -0,0 +1,7 @@ +{ + "title": "Kesalahan kehabisan konteks", + "description": "Obrolan ini hampir mencapai batas memori AI, seperti papan tulis yang mulai penuh. Kita bisa memperluas jendela memori (disebut ukuran konteks) agar AI dapat mengingat lebih banyak, tetapi ini mungkin akan menggunakan lebih banyak memori komputer Anda. Kita juga bisa memotong input, artinya sebagian riwayat obrolan akan dilupakan untuk memberi ruang pada pesan baru.", + "increaseContextSizeDescription": "Apakah Anda ingin memperbesar ukuran konteks?", + "truncateInput": "Potong Input", + "increaseContextSize": "Perbesar Ukuran Konteks" +} diff --git a/web-app/src/locales/vn/model-errors.json b/web-app/src/locales/vn/model-errors.json new file mode 100644 index 000000000..73f51d7da --- /dev/null +++ b/web-app/src/locales/vn/model-errors.json @@ -0,0 +1,7 @@ +{ + "title": "Lỗi vượt quá ngữ cảnh", + "description": "Cuộc trò chuyện này đang đạt đến giới hạn bộ nhớ của AI, giống như một bảng trắng sắp đầy. Chúng ta có thể mở rộng cửa sổ bộ nhớ (gọi là kích thước ngữ cảnh) để AI nhớ được nhiều hơn, nhưng điều này có thể sử dụng nhiều bộ nhớ máy tính của bạn hơn. Chúng ta cũng có thể cắt bớt đầu vào, nghĩa là AI sẽ quên một phần lịch sử trò chuyện để dành chỗ cho các tin nhắn mới.", + "increaseContextSizeDescription": "Bạn có muốn tăng kích thước ngữ cảnh không?", + "truncateInput": "Cắt bớt đầu vào", + "increaseContextSize": "Tăng kích thước ngữ cảnh" +} diff --git a/web-app/src/locales/zh-CN/model-errors.json b/web-app/src/locales/zh-CN/model-errors.json new file mode 100644 index 000000000..952082a16 --- /dev/null +++ b/web-app/src/locales/zh-CN/model-errors.json @@ -0,0 +1,7 @@ +{ + "title": "超出上下文错误", + "description": "此对话已接近 AI 的记忆上限,就像白板快被填满一样。我们可以扩展记忆窗口(称为上下文大小),这样它能记住更多内容,但可能会占用你电脑更多内存。我们也可以截断输入,这意味着它会遗忘部分聊天记录,为新消息腾出空间。", + "increaseContextSizeDescription": "你想要增加上下文大小吗?", + "truncateInput": "截断输入", + "increaseContextSize": "增加上下文大小" +} diff --git a/web-app/src/locales/zh-TW/model-errors.json b/web-app/src/locales/zh-TW/model-errors.json new file mode 100644 index 000000000..90cf16f77 --- /dev/null +++ b/web-app/src/locales/zh-TW/model-errors.json @@ -0,0 +1,7 @@ +{ + "title": "超出上下文錯誤", + "description": "此對話已接近 AI 的記憶上限,就像白板快被填滿一樣。我們可以擴大記憶視窗(稱為上下文大小),讓它能記住更多內容,但這可能會佔用你電腦更多記憶體。我們也可以截斷輸入,這表示它會忘記部分對話歷史,以便為新訊息騰出空間。", + "increaseContextSizeDescription": "你想要增加上下文大小嗎?", + "truncateInput": "截斷輸入", + "increaseContextSize": "增加上下文大小" +}