feat: add selection toolbar window for mac (#980)

* 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<T, A: Allocator> RawIntoIter<T, A> {
     |            ^^^^^^^^^
     |
     = note: see issue #32838 <https://github.com/rust-lang/rust/issues/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]: b751eef8e9/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

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
This commit is contained in:
BiggerRain
2025-11-27 10:12:49 +08:00
committed by GitHub
parent fc642ac0e3
commit ff7721d17f
37 changed files with 3060 additions and 918 deletions

View File

@@ -14,6 +14,7 @@
"dyld",
"elif",
"errmsg",
"frontmost",
"fullscreen",
"fulltext",
"headlessui",
@@ -40,6 +41,7 @@
"nowrap",
"nspanel",
"nsstring",
"objc",
"overscan",
"partialize",
"patchelf",

View File

@@ -13,6 +13,7 @@ Information about release notes of Coco App is provided here.
### 🚀 Features
- feat: add selection toolbar window for mac #980
- feat: add a heartbeat worker to check Coco server availability #988
### 🐛 Bug fix

View File

@@ -19,9 +19,11 @@
},
"dependencies": {
"@headlessui/react": "^2.2.2",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "~2.0.0",

73
pnpm-lock.yaml generated
View File

@@ -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)
'@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)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@18.3.26)(react@18.3.1)
@@ -20,6 +23,9 @@ importers:
'@tauri-apps/plugin-autostart':
specifier: ~2.2.0
version: 2.2.0
'@tauri-apps/plugin-clipboard-manager':
specifier: ~2.3.2
version: 2.3.2
'@tauri-apps/plugin-deep-link':
specifier: ^2.2.1
version: 2.4.5
@@ -1029,6 +1035,32 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-primitive@2.1.4':
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.8':
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -1038,6 +1070,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@react-aria/focus@3.20.2':
resolution: {integrity: sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==}
peerDependencies:
@@ -1286,6 +1327,9 @@ packages:
'@tauri-apps/plugin-autostart@2.2.0':
resolution: {integrity: sha512-TzVcDZdOvdot0avkpstUWJKKEl4cyxLpFB9DZZRW5zH8k+Bv8IVJmO0zyYuw+7oKlGdHOINbD/7Je7GHMViw5w==}
'@tauri-apps/plugin-clipboard-manager@2.3.2':
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
'@tauri-apps/plugin-deep-link@2.4.5':
resolution: {integrity: sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==}
@@ -4553,6 +4597,24 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.26
'@radix-ui/react-primitive@2.1.4(@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)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.26)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/react@18.3.26)
'@radix-ui/react-separator@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)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@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)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/react@18.3.26)
'@radix-ui/react-slot@1.2.3(@types/react@18.3.26)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
@@ -4560,6 +4622,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.26
'@radix-ui/react-slot@1.2.4(@types/react@18.3.26)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.26
'@react-aria/focus@3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/interactions': 3.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4746,6 +4815,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.9.0
'@tauri-apps/plugin-clipboard-manager@2.3.2':
dependencies:
'@tauri-apps/api': 2.9.0
'@tauri-apps/plugin-deep-link@2.4.5':
dependencies:
'@tauri-apps/api': 2.9.0

1845
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -119,6 +119,7 @@ toml = "0.8"
path-clean = "1.0.1"
actix-files = "0.6.8"
actix-web = "4.11.0"
tauri-plugin-clipboard-manager = "2"
[dev-dependencies]
tempfile = "3.23.0"
@@ -130,6 +131,8 @@ objc2 = "0.6.2"
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
# macOS-only: used by selection_monitor.rs to check AX trust/prompt
macos-accessibility-client = "0.0.1"
[target."cfg(target_os = \"linux\")".dependencies]
gio = "0.21.2"

View File

@@ -38,5 +38,9 @@
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
<key>NSAppleEventsUsageDescription</key>
<string>Coco AI requires access to Apple Events to enable certain features, such as opening files and applications.</string>
<key>NSAccessibility</key>
<true/>
</dict>
</plist>

View File

@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "chat", "settings", "check"],
"windows": ["main", "chat", "settings", "check", "selection"],
"permissions": [
"core:default",
"core:event:allow-emit",
@@ -30,6 +30,7 @@
"core:window:allow-set-always-on-top",
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:window:allow-set-position",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",

View File

@@ -1,9 +0,0 @@
// use std::collections::HashMap;
use serde::{Deserialize, Serialize};
// use crate::common::health::Status;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestAccessTokenResponse {
pub access_token: String,
pub expire_in: u32,
}

View File

