diff --git a/package.json b/package.json index 055a1e6..9a0442f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "talkis", "private": true, - "version": "0.2.0", + "version": "0.3.0", "license": "AGPL-3.0-or-later", "type": "module", "packageManager": "bun@1.2.13", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 99eef7f..de11985 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4969,7 +4969,7 @@ dependencies = [ [[package]] name = "talkis" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64 0.22.1", "block2 0.6.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9eb8970..8fe0806 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "talkis" -version = "0.2.0" +version = "0.3.0" description = "Talkis - Voice to Text Desktop Widget" authors = ["trixter"] license = "AGPL-3.0-or-later" diff --git a/src-tauri/binaries/talkis-llm-aarch64-apple-darwin b/src-tauri/binaries/talkis-llm-aarch64-apple-darwin index eb17e2a..8d8efae 100644 Binary files a/src-tauri/binaries/talkis-llm-aarch64-apple-darwin and b/src-tauri/binaries/talkis-llm-aarch64-apple-darwin differ diff --git a/src-tauri/src/ai.rs b/src-tauri/src/ai.rs index fcf024a..64f2ad4 100644 --- a/src-tauri/src/ai.rs +++ b/src-tauri/src/ai.rs @@ -2164,7 +2164,60 @@ pub async fn install_stt_model( request = request.bearer_auth(whisper_key); } - let response = request.send().await.map_err(|err| { + // The Qwen/Parakeet model download runs inside the managed Python runtime via + // this one blocking POST, so the streaming cancel flag (which Whisper/LLM poll + // per chunk) has nothing to watch here — that's why "Отмена" did nothing for + // Qwen. Race the request against the cancel flag; on abort, kill the managed + // runtime (stops the in-flight huggingface download) and reset to "not downloaded". + let cancellable_engine_download = + managed_runtime_kind.is_some_and(|kind| kind != local_stt::LocalRuntimeKind::Whisper); + if cancellable_engine_download { + crate::download_cancel::clear(requested_model); + } + let send_result = if cancellable_engine_download { + let send = request.send(); + tokio::pin!(send); + loop { + tokio::select! { + result = &mut send => break result, + _ = tokio::time::sleep(Duration::from_millis(400)) => { + if crate::download_cancel::is_cancel_requested(requested_model) { + if let Some(stop) = &local_progress_stop { + stop.store(true, Ordering::Relaxed); + } + if let (Some(kind), Some(port)) = + (managed_runtime_kind, port_from_url(&models_url)) + { + if let Err(err) = local_stt::stop_managed_runtime(kind, port) { + logger::log_error("STT_INSTALL", &err); + } + } + crate::download_cancel::clear(requested_model); + local_stt::emit_model_download_progress_message( + &app, + requested_model, + "cancelled", + 0, + None, + crate::download_cancel::CANCELLED_MESSAGE, + ); + logger::log_info( + "STT_INSTALL", + &format!( + "User cancelled managed STT model download: {}", + requested_model + ), + ); + return Err(crate::download_cancel::CANCELLED_MESSAGE.to_string()); + } + } + } + } + } else { + request.send().await + }; + + let response = send_result.map_err(|err| { if let Some(stop) = &local_progress_stop { stop.store(true, Ordering::Relaxed); } @@ -2186,6 +2239,9 @@ pub async fn install_stt_model( if let Some(stop) = &local_progress_stop { stop.store(true, Ordering::Relaxed); } + if cancellable_engine_download { + crate::download_cancel::clear(requested_model); + } let status = response.status(); if !status.is_success() { diff --git a/src-tauri/src/commands/widget.rs b/src-tauri/src/commands/widget.rs index e4d8109..e00bdf0 100644 --- a/src-tauri/src/commands/widget.rs +++ b/src-tauri/src/commands/widget.rs @@ -66,23 +66,29 @@ pub fn ensure_widget_notice_window(app: &AppHandle) -> Result Result<(), String> { let widget_position = widget_window.outer_position().map_err(|e| e.to_string())?; let widget_size = widget_window.outer_size().map_err(|e| e.to_string())?; let scale_factor = widget_window.scale_factor().map_err(|e| e.to_string())?; let notice_width = NOTICE_WIDTH * scale_factor; - let notice_height = NOTICE_HEIGHT * scale_factor; + let notice_height = height * scale_factor; let notice_gap = NOTICE_GAP * scale_factor; let x = widget_position.x as f64 + (widget_size.width as f64 - notice_width) / 2.0; + // Anchor the bubble's bottom edge just above the widget, so it grows upward. let y = widget_position.y as f64 - notice_gap - notice_height; notice_window .set_size(tauri::Size::Logical(tauri::LogicalSize { width: NOTICE_WIDTH, - height: NOTICE_HEIGHT, + height, })) .map_err(|e| e.to_string())?; @@ -271,7 +277,8 @@ pub async fn show_widget_notice( .ok_or_else(|| "Widget window not found".to_string())?; let notice_window = ensure_widget_notice_window(&app)?; - position_widget_notice_window(&widget_window, ¬ice_window)?; + // Always (re)show collapsed; the overlay expands on click. + position_widget_notice_window(&widget_window, ¬ice_window, NOTICE_HEIGHT)?; app.emit_to( NOTICE_WINDOW_LABEL, @@ -294,6 +301,20 @@ pub async fn hide_widget_notice(app: AppHandle) -> Result<(), String> { Ok(()) } +/// Resize the notice bubble to fit its full text on click (or back to the +/// collapsed height). Re-anchors above the widget so it grows upward. +#[tauri::command] +pub async fn expand_widget_notice(app: AppHandle, height: f64) -> Result<(), String> { + let widget_window = app + .get_webview_window("widget") + .ok_or_else(|| "Widget window not found".to_string())?; + let notice_window = app + .get_webview_window(NOTICE_WINDOW_LABEL) + .ok_or_else(|| "Notice window not found".to_string())?; + let clamped = height.clamp(NOTICE_HEIGHT, NOTICE_MAX_HEIGHT); + position_widget_notice_window(&widget_window, ¬ice_window, clamped) +} + #[tauri::command] pub async fn widget_resize( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e3e7cb..3a4aad3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -146,6 +146,7 @@ pub fn run() { widget::activate_widget_for_hotkey, widget::show_widget_notice, widget::hide_widget_notice, + widget::expand_widget_notice, paste::remember_paste_target_window, paste::paste_text, ai::transcribe_and_clean, diff --git a/src-tauri/src/llm_runtime.rs b/src-tauri/src/llm_runtime.rs index 12cc262..0ea2e4f 100644 --- a/src-tauri/src/llm_runtime.rs +++ b/src-tauri/src/llm_runtime.rs @@ -22,6 +22,7 @@ struct LlmModelInfo { file_name: &'static str, url: &'static str, label: &'static str, + description: &'static str, size_label: &'static str, min_ram_gb: u32, } @@ -34,6 +35,7 @@ static LLM_CATALOG: &[LlmModelInfo] = &[ file_name: "Qwen2.5-3B-Instruct-Q4_K_M.gguf", url: "https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf", label: "Qwen2.5 3B Instruct", + description: "Компактная текстовая модель для саммари на слабых машинах: быстрая, с хорошей поддержкой русского.", size_label: "2.0 ГБ", min_ram_gb: 8, }, @@ -42,6 +44,7 @@ static LLM_CATALOG: &[LlmModelInfo] = &[ file_name: "Qwen2.5-7B-Instruct-Q4_K_M.gguf", url: "https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf", label: "Qwen2.5 7B Instruct", + description: "Более качественная текстовая модель для детальных саммари: точнее, но требовательнее к памяти.", size_label: "4.7 ГБ", min_ram_gb: 16, }, @@ -374,6 +377,7 @@ pub fn stop_runtime() { pub struct LocalLlmModel { id: String, label: String, + description: String, file_name: String, size_label: String, min_ram_gb: u32, @@ -387,6 +391,7 @@ pub fn list_local_llm_models(app: AppHandle) -> Vec { .map(|model| LocalLlmModel { id: model.id.to_string(), label: model.label.to_string(), + description: model.description.to_string(), file_name: model.file_name.to_string(), size_label: model.size_label.to_string(), min_ram_gb: model.min_ram_gb, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 76a45e4..58aa515 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Talkis", "mainBinaryName": "Talkis", - "version": "0.2.0", + "version": "0.3.0", "identifier": "com.trixter.talkis", "build": { "beforeDevCommand": "bun run prepare:sidecars && bun run dev", diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx new file mode 100644 index 0000000..5c0ac35 --- /dev/null +++ b/src/components/Dropdown.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from "react"; +import { Check, ChevronDown } from "lucide-react"; + +export interface DropdownOption { + value: string; + label: string; +} + +/** + * Custom select styled like the dropdowns in Settings (a `.btn` trigger + a + * popover list with a check on the active item) — used instead of a native + *