@@ -199,8 +199,13 @@ pub fn cancel_ai_download() {
199199}
200200
201201/// Uninstalls the AI model and binary, resets state.
202+ /// Async because `stop_process` can block up to 5 seconds (SIGTERM + wait + SIGKILL).
202203#[ tauri:: command]
203- pub fn uninstall_ai ( ) {
204+ pub async fn uninstall_ai ( ) {
205+ tauri:: async_runtime:: spawn_blocking ( uninstall_ai_sync) . await . ok ( ) ;
206+ }
207+
208+ fn uninstall_ai_sync ( ) {
204209 let mut manager = MANAGER . lock_ignore_poison ( ) ;
205210 if let Some ( ref mut m) = * manager {
206211 // Stop server if running
@@ -359,6 +364,30 @@ pub fn get_ai_runtime_status() -> AiRuntimeStatus {
359364 }
360365}
361366
367+ /// System memory info returned to frontend for the RAM gauge.
368+ #[ derive( Debug , Clone , serde:: Serialize ) ]
369+ #[ serde( rename_all = "camelCase" ) ]
370+ pub struct SystemMemoryInfo {
371+ pub total_bytes : u64 ,
372+ /// Memory actively used by processes (app + wired + compressed on macOS).
373+ pub used_bytes : u64 ,
374+ /// Memory available for new allocations (free + inactive + purgeable on macOS).
375+ pub available_bytes : u64 ,
376+ }
377+
378+ /// Returns system memory info (total, used by processes, and available).
379+ /// Uses the `sysinfo` crate for cross-platform accuracy.
380+ #[ tauri:: command]
381+ pub fn get_system_memory_info ( ) -> SystemMemoryInfo {
382+ let mut sys = sysinfo:: System :: new ( ) ;
383+ sys. refresh_memory ( ) ;
384+ SystemMemoryInfo {
385+ total_bytes : sys. total_memory ( ) ,
386+ used_bytes : sys. used_memory ( ) ,
387+ available_bytes : sys. available_memory ( ) ,
388+ }
389+ }
390+
362391/// Stores provider + context size + OpenAI config in manager state.
363392/// If provider is `local` and model is installed and hardware is supported, starts the server
364393/// in a background task. If provider is NOT `local` and a server is running, stops it.
@@ -497,6 +526,116 @@ pub fn start_ai_server<R: Runtime>(app: AppHandle<R>, ctx_size: u32) -> Result<(
497526 Ok ( ( ) )
498527}
499528
529+ /// Result of checking connectivity to an AI API endpoint.
530+ #[ derive( Debug , Clone , serde:: Serialize ) ]
531+ #[ serde( rename_all = "camelCase" ) ]
532+ pub struct AiConnectionCheckResult {
533+ pub connected : bool ,
534+ pub auth_error : bool ,
535+ pub models : Vec < String > ,
536+ pub error : Option < String > ,
537+ }
538+
539+ /// Checks connectivity to an AI API endpoint by calling GET {base_url}/models.
540+ /// Returns connection status, auth status, and available model list.
541+ #[ tauri:: command]
542+ pub async fn check_ai_connection ( base_url : String , api_key : String ) -> AiConnectionCheckResult {
543+ let url = format ! ( "{}/models" , base_url. trim_end_matches( '/' ) ) ;
544+
545+ let client = match reqwest:: Client :: builder ( )
546+ . timeout ( std:: time:: Duration :: from_secs ( 10 ) )
547+ . build ( )
548+ {
549+ Ok ( c) => c,
550+ Err ( e) => {
551+ return AiConnectionCheckResult {
552+ connected : false ,
553+ auth_error : false ,
554+ models : vec ! [ ] ,
555+ error : Some ( format ! ( "Can't create HTTP client: {e}" ) ) ,
556+ } ;
557+ }
558+ } ;
559+
560+ let mut request = client. get ( & url) ;
561+ if !api_key. is_empty ( ) {
562+ request = request. header ( "Authorization" , format ! ( "Bearer {api_key}" ) ) ;
563+ }
564+
565+ let response = match request. send ( ) . await {
566+ Ok ( r) => r,
567+ Err ( e) => {
568+ let msg = if e. is_timeout ( ) {
569+ String :: from ( "Can't reach server (timed out)" )
570+ } else if e. is_connect ( ) {
571+ String :: from ( "Can't reach server" )
572+ } else {
573+ format ! ( "Can't reach server: {e}" )
574+ } ;
575+ return AiConnectionCheckResult {
576+ connected : false ,
577+ auth_error : false ,
578+ models : vec ! [ ] ,
579+ error : Some ( msg) ,
580+ } ;
581+ }
582+ } ;
583+
584+ let status = response. status ( ) ;
585+
586+ if status == reqwest:: StatusCode :: UNAUTHORIZED || status == reqwest:: StatusCode :: FORBIDDEN {
587+ return AiConnectionCheckResult {
588+ connected : true ,
589+ auth_error : true ,
590+ models : vec ! [ ] ,
591+ error : Some ( String :: from ( "API key is invalid" ) ) ,
592+ } ;
593+ }
594+
595+ if status == reqwest:: StatusCode :: OK {
596+ let body = response. text ( ) . await . unwrap_or_default ( ) ;
597+ // Try parsing OpenAI-style response: { "data": [{ "id": "model-name" }, ...] }
598+ let models = parse_model_ids ( & body) ;
599+ return AiConnectionCheckResult {
600+ connected : true ,
601+ auth_error : false ,
602+ models,
603+ error : None ,
604+ } ;
605+ }
606+
607+ // Other HTTP error
608+ let body = response. text ( ) . await . unwrap_or_default ( ) ;
609+ let body_preview = if body. len ( ) > 200 {
610+ format ! ( "{}..." , & body[ ..200 ] )
611+ } else {
612+ body
613+ } ;
614+ AiConnectionCheckResult {
615+ connected : true ,
616+ auth_error : false ,
617+ models : vec ! [ ] ,
618+ error : Some ( format ! ( "HTTP {status}: {body_preview}" ) ) ,
619+ }
620+ }
621+
622+ /// Parses model IDs from an OpenAI-compatible /models response.
623+ /// Returns empty vec on parse failure (connected but can't list models).
624+ fn parse_model_ids ( body : & str ) -> Vec < String > {
625+ #[ derive( serde:: Deserialize ) ]
626+ struct ModelsResponse {
627+ data : Vec < ModelEntry > ,
628+ }
629+ #[ derive( serde:: Deserialize ) ]
630+ struct ModelEntry {
631+ id : String ,
632+ }
633+
634+ serde_json:: from_str :: < ModelsResponse > ( body)
635+ . map ( |r| r. data . into_iter ( ) . map ( |m| m. id ) . collect ( ) )
636+ . unwrap_or_default ( )
637+ }
638+
500639/// Formats bytes as GB with one decimal place (like "4.3 GB").
501640fn format_bytes_gb ( bytes : u64 ) -> String {
502641 let gb = bytes as f64 / 1_000_000_000.0 ;
@@ -633,6 +772,7 @@ async fn do_download<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
633772 // Step 1: Extract llama-server from bundled archive (instant, no download needed)
634773 let binary_path = ai_dir. join ( LLAMA_SERVER_BINARY ) ;
635774 if !binary_path. exists ( ) {
775+ let _ = app. emit ( "ai-extracting" , ( ) ) ;
636776 extract_bundled_llama_server ( app, & ai_dir) ?;
637777 }
638778
@@ -660,7 +800,8 @@ async fn do_download<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
660800
661801 download_file ( app, model. url , & model_path, is_cancel_requested) . await ?;
662802
663- // Verify download integrity by checking file size
803+ // Step 3: Verify download integrity by checking file size
804+ let _ = app. emit ( "ai-verifying" , ( ) ) ;
664805 let actual_size = fs:: metadata ( & model_path)
665806 . map ( |m| m. len ( ) )
666807 . map_err ( |e| format ! ( "Failed to read downloaded model file: {e}" ) ) ?;
0 commit comments