@@ -1,26 +1,8 @@
use crate::common;
use reqwest::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use tauri_plugin_store::JsonValue;
#[derive(Debug, Serialize, Deserialize)]
pub struct GetResponse {
pub _id: String,
pub _source: Source,
pub result: String,
pub payload: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Source {
pub id: String,
pub created: String,
pub updated: String,
pub status: String,
}
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
let status = response.status().as_u16();
let body = response

View File

@@ -1,5 +1,4 @@
pub mod assistant;
pub mod auth;
pub mod connector;
pub mod datasource;
pub mod document;

View File

@@ -3,6 +3,7 @@ mod autostart;
mod common;
mod extension;
mod search;
mod selection_monitor;
mod server;
mod settings;
mod setup;
@@ -72,24 +73,13 @@ async fn change_window_height(handle: AppHandle, height: u32) {
}
}
#[derive(serde::Deserialize)]
struct ThemeChangedPayload {
#[allow(dead_code)]
is_dark_mode: bool,
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
args: Vec<String>,
cwd: String,
}
// Removed unused Payload to avoid unnecessary serde derive macro invocations
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let ctx = tauri::generate_context!();
let mut app_builder = tauri::Builder::default();
let mut app_builder = tauri::Builder::default().plugin(tauri_plugin_clipboard_manager::init());
// Set up logger first
app_builder = app_builder.plugin(set_up_tauri_logger());
@@ -208,7 +198,9 @@ pub fn run() {
setup::backend_setup,
util::app_lang::update_app_lang,
util::path::path_absolute,
util::logging::app_log_dir
util::logging::app_log_dir,
selection_monitor::set_selection_enabled,
selection_monitor::get_selection_enabled,
])
.setup(|app| {
#[cfg(target_os = "macos")]
@@ -218,7 +210,6 @@ pub fn run() {
log::trace!("Dock icon should be hidden now");
}
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
let app_handle = app.app_handle();
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();

View File

@@ -0,0 +1,373 @@
/// Event payload sent to the frontend when selection is detected.
/// Coordinates use logical (Quartz) points with a top-left origin.
/// Note: `y` is flipped on the backend to match the frontends usage.
use tauri::Emitter;
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
text: String,
x: i32,
y: i32,
}
use std::sync::atomic::{AtomicBool, Ordering};
/// Global toggle: selection monitoring enabled by default.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
enabled: bool,
}
/// Read the current selection monitoring state.
pub fn is_selection_enabled() -> bool {
SELECTION_ENABLED.load(Ordering::Relaxed)
}
/// 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);
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
}
/// Tauri command: set selection monitoring state.
#[tauri::command]
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
set_selection_enabled_internal(&app_handle, enabled);
}
/// Tauri command: get selection monitoring state.
#[tauri::command]
pub fn get_selection_enabled() -> bool {
is_selection_enabled()
}
#[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: 入口函数启动");
use std::time::Duration;
use tauri::Emitter;
// Sync initial enabled state to the frontend on startup.
set_selection_enabled_internal(&app_handle, is_selection_enabled());
// Accessibility permission is required to read selected text in the foreground app.
// 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();
}
let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
if !trusted_after {
return;
}
}
#[cfg(not(target_os = "macos"))]
{
log::info!("start_selection_monitor: 非 macOS 平台,无划词监控");
}
// Background thread: drives popup show/hide based on mouse and AX selection state.
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
use objc2_app_kit::NSWorkspace;
use objc2_core_graphics::CGEvent;
use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
// Get current mouse position (logical top-left origin), flipping `y` to match frontend usage.
let current_mouse_point_global = || -> (i32, i32) {
unsafe {
let event = CGEvent::new(None);
let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
// Enumerate active displays to compute global bounds and pick the display containing the cursor.
let mut displays: [u32; 16] = [0; 16];
let mut display_count: u32 = 0;
let _ = CGGetActiveDisplayList(
displays.len() as u32,
displays.as_mut_ptr(),
&mut display_count,
);
if display_count == 0 {
// Fallback to main display.
let did = CGMainDisplayID();
let b = CGDisplayBounds(did);
let min_x_pt = b.origin.x as f64;
let max_top_pt = (b.origin.y + b.size.height) as f64;
let min_bottom_pt = b.origin.y as f64;
let total_h_pt = max_top_pt - min_bottom_pt;
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
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 min_x_pt = f64::INFINITY;
let mut max_top_pt = f64::NEG_INFINITY;
let mut min_bottom_pt = f64::INFINITY;
for i in 0..display_count as usize {
let did = displays[i];
let b = CGDisplayBounds(did);
if (b.origin.x as f64) < min_x_pt {
min_x_pt = b.origin.x as f64;
}
let top = (b.origin.y + b.size.height) as f64;
if top > max_top_pt {
max_top_pt = top;
}
if (b.origin.y as f64) < min_bottom_pt {
min_bottom_pt = b.origin.y as f64;
}
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
);
}
}
let total_h_pt = max_top_pt - min_bottom_pt;
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
(x_top_left, y_flipped)
}
};
// Determine whether the frontmost app is this process (Coco).
// Avoid misinterpreting empty selection when interacting with the popup itself.
let is_frontmost_app_me = || -> bool {
#[cfg(target_os = "macos")]
unsafe {
let workspace = NSWorkspace::sharedWorkspace();
if let Some(frontmost) = workspace.frontmostApplication() {
let pid = frontmost.processIdentifier();
let my_pid = std::process::id() as i32;
return pid == my_pid;
}
}
false
};
// Selection-driven state machine.
let mut popup_visible = false;
let mut last_text = String::new();
// Stability and hide thresholds (tunable).
let stable_threshold = 2; // same content ≥2 times → stable selection
let empty_threshold = 2; // empty value ≥2 times → stable empty
let mut stable_text = String::new();
let mut stable_count = 0;
let mut empty_count = 0;
loop {
std::thread::sleep(Duration::from_millis(30));
// If disabled: do not read AX / do not show popup; hide if currently visible.
if !is_selection_enabled() {
if popup_visible {
let _ = app_handle.emit("selection-detected", "");
popup_visible = false;
last_text.clear();
stable_text.clear();
}
continue;
}
// Skip empty-selection hide checks while interacting with the Coco popup.
let front_is_me = is_frontmost_app_me();
// 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 {
// Up to 2 retries, 35ms apart.
read_selected_text_with_retries(2, 35)
};
match selected_text {
Some(text) if !text.is_empty() => {
empty_count = 0;
if text == stable_text {
stable_count += 1;
} else {
stable_text = text.clone();
stable_count = 1;
}
// Update/show only when selection is stable to avoid flicker.
if stable_count >= stable_threshold {
if !popup_visible || text != last_text {
let (x, y) = current_mouse_point_global();
let payload = SelectionEventPayload {
text: text.clone(),
x,
y,
};
let _ = app_handle.emit("selection-detected", payload);
last_text = text;
popup_visible = true;
}
}
}
_ => {
// If not Coco in front and selection is empty: accumulate empties, then hide.
if !front_is_me {
stable_count = 0;
empty_count += 1;
if popup_visible && empty_count >= empty_threshold {
let _ = app_handle.emit("selection-detected", "");
popup_visible = false;
last_text.clear();
stable_text.clear();
}
} else {
// When Coco is frontmost: do not hide or clear state during interaction.
}
}
}
}
});
}
// 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;
}
/// 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")]
fn read_selected_text() -> Option<String> {
use objc2_app_kit::NSWorkspace;
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFRetained, CFString, CFType};
use std::ptr::NonNull;
// Prefer system-wide focused element; if unavailable, fall back to app/window focused element.
let mut focused_ui_ptr: *const CFType = std::ptr::null();
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
// System-wide focused UI element.
let system_elem = unsafe { AXUIElementCreateSystemWide() };
if !system_elem.is_null() {
let system_elem_retained: CFRetained<AXUIElement> =
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 = std::ptr::null();
}
}
// Fallback to the frontmost app's focused/window element.
if focused_ui_ptr.is_null() {
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
let frontmost_app = unsafe { workspace.frontmostApplication() }?;
let pid = unsafe { frontmost_app.processIdentifier() };
// Skip if frontmost is Coco (this process).
let my_pid = std::process::id() as i32;
if pid == my_pid {
return None;
}
let app_element = unsafe { AXUIElement::new_application(pid) };
let err = unsafe {
app_element
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success || focused_ui_ptr.is_null() {
// Try `AXFocusedWindow` as a lightweight fallback.
let mut focused_window_ptr: *const CFType = std::ptr::null();
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
let w_err = unsafe {
app_element.copy_attribute_value(
&focused_window_attr,
NonNull::new(&mut focused_window_ptr).unwrap(),
)
};
if w_err != AXError::Success || focused_window_ptr.is_null() {
return None;
}
focused_ui_ptr = focused_window_ptr;
}
}
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
let focused_ui: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(focused_ui_elem).unwrap()) };
// Prefer `AXSelectedText`; otherwise return None (can be extended to read ranges).
let mut selected_text_ptr: *const CFType = std::ptr::null();
let selected_text_attr = CFString::from_static_str("AXSelectedText");
let err = unsafe {
focused_ui.copy_attribute_value(
&selected_text_attr,
NonNull::new(&mut selected_text_ptr).unwrap(),
)
};
if err != AXError::Success || selected_text_ptr.is_null() {
return None;
}
// CFString → Rust String
let selected_cfstr: CFRetained<CFString> = unsafe {
CFRetained::from_raw(NonNull::new(selected_text_ptr.cast::<CFString>().cast_mut()).unwrap())
};
Some(selected_cfstr.to_string())
}
/// Read selected text with lightweight retries to handle transient AX focus instability.
#[cfg(target_os = "macos")]
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
use std::thread;
use std::time::Duration;
for attempt in 0..=retries {
if let Some(text) = read_selected_text() {
if !text.is_empty() {
if attempt > 0 {
log::info!(
"read_selected_text: 第{}次重试成功,获取到选中文本",
attempt
);
}
return Some(text);
}
}
if attempt < retries {
thread::sleep(Duration::from_millis(delay_ms));
}
}
None
}

