feat: integrate desktop runtime and root workspace updates

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-27 03:17:16 -03:00
1 parent f5a4a9ee97
commit 168623c459
93 files changed
+19540 -1208

No files matched your search

+5166
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command Line truncated
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ForgeLM Chat","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-open"]}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66 -122
View File
@@ -1,10 +1,14 @@
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::Mutex;
use std::path::Path;
use std::process::Command;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_dialog::DialogExt;
pub struct ServerState(pub Mutex<Option<Child>>);
mod runtime;
use runtime::config::{load_runtime_config, save_runtime_config};
use runtime::process::find_localllm_binary;
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
// ─── GPU ──────────────────────────────────────────────────────────────────────
@@ -47,107 +51,73 @@ fn query_gpu() -> Result<GpuInfo, String> {
// ─── Server lifecycle ─────────────────────────────────────────────────────────
/// Locate the `localllm` binary by searching several well-known locations:
/// 1. Workspace `target/release/` relative to the Tauri app's source tree
/// 2. Next to the running executable (for installed/bundled builds)
/// 3. System PATH
fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") { "localllm.exe" } else { "localllm" };
#[tauri::command]
fn bootstrap_runtime_command(
app: AppHandle,
state: State<RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
bootstrap_runtime(app, state)
}
// 1. Workspace target/release — works when running `npm run tauri dev` from tauri-app/
// The Tauri dev binary lives at tauri-app/src-tauri/target/debug/forgelm-chat.exe
// so we walk up to find the workspace root and look for target/release/localllm.exe
if let Ok(exe) = std::env::current_exe() {
// Walk up from the current exe looking for target/release/localllm.exe
let mut dir = exe.parent();
while let Some(d) = dir {
let candidate = d.join("target").join("release").join(exe_name);
if candidate.exists() {
return Some(candidate);
}
// Also check sibling: if we're inside target/debug, go up two levels
let candidate2 = d.join(exe_name);
if candidate2.exists() {
return Some(candidate2);
}
dir = d.parent();
}
}
#[tauri::command]
fn get_runtime_snapshot_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
get_runtime_snapshot(state)
}
// 2. Relative to CWD (useful when launched from repo root)
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
if cwd_candidate.exists() {
return Some(cwd_candidate);
}
#[tauri::command]
fn save_runtime_config_command_handler(
app: AppHandle,
state: State<RuntimeState>,
config: RuntimeConfig,
) -> Result<RuntimeSnapshot, String> {
save_runtime_config_command(app, state, config)
}
// 3. System PATH
if let Ok(output) = Command::new(if cfg!(target_os = "windows") { "where" } else { "which" })
.arg("localllm")
.output()
{
if output.status.success() {
let path_str = String::from_utf8_lossy(&output.stdout);
let first_line = path_str.lines().next().unwrap_or("").trim();
if !first_line.is_empty() {
return Some(PathBuf::from(first_line));
}
}
}
#[tauri::command]
fn start_runtime_command(
app: AppHandle,
state: State<RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
start_runtime(app, state)
}
None
#[tauri::command]
fn stop_runtime_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
stop_runtime(state)
}
#[tauri::command]
fn start_server(
state: State<ServerState>,
app: AppHandle,
state: State<RuntimeState>,
model_path: String,
addr: String,
gpu_layers: i32,
ctx_size: u32,
) -> Result<String, String> {
let mut guard = state.0.lock().map_err(|e| e.to_string())?;
if guard.is_some() {
return Err("Server is already running".into());
}
let binary_path = find_localllm_binary()
.ok_or_else(|| {
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
Then ensure the binary is in one of: target/release/, PATH, or next to this app."
.to_string()
})?;
let child = Command::new(&binary_path)
.env("LOCALLLM_MODEL", &model_path)
.env("LOCALLLM_ADDR", &addr)
.env("LOCALLLM_GPU_LAYERS", gpu_layers.to_string())
.env("LOCALLLM_CTX", ctx_size.to_string())
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))?;
let pid = child.id();
*guard = Some(child);
Ok(format!("Server started (pid={pid}) on {addr}"))
let mut config = load_runtime_config(&app).unwrap_or_default();
config.connection_mode = runtime::types::ConnectionMode::Local;
config.model_path = Some(model_path);
config.addr = addr;
config.gpu_layers = gpu_layers;
config.ctx_size = ctx_size;
save_runtime_config(&app, &config)?;
let snapshot = start_runtime(app, state)?;
Ok(format!("Local API {}", snapshot.resolved_url))
}
#[tauri::command]
fn stop_server(state: State<ServerState>) -> Result<String, String> {
let mut guard = state.0.lock().map_err(|e| e.to_string())?;
match guard.as_mut() {
None => Err("Server is not running".into()),
Some(child) => {
child.kill().map_err(|e| format!("Failed to kill server: {e}"))?;
let _ = child.wait();
*guard = None;
Ok("Server stopped".into())
}
}
fn stop_server(state: State<RuntimeState>) -> Result<String, String> {
let snapshot = stop_runtime(state)?;
Ok(format!("Local API stopped ({})", snapshot.resolved_url))
}
#[tauri::command]
fn server_status(state: State<ServerState>) -> String {
let guard = state.0.lock().unwrap();
if guard.is_some() { "running".into() } else { "stopped".into() }
fn server_status(state: State<RuntimeState>) -> String {
match get_runtime_snapshot(state) {
Ok(snapshot) if snapshot.process_running => "running".into(),
_ => "stopped".into(),
}
}
// ─── Model files ──────────────────────────────────────────────────────────────
@@ -288,46 +258,15 @@ fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, String>
// ─── Persistent config ────────────────────────────────────────────────────────
/// All user-configurable settings, persisted to app data dir as config.json.
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
pub struct AppConfig {
pub models_dir: Option<String>,
pub model_path: Option<String>,
pub addr: Option<String>,
pub gpu_layers: Option<i32>,
pub ctx_size: Option<u32>,
pub api_key: Option<String>,
pub server_url: Option<String>,
/// Extra model paths added via file picker (not necessarily in models_dir)
pub extra_model_paths: Option<Vec<String>>,
}
fn config_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Cannot create app data dir: {e}"))?;
Ok(data_dir.join("config.json"))
}
#[tauri::command]
fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> {
let path = config_path(&app)?;
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Serialization error: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
fn save_config(app: AppHandle, config: RuntimeConfig) -> Result<(), String> {
save_runtime_config(&app, &config)?;
Ok(())
}
#[tauri::command]
fn load_config(app: AppHandle) -> Result<AppConfig, String> {
let path = config_path(&app)?;
if !path.exists() {
return Ok(AppConfig::default());
}
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
fn load_config(app: AppHandle) -> Result<RuntimeConfig, String> {
load_runtime_config(&app)
}
/// Returns the resolved path to the localllm binary (useful for debugging).
@@ -357,13 +296,18 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(ServerState(Mutex::new(None)))
.manage(RuntimeState::new())
.invoke_handler(tauri::generate_handler![
query_gpu,
scan_models,
pick_models_dir,
pick_model_files,
import_model,
bootstrap_runtime_command,
get_runtime_snapshot_command,
save_runtime_config_command_handler,
start_runtime_command,
stop_runtime_command,
start_server,
stop_server,
server_status,
+32
View File
@@ -0,0 +1,32 @@
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use super::types::RuntimeConfig;
pub fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("Cannot create app data dir: {e}"))?;
Ok(data_dir.join("config.json"))
}
pub fn load_runtime_config(app: &AppHandle) -> Result<RuntimeConfig, String> {
let path = config_path(app)?;
if !path.exists() {
return Ok(RuntimeConfig::default());
}
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
}
pub fn save_runtime_config(app: &AppHandle, config: &RuntimeConfig) -> Result<(), String> {
let path = config_path(app)?;
let json =
serde_json::to_string_pretty(config).map_err(|e| format!("Serialization error: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
Ok(())
}
+120
View File
@@ -0,0 +1,120 @@
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::thread;
use std::time::{Duration, Instant};
fn resolve_socket_addr(addr: &str) -> Option<std::net::SocketAddr> {
match addr.to_socket_addrs() {
Ok(mut addrs) => addrs.next(),
Err(_) => None,
}
}
fn health_response_is_ready(response: &str) -> bool {
let mut sections = response.splitn(2, "\r\n\r\n");
let headers = sections.next().unwrap_or_default();
let body = sections.next().unwrap_or_default();
if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") {
return false;
}
let Ok(json) = serde_json::from_str::<serde_json::Value>(body) else {
return false;
};
json.get("status")
.and_then(|value| value.as_str())
.map(|status| status == "ok")
.unwrap_or(false)
}
pub fn wait_for_health(addr: &str, timeout: Duration) -> bool {
let Some(socket_addr) = resolve_socket_addr(addr) else {
return false;
};
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)) {
Ok(mut stream) => {
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(250)));
let request =
format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_ok() {
let mut response = String::new();
if stream.read_to_string(&mut response).is_ok()
&& health_response_is_ready(&response)
{
return true;
}
}
}
Err(_) => {}
}
thread::sleep(Duration::from_millis(250));
}
false
}
#[cfg(test)]
mod tests {
use super::wait_for_health;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
fn spawn_http_server(response: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let addr = listener.local_addr().expect("local addr");
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
addr.to_string()
}
#[test]
fn wait_for_health_accepts_healthy_http_response() {
let addr = spawn_http_server(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
);
assert!(wait_for_health(&addr, Duration::from_secs(1)));
}
#[test]
fn wait_for_health_rejects_non_healthy_payloads() {
let addr = spawn_http_server(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 21\r\n\r\n{\"status\":\"booting\"}",
);
assert!(!wait_for_health(&addr, Duration::from_secs(1)));
}
#[test]
fn wait_for_health_rejects_plain_open_ports() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind bare port");
let addr = listener.local_addr().expect("local addr").to_string();
thread::spawn(move || {
if let Ok((_stream, _)) = listener.accept() {
thread::sleep(Duration::from_millis(250));
}
});
assert!(!wait_for_health(&addr, Duration::from_millis(500)));
}
}
+254
View File
@@ -0,0 +1,254 @@
pub mod config;
pub mod health;
pub mod process;
pub mod types;
use std::process::Child;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, State};
use self::config::{load_runtime_config, save_runtime_config};
use self::health::wait_for_health;
use self::process::{find_localllm_binary, spawn_localllm};
use self::types::{
ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus, StartPolicy,
};
pub struct RuntimeInner {
pub child: Option<Child>,
pub status: RuntimeStatus,
pub last_error: Option<RuntimeErrorInfo>,
pub config: RuntimeConfig,
}
pub struct RuntimeState(pub Mutex<RuntimeInner>);
impl RuntimeState {
pub fn new() -> Self {
Self(Mutex::new(RuntimeInner {
child: None,
status: RuntimeStatus::Stopped,
last_error: None,
config: RuntimeConfig::default(),
}))
}
}
fn runtime_error(code: &str, message: impl Into<String>) -> RuntimeErrorInfo {
RuntimeErrorInfo {
code: code.to_string(),
message: message.into(),
}
}
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
match config.connection_mode {
ConnectionMode::Local => format!("http://{}", config.addr.trim()),
ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
}
}
fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool {
current.connection_mode != next.connection_mode
|| current.model_path != next.model_path
|| current.addr != next.addr
|| current.gpu_layers != next.gpu_layers
|| current.ctx_size != next.ctx_size
}
fn refresh_process_state(inner: &mut RuntimeInner) {
let Some(child) = inner.child.as_mut() else {
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded
) {
inner.status = RuntimeStatus::Stopped;
}
return;
};
match child.try_wait() {
Ok(Some(status)) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"unexpected_exit",
format!("Local API exited unexpectedly with status {status}"),
));
}
Ok(None) => {
if inner.status == RuntimeStatus::Stopped {
inner.status = RuntimeStatus::Ready;
}
}
Err(error) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("process_probe_failed", error.to_string()));
}
}
}
fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot {
RuntimeSnapshot {
status: inner.status,
ready: inner.status == RuntimeStatus::Ready,
process_running: inner.child.is_some(),
resolved_url: resolve_runtime_url(&inner.config),
binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()),
last_error: inner.last_error.clone(),
config: inner.config.clone(),
}
}
pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
Ok(build_snapshot(&inner))
}
pub fn save_runtime_config_command(
app: AppHandle,
state: State<'_, RuntimeState>,
config: RuntimeConfig,
) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
if inner.child.is_some() && requires_runtime_restart(&inner.config, &config) {
inner.last_error = Some(runtime_error(
"restart_required",
"Stop the local API before changing connection mode, model, address, or runtime sizing.",
));
return Ok(build_snapshot(&inner));
}
save_runtime_config(&app, &config)?;
inner.config = config;
inner.last_error = None;
Ok(build_snapshot(&inner))
}
fn start_runtime_inner(
app: &AppHandle,
state: &State<'_, RuntimeState>,
override_config: Option<RuntimeConfig>,
) -> Result<RuntimeSnapshot, String> {
let config = {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
if inner.child.is_some() {
return Ok(build_snapshot(&inner));
}
if let Some(config) = override_config {
save_runtime_config(app, &config)?;
inner.config = config;
}
if inner.config.connection_mode != ConnectionMode::Local {
inner.last_error = Some(runtime_error(
"remote_mode",
"Switch to Local mode before starting the built-in API",
));
inner.status = RuntimeStatus::Error;
return Ok(build_snapshot(&inner));
}
inner.status = RuntimeStatus::Starting;
inner.last_error = None;
inner.config.clone()
};
let child = match spawn_localllm(&config) {
Ok(child) => child,
Err(message) => {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("spawn_failed", message));
return Ok(build_snapshot(&inner));
}
};
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.child = Some(child);
inner.status = RuntimeStatus::Starting;
}
if wait_for_health(&config.addr, Duration::from_secs(15)) {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Ready;
inner.last_error = None;
Ok(build_snapshot(&inner))
} else {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
if let Some(child) = inner.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"health_timeout",
format!(
"Local API did not pass /health readiness on {} in time",
config.addr
),
));
Ok(build_snapshot(&inner))
}
}
pub fn bootstrap_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
let config = load_runtime_config(&app).unwrap_or_default();
let should_autostart = config.connection_mode == ConnectionMode::Local
&& config.start_policy == StartPolicy::OnAppLaunch
&& config
.model_path
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.config = config.clone();
refresh_process_state(&mut inner);
}
if should_autostart {
start_runtime_inner(&app, &state, Some(config))
} else {
get_runtime_snapshot(state)
}
}
pub fn start_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
start_runtime_inner(&app, &state, None)
}
pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Stopping;
if let Some(child) = inner.child.as_mut() {
child
.kill()
.map_err(|e| format!("Failed to kill server: {e}"))?;
let _ = child.wait();
}
inner.child = None;
inner.status = RuntimeStatus::Stopped;
inner.last_error = None;
Ok(build_snapshot(&inner))
}
@@ -0,0 +1,75 @@
use std::path::PathBuf;
use std::process::{Child, Command};
use super::types::RuntimeConfig;
pub fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") {
"localllm.exe"
} else {
"localllm"
};
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent();
while let Some(d) = dir {
let candidate = d.join("target").join("release").join(exe_name);
if candidate.exists() {
return Some(candidate);
}
let sibling_candidate = d.join(exe_name);
if sibling_candidate.exists() {
return Some(sibling_candidate);
}
dir = d.parent();
}
}
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
if cwd_candidate.exists() {
return Some(cwd_candidate);
}
if let Ok(output) = Command::new(if cfg!(target_os = "windows") {
"where"
} else {
"which"
})
.arg("localllm")
.output()
{
if output.status.success() {
let path_str = String::from_utf8_lossy(&output.stdout);
let first_line = path_str.lines().next().unwrap_or("").trim();
if !first_line.is_empty() {
return Some(PathBuf::from(first_line));
}
}
}
None
}
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
let model_path = config
.model_path
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Select a local model before starting the API".to_string())?;
let binary_path = find_localllm_binary().ok_or_else(|| {
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
Then ensure the binary is in target/release/, PATH, or next to this app."
.to_string()
})?;
Command::new(&binary_path)
.env("LOCALLLM_MODEL", model_path)
.env("LOCALLLM_ADDR", &config.addr)
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string())
.env("LOCALLLM_CTX", config.ctx_size.to_string())
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
}
+84
View File
@@ -0,0 +1,84 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionMode {
#[default]
Local,
Remote,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StartPolicy {
#[default]
Manual,
OnAppLaunch,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeStatus {
#[default]
Stopped,
Starting,
Ready,
Degraded,
Stopping,
Error,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct RuntimeConfig {
pub connection_mode: ConnectionMode,
pub start_policy: StartPolicy,
pub server_url: String,
pub api_key: String,
pub model_path: Option<String>,
pub models_dir: Option<String>,
pub addr: String,
pub gpu_layers: i32,
pub ctx_size: u32,
pub extra_model_paths: Vec<String>,
}
impl RuntimeConfig {
pub fn default_addr() -> String {
"127.0.0.1:8080".to_string()
}
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
connection_mode: ConnectionMode::Local,
start_policy: StartPolicy::Manual,
server_url: String::new(),
api_key: String::new(),
model_path: None,
models_dir: None,
addr: Self::default_addr(),
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: Vec::new(),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RuntimeErrorInfo {
pub code: String,
pub message: String,
}
#[derive(Serialize, Clone)]
pub struct RuntimeSnapshot {
pub status: RuntimeStatus,
pub ready: bool,
pub process_running: bool,
pub resolved_url: String,
pub binary_path: Option<String>,
pub last_error: Option<RuntimeErrorInfo>,
pub config: RuntimeConfig,
}