From 97d2450fa75bd14444e679113c0615f480096dd9 Mon Sep 17 00:00:00 2001 From: BiggerRain <15911122312@163.COM> Date: Fri, 5 Dec 2025 15:32:57 +0800 Subject: [PATCH] feat: selection settings add & delete (#992) * feat: add selection window page * fix: chat input * feat: add selection page * chore: add * chore: test * feat: add * feat: add store * feat: add selection settings * chore: remove unused code * docs: add release note * docs: add release note * chore: format code * chore: format code * fix: copy error * disable hashbrown default feature * Enable unstable feature allocator_api To make coco-app compile in CI: ``` --> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.15.5/src/raw/mod.rs:3856:12 | 3856 | impl RawIntoIter { | ^^^^^^^^^ | = note: see issue #32838 for more information = help: add `#![feature(allocator_api)]` to the crate attributes to enable = note: this compiler was built on 2025-06-25; consider upgrading it if it is out of date ``` I don't know why it does not compile, feature `allocator-api2` is enabled for `hashbrown 0.15.5`, so technically [1] it should not use the allocator APIs from the std. According to [2], enabling the `nightly` feature of `allocator-api2` may cause this issue as well, but it is not enabled in our case either. Anyway, enabling `#![feature(allocator_api)]` should make it work. [1]: https://github.com/rust-lang/hashbrown/blob/b751eef8e99ccf3652046ef4a9e1ec47c1bfb78d/src/raw/alloc.rs#L26-L47 [2]: https://github.com/rust-lang/hashbrown/issues/564 * put it in main.rs * format main.rs * Enable default-features for hashbrown 0.15.5 * format main.rs * enable feature allocator-api2 * feat: add selection set config * fix: selection setting * fix: ci error * fix: ci error * fix: ci error * fix: ci error * merge: merge main * fix: rust code warn * fix: rust code error * fix: rust code error * fix: selection settings * style: selection styles * style: selection styles * feat: selection settings add & delete * feat: selection settings add & delete * feat: selection settings add & delete * style: selection styles * chore: add @tauri-store/zustand plugin * refactor: the selection store using @tauri-store/zustand * fix: data error * fix: data error * chore: remove config * chore: selection * chore: selection * chore: width * chore: ignore selection in the app itself * style: selection styles * style: remove * docs: add notes * chore: add permission check * chore: selection * chore: style & store --------- Co-authored-by: Steve Lau --- docs/content.en/docs/release-notes/_index.md | 1 + package.json | 1 + pnpm-lock.yaml | 16 + src-tauri/src/lib.rs | 1 - src-tauri/src/selection_monitor.rs | 362 ++++++++++++++++-- src/components/Assistant/ServerList.tsx | 25 +- src/components/SearchChat/index.tsx | 65 ++-- src/components/Selection/HeaderToolbar.tsx | 58 +++ src/components/Selection/Toolbar.tsx | 116 ++++++ .../components/Selection/AddChatButton.tsx | 37 ++ .../components/Selection/AddChatDialog.tsx | 224 +++++++++++ .../components/Selection/ButtonsList.tsx | 173 +++++---- .../Advanced/components/Selection/config.ts | 50 +++ .../Advanced/components/Selection/index.tsx | 89 ++--- src/components/Settings/Advanced/index.tsx | 3 +- src/hooks/useSelectionEnabled.ts | 26 ++ src/hooks/useTray.ts | 62 ++- src/locales/en/translation.json | 26 +- src/locales/zh/translation.json | 26 +- src/pages/selection/index.tsx | 233 ++++------- src/routes/index.tsx | 26 +- src/routes/outlet.tsx | 25 +- src/stores/searchStore.ts | 5 + src/stores/selectionStore.ts | 22 +- src/types/platform.ts | 7 +- src/utils/webAdapter.ts | 2 +- vite.config.ts | 16 +- 27 files changed, 1260 insertions(+), 437 deletions(-) create mode 100644 src/components/Selection/HeaderToolbar.tsx create mode 100644 src/components/Selection/Toolbar.tsx create mode 100644 src/components/Settings/Advanced/components/Selection/AddChatButton.tsx create mode 100644 src/components/Settings/Advanced/components/Selection/AddChatDialog.tsx create mode 100644 src/components/Settings/Advanced/components/Selection/config.ts create mode 100644 src/hooks/useSelectionEnabled.ts diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index 8d64aac1..a4f793f0 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -15,6 +15,7 @@ Information about release notes of Coco App is provided here. - feat: add selection toolbar window for mac #980 - feat: add a heartbeat worker to check Coco server availability #988 +- feat: selection settings add & delete #992 ### 🐛 Bug fix diff --git a/package.json b/package.json index aa6d4231..03229a98 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@headlessui/react": "^2.2.2", + "@infinilabs/custom-icons": "0.0.4", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@tauri-apps/api": "^2.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea55e69b..52c8c11e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@headlessui/react': specifier: ^2.2.2 version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@infinilabs/custom-icons': + specifier: 0.0.4 + version: 0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-separator': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -700,6 +703,13 @@ packages: '@iconify/utils@3.0.2': resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==} + '@infinilabs/custom-icons@0.0.4': + resolution: {integrity: sha512-Oz5i06qW5a3wMfbJIZ5LESyO4V9p8njdoKnb/2Mf98ttRjTIedTXqrIWxLz8Tz84OTLi7mKswEQ71lAGeSqlug==} + peerDependencies: + lucide-react: '>=0.454.0' + react: '>=18' + react-dom: '>=18' + '@inquirer/checkbox@4.1.5': resolution: {integrity: sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==} engines: {node: '>=18'} @@ -4278,6 +4288,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@infinilabs/custom-icons@0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + lucide-react: 0.461.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@inquirer/checkbox@4.1.5(@types/node@22.18.12)': dependencies: '@inquirer/core': 10.1.10(@types/node@22.18.12) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b724410..d26f8604 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -65,7 +65,6 @@ async fn change_window_height(handle: AppHandle, height: u32) { let outer_size = window.outer_size().unwrap(); let window_width = outer_size.width as i32; - let window_height = outer_size.height as i32; let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2; diff --git a/src-tauri/src/selection_monitor.rs b/src-tauri/src/selection_monitor.rs index 36f807f5..bfd36307 100644 --- a/src-tauri/src/selection_monitor.rs +++ b/src-tauri/src/selection_monitor.rs @@ -10,16 +10,42 @@ struct SelectionEventPayload { y: i32, } +use once_cell::sync::Lazy; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; /// Global toggle: selection monitoring enabled by default. static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true); +/// Ensure we only start the monitor thread once. Allows delayed start after +/// Accessibility permission is granted post-launch. +static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false); + +/// Guard to avoid spawning multiple permission watcher threads. +#[cfg(target_os = "macos")] +static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false); + +/// Session flags for controlling macOS Accessibility prompts. +#[cfg(target_os = "macos")] +static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "macos")] +static LAST_ACCESSIBILITY_PROMPT: Lazy>> = + Lazy::new(|| Mutex::new(None)); + #[derive(serde::Serialize, Clone)] struct SelectionEnabledPayload { enabled: bool, } +#[derive(serde::Serialize, Clone)] +struct SelectionPermissionInfo { + bundle_id: String, + exe_path: String, + in_applications: bool, + is_dmg: bool, + is_dev_guess: bool, +} + /// Read the current selection monitoring state. pub fn is_selection_enabled() -> bool { SELECTION_ENABLED.load(Ordering::Relaxed) @@ -28,6 +54,7 @@ pub fn is_selection_enabled() -> bool { /// Update the monitoring state and broadcast to the frontend. fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) { SELECTION_ENABLED.store(enabled, Ordering::Relaxed); + log::info!(target: "coco_lib::selection_monitor", "selection monitoring toggled: enabled={}", enabled); let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled }); } @@ -35,6 +62,23 @@ fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) #[tauri::command] pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) { set_selection_enabled_internal(&app_handle, enabled); + + // When enabling selection monitoring on macOS, ensure Accessibility permission. + // If not granted, trigger system prompt and deep-link to the right settings pane, + // and notify frontend to guide the user. + #[cfg(target_os = "macos")] + { + if enabled { + let trusted = ensure_accessibility_permission(&app_handle); + // If permission is now trusted and the monitor hasn't started yet, + // start it immediately to avoid requiring an app restart. + if trusted && !MONITOR_THREAD_STARTED.load(Ordering::Relaxed) { + log::info!(target: "coco_lib::selection_monitor", "set_selection_enabled: permission trusted; starting monitor thread"); + start_selection_monitor(app_handle.clone()); + return; + } + } + } } /// Tauri command: get selection monitoring state. @@ -46,7 +90,7 @@ pub fn get_selection_enabled() -> bool { #[cfg(target_os = "macos")] pub fn start_selection_monitor(app_handle: tauri::AppHandle) { // Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread. - log::info!("start_selection_monitor: 入口函数启动"); + log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: entrypoint"); use std::time::Duration; use tauri::Emitter; @@ -57,21 +101,75 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { // If not granted, prompt the user once; if still not granted, skip starting the watcher. #[cfg(target_os = "macos")] { - let trusted_before = macos_accessibility_client::accessibility::application_is_trusted(); - if !trusted_before { - let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt(); + // If already started, don't start twice. + if MONITOR_THREAD_STARTED.load(Ordering::Relaxed) { + log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: already started; skipping"); + return; } - let trusted_after = macos_accessibility_client::accessibility::application_is_trusted(); - if !trusted_after { + if !ensure_accessibility_permission(&app_handle) { + log::warn!(target: "coco_lib::selection_monitor", "start_selection_monitor: accessibility not granted; deferring watcher start"); + + // Spawn a short-lived permission watcher to auto-start once the user grants. + if !PERMISSION_WATCHER_STARTED.swap(true, Ordering::Relaxed) { + let app_handle_clone = app_handle.clone(); + std::thread::Builder::new() + .name("selection-permission-watcher".into()) + .spawn(move || { + use std::time::Duration; + // Persistent polling with gentle backoff: checks every 2s for ~1 minute, + // then every 10s thereafter, until either trusted or selection disabled. + let mut checks: u32 = 0; + loop { + // If user disabled selection in between, stop early. + if !is_selection_enabled() { + log::debug!(target: "coco_lib::selection_monitor", "permission watcher: selection disabled; stop polling"); + break; + } + + // Fast trust check without prompt. + if macos_accessibility_client::accessibility::application_is_trusted() { + log::info!(target: "coco_lib::selection_monitor", "permission watcher: accessibility granted; starting monitor"); + // Reset watcher flag before starting monitor to allow future retries if needed. + PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed); + start_selection_monitor(app_handle_clone.clone()); + return; + } + + // Backoff strategy. + checks += 1; + let sleep_secs = if checks <= 30 { 2 } else { 10 }; // ~1 min fast, then slower + if checks % 30 == 0 { + log::debug!(target: "coco_lib::selection_monitor", "permission watcher: still not granted; continuing to poll (checks={})", checks); + } + std::thread::sleep(Duration::from_secs(sleep_secs)); + } + + // Done polling without success; allow future attempts. + PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed); + log::debug!(target: "coco_lib::selection_monitor", "permission watcher: stopped (no grant)"); + }) + .unwrap_or_else(|e| { + PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed); + // Fail fast here: spawning a watcher thread is critical for deferred start. + panic!( + "permission watcher: failed to spawn: {}", + e + ); + }); + } else { + log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: permission watcher already running; skip spawning"); + } return; } } #[cfg(not(target_os = "macos"))] { - log::info!("start_selection_monitor: 非 macOS 平台,无划词监控"); + log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: non-macos platform, no selection monitor"); } // Background thread: drives popup show/hide based on mouse and AX selection state. + MONITOR_THREAD_STARTED.store(true, Ordering::Relaxed); + log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: starting watcher thread"); std::thread::spawn(move || { #[cfg(target_os = "macos")] use objc2_app_kit::NSWorkspace; @@ -108,13 +206,13 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { return (x_top_left, y_flipped); } - let mut chosen = CGMainDisplayID(); // default fallback - log::info!( - "current_mouse: pt=({:.1},{:.1}) → display={}", - pt.x as f64, - pt.y as f64, - chosen - ); + let mut _chosen = CGMainDisplayID(); // default fallback + // log::info!( + // "current_mouse: pt=({:.1},{:.1}) → display={}", + // pt.x as f64, + // pt.y as f64, + // chosen + // ); let mut min_x_pt = f64::INFINITY; let mut max_top_pt = f64::NEG_INFINITY; @@ -136,15 +234,15 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { let in_x = pt.x >= b.origin.x && pt.x <= b.origin.x + b.size.width; let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height; if in_x && in_y { - chosen = did; - log::info!( - "current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})", - pt.x as f64, - pt.y as f64, - chosen, - b.origin.x, - b.origin.y - ); + _chosen = did; + // log::info!( + // "current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})", + // pt.x as f64, + // pt.y as f64, + // chosen, + // b.origin.x, + // b.origin.y + // ); } } @@ -199,13 +297,21 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { } // Skip empty-selection hide checks while interacting with the Coco popup. - let front_is_me = is_frontmost_app_me(); + // Robust check: treat as "self" if either the frontmost app or the + // system-wide focused element belongs to this process. + let front_is_me = is_frontmost_app_me() || is_focused_element_me(); + + // When Coco is frontmost, disable detection but do NOT hide the popup. + // Users may be clicking the popup; we must keep it visible. + if front_is_me { + // Reset counters to avoid stale state on re-entry. + stable_count = 0; + empty_count = 0; + continue; + } // Lightweight retries to smooth out transient AX focus instability. - let selected_text = if front_is_me { - // Do not read selection during popup interaction to avoid false empty. - None - } else { + let selected_text = { // Up to 2 retries, 35ms apart. read_selected_text_with_retries(2, 35) }; @@ -223,6 +329,14 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { // Update/show only when selection is stable to avoid flicker. if stable_count >= stable_threshold { if !popup_visible || text != last_text { + // Second guard: do not emit when Coco is frontmost + // or the system-wide focused element belongs to Coco. + // Keep popup as-is to allow user interaction. + if is_frontmost_app_me() || is_focused_element_me() { + stable_count = 0; + empty_count = 0; + continue; + } let (x, y) = current_mouse_point_global(); let payload = SelectionEventPayload { text: text.clone(), @@ -231,6 +345,9 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { }; let _ = app_handle.emit("selection-detected", payload); + // Log selection state change once per stable update to avoid flooding. + let snippet: String = stable_text.chars().take(120).collect(); + log::info!(target: "coco_lib::selection_monitor", "selection stable; showing popup (len={}, snippet=\"{}\")", stable_text.len(), snippet.replace('\n', "\\n")); last_text = text; popup_visible = true; } @@ -243,6 +360,7 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { empty_count += 1; if popup_visible && empty_count >= empty_threshold { let _ = app_handle.emit("selection-detected", ""); + log::info!(target: "coco_lib::selection_monitor", "selection empty; hiding popup"); popup_visible = false; last_text.clear(); stable_text.clear(); @@ -256,12 +374,192 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) { }); } +/// Ensure macOS Accessibility permission with double-checking and session throttling. +/// Returns true when trusted; otherwise triggers prompt/settings link and emits +/// `selection-permission-required` to the frontend, then returns false. +#[cfg(target_os = "macos")] +fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool { + use std::time::{Duration, Instant}; + + log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: begin"); + // First check — fast path. + let trusted = macos_accessibility_client::accessibility::application_is_trusted(); + if trusted { + SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed); + log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (fast path)"); + return true; + } + + // If we've seen trust earlier in this session, transient false may occur. + // Re-check after a short delay to avoid spurious prompts. + if SEEN_ACCESSIBILITY_TRUSTED_ONCE.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(150)); + if macos_accessibility_client::accessibility::application_is_trusted() { + log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (after transient recheck)"); + return true; + } + } + + // Throttle system prompt to at most once per 60s in this session. + let mut last = LAST_ACCESSIBILITY_PROMPT.lock().unwrap(); + let now = Instant::now(); + let allow_prompt = match *last { + Some(ts) => now.duration_since(ts) > Duration::from_secs(60), + None => true, + }; + + if allow_prompt { + // Try to trigger the system authorization prompt. + log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: triggering system prompt"); + let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt(); + *last = Some(now); + + // Small grace period then re-check. + std::thread::sleep(Duration::from_millis(150)); + if macos_accessibility_client::accessibility::application_is_trusted() { + SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed); + log::info!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: user granted accessibility during prompt"); + return true; + } + log::warn!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: still not trusted after prompt"); + } + + // Still not trusted — notify frontend and deep-link to settings. + let _ = app_handle.emit("selection-permission-required", true); + log::debug!(target: "coco_lib::selection_monitor", "selection-permission-required emitted"); + + // Provide richer context so frontend can give more explicit guidance. + let info = collect_selection_permission_info(); + log::info!(target: "coco_lib::selection_monitor", "selection-permission-info: bundle_id={}, exe_path={}, in_applications={}, is_dmg={}, is_dev_guess={}", + info.bundle_id, info.exe_path, info.in_applications, info.is_dmg, info.is_dev_guess); + let _ = app_handle.emit("selection-permission-info", info); + #[allow(unused_must_use)] + { + use std::process::Command; + Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") + .status(); + } + + false +} + +#[cfg(target_os = "macos")] +fn collect_selection_permission_info() -> SelectionPermissionInfo { + let exe_path = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| String::from("")); + let in_applications = exe_path.starts_with("/Applications/"); + let is_dmg = exe_path.starts_with("/Volumes/"); + let is_dev_guess = exe_path.contains("/target/debug/") + || exe_path.contains(".cargo") + || exe_path.contains("/node_modules/"); + + // Find the nearest *.app directory from the current executable path. + let bundle_id = get_bundle_id_dynamic(); + SelectionPermissionInfo { + bundle_id, + exe_path, + in_applications, + is_dmg, + is_dev_guess, + } +} + +#[cfg(target_os = "macos")] +fn get_bundle_id_dynamic() -> String { + use std::path::PathBuf; + use std::process::Command; + + // Find the nearest *.app directory from the current executable path. + let mut app_dir: Option = None; + if let Ok(mut p) = std::env::current_exe() { + for _ in 0..8 { + if let Some(name) = p.file_name().and_then(|n| n.to_str()) { + if name.ends_with(".app") { + app_dir = Some(p.clone()); + break; + } + } + if !p.pop() { + break; + } + } + } + + if let Some(app) = app_dir { + let info = app.join("Contents").join("Info.plist"); + if info.exists() { + // use `defaults read CFBundleIdentifier` to read Bundle ID + if let Ok(out) = Command::new("defaults") + .arg("read") + .arg(info.to_string_lossy().into_owned()) + .arg("CFBundleIdentifier") + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return s; + } + } + } + } + } + + // Fallback: use the default bundle ID when in dev mode or Info.plist is not found. + "rs.coco.app".to_string() +} + // macOS-wide accessibility entry point: allows reading system-level focused elements. #[cfg(target_os = "macos")] unsafe extern "C" { fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement; } +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn AXUIElementGetPid( + element: *mut objc2_application_services::AXUIElement, + pid: *mut i32, + ) -> objc2_application_services::AXError; +} + +#[cfg(target_os = "macos")] +fn is_focused_element_me() -> bool { + use objc2_application_services::{AXError, AXUIElement}; + use objc2_core_foundation::{CFRetained, CFString, CFType}; + use std::ptr::NonNull; + + let mut focused_ui_ptr: *const CFType = std::ptr::null(); + let focused_attr = CFString::from_static_str("AXFocusedUIElement"); + + let system_elem = unsafe { AXUIElementCreateSystemWide() }; + if system_elem.is_null() { + return false; + } + + let system_elem_retained: CFRetained = + unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) }; + let err = unsafe { + system_elem_retained + .copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap()) + }; + if err != AXError::Success || focused_ui_ptr.is_null() { + return false; + } + + let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::().cast_mut(); + let mut pid: i32 = -1; + let get_err = unsafe { AXUIElementGetPid(focused_ui_elem, &mut pid as *mut i32) }; + if get_err != AXError::Success { + return false; + } + + let my_pid = std::process::id() as i32; + pid == my_pid +} + /// Read the selected text of the frontmost application (without using the clipboard). /// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties. #[cfg(target_os = "macos")] @@ -357,10 +655,10 @@ fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option 0 { - log::info!( - "read_selected_text: 第{}次重试成功,获取到选中文本", - attempt - ); + // log::info!( + // "read_selected_text: 第{}次重试成功,获取到选中文本", + // attempt + // ); } return Some(text); } diff --git a/src/components/Assistant/ServerList.tsx b/src/components/Assistant/ServerList.tsx index d63f3fdc..9d10ca77 100644 --- a/src/components/Assistant/ServerList.tsx +++ b/src/components/Assistant/ServerList.tsx @@ -46,6 +46,12 @@ export function ServerList({ clearChat }: ServerListProps) { const [isRefreshing, setIsRefreshing] = useState(false); const [highlightId, setHighlightId] = useState(""); + const targetServerId = useSearchStore((state) => { + return state.targetServerId; + }); + const setTargetServerId = useSearchStore((state) => { + return state.setTargetServerId; + }); const askAiServerId = useSearchStore((state) => { return state.askAiServerId; }); @@ -102,17 +108,20 @@ export function ServerList({ clearChat }: ServerListProps) { }, [serverList]); useEffect(() => { - if (!askAiServerId || serverList.length === 0) return; - - const matched = serverList.find((server) => { - return server.id === askAiServerId; - }); + const targetId = targetServerId ?? askAiServerId; + if (!targetId || list.length === 0) return; + const matched = list.find((server) => server.id === targetId); if (!matched) return; switchServer(matched); - setAskAiServerId(void 0); - }, [serverList, askAiServerId]); + setHighlightId(matched.id); + if (targetServerId) { + setTargetServerId(void 0); + } else { + setAskAiServerId(void 0); + } + }, [list, askAiServerId, targetServerId]); useEffect(() => { if (!isTauri) return; @@ -291,4 +300,4 @@ export function ServerList({ clearChat }: ServerListProps) { ); -} +} \ No newline at end of file diff --git a/src/components/SearchChat/index.tsx b/src/components/SearchChat/index.tsx index e0d4e197..9f2bf5b9 100644 --- a/src/components/SearchChat/index.tsx +++ b/src/components/SearchChat/index.tsx @@ -316,35 +316,50 @@ function SearchChat({ const { normalOpacity, blurOpacity } = useAppearanceStore(); useEffect(() => { - const unlistenAsk = platformAdapter.listenEvent("selection-ask-ai", ({ payload }: any) => { - const value = typeof payload === "string" ? payload : String(payload?.text ?? ""); - dispatch({ type: "SET_CHAT_MODE", payload: true }); - dispatch({ type: "SET_INPUT", payload: value }); - platformAdapter.showWindow(); - }); - - const unlistenAction = platformAdapter.listenEvent("selection-action", ({ payload }: any) => { - const { action, text, assistantId } = payload || {}; - const value = String(text ?? ""); - if (action === "search") { - dispatch({ type: "SET_CHAT_MODE", payload: false }); - dispatch({ type: "SET_INPUT", payload: value }); - const { setSearchValue } = useSearchStore.getState(); - setSearchValue(value); - platformAdapter.showWindow(); - } else if (action === "chat") { + const unlistenAsk = platformAdapter.listenEvent( + "selection-ask-ai", + ({ payload }: any) => { + const value = + typeof payload === "string" ? payload : String(payload?.text ?? ""); dispatch({ type: "SET_CHAT_MODE", payload: true }); dispatch({ type: "SET_INPUT", payload: value }); - - const { assistantList } = useConnectStore.getState(); - const assistant = assistantList.find((item) => item._source?.id === assistantId); - if (assistant) { - const { setTargetAssistantId } = useSearchStore.getState(); - setTargetAssistantId(assistant._id); - } platformAdapter.showWindow(); } - }); + ); + + const unlistenAction = platformAdapter.listenEvent( + "selection-action", + ({ payload }: any) => { + const { action, text, assistantId, serverId } = payload || {}; + const value = String(text ?? ""); + + // + if (action === "search") { + dispatch({ type: "SET_CHAT_MODE", payload: false }); + dispatch({ type: "SET_INPUT", payload: value }); + const { setSearchValue } = useSearchStore.getState(); + setSearchValue(value); + } else if (action === "chat") { + dispatch({ type: "SET_CHAT_MODE", payload: true }); + dispatch({ type: "SET_INPUT", payload: value }); + // + const { setTargetServerId, setTargetAssistantId } = + useSearchStore.getState(); + + if (serverId) { + setTargetServerId(serverId); + } + + const { assistantList } = useConnectStore.getState(); + const assistant = assistantList.find( + (item) => item._source?.id === assistantId + ); + if (assistant) { + setTargetAssistantId(assistant._id); + } + } + } + ); return () => { unlistenAsk.then((fn) => fn()); diff --git a/src/components/Selection/HeaderToolbar.tsx b/src/components/Selection/HeaderToolbar.tsx new file mode 100644 index 00000000..009080ac --- /dev/null +++ b/src/components/Selection/HeaderToolbar.tsx @@ -0,0 +1,58 @@ +import { Separator } from "@radix-ui/react-separator"; + +import cocoLogoImg from "@/assets/app-icon.png"; +import SelectionToolbar from "@/components/Selection/Toolbar"; +import type { ActionConfig, ButtonConfig } from "@/components/Settings/Advanced/components/Selection/config"; + +export default function HeaderToolbar({ + buttons, + iconsOnly, + onAction, + onLogoClick, + className, + rootRef, + children, +}: { + buttons: ButtonConfig[]; + iconsOnly: boolean; + onAction: (action: ActionConfig) => void; + onLogoClick?: () => void; + className?: string; + rootRef?: React.Ref; + children?: React.ReactNode; +}) { + return ( +
+ Coco Logo { + try { + (e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png"; + } catch {} + }} + /> + + + + + + {children} +
+ ); +} diff --git a/src/components/Selection/Toolbar.tsx b/src/components/Selection/Toolbar.tsx new file mode 100644 index 00000000..3c28708a --- /dev/null +++ b/src/components/Selection/Toolbar.tsx @@ -0,0 +1,116 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { Search } from "lucide-react"; + +import { + ActionConfig, + ButtonConfig, + IconConfig, + resolveLucideIcon, +} from "@/components/Settings/Advanced/components/Selection/config"; + +const requiresAssistant = (type?: string) => + type === "ask_ai" || type === "translate" || type === "summary"; + +function IconRenderer({ icon }: { icon?: IconConfig }) { + if (icon?.type === "lucide") { + const Comp = resolveLucideIcon(icon?.name); + if (Comp) { + return ( + + ); + } + return ( + + ); + } + if (icon?.type === "custom" && icon?.dataUrl) { + return ( + + ); + } + return ; +} + +function ToolbarButton({ + btn, + onClick, + showLabel, +}: { + btn: ButtonConfig; + onClick: () => void; + showLabel: boolean; +}) { + const { t } = useTranslation(); + const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || ""; + return ( + + ); +} + +export default function SelectionToolbar({ + buttons, + iconsOnly, + onAction, + className, + requireAssistantCheck = true, +}: { + buttons: ButtonConfig[]; + iconsOnly: boolean; + onAction: (action: ActionConfig) => void; + className?: string; + requireAssistantCheck?: boolean; +}) { + const visibleButtons = (Array.isArray(buttons) ? buttons : []).filter((btn: any) => { + if (!requireAssistantCheck) return true; + const type = btn?.action?.type; + if (requiresAssistant(type)) { + return Boolean(btn?.action?.assistantId); + } + return true; + }); + + return ( +
+ {visibleButtons.map((btn) => ( + onAction(btn.action)} + showLabel={!iconsOnly} + /> + ))} +
+ ); +} diff --git a/src/components/Settings/Advanced/components/Selection/AddChatButton.tsx b/src/components/Settings/Advanced/components/Selection/AddChatButton.tsx new file mode 100644 index 00000000..8dbd1780 --- /dev/null +++ b/src/components/Settings/Advanced/components/Selection/AddChatButton.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ButtonConfig } from "./config"; +import AddChatDialog from "./AddChatDialog"; + +interface AddChatButtonProps { + serverList: any[]; + onAdd: (btn: ButtonConfig) => void; +} + +export function AddChatButton({ serverList, onAdd }: AddChatButtonProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( +
+ + + +
+ ); +} diff --git a/src/components/Settings/Advanced/components/Selection/AddChatDialog.tsx b/src/components/Settings/Advanced/components/Selection/AddChatDialog.tsx new file mode 100644 index 00000000..3b82b319 --- /dev/null +++ b/src/components/Settings/Advanced/components/Selection/AddChatDialog.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconPicker } from "@infinilabs/custom-icons"; +import type { IconConfig } from "@infinilabs/custom-icons"; +import { nanoid } from "nanoid"; + +import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher"; +import { Button } from "@/components/ui/button"; +import { ButtonConfig } from "./config"; +import { useThemeStore } from "@/stores/themeStore"; +import { useAppStore } from "@/stores/appStore"; + +export default function AddChatDialog({ + serverList, + open, + onOpenChange, + onAdd, +}: { + serverList: any[]; + open: boolean; + onOpenChange: (v: boolean) => void; + onAdd: (btn: ButtonConfig) => void; +}) { + const { t, i18n } = useTranslation(); + const { fetchAssistant } = AssistantFetcher({}); + + const [label, setLabel] = useState(""); + const [iconType, setIconType] = useState("lucide"); + const [lucideName, setLucideName] = useState("Bot"); + const [color, setColor] = useState("#0287FF"); + const [dataUrl, setDataUrl] = useState(""); + const [serverId, setServerId] = useState(""); + const [assistantList, setAssistantList] = useState([]); + const [assistantId, setAssistantId] = useState(""); + const [loading, setLoading] = useState(false); + + const activeTheme = useThemeStore((state) => state.activeTheme); + const language = useAppStore((state) => state.language); + + useEffect(() => { + if (!serverId) { + setAssistantList([]); + return; + } + setLoading(true); + fetchAssistant({ current: 1, pageSize: 1000, serverId }) + .then((data) => setAssistantList(data.list || [])) + .catch(() => setAssistantList([])) + .finally(() => setLoading(false)); + }, [serverId]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + handleClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [open]); + + const reset = () => { + setLabel(""); + setIconType("lucide"); + setLucideName("Bot"); + setColor("#0287FF"); + setDataUrl(""); + setServerId(""); + setAssistantList([]); + setAssistantId(""); + }; + + const handleClose = () => { + onOpenChange(false); + reset(); + }; + + const handleAdd = () => { + const id = `custom-${nanoid(8)}`; + const icon: IconConfig = + iconType === "lucide" + ? { type: "lucide", name: lucideName, color } + : { type: "custom", dataUrl, color }; + const btn: any = { + id, + label: label || t("selection.custom.chat"), + icon, + action: { + type: "ask_ai", + assistantServerId: serverId || undefined, + assistantId: assistantId || undefined, + }, + }; + onAdd(btn); + handleClose(); + }; + + const applyIconConfig = (cfg: IconConfig) => { + if (cfg.type === "lucide") { + setIconType("lucide"); + setLucideName(String(cfg.name || lucideName || "Bot")); + } else { + setIconType("custom"); + setDataUrl(String(cfg.dataUrl || dataUrl || "")); + } + if (cfg.color) { + setColor(String(cfg.color)); + } + }; + + if (!open) return null; + + const currentLanguage = language || i18n.language; + + return ( +
+
e.stopPropagation()} + > +
+