View File

@@ -107,6 +107,12 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
})
.expect("failed to run this closure on the main thread");
// Start system-wide selection monitor (macOS-only currently)
#[cfg(target_os = "macos")]
{
crate::selection_monitor::start_selection_monitor(tauri_app_handle.clone());
}
crate::init(&tauri_app_handle).await;
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {

View File

@@ -78,6 +78,29 @@
"state": "active",
"radius": 7
}
},
{
"label": "selection",
"title": "Selection",
"alwaysOnTop": true,
"shadow": false,
"decorations": false,
"transparent": true,
"closable": true,
"minimizable": false,
"maximizable": false,
"dragDropEnabled": false,
"resizable": false,
"center": false,
"url": "/ui/selection",
"hiddenTitle": true,
"visible": false,
"acceptFirstMouse": true,
"windowEffects": {
"effects": [],
"state": "active",
"radius": 7
}
}
],
"security": {

View File

@@ -25,7 +25,8 @@ export const AssistantFetcher = ({
query?: string;
}) => {
try {
if (await unrequitable()) {
// Only gate by current window service when no explicit serverId provided.
if (!params.serverId && (await unrequitable())) {
return {
total: 0,
list: [],

View File

@@ -47,6 +47,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId;
});
const targetAssistantId = useSearchStore((state) => state.targetAssistantId);
const setTargetAssistantId = useSearchStore((state) => {
return state.setTargetAssistantId;
});
const { fetchAssistant } = AssistantFetcher({
debounceKeyword,
@@ -81,17 +85,22 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
useEffect(() => {
if (!askAiAssistantId || assistantList.length === 0) return;
const matched = assistantList.find((item) => {
return item._id === askAiAssistantId;
});
const targetId = askAiAssistantId ?? targetAssistantId;
if (!targetId || assistantList.length === 0) return;
const matched = assistantList.find((item) => item._id === targetId);
if (!matched) return;
setCurrentAssistant(matched);
setAskAiAssistantId(void 0);
}, [assistantList, askAiAssistantId]);
if (currentAssistant?._id !== matched._id) {
setCurrentAssistant(matched);
}
if (askAiAssistantId) {
setAskAiAssistantId(void 0);
} else if (targetAssistantId) {
setTargetAssistantId(void 0);
}
}, [assistantList, askAiAssistantId, targetAssistantId]);
useKeyPress(
["uparrow", "downarrow", "enter"],

View File

@@ -10,6 +10,7 @@ import {
} from "react";
import clsx from "clsx";
import { useMount, useMutationObserver } from "ahooks";
import { debounce } from "lodash-es";
import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox";
@@ -36,7 +37,7 @@ import {
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { POPOVER_PANEL_SELECTOR } from "@/constants";
import { useChatStore } from "@/stores/chatStore";
import { debounce } from "lodash-es";
import { useSearchStore } from "@/stores/searchStore";
interface SearchChatProps {
isTauri?: boolean;
@@ -314,6 +315,43 @@ 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") {
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();
}
});
return () => {
unlistenAsk.then((fn) => fn());
unlistenAction.then((fn) => fn());
};
}, []);
useEffect(() => {
if (isTauri) {
changeMode(defaultStartupWindow === "chatMode");

View File

@@ -0,0 +1,304 @@
import { useEffect, useRef, useState } from "react";
import {
GripVertical,
Bot,
Copy,
Languages,
Search,
Volume2,
FileText,
} 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<LucideIconName, any> = {
Search,
Bot,
Languages,
FileText,
Copy,
Volume2,
};
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
type AssistantCacheItem = {
list: any[];
updatedAt: number;
};
function loadAssistantCache(): Record<string, AssistantCacheItem> {
try {
const raw = localStorage.getItem(ASSISTANT_CACHE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
return {};
} catch {
return {};
}
}
function saveAssistantCache(cache: Record<string, AssistantCacheItem>) {
try {
localStorage.setItem(ASSISTANT_CACHE_KEY, JSON.stringify(cache));
} catch (e) {
console.error("Persist assistant cache failed:", e);
}
}
type ButtonsListProps = {
buttons: ButtonConfig[];
setButtons: React.Dispatch<React.SetStateAction<ButtonConfig[]>>;
serverList: any[];
};
const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
const { t } = useTranslation();
const { fetchAssistant } = AssistantFetcher({});
const [assistantByServer, setAssistantByServer] = useState<Record<string, any[]>>({});
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<Record<string, boolean>>({});
const [assistantCache, setAssistantCacheState] = useState<Record<string, AssistantCacheItem>>(() => loadAssistantCache());
const dragIndexRef = useRef<number | null>(null);
const initializedServiceRef = useRef<boolean>(false);
const onDragStart = (index: number) => {
dragIndexRef.current = index;
};
const onDrop = (index: number) => {
const from = dragIndexRef.current;
dragIndexRef.current = null;
if (from === null || from === index) return;
setButtons((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
return next;
});
};
const updateAction = (id: string, patch: Partial<ButtonConfig["action"]>) => {
setButtons((prev) => prev.map((b) => (b.id === id ? { ...b, action: { ...b.action, ...patch } } : b)));
};
const handleAssistantSelect = (btn: ButtonConfig, value: string) => {
const id = value || undefined;
updateAction(btn.id, { assistantId: id });
};
const handleServerSelect = async (btn: ButtonConfig, serverId: string) => {
const sid = serverId || undefined;
try {
const target = serverList.find((s: any) => s.id === sid);
if (target) {
await setCurrentWindowService(target);
}
} catch (e) {
console.error("setCurrentWindowService failed:", e);
}
updateAction(btn.id, { assistantServerId: sid, assistantId: undefined });
if (!sid) return;
const cached = assistantCache[sid];
if (cached && Array.isArray(cached.list)) {
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
}
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
try {
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() } };
setAssistantCacheState(nextCache);
saveAssistantCache(nextCache);
} catch (err) {
console.error("Fetch assistants for server failed:", err);
setAssistantByServer((prev) => ({ ...prev, [sid]: [] }));
} finally {
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
}
};
useEffect(() => {
if (initializedServiceRef.current) return;
initializedServiceRef.current = true;
const preferredSid =
buttons.find((b) => b.action.assistantServerId)?.action.assistantServerId ||
Object.keys(assistantCache)[0];
if (!preferredSid) return;
const target = serverList.find((s: any) => s.id === preferredSid);
if (!target) return;
setCurrentWindowService(target).catch((e) => {
console.error("init setCurrentWindowService failed:", e);
});
}, [serverList, buttons]);
useEffect(() => {
const uniqueServerIds = Array.from(
new Set(
buttons
.map((b) => b.action.assistantServerId)
.filter((sid): sid is string => Boolean(sid))
)
);
uniqueServerIds.forEach(async (sid) => {
if (!sid) return;
const cached = assistantCache[sid];
if (cached && Array.isArray(cached.list)) {
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
}
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
try {
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() } };
setAssistantCacheState(nextCache);
saveAssistantCache(nextCache);
} catch (err) {
console.error("Prefetch assistants for stored server failed:", err);
} finally {
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
}
});
}, [buttons]);
return (
<div className="space-y-3">
{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";
return (
<div
key={btn.id}
className={clsx(
"rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-white dark:bg-[#0B1220] shadow-sm",
"p-3"
)}
draggable
onDragStart={() => onDragStart(index)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(index)}
>
<div className="flex items-center gap-3">
<GripVertical className="size-4 text-[#64748B] shrink-0" />
{IconComp ? (
<IconComp className="size-4 shrink-0" style={{ color: btn.icon.color || "#6B7280" }} />
) : (
<img src={(btn.icon as any).dataUrl} alt="icon" className="w-4 h-4 rounded shrink-0" />
)}
<span className="text-sm font-medium">{btn.labelKey ? t(btn.labelKey) : btn.label}</span>
<span
className={clsx(
"ml-2 inline-flex items-center rounded px-2 py-0.5 text-xs",
visualType === "Chat"
? "bg-[#0287FF]/10 text-[#0287FF]"
: visualType === "Search"
? "bg-[#6366F1]/10 text-[#6366F1]"
: "bg-[#64748B]/10 text-[#64748B]"
)}
>
{visualType}
</span>
<div className="ml-auto flex items-center gap-2">
{isChat && (
<>
<select
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
value={btn.action.assistantServerId || ""}
onChange={(e) => handleServerSelect(btn, e.target.value)}
title={t("selection.bind.service")}
>
<option value="">{t("selection.bind.defaultService")}</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
{(() => {
const sid = btn.action.assistantServerId;
const list = (sid && assistantByServer[sid]) || [];
const loading = !!(sid && assistantLoadingByServer[sid]);
return (
<select
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
value={btn.action.assistantId || ""}
onChange={(e) => handleAssistantSelect(btn, e.target.value)}
title={t("selection.bind.assistant")}
disabled={loading}
>
<option value="">{t("selection.bind.defaultAssistant")}</option>
{loading && (
<option value="" disabled>
...
</option>
)}
{list.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
);
})()}
</>
)}
</div>
</div>
</div>
);
})}
</div>
);
};
export default ButtonsList;

