feat: selection settings add & delete (#992)

* feat: add selection window page

* fix: chat input

* feat: add selection page

* chore: add

* chore: test

* feat: add

* feat: add store

* feat: add selection settings

* chore: remove unused code

* docs: add release note

* docs: add release note

* chore: format code

* chore: format code

* fix: copy error

* disable hashbrown default feature

* Enable unstable feature allocator_api

To make coco-app compile in CI:

```
--> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.15.5/src/raw/mod.rs:3856:12
     |
3856 | impl<T, A: Allocator> RawIntoIter<T, A> {
     |            ^^^^^^^^^
     |
     = note: see issue #32838 <https://github.com/rust-lang/rust/issues/32838> for more information
     = help: add `#![feature(allocator_api)]` to the crate attributes to enable
     = note: this compiler was built on 2025-06-25; consider upgrading it if it is out of date
```

I don't know why it does not compile, feature `allocator-api2` is
enabled for `hashbrown 0.15.5`, so technically [1] it should not use the
allocator APIs from the std. According to [2], enabling the `nightly`
feature of `allocator-api2` may cause this issue as well, but it is not
enabled in our case either.

Anyway, enabling `#![feature(allocator_api)]` should make it work.

[1]: b751eef8e9/src/raw/alloc.rs (L26-L47)
[2]: https://github.com/rust-lang/hashbrown/issues/564

* put it in main.rs

* format main.rs

* Enable default-features for hashbrown 0.15.5

* format main.rs

* enable feature allocator-api2

* feat: add selection set config

* fix: selection setting

* fix: ci error

* fix: ci error

* fix: ci error

* fix: ci error

* merge: merge main

* fix: rust code warn

* fix: rust code error

* fix: rust code error

* fix: selection settings

* style: selection styles

* style: selection styles

* feat: selection settings add & delete

* feat: selection settings add & delete

* feat: selection settings add & delete

* style: selection styles

* chore: add @tauri-store/zustand plugin

* refactor: the selection store using @tauri-store/zustand

* fix: data error

* fix: data error

* chore: remove config

* chore: selection

* chore: selection

* chore: width

* chore: ignore selection in the app itself

* style: selection styles

* style: remove

* docs: add notes

* chore: add permission check

* chore: selection

* chore: style & store

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
This commit is contained in:
BiggerRain
2025-12-05 15:32:57 +08:00
committed by GitHub
parent 18828ab043
commit 97d2450fa7
27 changed files with 1260 additions and 437 deletions

View File

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

View File

@@ -19,6 +19,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.2",
"@infinilabs/custom-icons": "0.0.4",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@tauri-apps/api": "^2.5.0",

16
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@headlessui/react':
specifier: ^2.2.2
version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@infinilabs/custom-icons':
specifier: 0.0.4
version: 0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-separator':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -700,6 +703,13 @@ packages:
'@iconify/utils@3.0.2':
resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==}
'@infinilabs/custom-icons@0.0.4':
resolution: {integrity: sha512-Oz5i06qW5a3wMfbJIZ5LESyO4V9p8njdoKnb/2Mf98ttRjTIedTXqrIWxLz8Tz84OTLi7mKswEQ71lAGeSqlug==}
peerDependencies:
lucide-react: '>=0.454.0'
react: '>=18'
react-dom: '>=18'
'@inquirer/checkbox@4.1.5':
resolution: {integrity: sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==}
engines: {node: '>=18'}
@@ -4278,6 +4288,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@infinilabs/custom-icons@0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
lucide-react: 0.461.0(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@inquirer/checkbox@4.1.5(@types/node@22.18.12)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.18.12)

View File

