mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-15 19:17:42 +01:00
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<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
* 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 <stevelauc@outlook.com>
This commit is contained in:
@@ -15,6 +15,7 @@ Information about release notes of Coco App is provided here.
|
|||||||
|
|
||||||
- feat: add selection toolbar window for mac #980
|
- feat: add selection toolbar window for mac #980
|
||||||
- feat: add a heartbeat worker to check Coco server availability #988
|
- feat: add a heartbeat worker to check Coco server availability #988
|
||||||
|
- feat: selection settings add & delete #992
|
||||||
|
|
||||||
### 🐛 Bug fix
|
### 🐛 Bug fix
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.2",
|
"@headlessui/react": "^2.2.2",
|
||||||
|
"@infinilabs/custom-icons": "0.0.4",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@headlessui/react':
|
'@headlessui/react':
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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':
|
'@radix-ui/react-separator':
|
||||||
specifier: ^1.1.8
|
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)
|
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':
|
'@iconify/utils@3.0.2':
|
||||||
resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==}
|
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':
|
'@inquirer/checkbox@4.1.5':
|
||||||
resolution: {integrity: sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==}
|
resolution: {integrity: sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4278,6 +4288,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@inquirer/checkbox@4.1.5(@types/node@22.18.12)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 10.1.10(@types/node@22.18.12)
|
'@inquirer/core': 10.1.10(@types/node@22.18.12)
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ async fn change_window_height(handle: AppHandle, height: u32) {
|
|||||||
|
|
||||||
let outer_size = window.outer_size().unwrap();
|
let outer_size = window.outer_size().unwrap();
|
||||||
let window_width = outer_size.width as i32;
|
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;
|
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||||
|
|
||||||
|
|||||||
@@ -10,16 +10,42 @@ struct SelectionEventPayload {
|
|||||||
y: i32,
|
y: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
/// Global toggle: selection monitoring enabled by default.
|
/// Global toggle: selection monitoring enabled by default.
|
||||||
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
|
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<Mutex<Option<std::time::Instant>>> =
|
||||||
|
Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
#[derive(serde::Serialize, Clone)]
|
#[derive(serde::Serialize, Clone)]
|
||||||
struct SelectionEnabledPayload {
|
struct SelectionEnabledPayload {
|
||||||
enabled: bool,
|
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.
|
/// Read the current selection monitoring state.
|
||||||
pub fn is_selection_enabled() -> bool {
|
pub fn is_selection_enabled() -> bool {
|
||||||
SELECTION_ENABLED.load(Ordering::Relaxed)
|
SELECTION_ENABLED.load(Ordering::Relaxed)
|
||||||
@@ -28,6 +54,7 @@ pub fn is_selection_enabled() -> bool {
|
|||||||
/// Update the monitoring state and broadcast to the frontend.
|
/// Update the monitoring state and broadcast to the frontend.
|
||||||
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
|
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
|
||||||
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
|
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 });
|
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]
|
#[tauri::command]
|
||||||
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
|
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
|
||||||
set_selection_enabled_internal(&app_handle, enabled);
|
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.
|
/// Tauri command: get selection monitoring state.
|
||||||
@@ -46,7 +90,7 @@ pub fn get_selection_enabled() -> bool {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||||
// Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread.
|
// 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 std::time::Duration;
|
||||||
use tauri::Emitter;
|
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.
|
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
|
// If already started, don't start twice.
|
||||||
if !trusted_before {
|
if MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
|
||||||
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
|
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 !ensure_accessibility_permission(&app_handle) {
|
||||||
if !trusted_after {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[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.
|
// 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 || {
|
std::thread::spawn(move || {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use objc2_app_kit::NSWorkspace;
|
use objc2_app_kit::NSWorkspace;
|
||||||
@@ -108,13 +206,13 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
|||||||
return (x_top_left, y_flipped);
|
return (x_top_left, y_flipped);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut chosen = CGMainDisplayID(); // default fallback
|
let mut _chosen = CGMainDisplayID(); // default fallback
|
||||||
log::info!(
|
// log::info!(
|
||||||
"current_mouse: pt=({:.1},{:.1}) → display={}",
|
// "current_mouse: pt=({:.1},{:.1}) → display={}",
|
||||||
pt.x as f64,
|
// pt.x as f64,
|
||||||
pt.y as f64,
|
// pt.y as f64,
|
||||||
chosen
|
// chosen
|
||||||
);
|
// );
|
||||||
|
|
||||||
let mut min_x_pt = f64::INFINITY;
|
let mut min_x_pt = f64::INFINITY;
|
||||||
let mut max_top_pt = f64::NEG_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_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;
|
let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height;
|
||||||
if in_x && in_y {
|
if in_x && in_y {
|
||||||
chosen = did;
|
_chosen = did;
|
||||||
log::info!(
|
// log::info!(
|
||||||
"current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
|
// "current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
|
||||||
pt.x as f64,
|
// pt.x as f64,
|
||||||
pt.y as f64,
|
// pt.y as f64,
|
||||||
chosen,
|
// chosen,
|
||||||
b.origin.x,
|
// b.origin.x,
|
||||||
b.origin.y
|
// 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.
|
// 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.
|
// Lightweight retries to smooth out transient AX focus instability.
|
||||||
let selected_text = if front_is_me {
|
let selected_text = {
|
||||||
// Do not read selection during popup interaction to avoid false empty.
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
// Up to 2 retries, 35ms apart.
|
// Up to 2 retries, 35ms apart.
|
||||||
read_selected_text_with_retries(2, 35)
|
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.
|
// Update/show only when selection is stable to avoid flicker.
|
||||||
if stable_count >= stable_threshold {
|
if stable_count >= stable_threshold {
|
||||||
if !popup_visible || text != last_text {
|
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 (x, y) = current_mouse_point_global();
|
||||||
let payload = SelectionEventPayload {
|
let payload = SelectionEventPayload {
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
@@ -231,6 +345,9 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let _ = app_handle.emit("selection-detected", payload);
|
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;
|
last_text = text;
|
||||||
popup_visible = true;
|
popup_visible = true;
|
||||||
}
|
}
|
||||||
@@ -243,6 +360,7 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
|||||||
empty_count += 1;
|
empty_count += 1;
|
||||||
if popup_visible && empty_count >= empty_threshold {
|
if popup_visible && empty_count >= empty_threshold {
|
||||||
let _ = app_handle.emit("selection-detected", "");
|
let _ = app_handle.emit("selection-detected", "");
|
||||||
|
log::info!(target: "coco_lib::selection_monitor", "selection empty; hiding popup");
|
||||||
popup_visible = false;
|
popup_visible = false;
|
||||||
last_text.clear();
|
last_text.clear();
|
||||||
stable_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("<unknown>"));
|
||||||
|
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<PathBuf> = 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 <Info.plist> 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.
|
// macOS-wide accessibility entry point: allows reading system-level focused elements.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
unsafe extern "C" {
|
unsafe extern "C" {
|
||||||
fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement;
|
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<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.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().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).
|
/// 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.
|
/// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -357,10 +655,10 @@ fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String
|
|||||||
if let Some(text) = read_selected_text() {
|
if let Some(text) = read_selected_text() {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
log::info!(
|
// log::info!(
|
||||||
"read_selected_text: 第{}次重试成功,获取到选中文本",
|
// "read_selected_text: 第{}次重试成功,获取到选中文本",
|
||||||
attempt
|
// attempt
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
return Some(text);
|
return Some(text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [highlightId, setHighlightId] = useState<string>("");
|
const [highlightId, setHighlightId] = useState<string>("");
|
||||||
|
|
||||||
|
const targetServerId = useSearchStore((state) => {
|
||||||
|
return state.targetServerId;
|
||||||
|
});
|
||||||
|
const setTargetServerId = useSearchStore((state) => {
|
||||||
|
return state.setTargetServerId;
|
||||||
|
});
|
||||||
const askAiServerId = useSearchStore((state) => {
|
const askAiServerId = useSearchStore((state) => {
|
||||||
return state.askAiServerId;
|
return state.askAiServerId;
|
||||||
});
|
});
|
||||||
@@ -102,17 +108,20 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
}, [serverList]);
|
}, [serverList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!askAiServerId || serverList.length === 0) return;
|
const targetId = targetServerId ?? askAiServerId;
|
||||||
|
if (!targetId || list.length === 0) return;
|
||||||
const matched = serverList.find((server) => {
|
|
||||||
return server.id === askAiServerId;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const matched = list.find((server) => server.id === targetId);
|
||||||
if (!matched) return;
|
if (!matched) return;
|
||||||
|
|
||||||
switchServer(matched);
|
switchServer(matched);
|
||||||
setAskAiServerId(void 0);
|
setHighlightId(matched.id);
|
||||||
}, [serverList, askAiServerId]);
|
if (targetServerId) {
|
||||||
|
setTargetServerId(void 0);
|
||||||
|
} else {
|
||||||
|
setAskAiServerId(void 0);
|
||||||
|
}
|
||||||
|
}, [list, askAiServerId, targetServerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
@@ -291,4 +300,4 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -316,35 +316,50 @@ function SearchChat({
|
|||||||
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenAsk = platformAdapter.listenEvent("selection-ask-ai", ({ payload }: any) => {
|
const unlistenAsk = platformAdapter.listenEvent(
|
||||||
const value = typeof payload === "string" ? payload : String(payload?.text ?? "");
|
"selection-ask-ai",
|
||||||
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
({ payload }: any) => {
|
||||||
dispatch({ type: "SET_INPUT", payload: value });
|
const value =
|
||||||
platformAdapter.showWindow();
|
typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||||
});
|
|
||||||
|
|
||||||
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_CHAT_MODE", payload: true });
|
||||||
dispatch({ type: "SET_INPUT", payload: value });
|
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();
|
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 () => {
|
return () => {
|
||||||
unlistenAsk.then((fn) => fn());
|
unlistenAsk.then((fn) => fn());
|
||||||
|
|||||||
58
src/components/Selection/HeaderToolbar.tsx
Normal file
58
src/components/Selection/HeaderToolbar.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 flex-nowrap overflow-hidden ${className ?? ""}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={cocoLogoImg}
|
||||||
|
alt="Coco Logo"
|
||||||
|
className="w-6 h-6"
|
||||||
|
onClick={onLogoClick}
|
||||||
|
onError={(e) => {
|
||||||
|
try {
|
||||||
|
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
decorative
|
||||||
|
className="mx-1 h-4 w-px bg-gray-300 dark:bg-white/30 shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectionToolbar
|
||||||
|
buttons={buttons}
|
||||||
|
iconsOnly={iconsOnly}
|
||||||
|
onAction={onAction}
|
||||||
|
requireAssistantCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/Selection/Toolbar.tsx
Normal file
116
src/components/Selection/Toolbar.tsx
Normal file
@@ -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 (
|
||||||
|
<Comp
|
||||||
|
className="size-4 transition-transform duration-150"
|
||||||
|
// style={icon?.color ? { color: icon.color } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Search
|
||||||
|
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
|
||||||
|
// }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Search className="size-4 text-[#6366F1]" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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} />
|
||||||
|
{showLabel && (
|
||||||
|
<span className="text-[12px] transition-opacity duration-150">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-1 flex-nowrap overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleButtons.map((btn) => (
|
||||||
|
<ToolbarButton
|
||||||
|
key={btn.id}
|
||||||
|
btn={btn}
|
||||||
|
onClick={() => onAction(btn.action)}
|
||||||
|
showLabel={!iconsOnly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="inline-flex items-center gap-2 border border-dashed border-border hover:border-primary/50 hover:bg-secondary/50 text-muted-foreground transition-all duration-200"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t("selection.actions.addChat")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AddChatDialog
|
||||||
|
serverList={serverList}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onAdd={onAdd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<IconConfig["type"]>("lucide");
|
||||||
|
const [lucideName, setLucideName] = useState<string>("Bot");
|
||||||
|
const [color, setColor] = useState<string>("#0287FF");
|
||||||
|
const [dataUrl, setDataUrl] = useState<string>("");
|
||||||
|
const [serverId, setServerId] = useState<string>("");
|
||||||
|
const [assistantList, setAssistantList] = useState<any[]>([]);
|
||||||
|
const [assistantId, setAssistantId] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState<boolean>(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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold leading-none tracking-tight text-foreground">
|
||||||
|
{t("selection.custom.chat")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
|
{t("selection.bind.assistant")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("selection.custom.namePlaceholder")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full border border-[#E6E8EF] dark:border-[#2E3644]"
|
||||||
|
placeholder={t("selection.custom.namePlaceholder")}
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("selection.icon.pick")}
|
||||||
|
</label>
|
||||||
|
<IconPicker
|
||||||
|
initial={
|
||||||
|
iconType === "lucide"
|
||||||
|
? { type: "lucide", name: lucideName, color }
|
||||||
|
: { type: "custom", dataUrl, color }
|
||||||
|
}
|
||||||
|
onChange={applyIconConfig}
|
||||||
|
theme={activeTheme}
|
||||||
|
locale={currentLanguage}
|
||||||
|
showLibraryLink={false}
|
||||||
|
controls={{
|
||||||
|
color: false,
|
||||||
|
size: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("selection.bind.service")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
|
||||||
|
value={serverId}
|
||||||
|
onChange={(e) => setServerId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("selection.bind.defaultService")}
|
||||||
|
</option>
|
||||||
|
{serverList.map((s: any) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name || s.endpoint || s.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("selection.bind.assistant")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
|
||||||
|
value={assistantId}
|
||||||
|
onChange={(e) => setAssistantId(e.target.value)}
|
||||||
|
disabled={loading || !serverId}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{loading
|
||||||
|
? t("common.loading")
|
||||||
|
: t("selection.bind.defaultAssistant")}
|
||||||
|
</option>
|
||||||
|
{!loading &&
|
||||||
|
assistantList.map((a: any) => (
|
||||||
|
<option key={a._id} value={a._id}>
|
||||||
|
{a._source?.name || a._id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
{t("deleteDialog.button.cancel") ?? "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAdd}>
|
||||||
|
{t("settings.shortcut.save") ?? "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,61 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { GripVertical, Trash2 } from "lucide-react";
|
||||||
GripVertical,
|
|
||||||
Bot,
|
|
||||||
Copy,
|
|
||||||
Languages,
|
|
||||||
Search,
|
|
||||||
Volume2,
|
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
||||||
import { setCurrentWindowService } from "@/commands/windowService";
|
import { setCurrentWindowService } from "@/commands/windowService";
|
||||||
|
import { AddChatButton } from "./AddChatButton";
|
||||||
type ActionType =
|
import { ButtonConfig, resolveLucideIcon } from "./config";
|
||||||
| "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";
|
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
|
||||||
|
|
||||||
@@ -94,9 +45,23 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fetchAssistant } = AssistantFetcher({});
|
const { fetchAssistant } = AssistantFetcher({});
|
||||||
|
|
||||||
const [assistantByServer, setAssistantByServer] = useState<Record<string, any[]>>({});
|
const [assistantByServer, setAssistantByServer] = useState<
|
||||||
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<Record<string, boolean>>({});
|
Record<string, any[]>
|
||||||
const [assistantCache, setAssistantCacheState] = useState<Record<string, AssistantCacheItem>>(() => loadAssistantCache());
|
>({});
|
||||||
|
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const [assistantCache, setAssistantCacheState] = useState<
|
||||||
|
Record<string, AssistantCacheItem>
|
||||||
|
>(() => loadAssistantCache());
|
||||||
|
const BUILT_IN_IDS = new Set([
|
||||||
|
"search",
|
||||||
|
"ask_ai",
|
||||||
|
"translate",
|
||||||
|
"summary",
|
||||||
|
"copy",
|
||||||
|
"speak",
|
||||||
|
]);
|
||||||
|
|
||||||
const dragIndexRef = useRef<number | null>(null);
|
const dragIndexRef = useRef<number | null>(null);
|
||||||
const initializedServiceRef = useRef<boolean>(false);
|
const initializedServiceRef = useRef<boolean>(false);
|
||||||
@@ -118,7 +83,11 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateAction = (id: string, patch: Partial<ButtonConfig["action"]>) => {
|
const updateAction = (id: string, patch: Partial<ButtonConfig["action"]>) => {
|
||||||
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) => {
|
const handleAssistantSelect = (btn: ButtonConfig, value: string) => {
|
||||||
@@ -145,10 +114,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
}
|
}
|
||||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||||
try {
|
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 || [];
|
const list = data.list || [];
|
||||||
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||||
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
const nextCache = {
|
||||||
|
...assistantCache,
|
||||||
|
[sid]: { list, updatedAt: Date.now() },
|
||||||
|
};
|
||||||
setAssistantCacheState(nextCache);
|
setAssistantCacheState(nextCache);
|
||||||
saveAssistantCache(nextCache);
|
saveAssistantCache(nextCache);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -164,8 +140,8 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
initializedServiceRef.current = true;
|
initializedServiceRef.current = true;
|
||||||
|
|
||||||
const preferredSid =
|
const preferredSid =
|
||||||
buttons.find((b) => b.action.assistantServerId)?.action.assistantServerId ||
|
buttons.find((b) => b.action.assistantServerId)?.action
|
||||||
Object.keys(assistantCache)[0];
|
.assistantServerId || Object.keys(assistantCache)[0];
|
||||||
|
|
||||||
if (!preferredSid) return;
|
if (!preferredSid) return;
|
||||||
const target = serverList.find((s: any) => s.id === preferredSid);
|
const target = serverList.find((s: any) => s.id === preferredSid);
|
||||||
@@ -193,10 +169,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
}
|
}
|
||||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||||
try {
|
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 || [];
|
const list = data.list || [];
|
||||||
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||||
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
const nextCache = {
|
||||||
|
...assistantCache,
|
||||||
|
[sid]: { list, updatedAt: Date.now() },
|
||||||
|
};
|
||||||
setAssistantCacheState(nextCache);
|
setAssistantCacheState(nextCache);
|
||||||
saveAssistantCache(nextCache);
|
saveAssistantCache(nextCache);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -210,9 +193,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{buttons.map((btn, index) => {
|
{buttons.map((btn, index) => {
|
||||||
const IconComp = btn.icon.type === "lucide" ? LUCIDE_ICON_MAP[btn.icon.name] : null;
|
const IconComp =
|
||||||
const isChat = ["ask_ai", "translate", "summary"].includes(btn.action.type);
|
btn.icon.type === "lucide" ? resolveLucideIcon(btn.icon.name) : null;
|
||||||
const visualType: "Chat" | "Search" | "Tool" = isChat ? "Chat" : btn.action.type === "search" ? "Search" : "Tool";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -229,11 +220,20 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GripVertical className="size-4 text-[#64748B] shrink-0" />
|
<GripVertical className="size-4 text-[#64748B] shrink-0" />
|
||||||
{IconComp ? (
|
{IconComp ? (
|
||||||
<IconComp className="size-4 shrink-0" style={{ color: btn.icon.color || "#6B7280" }} />
|
<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" />
|
<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="text-sm font-medium">
|
||||||
|
{btn.labelKey ? t(btn.labelKey) : btn.label}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"ml-2 inline-flex items-center rounded px-2 py-0.5 text-xs",
|
"ml-2 inline-flex items-center rounded px-2 py-0.5 text-xs",
|
||||||
@@ -251,12 +251,14 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
{isChat && (
|
{isChat && (
|
||||||
<>
|
<>
|
||||||
<select
|
<select
|
||||||
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
||||||
value={btn.action.assistantServerId || ""}
|
value={btn.action.assistantServerId || ""}
|
||||||
onChange={(e) => handleServerSelect(btn, e.target.value)}
|
onChange={(e) => handleServerSelect(btn, e.target.value)}
|
||||||
title={t("selection.bind.service")}
|
title={t("selection.bind.service")}
|
||||||
>
|
>
|
||||||
<option value="">{t("selection.bind.defaultService")}</option>
|
<option value="">
|
||||||
|
{t("selection.bind.defaultService")}
|
||||||
|
</option>
|
||||||
{serverList.map((s: any) => (
|
{serverList.map((s: any) => (
|
||||||
<option key={s.id} value={s.id}>
|
<option key={s.id} value={s.id}>
|
||||||
{s.name || s.endpoint || s.id}
|
{s.name || s.endpoint || s.id}
|
||||||
@@ -270,16 +272,20 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
const loading = !!(sid && assistantLoadingByServer[sid]);
|
const loading = !!(sid && assistantLoadingByServer[sid]);
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
||||||
value={btn.action.assistantId || ""}
|
value={btn.action.assistantId || ""}
|
||||||
onChange={(e) => handleAssistantSelect(btn, e.target.value)}
|
onChange={(e) =>
|
||||||
|
handleAssistantSelect(btn, e.target.value)
|
||||||
|
}
|
||||||
title={t("selection.bind.assistant")}
|
title={t("selection.bind.assistant")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<option value="">{t("selection.bind.defaultAssistant")}</option>
|
<option value="">
|
||||||
|
{t("selection.bind.defaultAssistant")}
|
||||||
|
</option>
|
||||||
{loading && (
|
{loading && (
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
加载中...
|
{t("common.loading")}
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
{list.map((a: any) => (
|
{list.map((a: any) => (
|
||||||
@@ -292,13 +298,30 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
|||||||
})()}
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isBuiltIn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-transparent bg-red-50 text-red-600 hover:bg-red-100 p-1"
|
||||||
|
title={t("selection.actions.delete")}
|
||||||
|
aria-label={t("selection.actions.delete")}
|
||||||
|
onClick={() =>
|
||||||
|
setButtons((prev) => prev.filter((b) => b.id !== btn.id))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<AddChatButton
|
||||||
|
serverList={serverList}
|
||||||
|
onAdd={(newBtn) => setButtons((prev) => [...prev, newBtn])}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ButtonsList;
|
export default ButtonsList;
|
||||||
|
|||||||
@@ -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<string, any> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -5,48 +5,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useSelectionStore } from "@/stores/selectionStore";
|
import { useSelectionStore } from "@/stores/selectionStore";
|
||||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import { useEnabledServers } from "@/hooks/useEnabledServers";
|
import { useEnabledServers } from "@/hooks/useEnabledServers";
|
||||||
import ButtonsList from "./ButtonsList";
|
import ButtonsList from "./ButtonsList";
|
||||||
|
import HeaderToolbar from "@/components/Selection/HeaderToolbar";
|
||||||
/**
|
import { ButtonConfig } from "./config";
|
||||||
* 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[] = [
|
const DEFAULT_CONFIG: ButtonConfig[] = [
|
||||||
{
|
{
|
||||||
@@ -60,7 +22,7 @@ const DEFAULT_CONFIG: ButtonConfig[] = [
|
|||||||
id: "ask_ai",
|
id: "ask_ai",
|
||||||
label: "问答",
|
label: "问答",
|
||||||
labelKey: "selection.actions.ask_ai",
|
labelKey: "selection.actions.ask_ai",
|
||||||
icon: { type: "lucide", name: "Bot", color: "#0287FF" },
|
icon: { type: "lucide", name: "BotMessageSquare", color: "#0287FF" },
|
||||||
action: { type: "ask_ai" },
|
action: { type: "ask_ai" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -103,9 +65,11 @@ function loadToolbarConfig(): ButtonConfig[] {
|
|||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return DEFAULT_CONFIG;
|
if (!raw) return DEFAULT_CONFIG;
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (Array.isArray(parsed) && parsed.length > 0)
|
let cfg: ButtonConfig[] = Array.isArray(parsed) && parsed.length > 0 ? (parsed as ButtonConfig[]) : DEFAULT_CONFIG;
|
||||||
return parsed as ButtonConfig[];
|
// Lightweight migration: ensure ask_ai icon follows the updated default
|
||||||
return DEFAULT_CONFIG;
|
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 {
|
} catch {
|
||||||
return DEFAULT_CONFIG;
|
return DEFAULT_CONFIG;
|
||||||
}
|
}
|
||||||
@@ -125,6 +89,7 @@ const SelectionSettings = () => {
|
|||||||
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
|
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
|
||||||
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
|
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
|
||||||
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
|
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
|
||||||
|
const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
|
||||||
|
|
||||||
// Initialize from global store; write back on change for multi-window sync
|
// Initialize from global store; write back on change for multi-window sync
|
||||||
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||||
@@ -143,7 +108,7 @@ const SelectionSettings = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveToolbarConfig(buttons);
|
saveToolbarConfig(buttons);
|
||||||
setToolbarConfig(buttons); // push to store for multi-window
|
setToolbarConfig(buttons);
|
||||||
}, [buttons]);
|
}, [buttons]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -152,6 +117,24 @@ const SelectionSettings = () => {
|
|||||||
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
|
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative rounded-xl p-4 bg-gradient-to-r from-[#E6F0FA] to-[#FFF1F1]">
|
||||||
|
<div className="flex items-center flex-col" aria-hidden="true">
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white/70 shadow-sm dark:border-gray-700 dark:bg-gray-900/40">
|
||||||
|
<HeaderToolbar
|
||||||
|
buttons={buttons as any}
|
||||||
|
iconsOnly={iconsOnly}
|
||||||
|
onAction={() => {}}
|
||||||
|
onLogoClick={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-transparent cursor-not-allowed"
|
||||||
|
aria-label={t("selection.preview.readonly")}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon={Sparkles}
|
icon={Sparkles}
|
||||||
title={t("settings.ai.title")}
|
title={t("settings.ai.title")}
|
||||||
@@ -159,15 +142,7 @@ const SelectionSettings = () => {
|
|||||||
>
|
>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
checked={selectionEnabled}
|
checked={selectionEnabled}
|
||||||
onChange={async (value) => {
|
onChange={setSelectionEnabled}
|
||||||
try {
|
|
||||||
await platformAdapter.invokeBackend("set_selection_enabled", {
|
|
||||||
enabled: value,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("set_selection_enabled invoke failed:", e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
label={t("settings.ai.toggle")}
|
label={t("settings.ai.toggle")}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
@@ -188,7 +163,11 @@ const SelectionSettings = () => {
|
|||||||
label={t("selection.display.iconsOnlyLabel")}
|
label={t("selection.display.iconsOnlyLabel")}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
<ButtonsList buttons={buttons} setButtons={setButtons} serverList={serverList} />
|
<ButtonsList
|
||||||
|
buttons={buttons}
|
||||||
|
setButtons={setButtons}
|
||||||
|
serverList={serverList}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import platformAdapter from "@/utils/platformAdapter";
|
|||||||
import UpdateSettings from "./components/UpdateSettings";
|
import UpdateSettings from "./components/UpdateSettings";
|
||||||
import SettingsToggle from "../SettingsToggle";
|
import SettingsToggle from "../SettingsToggle";
|
||||||
import SelectionSettings from "./components/Selection";
|
import SelectionSettings from "./components/Selection";
|
||||||
|
import { isMac } from "@/utils/platform";
|
||||||
|
|
||||||
const Advanced = () => {
|
const Advanced = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -190,7 +191,7 @@ const Advanced = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectionSettings />
|
{isMac && <SelectionSettings />}
|
||||||
|
|
||||||
<Shortcuts />
|
<Shortcuts />
|
||||||
|
|
||||||
|
|||||||
26
src/hooks/useSelectionEnabled.ts
Normal file
26
src/hooks/useSelectionEnabled.ts
Normal file
@@ -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<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();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -50,52 +50,72 @@ export const useTray = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTrayMenu = async () => {
|
const getTrayMenu = async () => {
|
||||||
const items = await Promise.all([
|
const itemPromises: Promise<any>[] = [];
|
||||||
|
|
||||||
|
itemPromises.push(
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: t("tray.showCoco"),
|
text: t("tray.showCoco"),
|
||||||
accelerator: showCocoShortcuts.join("+"),
|
accelerator: showCocoShortcuts.join("+"),
|
||||||
action: () => {
|
action: () => {
|
||||||
show_coco();
|
show_coco();
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
);
|
||||||
MenuItem.new({
|
|
||||||
text: selectionEnabled
|
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
|
||||||
? t("tray.selectionDisable")
|
|
||||||
: t("tray.selectionEnable"),
|
if (isMac) {
|
||||||
action: async () => {
|
itemPromises.push(
|
||||||
try {
|
MenuItem.new({
|
||||||
await platformAdapter.invokeBackend("set_selection_enabled", { enabled: !selectionEnabled });
|
text: selectionEnabled
|
||||||
} catch (e) {
|
? t("tray.selectionDisable")
|
||||||
console.error("set_selection_enabled invoke failed:", e);
|
: 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({
|
MenuItem.new({
|
||||||
text: t("tray.settings"),
|
text: t("tray.settings"),
|
||||||
// accelerator: "CommandOrControl+,",
|
// accelerator: "CommandOrControl+,",
|
||||||
action: () => {
|
action: () => {
|
||||||
show_settings();
|
show_settings();
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
itemPromises.push(
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: t("tray.checkUpdate"),
|
text: t("tray.checkUpdate"),
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await show_check();
|
await show_check();
|
||||||
|
|
||||||
platformAdapter.emitEvent("check-update");
|
platformAdapter.emitEvent("check-update");
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
);
|
||||||
|
|
||||||
|
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
|
||||||
|
|
||||||
|
itemPromises.push(
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: t("tray.quitCoco"),
|
text: t("tray.quitCoco"),
|
||||||
accelerator: "CommandOrControl+Q",
|
accelerator: "CommandOrControl+Q",
|
||||||
action: () => {
|
action: () => {
|
||||||
exit(0);
|
exit(0);
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
]);
|
);
|
||||||
|
|
||||||
|
const items = await Promise.all(itemPromises);
|
||||||
return Menu.new({ items });
|
return Menu.new({ items });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -650,6 +650,10 @@
|
|||||||
"login": "Login"
|
"login": "Login"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"notFound": "Not found"
|
||||||
|
},
|
||||||
"selection": {
|
"selection": {
|
||||||
"title": "Selection Toolbar Settings",
|
"title": "Selection Toolbar Settings",
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -658,7 +662,9 @@
|
|||||||
"translate": "Translate",
|
"translate": "Translate",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"speak": "Read Aloud"
|
"speak": "Read Aloud",
|
||||||
|
"delete": "Delete",
|
||||||
|
"addChat": "Add Chat Button"
|
||||||
},
|
},
|
||||||
"noText": "No text detected",
|
"noText": "No text detected",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
@@ -683,8 +689,24 @@
|
|||||||
"bind": {
|
"bind": {
|
||||||
"service": "Select Service",
|
"service": "Select Service",
|
||||||
"defaultService": "Default service",
|
"defaultService": "Default service",
|
||||||
"assistant": "Bind Assistant (Chat only)",
|
"assistant": "Bind Assistant",
|
||||||
"defaultAssistant": "Default 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -649,6 +649,10 @@
|
|||||||
"login": "登录"
|
"login": "登录"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "加载中...",
|
||||||
|
"notFound": "未找到"
|
||||||
|
},
|
||||||
"selection": {
|
"selection": {
|
||||||
"title": "划词工具栏设置",
|
"title": "划词工具栏设置",
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -657,7 +661,9 @@
|
|||||||
"translate": "翻译",
|
"translate": "翻译",
|
||||||
"summary": "总结",
|
"summary": "总结",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"speak": "朗读"
|
"speak": "朗读",
|
||||||
|
"delete": "删除",
|
||||||
|
"addChat": "添加聊天功能"
|
||||||
},
|
},
|
||||||
"noText": "未检测到文本",
|
"noText": "未检测到文本",
|
||||||
"copied": "已复制",
|
"copied": "已复制",
|
||||||
@@ -682,8 +688,24 @@
|
|||||||
"bind": {
|
"bind": {
|
||||||
"service": "选择服务",
|
"service": "选择服务",
|
||||||
"defaultService": "默认服务",
|
"defaultService": "默认服务",
|
||||||
"assistant": "绑定小助手(仅聊天)",
|
"assistant": "绑定小助手",
|
||||||
"defaultAssistant": "默认小助手"
|
"defaultAssistant": "默认小助手"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"chat": "自定义聊天",
|
||||||
|
"namePlaceholder": "功能名称"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "图标类型",
|
||||||
|
"lucide": "Lucide",
|
||||||
|
"custom": "自定义",
|
||||||
|
"pick": "选择图标",
|
||||||
|
"color": "图标颜色",
|
||||||
|
"upload": "上传图标",
|
||||||
|
"alt": "图标"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"readonly": "预览仅供查看"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { X, Pause, Play } from "lucide-react";
|
||||||
Bot,
|
|
||||||
Copy,
|
|
||||||
Languages,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
Volume2,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Separator } from "@radix-ui/react-separator";
|
|
||||||
|
|
||||||
import { useSelectionStore } from "@/stores/selectionStore";
|
import { useSelectionStore } from "@/stores/selectionStore";
|
||||||
import { copyToClipboard } from "@/utils";
|
import { copyToClipboard } from "@/utils";
|
||||||
import cocoLogoImg from "@/assets/app-icon.png";
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
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
|
// Simple animated selection window content
|
||||||
export default function SelectionWindow() {
|
export default function SelectionWindow() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [animatingOut, setAnimatingOut] = useState(false);
|
const [animatingOut, setAnimatingOut] = useState(false);
|
||||||
@@ -137,34 +127,27 @@ export default function SelectionWindow() {
|
|||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openMain = async () => {
|
|
||||||
try {
|
|
||||||
await platformAdapter.commands("show_coco");
|
|
||||||
} catch {
|
|
||||||
await platformAdapter.emitEvent("show-coco");
|
|
||||||
await platformAdapter.showWindow();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChatAction = useCallback(
|
const handleChatAction = useCallback(
|
||||||
async (assistantId?: string) => {
|
async (action: ActionConfig) => {
|
||||||
const payloadText = (textRef.current || "").trim();
|
const payloadText = (textRef.current || "").trim();
|
||||||
|
|
||||||
if (!payloadText) return;
|
if (!payloadText) return;
|
||||||
|
|
||||||
await openMain();
|
await show_coco();
|
||||||
await new Promise((r) => setTimeout(r, 120));
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
|
||||||
await platformAdapter.emitEvent("selection-action", {
|
await platformAdapter.emitEvent("selection-action", {
|
||||||
action: "chat",
|
action: "chat",
|
||||||
text: payloadText,
|
text: payloadText,
|
||||||
assistantId,
|
assistantId: action.assistantId,
|
||||||
|
serverId: action.assistantServerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isSpeaking) {
|
if (!isSpeaking) {
|
||||||
await close();
|
await close();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[openMain, isSpeaking, close]
|
[isSpeaking, close]
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchMain = useCallback(async () => {
|
const searchMain = useCallback(async () => {
|
||||||
@@ -172,7 +155,7 @@ export default function SelectionWindow() {
|
|||||||
console.log("searchMain payload", payloadText);
|
console.log("searchMain payload", payloadText);
|
||||||
if (!payloadText) return;
|
if (!payloadText) return;
|
||||||
|
|
||||||
await openMain();
|
await show_coco();
|
||||||
await new Promise((r) => setTimeout(r, 120));
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
await platformAdapter.emitEvent("selection-action", {
|
await platformAdapter.emitEvent("selection-action", {
|
||||||
action: "search",
|
action: "search",
|
||||||
@@ -239,7 +222,7 @@ export default function SelectionWindow() {
|
|||||||
setIsSpeaking(true);
|
setIsSpeaking(true);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("TTS 播放失败", e);
|
console.error("TTS play failed:", e);
|
||||||
stopSpeak();
|
stopSpeak();
|
||||||
scheduleAutoHide();
|
scheduleAutoHide();
|
||||||
}
|
}
|
||||||
@@ -258,124 +241,88 @@ export default function SelectionWindow() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getActionHandler = (type: string, assistantId?: string) => {
|
const getActionHandler = (action: ActionConfig) => {
|
||||||
switch (type) {
|
switch (action.type) {
|
||||||
case "ask_ai":
|
case "ask_ai":
|
||||||
case "translate":
|
case "translate":
|
||||||
case "summary":
|
case "summary":
|
||||||
return () => handleChatAction(assistantId);
|
handleChatAction(action);
|
||||||
|
break;
|
||||||
case "copy":
|
case "copy":
|
||||||
return handleCopy;
|
handleCopy();
|
||||||
|
break;
|
||||||
case "search":
|
case "search":
|
||||||
return searchMain;
|
searchMain();
|
||||||
|
break;
|
||||||
case "speak":
|
case "speak":
|
||||||
return speak;
|
speak();
|
||||||
|
break;
|
||||||
default:
|
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 iconsOnly = useSelectionStore((s) => s.iconsOnly);
|
||||||
|
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||||
const requiresAssistant = (type?: string) =>
|
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
type === "ask_ai" || type === "translate" || type === "summary";
|
|
||||||
|
|
||||||
const visibleButtons = useMemo(
|
const visibleButtons = useMemo(
|
||||||
() =>
|
() => (Array.isArray(toolbarConfig) ? toolbarConfig : []),
|
||||||
(Array.isArray(toolbarConfig) ? toolbarConfig : []).filter((btn: any) => {
|
|
||||||
const type = btn?.action?.type;
|
|
||||||
if (requiresAssistant(type)) {
|
|
||||||
return Boolean(btn?.action?.assistantId);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
[toolbarConfig]
|
[toolbarConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lucide icon map for dynamic rendering
|
// Resize window width to fit toolbar content (sum child widths to avoid feedback growth)
|
||||||
const LUCIDE_ICON_MAP: Record<string, any> = {
|
const resizeToContentWidth = useCallback(async () => {
|
||||||
Search,
|
try {
|
||||||
Bot,
|
if (!visible) return;
|
||||||
Languages,
|
const el = toolbarRef.current;
|
||||||
FileText,
|
if (!el) return;
|
||||||
Copy,
|
|
||||||
Volume2,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Component: render icon (lucide or custom)
|
// Robust intrinsic width measurement: clone offscreen to avoid flex shrink/grow feedback.
|
||||||
const IconRenderer = ({ icon }: { icon?: any }) => {
|
const clone = el.cloneNode(true) as HTMLElement;
|
||||||
// Support lucide icon or custom image
|
clone.style.position = "absolute";
|
||||||
if (icon?.type === "lucide") {
|
clone.style.visibility = "hidden";
|
||||||
const Icon =
|
clone.style.left = "-10000px";
|
||||||
LUCIDE_ICON_MAP[icon?.name as string] || LUCIDE_ICON_MAP.Search;
|
clone.style.top = "0";
|
||||||
return (
|
clone.style.width = "auto";
|
||||||
<Icon
|
clone.style.maxWidth = "none";
|
||||||
className="size-4 transition-transform duration-150"
|
clone.style.overflow = "visible";
|
||||||
style={icon?.color ? { color: icon.color } : undefined}
|
// 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 (
|
|
||||||
<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 win = await platformAdapter.getCurrentWebviewWindow();
|
||||||
const ToolbarButton = ({
|
const size = await win.innerSize();
|
||||||
btn,
|
// Only update width; preserve current height
|
||||||
onClick,
|
const currentWidth = size.width;
|
||||||
}: {
|
if (Math.abs(currentWidth - desiredWidth) >= 2) {
|
||||||
btn: any;
|
await platformAdapter.setWindowSize(desiredWidth, 32);
|
||||||
onClick: () => void;
|
}
|
||||||
}) => {
|
} catch (e) {
|
||||||
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
|
console.warn("resizeToContentWidth failed:", e);
|
||||||
return (
|
}
|
||||||
<button
|
}, [visible, iconsOnly]);
|
||||||
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
|
// Recalculate on relevant changes (buttons, labels, speaking state, visibility)
|
||||||
const HeaderLogo = () => {
|
useEffect(() => {
|
||||||
return (
|
if (!visible) return;
|
||||||
<img
|
// Ensure DOM updated before measure
|
||||||
src={cocoLogoImg}
|
const id = window.requestAnimationFrame(() => {
|
||||||
alt="Coco Logo"
|
resizeToContentWidth();
|
||||||
className="w-6 h-6"
|
});
|
||||||
onClick={openMain}
|
return () => cancelAnimationFrame(id);
|
||||||
onError={(e) => {
|
}, [visibleButtons, iconsOnly, isSpeaking, visible, resizeToContentWidth]);
|
||||||
try {
|
|
||||||
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
|
|
||||||
} catch {}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Component: selected text preview
|
// Component: selected text preview
|
||||||
const TextPreview = ({ text }: { text: string }) => {
|
const TextPreview = ({ text }: { text: string }) => {
|
||||||
@@ -484,31 +431,15 @@ export default function SelectionWindow() {
|
|||||||
<TextPreview text={text} />
|
<TextPreview text={text} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<HeaderToolbar
|
||||||
data-tauri-drag-region="false"
|
buttons={visibleButtons as any}
|
||||||
className="flex items-center gap-1 p-1 flex-nowrap overflow-hidden"
|
iconsOnly={iconsOnly}
|
||||||
|
onAction={getActionHandler}
|
||||||
|
onLogoClick={show_coco}
|
||||||
|
rootRef={toolbarRef}
|
||||||
>
|
>
|
||||||
<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 />}
|
{isSpeaking && <SpeakControls />}
|
||||||
</div>
|
</HeaderToolbar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { createBrowserRouter } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
import { Suspense, lazy } from "react";
|
||||||
|
|
||||||
import Layout from "./layout";
|
import Layout from "./layout";
|
||||||
import ErrorPage from "@/pages/error/index";
|
import ErrorPage from "@/pages/error/index";
|
||||||
import DesktopApp from "@/pages/main/index";
|
|
||||||
import SettingsPage from "@/pages/settings/index";
|
const DesktopApp = lazy(() => import("@/pages/main/index"));
|
||||||
import StandaloneChat from "@/pages/chat/index";
|
const SettingsPage = lazy(() => import("@/pages/settings/index"));
|
||||||
import WebPage from "@/pages/web/index";
|
const StandaloneChat = lazy(() => import("@/pages/chat/index"));
|
||||||
import CheckPage from "@/pages/check/index";
|
const WebPage = lazy(() => import("@/pages/web/index"));
|
||||||
import SelectionWindow from "@/pages/selection/index";
|
const CheckPage = lazy(() => import("@/pages/check/index"));
|
||||||
|
const SelectionWindow = lazy(() => import("@/pages/selection/index"));
|
||||||
|
|
||||||
const routerOptions = {
|
const routerOptions = {
|
||||||
basename: "/",
|
basename: "/",
|
||||||
@@ -24,12 +26,12 @@ export const router = createBrowserRouter(
|
|||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
errorElement: <ErrorPage />,
|
errorElement: <ErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{ path: "/ui", element: <DesktopApp /> },
|
{ path: "/ui", element: (<Suspense fallback={<></>}><DesktopApp /></Suspense>) },
|
||||||
{ path: "/ui/settings", element: <SettingsPage /> },
|
{ path: "/ui/settings", element: (<Suspense fallback={<></>}><SettingsPage /></Suspense>) },
|
||||||
{ path: "/ui/chat", element: <StandaloneChat /> },
|
{ path: "/ui/chat", element: (<Suspense fallback={<></>}><StandaloneChat /></Suspense>) },
|
||||||
{ path: "/ui/check", element: <CheckPage /> },
|
{ path: "/ui/check", element: (<Suspense fallback={<></>}><CheckPage /></Suspense>) },
|
||||||
{ path: "/ui/selection", element: <SelectionWindow /> },
|
{ path: "/ui/selection", element: (<Suspense fallback={<></>}><SelectionWindow /></Suspense>) },
|
||||||
{ path: "/web", element: <WebPage /> },
|
{ path: "/web", element: (<Suspense fallback={<></>}><WebPage /></Suspense>) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
|
|||||||
import { useSelectionStore } from "@/stores/selectionStore";
|
import { useSelectionStore } from "@/stores/selectionStore";
|
||||||
import { useServers } from "@/hooks/useServers";
|
import { useServers } from "@/hooks/useServers";
|
||||||
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
|
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
|
||||||
import { useSelectionWindow } from "../hooks/useSelectionWindow";
|
import { useSelectionWindow } from "@/hooks/useSelectionWindow";
|
||||||
|
|
||||||
export default function LayoutOutlet() {
|
export default function LayoutOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -35,27 +35,6 @@ export default function LayoutOutlet() {
|
|||||||
// init deep link manager
|
// init deep link manager
|
||||||
useDeepLinkManager();
|
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(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(language);
|
i18n.changeLanguage(language);
|
||||||
}, [language]);
|
}, [language]);
|
||||||
@@ -154,4 +133,4 @@ export default function LayoutOutlet() {
|
|||||||
<ErrorNotification />
|
<ErrorNotification />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,8 @@ export type ISearchStore = {
|
|||||||
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
|
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
|
||||||
askAiAssistantId?: string;
|
askAiAssistantId?: string;
|
||||||
setAskAiAssistantId: (askAiAssistantId?: string) => void;
|
setAskAiAssistantId: (askAiAssistantId?: string) => void;
|
||||||
|
targetServerId?: string;
|
||||||
|
setTargetServerId: (targetServerId?: string) => void;
|
||||||
targetAssistantId?: string;
|
targetAssistantId?: string;
|
||||||
setTargetAssistantId: (targetAssistantId?: string) => void;
|
setTargetAssistantId: (targetAssistantId?: string) => void;
|
||||||
visibleExtensionStore: boolean;
|
visibleExtensionStore: boolean;
|
||||||
@@ -104,6 +106,9 @@ export const useSearchStore = create<ISearchStore>()(
|
|||||||
setAskAiAssistantId: (askAiAssistantId) => {
|
setAskAiAssistantId: (askAiAssistantId) => {
|
||||||
return set({ askAiAssistantId });
|
return set({ askAiAssistantId });
|
||||||
},
|
},
|
||||||
|
setTargetServerId: (targetServerId) => {
|
||||||
|
return set({ targetServerId });
|
||||||
|
},
|
||||||
setTargetAssistantId: (targetAssistantId) => {
|
setTargetAssistantId: (targetAssistantId) => {
|
||||||
return set({ targetAssistantId });
|
return set({ targetAssistantId });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,34 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { createTauriStore } from '@tauri-store/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 IconConfig =
|
||||||
| { type: "lucide"; name: LucideIconName; color?: string }
|
| { type: "lucide"; name: string; color?: string }
|
||||||
| { type: "custom"; dataUrl: string; color?: string };
|
| { type: "custom"; dataUrl: string; color?: string };
|
||||||
|
|
||||||
type ActionType =
|
|
||||||
| "search"
|
|
||||||
| "ask_ai"
|
|
||||||
| "translate"
|
|
||||||
| "summary"
|
|
||||||
| "copy"
|
|
||||||
| "speak"
|
|
||||||
| "custom";
|
|
||||||
|
|
||||||
export type ButtonConfig = {
|
export type ButtonConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: IconConfig;
|
icon: IconConfig;
|
||||||
action: {
|
action: {
|
||||||
type: ActionType;
|
type: string;
|
||||||
assistantId?: string;
|
assistantId?: string;
|
||||||
assistantServerId?: string;
|
assistantServerId?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
|
|||||||
@@ -53,13 +53,10 @@ export interface EventPayloads {
|
|||||||
"server-list-changed": Server[];
|
"server-list-changed": Server[];
|
||||||
"selection-text": string;
|
"selection-text": string;
|
||||||
"selection-ask-ai": any;
|
"selection-ask-ai": any;
|
||||||
"selection-action": {
|
"selection-action": any;
|
||||||
action: "translate" | "search" | "copy" | "summary";
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
"selection-detected": string;
|
"selection-detected": string;
|
||||||
"selection-enabled": boolean;
|
"selection-enabled": boolean;
|
||||||
"selection-icons-only": { value: boolean };
|
"change-selection-store": any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window operation interface
|
// Window operation interface
|
||||||
|
|||||||
@@ -277,4 +277,4 @@ export const createWebAdapter = (): WebPlatformAdapter => {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -87,10 +87,20 @@ export default defineConfig(async () => ({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
vendor: ["react", "react-dom"],
|
react: ["react", "react-dom"],
|
||||||
katex: ["rehype-katex"],
|
router: ["react-router-dom"],
|
||||||
highlight: ["rehype-highlight"],
|
markdown: [
|
||||||
|
"react-markdown",
|
||||||
|
"remark-gfm",
|
||||||
|
"remark-breaks",
|
||||||
|
"remark-math",
|
||||||
|
"rehype-highlight",
|
||||||
|
"rehype-katex",
|
||||||
|
"mdast-util-gfm-autolink-literal",
|
||||||
|
],
|
||||||
mermaid: ["mermaid"],
|
mermaid: ["mermaid"],
|
||||||
|
icons: ["lucide-react", "@infinilabs/custom-icons"],
|
||||||
|
utils: ["lodash-es", "dayjs", "uuid", "nanoid", "axios"],
|
||||||
"tauri-api": [
|
"tauri-api": [
|
||||||
"@tauri-apps/api/core",
|
"@tauri-apps/api/core",
|
||||||
"@tauri-apps/api/event",
|
"@tauri-apps/api/event",
|
||||||
|
|||||||
Reference in New Issue
Block a user