View File

@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { Sparkles } from "lucide-react";
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";
const DEFAULT_CONFIG: ButtonConfig[] = [
{
id: "search",
label: "搜索",
labelKey: "selection.actions.search",
icon: { type: "lucide", name: "Search", color: "#6366F1" },
action: { type: "search" },
},
{
id: "ask_ai",
label: "问答",
labelKey: "selection.actions.ask_ai",
icon: { type: "lucide", name: "Bot", color: "#0287FF" },
action: { type: "ask_ai" },
},
{
id: "translate",
label: "翻译",
labelKey: "selection.actions.translate",
icon: { type: "lucide", name: "Languages", color: "#14B8A6" },
action: { type: "translate" },
},
{
id: "summary",
label: "总结",
labelKey: "selection.actions.summary",
icon: { type: "lucide", name: "FileText", color: "#0EA5E9" },
action: { type: "summary" },
},
{
id: "copy",
label: "复制",
labelKey: "selection.actions.copy",
icon: { type: "lucide", name: "Copy", color: "#64748B" },
action: { type: "copy" },
},
{
id: "speak",
label: "朗读",
labelKey: "selection.actions.speak",
icon: { type: "lucide", name: "Volume2", color: "#F59E0B" },
action: { type: "speak" },
},
];
const STORAGE_KEY = "selection_toolbar_config";
/**
* Utilities: load/save local toolbar config
*/
function loadToolbarConfig(): ButtonConfig[] {
try {
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;
} catch {
return DEFAULT_CONFIG;
}
}
function saveToolbarConfig(cfg: ButtonConfig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
}
/**
* Selection settings panel: toolbar buttons with sorting and assistant mapping
*/
const SelectionSettings = () => {
const { t } = useTranslation();
// Reactive service and assistant list
const { enabledServers: serverList } = useEnabledServers();
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
useEffect(() => {
useSelectionStore.getState().initSync();
}, []);
// Initialize from global store; write back on change for multi-window sync
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
const setToolbarConfig = useSelectionStore((s) => s.setToolbarConfig);
const [buttons, setButtons] = useState<ButtonConfig[]>(() =>
loadToolbarConfig()
);
useEffect(() => {
// prefer store config if present
if (Array.isArray(toolbarConfig) && toolbarConfig.length > 0) {
setButtons(toolbarConfig as ButtonConfig[]);
}
}, []);
useEffect(() => {
saveToolbarConfig(buttons);
setToolbarConfig(buttons); // push to store for multi-window
}, [buttons]);
return (
<div className="space-y-6 py-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
</div>
<SettingsItem
icon={Sparkles}
title={t("settings.ai.title")}
description={t("settings.ai.description")}
>
<SettingsToggle
checked={selectionEnabled}
onChange={async (value) => {
try {
await platformAdapter.invokeBackend("set_selection_enabled", {
enabled: value,
});
} catch (e) {
console.error("set_selection_enabled invoke failed:", e);
}
}}
label={t("settings.ai.toggle")}
/>
</SettingsItem>
{selectionEnabled && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SettingsItem
icon={Sparkles}
title={t("selection.display.title")}
description={t("selection.display.iconsOnlyDesc")}
>
<SettingsToggle
checked={iconsOnly}
onChange={async (value) => {
// Update local store
setIconsOnly(value);
}}
label={t("selection.display.iconsOnlyLabel")}
/>
</SettingsItem>
<ButtonsList buttons={buttons} setButtons={setButtons} serverList={serverList} />
</div>
)}
</div>
);
};
export default SelectionSettings;

View File