@@ -65,7 +65,6 @@ async fn change_window_height(handle: AppHandle, height: u32) {
let outer_size = window.outer_size().unwrap();
let window_width = outer_size.width as i32;
let window_height = outer_size.height as i32;
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;

View File

@@ -10,16 +10,42 @@ struct SelectionEventPayload {
y: i32,
}
use once_cell::sync::Lazy;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
/// Global toggle: selection monitoring enabled by default.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
/// Ensure we only start the monitor thread once. Allows delayed start after
/// Accessibility permission is granted post-launch.
static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false);
/// Guard to avoid spawning multiple permission watcher threads.
#[cfg(target_os = "macos")]
static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
/// Session flags for controlling macOS Accessibility prompts.
#[cfg(target_os = "macos")]
static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
static LAST_ACCESSIBILITY_PROMPT: Lazy<Mutex<Option<std::time::Instant>>> =
Lazy::new(|| Mutex::new(None));
#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
enabled: bool,
}
#[derive(serde::Serialize, Clone)]
struct SelectionPermissionInfo {
bundle_id: String,
exe_path: String,
in_applications: bool,
is_dmg: bool,
is_dev_guess: bool,
}
/// Read the current selection monitoring state.
pub fn is_selection_enabled() -> bool {
SELECTION_ENABLED.load(Ordering::Relaxed)
@@ -28,6 +54,7 @@ pub fn is_selection_enabled() -> bool {
/// Update the monitoring state and broadcast to the frontend.
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "selection monitoring toggled: enabled={}", enabled);
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
}
@@ -35,6 +62,23 @@ fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool)
#[tauri::command]
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
set_selection_enabled_internal(&app_handle, enabled);
// When enabling selection monitoring on macOS, ensure Accessibility permission.
// If not granted, trigger system prompt and deep-link to the right settings pane,
// and notify frontend to guide the user.
#[cfg(target_os = "macos")]
{
if enabled {
let trusted = ensure_accessibility_permission(&app_handle);
// If permission is now trusted and the monitor hasn't started yet,
// start it immediately to avoid requiring an app restart.
if trusted && !MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
log::info!(target: "coco_lib::selection_monitor", "set_selection_enabled: permission trusted; starting monitor thread");
start_selection_monitor(app_handle.clone());
return;
}
}
}
}
/// Tauri command: get selection monitoring state.
@@ -46,7 +90,7 @@ pub fn get_selection_enabled() -> bool {
#[cfg(target_os = "macos")]
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
// Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread.
log::info!("start_selection_monitor: 入口函数启动");
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: entrypoint");
use std::time::Duration;
use tauri::Emitter;
@@ -57,21 +101,75 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
#[cfg(target_os = "macos")]
{
let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
if !trusted_before {
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
// If already started, don't start twice.
if MONITOR_THREAD_STARTED.load(Ordering::Relaxed) {
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: already started; skipping");
return;
}
let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
if !trusted_after {
if !ensure_accessibility_permission(&app_handle) {
log::warn!(target: "coco_lib::selection_monitor", "start_selection_monitor: accessibility not granted; deferring watcher start");
// Spawn a short-lived permission watcher to auto-start once the user grants.
if !PERMISSION_WATCHER_STARTED.swap(true, Ordering::Relaxed) {
let app_handle_clone = app_handle.clone();
std::thread::Builder::new()
.name("selection-permission-watcher".into())
.spawn(move || {
use std::time::Duration;
// Persistent polling with gentle backoff: checks every 2s for ~1 minute,
// then every 10s thereafter, until either trusted or selection disabled.
let mut checks: u32 = 0;
loop {
// If user disabled selection in between, stop early.
if !is_selection_enabled() {
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: selection disabled; stop polling");
break;
}
// Fast trust check without prompt.
if macos_accessibility_client::accessibility::application_is_trusted() {
log::info!(target: "coco_lib::selection_monitor", "permission watcher: accessibility granted; starting monitor");
// Reset watcher flag before starting monitor to allow future retries if needed.
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
start_selection_monitor(app_handle_clone.clone());
return;
}
// Backoff strategy.
checks += 1;
let sleep_secs = if checks <= 30 { 2 } else { 10 }; // ~1 min fast, then slower
if checks % 30 == 0 {
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: still not granted; continuing to poll (checks={})", checks);
}
std::thread::sleep(Duration::from_secs(sleep_secs));
}
// Done polling without success; allow future attempts.
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
log::debug!(target: "coco_lib::selection_monitor", "permission watcher: stopped (no grant)");
})
.unwrap_or_else(|e| {
PERMISSION_WATCHER_STARTED.store(false, Ordering::Relaxed);
// Fail fast here: spawning a watcher thread is critical for deferred start.
panic!(
"permission watcher: failed to spawn: {}",
e
);
});
} else {
log::debug!(target: "coco_lib::selection_monitor", "start_selection_monitor: permission watcher already running; skip spawning");
}
return;
}
}
#[cfg(not(target_os = "macos"))]
{
log::info!("start_selection_monitor: 非 macOS 平台,无划词监控");
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: non-macos platform, no selection monitor");
}
// Background thread: drives popup show/hide based on mouse and AX selection state.
MONITOR_THREAD_STARTED.store(true, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "start_selection_monitor: starting watcher thread");
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
use objc2_app_kit::NSWorkspace;
@@ -108,13 +206,13 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
return (x_top_left, y_flipped);
}
let mut chosen = CGMainDisplayID(); // default fallback
log::info!(
"current_mouse: pt=({:.1},{:.1}) → display={}",
pt.x as f64,
pt.y as f64,
chosen
);
let mut _chosen = CGMainDisplayID(); // default fallback
// log::info!(
// "current_mouse: pt=({:.1},{:.1}) → display={}",
// pt.x as f64,
// pt.y as f64,
// chosen
// );
let mut min_x_pt = f64::INFINITY;
let mut max_top_pt = f64::NEG_INFINITY;
@@ -136,15 +234,15 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
let in_x = pt.x >= b.origin.x && pt.x <= b.origin.x + b.size.width;
let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height;
if in_x && in_y {
chosen = did;
log::info!(
"current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
pt.x as f64,
pt.y as f64,
chosen,
b.origin.x,
b.origin.y
);
_chosen = did;
// log::info!(
// "current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
// pt.x as f64,
// pt.y as f64,
// chosen,
// b.origin.x,
// b.origin.y
// );
}
}
@@ -199,13 +297,21 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
}
// Skip empty-selection hide checks while interacting with the Coco popup.
let front_is_me = is_frontmost_app_me();
// Robust check: treat as "self" if either the frontmost app or the
// system-wide focused element belongs to this process.
let front_is_me = is_frontmost_app_me() || is_focused_element_me();
// When Coco is frontmost, disable detection but do NOT hide the popup.
// Users may be clicking the popup; we must keep it visible.
if front_is_me {
// Reset counters to avoid stale state on re-entry.
stable_count = 0;
empty_count = 0;
continue;
}
// Lightweight retries to smooth out transient AX focus instability.
let selected_text = if front_is_me {
// Do not read selection during popup interaction to avoid false empty.
None
} else {
let selected_text = {
// Up to 2 retries, 35ms apart.
read_selected_text_with_retries(2, 35)
};
@@ -223,6 +329,14 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
// Update/show only when selection is stable to avoid flicker.
if stable_count >= stable_threshold {
if !popup_visible || text != last_text {
// Second guard: do not emit when Coco is frontmost
// or the system-wide focused element belongs to Coco.
// Keep popup as-is to allow user interaction.
if is_frontmost_app_me() || is_focused_element_me() {
stable_count = 0;
empty_count = 0;
continue;
}
let (x, y) = current_mouse_point_global();
let payload = SelectionEventPayload {
text: text.clone(),
@@ -231,6 +345,9 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
};
let _ = app_handle.emit("selection-detected", payload);
// Log selection state change once per stable update to avoid flooding.
let snippet: String = stable_text.chars().take(120).collect();
log::info!(target: "coco_lib::selection_monitor", "selection stable; showing popup (len={}, snippet=\"{}\")", stable_text.len(), snippet.replace('\n', "\\n"));
last_text = text;
popup_visible = true;
}
@@ -243,6 +360,7 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
empty_count += 1;
if popup_visible && empty_count >= empty_threshold {
let _ = app_handle.emit("selection-detected", "");
log::info!(target: "coco_lib::selection_monitor", "selection empty; hiding popup");
popup_visible = false;
last_text.clear();
stable_text.clear();
@@ -256,12 +374,192 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
});
}
/// Ensure macOS Accessibility permission with double-checking and session throttling.
/// Returns true when trusted; otherwise triggers prompt/settings link and emits
/// `selection-permission-required` to the frontend, then returns false.
#[cfg(target_os = "macos")]
fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool {
use std::time::{Duration, Instant};
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: begin");
// First check — fast path.
let trusted = macos_accessibility_client::accessibility::application_is_trusted();
if trusted {
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (fast path)");
return true;
}
// If we've seen trust earlier in this session, transient false may occur.
// Re-check after a short delay to avoid spurious prompts.
if SEEN_ACCESSIBILITY_TRUSTED_ONCE.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(150));
if macos_accessibility_client::accessibility::application_is_trusted() {
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: trusted=true (after transient recheck)");
return true;
}
}
// Throttle system prompt to at most once per 60s in this session.
let mut last = LAST_ACCESSIBILITY_PROMPT.lock().unwrap();
let now = Instant::now();
let allow_prompt = match *last {
Some(ts) => now.duration_since(ts) > Duration::from_secs(60),
None => true,
};
if allow_prompt {
// Try to trigger the system authorization prompt.
log::debug!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: triggering system prompt");
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
*last = Some(now);
// Small grace period then re-check.
std::thread::sleep(Duration::from_millis(150));
if macos_accessibility_client::accessibility::application_is_trusted() {
SEEN_ACCESSIBILITY_TRUSTED_ONCE.store(true, Ordering::Relaxed);
log::info!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: user granted accessibility during prompt");
return true;
}
log::warn!(target: "coco_lib::selection_monitor", "ensure_accessibility_permission: still not trusted after prompt");
}
// Still not trusted — notify frontend and deep-link to settings.
let _ = app_handle.emit("selection-permission-required", true);
log::debug!(target: "coco_lib::selection_monitor", "selection-permission-required emitted");
// Provide richer context so frontend can give more explicit guidance.
let info = collect_selection_permission_info();
log::info!(target: "coco_lib::selection_monitor", "selection-permission-info: bundle_id={}, exe_path={}, in_applications={}, is_dmg={}, is_dev_guess={}",
info.bundle_id, info.exe_path, info.in_applications, info.is_dmg, info.is_dev_guess);
let _ = app_handle.emit("selection-permission-info", info);
#[allow(unused_must_use)]
{
use std::process::Command;
Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.status();
}
false
}
#[cfg(target_os = "macos")]
fn collect_selection_permission_info() -> SelectionPermissionInfo {
let exe_path = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| String::from("<unknown>"));
let in_applications = exe_path.starts_with("/Applications/");
let is_dmg = exe_path.starts_with("/Volumes/");
let is_dev_guess = exe_path.contains("/target/debug/")
|| exe_path.contains(".cargo")
|| exe_path.contains("/node_modules/");
// Find the nearest *.app directory from the current executable path.
let bundle_id = get_bundle_id_dynamic();
SelectionPermissionInfo {
bundle_id,
exe_path,
in_applications,
is_dmg,
is_dev_guess,
}
}
#[cfg(target_os = "macos")]
fn get_bundle_id_dynamic() -> String {
use std::path::PathBuf;
use std::process::Command;
// Find the nearest *.app directory from the current executable path.
let mut app_dir: Option<PathBuf> = None;
if let Ok(mut p) = std::env::current_exe() {
for _ in 0..8 {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".app") {
app_dir = Some(p.clone());
break;
}
}
if !p.pop() {
break;
}
}
}
if let Some(app) = app_dir {
let info = app.join("Contents").join("Info.plist");
if info.exists() {
// use `defaults read <Info.plist> CFBundleIdentifier` to read Bundle ID
if let Ok(out) = Command::new("defaults")
.arg("read")
.arg(info.to_string_lossy().into_owned())
.arg("CFBundleIdentifier")
.output()
{
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return s;
}
}
}
}
}
// Fallback: use the default bundle ID when in dev mode or Info.plist is not found.
"rs.coco.app".to_string()
}
// macOS-wide accessibility entry point: allows reading system-level focused elements.
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement;
}
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn AXUIElementGetPid(
element: *mut objc2_application_services::AXUIElement,
pid: *mut i32,
) -> objc2_application_services::AXError;
}
#[cfg(target_os = "macos")]
fn is_focused_element_me() -> bool {
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFRetained, CFString, CFType};
use std::ptr::NonNull;
let mut focused_ui_ptr: *const CFType = std::ptr::null();
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
let system_elem = unsafe { AXUIElementCreateSystemWide() };
if system_elem.is_null() {
return false;
}
let system_elem_retained: CFRetained<AXUIElement> =
unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) };
let err = unsafe {
system_elem_retained
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
};
if err != AXError::Success || focused_ui_ptr.is_null() {
return false;
}
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
let mut pid: i32 = -1;
let get_err = unsafe { AXUIElementGetPid(focused_ui_elem, &mut pid as *mut i32) };
if get_err != AXError::Success {
return false;
}
let my_pid = std::process::id() as i32;
pid == my_pid
}
/// Read the selected text of the frontmost application (without using the clipboard).
/// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties.
#[cfg(target_os = "macos")]
@@ -357,10 +655,10 @@ fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String
if let Some(text) = read_selected_text() {
if !text.is_empty() {
if attempt > 0 {
log::info!(
"read_selected_text: 第{}次重试成功,获取到选中文本",
attempt
);
// log::info!(
// "read_selected_text: 第{}次重试成功,获取到选中文本",
// attempt
// );
}
return Some(text);
}

View File

@@ -46,6 +46,12 @@ export function ServerList({ clearChat }: ServerListProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [highlightId, setHighlightId] = useState<string>("");
const targetServerId = useSearchStore((state) => {
return state.targetServerId;
});
const setTargetServerId = useSearchStore((state) => {
return state.setTargetServerId;
});
const askAiServerId = useSearchStore((state) => {
return state.askAiServerId;
});
@@ -102,17 +108,20 @@ export function ServerList({ clearChat }: ServerListProps) {
}, [serverList]);
useEffect(() => {
if (!askAiServerId || serverList.length === 0) return;
const matched = serverList.find((server) => {
return server.id === askAiServerId;
});
const targetId = targetServerId ?? askAiServerId;
if (!targetId || list.length === 0) return;
const matched = list.find((server) => server.id === targetId);
if (!matched) return;
switchServer(matched);
setAskAiServerId(void 0);
}, [serverList, askAiServerId]);
setHighlightId(matched.id);
if (targetServerId) {
setTargetServerId(void 0);
} else {
setAskAiServerId(void 0);
}
}, [list, askAiServerId, targetServerId]);
useEffect(() => {
if (!isTauri) return;
@@ -291,4 +300,4 @@ export function ServerList({ clearChat }: ServerListProps) {
</PopoverPanel>
</Popover>
);
}
}

