feat: add runtime lifecycle health revalidation
This commit is contained in:
1 parent
f5a4a9ee97
commit
b757990f8d
27 files changed
+4851
-277
No files matched your search
Generated
+1678
-1
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test": "vitest run",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
@@ -12,9 +13,9 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"marked": "^12.0.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.0.0",
|
||||
"marked": "^12.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -23,8 +24,10 @@
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.8",
|
||||
"@vitejs/plugin-react": "^4.3.1"
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
+66
-122
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
|
||||
pub fn check_health(addr: &str, timeout: Duration) -> bool {
|
||||
let Some(socket_addr) = resolve_socket_addr(addr) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match TcpStream::connect_timeout(&socket_addr, timeout) {
|
||||
Ok(mut stream) => {
|
||||
let _ = stream.set_read_timeout(Some(timeout));
|
||||
let _ = stream.set_write_timeout(Some(timeout));
|
||||
|
||||
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();
|
||||
return stream.read_to_string(&mut response).is_ok()
|
||||
&& health_response_is_ready(&response);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
Err(_) => 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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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::{check_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;
|
||||
}
|
||||
|
||||
if inner.config.connection_mode == ConnectionMode::Local
|
||||
&& matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded)
|
||||
{
|
||||
if check_health(&inner.config.addr, Duration::from_millis(400)) {
|
||||
inner.status = RuntimeStatus::Ready;
|
||||
if inner.last_error.as_ref().map(|error| error.code.as_str())
|
||||
== Some("health_check_failed")
|
||||
{
|
||||
inner.last_error = None;
|
||||
}
|
||||
} else {
|
||||
inner.status = RuntimeStatus::Degraded;
|
||||
inner.last_error = Some(runtime_error(
|
||||
"health_check_failed",
|
||||
format!(
|
||||
"Local API process is still running, but /health is no longer ready on {}.",
|
||||
inner.config.addr
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spawn_healthy_server() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("read healthy addr")
|
||||
.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
use std::io::{Read, Write};
|
||||
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let _ = stream.read(&mut buffer);
|
||||
let _ = stream.write_all(
|
||||
b"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\"}",
|
||||
);
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
fn unused_local_addr() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port");
|
||||
let addr = listener.local_addr().expect("read local addr").to_string();
|
||||
drop(listener);
|
||||
addr
|
||||
}
|
||||
|
||||
fn spawn_sleeping_child() -> Child {
|
||||
std::process::Command::new("python")
|
||||
.args(["-c", "import time; time.sleep(60)"])
|
||||
.spawn()
|
||||
.expect("spawn sleeping child")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() {
|
||||
let mut child = spawn_sleeping_child();
|
||||
let addr = unused_local_addr();
|
||||
|
||||
let (status, error_code) = {
|
||||
let mut inner = RuntimeInner {
|
||||
child: Some(child),
|
||||
status: RuntimeStatus::Ready,
|
||||
last_error: None,
|
||||
config: RuntimeConfig {
|
||||
model_path: Some("D:/models/demo.gguf".to_string()),
|
||||
addr,
|
||||
..RuntimeConfig::default()
|
||||
},
|
||||
};
|
||||
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
child = inner.child.take().expect("process remains managed");
|
||||
|
||||
(
|
||||
inner.status,
|
||||
inner.last_error.as_ref().map(|error| error.code.clone()),
|
||||
)
|
||||
};
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert_eq!(status, RuntimeStatus::Degraded);
|
||||
assert_eq!(error_code.as_deref(), Some("health_check_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() {
|
||||
let mut child = spawn_sleeping_child();
|
||||
let addr = spawn_healthy_server();
|
||||
|
||||
let (status, error_code) = {
|
||||
let mut inner = RuntimeInner {
|
||||
child: Some(child),
|
||||
status: RuntimeStatus::Degraded,
|
||||
last_error: Some(runtime_error(
|
||||
"health_check_failed",
|
||||
"Local API process is still running, but /health is no longer ready.",
|
||||
)),
|
||||
config: RuntimeConfig {
|
||||
model_path: Some("D:/models/demo.gguf".to_string()),
|
||||
addr,
|
||||
..RuntimeConfig::default()
|
||||
},
|
||||
};
|
||||
|
||||
refresh_process_state(&mut inner);
|
||||
|
||||
child = inner.child.take().expect("process remains managed");
|
||||
|
||||
(
|
||||
inner.status,
|
||||
inner.last_error.as_ref().map(|error| error.code.clone()),
|
||||
)
|
||||
};
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert_eq!(status, RuntimeStatus::Ready);
|
||||
assert_eq!(error_code, None);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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, Debug)]
|
||||
#[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,
|
||||
}
|
||||
+12
-18
@@ -3,7 +3,7 @@ import Sidebar from "./components/Sidebar";
|
||||
import ChatView from "./components/ChatView";
|
||||
import { useDB } from "./hooks/useDB";
|
||||
import { useModels } from "./hooks/useModels";
|
||||
import { useServerProcess } from "./hooks/useServerProcess";
|
||||
import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
|
||||
import type { Chat } from "./lib/types";
|
||||
import "./App.css";
|
||||
|
||||
@@ -11,22 +11,20 @@ export default function App() {
|
||||
const { db, ready } = useDB();
|
||||
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [server, setServer] = useState(() => localStorage.getItem("forgelm_server") || "http://127.0.0.1:8080");
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || "");
|
||||
|
||||
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey);
|
||||
const serverProcess = useServerProcess();
|
||||
|
||||
// Persist server/key
|
||||
useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]);
|
||||
useEffect(() => { localStorage.setItem("forgelm_apikey", apiKey); }, [apiKey]);
|
||||
const runtime = useRuntimeManager();
|
||||
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
|
||||
runtime.connection.serverUrl,
|
||||
runtime.connection.apiKey,
|
||||
Boolean(runtime.connection.serverUrl),
|
||||
);
|
||||
|
||||
// Auto-refresh models when server starts
|
||||
useEffect(() => {
|
||||
if (serverProcess.status === "running") {
|
||||
if (runtime.snapshot.status === "ready") {
|
||||
setTimeout(refreshModels, 2000);
|
||||
}
|
||||
}, [serverProcess.status, refreshModels]);
|
||||
}, [runtime.snapshot.status, refreshModels]);
|
||||
|
||||
const loadChats = useCallback(() => {
|
||||
if (!db) return;
|
||||
@@ -70,22 +68,18 @@ export default function App() {
|
||||
onSelectChat={setCurrentChatId}
|
||||
onNewChat={newChat}
|
||||
onDeleteChat={deleteChat}
|
||||
server={server}
|
||||
onServerChange={setServer}
|
||||
apiKey={apiKey}
|
||||
onApiKeyChange={setApiKey}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
status={status}
|
||||
onRefreshModels={refreshModels}
|
||||
serverProcess={serverProcess}
|
||||
runtime={runtime}
|
||||
/>
|
||||
<ChatView
|
||||
db={db!}
|
||||
chatId={currentChatId}
|
||||
server={server}
|
||||
apiKey={apiKey}
|
||||
server={runtime.connection.serverUrl}
|
||||
apiKey={runtime.connection.apiKey}
|
||||
model={selectedModel}
|
||||
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); }}
|
||||
onChatUpdated={loadChats}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
|
||||
import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess";
|
||||
import ServerPanel from "./ServerPanel";
|
||||
import RuntimeSettingsDrawer from "../features/runtime/components/RuntimeSettingsDrawer";
|
||||
import RuntimeStatusCard from "../features/runtime/components/RuntimeStatusCard";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "../features/runtime/types";
|
||||
import "./Sidebar.css";
|
||||
|
||||
interface Props {
|
||||
@@ -10,33 +11,31 @@ interface Props {
|
||||
onSelectChat: (id: number) => void;
|
||||
onNewChat: () => void;
|
||||
onDeleteChat: (id: number) => void;
|
||||
server: string;
|
||||
onServerChange: (v: string) => void;
|
||||
apiKey: string;
|
||||
onApiKeyChange: (v: string) => void;
|
||||
models: ModelInfo[];
|
||||
selectedModel: string;
|
||||
onModelChange: (v: string) => void;
|
||||
status: ServerStatus;
|
||||
onRefreshModels: () => void;
|
||||
serverProcess: {
|
||||
status: ProcessStatus;
|
||||
message: string;
|
||||
start: (cfg: ServerConfig) => void;
|
||||
stop: () => void;
|
||||
runtime: {
|
||||
snapshot: RuntimeSnapshot;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
startLocal: () => Promise<unknown>;
|
||||
stopLocal: () => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
|
||||
server, onServerChange, apiKey, onApiKeyChange,
|
||||
models, selectedModel, onModelChange, status, onRefreshModels,
|
||||
serverProcess,
|
||||
runtime,
|
||||
}: Props) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showRuntimeSettings, setShowRuntimeSettings] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<>
|
||||
<aside className="sidebar">
|
||||
{/* ── Header ── */}
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-brand">
|
||||
@@ -45,42 +44,20 @@ export default function Sidebar({
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
title="Connection settings"
|
||||
onClick={() => setShowRuntimeSettings(true)}
|
||||
title="Runtime settings"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Connection settings ── */}
|
||||
{showSettings && (
|
||||
<div className="settings-panel">
|
||||
<label className="settings-label">Server URL</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={server}
|
||||
onChange={(e) => onServerChange(e.target.value)}
|
||||
onBlur={onRefreshModels}
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
/>
|
||||
<label className="settings-label">API Key (optional)</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
placeholder="sk-…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Local server control ── */}
|
||||
<ServerPanel
|
||||
status={serverProcess.status}
|
||||
message={serverProcess.message}
|
||||
onStart={serverProcess.start}
|
||||
onStop={serverProcess.stop}
|
||||
<RuntimeStatusCard
|
||||
snapshot={runtime.snapshot}
|
||||
loading={runtime.loading}
|
||||
busy={runtime.busy}
|
||||
onOpenSettings={() => setShowRuntimeSettings(true)}
|
||||
onStart={() => { void runtime.startLocal(); }}
|
||||
onStop={() => { void runtime.stopLocal(); }}
|
||||
/>
|
||||
|
||||
{/* ── Active model selector ── */}
|
||||
@@ -144,6 +121,18 @@ export default function Sidebar({
|
||||
<div className="sidebar-footer">
|
||||
<span className="footer-version">ForgeLM v0.1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</aside>
|
||||
|
||||
<RuntimeSettingsDrawer
|
||||
open={showRuntimeSettings}
|
||||
snapshot={runtime.snapshot}
|
||||
busy={runtime.busy}
|
||||
onClose={() => setShowRuntimeSettings(false)}
|
||||
onSave={runtime.saveConfig}
|
||||
onStart={() => runtime.startLocal()}
|
||||
onStop={() => runtime.stopLocal()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
|
||||
import type {
|
||||
RuntimeConfig,
|
||||
RuntimeGpuInfo,
|
||||
RuntimeModelFile,
|
||||
RuntimePickDirResult,
|
||||
RuntimeSnapshot,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function readFallbackSnapshot(): RuntimeSnapshot {
|
||||
if (!canUseLocalStorage()) {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
|
||||
try {
|
||||
return { ...createDefaultRuntimeSnapshot(), ...JSON.parse(raw) } as RuntimeSnapshot;
|
||||
} catch {
|
||||
return createDefaultRuntimeSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function writeFallbackSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
|
||||
if (canUseLocalStorage()) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function bootstrapRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("bootstrap_runtime_command");
|
||||
} catch {
|
||||
return readFallbackSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRuntimeSnapshot(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("get_runtime_snapshot_command");
|
||||
} catch {
|
||||
return readFallbackSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRuntimeConfig(config: RuntimeConfig): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("save_runtime_config_command_handler", { config });
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({ ...snapshot, config });
|
||||
}
|
||||
}
|
||||
|
||||
export async function startRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("start_runtime_command");
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({
|
||||
...snapshot,
|
||||
status: "error",
|
||||
last_error: { code: "tauri_unavailable", message: "Local runtime control requires the Tauri desktop app." },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopRuntime(): Promise<RuntimeSnapshot> {
|
||||
try {
|
||||
return await invoke<RuntimeSnapshot>("stop_runtime_command");
|
||||
} catch {
|
||||
const snapshot = readFallbackSnapshot();
|
||||
return writeFallbackSnapshot({ ...snapshot, status: "stopped", process_running: false, ready: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryRuntimeGpu(): Promise<RuntimeGpuInfo | null> {
|
||||
try {
|
||||
return await invoke<RuntimeGpuInfo>("query_gpu");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanRuntimeModels(dir: string): Promise<RuntimeModelFile[]> {
|
||||
return invoke<RuntimeModelFile[]>("scan_models", { dir });
|
||||
}
|
||||
|
||||
export async function pickRuntimeModelsDir(): Promise<RuntimePickDirResult | null> {
|
||||
try {
|
||||
return await invoke<RuntimePickDirResult>("pick_models_dir");
|
||||
} catch (error) {
|
||||
if (String(error) === "cancelled") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickRuntimeModelFiles(): Promise<RuntimeModelFile[]> {
|
||||
try {
|
||||
return await invoke<RuntimeModelFile[]>("pick_model_files");
|
||||
} catch (error) {
|
||||
if (String(error) === "cancelled") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
.runtime-drawer-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.runtime-drawer-backdrop {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: rgba(5, 6, 10, 0.58);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.runtime-drawer {
|
||||
width: min(560px, 100%);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(72, 166, 255, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, #10161f, #0b1017 72%);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: -28px 0 70px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.runtime-drawer-header,
|
||||
.runtime-drawer-footer {
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.runtime-drawer-footer {
|
||||
border-bottom: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-drawer-kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #78bdf4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.runtime-drawer-header h2 {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.runtime-drawer-copy {
|
||||
margin-top: 10px;
|
||||
max-width: 34ch;
|
||||
color: var(--text2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.runtime-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-drawer-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 22px 28px;
|
||||
}
|
||||
|
||||
.runtime-drawer-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.runtime-section-card,
|
||||
.runtime-overview-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.runtime-section-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.runtime-overview-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.runtime-overview-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runtime-overview-value {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.runtime-overview-mono,
|
||||
.runtime-static-value-mono {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-drawer-section-title,
|
||||
.runtime-field-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runtime-segmented {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn,
|
||||
.runtime-footer-btn {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn {
|
||||
flex: 1;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.runtime-segmented-btn.active {
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
.runtime-field-input,
|
||||
.runtime-static-value {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
padding: 11px 13px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.runtime-static-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.runtime-field-select {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.runtime-static-value-path {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.runtime-grid-two {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-field-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.runtime-field-row-top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.runtime-field-support {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-inline-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runtime-inline-btn,
|
||||
.runtime-preset,
|
||||
.runtime-chip {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-inline-btn {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-inline-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-chip span {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.runtime-preset-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-preset {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-preset.active {
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: rgba(72, 166, 255, 0.16);
|
||||
}
|
||||
|
||||
.runtime-checkbox-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-error-banner {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(240, 64, 64, 0.18);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
color: #ffb7b7;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-warning-banner {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(243, 211, 139, 0.18);
|
||||
background: rgba(243, 211, 139, 0.08);
|
||||
color: #f3d38b;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-footer-btn {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.runtime-footer-btn:active:not(:disabled),
|
||||
.runtime-segmented-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.runtime-footer-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-footer-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-footer-btn-primary {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.55);
|
||||
background: linear-gradient(135deg, #2c8ce8, #49c0ff);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.runtime-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.runtime-overview-card,
|
||||
.runtime-grid-two,
|
||||
.runtime-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.runtime-drawer-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
pickRuntimeModelFiles,
|
||||
pickRuntimeModelsDir,
|
||||
queryRuntimeGpu,
|
||||
scanRuntimeModels,
|
||||
} from "../api";
|
||||
import type {
|
||||
RuntimeConfig,
|
||||
RuntimeGpuInfo,
|
||||
RuntimeModelFile,
|
||||
RuntimeSnapshot,
|
||||
} from "../types";
|
||||
import "./RuntimeSettingsDrawer.css";
|
||||
|
||||
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
|
||||
|
||||
function pathName(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
function toModelFile(path: string): RuntimeModelFile {
|
||||
return { name: pathName(path) || path, path };
|
||||
}
|
||||
|
||||
function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] {
|
||||
const seen = new Set<string>();
|
||||
return models.filter((model) => {
|
||||
if (seen.has(model.path)) return false;
|
||||
seen.add(model.path);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string {
|
||||
const nextChoices = [...scanned, ...extra].map((model) => model.path);
|
||||
if (current && (nextChoices.length === 0 || nextChoices.includes(current))) {
|
||||
return current;
|
||||
}
|
||||
return scanned[0]?.path || extra[0]?.path || "";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
snapshot: RuntimeSnapshot;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
|
||||
onStart: () => Promise<unknown>;
|
||||
onStop: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export default function RuntimeSettingsDrawer({
|
||||
open,
|
||||
snapshot,
|
||||
busy,
|
||||
onClose,
|
||||
onSave,
|
||||
onStart,
|
||||
onStop,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState(snapshot.config.connection_mode);
|
||||
const [startPolicy, setStartPolicy] = useState(snapshot.config.start_policy);
|
||||
const [serverUrl, setServerUrl] = useState(snapshot.config.server_url);
|
||||
const [apiKey, setApiKey] = useState(snapshot.config.api_key);
|
||||
const [modelsDir, setModelsDir] = useState(snapshot.config.models_dir || "");
|
||||
const [selectedModel, setSelectedModel] = useState(snapshot.config.model_path || "");
|
||||
const [addr, setAddr] = useState(snapshot.config.addr);
|
||||
const [gpuLayers, setGpuLayers] = useState(snapshot.config.gpu_layers);
|
||||
const [ctxSize, setCtxSize] = useState(snapshot.config.ctx_size);
|
||||
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
|
||||
const [extraModels, setExtraModels] = useState<RuntimeModelFile[]>([]);
|
||||
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
|
||||
const [gpuError, setGpuError] = useState("");
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const extra = snapshot.config.extra_model_paths.map(toModelFile);
|
||||
|
||||
setMode(snapshot.config.connection_mode);
|
||||
setStartPolicy(snapshot.config.start_policy);
|
||||
setServerUrl(snapshot.config.server_url);
|
||||
setApiKey(snapshot.config.api_key);
|
||||
setModelsDir(snapshot.config.models_dir || "");
|
||||
setSelectedModel(snapshot.config.model_path || "");
|
||||
setAddr(snapshot.config.addr);
|
||||
setGpuLayers(snapshot.config.gpu_layers);
|
||||
setCtxSize(snapshot.config.ctx_size);
|
||||
setExtraModels(extra);
|
||||
setScannedModels([]);
|
||||
setLocalError("");
|
||||
setGpuError("");
|
||||
|
||||
if (snapshot.config.models_dir?.trim()) {
|
||||
setScanning(true);
|
||||
scanRuntimeModels(snapshot.config.models_dir)
|
||||
.then((models) => {
|
||||
setScannedModels(models);
|
||||
setSelectedModel((current) => chooseModel(current || snapshot.config.model_path || "", models, extra));
|
||||
})
|
||||
.catch((error) => setLocalError(String(error)))
|
||||
.finally(() => setScanning(false));
|
||||
}
|
||||
|
||||
queryRuntimeGpu()
|
||||
.then((info) => {
|
||||
setGpuInfo(info);
|
||||
if (info && snapshot.config.gpu_layers === 0) {
|
||||
setGpuLayers(info.recommended_layers);
|
||||
}
|
||||
})
|
||||
.catch((error) => setGpuError(String(error)));
|
||||
}, [open, snapshot]);
|
||||
|
||||
const allModels = useMemo(() => {
|
||||
const merged = dedupeModels([...scannedModels, ...extraModels]);
|
||||
if (selectedModel && !merged.some((model) => model.path === selectedModel)) {
|
||||
return [toModelFile(selectedModel), ...merged];
|
||||
}
|
||||
return merged;
|
||||
}, [extraModels, scannedModels, selectedModel]);
|
||||
|
||||
const savedLocalModel = snapshot.config.model_path?.trim() || "";
|
||||
const hasPendingModeChange = mode !== snapshot.config.connection_mode;
|
||||
const maxLayers = gpuInfo?.recommended_layers ?? 200;
|
||||
const startReason =
|
||||
mode !== "local"
|
||||
? "Save mode before starting the local runtime."
|
||||
: !snapshot.binary_path
|
||||
? "Build or bundle the local API binary before starting."
|
||||
: !selectedModel.trim()
|
||||
? "Choose a model before starting the local runtime."
|
||||
: "";
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const buildPatch = (): Partial<RuntimeConfig> => ({
|
||||
connection_mode: mode,
|
||||
start_policy: startPolicy,
|
||||
server_url: serverUrl.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
models_dir: mode === "local" ? (modelsDir.trim() || null) : snapshot.config.models_dir,
|
||||
model_path: mode === "local" ? (selectedModel.trim() || null) : snapshot.config.model_path,
|
||||
addr: mode === "local" ? addr.trim() || snapshot.config.addr : snapshot.config.addr,
|
||||
gpu_layers: mode === "local" ? Math.max(0, Math.min(gpuLayers, maxLayers)) : snapshot.config.gpu_layers,
|
||||
ctx_size: mode === "local" ? Math.max(512, ctxSize || 4096) : snapshot.config.ctx_size,
|
||||
extra_model_paths: mode === "local" ? extraModels.map((model) => model.path) : snapshot.config.extra_model_paths,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
await onSave(buildPatch());
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndStart = async () => {
|
||||
setLocalError("");
|
||||
|
||||
if (snapshot.process_running) {
|
||||
try {
|
||||
await onStop();
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await onSave(buildPatch());
|
||||
if (next.config.connection_mode !== "local") return;
|
||||
if (!next.config.model_path?.trim()) return;
|
||||
if (next.last_error?.code === "restart_required") return;
|
||||
await onStart();
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescan = async (dir = modelsDir) => {
|
||||
if (!dir.trim()) return;
|
||||
setScanning(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
const models = await scanRuntimeModels(dir.trim());
|
||||
setScannedModels(models);
|
||||
setSelectedModel((current) => chooseModel(current, models, extraModels));
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
setScannedModels([]);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickDir = async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const result = await pickRuntimeModelsDir();
|
||||
if (!result) return;
|
||||
setModelsDir(result.dir);
|
||||
setScannedModels(result.models);
|
||||
setSelectedModel((current) => chooseModel(current, result.models, extraModels));
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickFiles = async () => {
|
||||
setLocalError("");
|
||||
try {
|
||||
const picked = await pickRuntimeModelFiles();
|
||||
if (picked.length === 0) return;
|
||||
setExtraModels((current) => {
|
||||
const next = dedupeModels([...current, ...picked]);
|
||||
if (!selectedModel) {
|
||||
setSelectedModel(next[0]?.path || "");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
setLocalError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveExtra = (path: string) => {
|
||||
setExtraModels((current) => {
|
||||
const next = current.filter((model) => model.path !== path);
|
||||
setSelectedModel((selected) => (selected === path ? chooseModel("", scannedModels, next) : selected));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Runtime settings">
|
||||
<button className="runtime-drawer-backdrop" onClick={onClose} aria-label="Close runtime settings" />
|
||||
<aside className="runtime-drawer">
|
||||
<div className="runtime-drawer-header">
|
||||
<div>
|
||||
<div className="runtime-drawer-kicker">Runtime workspace</div>
|
||||
<h2>One control plane for startup, models, and API routing</h2>
|
||||
<p className="runtime-drawer-copy">
|
||||
Save the config once, then start the managed API from the same surface.
|
||||
</p>
|
||||
</div>
|
||||
<button className="runtime-drawer-close" onClick={onClose} type="button" aria-label="Close runtime settings">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="runtime-drawer-body">
|
||||
<section className="runtime-overview-card">
|
||||
<div>
|
||||
<div className="runtime-overview-label">Current status</div>
|
||||
<div className="runtime-overview-value">{snapshot.status === "ready" ? "Local API ready" : snapshot.config.connection_mode === "remote" ? "Remote API mode" : "Local API setup"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-overview-label">Endpoint</div>
|
||||
<div className="runtime-overview-value runtime-overview-mono">{mode === "remote" ? (serverUrl.trim() || "Not configured") : `http://${addr.trim() || snapshot.config.addr}`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-overview-label">Saved model</div>
|
||||
<div className="runtime-overview-value">{savedLocalModel ? pathName(savedLocalModel) : "No model saved"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-drawer-section-title">Connection mode</div>
|
||||
<div className="runtime-segmented" role="tablist" aria-label="Connection mode">
|
||||
<button className={`runtime-segmented-btn${mode === "local" ? " active" : ""}`} onClick={() => setMode("local")} type="button">
|
||||
Local API
|
||||
</button>
|
||||
<button className={`runtime-segmented-btn${mode === "remote" ? " active" : ""}`} onClick={() => setMode("remote")} type="button">
|
||||
Remote API
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{mode === "remote" ? (
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
|
||||
<input
|
||||
id="runtime-remote-url"
|
||||
className="runtime-field-input"
|
||||
value={serverUrl}
|
||||
onChange={(event) => setServerUrl(event.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
|
||||
<input
|
||||
id="runtime-api-key"
|
||||
className="runtime-field-input"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="sk-..."
|
||||
type="password"
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||||
<div>
|
||||
<div className="runtime-field-label">Managed endpoint</div>
|
||||
<div className="runtime-static-value runtime-static-value-mono">http://{addr.trim() || snapshot.config.addr}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="runtime-field-label">Binary discovery</div>
|
||||
<div className="runtime-static-value runtime-static-value-path">{snapshot.binary_path || "Binary not found yet"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row">
|
||||
<div>
|
||||
<div className="runtime-field-label">Models folder</div>
|
||||
<div className="runtime-static-value runtime-static-value-path">{modelsDir || "Choose a folder or add individual files"}</div>
|
||||
</div>
|
||||
<div className="runtime-inline-actions">
|
||||
<button className="runtime-inline-btn" onClick={handlePickDir} type="button">Browse</button>
|
||||
<button className="runtime-inline-btn" onClick={() => { void handleRescan(); }} type="button" disabled={!modelsDir.trim() || scanning}>
|
||||
{scanning ? "Scanning..." : "Rescan"}
|
||||
</button>
|
||||
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
|
||||
<select
|
||||
id="runtime-model-select"
|
||||
className="runtime-field-input runtime-field-select"
|
||||
value={selectedModel}
|
||||
onChange={(event) => setSelectedModel(event.target.value)}
|
||||
>
|
||||
<option value="">Choose a model</option>
|
||||
{allModels.map((model) => (
|
||||
<option key={model.path} value={model.path}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{extraModels.length > 0 && (
|
||||
<div className="runtime-chip-list">
|
||||
{extraModels.map((model) => (
|
||||
<button
|
||||
key={model.path}
|
||||
className="runtime-chip"
|
||||
onClick={() => handleRemoveExtra(model.path)}
|
||||
type="button"
|
||||
title={`Remove ${model.name}`}
|
||||
>
|
||||
{model.name}
|
||||
<span>×</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
|
||||
<div>
|
||||
<label className="runtime-field-label" htmlFor="runtime-addr">Listen address</label>
|
||||
<input
|
||||
id="runtime-addr"
|
||||
className="runtime-field-input"
|
||||
value={addr}
|
||||
onChange={(event) => setAddr(event.target.value)}
|
||||
placeholder="127.0.0.1:8080"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="runtime-field-label" htmlFor="runtime-context">Context size</label>
|
||||
<input
|
||||
id="runtime-context"
|
||||
className="runtime-field-input"
|
||||
min={512}
|
||||
step={512}
|
||||
value={ctxSize}
|
||||
onChange={(event) => setCtxSize(Math.max(512, parseInt(event.target.value, 10) || 4096))}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<div className="runtime-field-row runtime-field-row-top">
|
||||
<div>
|
||||
<div className="runtime-field-label">GPU layers</div>
|
||||
<div className="runtime-field-support">
|
||||
{gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"}
|
||||
</div>
|
||||
</div>
|
||||
{gpuInfo && (
|
||||
<button className="runtime-inline-btn" onClick={() => setGpuLayers(gpuInfo.recommended_layers)} type="button">
|
||||
Use recommended
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="runtime-field-input"
|
||||
min={0}
|
||||
max={maxLayers}
|
||||
value={gpuLayers}
|
||||
onChange={(event) => setGpuLayers(Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)))}
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<div className="runtime-preset-row">
|
||||
{CTX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
className={`runtime-preset${ctxSize === preset ? " active" : ""}`}
|
||||
onClick={() => setCtxSize(preset)}
|
||||
type="button"
|
||||
>
|
||||
{preset >= 1024 ? `${preset / 1024}k` : preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtime-drawer-section runtime-section-card">
|
||||
<label className="runtime-checkbox-row">
|
||||
<input
|
||||
checked={startPolicy === "on_app_launch"}
|
||||
onChange={(event) => setStartPolicy(event.target.checked ? "on_app_launch" : "manual")}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Start the local API automatically when the desktop app opens</span>
|
||||
</label>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(localError || snapshot.last_error) && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-error-banner">{localError || snapshot.last_error?.message}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!snapshot.process_running && mode === "local" && startReason && (
|
||||
<section className="runtime-drawer-section">
|
||||
<div className="runtime-warning-banner">{startReason}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="runtime-drawer-footer">
|
||||
<button className="runtime-footer-btn runtime-footer-btn-secondary" onClick={() => { void handleSave(); }} type="button" disabled={busy}>
|
||||
{busy ? "Saving..." : "Save settings"}
|
||||
</button>
|
||||
|
||||
{mode === "local" && (
|
||||
<button
|
||||
className="runtime-footer-btn runtime-footer-btn-primary"
|
||||
onClick={() => { void handleSaveAndStart(); }}
|
||||
type="button"
|
||||
disabled={busy || hasPendingModeChange || (!snapshot.process_running && Boolean(startReason))}
|
||||
>
|
||||
{hasPendingModeChange
|
||||
? "Save mode before applying"
|
||||
: snapshot.process_running
|
||||
? "Stop local API"
|
||||
: "Save and start local API"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
.runtime-card {
|
||||
margin: 10px 12px 8px;
|
||||
padding: 15px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(72, 166, 255, 0.18), transparent 40%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01));
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.runtime-card-success {
|
||||
border-color: rgba(48, 209, 88, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-warning {
|
||||
border-color: rgba(255, 214, 10, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-danger {
|
||||
border-color: rgba(240, 64, 64, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.runtime-card-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.runtime-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.runtime-card-mode {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border2);
|
||||
color: var(--text2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.runtime-card-mode-local {
|
||||
color: #71c5ff;
|
||||
border-color: rgba(113, 197, 255, 0.28);
|
||||
}
|
||||
|
||||
.runtime-card-mode-remote {
|
||||
color: #92e5c7;
|
||||
border-color: rgba(146, 229, 199, 0.24);
|
||||
}
|
||||
|
||||
.runtime-card-copy {
|
||||
margin-top: 10px;
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.runtime-card-meta span,
|
||||
.runtime-card-note {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.runtime-card-note {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
border-radius: 12px;
|
||||
color: #f3d38b;
|
||||
border-color: rgba(243, 211, 139, 0.16);
|
||||
background: rgba(243, 211, 139, 0.08);
|
||||
}
|
||||
|
||||
.runtime-card-error {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(240, 64, 64, 0.2);
|
||||
background: rgba(240, 64, 64, 0.08);
|
||||
color: #ffb4b4;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.runtime-card-btn {
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border2);
|
||||
padding: 9px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.runtime-card-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.runtime-card-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-card-btn-secondary {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.runtime-card-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary {
|
||||
color: #fff;
|
||||
border-color: rgba(72, 166, 255, 0.6);
|
||||
background: linear-gradient(135deg, #2b88e6, #46c2ff);
|
||||
}
|
||||
|
||||
.runtime-card-btn-primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #3193f5, #55cbff);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils";
|
||||
import type { RuntimeSnapshot } from "../types";
|
||||
import "./RuntimeStatusCard.css";
|
||||
|
||||
function summarizePath(path: string | null): string {
|
||||
if (!path) return "No model selected";
|
||||
const segments = path.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
snapshot: RuntimeSnapshot;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
onOpenSettings: () => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export default function RuntimeStatusCard({ snapshot, loading, busy, onOpenSettings, onStart, onStop }: Props) {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
const isLocal = snapshot.config.connection_mode === "local";
|
||||
const startGuard = getRuntimeStartGuard(snapshot);
|
||||
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
|
||||
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
|
||||
const primaryLabel = needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop";
|
||||
const primaryDisabled = busy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
|
||||
|
||||
return (
|
||||
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
|
||||
<div className="runtime-card-header">
|
||||
<div>
|
||||
<div className="runtime-card-kicker">Runtime Manager</div>
|
||||
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
|
||||
</div>
|
||||
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
|
||||
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="runtime-card-copy">
|
||||
{snapshot.config.connection_mode === "local"
|
||||
? `Managed endpoint ${snapshot.resolved_url}`
|
||||
: snapshot.config.server_url || "Configure a remote API endpoint"}
|
||||
</p>
|
||||
|
||||
<div className="runtime-card-meta" aria-label="Runtime details">
|
||||
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
|
||||
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
|
||||
</div>
|
||||
|
||||
{needsSetup && <div className="runtime-card-note">{startGuard.reason}</div>}
|
||||
|
||||
{snapshot.last_error && (
|
||||
<div className="runtime-card-error" role="status">
|
||||
{snapshot.last_error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="runtime-card-actions">
|
||||
<button className="runtime-card-btn runtime-card-btn-secondary" onClick={onOpenSettings} type="button">
|
||||
Manage
|
||||
</button>
|
||||
{snapshot.config.connection_mode === "local" && (
|
||||
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
|
||||
{busy ? "Working..." : primaryLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
bootstrapRuntime,
|
||||
getRuntimeSnapshot,
|
||||
pickRuntimeModelsDir,
|
||||
saveRuntimeConfig,
|
||||
startRuntime,
|
||||
stopRuntime,
|
||||
} from "../api";
|
||||
import RuntimeSettingsDrawer from "./RuntimeSettingsDrawer";
|
||||
import RuntimeStatusCard from "./RuntimeStatusCard";
|
||||
import { useRuntimeManager } from "../useRuntimeManager";
|
||||
import { createDefaultRuntimeSnapshot } from "../runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "../types";
|
||||
|
||||
type SnapshotOverrides = Partial<Omit<RuntimeSnapshot, "config">> & {
|
||||
config?: Partial<RuntimeSnapshot["config"]>;
|
||||
};
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
bootstrapRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
|
||||
getRuntimeSnapshot: vi.fn(async () => createDefaultRuntimeSnapshot()),
|
||||
pickRuntimeModelFiles: vi.fn(async () => []),
|
||||
pickRuntimeModelsDir: vi.fn(async () => null),
|
||||
queryRuntimeGpu: vi.fn(async () => null),
|
||||
saveRuntimeConfig: vi.fn(async (config) => ({ ...createDefaultRuntimeSnapshot(), config })),
|
||||
scanRuntimeModels: vi.fn(async () => []),
|
||||
startRuntime: vi.fn(async () => ({ ...createDefaultRuntimeSnapshot(), status: "ready", ready: true, process_running: true })),
|
||||
stopRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
|
||||
}));
|
||||
|
||||
function makeSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
|
||||
return {
|
||||
...createDefaultRuntimeSnapshot(),
|
||||
...overrides,
|
||||
config: {
|
||||
...createDefaultRuntimeSnapshot().config,
|
||||
...overrides.config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function RuntimeManagerHarness({ startOpen = false }: { startOpen?: boolean }) {
|
||||
const runtime = useRuntimeManager();
|
||||
const [drawerOpen, setDrawerOpen] = useState(startOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RuntimeStatusCard
|
||||
snapshot={runtime.snapshot}
|
||||
loading={runtime.loading}
|
||||
busy={runtime.busy}
|
||||
onOpenSettings={() => setDrawerOpen(true)}
|
||||
onStart={() => {
|
||||
void runtime.startLocal();
|
||||
}}
|
||||
onStop={() => {
|
||||
void runtime.stopLocal();
|
||||
}}
|
||||
/>
|
||||
<RuntimeSettingsDrawer
|
||||
open={drawerOpen}
|
||||
snapshot={runtime.snapshot}
|
||||
busy={runtime.busy}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onSave={runtime.saveConfig}
|
||||
onStart={runtime.startLocal}
|
||||
onStop={runtime.stopLocal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Runtime app surface", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
// @ts-expect-error React test environment flag
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
||||
});
|
||||
|
||||
it("routes incomplete local runtime setup to the settings drawer instead of trying to start", async () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
const onStart = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeStatusCard
|
||||
snapshot={makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: { model_path: null, connection_mode: "local" },
|
||||
})}
|
||||
loading={false}
|
||||
busy={false}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onStart={onStart}
|
||||
onStop={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Set up"));
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
expect(onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves local runtime settings before starting from the unified drawer", async () => {
|
||||
const onSave = vi.fn(async (patch) => makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
...patch,
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
},
|
||||
}));
|
||||
const onStart = vi.fn(async () => makeSnapshot({ status: "starting" }));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeSettingsDrawer
|
||||
open
|
||||
snapshot={makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
models_dir: "D:/models",
|
||||
},
|
||||
})}
|
||||
busy={false}
|
||||
onClose={vi.fn()}
|
||||
onSave={onSave}
|
||||
onStart={onStart}
|
||||
onStop={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
|
||||
expect(action).toBeTruthy();
|
||||
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
action?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave.mock.calls[0]?.[0]).toMatchObject({
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
models_dir: "D:/models",
|
||||
});
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops the local runtime from the same drawer action when the process is already running", async () => {
|
||||
const onSave = vi.fn();
|
||||
const onStop = vi.fn(async () => makeSnapshot({ status: "stopping", process_running: false }));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeSettingsDrawer
|
||||
open
|
||||
snapshot={makeSnapshot({
|
||||
status: "ready",
|
||||
process_running: true,
|
||||
ready: true,
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
},
|
||||
})}
|
||||
busy={false}
|
||||
onClose={vi.fn()}
|
||||
onSave={onSave}
|
||||
onStart={vi.fn()}
|
||||
onStop={onStop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Stop local API"));
|
||||
expect(action).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
action?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the local primary CTA visible while switching from remote to local mode", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RuntimeSettingsDrawer
|
||||
open
|
||||
snapshot={makeSnapshot({
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
},
|
||||
})}
|
||||
busy={false}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn(async () => makeSnapshot())}
|
||||
onStart={vi.fn(async () => makeSnapshot({ status: "starting" }))}
|
||||
onStop={vi.fn(async () => makeSnapshot({ status: "stopped" }))}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const localTab = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Local API"));
|
||||
expect(localTab).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
localTab?.click();
|
||||
});
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save mode before applying"));
|
||||
expect(action).toBeTruthy();
|
||||
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("covers the saved local startup lifecycle from stopped to starting to ready", async () => {
|
||||
const initialSnapshot = makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
start_policy: "on_app_launch",
|
||||
},
|
||||
});
|
||||
const startDeferred = createDeferred<RuntimeSnapshot>();
|
||||
|
||||
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(startRuntime).mockReturnValue(startDeferred.promise);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RuntimeManagerHarness />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Local API offline");
|
||||
expect(container.textContent).toContain("demo.gguf");
|
||||
|
||||
const startButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Start"));
|
||||
expect(startButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
startButton?.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Starting local API");
|
||||
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
startDeferred.resolve(makeSnapshot({
|
||||
...initialSnapshot,
|
||||
status: "ready",
|
||||
process_running: true,
|
||||
ready: true,
|
||||
}));
|
||||
await startDeferred.promise;
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Ready");
|
||||
expect(container.textContent).toContain("Stop");
|
||||
});
|
||||
|
||||
it("supports local onboarding from browsing a models folder through save and start", async () => {
|
||||
const initialSnapshot = makeSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(pickRuntimeModelsDir).mockResolvedValue({
|
||||
dir: "D:/models",
|
||||
models: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
|
||||
});
|
||||
|
||||
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
|
||||
vi.mocked(saveRuntimeConfig).mockImplementation(async (config: RuntimeConfig) => makeSnapshot({
|
||||
...initialSnapshot,
|
||||
config,
|
||||
}));
|
||||
vi.mocked(startRuntime).mockResolvedValue(makeSnapshot({
|
||||
...initialSnapshot,
|
||||
status: "ready",
|
||||
process_running: true,
|
||||
ready: true,
|
||||
config: {
|
||||
...initialSnapshot.config,
|
||||
models_dir: "D:/models",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
},
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RuntimeManagerHarness startOpen />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const browseButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Browse"));
|
||||
expect(browseButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
browseButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const select = container.querySelector("#runtime-model-select") as HTMLSelectElement | null;
|
||||
expect(select?.value).toBe("D:/models/demo.gguf");
|
||||
expect(container.textContent).toContain("D:/models");
|
||||
|
||||
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
|
||||
expect(action).toBeTruthy();
|
||||
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
action?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveRuntimeConfig)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(saveRuntimeConfig).mock.calls[0]?.[0]).toMatchObject({
|
||||
connection_mode: "local",
|
||||
models_dir: "D:/models",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
});
|
||||
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Local API ready");
|
||||
expect(container.textContent).toContain("Stop local API");
|
||||
});
|
||||
|
||||
it("surfaces degraded local runtime state after periodic health revalidation fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const readySnapshot = makeSnapshot({
|
||||
status: "ready",
|
||||
ready: true,
|
||||
process_running: true,
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
},
|
||||
});
|
||||
|
||||
const degradedSnapshot = makeSnapshot({
|
||||
...readySnapshot,
|
||||
status: "degraded",
|
||||
ready: false,
|
||||
last_error: {
|
||||
code: "health_check_failed",
|
||||
message: "Local API process is still running, but /health is no longer ready.",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(bootstrapRuntime).mockResolvedValue(readySnapshot);
|
||||
vi.mocked(getRuntimeSnapshot).mockResolvedValue(degradedSnapshot);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RuntimeManagerHarness />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Ready");
|
||||
expect(container.textContent).toContain("Stop");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
expect(vi.mocked(getRuntimeSnapshot)).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Attention needed");
|
||||
expect(container.textContent).toContain("Local API process is still running, but /health is no longer ready.");
|
||||
expect(container.textContent).toContain("Stop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
|
||||
import type { RuntimeSnapshot } from "./types";
|
||||
|
||||
function createSnapshot(overrides: Partial<RuntimeSnapshot> = {}): RuntimeSnapshot {
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: "http://127.0.0.1:8080",
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getRuntimeConnection", () => {
|
||||
it("prefers the resolved local runtime url in local mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
resolved_url: "http://127.0.0.1:9000",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "http://example.com:9999",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:9000",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(connection.serverUrl).toBe("http://127.0.0.1:9000");
|
||||
expect(connection.mode).toBe("local");
|
||||
});
|
||||
|
||||
it("uses remote settings in remote mode", () => {
|
||||
const connection = getRuntimeConnection(createSnapshot({
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(connection.serverUrl).toBe("https://api.example.com");
|
||||
expect(connection.apiKey).toBe("sk-demo");
|
||||
expect(connection.mode).toBe("remote");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStateLabel", () => {
|
||||
it("reports ready snapshots as ready", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
|
||||
});
|
||||
|
||||
it("reports local background start progress", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
|
||||
});
|
||||
|
||||
it("reports remote mode without runtime ownership", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({
|
||||
status: "stopped",
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}))).toBe("Remote API");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStartGuard", () => {
|
||||
it("requires a saved model before starting the local runtime", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(false);
|
||||
expect(guard.reason.toLowerCase()).toContain("model");
|
||||
});
|
||||
|
||||
it("allows starting when the saved local runtime config is complete", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: "D:/models/demo.gguf",
|
||||
models_dir: "D:/models",
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 24,
|
||||
ctx_size: 8192,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(true);
|
||||
expect(guard.reason).toBe("");
|
||||
});
|
||||
|
||||
it("never offers managed start controls in remote mode", () => {
|
||||
const guard = getRuntimeStartGuard(createSnapshot({
|
||||
binary_path: "D:/bin/localllm.exe",
|
||||
config: {
|
||||
connection_mode: "remote",
|
||||
start_policy: "manual",
|
||||
server_url: "https://api.example.com",
|
||||
api_key: "sk-demo",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(guard.canStart).toBe(false);
|
||||
expect(guard.reason.toLowerCase()).toContain("remote");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { RuntimeConnection, RuntimeSnapshot } from "./types";
|
||||
|
||||
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
|
||||
|
||||
function normalizeUrl(value: string | null | undefined): string {
|
||||
const trimmed = (value || "").trim();
|
||||
return trimmed ? trimmed.replace(/\/+$/, "") : "";
|
||||
}
|
||||
|
||||
export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return {
|
||||
mode: "remote",
|
||||
serverUrl: normalizeUrl(snapshot.config.server_url),
|
||||
apiKey: snapshot.config.api_key || "",
|
||||
managed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "local",
|
||||
serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL,
|
||||
apiKey: snapshot.config.api_key || "",
|
||||
managed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "Remote API";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Ready";
|
||||
case "starting":
|
||||
return "Starting local API";
|
||||
case "stopping":
|
||||
return "Stopping local API";
|
||||
case "error":
|
||||
return "Local API error";
|
||||
case "degraded":
|
||||
return "Attention needed";
|
||||
default:
|
||||
return "Local API offline";
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "success";
|
||||
case "starting":
|
||||
case "stopping":
|
||||
case "degraded":
|
||||
return "warning";
|
||||
case "error":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuntimeStartGuard {
|
||||
canStart: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard {
|
||||
if (snapshot.config.connection_mode === "remote") {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Remote mode uses an external API and does not launch the local runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot.binary_path) {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Build or bundle the local API binary before starting the managed runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot.config.model_path?.trim()) {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Choose a local model before starting the managed runtime.",
|
||||
};
|
||||
}
|
||||
|
||||
return { canStart: true, reason: "" };
|
||||
}
|
||||
|
||||
export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
|
||||
return {
|
||||
status: "stopped",
|
||||
ready: false,
|
||||
process_running: false,
|
||||
resolved_url: DEFAULT_LOCAL_URL,
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "",
|
||||
api_key: "",
|
||||
model_path: null,
|
||||
models_dir: null,
|
||||
addr: "127.0.0.1:8080",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type ConnectionMode = "local" | "remote";
|
||||
export type StartPolicy = "manual" | "on_app_launch";
|
||||
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
|
||||
|
||||
export interface RuntimeConfig {
|
||||
connection_mode: ConnectionMode;
|
||||
start_policy: StartPolicy;
|
||||
server_url: string;
|
||||
api_key: string;
|
||||
model_path: string | null;
|
||||
models_dir: string | null;
|
||||
addr: string;
|
||||
gpu_layers: number;
|
||||
ctx_size: number;
|
||||
extra_model_paths: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeErrorInfo {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSnapshot {
|
||||
status: RuntimeStatus;
|
||||
ready: boolean;
|
||||
process_running: boolean;
|
||||
resolved_url: string;
|
||||
binary_path: string | null;
|
||||
last_error: RuntimeErrorInfo | null;
|
||||
config: RuntimeConfig;
|
||||
}
|
||||
|
||||
export interface RuntimeConnection {
|
||||
mode: ConnectionMode;
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeModelFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RuntimePickDirResult {
|
||||
dir: string;
|
||||
models: RuntimeModelFile[];
|
||||
}
|
||||
|
||||
export interface RuntimeGpuInfo {
|
||||
name: string;
|
||||
vram_mib: number;
|
||||
recommended_layers: number;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, startRuntime, stopRuntime } from "./api";
|
||||
import { createDefaultRuntimeSnapshot, getRuntimeConnection } from "./runtime-utils";
|
||||
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
|
||||
|
||||
export function useRuntimeManager() {
|
||||
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(createDefaultRuntimeSnapshot());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const next = await getRuntimeSnapshot();
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
bootstrapRuntime()
|
||||
.then((next) => {
|
||||
if (active) {
|
||||
setSnapshot(next);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
refresh().catch(() => undefined);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const saveConfig = useCallback(async (patch: Partial<RuntimeConfig>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await saveRuntimeConfig({ ...snapshot.config, ...patch });
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [snapshot.config]);
|
||||
|
||||
const startLocal = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
status: "starting",
|
||||
process_running: true,
|
||||
ready: false,
|
||||
last_error: null,
|
||||
}));
|
||||
try {
|
||||
const next = await startRuntime();
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopLocal = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setSnapshot((current) => ({
|
||||
...current,
|
||||
status: "stopping",
|
||||
ready: false,
|
||||
last_error: null,
|
||||
}));
|
||||
try {
|
||||
const next = await stopRuntime();
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connection = useMemo(() => getRuntimeConnection(snapshot), [snapshot]);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
connection,
|
||||
loading,
|
||||
busy,
|
||||
refresh,
|
||||
saveConfig,
|
||||
startLocal,
|
||||
stopLocal,
|
||||
};
|
||||
}
|
||||
@@ -1,26 +1,40 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ModelInfo, ServerStatus } from "../lib/types";
|
||||
|
||||
export function useModels(server: string, apiKey: string) {
|
||||
export function useModels(server: string, apiKey: string, enabled = true) {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [status, setStatus] = useState<ServerStatus>("loading");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || !server.trim()) {
|
||||
setModels([]);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${server}/v1/models`, { headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const list: ModelInfo[] = data.data || [];
|
||||
setModels(list);
|
||||
if (list.length > 0 && !selectedModel) setSelectedModel(list[0].id);
|
||||
if (list.length === 0) {
|
||||
setSelectedModel("");
|
||||
} else if (!selectedModel || !list.some((item) => item.id === selectedModel)) {
|
||||
setSelectedModel(list[0].id);
|
||||
}
|
||||
setStatus("ok");
|
||||
} catch {
|
||||
setModels([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [server, apiKey, selectedModel]);
|
||||
}, [enabled, server, apiKey, selectedModel]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
+18
-18
@@ -2,23 +2,23 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0c0c0e;
|
||||
--bg2: #141416;
|
||||
--bg3: #1c1c1f;
|
||||
--bg4: #242428;
|
||||
--bg5: #2c2c31;
|
||||
--border: #2a2a30;
|
||||
--border2: #38383f;
|
||||
--text: #e8e8ed;
|
||||
--text2: #8e8e99;
|
||||
--text3: #5a5a66;
|
||||
--accent: #5b6af0;
|
||||
--accent2: #4a58e0;
|
||||
--accent-glow: rgba(91,106,240,0.15);
|
||||
--user-bg: #1a2040;
|
||||
--user-border: #2a3560;
|
||||
--asst-bg: #141416;
|
||||
--asst-border: #2a2a30;
|
||||
--bg: #0a1016;
|
||||
--bg2: #0f1721;
|
||||
--bg3: #14202d;
|
||||
--bg4: #182636;
|
||||
--bg5: #203246;
|
||||
--border: #243241;
|
||||
--border2: #344759;
|
||||
--text: #edf5ff;
|
||||
--text2: #93a8bc;
|
||||
--text3: #5d7288;
|
||||
--accent: #48a6ff;
|
||||
--accent2: #2f8ae3;
|
||||
--accent-glow: rgba(72,166,255,0.16);
|
||||
--user-bg: #10263f;
|
||||
--user-border: #214468;
|
||||
--asst-bg: #101924;
|
||||
--asst-border: #243241;
|
||||
--danger: #f04040;
|
||||
--success: #30d158;
|
||||
--warning: #ffd60a;
|
||||
@@ -36,7 +36,7 @@ html, body, #root {
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
font-family: "Segoe UI Variable", "SF Pro Display", "IBM Plex Sans", "Inter", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user