@@ -10,6 +10,7 @@ import {
Unplug,
} from "lucide-react";
import { useMount } from "ahooks";
import { isNil } from "lodash-es";
import Shortcuts from "./components/Shortcuts";
import SettingsItem from "../SettingsItem";
@@ -20,7 +21,7 @@ import SettingsInput from "@/components//Settings/SettingsInput";
import platformAdapter from "@/utils/platformAdapter";
import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
import { isNil } from "lodash-es";
import SelectionSettings from "./components/Selection";
const Advanced = () => {
const { t } = useTranslation();
@@ -189,6 +190,8 @@ const Advanced = () => {
})}
</div>
<SelectionSettings />
<Shortcuts />
<Appearance />

View File

@@ -18,6 +18,7 @@ import { isTauri } from "@tauri-apps/api/core";
import { isEnabled } from "@tauri-apps/plugin-autostart";
import { emit } from "@tauri-apps/api/event";
import { useCreation } from "ahooks";
import clsx from "clsx";
import SettingsItem from "./SettingsItem";
import SettingsToggle from "./SettingsToggle";
@@ -34,7 +35,6 @@ import {
unregister_shortcut,
} from "@/commands";
import platformAdapter from "@/utils/platformAdapter";
import clsx from "clsx";
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
export function ThemeOption({
@@ -83,6 +83,8 @@ export default function GeneralSettings() {
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
const { windowMode, setWindowMode } = useAppearanceStore();
const fetchAutoStartStatus = async () => {
if (isTauri()) {
try {
@@ -305,6 +307,8 @@ export default function GeneralSettings() {
})}
</div>
<SettingsItem
icon={Globe}
title={t("settings.language.title")}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,21 @@
import { useMemo } from "react";
import { useConnectStore } from "@/stores/connectStore";
import { getEnabledServers } from "@/utils/servers";
import { useServers } from "./useServers";
/**
* Hook: returns enabled & available servers, plus refresh function.
*/
export function useEnabledServers() {
const serverList = useConnectStore((s) => s.serverList);
const { refreshServerList } = useServers();
const enabledServers = useMemo(() => {
const list = getEnabledServers(serverList);
// Further filter to public servers or those with user profile (logged-in)
return list.filter((s) => s.public || s.profile);
}, [serverList]);
return { enabledServers, refreshServerList };
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "lodash-es";
export interface SelectionState {
text: string;
rect: DOMRect | null;
visible: boolean;
}
export function useSelectionPanel() {
const [state, setState] = useState<SelectionState>({
text: "",
rect: null,
visible: false,
});
const latestTextRef = useRef<string>("");
const computeRect = useCallback((): DOMRect | null => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) return null;
return rect;
}, []);
const updateSelection = useCallback(() => {
const sel = window.getSelection();
const text = sel?.toString().trim() ?? "";
const rect = computeRect();
if (!text || !rect) {
setState((prev) => ({ ...prev, visible: false }));
latestTextRef.current = "";
return;
}
// Suppress duplicates to avoid flicker and needless IPC
if (text === latestTextRef.current && state.visible) {
// Only reposition on scroll/resize
setState((prev) => ({ ...prev, rect }));
return;
}
latestTextRef.current = text;
setState({ text, rect, visible: true });
}, [computeRect, state.visible]);
useEffect(() => {
const onMouseUp = debounce(updateSelection, 50);
const onSelectionChange = debounce(updateSelection, 80);
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("mouseup", onMouseUp);
window.addEventListener("scroll", onSelectionChange, { passive: true });
window.addEventListener("resize", onSelectionChange);
return () => {
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("scroll", onSelectionChange);
window.removeEventListener("resize", onSelectionChange);
};
}, [updateSelection]);
const close = useCallback(() => {
setState({ text: "", rect: null, visible: false });
latestTextRef.current = "";
}, []);
return {
state,
close,
};
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useRef } from "react";
import platformAdapter from "@/utils/platformAdapter";
import { useSelectionStore } from "@/stores/selectionStore";
import { useSelectionPanel } from "@/hooks/useSelectionPanel";
export function useSelectionWindow() {
const { state: panelState, close: onClose } = useSelectionPanel();
const lastWidthRef = useRef<number | null>(null);
// Subscribe to store for reactive updates
const iconsOnly = useSelectionStore((s) => s.iconsOnly);
const selectionEnabled = useSelectionStore((s) => s.selectionEnabled);
const WIDTH_ICONS_ONLY = 250;
const WIDTH_FULL = 470;
const HEIGHT = 32;
const getSelectionWidth = (isIconsOnly: boolean) =>
isIconsOnly ? WIDTH_ICONS_ONLY : WIDTH_FULL;
useEffect(() => {
const openSelectionWindow = async (payload: any) => {
// console.log("[selection] openSelectionWindow payload", payload);
// when selection is disabled, hide the existing window and return
if (!selectionEnabled) {
const existing = await platformAdapter.getWindowByLabel("selection");
if (existing) {
await existing.hide();
}
return;
}
const label = "selection";
const width = getSelectionWidth(iconsOnly);
const height = HEIGHT;
const options: any = {
label,
title: "Selection",
width,
height,
alwaysOnTop: true,
shadow: false,
decorations: false,
transparent: true,
closable: true,
minimizable: false,
maximizable: false,
dragDropEnabled: false,
resizable: false,
center: false,
url: "/ui/selection",
windowEffects: {
effects: [],
state: "active",
radius: 7,
},
hiddenTitle: true,
visible: false,
acceptFirstMouse: true,
data: { timestamp: Date.now() },
};
const raw = typeof payload === "string" ? payload : String(payload?.text ?? "");
const text = raw.trim();
// Receive backend "top-left origin + logical coordinates (Quartz point)" directly, no need for dpr conversion
const xLogical = Math.round(Number(payload?.x ?? 0));
const yLogical = Math.round(Number(payload?.y ?? 0));
const existingWindow = await platformAdapter.getWindowByLabel(label);
// Empty text: hide existing window and emit empty event
if (!text) {
if (existingWindow) {
await existingWindow.hide();
}
await platformAdapter.emitEvent("selection-text", "");
return;
}
if (!existingWindow) {
await platformAdapter.createWindow(label, options);
}
const win = await platformAdapter.getWindowByLabel(label);
if (!win) return;
// Set window size to fixed width and height
// Avoid redundant setSize calls if width is unchanged
if (lastWidthRef.current !== width) {
// @ts-ignore
await win.setSize({ type: "Logical", width, height });
lastWidthRef.current = width;
}
await win.show();
// Position window based on "top-left origin + logical coordinates" directly
// X offset 0, Y offset -40px (not subtracting window height)
if (xLogical > 0 || yLogical > 0) {
const offsetX = 0;
const offsetY = 40;
const targetX = Math.max(0, xLogical + offsetX);
const targetY = Math.max(0, yLogical - offsetY);
// @ts-ignore
await win.setPosition({ type: "Logical", x: targetX, y: targetY });
}
await platformAdapter.emitEvent("selection-text", text);
};
// DOM fallback: when panel is visible and has text, use its position
if (panelState?.visible && panelState?.text) {
const rect = panelState.rect || null;
const screenX = window.screenX || 0; // CSS pixel (logical coordinate)
const screenY = window.screenY || 0; // CSS pixel (logical coordinate)
const xLogical = rect ? Math.round(screenX + rect.left) : 0;
const yLogical = rect ? Math.round(screenY + rect.top) : 0;
console.log("[selection] DOM fallback logical", { screenX, screenY, rect, xLogical, yLogical });
openSelectionWindow({ text: panelState.text, x: xLogical, y: yLogical });
onClose?.();
}
// Listen to selection-detected event from backend
const unlistenSelection = platformAdapter.listenEvent(
"selection-detected",
async (event: any) => {
const payload = event?.payload;
await openSelectionWindow(payload);
}
);
return () => {
unlistenSelection.then((fn) => fn());
};
}, [panelState?.visible, panelState?.text, onClose, iconsOnly, selectionEnabled]);
}

View File

@@ -9,6 +9,7 @@ import { isMac } from "@/utils/platform";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { show_coco, show_settings, show_check } from "@/commands";
import { useSelectionStore } from "@/stores/selectionStore";
const TRAY_ID = "COCO_TRAY";
@@ -16,11 +17,13 @@ export const useTray = () => {
const { t, i18n } = useTranslation();
const showCocoShortcuts = useAppStore((state) => state.showCocoShortcuts);
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
useUpdateEffect(() => {
if (showCocoShortcuts.length === 0) return;
updateTrayMenu();
}, [i18n.language, showCocoShortcuts]);
}, [i18n.language, showCocoShortcuts, selectionEnabled]);
const getTrayById = () => {
return TrayIcon.getById(TRAY_ID);
@@ -56,6 +59,18 @@ export const useTray = () => {
},
}),
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);
}
},
}),
MenuItem.new({
text: t("tray.settings"),
// accelerator: "CommandOrControl+,",

View File

@@ -23,6 +23,11 @@
"default": "Default",
"compact": "Compact"
},
"ai": {
"title": "Selection Toolbar",
"description": "Show selection toolbar after text selection",
"toggle": "Enable selection toolbar"
},
"language": {
"title": "Language",
"description": "Choose your preferred language",
@@ -534,6 +539,8 @@
},
"tray": {
"showCoco": "Show Coco",
"selectionDisable": "Disable Selection Toolbar",
"selectionEnable": "Enable Selection Toolbar",
"settings": "Settings",
"quitCoco": "Quit Coco",
"checkUpdate": "Check for Updates"
@@ -628,5 +635,42 @@
"buttons": {
"login": "Login"
}
},
"selection": {
"title": "Selection Toolbar Settings",
"actions": {
"search": "Search",
"ask_ai": "Q&A",
"translate": "Translate",
"summary": "Summary",
"copy": "Copy",
"speak": "Read Aloud"
},
"noText": "No text detected",
"copied": "Copied",
"speak": {
"stopTitle": "Stop speaking",
"stopAria": "Stop speaking",
"stopLabel": "Stop",
"pauseTitle": "Pause speaking",
"pauseAria": "Pause speaking",
"pauseLabel": "Pause",
"resumeTitle": "Resume speaking",
"resumeAria": "Resume speaking",
"resumeLabel": "Resume",
"volumeSr": "Volume",
"volumeAria": "Speech volume"
},
"display": {
"title": "Display Style",
"iconsOnlyDesc": "Show icons only (hide text labels)",
"iconsOnlyLabel": "Icons only"
},
"bind": {
"service": "Select Service",
"defaultService": "Default service",
"assistant": "Bind Assistant (Chat only)",
"defaultAssistant": "Default assistant"
}
}
}