View File

@@ -316,35 +316,50 @@ function SearchChat({
const { normalOpacity, blurOpacity } = useAppearanceStore();
useEffect(() => {
const unlistenAsk = platformAdapter.listenEvent("selection-ask-ai", ({ payload }: any) => {
const value = typeof payload === "string" ? payload : String(payload?.text ?? "");
dispatch({ type: "SET_CHAT_MODE", payload: true });
dispatch({ type: "SET_INPUT", payload: value });
platformAdapter.showWindow();
});
const unlistenAction = platformAdapter.listenEvent("selection-action", ({ payload }: any) => {
const { action, text, assistantId } = payload || {};
const value = String(text ?? "");
if (action === "search") {
dispatch({ type: "SET_CHAT_MODE", payload: false });
dispatch({ type: "SET_INPUT", payload: value });
const { setSearchValue } = useSearchStore.getState();
setSearchValue(value);
platformAdapter.showWindow();
} else if (action === "chat") {
const unlistenAsk = platformAdapter.listenEvent(
"selection-ask-ai",
({ payload }: any) => {
const value =
typeof payload === "string" ? payload : String(payload?.text ?? "");
dispatch({ type: "SET_CHAT_MODE", payload: true });
dispatch({ type: "SET_INPUT", payload: value });
const { assistantList } = useConnectStore.getState();
const assistant = assistantList.find((item) => item._source?.id === assistantId);
if (assistant) {
const { setTargetAssistantId } = useSearchStore.getState();
setTargetAssistantId(assistant._id);
}
platformAdapter.showWindow();
}
});
);
const unlistenAction = platformAdapter.listenEvent(
"selection-action",
({ payload }: any) => {
const { action, text, assistantId, serverId } = payload || {};
const value = String(text ?? "");
//
if (action === "search") {
dispatch({ type: "SET_CHAT_MODE", payload: false });
dispatch({ type: "SET_INPUT", payload: value });
const { setSearchValue } = useSearchStore.getState();
setSearchValue(value);
} else if (action === "chat") {
dispatch({ type: "SET_CHAT_MODE", payload: true });
dispatch({ type: "SET_INPUT", payload: value });
//
const { setTargetServerId, setTargetAssistantId } =
useSearchStore.getState();
if (serverId) {
setTargetServerId(serverId);
}
const { assistantList } = useConnectStore.getState();
const assistant = assistantList.find(
(item) => item._source?.id === assistantId
);
if (assistant) {
setTargetAssistantId(assistant._id);
}
}
}
);
return () => {
unlistenAsk.then((fn) => fn());

View File

@@ -0,0 +1,58 @@
import { Separator } from "@radix-ui/react-separator";
import cocoLogoImg from "@/assets/app-icon.png";
import SelectionToolbar from "@/components/Selection/Toolbar";
import type { ActionConfig, ButtonConfig } from "@/components/Settings/Advanced/components/Selection/config";
export default function HeaderToolbar({
buttons,
iconsOnly,
onAction,
onLogoClick,
className,
rootRef,
children,
}: {
buttons: ButtonConfig[];
iconsOnly: boolean;
onAction: (action: ActionConfig) => void;
onLogoClick?: () => void;
className?: string;
rootRef?: React.Ref<HTMLDivElement>;
children?: React.ReactNode;
}) {
return (
<div
ref={rootRef}
data-tauri-drag-region="false"
className={`flex items-center gap-1 px-2 py-1 flex-nowrap overflow-hidden ${className ?? ""}`}
>
<img
src={cocoLogoImg}
alt="Coco Logo"
className="w-6 h-6"
onClick={onLogoClick}
onError={(e) => {
try {
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
} catch {}
}}
/>
<Separator
orientation="vertical"
decorative
className="mx-1 h-4 w-px bg-gray-300 dark:bg-white/30 shrink-0"
/>
<SelectionToolbar
buttons={buttons}
iconsOnly={iconsOnly}
onAction={onAction}
requireAssistantCheck={false}
/>
{children}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import {
ActionConfig,
ButtonConfig,
IconConfig,
resolveLucideIcon,
} from "@/components/Settings/Advanced/components/Selection/config";
const requiresAssistant = (type?: string) =>
type === "ask_ai" || type === "translate" || type === "summary";
function IconRenderer({ icon }: { icon?: IconConfig }) {
if (icon?.type === "lucide") {
const Comp = resolveLucideIcon(icon?.name);
if (Comp) {
return (
<Comp
className="size-4 transition-transform duration-150"
// style={icon?.color ? { color: icon.color } : undefined}
/>
);
}
return (
<Search
className="size-4 transition-transform duration-150"
// style={icon?.color ? { color: icon.color } : undefined}
/>
);
}
if (icon?.type === "custom" && icon?.dataUrl) {
return (
<img
src={icon.dataUrl}
className="size-4 rounded"
alt=""
// style={
// icon?.color
// ? { filter: `drop-shadow(0 0 0 ${icon.color})` }
// : undefined
// }
/>
);
}
return <Search className="size-4 text-[#6366F1]" />;
}
function ToolbarButton({
btn,
onClick,
showLabel,
}: {
btn: ButtonConfig;
onClick: () => void;
showLabel: boolean;
}) {
const { t } = useTranslation();
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
return (
<button
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
onClick={onClick}
title={label}
>
<IconRenderer icon={btn?.icon} />
{showLabel && (
<span className="text-[12px] transition-opacity duration-150">
{label}
</span>
)}
</button>
);
}
export default function SelectionToolbar({
buttons,
iconsOnly,
onAction,
className,
requireAssistantCheck = true,
}: {
buttons: ButtonConfig[];
iconsOnly: boolean;
onAction: (action: ActionConfig) => void;
className?: string;
requireAssistantCheck?: boolean;
}) {
const visibleButtons = (Array.isArray(buttons) ? buttons : []).filter((btn: any) => {
if (!requireAssistantCheck) return true;
const type = btn?.action?.type;
if (requiresAssistant(type)) {
return Boolean(btn?.action?.assistantId);
}
return true;
});
return (
<div
className={clsx(
"flex items-center gap-1 flex-nowrap overflow-hidden",
className
)}
>
{visibleButtons.map((btn) => (
<ToolbarButton
key={btn.id}
btn={btn}
onClick={() => onAction(btn.action)}
showLabel={!iconsOnly}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ButtonConfig } from "./config";
import AddChatDialog from "./AddChatDialog";
interface AddChatButtonProps {
serverList: any[];
onAdd: (btn: ButtonConfig) => void;
}
export function AddChatButton({ serverList, onAdd }: AddChatButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="pt-1">
<Button
variant="ghost"
className="inline-flex items-center gap-2 border border-dashed border-border hover:border-primary/50 hover:bg-secondary/50 text-muted-foreground transition-all duration-200"
onClick={() => setOpen(true)}
>
<Plus className="w-4 h-4" />
{t("selection.actions.addChat")}
</Button>
<AddChatDialog
serverList={serverList}
open={open}
onOpenChange={setOpen}
onAdd={onAdd}
/>
</div>
);
}

View File

@@ -0,0 +1,224 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPicker } from "@infinilabs/custom-icons";
import type { IconConfig } from "@infinilabs/custom-icons";
import { nanoid } from "nanoid";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { Button } from "@/components/ui/button";
import { ButtonConfig } from "./config";
import { useThemeStore } from "@/stores/themeStore";
import { useAppStore } from "@/stores/appStore";
export default function AddChatDialog({
serverList,
open,
onOpenChange,
onAdd,
}: {
serverList: any[];
open: boolean;
onOpenChange: (v: boolean) => void;
onAdd: (btn: ButtonConfig) => void;
}) {
const { t, i18n } = useTranslation();
const { fetchAssistant } = AssistantFetcher({});
const [label, setLabel] = useState("");
const [iconType, setIconType] = useState<IconConfig["type"]>("lucide");
const [lucideName, setLucideName] = useState<string>("Bot");
const [color, setColor] = useState<string>("#0287FF");
const [dataUrl, setDataUrl] = useState<string>("");
const [serverId, setServerId] = useState<string>("");
const [assistantList, setAssistantList] = useState<any[]>([]);
const [assistantId, setAssistantId] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const activeTheme = useThemeStore((state) => state.activeTheme);
const language = useAppStore((state) => state.language);
useEffect(() => {
if (!serverId) {
setAssistantList([]);
return;
}
setLoading(true);
fetchAssistant({ current: 1, pageSize: 1000, serverId })
.then((data) => setAssistantList(data.list || []))
.catch(() => setAssistantList([]))
.finally(() => setLoading(false));
}, [serverId]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open]);
const reset = () => {
setLabel("");
setIconType("lucide");
setLucideName("Bot");
setColor("#0287FF");
setDataUrl("");
setServerId("");
setAssistantList([]);
setAssistantId("");
};
const handleClose = () => {
onOpenChange(false);
reset();
};
const handleAdd = () => {
const id = `custom-${nanoid(8)}`;
const icon: IconConfig =
iconType === "lucide"
? { type: "lucide", name: lucideName, color }
: { type: "custom", dataUrl, color };
const btn: any = {
id,
label: label || t("selection.custom.chat"),
icon,
action: {
type: "ask_ai",
assistantServerId: serverId || undefined,
assistantId: assistantId || undefined,
},
};
onAdd(btn);
handleClose();
};
const applyIconConfig = (cfg: IconConfig) => {
if (cfg.type === "lucide") {
setIconType("lucide");
setLucideName(String(cfg.name || lucideName || "Bot"));
} else {
setIconType("custom");
setDataUrl(String(cfg.dataUrl || dataUrl || ""));
}
if (cfg.color) {
setColor(String(cfg.color));
}
};
if (!open) return null;
const currentLanguage = language || i18n.language;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={handleClose}
>
<div
className="w-full max-w-lg rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4">
<h2 className="text-lg font-semibold leading-none tracking-tight text-foreground">
{t("selection.custom.chat")}
</h2>
<p className="text-sm text-muted-foreground mt-1.5">
{t("selection.bind.assistant")}
</p>
</div>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.custom.namePlaceholder")}
</label>
<input
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full border border-[#E6E8EF] dark:border-[#2E3644]"
placeholder={t("selection.custom.namePlaceholder")}
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.icon.pick")}
</label>
<IconPicker
initial={
iconType === "lucide"
? { type: "lucide", name: lucideName, color }
: { type: "custom", dataUrl, color }
}
onChange={applyIconConfig}
theme={activeTheme}
locale={currentLanguage}
showLibraryLink={false}
controls={{
color: false,
size: false,
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.service")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
>
<option value="" disabled>
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.assistant")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
value={assistantId}
onChange={(e) => setAssistantId(e.target.value)}
disabled={loading || !serverId}
>
<option value="" disabled>
{loading
? t("common.loading")
: t("selection.bind.defaultAssistant")}
</option>
{!loading &&
assistantList.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="secondary" onClick={handleClose}>
{t("deleteDialog.button.cancel") ?? "Cancel"}
</Button>
<Button onClick={handleAdd}>
{t("settings.shortcut.save") ?? "Save"}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,61 +1,12 @@
import { useEffect, useRef, useState } from "react";
import {
GripVertical,
Bot,
Copy,
Languages,
Search,
Volume2,
FileText,
} from "lucide-react";
import { GripVertical, Trash2 } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { setCurrentWindowService } from "@/commands/windowService";
type ActionType =
| "search"
| "ask_ai"
| "translate"
| "summary"
| "copy"
| "speak"
| "custom";
type LucideIconName =
| "Search"
| "Bot"
| "Languages"
| "FileText"
| "Copy"
| "Volume2";
type IconConfig =
| { type: "lucide"; name: LucideIconName; color?: string }
| { type: "custom"; dataUrl: string; color?: string };
export type ButtonConfig = {
id: string;
label: string;
icon: IconConfig;
action: {
type: ActionType;
assistantId?: string;
assistantServerId?: string;
eventName?: string;
};
labelKey?: string;
};
const LUCIDE_ICON_MAP: Record<LucideIconName, any> = {
Search,
Bot,
Languages,
FileText,
Copy,
Volume2,
};
import { AddChatButton } from "./AddChatButton";
import { ButtonConfig, resolveLucideIcon } from "./config";
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
@@ -94,9 +45,23 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
const { t } = useTranslation();
const { fetchAssistant } = AssistantFetcher({});
const [assistantByServer, setAssistantByServer] = useState<Record<string, any[]>>({});
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<Record<string, boolean>>({});
const [assistantCache, setAssistantCacheState] = useState<Record<string, AssistantCacheItem>>(() => loadAssistantCache());
const [assistantByServer, setAssistantByServer] = useState<
Record<string, any[]>
>({});
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<
Record<string, boolean>
>({});
const [assistantCache, setAssistantCacheState] = useState<
Record<string, AssistantCacheItem>
>(() => loadAssistantCache());
const BUILT_IN_IDS = new Set([
"search",
"ask_ai",
"translate",
"summary",
"copy",
"speak",
]);
const dragIndexRef = useRef<number | null>(null);
const initializedServiceRef = useRef<boolean>(false);
@@ -118,7 +83,11 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
};
const updateAction = (id: string, patch: Partial<ButtonConfig["action"]>) => {
setButtons((prev) => prev.map((b) => (b.id === id ? { ...b, action: { ...b.action, ...patch } } : b)));
setButtons((prev) =>
prev.map((b) =>
b.id === id ? { ...b, action: { ...b.action, ...patch } } : b
)
);
};
const handleAssistantSelect = (btn: ButtonConfig, value: string) => {
@@ -145,10 +114,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
}
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
try {
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
const data = await fetchAssistant({
current: 1,
pageSize: 1000,
serverId: sid,
});
const list = data.list || [];
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
const nextCache = {
...assistantCache,
[sid]: { list, updatedAt: Date.now() },
};
setAssistantCacheState(nextCache);
saveAssistantCache(nextCache);
} catch (err) {
@@ -164,8 +140,8 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
initializedServiceRef.current = true;
const preferredSid =
buttons.find((b) => b.action.assistantServerId)?.action.assistantServerId ||
Object.keys(assistantCache)[0];
buttons.find((b) => b.action.assistantServerId)?.action
.assistantServerId || Object.keys(assistantCache)[0];
if (!preferredSid) return;
const target = serverList.find((s: any) => s.id === preferredSid);
@@ -193,10 +169,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
}
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
try {
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
const data = await fetchAssistant({
current: 1,
pageSize: 1000,
serverId: sid,
});
const list = data.list || [];
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
const nextCache = {
...assistantCache,
[sid]: { list, updatedAt: Date.now() },
};
setAssistantCacheState(nextCache);
saveAssistantCache(nextCache);
} catch (err) {
@@ -210,9 +193,17 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
return (
<div className="space-y-3">
{buttons.map((btn, index) => {
const IconComp = btn.icon.type === "lucide" ? LUCIDE_ICON_MAP[btn.icon.name] : null;
const isChat = ["ask_ai", "translate", "summary"].includes(btn.action.type);
const visualType: "Chat" | "Search" | "Tool" = isChat ? "Chat" : btn.action.type === "search" ? "Search" : "Tool";
const IconComp =
btn.icon.type === "lucide" ? resolveLucideIcon(btn.icon.name) : null;
const isChat = ["ask_ai", "translate", "summary"].includes(
btn.action.type
);
const isBuiltIn = BUILT_IN_IDS.has(btn.id);
const visualType: "Chat" | "Search" | "Tool" = isChat
? "Chat"
: btn.action.type === "search"
? "Search"
: "Tool";
return (
<div
@@ -229,11 +220,20 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
<div className="flex items-center gap-3">
<GripVertical className="size-4 text-[#64748B] shrink-0" />
{IconComp ? (
<IconComp className="size-4 shrink-0" style={{ color: btn.icon.color || "#6B7280" }} />
<IconComp
className="size-4 shrink-0"
// style={{ color: btn.icon.color || "#6B7280" }}
/>
) : (
<img src={(btn.icon as any).dataUrl} alt="icon" className="w-4 h-4 rounded shrink-0" />
<img
src={(btn.icon as any).dataUrl}
alt="icon"
className="w-4 h-4 rounded shrink-0"
/>
)}
<span className="text-sm font-medium">{btn.labelKey ? t(btn.labelKey) : btn.label}</span>
<span className="text-sm font-medium">
{btn.labelKey ? t(btn.labelKey) : btn.label}
</span>
<span
className={clsx(
"ml-2 inline-flex items-center rounded px-2 py-0.5 text-xs",
@@ -251,12 +251,14 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
{isChat && (
<>
<select
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
value={btn.action.assistantServerId || ""}
onChange={(e) => handleServerSelect(btn, e.target.value)}
title={t("selection.bind.service")}
>
<option value="">{t("selection.bind.defaultService")}</option>
<option value="">
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
@@ -270,16 +272,20 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
const loading = !!(sid && assistantLoadingByServer[sid]);
return (
<select
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
value={btn.action.assistantId || ""}
onChange={(e) => handleAssistantSelect(btn, e.target.value)}
onChange={(e) =>
handleAssistantSelect(btn, e.target.value)
}
title={t("selection.bind.assistant")}
disabled={loading}
>
<option value="">{t("selection.bind.defaultAssistant")}</option>
<option value="">
{t("selection.bind.defaultAssistant")}
</option>
{loading && (
<option value="" disabled>
...
{t("common.loading")}
</option>
)}
{list.map((a: any) => (
@@ -292,13 +298,30 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
})()}
</>
)}
{!isBuiltIn && (
<button
type="button"
className="inline-flex items-center justify-center rounded-md border border-transparent bg-red-50 text-red-600 hover:bg-red-100 p-1"
title={t("selection.actions.delete")}
aria-label={t("selection.actions.delete")}
onClick={() =>
setButtons((prev) => prev.filter((b) => b.id !== btn.id))
}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
})}
<AddChatButton
serverList={serverList}
onAdd={(newBtn) => setButtons((prev) => [...prev, newBtn])}
/>
</div>
);
};
export default ButtonsList;
export default ButtonsList;

View File

@@ -0,0 +1,50 @@
import {
Copy,
FileText,
Languages,
Search,
Volume2,
BotMessageSquare,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
export type IconConfig =
| { type: "lucide"; name: string; color?: string }
| { type: "custom"; dataUrl: string; color?: string };
export type ActionConfig = {
type: string;
assistantId?: string;
assistantServerId?: string;
eventName?: string;
};
export type ButtonConfig = {
id: string;
label: string;
icon: IconConfig;
action: ActionConfig;
labelKey?: string;
};
export const LUCIDE_ICON_MAP: Record<string, any> = {
Search,
Languages,
FileText,
Copy,
Volume2,
BotMessageSquare,
};
export function resolveLucideIcon(name?: string): any {
if (!name) return (LucideIcons as any)["Search"] || Search;
const direct = (LucideIcons as any)[name];
if (direct) return direct;
const normalized = String(name)
.trim()
.replace(/[-_\s]+/g, " ")
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("");
return (LucideIcons as any)[normalized] || (LUCIDE_ICON_MAP as any)[normalized] || null;
}

View File

@@ -5,48 +5,10 @@ import { useTranslation } from "react-i18next";
import { useSelectionStore } from "@/stores/selectionStore";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import SettingsItem from "@/components/Settings/SettingsItem";
import platformAdapter from "@/utils/platformAdapter";
import { useEnabledServers } from "@/hooks/useEnabledServers";
import ButtonsList from "./ButtonsList";
/**
* Selection toolbar button config types
*/
type IconConfig =
| { type: "lucide"; name: LucideIconName; color?: string }
| { type: "custom"; dataUrl: string; color?: string };
type ActionType =
| "search"
| "ask_ai"
| "translate"
| "summary"
| "copy"
| "speak"
| "custom";
type ButtonConfig = {
id: string;
label: string;
icon: IconConfig;
action: {
type: ActionType;
assistantId?: string;
assistantServerId?: string;
eventName?: string;
};
// i18n key for built-in labels; if present, render by t(labelKey)
labelKey?: string;
};
type LucideIconName =
| "Search"
| "Bot"
| "Languages"
| "FileText"
| "Copy"
| "Volume2";
import HeaderToolbar from "@/components/Selection/HeaderToolbar";
import { ButtonConfig } from "./config";
const DEFAULT_CONFIG: ButtonConfig[] = [
{
@@ -60,7 +22,7 @@ const DEFAULT_CONFIG: ButtonConfig[] = [
id: "ask_ai",
label: "问答",
labelKey: "selection.actions.ask_ai",
icon: { type: "lucide", name: "Bot", color: "#0287FF" },
icon: { type: "lucide", name: "BotMessageSquare", color: "#0287FF" },
action: { type: "ask_ai" },
},
{
@@ -103,9 +65,11 @@ function loadToolbarConfig(): ButtonConfig[] {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_CONFIG;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0)
return parsed as ButtonConfig[];
return DEFAULT_CONFIG;
let cfg: ButtonConfig[] = Array.isArray(parsed) && parsed.length > 0 ? (parsed as ButtonConfig[]) : DEFAULT_CONFIG;
// Lightweight migration: ensure ask_ai icon follows the updated default
const defaultAsk = DEFAULT_CONFIG.find((b) => b.id === "ask_ai");
cfg = cfg.map((b) => (b.id === "ask_ai" && defaultAsk ? { ...b, icon: defaultAsk.icon } : b));
return cfg;
} catch {
return DEFAULT_CONFIG;
}
@@ -125,6 +89,7 @@ const SelectionSettings = () => {
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
// Initialize from global store; write back on change for multi-window sync
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
@@ -143,7 +108,7 @@ const SelectionSettings = () => {
useEffect(() => {
saveToolbarConfig(buttons);
setToolbarConfig(buttons); // push to store for multi-window
setToolbarConfig(buttons);
}, [buttons]);
return (
@@ -152,6 +117,24 @@ const SelectionSettings = () => {
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
</div>
<div className="relative rounded-xl p-4 bg-gradient-to-r from-[#E6F0FA] to-[#FFF1F1]">
<div className="flex items-center flex-col" aria-hidden="true">
<div className="rounded-xl border border-gray-200 bg-white/70 shadow-sm dark:border-gray-700 dark:bg-gray-900/40">
<HeaderToolbar
buttons={buttons as any}
iconsOnly={iconsOnly}
onAction={() => {}}
onLogoClick={() => {}}
/>
</div>
</div>
<div
className="absolute inset-0 bg-transparent cursor-not-allowed"
aria-label={t("selection.preview.readonly")}
tabIndex={-1}
/>
</div>
<SettingsItem
icon={Sparkles}
title={t("settings.ai.title")}
@@ -159,15 +142,7 @@ const SelectionSettings = () => {
>
<SettingsToggle
checked={selectionEnabled}
onChange={async (value) => {
try {
await platformAdapter.invokeBackend("set_selection_enabled", {
enabled: value,
});
} catch (e) {
console.error("set_selection_enabled invoke failed:", e);
}
}}
onChange={setSelectionEnabled}
label={t("settings.ai.toggle")}
/>
</SettingsItem>
@@ -188,7 +163,11 @@ const SelectionSettings = () => {
label={t("selection.display.iconsOnlyLabel")}
/>
</SettingsItem>
<ButtonsList buttons={buttons} setButtons={setButtons} serverList={serverList} />
<ButtonsList
buttons={buttons}
setButtons={setButtons}
serverList={serverList}
/>
</div>
)}
</div>

View File

@@ -22,6 +22,7 @@ import platformAdapter from "@/utils/platformAdapter";
import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
import SelectionSettings from "./components/Selection";
import { isMac } from "@/utils/platform";
const Advanced = () => {
const { t } = useTranslation();
@@ -190,7 +191,7 @@ const Advanced = () => {
})}
</div>
<SelectionSettings />
{isMac && <SelectionSettings />}
<Shortcuts />

View File

@@ -0,0 +1,26 @@
import { useMount } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { useSelectionStore } from "@/stores/selectionStore";
export default function useSelectionEnabled() {
useMount(async () => {
try {
const enabled = await platformAdapter.invokeBackend<boolean>("get_selection_enabled");
useSelectionStore.getState().setSelectionEnabled(!!enabled);
} catch (e) {
console.error("get_selection_enabled failed:", e);
}
const unlisten = await platformAdapter.listenEvent(
"selection-enabled",
({ payload }: any) => {
useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled);
}
);
return () => {
unlisten && unlisten();
};
});
}

View File

@@ -50,52 +50,72 @@ export const useTray = () => {
};
const getTrayMenu = async () => {
const items = await Promise.all([
const itemPromises: Promise<any>[] = [];
itemPromises.push(
MenuItem.new({
text: t("tray.showCoco"),
accelerator: showCocoShortcuts.join("+"),
action: () => {
show_coco();
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: selectionEnabled
? t("tray.selectionDisable")
: t("tray.selectionEnable"),
action: async () => {
try {
await platformAdapter.invokeBackend("set_selection_enabled", { enabled: !selectionEnabled });
} catch (e) {
console.error("set_selection_enabled invoke failed:", e);
}
},
}),
})
);
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
if (isMac) {
itemPromises.push(
MenuItem.new({
text: selectionEnabled
? t("tray.selectionDisable")
: t("tray.selectionEnable"),
action: async () => {
try {
await platformAdapter.invokeBackend("set_selection_enabled", {
enabled: !selectionEnabled,
});
} catch (e) {
console.error("set_selection_enabled invoke failed:", e);
}
},
})
);
}
itemPromises.push(
MenuItem.new({
text: t("tray.settings"),
// accelerator: "CommandOrControl+,",
action: () => {
show_settings();
},
}),
})
);
itemPromises.push(
MenuItem.new({
text: t("tray.checkUpdate"),
action: async () => {
await show_check();
platformAdapter.emitEvent("check-update");
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
})
);
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
itemPromises.push(
MenuItem.new({
text: t("tray.quitCoco"),
accelerator: "CommandOrControl+Q",
action: () => {
exit(0);
},
}),
]);
})
);
const items = await Promise.all(itemPromises);
return Menu.new({ items });
};

View File

@@ -650,6 +650,10 @@
"login": "Login"
}
},
"common": {
"loading": "Loading...",
"notFound": "Not found"
},
"selection": {
"title": "Selection Toolbar Settings",
"actions": {
@@ -658,7 +662,9 @@
"translate": "Translate",
"summary": "Summary",
"copy": "Copy",
"speak": "Read Aloud"
"speak": "Read Aloud",
"delete": "Delete",
"addChat": "Add Chat Button"
},
"noText": "No text detected",
"copied": "Copied",
@@ -683,8 +689,24 @@
"bind": {
"service": "Select Service",
"defaultService": "Default service",
"assistant": "Bind Assistant (Chat only)",
"assistant": "Bind Assistant",
"defaultAssistant": "Default assistant"
},
"custom": {
"chat": "Custom Chat",
"namePlaceholder": "Button name"
},
"icon": {
"type": "Icon Type",
"lucide": "Lucide",
"custom": "Custom",
"pick": "Pick Icon",
"color": "Icon Color",
"upload": "Upload Icon",
"alt": "icon"
},
"preview": {
"readonly": "Preview is read-only"
}
}
}

View File

@@ -649,6 +649,10 @@
"login": "登录"
}
},
"common": {
"loading": "加载中...",
"notFound": "未找到"
},
"selection": {
"title": "划词工具栏设置",
"actions": {
@@ -657,7 +661,9 @@
"translate": "翻译",
"summary": "总结",
"copy": "复制",
"speak": "朗读"
"speak": "朗读",
"delete": "删除",
"addChat": "添加聊天功能"
},
"noText": "未检测到文本",
"copied": "已复制",
@@ -682,8 +688,24 @@
"bind": {
"service": "选择服务",
"defaultService": "默认服务",
"assistant": "绑定小助手(仅聊天)",
"assistant": "绑定小助手",
"defaultAssistant": "默认小助手"
},
"custom": {
"chat": "自定义聊天",
"namePlaceholder": "功能名称"
},
"icon": {
"type": "图标类型",
"lucide": "Lucide",
"custom": "自定义",
"pick": "选择图标",
"color": "图标颜色",
"upload": "上传图标",
"alt": "图标"
},
"preview": {
"readonly": "预览仅供查看"
}
}
}

View File

@@ -1,28 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Copy,
Languages,
Search,
X,
Volume2,
Pause,
Play,
FileText,
} from "lucide-react";
import { X, Pause, Play } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Separator } from "@radix-ui/react-separator";
import { useSelectionStore } from "@/stores/selectionStore";
import { copyToClipboard } from "@/utils";
import cocoLogoImg from "@/assets/app-icon.png";
import platformAdapter from "@/utils/platformAdapter";
import HeaderToolbar from "@/components/Selection/HeaderToolbar";
import type { ActionConfig } from "@/components/Settings/Advanced/components/Selection/config";
import { show_coco } from "@/commands";
// Simple animated selection window content
export default function SelectionWindow() {
const { t } = useTranslation();
const [text, setText] = useState("");
const [visible, setVisible] = useState(false);
const [animatingOut, setAnimatingOut] = useState(false);
@@ -137,34 +127,27 @@ export default function SelectionWindow() {
}, 150);
};
const openMain = async () => {
try {
await platformAdapter.commands("show_coco");
} catch {
await platformAdapter.emitEvent("show-coco");
await platformAdapter.showWindow();
}
};
const handleChatAction = useCallback(
async (assistantId?: string) => {
async (action: ActionConfig) => {
const payloadText = (textRef.current || "").trim();
if (!payloadText) return;
await openMain();
await new Promise((r) => setTimeout(r, 120));
await show_coco();
await new Promise((r) => setTimeout(r, 300));
await platformAdapter.emitEvent("selection-action", {
action: "chat",
text: payloadText,
assistantId,
assistantId: action.assistantId,
serverId: action.assistantServerId,
});
if (!isSpeaking) {
await close();
}
},
[openMain, isSpeaking, close]
[isSpeaking, close]
);
const searchMain = useCallback(async () => {
@@ -172,7 +155,7 @@ export default function SelectionWindow() {
console.log("searchMain payload", payloadText);
if (!payloadText) return;
await openMain();
await show_coco();
await new Promise((r) => setTimeout(r, 120));
await platformAdapter.emitEvent("selection-action", {
action: "search",
@@ -239,7 +222,7 @@ export default function SelectionWindow() {
setIsSpeaking(true);
setIsPaused(false);
} catch (e) {
console.error("TTS 播放失败", e);
console.error("TTS play failed:", e);
stopSpeak();
scheduleAutoHide();
}
@@ -258,124 +241,88 @@ export default function SelectionWindow() {
}
}, []);
const getActionHandler = (type: string, assistantId?: string) => {
switch (type) {
const getActionHandler = (action: ActionConfig) => {
switch (action.type) {
case "ask_ai":
case "translate":
case "summary":
return () => handleChatAction(assistantId);
handleChatAction(action);
break;
case "copy":
return handleCopy;
handleCopy();
break;
case "search":
return searchMain;
searchMain();
break;
case "speak":
return speak;
speak();
break;
default:
return () => {};
break;
}
};
// Render buttons from store; hide ones requiring assistant without assistantId
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
const iconsOnly = useSelectionStore((s) => s.iconsOnly);
const requiresAssistant = (type?: string) =>
type === "ask_ai" || type === "translate" || type === "summary";
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const visibleButtons = useMemo(
() =>
(Array.isArray(toolbarConfig) ? toolbarConfig : []).filter((btn: any) => {
const type = btn?.action?.type;
if (requiresAssistant(type)) {
return Boolean(btn?.action?.assistantId);
}
return true;
}),
() => (Array.isArray(toolbarConfig) ? toolbarConfig : []),
[toolbarConfig]
);
// Lucide icon map for dynamic rendering
const LUCIDE_ICON_MAP: Record<string, any> = {
Search,
Bot,
Languages,
FileText,
Copy,
Volume2,
};
// Resize window width to fit toolbar content (sum child widths to avoid feedback growth)
const resizeToContentWidth = useCallback(async () => {
try {
if (!visible) return;
const el = toolbarRef.current;
if (!el) return;
// Component: render icon (lucide or custom)
const IconRenderer = ({ icon }: { icon?: any }) => {
// Support lucide icon or custom image
if (icon?.type === "lucide") {
const Icon =
LUCIDE_ICON_MAP[icon?.name as string] || LUCIDE_ICON_MAP.Search;
return (
<Icon
className="size-4 transition-transform duration-150"
style={icon?.color ? { color: icon.color } : undefined}
/>
// Robust intrinsic width measurement: clone offscreen to avoid flex shrink/grow feedback.
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "absolute";
clone.style.visibility = "hidden";
clone.style.left = "-10000px";
clone.style.top = "0";
clone.style.width = "auto";
clone.style.maxWidth = "none";
clone.style.overflow = "visible";
// prevent wrapping that may change inline width
(clone.style as any).whiteSpace = "nowrap";
document.body.appendChild(clone);
const intrinsicWidth = Math.ceil(clone.getBoundingClientRect().width);
clone.remove();
// Apply small buffer and clamp by mode
const PAD = 12;
const WIDTH_MIN = iconsOnly ? 160 : 240;
const WIDTH_MAX = iconsOnly ? 640 : 960;
const desiredWidth = Math.max(
WIDTH_MIN,
Math.min(WIDTH_MAX, Math.ceil(intrinsicWidth + PAD))
);
}
if (icon?.type === "custom" && icon?.dataUrl) {
return (
<img
src={icon.dataUrl}
className="size-4 rounded"
alt=""
style={
icon?.color
? { filter: `drop-shadow(0 0 0 ${icon.color})` }
: undefined
}
/>
);
}
// default
return <Search className="size-4 text-[#6366F1]" />;
};
// Component: single toolbar button
const ToolbarButton = ({
btn,
onClick,
}: {
btn: any;
onClick: () => void;
}) => {
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
return (
<button
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
onClick={onClick}
title={label}
>
<IconRenderer icon={btn?.icon} />
{!iconsOnly && (
<span className="text-[12px] transition-opacity duration-150">
{label}
</span>
)}
</button>
);
};
const win = await platformAdapter.getCurrentWebviewWindow();
const size = await win.innerSize();
// Only update width; preserve current height
const currentWidth = size.width;
if (Math.abs(currentWidth - desiredWidth) >= 2) {
await platformAdapter.setWindowSize(desiredWidth, 32);
}
} catch (e) {
console.warn("resizeToContentWidth failed:", e);
}
}, [visible, iconsOnly]);
// Component: header logo
const HeaderLogo = () => {
return (
<img
src={cocoLogoImg}
alt="Coco Logo"
className="w-6 h-6"
onClick={openMain}
onError={(e) => {
try {
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
} catch {}
}}
/>
);
};
// Recalculate on relevant changes (buttons, labels, speaking state, visibility)
useEffect(() => {
if (!visible) return;
// Ensure DOM updated before measure
const id = window.requestAnimationFrame(() => {
resizeToContentWidth();
});
return () => cancelAnimationFrame(id);
}, [visibleButtons, iconsOnly, isSpeaking, visible, resizeToContentWidth]);
// Component: selected text preview
const TextPreview = ({ text }: { text: string }) => {
@@ -484,31 +431,15 @@ export default function SelectionWindow() {
<TextPreview text={text} />
</div>
<div
data-tauri-drag-region="false"
className="flex items-center gap-1 p-1 flex-nowrap overflow-hidden"
<HeaderToolbar
buttons={visibleButtons as any}
iconsOnly={iconsOnly}
onAction={getActionHandler}
onLogoClick={show_coco}
rootRef={toolbarRef}
>
<HeaderLogo />
<Separator
orientation="vertical"
decorative
className="mx-2 h-4 w-px bg-gray-300 dark:bg-white/30 shrink-0"
/>
{visibleButtons.map((btn: any) => {
const { type, assistantId } = btn?.action;
return (
<ToolbarButton
key={btn.id}
btn={btn}
onClick={getActionHandler(type, assistantId)}
/>
);
})}
{isSpeaking && <SpeakControls />}
</div>
</HeaderToolbar>
</div>
);
}

View File

@@ -1,13 +1,15 @@
import { createBrowserRouter } from "react-router-dom";
import { Suspense, lazy } from "react";
import Layout from "./layout";
import ErrorPage from "@/pages/error/index";
import DesktopApp from "@/pages/main/index";
import SettingsPage from "@/pages/settings/index";
import StandaloneChat from "@/pages/chat/index";
import WebPage from "@/pages/web/index";
import CheckPage from "@/pages/check/index";
import SelectionWindow from "@/pages/selection/index";
const DesktopApp = lazy(() => import("@/pages/main/index"));
const SettingsPage = lazy(() => import("@/pages/settings/index"));
const StandaloneChat = lazy(() => import("@/pages/chat/index"));
const WebPage = lazy(() => import("@/pages/web/index"));
const CheckPage = lazy(() => import("@/pages/check/index"));
const SelectionWindow = lazy(() => import("@/pages/selection/index"));
const routerOptions = {
basename: "/",
@@ -24,12 +26,12 @@ export const router = createBrowserRouter(
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{ path: "/ui", element: <DesktopApp /> },
{ path: "/ui/settings", element: <SettingsPage /> },
{ path: "/ui/chat", element: <StandaloneChat /> },
{ path: "/ui/check", element: <CheckPage /> },
{ path: "/ui/selection", element: <SelectionWindow /> },
{ path: "/web", element: <WebPage /> },
{ path: "/ui", element: (<Suspense fallback={<></>}><DesktopApp /></Suspense>) },
{ path: "/ui/settings", element: (<Suspense fallback={<></>}><SettingsPage /></Suspense>) },
{ path: "/ui/chat", element: (<Suspense fallback={<></>}><StandaloneChat /></Suspense>) },
{ path: "/ui/check", element: (<Suspense fallback={<></>}><CheckPage /></Suspense>) },
{ path: "/ui/selection", element: (<Suspense fallback={<></>}><SelectionWindow /></Suspense>) },
{ path: "/web", element: (<Suspense fallback={<></>}><WebPage /></Suspense>) },
],
},
],

View File

@@ -18,7 +18,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
import { useSelectionStore } from "@/stores/selectionStore";
import { useServers } from "@/hooks/useServers";
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
import { useSelectionWindow } from "../hooks/useSelectionWindow";
import { useSelectionWindow } from "@/hooks/useSelectionWindow";
export default function LayoutOutlet() {
const location = useLocation();
@@ -35,27 +35,6 @@ export default function LayoutOutlet() {
// init deep link manager
useDeepLinkManager();
// --- Selection state: init + subscribe backend as SSOT ---
useMount(async () => {
try {
const enabled = await platformAdapter.invokeBackend<boolean>("get_selection_enabled");
useSelectionStore.getState().setSelectionEnabled(!!enabled);
} catch (e) {
console.error("get_selection_enabled failed:", e);
}
const unlisten = await platformAdapter.listenEvent(
"selection-enabled",
({ payload }: any) => {
useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled);
}
);
return () => {
unlisten && unlisten();
};
});
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
@@ -154,4 +133,4 @@ export default function LayoutOutlet() {
<ErrorNotification />
</>
);
}
}

View File

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

View File

@@ -1,34 +1,16 @@
import { create } from 'zustand';
import { createTauriStore } from '@tauri-store/zustand';
// Types adapted from Selection/index.tsx to ensure compatibility
export type LucideIconName =
| "Search"
| "Bot"
| "Languages"
| "FileText"
| "Copy"
| "Volume2";
type IconConfig =
| { type: "lucide"; name: LucideIconName; color?: string }
| { type: "lucide"; name: string; color?: string }
| { type: "custom"; dataUrl: string; color?: string };
type ActionType =
| "search"
| "ask_ai"
| "translate"
| "summary"
| "copy"
| "speak"
| "custom";
export type ButtonConfig = {
id: string;
label: string;
icon: IconConfig;
action: {
type: ActionType;
type: string;
assistantId?: string;
assistantServerId?: string;
eventName?: string;

View File

@@ -53,13 +53,10 @@ export interface EventPayloads {
"server-list-changed": Server[];
"selection-text": string;
"selection-ask-ai": any;
"selection-action": {
action: "translate" | "search" | "copy" | "summary";
text: string;
};
"selection-action": any;
"selection-detected": string;
"selection-enabled": boolean;
"selection-icons-only": { value: boolean };
"change-selection-store": any;
}
// Window operation interface

View File

@@ -277,4 +277,4 @@ export const createWebAdapter = (): WebPlatformAdapter => {
return Promise.resolve();
},
};
};
};

View File

@@ -87,10 +87,20 @@ export default defineConfig(async () => ({
rollupOptions: {
output: {
manualChunks: {
vendor: ["react", "react-dom"],
katex: ["rehype-katex"],
highlight: ["rehype-highlight"],
react: ["react", "react-dom"],
router: ["react-router-dom"],
markdown: [
"react-markdown",
"remark-gfm",
"remark-breaks",
"remark-math",
"rehype-highlight",
"rehype-katex",
"mdast-util-gfm-autolink-literal",
],
mermaid: ["mermaid"],
icons: ["lucide-react", "@infinilabs/custom-icons"],
utils: ["lodash-es", "dayjs", "uuid", "nanoid", "axios"],
"tauri-api": [
"@tauri-apps/api/core",
"@tauri-apps/api/event",