+ {t("selection.custom.chat")} +

+

+ {t("selection.bind.assistant")} +

+
+ +
+
+ + setLabel(e.target.value)} + /> +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/Settings/Advanced/components/Selection/ButtonsList.tsx b/src/components/Settings/Advanced/components/Selection/ButtonsList.tsx index 37ea8946..cd8c818f 100644 --- a/src/components/Settings/Advanced/components/Selection/ButtonsList.tsx +++ b/src/components/Settings/Advanced/components/Selection/ButtonsList.tsx @@ -1,61 +1,12 @@ import { useEffect, useRef, useState } from "react"; -import { - GripVertical, - Bot, - Copy, - Languages, - Search, - Volume2, - FileText, -} from "lucide-react"; +import { GripVertical, Trash2 } from "lucide-react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher"; import { setCurrentWindowService } from "@/commands/windowService"; - -type ActionType = - | "search" - | "ask_ai" - | "translate" - | "summary" - | "copy" - | "speak" - | "custom"; - -type LucideIconName = - | "Search" - | "Bot" - | "Languages" - | "FileText" - | "Copy" - | "Volume2"; - -type IconConfig = - | { type: "lucide"; name: LucideIconName; color?: string } - | { type: "custom"; dataUrl: string; color?: string }; - -export type ButtonConfig = { - id: string; - label: string; - icon: IconConfig; - action: { - type: ActionType; - assistantId?: string; - assistantServerId?: string; - eventName?: string; - }; - labelKey?: string; -}; - -const LUCIDE_ICON_MAP: Record = { - Search, - Bot, - Languages, - FileText, - Copy, - Volume2, -}; +import { AddChatButton } from "./AddChatButton"; +import { ButtonConfig, resolveLucideIcon } from "./config"; const ASSISTANT_CACHE_KEY = "assistant_list_cache"; @@ -94,9 +45,23 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => { const { t } = useTranslation(); const { fetchAssistant } = AssistantFetcher({}); - const [assistantByServer, setAssistantByServer] = useState>({}); - const [assistantLoadingByServer, setAssistantLoadingByServer] = useState>({}); - const [assistantCache, setAssistantCacheState] = useState>(() => loadAssistantCache()); + const [assistantByServer, setAssistantByServer] = useState< + Record + >({}); + const [assistantLoadingByServer, setAssistantLoadingByServer] = useState< + Record + >({}); + const [assistantCache, setAssistantCacheState] = useState< + Record + >(() => loadAssistantCache()); + const BUILT_IN_IDS = new Set([ + "search", + "ask_ai", + "translate", + "summary", + "copy", + "speak", + ]); const dragIndexRef = useRef(null); const initializedServiceRef = useRef(false); @@ -118,7 +83,11 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => { }; const updateAction = (id: string, patch: Partial) => { - setButtons((prev) => prev.map((b) => (b.id === id ? { ...b, action: { ...b.action, ...patch } } : b))); + setButtons((prev) => + prev.map((b) => + b.id === id ? { ...b, action: { ...b.action, ...patch } } : b + ) + ); }; const handleAssistantSelect = (btn: ButtonConfig, value: string) => { @@ -145,10 +114,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => { } setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true })); try { - const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid }); + const data = await fetchAssistant({ + current: 1, + pageSize: 1000, + serverId: sid, + }); const list = data.list || []; setAssistantByServer((prev) => ({ ...prev, [sid]: list })); - const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } }; + const nextCache = { + ...assistantCache, + [sid]: { list, updatedAt: Date.now() }, + }; setAssistantCacheState(nextCache); saveAssistantCache(nextCache); } catch (err) { @@ -164,8 +140,8 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => { initializedServiceRef.current = true; const preferredSid = - buttons.find((b) => b.action.assistantServerId)?.action.assistantServerId || - Object.keys(assistantCache)[0]; + buttons.find((b) => b.action.assistantServerId)?.action + .assistantServerId || Object.keys(assistantCache)[0]; if (!preferredSid) return; const target = serverList.find((s: any) => s.id === preferredSid); @@ -193,10 +169,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => { } setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true })); try { - const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid }); + const data = await fetchAssistant({ + current: 1, + pageSize: 1000, + serverId: sid, + }); const list = data.list || []; setAssistantByServer((prev) => ({ ...prev, [sid]: list })); - const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } }; + const nextCache = { + ...assistantCache, + [sid]: { list, updatedAt: Date.now() }, + }; setAssistantCacheState(nextCache); saveAssistantCache(nextCache); } catch (err) { @@ -210,9 +193,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => { return (
{buttons.map((btn, index) => { - const IconComp = btn.icon.type === "lucide" ? LUCIDE_ICON_MAP[btn.icon.name] : null; - const isChat = ["ask_ai", "translate", "summary"].includes(btn.action.type); - const visualType: "Chat" | "Search" | "Tool" = isChat ? "Chat" : btn.action.type === "search" ? "Search" : "Tool"; + const IconComp = + btn.icon.type === "lucide" ? resolveLucideIcon(btn.icon.name) : null; + const isChat = ["ask_ai", "translate", "summary"].includes( + btn.action.type + ); + const isBuiltIn = BUILT_IN_IDS.has(btn.id); + const visualType: "Chat" | "Search" | "Tool" = isChat + ? "Chat" + : btn.action.type === "search" + ? "Search" + : "Tool"; return (
{
{IconComp ? ( - + ) : ( - icon + icon )} - {btn.labelKey ? t(btn.labelKey) : btn.label} + + {btn.labelKey ? t(btn.labelKey) : btn.label} + { {isChat && ( <> handleAssistantSelect(btn, e.target.value)} + onChange={(e) => + handleAssistantSelect(btn, e.target.value) + } title={t("selection.bind.assistant")} disabled={loading} > - + {loading && ( )} {list.map((a: any) => ( @@ -292,13 +298,30 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => { })()} )} + {!isBuiltIn && ( + + )}
); })} + setButtons((prev) => [...prev, newBtn])} + /> ); }; -export default ButtonsList; \ No newline at end of file +export default ButtonsList; diff --git a/src/components/Settings/Advanced/components/Selection/config.ts b/src/components/Settings/Advanced/components/Selection/config.ts new file mode 100644 index 00000000..4ecea78b --- /dev/null +++ b/src/components/Settings/Advanced/components/Selection/config.ts @@ -0,0 +1,50 @@ +import { + Copy, + FileText, + Languages, + Search, + Volume2, + BotMessageSquare, +} from "lucide-react"; +import * as LucideIcons from "lucide-react"; + +export type IconConfig = + | { type: "lucide"; name: string; color?: string } + | { type: "custom"; dataUrl: string; color?: string }; + +export type ActionConfig = { + type: string; + assistantId?: string; + assistantServerId?: string; + eventName?: string; +}; + +export type ButtonConfig = { + id: string; + label: string; + icon: IconConfig; + action: ActionConfig; + labelKey?: string; +}; + +export const LUCIDE_ICON_MAP: Record = { + Search, + Languages, + FileText, + Copy, + Volume2, + BotMessageSquare, +}; + +export function resolveLucideIcon(name?: string): any { + if (!name) return (LucideIcons as any)["Search"] || Search; + const direct = (LucideIcons as any)[name]; + if (direct) return direct; + const normalized = String(name) + .trim() + .replace(/[-_\s]+/g, " ") + .split(" ") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); + return (LucideIcons as any)[normalized] || (LUCIDE_ICON_MAP as any)[normalized] || null; +} diff --git a/src/components/Settings/Advanced/components/Selection/index.tsx b/src/components/Settings/Advanced/components/Selection/index.tsx index 425d87f5..d0f93767 100644 --- a/src/components/Settings/Advanced/components/Selection/index.tsx +++ b/src/components/Settings/Advanced/components/Selection/index.tsx @@ -5,48 +5,10 @@ import { useTranslation } from "react-i18next"; import { useSelectionStore } from "@/stores/selectionStore"; import SettingsToggle from "@/components/Settings/SettingsToggle"; import SettingsItem from "@/components/Settings/SettingsItem"; -import platformAdapter from "@/utils/platformAdapter"; import { useEnabledServers } from "@/hooks/useEnabledServers"; import ButtonsList from "./ButtonsList"; - -/** - * Selection toolbar button config types - */ -type IconConfig = - | { type: "lucide"; name: LucideIconName; color?: string } - | { type: "custom"; dataUrl: string; color?: string }; - -type ActionType = - | "search" - | "ask_ai" - | "translate" - | "summary" - | "copy" - | "speak" - | "custom"; - -type ButtonConfig = { - id: string; - label: string; - icon: IconConfig; - action: { - type: ActionType; - assistantId?: string; - assistantServerId?: string; - eventName?: string; - }; - // i18n key for built-in labels; if present, render by t(labelKey) - labelKey?: string; -}; - -type LucideIconName = - | "Search" - | "Bot" - | "Languages" - | "FileText" - | "Copy" - | "Volume2"; - +import HeaderToolbar from "@/components/Selection/HeaderToolbar"; +import { ButtonConfig } from "./config"; const DEFAULT_CONFIG: ButtonConfig[] = [ { @@ -60,7 +22,7 @@ const DEFAULT_CONFIG: ButtonConfig[] = [ id: "ask_ai", label: "问答", labelKey: "selection.actions.ask_ai", - icon: { type: "lucide", name: "Bot", color: "#0287FF" }, + icon: { type: "lucide", name: "BotMessageSquare", color: "#0287FF" }, action: { type: "ask_ai" }, }, { @@ -103,9 +65,11 @@ function loadToolbarConfig(): ButtonConfig[] { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return DEFAULT_CONFIG; const parsed = JSON.parse(raw); - if (Array.isArray(parsed) && parsed.length > 0) - return parsed as ButtonConfig[]; - return DEFAULT_CONFIG; + let cfg: ButtonConfig[] = Array.isArray(parsed) && parsed.length > 0 ? (parsed as ButtonConfig[]) : DEFAULT_CONFIG; + // Lightweight migration: ensure ask_ai icon follows the updated default + const defaultAsk = DEFAULT_CONFIG.find((b) => b.id === "ask_ai"); + cfg = cfg.map((b) => (b.id === "ask_ai" && defaultAsk ? { ...b, icon: defaultAsk.icon } : b)); + return cfg; } catch { return DEFAULT_CONFIG; } @@ -125,6 +89,7 @@ const SelectionSettings = () => { const selectionEnabled = useSelectionStore((state) => state.selectionEnabled); const iconsOnly = useSelectionStore((state) => state.iconsOnly); const setIconsOnly = useSelectionStore((state) => state.setIconsOnly); + const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled); // Initialize from global store; write back on change for multi-window sync const toolbarConfig = useSelectionStore((s) => s.toolbarConfig); @@ -143,7 +108,7 @@ const SelectionSettings = () => { useEffect(() => { saveToolbarConfig(buttons); - setToolbarConfig(buttons); // push to store for multi-window + setToolbarConfig(buttons); }, [buttons]); return ( @@ -152,6 +117,24 @@ const SelectionSettings = () => {

{t("selection.title")}

+
+ +
+
+ { > { - try { - await platformAdapter.invokeBackend("set_selection_enabled", { - enabled: value, - }); - } catch (e) { - console.error("set_selection_enabled invoke failed:", e); - } - }} + onChange={setSelectionEnabled} label={t("settings.ai.toggle")} /> @@ -188,7 +163,11 @@ const SelectionSettings = () => { label={t("selection.display.iconsOnlyLabel")} /> - +
)} diff --git a/src/components/Settings/Advanced/index.tsx b/src/components/Settings/Advanced/index.tsx index 4a7f64f6..34a61955 100644 --- a/src/components/Settings/Advanced/index.tsx +++ b/src/components/Settings/Advanced/index.tsx @@ -22,6 +22,7 @@ import platformAdapter from "@/utils/platformAdapter"; import UpdateSettings from "./components/UpdateSettings"; import SettingsToggle from "../SettingsToggle"; import SelectionSettings from "./components/Selection"; +import { isMac } from "@/utils/platform"; const Advanced = () => { const { t } = useTranslation(); @@ -190,7 +191,7 @@ const Advanced = () => { })} - + {isMac && } diff --git a/src/hooks/useSelectionEnabled.ts b/src/hooks/useSelectionEnabled.ts new file mode 100644 index 00000000..9a119222 --- /dev/null +++ b/src/hooks/useSelectionEnabled.ts @@ -0,0 +1,26 @@ +import { useMount } from "ahooks"; + +import platformAdapter from "@/utils/platformAdapter"; +import { useSelectionStore } from "@/stores/selectionStore"; + +export default function useSelectionEnabled() { + useMount(async () => { + try { + const enabled = await platformAdapter.invokeBackend("get_selection_enabled"); + useSelectionStore.getState().setSelectionEnabled(!!enabled); + } catch (e) { + console.error("get_selection_enabled failed:", e); + } + + const unlisten = await platformAdapter.listenEvent( + "selection-enabled", + ({ payload }: any) => { + useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled); + } + ); + + return () => { + unlisten && unlisten(); + }; + }); +} \ No newline at end of file diff --git a/src/hooks/useTray.ts b/src/hooks/useTray.ts index a2fb37de..3a0638d8 100644 --- a/src/hooks/useTray.ts +++ b/src/hooks/useTray.ts @@ -50,52 +50,72 @@ export const useTray = () => { }; const getTrayMenu = async () => { - const items = await Promise.all([ + const itemPromises: Promise[] = []; + + itemPromises.push( MenuItem.new({ text: t("tray.showCoco"), accelerator: showCocoShortcuts.join("+"), action: () => { show_coco(); }, - }), - PredefinedMenuItem.new({ item: "Separator" }), - MenuItem.new({ - text: selectionEnabled - ? t("tray.selectionDisable") - : t("tray.selectionEnable"), - action: async () => { - try { - await platformAdapter.invokeBackend("set_selection_enabled", { enabled: !selectionEnabled }); - } catch (e) { - console.error("set_selection_enabled invoke failed:", e); - } - }, - }), + }) + ); + + itemPromises.push(PredefinedMenuItem.new({ item: "Separator" })); + + if (isMac) { + itemPromises.push( + MenuItem.new({ + text: selectionEnabled + ? t("tray.selectionDisable") + : t("tray.selectionEnable"), + action: async () => { + try { + await platformAdapter.invokeBackend("set_selection_enabled", { + enabled: !selectionEnabled, + }); + } catch (e) { + console.error("set_selection_enabled invoke failed:", e); + } + }, + }) + ); + } + + itemPromises.push( MenuItem.new({ text: t("tray.settings"), // accelerator: "CommandOrControl+,", action: () => { show_settings(); }, - }), + }) + ); + + itemPromises.push( MenuItem.new({ text: t("tray.checkUpdate"), action: async () => { await show_check(); - platformAdapter.emitEvent("check-update"); }, - }), - PredefinedMenuItem.new({ item: "Separator" }), + }) + ); + + itemPromises.push(PredefinedMenuItem.new({ item: "Separator" })); + + itemPromises.push( MenuItem.new({ text: t("tray.quitCoco"), accelerator: "CommandOrControl+Q", action: () => { exit(0); }, - }), - ]); + }) + ); + const items = await Promise.all(itemPromises); return Menu.new({ items }); }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e8ef8f88..4fa44d74 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -650,6 +650,10 @@ "login": "Login" } }, + "common": { + "loading": "Loading...", + "notFound": "Not found" + }, "selection": { "title": "Selection Toolbar Settings", "actions": { @@ -658,7 +662,9 @@ "translate": "Translate", "summary": "Summary", "copy": "Copy", - "speak": "Read Aloud" + "speak": "Read Aloud", + "delete": "Delete", + "addChat": "Add Chat Button" }, "noText": "No text detected", "copied": "Copied", @@ -683,8 +689,24 @@ "bind": { "service": "Select Service", "defaultService": "Default service", - "assistant": "Bind Assistant (Chat only)", + "assistant": "Bind Assistant", "defaultAssistant": "Default assistant" + }, + "custom": { + "chat": "Custom Chat", + "namePlaceholder": "Button name" + }, + "icon": { + "type": "Icon Type", + "lucide": "Lucide", + "custom": "Custom", + "pick": "Pick Icon", + "color": "Icon Color", + "upload": "Upload Icon", + "alt": "icon" + }, + "preview": { + "readonly": "Preview is read-only" } } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 96062a3f..a3aa7800 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -649,6 +649,10 @@ "login": "登录" } }, + "common": { + "loading": "加载中...", + "notFound": "未找到" + }, "selection": { "title": "划词工具栏设置", "actions": { @@ -657,7 +661,9 @@ "translate": "翻译", "summary": "总结", "copy": "复制", - "speak": "朗读" + "speak": "朗读", + "delete": "删除", + "addChat": "添加聊天功能" }, "noText": "未检测到文本", "copied": "已复制", @@ -682,8 +688,24 @@ "bind": { "service": "选择服务", "defaultService": "默认服务", - "assistant": "绑定小助手(仅聊天)", + "assistant": "绑定小助手", "defaultAssistant": "默认小助手" + }, + "custom": { + "chat": "自定义聊天", + "namePlaceholder": "功能名称" + }, + "icon": { + "type": "图标类型", + "lucide": "Lucide", + "custom": "自定义", + "pick": "选择图标", + "color": "图标颜色", + "upload": "上传图标", + "alt": "图标" + }, + "preview": { + "readonly": "预览仅供查看" } } } diff --git a/src/pages/selection/index.tsx b/src/pages/selection/index.tsx index a9a8851a..27de3b2f 100644 --- a/src/pages/selection/index.tsx +++ b/src/pages/selection/index.tsx @@ -1,28 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Bot, - Copy, - Languages, - Search, - X, - Volume2, - Pause, - Play, - FileText, -} from "lucide-react"; +import { X, Pause, Play } from "lucide-react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; -import { Separator } from "@radix-ui/react-separator"; import { useSelectionStore } from "@/stores/selectionStore"; import { copyToClipboard } from "@/utils"; -import cocoLogoImg from "@/assets/app-icon.png"; import platformAdapter from "@/utils/platformAdapter"; +import HeaderToolbar from "@/components/Selection/HeaderToolbar"; +import type { ActionConfig } from "@/components/Settings/Advanced/components/Selection/config"; +import { show_coco } from "@/commands"; // Simple animated selection window content export default function SelectionWindow() { const { t } = useTranslation(); - const [text, setText] = useState(""); const [visible, setVisible] = useState(false); const [animatingOut, setAnimatingOut] = useState(false); @@ -137,34 +127,27 @@ export default function SelectionWindow() { }, 150); }; - const openMain = async () => { - try { - await platformAdapter.commands("show_coco"); - } catch { - await platformAdapter.emitEvent("show-coco"); - await platformAdapter.showWindow(); - } - }; - const handleChatAction = useCallback( - async (assistantId?: string) => { + async (action: ActionConfig) => { const payloadText = (textRef.current || "").trim(); + if (!payloadText) return; - await openMain(); - await new Promise((r) => setTimeout(r, 120)); + await show_coco(); + await new Promise((r) => setTimeout(r, 300)); await platformAdapter.emitEvent("selection-action", { action: "chat", text: payloadText, - assistantId, + assistantId: action.assistantId, + serverId: action.assistantServerId, }); if (!isSpeaking) { await close(); } }, - [openMain, isSpeaking, close] + [isSpeaking, close] ); const searchMain = useCallback(async () => { @@ -172,7 +155,7 @@ export default function SelectionWindow() { console.log("searchMain payload", payloadText); if (!payloadText) return; - await openMain(); + await show_coco(); await new Promise((r) => setTimeout(r, 120)); await platformAdapter.emitEvent("selection-action", { action: "search", @@ -239,7 +222,7 @@ export default function SelectionWindow() { setIsSpeaking(true); setIsPaused(false); } catch (e) { - console.error("TTS 播放失败", e); + console.error("TTS play failed:", e); stopSpeak(); scheduleAutoHide(); } @@ -258,124 +241,88 @@ export default function SelectionWindow() { } }, []); - const getActionHandler = (type: string, assistantId?: string) => { - switch (type) { + const getActionHandler = (action: ActionConfig) => { + switch (action.type) { case "ask_ai": case "translate": case "summary": - return () => handleChatAction(assistantId); + handleChatAction(action); + break; case "copy": - return handleCopy; + handleCopy(); + break; case "search": - return searchMain; + searchMain(); + break; case "speak": - return speak; + speak(); + break; default: - return () => {}; + break; } }; - // Render buttons from store; hide ones requiring assistant without assistantId - const toolbarConfig = useSelectionStore((s) => s.toolbarConfig); const iconsOnly = useSelectionStore((s) => s.iconsOnly); - - const requiresAssistant = (type?: string) => - type === "ask_ai" || type === "translate" || type === "summary"; + const toolbarConfig = useSelectionStore((s) => s.toolbarConfig); + const toolbarRef = useRef(null); const visibleButtons = useMemo( - () => - (Array.isArray(toolbarConfig) ? toolbarConfig : []).filter((btn: any) => { - const type = btn?.action?.type; - if (requiresAssistant(type)) { - return Boolean(btn?.action?.assistantId); - } - return true; - }), + () => (Array.isArray(toolbarConfig) ? toolbarConfig : []), [toolbarConfig] ); - // Lucide icon map for dynamic rendering - const LUCIDE_ICON_MAP: Record = { - Search, - Bot, - Languages, - FileText, - Copy, - Volume2, - }; + // Resize window width to fit toolbar content (sum child widths to avoid feedback growth) + const resizeToContentWidth = useCallback(async () => { + try { + if (!visible) return; + const el = toolbarRef.current; + if (!el) return; - // Component: render icon (lucide or custom) - const IconRenderer = ({ icon }: { icon?: any }) => { - // Support lucide icon or custom image - if (icon?.type === "lucide") { - const Icon = - LUCIDE_ICON_MAP[icon?.name as string] || LUCIDE_ICON_MAP.Search; - return ( - + // Robust intrinsic width measurement: clone offscreen to avoid flex shrink/grow feedback. + const clone = el.cloneNode(true) as HTMLElement; + clone.style.position = "absolute"; + clone.style.visibility = "hidden"; + clone.style.left = "-10000px"; + clone.style.top = "0"; + clone.style.width = "auto"; + clone.style.maxWidth = "none"; + clone.style.overflow = "visible"; + // prevent wrapping that may change inline width + (clone.style as any).whiteSpace = "nowrap"; + document.body.appendChild(clone); + const intrinsicWidth = Math.ceil(clone.getBoundingClientRect().width); + clone.remove(); + + // Apply small buffer and clamp by mode + const PAD = 12; + const WIDTH_MIN = iconsOnly ? 160 : 240; + const WIDTH_MAX = iconsOnly ? 640 : 960; + const desiredWidth = Math.max( + WIDTH_MIN, + Math.min(WIDTH_MAX, Math.ceil(intrinsicWidth + PAD)) ); - } - if (icon?.type === "custom" && icon?.dataUrl) { - return ( - - ); - } - // default - return ; - }; - // Component: single toolbar button - const ToolbarButton = ({ - btn, - onClick, - }: { - btn: any; - onClick: () => void; - }) => { - const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || ""; - return ( - - ); - }; + const win = await platformAdapter.getCurrentWebviewWindow(); + const size = await win.innerSize(); + // Only update width; preserve current height + const currentWidth = size.width; + if (Math.abs(currentWidth - desiredWidth) >= 2) { + await platformAdapter.setWindowSize(desiredWidth, 32); + } + } catch (e) { + console.warn("resizeToContentWidth failed:", e); + } + }, [visible, iconsOnly]); - // Component: header logo - const HeaderLogo = () => { - return ( - Coco Logo { - try { - (e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png"; - } catch {} - }} - /> - ); - }; + // Recalculate on relevant changes (buttons, labels, speaking state, visibility) + useEffect(() => { + if (!visible) return; + // Ensure DOM updated before measure + const id = window.requestAnimationFrame(() => { + resizeToContentWidth(); + }); + return () => cancelAnimationFrame(id); + }, [visibleButtons, iconsOnly, isSpeaking, visible, resizeToContentWidth]); // Component: selected text preview const TextPreview = ({ text }: { text: string }) => { @@ -484,31 +431,15 @@ export default function SelectionWindow() { -
- - - - - {visibleButtons.map((btn: any) => { - const { type, assistantId } = btn?.action; - return ( - - ); - })} - {isSpeaking && } -
+ ); } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 7479f07f..67c665b2 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,13 +1,15 @@ import { createBrowserRouter } from "react-router-dom"; +import { Suspense, lazy } from "react"; import Layout from "./layout"; import ErrorPage from "@/pages/error/index"; -import DesktopApp from "@/pages/main/index"; -import SettingsPage from "@/pages/settings/index"; -import StandaloneChat from "@/pages/chat/index"; -import WebPage from "@/pages/web/index"; -import CheckPage from "@/pages/check/index"; -import SelectionWindow from "@/pages/selection/index"; + +const DesktopApp = lazy(() => import("@/pages/main/index")); +const SettingsPage = lazy(() => import("@/pages/settings/index")); +const StandaloneChat = lazy(() => import("@/pages/chat/index")); +const WebPage = lazy(() => import("@/pages/web/index")); +const CheckPage = lazy(() => import("@/pages/check/index")); +const SelectionWindow = lazy(() => import("@/pages/selection/index")); const routerOptions = { basename: "/", @@ -24,12 +26,12 @@ export const router = createBrowserRouter( element: , errorElement: , children: [ - { path: "/ui", element: }, - { path: "/ui/settings", element: }, - { path: "/ui/chat", element: }, - { path: "/ui/check", element: }, - { path: "/ui/selection", element: }, - { path: "/web", element: }, + { path: "/ui", element: (}>) }, + { path: "/ui/settings", element: (}>) }, + { path: "/ui/chat", element: (}>) }, + { path: "/ui/check", element: (}>) }, + { path: "/ui/selection", element: (}>) }, + { path: "/web", element: (}>) }, ], }, ], diff --git a/src/routes/outlet.tsx b/src/routes/outlet.tsx index edf29959..bf37901c 100644 --- a/src/routes/outlet.tsx +++ b/src/routes/outlet.tsx @@ -18,7 +18,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore"; import { useSelectionStore } from "@/stores/selectionStore"; import { useServers } from "@/hooks/useServers"; import { useDeepLinkManager } from "@/hooks/useDeepLinkManager"; -import { useSelectionWindow } from "../hooks/useSelectionWindow"; +import { useSelectionWindow } from "@/hooks/useSelectionWindow"; export default function LayoutOutlet() { const location = useLocation(); @@ -35,27 +35,6 @@ export default function LayoutOutlet() { // init deep link manager useDeepLinkManager(); - // --- Selection state: init + subscribe backend as SSOT --- - useMount(async () => { - try { - const enabled = await platformAdapter.invokeBackend("get_selection_enabled"); - useSelectionStore.getState().setSelectionEnabled(!!enabled); - } catch (e) { - console.error("get_selection_enabled failed:", e); - } - - const unlisten = await platformAdapter.listenEvent( - "selection-enabled", - ({ payload }: any) => { - useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled); - } - ); - - return () => { - unlisten && unlisten(); - }; - }); - useEffect(() => { i18n.changeLanguage(language); }, [language]); @@ -154,4 +133,4 @@ export default function LayoutOutlet() { ); -} +} \ No newline at end of file diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts index 7ee729d3..7c437789 100644 --- a/src/stores/searchStore.ts +++ b/src/stores/searchStore.ts @@ -44,6 +44,8 @@ export type ISearchStore = { setEnabledAiOverview: (enabledAiOverview: boolean) => void; askAiAssistantId?: string; setAskAiAssistantId: (askAiAssistantId?: string) => void; + targetServerId?: string; + setTargetServerId: (targetServerId?: string) => void; targetAssistantId?: string; setTargetAssistantId: (targetAssistantId?: string) => void; visibleExtensionStore: boolean; @@ -104,6 +106,9 @@ export const useSearchStore = create()( setAskAiAssistantId: (askAiAssistantId) => { return set({ askAiAssistantId }); }, + setTargetServerId: (targetServerId) => { + return set({ targetServerId }); + }, setTargetAssistantId: (targetAssistantId) => { return set({ targetAssistantId }); }, diff --git a/src/stores/selectionStore.ts b/src/stores/selectionStore.ts index 67d624a2..41848dc5 100644 --- a/src/stores/selectionStore.ts +++ b/src/stores/selectionStore.ts @@ -1,34 +1,16 @@ import { create } from 'zustand'; import { createTauriStore } from '@tauri-store/zustand'; -// Types adapted from Selection/index.tsx to ensure compatibility -export type LucideIconName = - | "Search" - | "Bot" - | "Languages" - | "FileText" - | "Copy" - | "Volume2"; - type IconConfig = - | { type: "lucide"; name: LucideIconName; color?: string } + | { type: "lucide"; name: string; color?: string } | { type: "custom"; dataUrl: string; color?: string }; -type ActionType = - | "search" - | "ask_ai" - | "translate" - | "summary" - | "copy" - | "speak" - | "custom"; - export type ButtonConfig = { id: string; label: string; icon: IconConfig; action: { - type: ActionType; + type: string; assistantId?: string; assistantServerId?: string; eventName?: string; diff --git a/src/types/platform.ts b/src/types/platform.ts index 8bc5c966..cb5d53fc 100644 --- a/src/types/platform.ts +++ b/src/types/platform.ts @@ -53,13 +53,10 @@ export interface EventPayloads { "server-list-changed": Server[]; "selection-text": string; "selection-ask-ai": any; - "selection-action": { - action: "translate" | "search" | "copy" | "summary"; - text: string; - }; + "selection-action": any; "selection-detected": string; "selection-enabled": boolean; - "selection-icons-only": { value: boolean }; + "change-selection-store": any; } // Window operation interface diff --git a/src/utils/webAdapter.ts b/src/utils/webAdapter.ts index 618c2489..d6d84091 100644 --- a/src/utils/webAdapter.ts +++ b/src/utils/webAdapter.ts @@ -277,4 +277,4 @@ export const createWebAdapter = (): WebPlatformAdapter => { return Promise.resolve(); }, }; -}; +}; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 23daed19..0fd96a1c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -87,10 +87,20 @@ export default defineConfig(async () => ({ rollupOptions: { output: { manualChunks: { - vendor: ["react", "react-dom"], - katex: ["rehype-katex"], - highlight: ["rehype-highlight"], + react: ["react", "react-dom"], + router: ["react-router-dom"], + markdown: [ + "react-markdown", + "remark-gfm", + "remark-breaks", + "remark-math", + "rehype-highlight", + "rehype-katex", + "mdast-util-gfm-autolink-literal", + ], mermaid: ["mermaid"], + icons: ["lucide-react", "@infinilabs/custom-icons"], + utils: ["lodash-es", "dayjs", "uuid", "nanoid", "axios"], "tauri-api": [ "@tauri-apps/api/core", "@tauri-apps/api/event",