View File

@@ -23,6 +23,11 @@
"default": "默认",
"compact": "紧凑"
},
"ai": {
"title": "划词工具栏",
"description": "选择文本后显示独立可操作窗口",
"toggle": "启用划词工具栏"
},
"language": {
"title": "语言",
"description": "选择您的首选语言",
@@ -534,6 +539,8 @@
},
"tray": {
"showCoco": "显示 Coco",
"selectionDisable": "禁用划词工具栏",
"selectionEnable": "启用划词工具栏",
"settings": "偏好设置",
"quitCoco": "退出 Coco",
"checkUpdate": "检查更新"
@@ -627,5 +634,42 @@
"buttons": {
"login": "登录"
}
},
"selection": {
"title": "划词工具栏设置",
"actions": {
"search": "搜索",
"ask_ai": "问答",
"translate": "翻译",
"summary": "总结",
"copy": "复制",
"speak": "朗读"
},
"noText": "未检测到文本",
"copied": "已复制",
"speak": {
"stopTitle": "停止朗读",
"stopAria": "停止朗读",
"stopLabel": "停止",
"pauseTitle": "暂停朗读",
"pauseAria": "暂停朗读",
"pauseLabel": "暂停",
"resumeTitle": "继续朗读",
"resumeAria": "继续朗读",
"resumeLabel": "继续",
"volumeSr": "音量",
"volumeAria": "朗读音量"
},
"display": {
"title": "显示样式",
"iconsOnlyDesc": "仅显示图标(隐藏文字标签)",
"iconsOnlyLabel": "仅显示图标"
},
"bind": {
"service": "选择服务",
"defaultService": "默认服务",
"assistant": "绑定小助手(仅聊天)",
"defaultAssistant": "默认小助手"
}
}
}

View File

