mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-14 18:47: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 a heartbeat worker to check Coco server availability #988
|
||||
- feat: selection settings add & delete #992
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@infinilabs/custom-icons": "0.0.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@headlessui/react':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@infinilabs/custom-icons':
|
||||
specifier: 0.0.4
|
||||
version: 0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -700,6 +703,13 @@ packages:
|
||||
'@iconify/utils@3.0.2':
|
||||
resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==}
|
||||
|
||||
'@infinilabs/custom-icons@0.0.4':
|
||||
resolution: {integrity: sha512-Oz5i06qW5a3wMfbJIZ5LESyO4V9p8njdoKnb/2Mf98ttRjTIedTXqrIWxLz8Tz84OTLi7mKswEQ71lAGeSqlug==}
|
||||
peerDependencies:
|
||||
lucide-react: '>=0.454.0'
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
'@inquirer/checkbox@4.1.5':
|
||||
resolution: {integrity: sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4278,6 +4288,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@infinilabs/custom-icons@0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
lucide-react: 0.461.0(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@inquirer/checkbox@4.1.5(@types/node@22.18.12)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.10(@types/node@22.18.12)
|
||||
|
||||
@@ -65,7 +65,6 @@ async fn change_window_height(handle: AppHandle, height: u32) {
|
||||
|
||||
let outer_size = window.outer_size().unwrap();
|
||||
let window_width = outer_size.width as i32;
|
||||
let window_height = outer_size.height as i32;
|
||||
|
||||
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||
|
||||
|
||||
@@ -10,16 +10,42 @@ struct SelectionEventPayload {
|
||||
y: i32,
|
||||
}
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Global toggle: selection monitoring enabled by default.
|
||||
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Ensure we only start the monitor thread once. Allows delayed start after
|
||||
/// Accessibility permission is granted post-launch.
|
||||
static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Guard to avoid spawning multiple permission watcher threads.
|
||||
#[cfg(target_os = "macos")]
|
||||
static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Session flags for controlling macOS Accessibility prompts.
|
||||
#[cfg(target_os = "macos")]
|
||||
static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false);
|
||||
#[cfg(target_os = "macos")]
|
||||
static LAST_ACCESSIBILITY_PROMPT: Lazy<Mutex<Option<std::time::Instant>>> =
|
||||
Lazy::new(|| Mutex::new(None));
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct SelectionEnabledPayload {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct SelectionPermissionInfo {
|
||||
bundle_id: String,
|
||||
exe_path: String,
|
||||
in_applications: bool,
|
||||
is_dmg: bool,
|
||||
is_dev_guess: bool,
|
||||
}
|
||||
|
||||
/// Read the current selection monitoring state.
|
||||
pub fn is_selection_enabled() -> bool {
|
||||
SELECTION_ENABLED.load(Ordering::Relaxed)
|
||||
@@ -28,6 +54,7 @@ pub fn is_selection_enabled() -> bool {
|
||||
/// Update the monitoring state and broadcast to the frontend.
|
||||
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
|
||||
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
log::info!(target: "coco_lib::selection_monitor", "selection monitoring toggled: enabled={}", enabled);
|
||||
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
|
||||
}
|
||||
|
||||
@@ -35,6 +62,23 @@ fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool)
|
||||
#[tauri::command]
|
||||
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
|
||||
set_selection_enabled_internal(&app_handle, enabled);
|
||||
|
||||
// When enabling selection monitoring on macOS, ensure Accessibility permission.
|
||||
// If not granted, trigger system prompt and deep-link to the right settings pane,
|
||||
// and notify frontend to guide the user.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if enabled {
|
||||
let trusted = ensure_accessibility_permission(&app_handle);
|
||||
// If permission is now trusted and the monitor hasn't started yet,
|
||||
// start it immediately to avoid requiring an app restart.
|
||||
if trusted && !MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
|
||||
log::info!(target: "coco_lib::selection_monitor", "set_selection_enabled: permission trusted; starting monitor thread");
|
||||
start_selection_monitor(app_handle.clone());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command: get selection monitoring state.
|
||||
@@ -46,7 +90,7 @@ pub fn get_selection_enabled() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
// Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread.
|
||||
log::info!("start_selection_monitor: 入口函数启动");
|
||||
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: entrypoint");
|
||||
use std::time::Duration;
|
||||
use tauri::Emitter;
|
||||
|
||||
@@ -57,21 +101,75 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
|
||||
if !trusted_before {
|
||||
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
|
||||
// If already started, don't start twice.
|
||||
if MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
|
||||
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: already started; skipping");
|
||||
return;
|
||||
}
|
||||
let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
|
||||
if !trusted_after {
|
||||
if !ensure_accessibility_permission(&app_handle) {
|
||||
log::warn!(target: "coco_lib::selection_monitor", "start_selection_monitor: accessibility not granted; deferring watcher start");
|
||||
|
||||
// Spawn a short-lived permission watcher to auto-start once the user grants.
|
||||
if !PERMISSION_WATCHER_STARTED.swap(true, Ordering::Relaxed) {
|
||||
let app_handle_clone = app_handle.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("selection-permission-watcher".into())
|
||||
.spawn(move || {
|
||||
use std::time::Duration;
|
||||
// Persistent polling with gentle backoff: checks every 2s for ~1 minute,
|
||||
// then every 10s thereafter, until either trusted or selection disabled.
|
||||
let mut checks: u32 = 0;
|
||||
loop {
|
||||
// If user disabled selection in between, stop early.
|
||||
if !is_selection_enabled() {
|
||||
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: selection disabled; stop polling");
|
||||
break;
|
||||
}
|
||||
|
||||
// Fast trust check without prompt.
|
||||
if macos_accessibility_client::accessibility::application_is_trusted() {
|
||||
log::info!(target: "coco_lib::selection_monitor", "permission watcher: accessibility granted; starting monitor");
|
||||
// Reset watcher flag before starting monitor to allow future retries if needed.
|
||||
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
|
||||
start_selection_monitor(app_handle_clone.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
// Backoff strategy.
|
||||
checks += 1;
|
||||
let sleep_secs = if checks <= 30 { 2 } else { 10 }; // ~1 min fast, then slower
|
||||
if checks % 30 == 0 {
|
||||
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: still not granted; continuing to poll (checks={})", checks);
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(sleep_secs));
|
||||
}
|
||||
|
||||
// Done polling without success; allow future attempts.
|
||||
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
|
||||
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: stopped (no grant)");
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
|
||||
// Fail fast here: spawning a watcher thread is critical for deferred start.
|
||||
panic!(
|
||||
"permission watcher: failed to spawn: {}",
|
||||
e
|
||||
);
|
||||
});
|
||||
} else {
|
||||
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: permission watcher already running; skip spawning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
log::info!("start_selection_monitor: 非 macOS 平台,无划词监控");
|
||||
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: non-macos platform, no selection monitor");
|
||||
}
|
||||
|
||||
// Background thread: drives popup show/hide based on mouse and AX selection state.
|
||||
MONITOR_THREAD_STARTED.store(true, Ordering::Relaxed);
|
||||
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: starting watcher thread");
|
||||
std::thread::spawn(move || {
|
||||
#[cfg(target_os = "macos")]
|
||||
use objc2_app_kit::NSWorkspace;
|
||||
@@ -108,13 +206,13 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
return (x_top_left, y_flipped);
|
||||
}
|
||||
|
||||
let mut chosen = CGMainDisplayID(); // default fallback
|
||||
log::info!(
|
||||
"current_mouse: pt=({:.1},{:.1}) → display={}",
|
||||
pt.x as f64,
|
||||
pt.y as f64,
|
||||
chosen
|
||||
);
|
||||
let mut _chosen = CGMainDisplayID(); // default fallback
|
||||
// log::info!(
|
||||
// "current_mouse: pt=({:.1},{:.1}) → display={}",
|
||||
// pt.x as f64,
|
||||
// pt.y as f64,
|
||||
// chosen
|
||||
// );
|
||||
|
||||
let mut min_x_pt = f64::INFINITY;
|
||||
let mut max_top_pt = f64::NEG_INFINITY;
|
||||
@@ -136,15 +234,15 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
let in_x = pt.x >= b.origin.x && pt.x <= b.origin.x + b.size.width;
|
||||
let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height;
|
||||
if in_x && in_y {
|
||||
chosen = did;
|
||||
log::info!(
|
||||
"current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
|
||||
pt.x as f64,
|
||||
pt.y as f64,
|
||||
chosen,
|
||||
b.origin.x,
|
||||
b.origin.y
|
||||
);
|
||||
_chosen = did;
|
||||
// log::info!(
|
||||
// "current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
|
||||
// pt.x as f64,
|
||||
// pt.y as f64,
|
||||
// chosen,
|
||||
// b.origin.x,
|
||||
// b.origin.y
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,13 +297,21 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
}
|
||||
|
||||
// Skip empty-selection hide checks while interacting with the Coco popup.
|
||||
let front_is_me = is_frontmost_app_me();
|
||||
// Robust check: treat as "self" if either the frontmost app or the
|
||||
// system-wide focused element belongs to this process.
|
||||
let front_is_me = is_frontmost_app_me() || is_focused_element_me();
|
||||
|
||||
// When Coco is frontmost, disable detection but do NOT hide the popup.
|
||||
// Users may be clicking the popup; we must keep it visible.
|
||||
if front_is_me {
|
||||
// Reset counters to avoid stale state on re-entry.
|
||||
stable_count = 0;
|
||||
empty_count = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lightweight retries to smooth out transient AX focus instability.
|
||||
let selected_text = if front_is_me {
|
||||
// Do not read selection during popup interaction to avoid false empty.
|
||||
None
|
||||
} else {
|
||||
let selected_text = {
|
||||
// Up to 2 retries, 35ms apart.
|
||||
read_selected_text_with_retries(2, 35)
|
||||
};
|
||||
@@ -223,6 +329,14 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
// Update/show only when selection is stable to avoid flicker.
|
||||
if stable_count >= stable_threshold {
|
||||
if !popup_visible || text != last_text {
|
||||
// Second guard: do not emit when Coco is frontmost
|
||||
// or the system-wide focused element belongs to Coco.
|
||||
// Keep popup as-is to allow user interaction.
|
||||
if is_frontmost_app_me() || is_focused_element_me() {
|
||||
stable_count = 0;
|
||||
empty_count = 0;
|
||||
continue;
|
||||
}
|
||||
let (x, y) = current_mouse_point_global();
|
||||
let payload = SelectionEventPayload {
|
||||
text: text.clone(),
|
||||
@@ -231,6 +345,9 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
};
|
||||
|
||||
let _ = app_handle.emit("selection-detected", payload);
|
||||
// Log selection state change once per stable update to avoid flooding.
|
||||
let snippet: String = stable_text.chars().take(120).collect();
|
||||
log::info!(target: "coco_lib::selection_monitor", "selection stable; showing popup (len={}, snippet=\"{}\")", stable_text.len(), snippet.replace('\n', "\\n"));
|
||||
last_text = text;
|
||||
popup_visible = true;
|
||||
}
|
||||
@@ -243,6 +360,7 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
empty_count += 1;
|
||||
if popup_visible && empty_count >= empty_threshold {
|
||||
let _ = app_handle.emit("selection-detected", "");
|
||||
log::info!(target: "coco_lib::selection_monitor", "selection empty; hiding popup");
|
||||
popup_visible = false;
|
||||
last_text.clear();
|
||||
stable_text.clear();
|
||||
@@ -256,12 +374,192 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Ensure macOS Accessibility permission with double-checking and session throttling.
|
||||
/// Returns true when trusted; otherwise triggers prompt/settings link and emits
|
||||
/// `selection-permission-required` to the frontend, then returns false.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: begin");
|
||||
// First check — fast path.
|
||||
let trusted = macos_accessibility_client::accessibility::application_is_trusted();
|
||||
if trusted {
|
||||
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
|
||||
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (fast path)");
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we've seen trust earlier in this session, transient false may occur.
|
||||
// Re-check after a short delay to avoid spurious prompts.
|
||||
if SEEN_ACCESSIBILITY_TRUSTED_ONCE.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
if macos_accessibility_client::accessibility::application_is_trusted() {
|
||||
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (after transient recheck)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle system prompt to at most once per 60s in this session.
|
||||
let mut last = LAST_ACCESSIBILITY_PROMPT.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
let allow_prompt = match *last {
|
||||
Some(ts) => now.duration_since(ts) > Duration::from_secs(60),
|
||||
None => true,
|
||||
};
|
||||
|
||||
if allow_prompt {
|
||||
// Try to trigger the system authorization prompt.
|
||||
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: triggering system prompt");
|
||||
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
|
||||
*last = Some(now);
|
||||
|
||||
// Small grace period then re-check.
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
if macos_accessibility_client::accessibility::application_is_trusted() {
|
||||
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
|
||||
log::info!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: user granted accessibility during prompt");
|
||||
return true;
|
||||
}
|
||||
log::warn!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: still not trusted after prompt");
|
||||
}
|
||||
|
||||
// Still not trusted — notify frontend and deep-link to settings.
|
||||
let _ = app_handle.emit("selection-permission-required", true);
|
||||
log::debug!(target: "coco_lib::selection_monitor", "selection-permission-required emitted");
|
||||
|
||||
// Provide richer context so frontend can give more explicit guidance.
|
||||
let info = collect_selection_permission_info();
|
||||
log::info!(target: "coco_lib::selection_monitor", "selection-permission-info: bundle_id={}, exe_path={}, in_applications={}, is_dmg={}, is_dev_guess={}",
|
||||
info.bundle_id, info.exe_path, info.in_applications, info.is_dmg, info.is_dev_guess);
|
||||
let _ = app_handle.emit("selection-permission-info", info);
|
||||
#[allow(unused_must_use)]
|
||||
{
|
||||
use std::process::Command;
|
||||
Command::new("open")
|
||||
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
|
||||
.status();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn collect_selection_permission_info() -> SelectionPermissionInfo {
|
||||
let exe_path = std::env::current_exe()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| String::from("<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.
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe extern "C" {
|
||||
fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe extern "C" {
|
||||
fn AXUIElementGetPid(
|
||||
element: *mut objc2_application_services::AXUIElement,
|
||||
pid: *mut i32,
|
||||
) -> objc2_application_services::AXError;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_focused_element_me() -> bool {
|
||||
use objc2_application_services::{AXError, AXUIElement};
|
||||
use objc2_core_foundation::{CFRetained, CFString, CFType};
|
||||
use std::ptr::NonNull;
|
||||
|
||||
let mut focused_ui_ptr: *const CFType = std::ptr::null();
|
||||
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
|
||||
|
||||
let system_elem = unsafe { AXUIElementCreateSystemWide() };
|
||||
if system_elem.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let system_elem_retained: CFRetained<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).
|
||||
/// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -357,10 +655,10 @@ fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String
|
||||
if let Some(text) = read_selected_text() {
|
||||
if !text.is_empty() {
|
||||
if attempt > 0 {
|
||||
log::info!(
|
||||
"read_selected_text: 第{}次重试成功,获取到选中文本",
|
||||
attempt
|
||||
);
|
||||
// log::info!(
|
||||
// "read_selected_text: 第{}次重试成功,获取到选中文本",
|
||||
// attempt
|
||||
// );
|
||||
}
|
||||
return Some(text);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [highlightId, setHighlightId] = useState<string>("");
|
||||
|
||||
const targetServerId = useSearchStore((state) => {
|
||||
return state.targetServerId;
|
||||
});
|
||||
const setTargetServerId = useSearchStore((state) => {
|
||||
return state.setTargetServerId;
|
||||
});
|
||||
const askAiServerId = useSearchStore((state) => {
|
||||
return state.askAiServerId;
|
||||
});
|
||||
@@ -102,17 +108,20 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
}, [serverList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiServerId || serverList.length === 0) return;
|
||||
|
||||
const matched = serverList.find((server) => {
|
||||
return server.id === askAiServerId;
|
||||
});
|
||||
const targetId = targetServerId ?? askAiServerId;
|
||||
if (!targetId || list.length === 0) return;
|
||||
|
||||
const matched = list.find((server) => server.id === targetId);
|
||||
if (!matched) return;
|
||||
|
||||
switchServer(matched);
|
||||
setAskAiServerId(void 0);
|
||||
}, [serverList, askAiServerId]);
|
||||
setHighlightId(matched.id);
|
||||
if (targetServerId) {
|
||||
setTargetServerId(void 0);
|
||||
} else {
|
||||
setAskAiServerId(void 0);
|
||||
}
|
||||
}, [list, askAiServerId, targetServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
@@ -291,4 +300,4 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -316,35 +316,50 @@ function SearchChat({
|
||||
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenAsk = platformAdapter.listenEvent("selection-ask-ai", ({ payload }: any) => {
|
||||
const value = typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
platformAdapter.showWindow();
|
||||
});
|
||||
|
||||
const unlistenAction = platformAdapter.listenEvent("selection-action", ({ payload }: any) => {
|
||||
const { action, text, assistantId } = payload || {};
|
||||
const value = String(text ?? "");
|
||||
if (action === "search") {
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: false });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
const { setSearchValue } = useSearchStore.getState();
|
||||
setSearchValue(value);
|
||||
platformAdapter.showWindow();
|
||||
} else if (action === "chat") {
|
||||
const unlistenAsk = platformAdapter.listenEvent(
|
||||
"selection-ask-ai",
|
||||
({ payload }: any) => {
|
||||
const value =
|
||||
typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
|
||||
const { assistantList } = useConnectStore.getState();
|
||||
const assistant = assistantList.find((item) => item._source?.id === assistantId);
|
||||
if (assistant) {
|
||||
const { setTargetAssistantId } = useSearchStore.getState();
|
||||
setTargetAssistantId(assistant._id);
|
||||
}
|
||||
platformAdapter.showWindow();
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const unlistenAction = platformAdapter.listenEvent(
|
||||
"selection-action",
|
||||
({ payload }: any) => {
|
||||
const { action, text, assistantId, serverId } = payload || {};
|
||||
const value = String(text ?? "");
|
||||
|
||||
//
|
||||
if (action === "search") {
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: false });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
const { setSearchValue } = useSearchStore.getState();
|
||||
setSearchValue(value);
|
||||
} else if (action === "chat") {
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
//
|
||||
const { setTargetServerId, setTargetAssistantId } =
|
||||
useSearchStore.getState();
|
||||
|
||||
if (serverId) {
|
||||
setTargetServerId(serverId);
|
||||
}
|
||||
|
||||
const { assistantList } = useConnectStore.getState();
|
||||
const assistant = assistantList.find(
|
||||
(item) => item._source?.id === assistantId
|
||||
);
|
||||
if (assistant) {
|
||||
setTargetAssistantId(assistant._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlistenAsk.then((fn) => fn());
|
||||
|
||||
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 {
|
||||
GripVertical,
|
||||
Bot,
|
||||
Copy,
|
||||
Languages,
|
||||
Search,
|
||||
Volume2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { GripVertical, Trash2 } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
||||
import { setCurrentWindowService } from "@/commands/windowService";
|
||||
|
||||
type ActionType =
|
||||
| "search"
|
||||
| "ask_ai"
|
||||
| "translate"
|
||||
| "summary"
|
||||
| "copy"
|
||||
| "speak"
|
||||
| "custom";
|
||||
|
||||
type LucideIconName =
|
||||
| "Search"
|
||||
| "Bot"
|
||||
| "Languages"
|
||||
| "FileText"
|
||||
| "Copy"
|
||||
| "Volume2";
|
||||
|
||||
type IconConfig =
|
||||
| { type: "lucide"; name: LucideIconName; color?: string }
|
||||
| { type: "custom"; dataUrl: string; color?: string };
|
||||
|
||||
export type ButtonConfig = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: IconConfig;
|
||||
action: {
|
||||
type: ActionType;
|
||||
assistantId?: string;
|
||||
assistantServerId?: string;
|
||||
eventName?: string;
|
||||
};
|
||||
labelKey?: string;
|
||||
};
|
||||
|
||||
const LUCIDE_ICON_MAP: Record<LucideIconName, any> = {
|
||||
Search,
|
||||
Bot,
|
||||
Languages,
|
||||
FileText,
|
||||
Copy,
|
||||
Volume2,
|
||||
};
|
||||
import { AddChatButton } from "./AddChatButton";
|
||||
import { ButtonConfig, resolveLucideIcon } from "./config";
|
||||
|
||||
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
|
||||
|
||||
@@ -94,9 +45,23 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { fetchAssistant } = AssistantFetcher({});
|
||||
|
||||
const [assistantByServer, setAssistantByServer] = useState<Record<string, any[]>>({});
|
||||
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<Record<string, boolean>>({});
|
||||
const [assistantCache, setAssistantCacheState] = useState<Record<string, AssistantCacheItem>>(() => loadAssistantCache());
|
||||
const [assistantByServer, setAssistantByServer] = useState<
|
||||
Record<string, any[]>
|
||||
>({});
|
||||
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 initializedServiceRef = useRef<boolean>(false);
|
||||
@@ -118,7 +83,11 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -145,10 +114,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
}
|
||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||
try {
|
||||
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
|
||||
const data = await fetchAssistant({
|
||||
current: 1,
|
||||
pageSize: 1000,
|
||||
serverId: sid,
|
||||
});
|
||||
const list = data.list || [];
|
||||
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
||||
const nextCache = {
|
||||
...assistantCache,
|
||||
[sid]: { list, updatedAt: Date.now() },
|
||||
};
|
||||
setAssistantCacheState(nextCache);
|
||||
saveAssistantCache(nextCache);
|
||||
} catch (err) {
|
||||
@@ -164,8 +140,8 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
initializedServiceRef.current = true;
|
||||
|
||||
const preferredSid =
|
||||
buttons.find((b) => b.action.assistantServerId)?.action.assistantServerId ||
|
||||
Object.keys(assistantCache)[0];
|
||||
buttons.find((b) => b.action.assistantServerId)?.action
|
||||
.assistantServerId || Object.keys(assistantCache)[0];
|
||||
|
||||
if (!preferredSid) return;
|
||||
const target = serverList.find((s: any) => s.id === preferredSid);
|
||||
@@ -193,10 +169,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
}
|
||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||
try {
|
||||
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
|
||||
const data = await fetchAssistant({
|
||||
current: 1,
|
||||
pageSize: 1000,
|
||||
serverId: sid,
|
||||
});
|
||||
const list = data.list || [];
|
||||
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
||||
const nextCache = {
|
||||
...assistantCache,
|
||||
[sid]: { list, updatedAt: Date.now() },
|
||||
};
|
||||
setAssistantCacheState(nextCache);
|
||||
saveAssistantCache(nextCache);
|
||||
} catch (err) {
|
||||
@@ -210,9 +193,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((btn, index) => {
|
||||
const IconComp = btn.icon.type === "lucide" ? LUCIDE_ICON_MAP[btn.icon.name] : null;
|
||||
const isChat = ["ask_ai", "translate", "summary"].includes(btn.action.type);
|
||||
const visualType: "Chat" | "Search" | "Tool" = isChat ? "Chat" : btn.action.type === "search" ? "Search" : "Tool";
|
||||
const IconComp =
|
||||
btn.icon.type === "lucide" ? resolveLucideIcon(btn.icon.name) : null;
|
||||
const isChat = ["ask_ai", "translate", "summary"].includes(
|
||||
btn.action.type
|
||||
);
|
||||
const isBuiltIn = BUILT_IN_IDS.has(btn.id);
|
||||
const visualType: "Chat" | "Search" | "Tool" = isChat
|
||||
? "Chat"
|
||||
: btn.action.type === "search"
|
||||
? "Search"
|
||||
: "Tool";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -229,11 +220,20 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="size-4 text-[#64748B] shrink-0" />
|
||||
{IconComp ? (
|
||||
<IconComp className="size-4 shrink-0" style={{ color: btn.icon.color || "#6B7280" }} />
|
||||
<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
|
||||
className={clsx(
|
||||
"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 && (
|
||||
<>
|
||||
<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 || ""}
|
||||
onChange={(e) => handleServerSelect(btn, e.target.value)}
|
||||
title={t("selection.bind.service")}
|
||||
>
|
||||
<option value="">{t("selection.bind.defaultService")}</option>
|
||||
<option value="">
|
||||
{t("selection.bind.defaultService")}
|
||||
</option>
|
||||
{serverList.map((s: any) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name || s.endpoint || s.id}
|
||||
@@ -270,16 +272,20 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
const loading = !!(sid && assistantLoadingByServer[sid]);
|
||||
return (
|
||||
<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 || ""}
|
||||
onChange={(e) => handleAssistantSelect(btn, e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleAssistantSelect(btn, e.target.value)
|
||||
}
|
||||
title={t("selection.bind.assistant")}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">{t("selection.bind.defaultAssistant")}</option>
|
||||
<option value="">
|
||||
{t("selection.bind.defaultAssistant")}
|
||||
</option>
|
||||
{loading && (
|
||||
<option value="" disabled>
|
||||
加载中...
|
||||
{t("common.loading")}
|
||||
</option>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
<AddChatButton
|
||||
serverList={serverList}
|
||||
onAdd={(newBtn) => setButtons((prev) => [...prev, newBtn])}
|
||||
/>
|
||||
</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 SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useEnabledServers } from "@/hooks/useEnabledServers";
|
||||
import ButtonsList from "./ButtonsList";
|
||||
|
||||
/**
|
||||
* Selection toolbar button config types
|
||||
*/
|
||||
type IconConfig =
|
||||
| { type: "lucide"; name: LucideIconName; color?: string }
|
||||
| { type: "custom"; dataUrl: string; color?: string };
|
||||
|
||||
type ActionType =
|
||||
| "search"
|
||||
| "ask_ai"
|
||||
| "translate"
|
||||
| "summary"
|
||||
| "copy"
|
||||
| "speak"
|
||||
| "custom";
|
||||
|
||||
type ButtonConfig = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: IconConfig;
|
||||
action: {
|
||||
type: ActionType;
|
||||
assistantId?: string;
|
||||
assistantServerId?: string;
|
||||
eventName?: string;
|
||||
};
|
||||
// i18n key for built-in labels; if present, render by t(labelKey)
|
||||
labelKey?: string;
|
||||
};
|
||||
|
||||
type LucideIconName =
|
||||
| "Search"
|
||||
| "Bot"
|
||||
| "Languages"
|
||||
| "FileText"
|
||||
| "Copy"
|
||||
| "Volume2";
|
||||
|
||||
import HeaderToolbar from "@/components/Selection/HeaderToolbar";
|
||||
import { ButtonConfig } from "./config";
|
||||
|
||||
const DEFAULT_CONFIG: ButtonConfig[] = [
|
||||
{
|
||||
@@ -60,7 +22,7 @@ const DEFAULT_CONFIG: ButtonConfig[] = [
|
||||
id: "ask_ai",
|
||||
label: "问答",
|
||||
labelKey: "selection.actions.ask_ai",
|
||||
icon: { type: "lucide", name: "Bot", color: "#0287FF" },
|
||||
icon: { type: "lucide", name: "BotMessageSquare", color: "#0287FF" },
|
||||
action: { type: "ask_ai" },
|
||||
},
|
||||
{
|
||||
@@ -103,9 +65,11 @@ function loadToolbarConfig(): ButtonConfig[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_CONFIG;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0)
|
||||
return parsed as ButtonConfig[];
|
||||
return DEFAULT_CONFIG;
|
||||
let cfg: ButtonConfig[] = Array.isArray(parsed) && parsed.length > 0 ? (parsed as ButtonConfig[]) : DEFAULT_CONFIG;
|
||||
// Lightweight migration: ensure ask_ai icon follows the updated default
|
||||
const defaultAsk = DEFAULT_CONFIG.find((b) => b.id === "ask_ai");
|
||||
cfg = cfg.map((b) => (b.id === "ask_ai" && defaultAsk ? { ...b, icon: defaultAsk.icon } : b));
|
||||
return cfg;
|
||||
} catch {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
@@ -125,6 +89,7 @@ const SelectionSettings = () => {
|
||||
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
|
||||
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
|
||||
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
|
||||
const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
|
||||
|
||||
// Initialize from global store; write back on change for multi-window sync
|
||||
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||
@@ -143,7 +108,7 @@ const SelectionSettings = () => {
|
||||
|
||||
useEffect(() => {
|
||||
saveToolbarConfig(buttons);
|
||||
setToolbarConfig(buttons); // push to store for multi-window
|
||||
setToolbarConfig(buttons);
|
||||
}, [buttons]);
|
||||
|
||||
return (
|
||||
@@ -152,6 +117,24 @@ const SelectionSettings = () => {
|
||||
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
|
||||
</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
|
||||
icon={Sparkles}
|
||||
title={t("settings.ai.title")}
|
||||
@@ -159,15 +142,7 @@ const SelectionSettings = () => {
|
||||
>
|
||||
<SettingsToggle
|
||||
checked={selectionEnabled}
|
||||
onChange={async (value) => {
|
||||
try {
|
||||
await platformAdapter.invokeBackend("set_selection_enabled", {
|
||||
enabled: value,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("set_selection_enabled invoke failed:", e);
|
||||
}
|
||||
}}
|
||||
onChange={setSelectionEnabled}
|
||||
label={t("settings.ai.toggle")}
|
||||
/>
|
||||
</SettingsItem>
|
||||
@@ -188,7 +163,11 @@ const SelectionSettings = () => {
|
||||
label={t("selection.display.iconsOnlyLabel")}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<ButtonsList buttons={buttons} setButtons={setButtons} serverList={serverList} />
|
||||
<ButtonsList
|
||||
buttons={buttons}
|
||||
setButtons={setButtons}
|
||||
serverList={serverList}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import platformAdapter from "@/utils/platformAdapter";
|
||||
import UpdateSettings from "./components/UpdateSettings";
|
||||
import SettingsToggle from "../SettingsToggle";
|
||||
import SelectionSettings from "./components/Selection";
|
||||
import { isMac } from "@/utils/platform";
|
||||
|
||||
const Advanced = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -190,7 +191,7 @@ const Advanced = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SelectionSettings />
|
||||
{isMac && <SelectionSettings />}
|
||||
|
||||
<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 items = await Promise.all([
|
||||
const itemPromises: Promise<any>[] = [];
|
||||
|
||||
itemPromises.push(
|
||||
MenuItem.new({
|
||||
text: t("tray.showCoco"),
|
||||
accelerator: showCocoShortcuts.join("+"),
|
||||
action: () => {
|
||||
show_coco();
|
||||
},
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: selectionEnabled
|
||||
? t("tray.selectionDisable")
|
||||
: t("tray.selectionEnable"),
|
||||
action: async () => {
|
||||
try {
|
||||
await platformAdapter.invokeBackend("set_selection_enabled", { enabled: !selectionEnabled });
|
||||
} catch (e) {
|
||||
console.error("set_selection_enabled invoke failed:", e);
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
|
||||
|
||||
if (isMac) {
|
||||
itemPromises.push(
|
||||
MenuItem.new({
|
||||
text: selectionEnabled
|
||||
? t("tray.selectionDisable")
|
||||
: t("tray.selectionEnable"),
|
||||
action: async () => {
|
||||
try {
|
||||
await platformAdapter.invokeBackend("set_selection_enabled", {
|
||||
enabled: !selectionEnabled,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("set_selection_enabled invoke failed:", e);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
itemPromises.push(
|
||||
MenuItem.new({
|
||||
text: t("tray.settings"),
|
||||
// accelerator: "CommandOrControl+,",
|
||||
action: () => {
|
||||
show_settings();
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
itemPromises.push(
|
||||
MenuItem.new({
|
||||
text: t("tray.checkUpdate"),
|
||||
action: async () => {
|
||||
await show_check();
|
||||
|
||||
platformAdapter.emitEvent("check-update");
|
||||
},
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
})
|
||||
);
|
||||
|
||||
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
|
||||
|
||||
itemPromises.push(
|
||||
MenuItem.new({
|
||||
text: t("tray.quitCoco"),
|
||||
accelerator: "CommandOrControl+Q",
|
||||
action: () => {
|
||||
exit(0);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
const items = await Promise.all(itemPromises);
|
||||
return Menu.new({ items });
|
||||
};
|
||||
|
||||
|
||||
@@ -650,6 +650,10 @@
|
||||
"login": "Login"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"notFound": "Not found"
|
||||
},
|
||||
"selection": {
|
||||
"title": "Selection Toolbar Settings",
|
||||
"actions": {
|
||||
@@ -658,7 +662,9 @@
|
||||
"translate": "Translate",
|
||||
"summary": "Summary",
|
||||
"copy": "Copy",
|
||||
"speak": "Read Aloud"
|
||||
"speak": "Read Aloud",
|
||||
"delete": "Delete",
|
||||
"addChat": "Add Chat Button"
|
||||
},
|
||||
"noText": "No text detected",
|
||||
"copied": "Copied",
|
||||
@@ -683,8 +689,24 @@
|
||||
"bind": {
|
||||
"service": "Select Service",
|
||||
"defaultService": "Default service",
|
||||
"assistant": "Bind Assistant (Chat only)",
|
||||
"assistant": "Bind Assistant",
|
||||
"defaultAssistant": "Default assistant"
|
||||
},
|
||||
"custom": {
|
||||
"chat": "Custom Chat",
|
||||
"namePlaceholder": "Button name"
|
||||
},
|
||||
"icon": {
|
||||
"type": "Icon Type",
|
||||
"lucide": "Lucide",
|
||||
"custom": "Custom",
|
||||
"pick": "Pick Icon",
|
||||
"color": "Icon Color",
|
||||
"upload": "Upload Icon",
|
||||
"alt": "icon"
|
||||
},
|
||||
"preview": {
|
||||
"readonly": "Preview is read-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +649,10 @@
|
||||
"login": "登录"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"notFound": "未找到"
|
||||
},
|
||||
"selection": {
|
||||
"title": "划词工具栏设置",
|
||||
"actions": {
|
||||
@@ -657,7 +661,9 @@
|
||||
"translate": "翻译",
|
||||
"summary": "总结",
|
||||
"copy": "复制",
|
||||
"speak": "朗读"
|
||||
"speak": "朗读",
|
||||
"delete": "删除",
|
||||
"addChat": "添加聊天功能"
|
||||
},
|
||||
"noText": "未检测到文本",
|
||||
"copied": "已复制",
|
||||
@@ -682,8 +688,24 @@
|
||||
"bind": {
|
||||
"service": "选择服务",
|
||||
"defaultService": "默认服务",
|
||||
"assistant": "绑定小助手(仅聊天)",
|
||||
"assistant": "绑定小助手",
|
||||
"defaultAssistant": "默认小助手"
|
||||
},
|
||||
"custom": {
|
||||
"chat": "自定义聊天",
|
||||
"namePlaceholder": "功能名称"
|
||||
},
|
||||
"icon": {
|
||||
"type": "图标类型",
|
||||
"lucide": "Lucide",
|
||||
"custom": "自定义",
|
||||
"pick": "选择图标",
|
||||
"color": "图标颜色",
|
||||
"upload": "上传图标",
|
||||
"alt": "图标"
|
||||
},
|
||||
"preview": {
|
||||
"readonly": "预览仅供查看"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Copy,
|
||||
Languages,
|
||||
Search,
|
||||
X,
|
||||
Volume2,
|
||||
Pause,
|
||||
Play,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { X, Pause, Play } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Separator } from "@radix-ui/react-separator";
|
||||
|
||||
import { useSelectionStore } from "@/stores/selectionStore";
|
||||
import { copyToClipboard } from "@/utils";
|
||||
import cocoLogoImg from "@/assets/app-icon.png";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import HeaderToolbar from "@/components/Selection/HeaderToolbar";
|
||||
import type { ActionConfig } from "@/components/Settings/Advanced/components/Selection/config";
|
||||
import { show_coco } from "@/commands";
|
||||
|
||||
// Simple animated selection window content
|
||||
export default function SelectionWindow() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [animatingOut, setAnimatingOut] = useState(false);
|
||||
@@ -137,34 +127,27 @@ export default function SelectionWindow() {
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const openMain = async () => {
|
||||
try {
|
||||
await platformAdapter.commands("show_coco");
|
||||
} catch {
|
||||
await platformAdapter.emitEvent("show-coco");
|
||||
await platformAdapter.showWindow();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatAction = useCallback(
|
||||
async (assistantId?: string) => {
|
||||
async (action: ActionConfig) => {
|
||||
const payloadText = (textRef.current || "").trim();
|
||||
|
||||
if (!payloadText) return;
|
||||
|
||||
await openMain();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await show_coco();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
await platformAdapter.emitEvent("selection-action", {
|
||||
action: "chat",
|
||||
text: payloadText,
|
||||
assistantId,
|
||||
assistantId: action.assistantId,
|
||||
serverId: action.assistantServerId,
|
||||
});
|
||||
|
||||
if (!isSpeaking) {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
[openMain, isSpeaking, close]
|
||||
[isSpeaking, close]
|
||||
);
|
||||
|
||||
const searchMain = useCallback(async () => {
|
||||
@@ -172,7 +155,7 @@ export default function SelectionWindow() {
|
||||
console.log("searchMain payload", payloadText);
|
||||
if (!payloadText) return;
|
||||
|
||||
await openMain();
|
||||
await show_coco();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await platformAdapter.emitEvent("selection-action", {
|
||||
action: "search",
|
||||
@@ -239,7 +222,7 @@ export default function SelectionWindow() {
|
||||
setIsSpeaking(true);
|
||||
setIsPaused(false);
|
||||
} catch (e) {
|
||||
console.error("TTS 播放失败", e);
|
||||
console.error("TTS play failed:", e);
|
||||
stopSpeak();
|
||||
scheduleAutoHide();
|
||||
}
|
||||
@@ -258,124 +241,88 @@ export default function SelectionWindow() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getActionHandler = (type: string, assistantId?: string) => {
|
||||
switch (type) {
|
||||
const getActionHandler = (action: ActionConfig) => {
|
||||
switch (action.type) {
|
||||
case "ask_ai":
|
||||
case "translate":
|
||||
case "summary":
|
||||
return () => handleChatAction(assistantId);
|
||||
handleChatAction(action);
|
||||
break;
|
||||
case "copy":
|
||||
return handleCopy;
|
||||
handleCopy();
|
||||
break;
|
||||
case "search":
|
||||
return searchMain;
|
||||
searchMain();
|
||||
break;
|
||||
case "speak":
|
||||
return speak;
|
||||
speak();
|
||||
break;
|
||||
default:
|
||||
return () => {};
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Render buttons from store; hide ones requiring assistant without assistantId
|
||||
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||
const iconsOnly = useSelectionStore((s) => s.iconsOnly);
|
||||
|
||||
const requiresAssistant = (type?: string) =>
|
||||
type === "ask_ai" || type === "translate" || type === "summary";
|
||||
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const visibleButtons = useMemo(
|
||||
() =>
|
||||
(Array.isArray(toolbarConfig) ? toolbarConfig : []).filter((btn: any) => {
|
||||
const type = btn?.action?.type;
|
||||
if (requiresAssistant(type)) {
|
||||
return Boolean(btn?.action?.assistantId);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
() => (Array.isArray(toolbarConfig) ? toolbarConfig : []),
|
||||
[toolbarConfig]
|
||||
);
|
||||
|
||||
// Lucide icon map for dynamic rendering
|
||||
const LUCIDE_ICON_MAP: Record<string, any> = {
|
||||
Search,
|
||||
Bot,
|
||||
Languages,
|
||||
FileText,
|
||||
Copy,
|
||||
Volume2,
|
||||
};
|
||||
// Resize window width to fit toolbar content (sum child widths to avoid feedback growth)
|
||||
const resizeToContentWidth = useCallback(async () => {
|
||||
try {
|
||||
if (!visible) return;
|
||||
const el = toolbarRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Component: render icon (lucide or custom)
|
||||
const IconRenderer = ({ icon }: { icon?: any }) => {
|
||||
// Support lucide icon or custom image
|
||||
if (icon?.type === "lucide") {
|
||||
const Icon =
|
||||
LUCIDE_ICON_MAP[icon?.name as string] || LUCIDE_ICON_MAP.Search;
|
||||
return (
|
||||
<Icon
|
||||
className="size-4 transition-transform duration-150"
|
||||
style={icon?.color ? { color: icon.color } : undefined}
|
||||
/>
|
||||
// Robust intrinsic width measurement: clone offscreen to avoid flex shrink/grow feedback.
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "absolute";
|
||||
clone.style.visibility = "hidden";
|
||||
clone.style.left = "-10000px";
|
||||
clone.style.top = "0";
|
||||
clone.style.width = "auto";
|
||||
clone.style.maxWidth = "none";
|
||||
clone.style.overflow = "visible";
|
||||
// prevent wrapping that may change inline width
|
||||
(clone.style as any).whiteSpace = "nowrap";
|
||||
document.body.appendChild(clone);
|
||||
const intrinsicWidth = Math.ceil(clone.getBoundingClientRect().width);
|
||||
clone.remove();
|
||||
|
||||
// Apply small buffer and clamp by mode
|
||||
const PAD = 12;
|
||||
const WIDTH_MIN = iconsOnly ? 160 : 240;
|
||||
const WIDTH_MAX = iconsOnly ? 640 : 960;
|
||||
const desiredWidth = Math.max(
|
||||
WIDTH_MIN,
|
||||
Math.min(WIDTH_MAX, Math.ceil(intrinsicWidth + PAD))
|
||||
);
|
||||
}
|
||||
if (icon?.type === "custom" && icon?.dataUrl) {
|
||||
return (
|
||||
<img
|
||||
src={icon.dataUrl}
|
||||
className="size-4 rounded"
|
||||
alt=""
|
||||
style={
|
||||
icon?.color
|
||||
? { filter: `drop-shadow(0 0 0 ${icon.color})` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// default
|
||||
return <Search className="size-4 text-[#6366F1]" />;
|
||||
};
|
||||
|
||||
// Component: single toolbar button
|
||||
const ToolbarButton = ({
|
||||
btn,
|
||||
onClick,
|
||||
}: {
|
||||
btn: any;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
>
|
||||
<IconRenderer icon={btn?.icon} />
|
||||
{!iconsOnly && (
|
||||
<span className="text-[12px] transition-opacity duration-150">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const win = await platformAdapter.getCurrentWebviewWindow();
|
||||
const size = await win.innerSize();
|
||||
// Only update width; preserve current height
|
||||
const currentWidth = size.width;
|
||||
if (Math.abs(currentWidth - desiredWidth) >= 2) {
|
||||
await platformAdapter.setWindowSize(desiredWidth, 32);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("resizeToContentWidth failed:", e);
|
||||
}
|
||||
}, [visible, iconsOnly]);
|
||||
|
||||
// Component: header logo
|
||||
const HeaderLogo = () => {
|
||||
return (
|
||||
<img
|
||||
src={cocoLogoImg}
|
||||
alt="Coco Logo"
|
||||
className="w-6 h-6"
|
||||
onClick={openMain}
|
||||
onError={(e) => {
|
||||
try {
|
||||
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
// Recalculate on relevant changes (buttons, labels, speaking state, visibility)
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
// Ensure DOM updated before measure
|
||||
const id = window.requestAnimationFrame(() => {
|
||||
resizeToContentWidth();
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [visibleButtons, iconsOnly, isSpeaking, visible, resizeToContentWidth]);
|
||||
|
||||
// Component: selected text preview
|
||||
const TextPreview = ({ text }: { text: string }) => {
|
||||
@@ -484,31 +431,15 @@ export default function SelectionWindow() {
|
||||
<TextPreview text={text} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region="false"
|
||||
className="flex items-center gap-1 p-1 flex-nowrap overflow-hidden"
|
||||
<HeaderToolbar
|
||||
buttons={visibleButtons as any}
|
||||
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 />}
|
||||
</div>
|
||||
</HeaderToolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import { Suspense, lazy } from "react";
|
||||
|
||||
import Layout from "./layout";
|
||||
import ErrorPage from "@/pages/error/index";
|
||||
import DesktopApp from "@/pages/main/index";
|
||||
import SettingsPage from "@/pages/settings/index";
|
||||
import StandaloneChat from "@/pages/chat/index";
|
||||
import WebPage from "@/pages/web/index";
|
||||
import CheckPage from "@/pages/check/index";
|
||||
import SelectionWindow from "@/pages/selection/index";
|
||||
|
||||
const DesktopApp = lazy(() => import("@/pages/main/index"));
|
||||
const SettingsPage = lazy(() => import("@/pages/settings/index"));
|
||||
const StandaloneChat = lazy(() => import("@/pages/chat/index"));
|
||||
const WebPage = lazy(() => import("@/pages/web/index"));
|
||||
const CheckPage = lazy(() => import("@/pages/check/index"));
|
||||
const SelectionWindow = lazy(() => import("@/pages/selection/index"));
|
||||
|
||||
const routerOptions = {
|
||||
basename: "/",
|
||||
@@ -24,12 +26,12 @@ export const router = createBrowserRouter(
|
||||
element: <Layout />,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ path: "/ui", element: <DesktopApp /> },
|
||||
{ path: "/ui/settings", element: <SettingsPage /> },
|
||||
{ path: "/ui/chat", element: <StandaloneChat /> },
|
||||
{ path: "/ui/check", element: <CheckPage /> },
|
||||
{ path: "/ui/selection", element: <SelectionWindow /> },
|
||||
{ path: "/web", element: <WebPage /> },
|
||||
{ path: "/ui", element: (<Suspense fallback={<></>}><DesktopApp /></Suspense>) },
|
||||
{ path: "/ui/settings", element: (<Suspense fallback={<></>}><SettingsPage /></Suspense>) },
|
||||
{ path: "/ui/chat", element: (<Suspense fallback={<></>}><StandaloneChat /></Suspense>) },
|
||||
{ path: "/ui/check", element: (<Suspense fallback={<></>}><CheckPage /></Suspense>) },
|
||||
{ path: "/ui/selection", element: (<Suspense fallback={<></>}><SelectionWindow /></Suspense>) },
|
||||
{ path: "/web", element: (<Suspense fallback={<></>}><WebPage /></Suspense>) },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { useSelectionStore } from "@/stores/selectionStore";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
|
||||
import { useSelectionWindow } from "../hooks/useSelectionWindow";
|
||||
import { useSelectionWindow } from "@/hooks/useSelectionWindow";
|
||||
|
||||
export default function LayoutOutlet() {
|
||||
const location = useLocation();
|
||||
@@ -35,27 +35,6 @@ export default function LayoutOutlet() {
|
||||
// init deep link manager
|
||||
useDeepLinkManager();
|
||||
|
||||
// --- Selection state: init + subscribe backend as SSOT ---
|
||||
useMount(async () => {
|
||||
try {
|
||||
const enabled = await platformAdapter.invokeBackend<boolean>("get_selection_enabled");
|
||||
useSelectionStore.getState().setSelectionEnabled(!!enabled);
|
||||
} catch (e) {
|
||||
console.error("get_selection_enabled failed:", e);
|
||||
}
|
||||
|
||||
const unlisten = await platformAdapter.listenEvent(
|
||||
"selection-enabled",
|
||||
({ payload }: any) => {
|
||||
useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten && unlisten();
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
@@ -154,4 +133,4 @@ export default function LayoutOutlet() {
|
||||
<ErrorNotification />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export type ISearchStore = {
|
||||
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
|
||||
askAiAssistantId?: string;
|
||||
setAskAiAssistantId: (askAiAssistantId?: string) => void;
|
||||
targetServerId?: string;
|
||||
setTargetServerId: (targetServerId?: string) => void;
|
||||
targetAssistantId?: string;
|
||||
setTargetAssistantId: (targetAssistantId?: string) => void;
|
||||
visibleExtensionStore: boolean;
|
||||
@@ -104,6 +106,9 @@ export const useSearchStore = create<ISearchStore>()(
|
||||
setAskAiAssistantId: (askAiAssistantId) => {
|
||||
return set({ askAiAssistantId });
|
||||
},
|
||||
setTargetServerId: (targetServerId) => {
|
||||
return set({ targetServerId });
|
||||
},
|
||||
setTargetAssistantId: (targetAssistantId) => {
|
||||
return set({ targetAssistantId });
|
||||
},
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import { createTauriStore } from '@tauri-store/zustand';
|
||||
|
||||
// Types adapted from Selection/index.tsx to ensure compatibility
|
||||
export type LucideIconName =
|
||||
| "Search"
|
||||
| "Bot"
|
||||
| "Languages"
|
||||
| "FileText"
|
||||
| "Copy"
|
||||
| "Volume2";
|
||||
|
||||
type IconConfig =
|
||||
| { type: "lucide"; name: LucideIconName; color?: string }
|
||||
| { type: "lucide"; name: string; color?: string }
|
||||
| { type: "custom"; dataUrl: string; color?: string };
|
||||
|
||||
type ActionType =
|
||||
| "search"
|
||||
| "ask_ai"
|
||||
| "translate"
|
||||
| "summary"
|
||||
| "copy"
|
||||
| "speak"
|
||||
| "custom";
|
||||
|
||||
export type ButtonConfig = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: IconConfig;
|
||||
action: {
|
||||
type: ActionType;
|
||||
type: string;
|
||||
assistantId?: string;
|
||||
assistantServerId?: string;
|
||||
eventName?: string;
|
||||
|
||||
@@ -53,13 +53,10 @@ export interface EventPayloads {
|
||||
"server-list-changed": Server[];
|
||||
"selection-text": string;
|
||||
"selection-ask-ai": any;
|
||||
"selection-action": {
|
||||
action: "translate" | "search" | "copy" | "summary";
|
||||
text: string;
|
||||
};
|
||||
"selection-action": any;
|
||||
"selection-detected": string;
|
||||
"selection-enabled": boolean;
|
||||
"selection-icons-only": { value: boolean };
|
||||
"change-selection-store": any;
|
||||
}
|
||||
|
||||
// Window operation interface
|
||||
|
||||
@@ -277,4 +277,4 @@ export const createWebAdapter = (): WebPlatformAdapter => {
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -87,10 +87,20 @@ export default defineConfig(async () => ({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ["react", "react-dom"],
|
||||
katex: ["rehype-katex"],
|
||||
highlight: ["rehype-highlight"],
|
||||
react: ["react", "react-dom"],
|
||||
router: ["react-router-dom"],
|
||||
markdown: [
|
||||
"react-markdown",
|
||||
"remark-gfm",
|
||||
"remark-breaks",
|
||||
"remark-math",
|
||||
"rehype-highlight",
|
||||
"rehype-katex",
|
||||
"mdast-util-gfm-autolink-literal",
|
||||
],
|
||||
mermaid: ["mermaid"],
|
||||
icons: ["lucide-react", "@infinilabs/custom-icons"],
|
||||
utils: ["lodash-es", "dayjs", "uuid", "nanoid", "axios"],
|
||||
"tauri-api": [
|
||||
"@tauri-apps/api/core",
|
||||
"@tauri-apps/api/event",
|
||||
|
||||
Reference in New Issue
Block a user