diff --git a/.osc-metadata/sync.json b/.osc-metadata/sync.json new file mode 100644 index 00000000..fb31c2b5 --- /dev/null +++ b/.osc-metadata/sync.json @@ -0,0 +1,5 @@ +{ + "fork_synced_at": "2026-07-10T09:51:06.647688+00:00", + "commits_behind_before_sync": 258, + "action_taken": "synced" +} \ No newline at end of file diff --git a/README.ja.md b/README.ja.md index 193adade..5631aecc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -850,7 +850,7 @@ llmfit は、組み込みのモデルダウンロード機能を備えたロー - llmfit は `GET /v1/models` をクエリして LM Studio で利用可能なモデルを一覧表示します - TUI で `d` を押すと `POST /api/v1/models/download` 経由でダウンロードをトリガーします -- ダウンロードの進捗は `GET /api/v1/models/download-status` をポーリングして追跡されます +- ダウンロードの進捗は POST 応答ストリームから読み取り、LM Studio がジョブ ID を返した場合は `GET /api/v1/models/download/status/:job_id` でも追跡します - LM Studio は HuggingFace のモデル名を直接受け入れるため、名前のマッピングは不要です ### リモートの LM Studio インスタンス diff --git a/README.zh.md b/README.zh.md index bd2636ca..0c775fb1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -850,7 +850,7 @@ llmfit 与 [LM Studio](https://lmstudio.ai) 集成,作为本地模型服务器 - llmfit 查询 `GET /v1/models` 列出 LM Studio 中可用的模型 - 在 TUI 中按 `d` 通过 `POST /api/v1/models/download` 触发下载 -- 通过轮询 `GET /api/v1/models/download-status` 跟踪下载进度 +- 下载进度先从 POST 响应流中读取;当 LM Studio 返回任务 ID 时,再通过 `GET /api/v1/models/download/status/:job_id` 跟踪 - LM Studio 直接接受 HuggingFace 模型名称,无需名称映射 ### 远程 LM Studio 实例 @@ -1000,4 +1000,4 @@ MIT --- -*本文档由 [@JasonYeYuhe](https://github.com/JasonYeYuhe) 翻译并维护。如果您发现任何翻译问题或需要增加新特性说明,欢迎提交 Issue 或与我联系。* \ No newline at end of file +*本文档由 [@JasonYeYuhe](https://github.com/JasonYeYuhe) 翻译并维护。如果您发现任何翻译问题或需要增加新特性说明,欢迎提交 Issue 或与我联系。* diff --git a/docs/providers.md b/docs/providers.md index 94ea525e..afb720aa 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -115,7 +115,7 @@ How it works: - llmfit queries `GET /v1/models` to list models available in LM Studio - pressing `d` in the TUI triggers a download via `POST /api/v1/models/download` -- download progress is tracked by polling `GET /api/v1/models/download-status` +- download progress is streamed from the POST response, then tracked via `GET /api/v1/models/download/status/:job_id` when LM Studio returns a job id - LM Studio accepts HuggingFace model names directly, so no name mapping is needed ### Remote LM Studio instances diff --git a/llmfit-core/src/providers.rs b/llmfit-core/src/providers.rs index da4defb8..1df1e147 100644 --- a/llmfit-core/src/providers.rs +++ b/llmfit-core/src/providers.rs @@ -1771,6 +1771,14 @@ impl LmStudioProvider { ) } + fn download_status_url(&self, job_id: &str) -> String { + format!( + "{}/api/v1/models/download/status/{}", + self.base_url.trim_end_matches('/'), + job_id + ) + } + /// Single-pass startup probe. /// Returns `(available, installed_models, count)`. pub fn detect_with_installed(&self) -> (bool, HashSet, usize) { @@ -1826,7 +1834,6 @@ struct LmStudioModel { #[derive(serde::Deserialize)] struct LmStudioDownloadResponse { #[serde(default)] - #[allow(dead_code)] job_id: Option, #[serde(default)] status: String, @@ -1835,6 +1842,10 @@ struct LmStudioDownloadResponse { total_size_bytes: Option, } +fn lmstudio_response_job_id(resp: &LmStudioDownloadResponse) -> Option<&str> { + resp.job_id.as_deref().filter(|job_id| !job_id.is_empty()) +} + #[derive(serde::Deserialize)] struct LmStudioDownloadStatus { #[serde(default)] @@ -1847,6 +1858,152 @@ struct LmStudioDownloadStatus { total_size_bytes: Option, } +#[derive(Debug, PartialEq, Eq)] +enum LmStudioDownloadTerminalStatus { + Done, + Failed, +} + +fn lmstudio_download_status_percent(st: &LmStudioDownloadStatus) -> Option { + st.progress + .map(|p| p * 100.0) + .or_else(|| match (st.downloaded_bytes, st.total_size_bytes) { + (Some(dl), Some(total)) if total > 0 => Some(dl as f64 / total as f64 * 100.0), + _ => None, + }) +} + +fn lmstudio_download_terminal_status(status: &str) -> Option { + match status { + "completed" | "already_downloaded" => Some(LmStudioDownloadTerminalStatus::Done), + "failed" => Some(LmStudioDownloadTerminalStatus::Failed), + _ => None, + } +} + +#[derive(Debug, PartialEq, Eq)] +enum LmStudioStatusPollResult { + Finished, + Fallback, +} + +fn poll_lmstudio_download_status( + status_url: &str, + api_key: Option<&str>, + tx: &std::sync::mpsc::Sender, + poll_interval: std::time::Duration, + max_polls: usize, +) -> LmStudioStatusPollResult { + let _ = tx.send(PullEvent::Progress { + status: "Downloading via LM Studio (tracking status)...".to_string(), + percent: None, + }); + + for _ in 0..max_polls { + std::thread::sleep(poll_interval); + + let mut req = ureq::get(status_url) + .config() + .timeout_global(Some(std::time::Duration::from_secs(5))) + .build(); + if let Some(key) = api_key { + req = req.header("Authorization", &format!("Bearer {}", key)); + } + let Ok(resp) = req.call() else { + return LmStudioStatusPollResult::Fallback; + }; + + let Ok(st) = resp.into_body().read_json::() else { + return LmStudioStatusPollResult::Fallback; + }; + + match lmstudio_download_terminal_status(&st.status) { + Some(LmStudioDownloadTerminalStatus::Done) => { + let _ = tx.send(PullEvent::Progress { + status: "Download complete".to_string(), + percent: Some(100.0), + }); + let _ = tx.send(PullEvent::Done); + return LmStudioStatusPollResult::Finished; + } + Some(LmStudioDownloadTerminalStatus::Failed) => { + let _ = tx.send(PullEvent::Error("LM Studio download failed".to_string())); + return LmStudioStatusPollResult::Finished; + } + None => { + let _ = tx.send(PullEvent::Progress { + status: "Downloading via LM Studio...".to_string(), + percent: lmstudio_download_status_percent(&st), + }); + } + } + } + + let _ = tx.send(PullEvent::Error("LM Studio download timed out".to_string())); + LmStudioStatusPollResult::Finished +} + +fn poll_lmstudio_installed_models( + models_url: &str, + api_key: Option<&str>, + model_tag: &str, + tx: &std::sync::mpsc::Sender, + poll_interval: std::time::Duration, + max_polls: usize, +) { + let candidates = hf_name_to_lmstudio_candidates(model_tag); + + let _ = tx.send(PullEvent::Progress { + status: "Downloading via LM Studio (tracking)...".to_string(), + percent: None, + }); + + for poll_num in 0..max_polls { + std::thread::sleep(poll_interval); + + let mut req = ureq::get(models_url) + .config() + .timeout_global(Some(std::time::Duration::from_secs(5))) + .build(); + if let Some(key) = api_key { + req = req.header("Authorization", &format!("Bearer {}", key)); + } + let Ok(resp) = req.call() else { + continue; + }; + + let Ok(list) = resp.into_body().read_json::() else { + continue; + }; + + let installed: HashSet = + list.data.into_iter().map(|m| m.id.to_lowercase()).collect(); + + for candidate in &candidates { + if installed.contains(candidate.as_str()) { + let _ = tx.send(PullEvent::Progress { + status: "Download complete".to_string(), + percent: Some(100.0), + }); + let _ = tx.send(PullEvent::Done); + return; + } + } + + // Send periodic progress so the UI knows we're still tracking the + // background download. + if poll_num % 10 == 9 { + let elapsed_secs = (poll_num + 1) as u64 * poll_interval.as_secs(); + let _ = tx.send(PullEvent::Progress { + status: format!("Downloading via LM Studio ({}s elapsed)...", elapsed_secs), + percent: None, + }); + } + } + + let _ = tx.send(PullEvent::Error("LM Studio download timed out".to_string())); +} + impl ModelProvider for LmStudioProvider { fn name(&self) -> &str { "LM Studio" @@ -1871,6 +2028,10 @@ impl ModelProvider for LmStudioProvider { fn start_pull(&self, model_tag: &str) -> Result { let download_url = self.download_url(); let models_url = self.models_url(); + let status_url_builder = LmStudioProvider { + base_url: self.base_url.clone(), + api_key: None, + }; let api_key = self.api_key.clone(); let tag = match lmstudio_pull_tag(model_tag) { Some(t) => t, @@ -1894,8 +2055,8 @@ impl ModelProvider for LmStudioProvider { // LM Studio may stream download progress as newline-delimited JSON // from the POST response, or it may acknowledge the request and // close the stream while the download proceeds in the background. - // In the latter case we poll the installed models list to detect - // eventual completion. + // In the latter case we poll the per-job status endpoint when a + // job id is available, falling back to the installed models list. let mut req = ureq::post(&download_url) .config() .timeout_global(Some(std::time::Duration::from_secs(3600))) @@ -1911,6 +2072,7 @@ impl ModelProvider for LmStudioProvider { use std::io::BufRead; let mut saw_completion = false; + let mut job_id: Option = None; for line in reader.lines() { let Ok(line) = line else { break }; if line.is_empty() { @@ -1920,6 +2082,13 @@ impl ModelProvider for LmStudioProvider { // Handle SSE "data: {json}" or plain JSON lines let json_str = line.strip_prefix("data: ").unwrap_or(&line); + if let Ok(dl_resp) = + serde_json::from_str::(json_str) + && let Some(id) = lmstudio_response_job_id(&dl_resp) + { + job_id = Some(id.to_string()); + } + // Try single status object, then first element of an array let status_opt: Option = serde_json::from_str(json_str).ok().or_else(|| { @@ -1928,7 +2097,7 @@ impl ModelProvider for LmStudioProvider { .and_then(|v| v.into_iter().next()) }); - // Also try the initial response format (has job_id) + // Also try the initial response format. if status_opt.is_none() { if let Ok(dl_resp) = serde_json::from_str::(json_str) @@ -1962,99 +2131,62 @@ impl ModelProvider for LmStudioProvider { let st = status_opt.unwrap(); - let percent = st.progress.map(|p| p * 100.0).or_else(|| { - match (st.downloaded_bytes, st.total_size_bytes) { - (Some(dl), Some(total)) if total > 0 => { - Some(dl as f64 / total as f64 * 100.0) - } - _ => None, + match lmstudio_download_terminal_status(&st.status) { + Some(LmStudioDownloadTerminalStatus::Done) => { + let _ = tx.send(PullEvent::Progress { + status: "Download complete".to_string(), + percent: Some(100.0), + }); + let _ = tx.send(PullEvent::Done); + saw_completion = true; + break; } - }); - - if st.status == "completed" || st.status == "already_downloaded" { - let _ = tx.send(PullEvent::Progress { - status: "Download complete".to_string(), - percent: Some(100.0), - }); - let _ = tx.send(PullEvent::Done); - saw_completion = true; - break; - } - - if st.status == "failed" { - let _ = - tx.send(PullEvent::Error("LM Studio download failed".to_string())); - return; + Some(LmStudioDownloadTerminalStatus::Failed) => { + let _ = tx.send(PullEvent::Error( + "LM Studio download failed".to_string(), + )); + return; + } + None => {} } let _ = tx.send(PullEvent::Progress { status: "Downloading via LM Studio...".to_string(), - percent, + percent: lmstudio_download_status_percent(&st), }); } if !saw_completion { // Stream ended without a completion event. The POST // succeeded so LM Studio accepted the request — it - // is likely downloading in the background. Poll the - // installed models list to detect completion. - let candidates = hf_name_to_lmstudio_candidates(&model_tag_owned); + // is likely downloading in the background. Poll + // per-job status when possible; otherwise fall back + // to the installed models list to detect completion. let poll_interval = std::time::Duration::from_secs(3); let max_polls = 600; // 30 minutes max - let _ = tx.send(PullEvent::Progress { - status: "Downloading via LM Studio (tracking)...".to_string(), - percent: None, - }); - - for poll_num in 0..max_polls { - std::thread::sleep(poll_interval); - - let mut req = ureq::get(&models_url) - .config() - .timeout_global(Some(std::time::Duration::from_secs(5))) - .build(); - if let Some(ref key) = api_key { - req = req.header("Authorization", &format!("Bearer {}", key)); - } - let Ok(resp) = req.call() else { - continue; - }; - - let Ok(list) = resp.into_body().read_json::() else { - continue; - }; - - let installed: HashSet = - list.data.into_iter().map(|m| m.id.to_lowercase()).collect(); - - for candidate in &candidates { - if installed.contains(candidate.as_str()) { - let _ = tx.send(PullEvent::Progress { - status: "Download complete".to_string(), - percent: Some(100.0), - }); - let _ = tx.send(PullEvent::Done); - return; - } - } - - // Send periodic progress so the UI knows we're - // still tracking the background download. - if poll_num % 10 == 9 { - let elapsed_secs = (poll_num + 1) as u64 * poll_interval.as_secs(); - let _ = tx.send(PullEvent::Progress { - status: format!( - "Downloading via LM Studio ({}s elapsed)...", - elapsed_secs - ), - percent: None, - }); + if let Some(ref job_id) = job_id { + let status_url = status_url_builder.download_status_url(job_id); + if poll_lmstudio_download_status( + &status_url, + api_key.as_deref(), + &tx, + poll_interval, + max_polls, + ) == LmStudioStatusPollResult::Finished + { + return; } } - let _ = - tx.send(PullEvent::Error("LM Studio download timed out".to_string())); + poll_lmstudio_installed_models( + &models_url, + api_key.as_deref(), + &model_tag_owned, + &tx, + poll_interval, + max_polls, + ); } } Err(e) => { @@ -3534,6 +3666,78 @@ mod tests { assert!(filter_key(Some("")).is_none()); } + #[test] + fn test_lmstudio_download_status_url_formats_base_urls() { + let default_provider = LmStudioProvider { + base_url: "http://127.0.0.1:1234".to_string(), + api_key: None, + }; + assert_eq!( + default_provider.download_status_url("abc123"), + "http://127.0.0.1:1234/api/v1/models/download/status/abc123" + ); + + let custom_provider = LmStudioProvider { + base_url: "http://lmstudio.example.test:4321/".to_string(), + api_key: None, + }; + assert_eq!( + custom_provider.download_status_url("job-42"), + "http://lmstudio.example.test:4321/api/v1/models/download/status/job-42" + ); + } + + #[test] + fn test_lmstudio_download_response_parses_optional_job_id() { + let with_job: LmStudioDownloadResponse = + serde_json::from_str(r#"{"job_id":"abc123","status":"download_started"}"#).unwrap(); + assert_eq!(lmstudio_response_job_id(&with_job), Some("abc123")); + + let without_job: LmStudioDownloadResponse = + serde_json::from_str(r#"{"status":"download_started"}"#).unwrap(); + assert_eq!(lmstudio_response_job_id(&without_job), None); + } + + #[test] + fn test_lmstudio_download_status_percent_and_terminal_mapping() { + let progress: LmStudioDownloadStatus = + serde_json::from_str(r#"{"status":"downloading","progress":0.42}"#).unwrap(); + assert_eq!(lmstudio_download_status_percent(&progress), Some(42.0)); + assert_eq!(lmstudio_download_terminal_status(&progress.status), None); + + assert_eq!( + lmstudio_download_terminal_status("completed"), + Some(LmStudioDownloadTerminalStatus::Done) + ); + assert_eq!( + lmstudio_download_terminal_status("already_downloaded"), + Some(LmStudioDownloadTerminalStatus::Done) + ); + assert_eq!( + lmstudio_download_terminal_status("failed"), + Some(LmStudioDownloadTerminalStatus::Failed) + ); + } + + #[test] + fn test_lmstudio_status_poll_error_falls_back_without_error() { + let (tx, rx) = std::sync::mpsc::channel(); + let result = poll_lmstudio_download_status( + "http://127.0.0.1:1/api/v1/models/download/status/abc123", + None, + &tx, + std::time::Duration::from_millis(0), + 1, + ); + + assert_eq!(result, LmStudioStatusPollResult::Fallback); + assert!( + !rx.try_iter() + .any(|event| matches!(event, PullEvent::Error(_))), + "status polling errors must fall back instead of emitting an error" + ); + } + #[test] fn test_is_model_installed_mlx_with_owner_prefixed_repo_id() { let mut installed = HashSet::new(); diff --git a/llmfit-desktop/tauri.conf.json b/llmfit-desktop/tauri.conf.json index f9fd7243..3bf9c45f 100644 --- a/llmfit-desktop/tauri.conf.json +++ b/llmfit-desktop/tauri.conf.json @@ -7,7 +7,9 @@ }, "bundle": { "windows": { - "webviewInstallMode": "downloadBootstrapper" + "webviewInstallMode": { + "type": "downloadBootstrapper" + } } }, "app": { diff --git a/llmfit-tui/src/main.rs b/llmfit-tui/src/main.rs index 2500666a..bd78c897 100644 --- a/llmfit-tui/src/main.rs +++ b/llmfit-tui/src/main.rs @@ -2314,12 +2314,12 @@ fn share_pending_cli(opts: &share::ShareOptions, token: Option) { ); } match (&outcome.pr_url, outcome.reused_existing_pr) { - (Some(url), true) => println!( + (Some(url), true) => eprintln!( "\n Added {} submission(s) to your open pull request: {url}", outcome.uploaded ), - (Some(url), false) => println!("\n Pull request opened: {url}"), - (None, _) => println!( + (Some(url), false) => eprintln!("\n Pull request opened: {url}"), + (None, _) => eprintln!( "\n All stored results were already contributed upstream — nothing new to submit." ), } diff --git a/llmfit-tui/src/serve_api.rs b/llmfit-tui/src/serve_api.rs index a02bd0ad..0a6f3d80 100644 --- a/llmfit-tui/src/serve_api.rs +++ b/llmfit-tui/src/serve_api.rs @@ -184,10 +184,29 @@ pub fn run_serve( if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - // A stale file from a previous instance blocks bind + // A stale socket from a previous instance blocks bind // (the path can outlive the process, e.g. a pod // volume across container restarts). - let _ = std::fs::remove_file(path); + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + use std::os::unix::fs::FileTypeExt; + if metadata.file_type().is_socket() { + let _ = std::fs::remove_file(path); + } else { + return Err(ApiError::bad_request(format!( + "{} exists and is not a unix socket", + path.display() + ))); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(ApiError::internal(format!( + "failed to inspect {}: {e}", + path.display() + ))); + } + } let listener = tokio::net::UnixListener::bind(path).map_err(|e| { ApiError::internal(format!("bind failed on {}: {e}", path.display())) })?; @@ -195,6 +214,10 @@ pub fn run_serve( use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)); + let app = app.layer(axum::Extension(ConnectInfo(SocketAddr::from(( + [127, 0, 0, 1], + 0, + ))))); axum::serve(listener, app.into_make_service()) .with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await;