@@ -0,0 +1,518 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Copy,
Languages,
Search,
X,
Volume2,
Pause,
Play,
FileText,
} 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";
// 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);
const containerRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [volume, setVolume] = useState(1);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
const textRef = useRef<string>("");
const AUTO_HIDE_KEY = "selection_auto_hide_ms";
const autoHideMs = useMemo(() => {
const v = Number(localStorage.getItem(AUTO_HIDE_KEY));
return Number.isFinite(v) && v > 0 ? v : 5000;
}, []);
const timerRef = useRef<number | null>(null);
const scheduleAutoHide = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
close();
}, autoHideMs);
};
useEffect(() => {
try {
const updateVoices = () => {
voicesRef.current = window.speechSynthesis.getVoices();
};
updateVoices();
window.speechSynthesis.onvoiceschanged = updateVoices;
} catch {}
const unlistenPromise = platformAdapter.listenEvent(
"selection-text",
async ({ payload }: any) => {
const incoming =
typeof payload === "string" ? payload : String(payload?.text ?? "");
const trimmed = incoming.trim();
const getCurrentWinSafe = async () => {
try {
return await platformAdapter.getCurrentWebviewWindow();
} catch {
return null;
}
};
if (!useSelectionStore.getState().selectionEnabled) {
setVisible(false);
const win = await getCurrentWinSafe();
win?.hide();
return;
}
if (!trimmed) {
setText("");
textRef.current = ""; // sync ref immediately to avoid stale value
setVisible(false);
const win = await getCurrentWinSafe();
win?.hide();
return;
}
setText(incoming);
textRef.current = incoming; // sync ref immediately to avoid relying on render
setAnimatingOut(false);
setVisible(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
scheduleAutoHide();
}
);
return () => {
unlistenPromise
.then((fn) => {
try {
fn();
} catch {}
})
.catch(() => {});
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [autoHideMs]);
useEffect(() => {
useSelectionStore.getState().initSync();
}, []);
const close = async () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
try {
window.speechSynthesis.cancel();
} catch {}
setIsSpeaking(false);
setIsPaused(false);
setAnimatingOut(true);
setTimeout(async () => {
setVisible(false);
const win = await platformAdapter.getCurrentWebviewWindow();
win?.hide();
}, 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) => {
const payloadText = (textRef.current || "").trim();
if (!payloadText) return;
await openMain();
await new Promise((r) => setTimeout(r, 120));
await platformAdapter.emitEvent("selection-action", {
action: "chat",
text: payloadText,
assistantId,
});
if (!isSpeaking) {
await close();
}
},
[openMain, isSpeaking, close]
);
const searchMain = useCallback(async () => {
const payloadText = (textRef.current || "").trim();
console.log("searchMain payload", payloadText);
if (!payloadText) return;
await openMain();
await new Promise((r) => setTimeout(r, 120));
await platformAdapter.emitEvent("selection-action", {
action: "search",
text: payloadText,
});
if (!isSpeaking) {
await close();
}
}, []);
const stopSpeak = () => {
window.speechSynthesis.cancel();
utteranceRef.current = null;
setIsSpeaking(false);
setIsPaused(false);
};
const speak = useCallback(async () => {
try {
const trimmed = text.trim();
if (!trimmed) return;
if (isSpeaking && !isPaused) {
window.speechSynthesis.pause();
setIsPaused(true);
return;
}
if (isSpeaking && isPaused) {
window.speechSynthesis.resume();
setIsPaused(false);
return;
}
const utterance = new SpeechSynthesisUtterance(trimmed);
const zhVoice =
voicesRef.current.find((v) => /zh|cn/i.test(v.lang)) ||
window.speechSynthesis.getVoices().find((v) => /zh|cn/i.test(v.lang));
if (zhVoice) utterance.voice = zhVoice;
utterance.rate = 1;
utterance.volume = volume;
// pause auto-hide while speaking
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
utterance.onend = () => {
setIsSpeaking(false);
setIsPaused(false);
utteranceRef.current = null;
scheduleAutoHide();
};
utterance.onerror = () => {
setIsSpeaking(false);
setIsPaused(false);
utteranceRef.current = null;
scheduleAutoHide();
};
utteranceRef.current = utterance;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
setIsSpeaking(true);
setIsPaused(false);
} catch (e) {
console.error("TTS 播放失败", e);
stopSpeak();
scheduleAutoHide();
}
}, [text]);
const handleCopy = useCallback(async () => {
const payloadText = (textRef.current || "").trim();
if (!payloadText) return;
try {
await copyToClipboard(payloadText, true);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.warn("Copy failed:", e);
}
}, []);
const getActionHandler = (type: string, assistantId?: string) => {
switch (type) {
case "ask_ai":
case "translate":
case "summary":
return () => handleChatAction(assistantId);
case "copy":
return handleCopy;
case "search":
return searchMain;
case "speak":
return speak;
default:
return () => {};
}
};
// 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 visibleButtons = useMemo(
() =>
(Array.isArray(toolbarConfig) ? toolbarConfig : []).filter((btn: any) => {
const type = btn?.action?.type;
if (requiresAssistant(type)) {
return Boolean(btn?.action?.assistantId);
}
return true;
}),
[toolbarConfig]
);
// Lucide icon map for dynamic rendering
const LUCIDE_ICON_MAP: Record<string, any> = {
Search,
Bot,
Languages,
FileText,
Copy,
Volume2,
};
// 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 (
<Icon
className="size-4 transition-transform duration-150"
style={icon?.color ? { color: icon.color } : undefined}
/>
);
}
if (icon?.type === "custom" && icon?.dataUrl) {
return (
<img
src={icon.dataUrl}
className="size-4 rounded"
alt=""
style={
icon?.color
? { filter: `drop-shadow(0 0 0 ${icon.color})` }
: undefined
}
/>
);
}
// default
return <Search className="size-4 text-[#6366F1]" />;
};
// Component: single toolbar button
const ToolbarButton = ({
btn,
onClick,
}: {
btn: any;
onClick: () => void;
}) => {
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
return (
<button
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
onClick={onClick}
title={label}
>
<IconRenderer icon={btn?.icon} />
{!iconsOnly && (
<span className="text-[12px] transition-opacity duration-150">
{label}
</span>
)}
</button>
);
};
// Component: header logo
const HeaderLogo = () => {
return (
<img
src={cocoLogoImg}
alt="Coco Logo"
className="w-6 h-6"
onClick={openMain}
onError={(e) => {
try {
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
} catch {}
}}
/>
);
};
// Component: selected text preview
const TextPreview = ({ text }: { text: string }) => {
return (
<div className="relative">
<div
data-tauri-drag-region="false"
className="rounded-md bg-black/5 dark:bg-white/5 px-2 py-1 leading-4 text-[12px] text-ellipsis whitespace-nowrap overflow-hidden"
>
{text || t("selection.noText")}
</div>
{copied && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-start pl-2">
<span className="px-2 py-1 rounded bg-black/75 text-white text-[12px]">
{t("selection.copied")}
</span>
</div>
)}
</div>
);
};
// Component: speak controls
const SpeakControls = () => {
return (
<div className="flex items-center gap-1">
<button
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
onClick={stopSpeak}
title={t("selection.speak.stopTitle")}
aria-label={t("selection.speak.stopAria")}
>
<X className="size-4 transition-transform duration-150" />
{!iconsOnly && (
<span className="text-[12px] transition-opacity duration-150">
{t("selection.speak.stopLabel")}
</span>
)}
</button>
<button
className="hidden items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
onClick={speak}
title={
isPaused
? t("selection.speak.resumeTitle")
: t("selection.speak.pauseTitle")
}
aria-pressed={isPaused}
aria-label={
isPaused
? t("selection.speak.resumeAria")
: t("selection.speak.pauseAria")
}
>
{isPaused ? (
<Play className="size-4 transition-transform duration-150" />
) : (
<Pause className="size-4 transition-transform duration-150" />
)}
{!iconsOnly && (
<span className="text-[12px] transition-opacity duration-150">
{isPaused
? t("selection.speak.resumeLabel")
: t("selection.speak.pauseLabel")}
</span>
)}
</button>
<label className="hidden items-center gap-1 text-[12px]">
<span className="sr-only">{t("selection.speak.volumeSr")}</span>
<input
type="range"
min={0}
max={1}
step={0.1}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
aria-label={t("selection.speak.volumeAria")}
/>
</label>
</div>
);
};
return (
<div
ref={containerRef}
onMouseDown={(e) => {
if (e.target === containerRef.current && !isSpeaking) {
close();
}
}}
className={clsx(
"m-0 p-0 w-full h-full overflow-hidden",
"text-[13px] select-none",
"bg-white dark:bg-[#1E293B]",
"text-[#111] dark:text-[#ddd]",
"rounded-xl",
"transition-all duration-150",
{
"translate-y-0": visible && !animatingOut,
"translate-y-1": !visible || animatingOut,
}
)}
>
<div className="hidden px-2 pt-1">
<TextPreview text={text} />
</div>
<div
data-tauri-drag-region="false"
className="flex items-center gap-1 p-1 flex-nowrap overflow-hidden"
>
<HeaderLogo />
<Separator
orientation="vertical"
decorative
className="mx-2 h-4 w-px bg-gray-300 dark:bg-white/30 shrink-0"
/>
{visibleButtons.map((btn: any) => {
const { type, assistantId } = btn?.action;
return (
<ToolbarButton
key={btn.id}
btn={btn}
onClick={getActionHandler(type, assistantId)}
/>
);
})}
{isSpeaking && <SpeakControls />}
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ 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 routerOptions = {
basename: "/",
@@ -27,6 +28,7 @@ export const router = createBrowserRouter(
{ path: "/ui/settings", element: <SettingsPage /> },
{ path: "/ui/chat", element: <StandaloneChat /> },
{ path: "/ui/check", element: <CheckPage /> },
{ path: "/ui/selection", element: <SelectionWindow /> },
{ path: "/web", element: <WebPage /> },
],
},

View File

@@ -17,6 +17,8 @@ import { Extension } from "@/components/Settings/Extensions";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { useServers } from "@/hooks/useServers";
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
import { useSelectionWindow } from "../hooks/useSelectionWindow";
import { useSelectionStore } from "@/stores/selectionStore";
export default function LayoutOutlet() {
const location = useLocation();
@@ -30,6 +32,27 @@ export default function LayoutOutlet() {
// init deep link manager
useDeepLinkManager();
// --- Selection state: init + subscribe backend as SSOT ---
useMount(async () => {
try {
const enabled = await platformAdapter.invokeBackend<boolean>("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]);
@@ -119,6 +142,9 @@ export default function LayoutOutlet() {
setDisabledExtensions(disabledExtensions.map((item) => item.id));
});
// --- Selection window ---
useSelectionWindow();
return (
<>
<Outlet />

View File

@@ -44,6 +44,8 @@ export type ISearchStore = {
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
askAiAssistantId?: string;
setAskAiAssistantId: (askAiAssistantId?: string) => void;
targetAssistantId?: string;
setTargetAssistantId: (targetAssistantId?: string) => void;
visibleExtensionStore: boolean;
setVisibleExtensionStore: (visibleExtensionStore: boolean) => void;
searchValue: string;
@@ -102,6 +104,9 @@ export const useSearchStore = create<ISearchStore>()(
setAskAiAssistantId: (askAiAssistantId) => {
return set({ askAiAssistantId });
},
setTargetAssistantId: (targetAssistantId) => {
return set({ targetAssistantId });
},
visibleExtensionStore: false,
setVisibleExtensionStore: (visibleExtensionStore) => {
return set({ visibleExtensionStore });

View File

@@ -0,0 +1,65 @@
import { create } from "zustand";
import { persist, subscribeWithSelector } from "zustand/middleware";
import platformAdapter from "@/utils/platformAdapter";
export type ISelectionStore = {
// whether selection is enabled
selectionEnabled: boolean;
setSelectionEnabled: (selectionEnabled: boolean) => void;
// toolbar buttons configuration for selection window
toolbarConfig: any[];
setToolbarConfig: (toolbarConfig: any[]) => void;
// whether to show icons only (hide labels) in selection window
iconsOnly: boolean;
setIconsOnly: (iconsOnly: boolean) => void;
// initialize cross-window sync listeners once
initSync: () => Promise<void>;
};
export const useSelectionStore = create<ISelectionStore>()(
subscribeWithSelector(
persist(
(set) => ({
selectionEnabled: false,
setSelectionEnabled(selectionEnabled) {
set({ selectionEnabled });
},
toolbarConfig: [],
setToolbarConfig(toolbarConfig) {
return set({ toolbarConfig });
},
iconsOnly: false,
setIconsOnly(iconsOnly) {
set({ iconsOnly });
// broadcast to other windows
try {
platformAdapter.emitEvent("selection-icons-only", { value: iconsOnly });
} catch {}
},
initSync: async () => {
// ensure listener only initialized once per window context
const hasInit = (window as any).__selectionIconsOnlyInit__;
if (hasInit) return;
(window as any).__selectionIconsOnlyInit__ = true;
try {
await platformAdapter.listenEvent(
"selection-icons-only",
({ payload }: any) => {
const next = Boolean(payload?.value);
// apply without re-broadcast to avoid echo
set({ iconsOnly: next });
}
);
} catch {}
},
}),
{
name: "selection-store",
partialize: (state) => ({
toolbarConfig: state.toolbarConfig,
iconsOnly: state.iconsOnly,
}),
}
)
)
);

View File

@@ -51,6 +51,15 @@ export interface EventPayloads {
extension_install_success: any;
open_view_extension: ViewExtensionOpened;
"server-list-changed": Server[];
"selection-text": string;
"selection-ask-ai": any;
"selection-action": {
action: "translate" | "search" | "copy" | "summary";
text: string;
};
"selection-detected": string;
"selection-enabled": boolean;
"selection-icons-only": { value: boolean };
}
// Window operation interface
@@ -65,6 +74,7 @@ export interface WindowOperations {
setFocus: () => Promise<void>;
center: () => Promise<void>;
close: () => Promise<void>;
hide: () => Promise<void>;
} | null>;
createWindow: (label: string, options: any) => Promise<void>;
createWebviewWindow: (label: string, options: any) => Promise<any>;

View File

@@ -10,7 +10,7 @@ import { getCurrentWindowService } from "@/commands/windowService";
import { useSearchStore } from "@/stores/searchStore";
import i18next from "i18next";
export async function copyToClipboard(text: string) {
export async function copyToClipboard(text: string, noTip = false) {
const addError = useAppStore.getState().addError;
const language = useAppStore.getState().language;
@@ -37,7 +37,7 @@ export async function copyToClipboard(text: string) {
document.body.removeChild(textArea);
}
addError(language === "zh" ? "复制成功" : "Copy Success", "info");
!noTip && addError(language === "zh" ? "复制成功" : "Copy Success", "info");
}
// 2

9
src/utils/servers.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Server } from "@/types/server";
/**
* Return enabled and available servers from a server list.
*/
export function getEnabledServers(list: Server[] | any[]): Server[] {
if (!Array.isArray(list)) return [] as Server[];
return (list as Server[]).filter((s) => s.enabled && s.available);
}