mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 11:37:47 +01:00
feat: add selection toolbar window for mac (#980)
* feat: add selection window page
* fix: chat input
* feat: add selection page
* chore: add
* chore: test
* feat: add
* feat: add store
* feat: add selection settings
* chore: remove unused code
* docs: add release note
* docs: add release note
* chore: format code
* chore: format code
* fix: copy error
* disable hashbrown default feature
* Enable unstable feature allocator_api
To make coco-app compile in CI:
```
--> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.15.5/src/raw/mod.rs:3856:12
|
3856 | impl<T, A: Allocator> RawIntoIter<T, A> {
| ^^^^^^^^^
|
= note: see issue #32838 <https://github.com/rust-lang/rust/issues/32838> for more information
= help: add `#![feature(allocator_api)]` to the crate attributes to enable
= note: this compiler was built on 2025-06-25; consider upgrading it if it is out of date
```
I don't know why it does not compile, feature `allocator-api2` is
enabled for `hashbrown 0.15.5`, so technically [1] it should not use the
allocator APIs from the std. According to [2], enabling the `nightly`
feature of `allocator-api2` may cause this issue as well, but it is not
enabled in our case either.
Anyway, enabling `#![feature(allocator_api)]` should make it work.
[1]: b751eef8e9/src/raw/alloc.rs (L26-L47)
[2]: https://github.com/rust-lang/hashbrown/issues/564
* put it in main.rs
* format main.rs
* Enable default-features for hashbrown 0.15.5
* format main.rs
* enable feature allocator-api2
* feat: add selection set config
* fix: selection setting
* fix: ci error
* fix: ci error
* fix: ci error
* fix: ci error
* merge: merge main
* fix: rust code warn
* fix: rust code error
* fix: rust code error
* fix: selection settings
* style: selection styles
* style: selection styles
---------
Co-authored-by: Steve Lau <stevelauc@outlook.com>
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -14,6 +14,7 @@
|
|||||||
"dyld",
|
"dyld",
|
||||||
"elif",
|
"elif",
|
||||||
"errmsg",
|
"errmsg",
|
||||||
|
"frontmost",
|
||||||
"fullscreen",
|
"fullscreen",
|
||||||
"fulltext",
|
"fulltext",
|
||||||
"headlessui",
|
"headlessui",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"nowrap",
|
"nowrap",
|
||||||
"nspanel",
|
"nspanel",
|
||||||
"nsstring",
|
"nsstring",
|
||||||
|
"objc",
|
||||||
"overscan",
|
"overscan",
|
||||||
"partialize",
|
"partialize",
|
||||||
"patchelf",
|
"patchelf",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Information about release notes of Coco App is provided here.
|
|||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
### 🐛 Bug fix
|
### 🐛 Bug fix
|
||||||
|
|||||||
@@ -19,9 +19,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.2",
|
"@headlessui/react": "^2.2.2",
|
||||||
|
"@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",
|
||||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||||
|
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||||
|
|||||||
73
pnpm-lock.yaml
generated
73
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)
|
||||||
|
'@radix-ui/react-separator':
|
||||||
|
specifier: ^1.1.8
|
||||||
|
version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3(@types/react@18.3.26)(react@18.3.1)
|
version: 1.2.3(@types/react@18.3.26)(react@18.3.1)
|
||||||
@@ -20,6 +23,9 @@ importers:
|
|||||||
'@tauri-apps/plugin-autostart':
|
'@tauri-apps/plugin-autostart':
|
||||||
specifier: ~2.2.0
|
specifier: ~2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
|
'@tauri-apps/plugin-clipboard-manager':
|
||||||
|
specifier: ~2.3.2
|
||||||
|
version: 2.3.2
|
||||||
'@tauri-apps/plugin-deep-link':
|
'@tauri-apps/plugin-deep-link':
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.4.5
|
version: 2.4.5
|
||||||
@@ -1029,6 +1035,32 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.4':
|
||||||
|
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-separator@1.1.8':
|
||||||
|
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.2.3':
|
'@radix-ui/react-slot@1.2.3':
|
||||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1038,6 +1070,15 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4':
|
||||||
|
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@react-aria/focus@3.20.2':
|
'@react-aria/focus@3.20.2':
|
||||||
resolution: {integrity: sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==}
|
resolution: {integrity: sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1286,6 +1327,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-autostart@2.2.0':
|
'@tauri-apps/plugin-autostart@2.2.0':
|
||||||
resolution: {integrity: sha512-TzVcDZdOvdot0avkpstUWJKKEl4cyxLpFB9DZZRW5zH8k+Bv8IVJmO0zyYuw+7oKlGdHOINbD/7Je7GHMViw5w==}
|
resolution: {integrity: sha512-TzVcDZdOvdot0avkpstUWJKKEl4cyxLpFB9DZZRW5zH8k+Bv8IVJmO0zyYuw+7oKlGdHOINbD/7Je7GHMViw5w==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-clipboard-manager@2.3.2':
|
||||||
|
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-deep-link@2.4.5':
|
'@tauri-apps/plugin-deep-link@2.4.5':
|
||||||
resolution: {integrity: sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==}
|
resolution: {integrity: sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==}
|
||||||
|
|
||||||
@@ -4553,6 +4597,24 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.26
|
'@types/react': 18.3.26
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.26)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.26
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.26)
|
||||||
|
|
||||||
|
'@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.26
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.26)
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.2.3(@types/react@18.3.26)(react@18.3.1)':
|
'@radix-ui/react-slot@1.2.3(@types/react@18.3.26)(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
|
||||||
@@ -4560,6 +4622,13 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.26
|
'@types/react': 18.3.26
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4(@types/react@18.3.26)(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.26
|
||||||
|
|
||||||
'@react-aria/focus@3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@react-aria/focus@3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@react-aria/interactions': 3.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@react-aria/interactions': 3.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -4746,6 +4815,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.0
|
'@tauri-apps/api': 2.9.0
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-clipboard-manager@2.3.2':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.9.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-deep-link@2.4.5':
|
'@tauri-apps/plugin-deep-link@2.4.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.0
|
'@tauri-apps/api': 2.9.0
|
||||||
|
|||||||
1845
src-tauri/Cargo.lock
generated
1845
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,7 @@ toml = "0.8"
|
|||||||
path-clean = "1.0.1"
|
path-clean = "1.0.1"
|
||||||
actix-files = "0.6.8"
|
actix-files = "0.6.8"
|
||||||
actix-web = "4.11.0"
|
actix-web = "4.11.0"
|
||||||
|
tauri-plugin-clipboard-manager = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.23.0"
|
tempfile = "3.23.0"
|
||||||
@@ -130,6 +131,8 @@ objc2 = "0.6.2"
|
|||||||
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||||
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
|
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
|
||||||
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
|
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
|
||||||
|
# macOS-only: used by selection_monitor.rs to check AX trust/prompt
|
||||||
|
macos-accessibility-client = "0.0.1"
|
||||||
|
|
||||||
[target."cfg(target_os = \"linux\")".dependencies]
|
[target."cfg(target_os = \"linux\")".dependencies]
|
||||||
gio = "0.21.2"
|
gio = "0.21.2"
|
||||||
|
|||||||
@@ -38,5 +38,9 @@
|
|||||||
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
|
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
|
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
|
||||||
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
|
<string>Coco AI requires access to Apple Events to enable certain features, such as opening files and applications.</string>
|
||||||
|
<key>NSAccessibility</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main", "chat", "settings", "check"],
|
"windows": ["main", "chat", "settings", "check", "selection"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:allow-emit",
|
"core:event:allow-emit",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:deny-internal-toggle-maximize",
|
"core:window:deny-internal-toggle-maximize",
|
||||||
"core:window:allow-set-shadow",
|
"core:window:allow-set-shadow",
|
||||||
|
"core:window:allow-set-position",
|
||||||
"core:app:allow-set-app-theme",
|
"core:app:allow-set-app-theme",
|
||||||
"shell:default",
|
"shell:default",
|
||||||
"http:default",
|
"http:default",
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
// use std::collections::HashMap;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
// use crate::common::health::Status;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct RequestAccessTokenResponse {
|
|
||||||
pub access_token: String,
|
|
||||||
pub expire_in: u32,
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,8 @@
|
|||||||
use crate::common;
|
use crate::common;
|
||||||
use reqwest::Response;
|
use reqwest::Response;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri_plugin_store::JsonValue;
|
use tauri_plugin_store::JsonValue;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct GetResponse {
|
|
||||||
pub _id: String,
|
|
||||||
pub _source: Source,
|
|
||||||
pub result: String,
|
|
||||||
pub payload: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Source {
|
|
||||||
pub id: String,
|
|
||||||
pub created: String,
|
|
||||||
pub updated: String,
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
|
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
let body = response
|
let body = response
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod assistant;
|
pub mod assistant;
|
||||||
pub mod auth;
|
|
||||||
pub mod connector;
|
pub mod connector;
|
||||||
pub mod datasource;
|
pub mod datasource;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod autostart;
|
|||||||
mod common;
|
mod common;
|
||||||
mod extension;
|
mod extension;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod selection_monitor;
|
||||||
mod server;
|
mod server;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod setup;
|
mod setup;
|
||||||
@@ -72,24 +73,13 @@ async fn change_window_height(handle: AppHandle, height: u32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
// Removed unused Payload to avoid unnecessary serde derive macro invocations
|
||||||
struct ThemeChangedPayload {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
is_dark_mode: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct Payload {
|
|
||||||
args: Vec<String>,
|
|
||||||
cwd: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let ctx = tauri::generate_context!();
|
let ctx = tauri::generate_context!();
|
||||||
|
|
||||||
let mut app_builder = tauri::Builder::default();
|
let mut app_builder = tauri::Builder::default().plugin(tauri_plugin_clipboard_manager::init());
|
||||||
// Set up logger first
|
// Set up logger first
|
||||||
app_builder = app_builder.plugin(set_up_tauri_logger());
|
app_builder = app_builder.plugin(set_up_tauri_logger());
|
||||||
|
|
||||||
@@ -208,7 +198,9 @@ pub fn run() {
|
|||||||
setup::backend_setup,
|
setup::backend_setup,
|
||||||
util::app_lang::update_app_lang,
|
util::app_lang::update_app_lang,
|
||||||
util::path::path_absolute,
|
util::path::path_absolute,
|
||||||
util::logging::app_log_dir
|
util::logging::app_log_dir,
|
||||||
|
selection_monitor::set_selection_enabled,
|
||||||
|
selection_monitor::get_selection_enabled,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -218,7 +210,6 @@ pub fn run() {
|
|||||||
log::trace!("Dock icon should be hidden now");
|
log::trace!("Dock icon should be hidden now");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
|
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
|
||||||
let app_handle = app.app_handle();
|
let app_handle = app.app_handle();
|
||||||
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||||
|
|||||||
373
src-tauri/src/selection_monitor.rs
Normal file
373
src-tauri/src/selection_monitor.rs
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/// Event payload sent to the frontend when selection is detected.
|
||||||
|
/// Coordinates use logical (Quartz) points with a top-left origin.
|
||||||
|
/// Note: `y` is flipped on the backend to match the frontend’s usage.
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Clone)]
|
||||||
|
struct SelectionEventPayload {
|
||||||
|
text: String,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
/// Global toggle: selection monitoring enabled by default.
|
||||||
|
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Clone)]
|
||||||
|
struct SelectionEnabledPayload {
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current selection monitoring state.
|
||||||
|
pub fn is_selection_enabled() -> bool {
|
||||||
|
SELECTION_ENABLED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the monitoring state and broadcast to the frontend.
|
||||||
|
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
|
||||||
|
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
|
||||||
|
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command: set selection monitoring state.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
|
||||||
|
set_selection_enabled_internal(&app_handle, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command: get selection monitoring state.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_selection_enabled() -> bool {
|
||||||
|
is_selection_enabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||||
|
// Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread.
|
||||||
|
log::info!("start_selection_monitor: 入口函数启动");
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
// Sync initial enabled state to the frontend on startup.
|
||||||
|
set_selection_enabled_internal(&app_handle, is_selection_enabled());
|
||||||
|
|
||||||
|
// Accessibility permission is required to read selected text in the foreground app.
|
||||||
|
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
|
||||||
|
if !trusted_before {
|
||||||
|
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
|
||||||
|
}
|
||||||
|
let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
|
||||||
|
if !trusted_after {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
log::info!("start_selection_monitor: 非 macOS 平台,无划词监控");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background thread: drives popup show/hide based on mouse and AX selection state.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use objc2_app_kit::NSWorkspace;
|
||||||
|
use objc2_core_graphics::CGEvent;
|
||||||
|
use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
|
||||||
|
|
||||||
|
// Get current mouse position (logical top-left origin), flipping `y` to match frontend usage.
|
||||||
|
let current_mouse_point_global = || -> (i32, i32) {
|
||||||
|
unsafe {
|
||||||
|
let event = CGEvent::new(None);
|
||||||
|
let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
|
||||||
|
|
||||||
|
// Enumerate active displays to compute global bounds and pick the display containing the cursor.
|
||||||
|
let mut displays: [u32; 16] = [0; 16];
|
||||||
|
let mut display_count: u32 = 0;
|
||||||
|
let _ = CGGetActiveDisplayList(
|
||||||
|
displays.len() as u32,
|
||||||
|
displays.as_mut_ptr(),
|
||||||
|
&mut display_count,
|
||||||
|
);
|
||||||
|
if display_count == 0 {
|
||||||
|
// Fallback to main display.
|
||||||
|
let did = CGMainDisplayID();
|
||||||
|
let b = CGDisplayBounds(did);
|
||||||
|
let min_x_pt = b.origin.x as f64;
|
||||||
|
let max_top_pt = (b.origin.y + b.size.height) as f64;
|
||||||
|
let min_bottom_pt = b.origin.y as f64;
|
||||||
|
let total_h_pt = max_top_pt - min_bottom_pt;
|
||||||
|
|
||||||
|
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
|
||||||
|
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
|
||||||
|
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
|
||||||
|
|
||||||
|
return (x_top_left, y_flipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chosen = CGMainDisplayID(); // default fallback
|
||||||
|
log::info!(
|
||||||
|
"current_mouse: pt=({:.1},{:.1}) → display={}",
|
||||||
|
pt.x as f64,
|
||||||
|
pt.y as f64,
|
||||||
|
chosen
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut min_x_pt = f64::INFINITY;
|
||||||
|
let mut max_top_pt = f64::NEG_INFINITY;
|
||||||
|
let mut min_bottom_pt = f64::INFINITY;
|
||||||
|
for i in 0..display_count as usize {
|
||||||
|
let did = displays[i];
|
||||||
|
let b = CGDisplayBounds(did);
|
||||||
|
if (b.origin.x as f64) < min_x_pt {
|
||||||
|
min_x_pt = b.origin.x as f64;
|
||||||
|
}
|
||||||
|
let top = (b.origin.y + b.size.height) as f64;
|
||||||
|
if top > max_top_pt {
|
||||||
|
max_top_pt = top;
|
||||||
|
}
|
||||||
|
if (b.origin.y as f64) < min_bottom_pt {
|
||||||
|
min_bottom_pt = b.origin.y as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
let in_x = pt.x >= b.origin.x && pt.x <= b.origin.x + b.size.width;
|
||||||
|
let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height;
|
||||||
|
if in_x && in_y {
|
||||||
|
chosen = did;
|
||||||
|
log::info!(
|
||||||
|
"current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
|
||||||
|
pt.x as f64,
|
||||||
|
pt.y as f64,
|
||||||
|
chosen,
|
||||||
|
b.origin.x,
|
||||||
|
b.origin.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_h_pt = max_top_pt - min_bottom_pt;
|
||||||
|
|
||||||
|
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
|
||||||
|
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
|
||||||
|
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
|
||||||
|
|
||||||
|
(x_top_left, y_flipped)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine whether the frontmost app is this process (Coco).
|
||||||
|
// Avoid misinterpreting empty selection when interacting with the popup itself.
|
||||||
|
let is_frontmost_app_me = || -> bool {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
unsafe {
|
||||||
|
let workspace = NSWorkspace::sharedWorkspace();
|
||||||
|
if let Some(frontmost) = workspace.frontmostApplication() {
|
||||||
|
let pid = frontmost.processIdentifier();
|
||||||
|
let my_pid = std::process::id() as i32;
|
||||||
|
return pid == my_pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection-driven state machine.
|
||||||
|
let mut popup_visible = false;
|
||||||
|
let mut last_text = String::new();
|
||||||
|
|
||||||
|
// Stability and hide thresholds (tunable).
|
||||||
|
let stable_threshold = 2; // same content ≥2 times → stable selection
|
||||||
|
let empty_threshold = 2; // empty value ≥2 times → stable empty
|
||||||
|
let mut stable_text = String::new();
|
||||||
|
let mut stable_count = 0;
|
||||||
|
let mut empty_count = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_millis(30));
|
||||||
|
|
||||||
|
// If disabled: do not read AX / do not show popup; hide if currently visible.
|
||||||
|
if !is_selection_enabled() {
|
||||||
|
if popup_visible {
|
||||||
|
let _ = app_handle.emit("selection-detected", "");
|
||||||
|
popup_visible = false;
|
||||||
|
last_text.clear();
|
||||||
|
stable_text.clear();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty-selection hide checks while interacting with the Coco popup.
|
||||||
|
let front_is_me = is_frontmost_app_me();
|
||||||
|
|
||||||
|
// Lightweight retries to smooth out transient AX focus instability.
|
||||||
|
let selected_text = if front_is_me {
|
||||||
|
// Do not read selection during popup interaction to avoid false empty.
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// Up to 2 retries, 35ms apart.
|
||||||
|
read_selected_text_with_retries(2, 35)
|
||||||
|
};
|
||||||
|
|
||||||
|
match selected_text {
|
||||||
|
Some(text) if !text.is_empty() => {
|
||||||
|
empty_count = 0;
|
||||||
|
if text == stable_text {
|
||||||
|
stable_count += 1;
|
||||||
|
} else {
|
||||||
|
stable_text = text.clone();
|
||||||
|
stable_count = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update/show only when selection is stable to avoid flicker.
|
||||||
|
if stable_count >= stable_threshold {
|
||||||
|
if !popup_visible || text != last_text {
|
||||||
|
let (x, y) = current_mouse_point_global();
|
||||||
|
let payload = SelectionEventPayload {
|
||||||
|
text: text.clone(),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app_handle.emit("selection-detected", payload);
|
||||||
|
last_text = text;
|
||||||
|
popup_visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// If not Coco in front and selection is empty: accumulate empties, then hide.
|
||||||
|
if !front_is_me {
|
||||||
|
stable_count = 0;
|
||||||
|
empty_count += 1;
|
||||||
|
if popup_visible && empty_count >= empty_threshold {
|
||||||
|
let _ = app_handle.emit("selection-detected", "");
|
||||||
|
popup_visible = false;
|
||||||
|
last_text.clear();
|
||||||
|
stable_text.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When Coco is frontmost: do not hide or clear state during interaction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS-wide accessibility entry point: allows reading system-level focused elements.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the selected text of the frontmost application (without using the clipboard).
|
||||||
|
/// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn read_selected_text() -> Option<String> {
|
||||||
|
use objc2_app_kit::NSWorkspace;
|
||||||
|
use objc2_application_services::{AXError, AXUIElement};
|
||||||
|
use objc2_core_foundation::{CFRetained, CFString, CFType};
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
// Prefer system-wide focused element; if unavailable, fall back to app/window focused element.
|
||||||
|
let mut focused_ui_ptr: *const CFType = std::ptr::null();
|
||||||
|
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
|
||||||
|
|
||||||
|
// System-wide focused UI element.
|
||||||
|
let system_elem = unsafe { AXUIElementCreateSystemWide() };
|
||||||
|
if !system_elem.is_null() {
|
||||||
|
let system_elem_retained: CFRetained<AXUIElement> =
|
||||||
|
unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) };
|
||||||
|
let err = unsafe {
|
||||||
|
system_elem_retained
|
||||||
|
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
|
||||||
|
};
|
||||||
|
if err != AXError::Success {
|
||||||
|
focused_ui_ptr = std::ptr::null();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the frontmost app's focused/window element.
|
||||||
|
if focused_ui_ptr.is_null() {
|
||||||
|
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
|
||||||
|
let frontmost_app = unsafe { workspace.frontmostApplication() }?;
|
||||||
|
let pid = unsafe { frontmost_app.processIdentifier() };
|
||||||
|
|
||||||
|
// Skip if frontmost is Coco (this process).
|
||||||
|
let my_pid = std::process::id() as i32;
|
||||||
|
if pid == my_pid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_element = unsafe { AXUIElement::new_application(pid) };
|
||||||
|
let err = unsafe {
|
||||||
|
app_element
|
||||||
|
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
|
||||||
|
};
|
||||||
|
if err != AXError::Success || focused_ui_ptr.is_null() {
|
||||||
|
// Try `AXFocusedWindow` as a lightweight fallback.
|
||||||
|
let mut focused_window_ptr: *const CFType = std::ptr::null();
|
||||||
|
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
|
||||||
|
let w_err = unsafe {
|
||||||
|
app_element.copy_attribute_value(
|
||||||
|
&focused_window_attr,
|
||||||
|
NonNull::new(&mut focused_window_ptr).unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if w_err != AXError::Success || focused_window_ptr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
focused_ui_ptr = focused_window_ptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
|
||||||
|
let focused_ui: CFRetained<AXUIElement> =
|
||||||
|
unsafe { CFRetained::from_raw(NonNull::new(focused_ui_elem).unwrap()) };
|
||||||
|
|
||||||
|
// Prefer `AXSelectedText`; otherwise return None (can be extended to read ranges).
|
||||||
|
let mut selected_text_ptr: *const CFType = std::ptr::null();
|
||||||
|
let selected_text_attr = CFString::from_static_str("AXSelectedText");
|
||||||
|
let err = unsafe {
|
||||||
|
focused_ui.copy_attribute_value(
|
||||||
|
&selected_text_attr,
|
||||||
|
NonNull::new(&mut selected_text_ptr).unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if err != AXError::Success || selected_text_ptr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFString → Rust String
|
||||||
|
let selected_cfstr: CFRetained<CFString> = unsafe {
|
||||||
|
CFRetained::from_raw(NonNull::new(selected_text_ptr.cast::<CFString>().cast_mut()).unwrap())
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(selected_cfstr.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read selected text with lightweight retries to handle transient AX focus instability.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
for attempt in 0..=retries {
|
||||||
|
if let Some(text) = read_selected_text() {
|
||||||
|
if !text.is_empty() {
|
||||||
|
if attempt > 0 {
|
||||||
|
log::info!(
|
||||||
|
"read_selected_text: 第{}次重试成功,获取到选中文本",
|
||||||
|
attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attempt < retries {
|
||||||
|
thread::sleep(Duration::from_millis(delay_ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -107,6 +107,12 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
|
|||||||
})
|
})
|
||||||
.expect("failed to run this closure on the main thread");
|
.expect("failed to run this closure on the main thread");
|
||||||
|
|
||||||
|
// Start system-wide selection monitor (macOS-only currently)
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
crate::selection_monitor::start_selection_monitor(tauri_app_handle.clone());
|
||||||
|
}
|
||||||
|
|
||||||
crate::init(&tauri_app_handle).await;
|
crate::init(&tauri_app_handle).await;
|
||||||
|
|
||||||
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
|
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
|
||||||
|
|||||||
@@ -78,6 +78,29 @@
|
|||||||
"state": "active",
|
"state": "active",
|
||||||
"radius": 7
|
"radius": 7
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "selection",
|
||||||
|
"title": "Selection",
|
||||||
|
"alwaysOnTop": true,
|
||||||
|
"shadow": false,
|
||||||
|
"decorations": false,
|
||||||
|
"transparent": true,
|
||||||
|
"closable": true,
|
||||||
|
"minimizable": false,
|
||||||
|
"maximizable": false,
|
||||||
|
"dragDropEnabled": false,
|
||||||
|
"resizable": false,
|
||||||
|
"center": false,
|
||||||
|
"url": "/ui/selection",
|
||||||
|
"hiddenTitle": true,
|
||||||
|
"visible": false,
|
||||||
|
"acceptFirstMouse": true,
|
||||||
|
"windowEffects": {
|
||||||
|
"effects": [],
|
||||||
|
"state": "active",
|
||||||
|
"radius": 7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const AssistantFetcher = ({
|
|||||||
query?: string;
|
query?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
if (await unrequitable()) {
|
// Only gate by current window service when no explicit serverId provided.
|
||||||
|
if (!params.serverId && (await unrequitable())) {
|
||||||
return {
|
return {
|
||||||
total: 0,
|
total: 0,
|
||||||
list: [],
|
list: [],
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
const setAskAiAssistantId = useSearchStore((state) => {
|
const setAskAiAssistantId = useSearchStore((state) => {
|
||||||
return state.setAskAiAssistantId;
|
return state.setAskAiAssistantId;
|
||||||
});
|
});
|
||||||
|
const targetAssistantId = useSearchStore((state) => state.targetAssistantId);
|
||||||
|
const setTargetAssistantId = useSearchStore((state) => {
|
||||||
|
return state.setTargetAssistantId;
|
||||||
|
});
|
||||||
|
|
||||||
const { fetchAssistant } = AssistantFetcher({
|
const { fetchAssistant } = AssistantFetcher({
|
||||||
debounceKeyword,
|
debounceKeyword,
|
||||||
@@ -81,17 +85,22 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
|
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!askAiAssistantId || assistantList.length === 0) return;
|
const targetId = askAiAssistantId ?? targetAssistantId;
|
||||||
|
if (!targetId || assistantList.length === 0) return;
|
||||||
const matched = assistantList.find((item) => {
|
|
||||||
return item._id === askAiAssistantId;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const matched = assistantList.find((item) => item._id === targetId);
|
||||||
if (!matched) return;
|
if (!matched) return;
|
||||||
|
|
||||||
setCurrentAssistant(matched);
|
if (currentAssistant?._id !== matched._id) {
|
||||||
setAskAiAssistantId(void 0);
|
setCurrentAssistant(matched);
|
||||||
}, [assistantList, askAiAssistantId]);
|
}
|
||||||
|
|
||||||
|
if (askAiAssistantId) {
|
||||||
|
setAskAiAssistantId(void 0);
|
||||||
|
} else if (targetAssistantId) {
|
||||||
|
setTargetAssistantId(void 0);
|
||||||
|
}
|
||||||
|
}, [assistantList, askAiAssistantId, targetAssistantId]);
|
||||||
|
|
||||||
useKeyPress(
|
useKeyPress(
|
||||||
["uparrow", "downarrow", "enter"],
|
["uparrow", "downarrow", "enter"],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useMount, useMutationObserver } from "ahooks";
|
import { useMount, useMutationObserver } from "ahooks";
|
||||||
|
import { debounce } from "lodash-es";
|
||||||
|
|
||||||
import Search from "@/components/Search/Search";
|
import Search from "@/components/Search/Search";
|
||||||
import InputBox from "@/components/Search/InputBox";
|
import InputBox from "@/components/Search/InputBox";
|
||||||
@@ -36,7 +37,7 @@ import {
|
|||||||
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
import { debounce } from "lodash-es";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
|
||||||
interface SearchChatProps {
|
interface SearchChatProps {
|
||||||
isTauri?: boolean;
|
isTauri?: boolean;
|
||||||
@@ -314,6 +315,43 @@ function SearchChat({
|
|||||||
|
|
||||||
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenAsk = platformAdapter.listenEvent("selection-ask-ai", ({ payload }: any) => {
|
||||||
|
const value = typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||||
|
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
||||||
|
dispatch({ type: "SET_INPUT", payload: value });
|
||||||
|
platformAdapter.showWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenAction = platformAdapter.listenEvent("selection-action", ({ payload }: any) => {
|
||||||
|
const { action, text, assistantId } = payload || {};
|
||||||
|
const value = String(text ?? "");
|
||||||
|
if (action === "search") {
|
||||||
|
dispatch({ type: "SET_CHAT_MODE", payload: false });
|
||||||
|
dispatch({ type: "SET_INPUT", payload: value });
|
||||||
|
const { setSearchValue } = useSearchStore.getState();
|
||||||
|
setSearchValue(value);
|
||||||
|
platformAdapter.showWindow();
|
||||||
|
} else if (action === "chat") {
|
||||||
|
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
||||||
|
dispatch({ type: "SET_INPUT", payload: value });
|
||||||
|
|
||||||
|
const { assistantList } = useConnectStore.getState();
|
||||||
|
const assistant = assistantList.find((item) => item._source?.id === assistantId);
|
||||||
|
if (assistant) {
|
||||||
|
const { setTargetAssistantId } = useSearchStore.getState();
|
||||||
|
setTargetAssistantId(assistant._id);
|
||||||
|
}
|
||||||
|
platformAdapter.showWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenAsk.then((fn) => fn());
|
||||||
|
unlistenAction.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
changeMode(defaultStartupWindow === "chatMode");
|
changeMode(defaultStartupWindow === "chatMode");
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
GripVertical,
|
||||||
|
Bot,
|
||||||
|
Copy,
|
||||||
|
Languages,
|
||||||
|
Search,
|
||||||
|
Volume2,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
||||||
|
import { setCurrentWindowService } from "@/commands/windowService";
|
||||||
|
|
||||||
|
type ActionType =
|
||||||
|
| "search"
|
||||||
|
| "ask_ai"
|
||||||
|
| "translate"
|
||||||
|
| "summary"
|
||||||
|
| "copy"
|
||||||
|
| "speak"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
type LucideIconName =
|
||||||
|
| "Search"
|
||||||
|
| "Bot"
|
||||||
|
| "Languages"
|
||||||
|
| "FileText"
|
||||||
|
| "Copy"
|
||||||
|
| "Volume2";
|
||||||
|
|
||||||
|
type IconConfig =
|
||||||
|
| { type: "lucide"; name: LucideIconName; color?: string }
|
||||||
|
| { type: "custom"; dataUrl: string; color?: string };
|
||||||
|
|
||||||
|
export type ButtonConfig = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: IconConfig;
|
||||||
|
action: {
|
||||||
|
type: ActionType;
|
||||||
|
assistantId?: string;
|
||||||
|
assistantServerId?: string;
|
||||||
|
eventName?: string;
|
||||||
|
};
|
||||||
|
labelKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LUCIDE_ICON_MAP: Record<LucideIconName, any> = {
|
||||||
|
Search,
|
||||||
|
Bot,
|
||||||
|
Languages,
|
||||||
|
FileText,
|
||||||
|
Copy,
|
||||||
|
Volume2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
|
||||||
|
|
||||||
|
type AssistantCacheItem = {
|
||||||
|
list: any[];
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadAssistantCache(): Record<string, AssistantCacheItem> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(ASSISTANT_CACHE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === "object") return parsed;
|
||||||
|
return {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAssistantCache(cache: Record<string, AssistantCacheItem>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ASSISTANT_CACHE_KEY, JSON.stringify(cache));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Persist assistant cache failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonsListProps = {
|
||||||
|
buttons: ButtonConfig[];
|
||||||
|
setButtons: React.Dispatch<React.SetStateAction<ButtonConfig[]>>;
|
||||||
|
serverList: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { fetchAssistant } = AssistantFetcher({});
|
||||||
|
|
||||||
|
const [assistantByServer, setAssistantByServer] = useState<Record<string, any[]>>({});
|
||||||
|
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<Record<string, boolean>>({});
|
||||||
|
const [assistantCache, setAssistantCacheState] = useState<Record<string, AssistantCacheItem>>(() => loadAssistantCache());
|
||||||
|
|
||||||
|
const dragIndexRef = useRef<number | null>(null);
|
||||||
|
const initializedServiceRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const onDragStart = (index: number) => {
|
||||||
|
dragIndexRef.current = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (index: number) => {
|
||||||
|
const from = dragIndexRef.current;
|
||||||
|
dragIndexRef.current = null;
|
||||||
|
if (from === null || from === index) return;
|
||||||
|
setButtons((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(index, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAction = (id: string, patch: Partial<ButtonConfig["action"]>) => {
|
||||||
|
setButtons((prev) => prev.map((b) => (b.id === id ? { ...b, action: { ...b.action, ...patch } } : b)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssistantSelect = (btn: ButtonConfig, value: string) => {
|
||||||
|
const id = value || undefined;
|
||||||
|
updateAction(btn.id, { assistantId: id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerSelect = async (btn: ButtonConfig, serverId: string) => {
|
||||||
|
const sid = serverId || undefined;
|
||||||
|
try {
|
||||||
|
const target = serverList.find((s: any) => s.id === sid);
|
||||||
|
if (target) {
|
||||||
|
await setCurrentWindowService(target);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("setCurrentWindowService failed:", e);
|
||||||
|
}
|
||||||
|
updateAction(btn.id, { assistantServerId: sid, assistantId: undefined });
|
||||||
|
if (!sid) return;
|
||||||
|
|
||||||
|
const cached = assistantCache[sid];
|
||||||
|
if (cached && Array.isArray(cached.list)) {
|
||||||
|
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
|
||||||
|
}
|
||||||
|
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||||
|
try {
|
||||||
|
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
|
||||||
|
const list = data.list || [];
|
||||||
|
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||||
|
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
||||||
|
setAssistantCacheState(nextCache);
|
||||||
|
saveAssistantCache(nextCache);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fetch assistants for server failed:", err);
|
||||||
|
setAssistantByServer((prev) => ({ ...prev, [sid]: [] }));
|
||||||
|
} finally {
|
||||||
|
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedServiceRef.current) return;
|
||||||
|
initializedServiceRef.current = true;
|
||||||
|
|
||||||
|
const preferredSid =
|
||||||
|
buttons.find((b) => b.action.assistantServerId)?.action.assistantServerId ||
|
||||||
|
Object.keys(assistantCache)[0];
|
||||||
|
|
||||||
|
if (!preferredSid) return;
|
||||||
|
const target = serverList.find((s: any) => s.id === preferredSid);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
setCurrentWindowService(target).catch((e) => {
|
||||||
|
console.error("init setCurrentWindowService failed:", e);
|
||||||
|
});
|
||||||
|
}, [serverList, buttons]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const uniqueServerIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
buttons
|
||||||
|
.map((b) => b.action.assistantServerId)
|
||||||
|
.filter((sid): sid is string => Boolean(sid))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
uniqueServerIds.forEach(async (sid) => {
|
||||||
|
if (!sid) return;
|
||||||
|
const cached = assistantCache[sid];
|
||||||
|
if (cached && Array.isArray(cached.list)) {
|
||||||
|
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
|
||||||
|
}
|
||||||
|
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||||
|
try {
|
||||||
|
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
|
||||||
|
const list = data.list || [];
|
||||||
|
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||||
|
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
||||||
|
setAssistantCacheState(nextCache);
|
||||||
|
saveAssistantCache(nextCache);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Prefetch assistants for stored server failed:", err);
|
||||||
|
} finally {
|
||||||
|
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [buttons]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{buttons.map((btn, index) => {
|
||||||
|
const IconComp = btn.icon.type === "lucide" ? LUCIDE_ICON_MAP[btn.icon.name] : null;
|
||||||
|
const isChat = ["ask_ai", "translate", "summary"].includes(btn.action.type);
|
||||||
|
const visualType: "Chat" | "Search" | "Tool" = isChat ? "Chat" : btn.action.type === "search" ? "Search" : "Tool";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={btn.id}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-white dark:bg-[#0B1220] shadow-sm",
|
||||||
|
"p-3"
|
||||||
|
)}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => onDragStart(index)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => onDrop(index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<GripVertical className="size-4 text-[#64748B] shrink-0" />
|
||||||
|
{IconComp ? (
|
||||||
|
<IconComp className="size-4 shrink-0" style={{ color: btn.icon.color || "#6B7280" }} />
|
||||||
|
) : (
|
||||||
|
<img src={(btn.icon as any).dataUrl} alt="icon" className="w-4 h-4 rounded shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{btn.labelKey ? t(btn.labelKey) : btn.label}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"ml-2 inline-flex items-center rounded px-2 py-0.5 text-xs",
|
||||||
|
visualType === "Chat"
|
||||||
|
? "bg-[#0287FF]/10 text-[#0287FF]"
|
||||||
|
: visualType === "Search"
|
||||||
|
? "bg-[#6366F1]/10 text-[#6366F1]"
|
||||||
|
: "bg-[#64748B]/10 text-[#64748B]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visualType}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{isChat && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
||||||
|
value={btn.action.assistantServerId || ""}
|
||||||
|
onChange={(e) => handleServerSelect(btn, e.target.value)}
|
||||||
|
title={t("selection.bind.service")}
|
||||||
|
>
|
||||||
|
<option value="">{t("selection.bind.defaultService")}</option>
|
||||||
|
{serverList.map((s: any) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name || s.endpoint || s.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const sid = btn.action.assistantServerId;
|
||||||
|
const list = (sid && assistantByServer[sid]) || [];
|
||||||
|
const loading = !!(sid && assistantLoadingByServer[sid]);
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
||||||
|
value={btn.action.assistantId || ""}
|
||||||
|
onChange={(e) => handleAssistantSelect(btn, e.target.value)}
|
||||||
|
title={t("selection.bind.assistant")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="">{t("selection.bind.defaultAssistant")}</option>
|
||||||
|
{loading && (
|
||||||
|
<option value="" disabled>
|
||||||
|
加载中...
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{list.map((a: any) => (
|
||||||
|
<option key={a._id} value={a._id}>
|
||||||
|
{a._source?.name || a._id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ButtonsList;
|
||||||
202
src/components/Settings/Advanced/components/Selection/index.tsx
Normal file
202
src/components/Settings/Advanced/components/Selection/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useSelectionStore } from "@/stores/selectionStore";
|
||||||
|
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||||
|
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import { useEnabledServers } from "@/hooks/useEnabledServers";
|
||||||
|
import ButtonsList from "./ButtonsList";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection toolbar button config types
|
||||||
|
*/
|
||||||
|
type IconConfig =
|
||||||
|
| { type: "lucide"; name: LucideIconName; color?: string }
|
||||||
|
| { type: "custom"; dataUrl: string; color?: string };
|
||||||
|
|
||||||
|
type ActionType =
|
||||||
|
| "search"
|
||||||
|
| "ask_ai"
|
||||||
|
| "translate"
|
||||||
|
| "summary"
|
||||||
|
| "copy"
|
||||||
|
| "speak"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
type ButtonConfig = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: IconConfig;
|
||||||
|
action: {
|
||||||
|
type: ActionType;
|
||||||
|
assistantId?: string;
|
||||||
|
assistantServerId?: string;
|
||||||
|
eventName?: string;
|
||||||
|
};
|
||||||
|
// i18n key for built-in labels; if present, render by t(labelKey)
|
||||||
|
labelKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LucideIconName =
|
||||||
|
| "Search"
|
||||||
|
| "Bot"
|
||||||
|
| "Languages"
|
||||||
|
| "FileText"
|
||||||
|
| "Copy"
|
||||||
|
| "Volume2";
|
||||||
|
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: ButtonConfig[] = [
|
||||||
|
{
|
||||||
|
id: "search",
|
||||||
|
label: "搜索",
|
||||||
|
labelKey: "selection.actions.search",
|
||||||
|
icon: { type: "lucide", name: "Search", color: "#6366F1" },
|
||||||
|
action: { type: "search" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ask_ai",
|
||||||
|
label: "问答",
|
||||||
|
labelKey: "selection.actions.ask_ai",
|
||||||
|
icon: { type: "lucide", name: "Bot", color: "#0287FF" },
|
||||||
|
action: { type: "ask_ai" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "translate",
|
||||||
|
label: "翻译",
|
||||||
|
labelKey: "selection.actions.translate",
|
||||||
|
icon: { type: "lucide", name: "Languages", color: "#14B8A6" },
|
||||||
|
action: { type: "translate" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "summary",
|
||||||
|
label: "总结",
|
||||||
|
labelKey: "selection.actions.summary",
|
||||||
|
icon: { type: "lucide", name: "FileText", color: "#0EA5E9" },
|
||||||
|
action: { type: "summary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copy",
|
||||||
|
label: "复制",
|
||||||
|
labelKey: "selection.actions.copy",
|
||||||
|
icon: { type: "lucide", name: "Copy", color: "#64748B" },
|
||||||
|
action: { type: "copy" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "speak",
|
||||||
|
label: "朗读",
|
||||||
|
labelKey: "selection.actions.speak",
|
||||||
|
icon: { type: "lucide", name: "Volume2", color: "#F59E0B" },
|
||||||
|
action: { type: "speak" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "selection_toolbar_config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilities: load/save local toolbar config
|
||||||
|
*/
|
||||||
|
function loadToolbarConfig(): ButtonConfig[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return DEFAULT_CONFIG;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0)
|
||||||
|
return parsed as ButtonConfig[];
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveToolbarConfig(cfg: ButtonConfig[]) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection settings panel: toolbar buttons with sorting and assistant mapping
|
||||||
|
*/
|
||||||
|
const SelectionSettings = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Reactive service and assistant list
|
||||||
|
const { enabledServers: serverList } = useEnabledServers();
|
||||||
|
|
||||||
|
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
|
||||||
|
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
|
||||||
|
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useSelectionStore.getState().initSync();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize from global store; write back on change for multi-window sync
|
||||||
|
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||||
|
const setToolbarConfig = useSelectionStore((s) => s.setToolbarConfig);
|
||||||
|
|
||||||
|
const [buttons, setButtons] = useState<ButtonConfig[]>(() =>
|
||||||
|
loadToolbarConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// prefer store config if present
|
||||||
|
if (Array.isArray(toolbarConfig) && toolbarConfig.length > 0) {
|
||||||
|
setButtons(toolbarConfig as ButtonConfig[]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveToolbarConfig(buttons);
|
||||||
|
setToolbarConfig(buttons); // push to store for multi-window
|
||||||
|
}, [buttons]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
icon={Sparkles}
|
||||||
|
title={t("settings.ai.title")}
|
||||||
|
description={t("settings.ai.description")}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
checked={selectionEnabled}
|
||||||
|
onChange={async (value) => {
|
||||||
|
try {
|
||||||
|
await platformAdapter.invokeBackend("set_selection_enabled", {
|
||||||
|
enabled: value,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("set_selection_enabled invoke failed:", e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={t("settings.ai.toggle")}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
{selectionEnabled && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<SettingsItem
|
||||||
|
icon={Sparkles}
|
||||||
|
title={t("selection.display.title")}
|
||||||
|
description={t("selection.display.iconsOnlyDesc")}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
checked={iconsOnly}
|
||||||
|
onChange={async (value) => {
|
||||||
|
// Update local store
|
||||||
|
setIconsOnly(value);
|
||||||
|
}}
|
||||||
|
label={t("selection.display.iconsOnlyLabel")}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
<ButtonsList buttons={buttons} setButtons={setButtons} serverList={serverList} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionSettings;
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Unplug,
|
Unplug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMount } from "ahooks";
|
import { useMount } from "ahooks";
|
||||||
|
import { isNil } from "lodash-es";
|
||||||
|
|
||||||
import Shortcuts from "./components/Shortcuts";
|
import Shortcuts from "./components/Shortcuts";
|
||||||
import SettingsItem from "../SettingsItem";
|
import SettingsItem from "../SettingsItem";
|
||||||
@@ -20,7 +21,7 @@ import SettingsInput from "@/components//Settings/SettingsInput";
|
|||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import UpdateSettings from "./components/UpdateSettings";
|
import UpdateSettings from "./components/UpdateSettings";
|
||||||
import SettingsToggle from "../SettingsToggle";
|
import SettingsToggle from "../SettingsToggle";
|
||||||
import { isNil } from "lodash-es";
|
import SelectionSettings from "./components/Selection";
|
||||||
|
|
||||||
const Advanced = () => {
|
const Advanced = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -189,6 +190,8 @@ const Advanced = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectionSettings />
|
||||||
|
|
||||||
<Shortcuts />
|
<Shortcuts />
|
||||||
|
|
||||||
<Appearance />
|
<Appearance />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { isTauri } from "@tauri-apps/api/core";
|
|||||||
import { isEnabled } from "@tauri-apps/plugin-autostart";
|
import { isEnabled } from "@tauri-apps/plugin-autostart";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import { useCreation } from "ahooks";
|
import { useCreation } from "ahooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
import SettingsItem from "./SettingsItem";
|
import SettingsItem from "./SettingsItem";
|
||||||
import SettingsToggle from "./SettingsToggle";
|
import SettingsToggle from "./SettingsToggle";
|
||||||
@@ -34,7 +35,6 @@ import {
|
|||||||
unregister_shortcut,
|
unregister_shortcut,
|
||||||
} from "@/commands";
|
} from "@/commands";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import clsx from "clsx";
|
|
||||||
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
|
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
|
||||||
|
|
||||||
export function ThemeOption({
|
export function ThemeOption({
|
||||||
@@ -83,6 +83,8 @@ export default function GeneralSettings() {
|
|||||||
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
|
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
|
||||||
const { windowMode, setWindowMode } = useAppearanceStore();
|
const { windowMode, setWindowMode } = useAppearanceStore();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const fetchAutoStartStatus = async () => {
|
const fetchAutoStartStatus = async () => {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
try {
|
try {
|
||||||
@@ -305,6 +307,8 @@ export default function GeneralSettings() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title={t("settings.language.title")}
|
title={t("settings.language.title")}
|
||||||
|
|||||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
21
src/hooks/useEnabledServers.ts
Normal file
21
src/hooks/useEnabledServers.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
|
import { getEnabledServers } from "@/utils/servers";
|
||||||
|
import { useServers } from "./useServers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: returns enabled & available servers, plus refresh function.
|
||||||
|
*/
|
||||||
|
export function useEnabledServers() {
|
||||||
|
const serverList = useConnectStore((s) => s.serverList);
|
||||||
|
const { refreshServerList } = useServers();
|
||||||
|
|
||||||
|
const enabledServers = useMemo(() => {
|
||||||
|
const list = getEnabledServers(serverList);
|
||||||
|
// Further filter to public servers or those with user profile (logged-in)
|
||||||
|
return list.filter((s) => s.public || s.profile);
|
||||||
|
}, [serverList]);
|
||||||
|
|
||||||
|
return { enabledServers, refreshServerList };
|
||||||
|
}
|
||||||
77
src/hooks/useSelectionPanel.ts
Normal file
77
src/hooks/useSelectionPanel.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { debounce } from "lodash-es";
|
||||||
|
|
||||||
|
export interface SelectionState {
|
||||||
|
text: string;
|
||||||
|
rect: DOMRect | null;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectionPanel() {
|
||||||
|
const [state, setState] = useState<SelectionState>({
|
||||||
|
text: "",
|
||||||
|
rect: null,
|
||||||
|
visible: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestTextRef = useRef<string>("");
|
||||||
|
|
||||||
|
const computeRect = useCallback((): DOMRect | null => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
||||||
|
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
if (!rect || rect.width === 0 || rect.height === 0) return null;
|
||||||
|
return rect;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSelection = useCallback(() => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const text = sel?.toString().trim() ?? "";
|
||||||
|
const rect = computeRect();
|
||||||
|
|
||||||
|
if (!text || !rect) {
|
||||||
|
setState((prev) => ({ ...prev, visible: false }));
|
||||||
|
latestTextRef.current = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress duplicates to avoid flicker and needless IPC
|
||||||
|
if (text === latestTextRef.current && state.visible) {
|
||||||
|
// Only reposition on scroll/resize
|
||||||
|
setState((prev) => ({ ...prev, rect }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestTextRef.current = text;
|
||||||
|
setState({ text, rect, visible: true });
|
||||||
|
}, [computeRect, state.visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMouseUp = debounce(updateSelection, 50);
|
||||||
|
const onSelectionChange = debounce(updateSelection, 80);
|
||||||
|
|
||||||
|
document.addEventListener("selectionchange", onSelectionChange);
|
||||||
|
document.addEventListener("mouseup", onMouseUp);
|
||||||
|
window.addEventListener("scroll", onSelectionChange, { passive: true });
|
||||||
|
window.addEventListener("resize", onSelectionChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("selectionchange", onSelectionChange);
|
||||||
|
document.removeEventListener("mouseup", onMouseUp);
|
||||||
|
window.removeEventListener("scroll", onSelectionChange);
|
||||||
|
window.removeEventListener("resize", onSelectionChange);
|
||||||
|
};
|
||||||
|
}, [updateSelection]);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setState({ text: "", rect: null, visible: false });
|
||||||
|
latestTextRef.current = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
}
|
||||||
138
src/hooks/useSelectionWindow.ts
Normal file
138
src/hooks/useSelectionWindow.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import { useSelectionStore } from "@/stores/selectionStore";
|
||||||
|
import { useSelectionPanel } from "@/hooks/useSelectionPanel";
|
||||||
|
|
||||||
|
export function useSelectionWindow() {
|
||||||
|
const { state: panelState, close: onClose } = useSelectionPanel();
|
||||||
|
const lastWidthRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Subscribe to store for reactive updates
|
||||||
|
const iconsOnly = useSelectionStore((s) => s.iconsOnly);
|
||||||
|
const selectionEnabled = useSelectionStore((s) => s.selectionEnabled);
|
||||||
|
|
||||||
|
const WIDTH_ICONS_ONLY = 250;
|
||||||
|
const WIDTH_FULL = 470;
|
||||||
|
const HEIGHT = 32;
|
||||||
|
const getSelectionWidth = (isIconsOnly: boolean) =>
|
||||||
|
isIconsOnly ? WIDTH_ICONS_ONLY : WIDTH_FULL;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openSelectionWindow = async (payload: any) => {
|
||||||
|
// console.log("[selection] openSelectionWindow payload", payload);
|
||||||
|
// when selection is disabled, hide the existing window and return
|
||||||
|
if (!selectionEnabled) {
|
||||||
|
const existing = await platformAdapter.getWindowByLabel("selection");
|
||||||
|
if (existing) {
|
||||||
|
await existing.hide();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = "selection";
|
||||||
|
const width = getSelectionWidth(iconsOnly);
|
||||||
|
const height = HEIGHT;
|
||||||
|
|
||||||
|
const options: any = {
|
||||||
|
label,
|
||||||
|
title: "Selection",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
shadow: false,
|
||||||
|
decorations: false,
|
||||||
|
transparent: true,
|
||||||
|
closable: true,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
dragDropEnabled: false,
|
||||||
|
resizable: false,
|
||||||
|
center: false,
|
||||||
|
url: "/ui/selection",
|
||||||
|
windowEffects: {
|
||||||
|
effects: [],
|
||||||
|
state: "active",
|
||||||
|
radius: 7,
|
||||||
|
},
|
||||||
|
hiddenTitle: true,
|
||||||
|
visible: false,
|
||||||
|
acceptFirstMouse: true,
|
||||||
|
data: { timestamp: Date.now() },
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||||
|
const text = raw.trim();
|
||||||
|
|
||||||
|
// Receive backend "top-left origin + logical coordinates (Quartz point)" directly, no need for dpr conversion
|
||||||
|
const xLogical = Math.round(Number(payload?.x ?? 0));
|
||||||
|
const yLogical = Math.round(Number(payload?.y ?? 0));
|
||||||
|
|
||||||
|
const existingWindow = await platformAdapter.getWindowByLabel(label);
|
||||||
|
|
||||||
|
// Empty text: hide existing window and emit empty event
|
||||||
|
if (!text) {
|
||||||
|
if (existingWindow) {
|
||||||
|
await existingWindow.hide();
|
||||||
|
}
|
||||||
|
await platformAdapter.emitEvent("selection-text", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingWindow) {
|
||||||
|
await platformAdapter.createWindow(label, options);
|
||||||
|
}
|
||||||
|
const win = await platformAdapter.getWindowByLabel(label);
|
||||||
|
if (!win) return;
|
||||||
|
|
||||||
|
// Set window size to fixed width and height
|
||||||
|
// Avoid redundant setSize calls if width is unchanged
|
||||||
|
if (lastWidthRef.current !== width) {
|
||||||
|
// @ts-ignore
|
||||||
|
await win.setSize({ type: "Logical", width, height });
|
||||||
|
lastWidthRef.current = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
await win.show();
|
||||||
|
|
||||||
|
// Position window based on "top-left origin + logical coordinates" directly
|
||||||
|
// X offset 0, Y offset -40px (not subtracting window height)
|
||||||
|
if (xLogical > 0 || yLogical > 0) {
|
||||||
|
const offsetX = 0;
|
||||||
|
const offsetY = 40;
|
||||||
|
const targetX = Math.max(0, xLogical + offsetX);
|
||||||
|
const targetY = Math.max(0, yLogical - offsetY);
|
||||||
|
// @ts-ignore
|
||||||
|
await win.setPosition({ type: "Logical", x: targetX, y: targetY });
|
||||||
|
}
|
||||||
|
|
||||||
|
await platformAdapter.emitEvent("selection-text", text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM fallback: when panel is visible and has text, use its position
|
||||||
|
if (panelState?.visible && panelState?.text) {
|
||||||
|
const rect = panelState.rect || null;
|
||||||
|
const screenX = window.screenX || 0; // CSS pixel (logical coordinate)
|
||||||
|
const screenY = window.screenY || 0; // CSS pixel (logical coordinate)
|
||||||
|
const xLogical = rect ? Math.round(screenX + rect.left) : 0;
|
||||||
|
const yLogical = rect ? Math.round(screenY + rect.top) : 0;
|
||||||
|
console.log("[selection] DOM fallback logical", { screenX, screenY, rect, xLogical, yLogical });
|
||||||
|
|
||||||
|
openSelectionWindow({ text: panelState.text, x: xLogical, y: yLogical });
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to selection-detected event from backend
|
||||||
|
const unlistenSelection = platformAdapter.listenEvent(
|
||||||
|
"selection-detected",
|
||||||
|
async (event: any) => {
|
||||||
|
const payload = event?.payload;
|
||||||
|
await openSelectionWindow(payload);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenSelection.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, [panelState?.visible, panelState?.text, onClose, iconsOnly, selectionEnabled]);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { isMac } from "@/utils/platform";
|
|||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { show_coco, show_settings, show_check } from "@/commands";
|
import { show_coco, show_settings, show_check } from "@/commands";
|
||||||
|
import { useSelectionStore } from "@/stores/selectionStore";
|
||||||
|
|
||||||
const TRAY_ID = "COCO_TRAY";
|
const TRAY_ID = "COCO_TRAY";
|
||||||
|
|
||||||
@@ -16,11 +17,13 @@ export const useTray = () => {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const showCocoShortcuts = useAppStore((state) => state.showCocoShortcuts);
|
const showCocoShortcuts = useAppStore((state) => state.showCocoShortcuts);
|
||||||
|
|
||||||
|
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
|
||||||
|
|
||||||
useUpdateEffect(() => {
|
useUpdateEffect(() => {
|
||||||
if (showCocoShortcuts.length === 0) return;
|
if (showCocoShortcuts.length === 0) return;
|
||||||
|
|
||||||
updateTrayMenu();
|
updateTrayMenu();
|
||||||
}, [i18n.language, showCocoShortcuts]);
|
}, [i18n.language, showCocoShortcuts, selectionEnabled]);
|
||||||
|
|
||||||
const getTrayById = () => {
|
const getTrayById = () => {
|
||||||
return TrayIcon.getById(TRAY_ID);
|
return TrayIcon.getById(TRAY_ID);
|
||||||
@@ -56,6 +59,18 @@ export const useTray = () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
MenuItem.new({
|
||||||
|
text: selectionEnabled
|
||||||
|
? t("tray.selectionDisable")
|
||||||
|
: t("tray.selectionEnable"),
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await platformAdapter.invokeBackend("set_selection_enabled", { enabled: !selectionEnabled });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("set_selection_enabled invoke failed:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: t("tray.settings"),
|
text: t("tray.settings"),
|
||||||
// accelerator: "CommandOrControl+,",
|
// accelerator: "CommandOrControl+,",
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
"default": "Default",
|
"default": "Default",
|
||||||
"compact": "Compact"
|
"compact": "Compact"
|
||||||
},
|
},
|
||||||
|
"ai": {
|
||||||
|
"title": "Selection Toolbar",
|
||||||
|
"description": "Show selection toolbar after text selection",
|
||||||
|
"toggle": "Enable selection toolbar"
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "Language",
|
"title": "Language",
|
||||||
"description": "Choose your preferred language",
|
"description": "Choose your preferred language",
|
||||||
@@ -534,6 +539,8 @@
|
|||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"showCoco": "Show Coco",
|
"showCoco": "Show Coco",
|
||||||
|
"selectionDisable": "Disable Selection Toolbar",
|
||||||
|
"selectionEnable": "Enable Selection Toolbar",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"quitCoco": "Quit Coco",
|
"quitCoco": "Quit Coco",
|
||||||
"checkUpdate": "Check for Updates"
|
"checkUpdate": "Check for Updates"
|
||||||
@@ -628,5 +635,42 @@
|
|||||||
"buttons": {
|
"buttons": {
|
||||||
"login": "Login"
|
"login": "Login"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"selection": {
|
||||||
|
"title": "Selection Toolbar Settings",
|
||||||
|
"actions": {
|
||||||
|
"search": "Search",
|
||||||
|
"ask_ai": "Q&A",
|
||||||
|
"translate": "Translate",
|
||||||
|
"summary": "Summary",
|
||||||
|
"copy": "Copy",
|
||||||
|
"speak": "Read Aloud"
|
||||||
|
},
|
||||||
|
"noText": "No text detected",
|
||||||
|
"copied": "Copied",
|
||||||
|
"speak": {
|
||||||
|
"stopTitle": "Stop speaking",
|
||||||
|
"stopAria": "Stop speaking",
|
||||||
|
"stopLabel": "Stop",
|
||||||
|
"pauseTitle": "Pause speaking",
|
||||||
|
"pauseAria": "Pause speaking",
|
||||||
|
"pauseLabel": "Pause",
|
||||||
|
"resumeTitle": "Resume speaking",
|
||||||
|
"resumeAria": "Resume speaking",
|
||||||
|
"resumeLabel": "Resume",
|
||||||
|
"volumeSr": "Volume",
|
||||||
|
"volumeAria": "Speech volume"
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"title": "Display Style",
|
||||||
|
"iconsOnlyDesc": "Show icons only (hide text labels)",
|
||||||
|
"iconsOnlyLabel": "Icons only"
|
||||||
|
},
|
||||||
|
"bind": {
|
||||||
|
"service": "Select Service",
|
||||||
|
"defaultService": "Default service",
|
||||||
|
"assistant": "Bind Assistant (Chat only)",
|
||||||
|
"defaultAssistant": "Default assistant"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
"default": "默认",
|
"default": "默认",
|
||||||
"compact": "紧凑"
|
"compact": "紧凑"
|
||||||
},
|
},
|
||||||
|
"ai": {
|
||||||
|
"title": "划词工具栏",
|
||||||
|
"description": "选择文本后显示独立可操作窗口",
|
||||||
|
"toggle": "启用划词工具栏"
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "语言",
|
"title": "语言",
|
||||||
"description": "选择您的首选语言",
|
"description": "选择您的首选语言",
|
||||||
@@ -534,6 +539,8 @@
|
|||||||
},
|
},
|
||||||
"tray": {
|
"tray": {
|
||||||
"showCoco": "显示 Coco",
|
"showCoco": "显示 Coco",
|
||||||
|
"selectionDisable": "禁用划词工具栏",
|
||||||
|
"selectionEnable": "启用划词工具栏",
|
||||||
"settings": "偏好设置",
|
"settings": "偏好设置",
|
||||||
"quitCoco": "退出 Coco",
|
"quitCoco": "退出 Coco",
|
||||||
"checkUpdate": "检查更新"
|
"checkUpdate": "检查更新"
|
||||||
@@ -627,5 +634,42 @@
|
|||||||
"buttons": {
|
"buttons": {
|
||||||
"login": "登录"
|
"login": "登录"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"selection": {
|
||||||
|
"title": "划词工具栏设置",
|
||||||
|
"actions": {
|
||||||
|
"search": "搜索",
|
||||||
|
"ask_ai": "问答",
|
||||||
|
"translate": "翻译",
|
||||||
|
"summary": "总结",
|
||||||
|
"copy": "复制",
|
||||||
|
"speak": "朗读"
|
||||||
|
},
|
||||||
|
"noText": "未检测到文本",
|
||||||
|
"copied": "已复制",
|
||||||
|
"speak": {
|
||||||
|
"stopTitle": "停止朗读",
|
||||||
|
"stopAria": "停止朗读",
|
||||||
|
"stopLabel": "停止",
|
||||||
|
"pauseTitle": "暂停朗读",
|
||||||
|
"pauseAria": "暂停朗读",
|
||||||
|
"pauseLabel": "暂停",
|
||||||
|
"resumeTitle": "继续朗读",
|
||||||
|
"resumeAria": "继续朗读",
|
||||||
|
"resumeLabel": "继续",
|
||||||
|
"volumeSr": "音量",
|
||||||
|
"volumeAria": "朗读音量"
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"title": "显示样式",
|
||||||
|
"iconsOnlyDesc": "仅显示图标(隐藏文字标签)",
|
||||||
|
"iconsOnlyLabel": "仅显示图标"
|
||||||
|
},
|
||||||
|
"bind": {
|
||||||
|
"service": "选择服务",
|
||||||
|
"defaultService": "默认服务",
|
||||||
|
"assistant": "绑定小助手(仅聊天)",
|
||||||
|
"defaultAssistant": "默认小助手"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
518
src/pages/selection/index.tsx
Normal file
518
src/pages/selection/index.tsx
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Copy,
|
||||||
|
Languages,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
Volume2,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Separator } from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { useSelectionStore } from "@/stores/selectionStore";
|
||||||
|
import { copyToClipboard } from "@/utils";
|
||||||
|
import cocoLogoImg from "@/assets/app-icon.png";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
|
// Simple animated selection window content
|
||||||
|
export default function SelectionWindow() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [animatingOut, setAnimatingOut] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||||
|
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
|
||||||
|
const textRef = useRef<string>("");
|
||||||
|
|
||||||
|
const AUTO_HIDE_KEY = "selection_auto_hide_ms";
|
||||||
|
const autoHideMs = useMemo(() => {
|
||||||
|
const v = Number(localStorage.getItem(AUTO_HIDE_KEY));
|
||||||
|
return Number.isFinite(v) && v > 0 ? v : 5000;
|
||||||
|
}, []);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const scheduleAutoHide = () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
close();
|
||||||
|
}, autoHideMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const updateVoices = () => {
|
||||||
|
voicesRef.current = window.speechSynthesis.getVoices();
|
||||||
|
};
|
||||||
|
updateVoices();
|
||||||
|
window.speechSynthesis.onvoiceschanged = updateVoices;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const unlistenPromise = platformAdapter.listenEvent(
|
||||||
|
"selection-text",
|
||||||
|
async ({ payload }: any) => {
|
||||||
|
const incoming =
|
||||||
|
typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||||
|
const trimmed = incoming.trim();
|
||||||
|
|
||||||
|
const getCurrentWinSafe = async () => {
|
||||||
|
try {
|
||||||
|
return await platformAdapter.getCurrentWebviewWindow();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!useSelectionStore.getState().selectionEnabled) {
|
||||||
|
setVisible(false);
|
||||||
|
const win = await getCurrentWinSafe();
|
||||||
|
win?.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
setText("");
|
||||||
|
textRef.current = ""; // sync ref immediately to avoid stale value
|
||||||
|
setVisible(false);
|
||||||
|
const win = await getCurrentWinSafe();
|
||||||
|
win?.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(incoming);
|
||||||
|
textRef.current = incoming; // sync ref immediately to avoid relying on render
|
||||||
|
setAnimatingOut(false);
|
||||||
|
setVisible(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
scheduleAutoHide();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenPromise
|
||||||
|
.then((fn) => {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoHideMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useSelectionStore.getState().initSync();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
} catch {}
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
setAnimatingOut(true);
|
||||||
|
setTimeout(async () => {
|
||||||
|
setVisible(false);
|
||||||
|
const win = await platformAdapter.getCurrentWebviewWindow();
|
||||||
|
win?.hide();
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMain = async () => {
|
||||||
|
try {
|
||||||
|
await platformAdapter.commands("show_coco");
|
||||||
|
} catch {
|
||||||
|
await platformAdapter.emitEvent("show-coco");
|
||||||
|
await platformAdapter.showWindow();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChatAction = useCallback(
|
||||||
|
async (assistantId?: string) => {
|
||||||
|
const payloadText = (textRef.current || "").trim();
|
||||||
|
if (!payloadText) return;
|
||||||
|
|
||||||
|
await openMain();
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
|
||||||
|
await platformAdapter.emitEvent("selection-action", {
|
||||||
|
action: "chat",
|
||||||
|
text: payloadText,
|
||||||
|
assistantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSpeaking) {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openMain, isSpeaking, close]
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchMain = useCallback(async () => {
|
||||||
|
const payloadText = (textRef.current || "").trim();
|
||||||
|
console.log("searchMain payload", payloadText);
|
||||||
|
if (!payloadText) return;
|
||||||
|
|
||||||
|
await openMain();
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await platformAdapter.emitEvent("selection-action", {
|
||||||
|
action: "search",
|
||||||
|
text: payloadText,
|
||||||
|
});
|
||||||
|
if (!isSpeaking) {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopSpeak = () => {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
utteranceRef.current = null;
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const speak = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
if (isSpeaking && !isPaused) {
|
||||||
|
window.speechSynthesis.pause();
|
||||||
|
setIsPaused(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSpeaking && isPaused) {
|
||||||
|
window.speechSynthesis.resume();
|
||||||
|
setIsPaused(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(trimmed);
|
||||||
|
const zhVoice =
|
||||||
|
voicesRef.current.find((v) => /zh|cn/i.test(v.lang)) ||
|
||||||
|
window.speechSynthesis.getVoices().find((v) => /zh|cn/i.test(v.lang));
|
||||||
|
if (zhVoice) utterance.voice = zhVoice;
|
||||||
|
utterance.rate = 1;
|
||||||
|
utterance.volume = volume;
|
||||||
|
|
||||||
|
// pause auto-hide while speaking
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.onend = () => {
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
utteranceRef.current = null;
|
||||||
|
scheduleAutoHide();
|
||||||
|
};
|
||||||
|
utterance.onerror = () => {
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
utteranceRef.current = null;
|
||||||
|
scheduleAutoHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
utteranceRef.current = utterance;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
setIsSpeaking(true);
|
||||||
|
setIsPaused(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("TTS 播放失败", e);
|
||||||
|
stopSpeak();
|
||||||
|
scheduleAutoHide();
|
||||||
|
}
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
const payloadText = (textRef.current || "").trim();
|
||||||
|
if (!payloadText) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyToClipboard(payloadText, true);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Copy failed:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getActionHandler = (type: string, assistantId?: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "ask_ai":
|
||||||
|
case "translate":
|
||||||
|
case "summary":
|
||||||
|
return () => handleChatAction(assistantId);
|
||||||
|
case "copy":
|
||||||
|
return handleCopy;
|
||||||
|
case "search":
|
||||||
|
return searchMain;
|
||||||
|
case "speak":
|
||||||
|
return speak;
|
||||||
|
default:
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render buttons from store; hide ones requiring assistant without assistantId
|
||||||
|
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||||
|
const iconsOnly = useSelectionStore((s) => s.iconsOnly);
|
||||||
|
|
||||||
|
const requiresAssistant = (type?: string) =>
|
||||||
|
type === "ask_ai" || type === "translate" || type === "summary";
|
||||||
|
|
||||||
|
const visibleButtons = useMemo(
|
||||||
|
() =>
|
||||||
|
(Array.isArray(toolbarConfig) ? toolbarConfig : []).filter((btn: any) => {
|
||||||
|
const type = btn?.action?.type;
|
||||||
|
if (requiresAssistant(type)) {
|
||||||
|
return Boolean(btn?.action?.assistantId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[toolbarConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lucide icon map for dynamic rendering
|
||||||
|
const LUCIDE_ICON_MAP: Record<string, any> = {
|
||||||
|
Search,
|
||||||
|
Bot,
|
||||||
|
Languages,
|
||||||
|
FileText,
|
||||||
|
Copy,
|
||||||
|
Volume2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component: render icon (lucide or custom)
|
||||||
|
const IconRenderer = ({ icon }: { icon?: any }) => {
|
||||||
|
// Support lucide icon or custom image
|
||||||
|
if (icon?.type === "lucide") {
|
||||||
|
const Icon =
|
||||||
|
LUCIDE_ICON_MAP[icon?.name as string] || LUCIDE_ICON_MAP.Search;
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
className="size-4 transition-transform duration-150"
|
||||||
|
style={icon?.color ? { color: icon.color } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (icon?.type === "custom" && icon?.dataUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={icon.dataUrl}
|
||||||
|
className="size-4 rounded"
|
||||||
|
alt=""
|
||||||
|
style={
|
||||||
|
icon?.color
|
||||||
|
? { filter: `drop-shadow(0 0 0 ${icon.color})` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// default
|
||||||
|
return <Search className="size-4 text-[#6366F1]" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component: single toolbar button
|
||||||
|
const ToolbarButton = ({
|
||||||
|
btn,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
btn: any;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => {
|
||||||
|
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<IconRenderer icon={btn?.icon} />
|
||||||
|
{!iconsOnly && (
|
||||||
|
<span className="text-[12px] transition-opacity duration-150">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component: header logo
|
||||||
|
const HeaderLogo = () => {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={cocoLogoImg}
|
||||||
|
alt="Coco Logo"
|
||||||
|
className="w-6 h-6"
|
||||||
|
onClick={openMain}
|
||||||
|
onError={(e) => {
|
||||||
|
try {
|
||||||
|
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component: selected text preview
|
||||||
|
const TextPreview = ({ text }: { text: string }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
className="rounded-md bg-black/5 dark:bg-white/5 px-2 py-1 leading-4 text-[12px] text-ellipsis whitespace-nowrap overflow-hidden"
|
||||||
|
>
|
||||||
|
{text || t("selection.noText")}
|
||||||
|
</div>
|
||||||
|
{copied && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-start pl-2">
|
||||||
|
<span className="px-2 py-1 rounded bg-black/75 text-white text-[12px]">
|
||||||
|
{t("selection.copied")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component: speak controls
|
||||||
|
const SpeakControls = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
|
||||||
|
onClick={stopSpeak}
|
||||||
|
title={t("selection.speak.stopTitle")}
|
||||||
|
aria-label={t("selection.speak.stopAria")}
|
||||||
|
>
|
||||||
|
<X className="size-4 transition-transform duration-150" />
|
||||||
|
{!iconsOnly && (
|
||||||
|
<span className="text-[12px] transition-opacity duration-150">
|
||||||
|
{t("selection.speak.stopLabel")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="hidden items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
|
||||||
|
onClick={speak}
|
||||||
|
title={
|
||||||
|
isPaused
|
||||||
|
? t("selection.speak.resumeTitle")
|
||||||
|
: t("selection.speak.pauseTitle")
|
||||||
|
}
|
||||||
|
aria-pressed={isPaused}
|
||||||
|
aria-label={
|
||||||
|
isPaused
|
||||||
|
? t("selection.speak.resumeAria")
|
||||||
|
: t("selection.speak.pauseAria")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPaused ? (
|
||||||
|
<Play className="size-4 transition-transform duration-150" />
|
||||||
|
) : (
|
||||||
|
<Pause className="size-4 transition-transform duration-150" />
|
||||||
|
)}
|
||||||
|
{!iconsOnly && (
|
||||||
|
<span className="text-[12px] transition-opacity duration-150">
|
||||||
|
{isPaused
|
||||||
|
? t("selection.speak.resumeLabel")
|
||||||
|
: t("selection.speak.pauseLabel")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<label className="hidden items-center gap-1 text-[12px]">
|
||||||
|
<span className="sr-only">{t("selection.speak.volumeSr")}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => setVolume(Number(e.target.value))}
|
||||||
|
aria-label={t("selection.speak.volumeAria")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === containerRef.current && !isSpeaking) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"m-0 p-0 w-full h-full overflow-hidden",
|
||||||
|
"text-[13px] select-none",
|
||||||
|
"bg-white dark:bg-[#1E293B]",
|
||||||
|
"text-[#111] dark:text-[#ddd]",
|
||||||
|
"rounded-xl",
|
||||||
|
"transition-all duration-150",
|
||||||
|
{
|
||||||
|
"translate-y-0": visible && !animatingOut,
|
||||||
|
"translate-y-1": !visible || animatingOut,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="hidden px-2 pt-1">
|
||||||
|
<TextPreview text={text} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
className="flex items-center gap-1 p-1 flex-nowrap overflow-hidden"
|
||||||
|
>
|
||||||
|
<HeaderLogo />
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
decorative
|
||||||
|
className="mx-2 h-4 w-px bg-gray-300 dark:bg-white/30 shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{visibleButtons.map((btn: any) => {
|
||||||
|
const { type, assistantId } = btn?.action;
|
||||||
|
return (
|
||||||
|
<ToolbarButton
|
||||||
|
key={btn.id}
|
||||||
|
btn={btn}
|
||||||
|
onClick={getActionHandler(type, assistantId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isSpeaking && <SpeakControls />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import SettingsPage from "@/pages/settings/index";
|
|||||||
import StandaloneChat from "@/pages/chat/index";
|
import StandaloneChat from "@/pages/chat/index";
|
||||||
import WebPage from "@/pages/web/index";
|
import WebPage from "@/pages/web/index";
|
||||||
import CheckPage from "@/pages/check/index";
|
import CheckPage from "@/pages/check/index";
|
||||||
|
import SelectionWindow from "@/pages/selection/index";
|
||||||
|
|
||||||
const routerOptions = {
|
const routerOptions = {
|
||||||
basename: "/",
|
basename: "/",
|
||||||
@@ -27,6 +28,7 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "/ui/settings", element: <SettingsPage /> },
|
{ path: "/ui/settings", element: <SettingsPage /> },
|
||||||
{ path: "/ui/chat", element: <StandaloneChat /> },
|
{ path: "/ui/chat", element: <StandaloneChat /> },
|
||||||
{ path: "/ui/check", element: <CheckPage /> },
|
{ path: "/ui/check", element: <CheckPage /> },
|
||||||
|
{ path: "/ui/selection", element: <SelectionWindow /> },
|
||||||
{ path: "/web", element: <WebPage /> },
|
{ path: "/web", element: <WebPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { Extension } from "@/components/Settings/Extensions";
|
|||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
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 { useSelectionStore } from "@/stores/selectionStore";
|
||||||
|
|
||||||
export default function LayoutOutlet() {
|
export default function LayoutOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -30,6 +32,27 @@ 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]);
|
||||||
@@ -119,6 +142,9 @@ export default function LayoutOutlet() {
|
|||||||
setDisabledExtensions(disabledExtensions.map((item) => item.id));
|
setDisabledExtensions(disabledExtensions.map((item) => item.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Selection window ---
|
||||||
|
useSelectionWindow();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
targetAssistantId?: string;
|
||||||
|
setTargetAssistantId: (targetAssistantId?: string) => void;
|
||||||
visibleExtensionStore: boolean;
|
visibleExtensionStore: boolean;
|
||||||
setVisibleExtensionStore: (visibleExtensionStore: boolean) => void;
|
setVisibleExtensionStore: (visibleExtensionStore: boolean) => void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
@@ -102,6 +104,9 @@ export const useSearchStore = create<ISearchStore>()(
|
|||||||
setAskAiAssistantId: (askAiAssistantId) => {
|
setAskAiAssistantId: (askAiAssistantId) => {
|
||||||
return set({ askAiAssistantId });
|
return set({ askAiAssistantId });
|
||||||
},
|
},
|
||||||
|
setTargetAssistantId: (targetAssistantId) => {
|
||||||
|
return set({ targetAssistantId });
|
||||||
|
},
|
||||||
visibleExtensionStore: false,
|
visibleExtensionStore: false,
|
||||||
setVisibleExtensionStore: (visibleExtensionStore) => {
|
setVisibleExtensionStore: (visibleExtensionStore) => {
|
||||||
return set({ visibleExtensionStore });
|
return set({ visibleExtensionStore });
|
||||||
|
|||||||
65
src/stores/selectionStore.ts
Normal file
65
src/stores/selectionStore.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, subscribeWithSelector } from "zustand/middleware";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
|
export type ISelectionStore = {
|
||||||
|
// whether selection is enabled
|
||||||
|
selectionEnabled: boolean;
|
||||||
|
setSelectionEnabled: (selectionEnabled: boolean) => void;
|
||||||
|
// toolbar buttons configuration for selection window
|
||||||
|
toolbarConfig: any[];
|
||||||
|
setToolbarConfig: (toolbarConfig: any[]) => void;
|
||||||
|
// whether to show icons only (hide labels) in selection window
|
||||||
|
iconsOnly: boolean;
|
||||||
|
setIconsOnly: (iconsOnly: boolean) => void;
|
||||||
|
// initialize cross-window sync listeners once
|
||||||
|
initSync: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSelectionStore = create<ISelectionStore>()(
|
||||||
|
subscribeWithSelector(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
selectionEnabled: false,
|
||||||
|
setSelectionEnabled(selectionEnabled) {
|
||||||
|
set({ selectionEnabled });
|
||||||
|
},
|
||||||
|
toolbarConfig: [],
|
||||||
|
setToolbarConfig(toolbarConfig) {
|
||||||
|
return set({ toolbarConfig });
|
||||||
|
},
|
||||||
|
iconsOnly: false,
|
||||||
|
setIconsOnly(iconsOnly) {
|
||||||
|
set({ iconsOnly });
|
||||||
|
// broadcast to other windows
|
||||||
|
try {
|
||||||
|
platformAdapter.emitEvent("selection-icons-only", { value: iconsOnly });
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
initSync: async () => {
|
||||||
|
// ensure listener only initialized once per window context
|
||||||
|
const hasInit = (window as any).__selectionIconsOnlyInit__;
|
||||||
|
if (hasInit) return;
|
||||||
|
(window as any).__selectionIconsOnlyInit__ = true;
|
||||||
|
try {
|
||||||
|
await platformAdapter.listenEvent(
|
||||||
|
"selection-icons-only",
|
||||||
|
({ payload }: any) => {
|
||||||
|
const next = Boolean(payload?.value);
|
||||||
|
// apply without re-broadcast to avoid echo
|
||||||
|
set({ iconsOnly: next });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "selection-store",
|
||||||
|
partialize: (state) => ({
|
||||||
|
toolbarConfig: state.toolbarConfig,
|
||||||
|
iconsOnly: state.iconsOnly,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -51,6 +51,15 @@ export interface EventPayloads {
|
|||||||
extension_install_success: any;
|
extension_install_success: any;
|
||||||
open_view_extension: ViewExtensionOpened;
|
open_view_extension: ViewExtensionOpened;
|
||||||
"server-list-changed": Server[];
|
"server-list-changed": Server[];
|
||||||
|
"selection-text": string;
|
||||||
|
"selection-ask-ai": any;
|
||||||
|
"selection-action": {
|
||||||
|
action: "translate" | "search" | "copy" | "summary";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
"selection-detected": string;
|
||||||
|
"selection-enabled": boolean;
|
||||||
|
"selection-icons-only": { value: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window operation interface
|
// Window operation interface
|
||||||
@@ -65,6 +74,7 @@ export interface WindowOperations {
|
|||||||
setFocus: () => Promise<void>;
|
setFocus: () => Promise<void>;
|
||||||
center: () => Promise<void>;
|
center: () => Promise<void>;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
|
hide: () => Promise<void>;
|
||||||
} | null>;
|
} | null>;
|
||||||
createWindow: (label: string, options: any) => Promise<void>;
|
createWindow: (label: string, options: any) => Promise<void>;
|
||||||
createWebviewWindow: (label: string, options: any) => Promise<any>;
|
createWebviewWindow: (label: string, options: any) => Promise<any>;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getCurrentWindowService } from "@/commands/windowService";
|
|||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
|
|
||||||
export async function copyToClipboard(text: string) {
|
export async function copyToClipboard(text: string, noTip = false) {
|
||||||
const addError = useAppStore.getState().addError;
|
const addError = useAppStore.getState().addError;
|
||||||
const language = useAppStore.getState().language;
|
const language = useAppStore.getState().language;
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export async function copyToClipboard(text: string) {
|
|||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
}
|
}
|
||||||
|
|
||||||
addError(language === "zh" ? "复制成功" : "Copy Success", "info");
|
!noTip && addError(language === "zh" ? "复制成功" : "Copy Success", "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2
|
// 2
|
||||||
|
|||||||
9
src/utils/servers.ts
Normal file
9
src/utils/servers.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Server } from "@/types/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return enabled and available servers from a server list.
|
||||||
|
*/
|
||||||
|
export function getEnabledServers(list: Server[] | any[]): Server[] {
|
||||||
|
if (!Array.isArray(list)) return [] as Server[];
|
||||||
|
return (list as Server[]).filter((s) => s.enabled && s.available);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user