mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-21 13:59:25 +01:00
Compare commits
34 Commits
release_pr
...
fix-macos-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05ca9646d | ||
|
|
b07707e973 | ||
|
|
6b0111b89f | ||
|
|
e029ddf2ba | ||
|
|
731cfc5bd7 | ||
|
|
cbd8dc52cd | ||
|
|
d1ad1af71a | ||
|
|
121f9c6118 | ||
|
|
770f60f30c | ||
|
|
5c92b5acab | ||
|
|
8e49455acf | ||
|
|
859def21bf | ||
|
|
6145306ee8 | ||
|
|
d0f7b7b833 | ||
|
|
f221606ae2 | ||
|
|
cd00ada3ac | ||
|
|
be6611133a | ||
|
|
9e682ceafc | ||
|
|
5510bedf7f | ||
|
|
ea34b7a404 | ||
|
|
ce94543baa | ||
|
|
89a8304b9e | ||
|
|
9652a54f08 | ||
|
|
ca71f07f3a | ||
|
|
00eb6bed2b | ||
|
|
95dc7a88d2 | ||
|
|
6aec9cbae2 | ||
|
|
4e58bc4b2c | ||
|
|
a9a4b5319c | ||
|
|
6523fef12b | ||
|
|
b8affcd4a1 | ||
|
|
595ae676b7 | ||
|
|
5c76c92c95 | ||
|
|
f03ad8a6c8 |
36
.github/workflows/frontend-ci.yml
vendored
36
.github/workflows/frontend-ci.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
# Only run it when Frontend code changes
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'tsup.config.ts'
|
||||
- 'package.json'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -17,6 +19,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -30,5 +35,36 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Switch platformAdapter to Web adapter
|
||||
shell: bash
|
||||
run: >
|
||||
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||
let s=fs.readFileSync(f,'utf8');
|
||||
s=s.replace(/import\\s*\\{\\s*createTauriAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/tauriAdapter\\\";/,'import { createWebAdapter } from \\\"./webAdapter\\\";');
|
||||
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createTauriAdapter\\(\\);/,'let platformAdapter = createWebAdapter();');
|
||||
fs.writeFileSync(f,s);"
|
||||
|
||||
- name: Build web (Tauri dependency check)
|
||||
run: pnpm build:web
|
||||
|
||||
- name: Verify no Tauri refs in web output
|
||||
shell: bash
|
||||
run: |
|
||||
if grep -R -n -E '@tauri-apps|tauri-plugin' out/search-chat; then
|
||||
echo 'Tauri references found in web build output';
|
||||
exit 1;
|
||||
else
|
||||
echo 'No Tauri references found';
|
||||
fi
|
||||
|
||||
- name: Restore platformAdapter to Tauri adapter
|
||||
shell: bash
|
||||
run: >
|
||||
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||
let s=fs.readFileSync(f,'utf8');
|
||||
s=s.replace(/import\\s*\\{\\s*createWebAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/webAdapter\\\";/,'import { createTauriAdapter } from \\\"./tauriAdapter\\\";');
|
||||
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createWebAdapter\\(\\);/,'let platformAdapter = createTauriAdapter();');
|
||||
fs.writeFileSync(f,s);"
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
|
||||
|
||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-server/](https://docs.infinilabs.com/coco-server/).
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
@@ -8,11 +8,43 @@ title: "Release Notes"
|
||||
Information about release notes of Coco App is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
feat: support switching groups via keyboard shortcuts #911
|
||||
feat: support opening logs from about page #915
|
||||
feat: support moving cursor with home and end keys #918
|
||||
feat: support pageup/pagedown to navigate search results #920
|
||||
feat: standardize multi-level menu label structure #925
|
||||
feat(View Extension): page field now accepts HTTP(s) links #925
|
||||
feat: return sub-exts when extension type exts themselves are matched #928
|
||||
feat: open quick ai with modifier key + enter #939
|
||||
feat: allow navigate back when cursor is at the beginning #940
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
fix: automatic update of service list #913
|
||||
fix: duplicate chat content #916
|
||||
fix: resolve pinned window shortcut not working #917
|
||||
fix: WM ext does not work when operating focused win from another display #919
|
||||
fix(Window Management): Next/Previous Desktop do not work #926
|
||||
fix: fix page rapidly flickering issue #935
|
||||
fix(view extension): broken search bar UI when opening extensions via hotkey #938
|
||||
fix: allow deletion after selecting all text #943
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
refactor: improve sorting logic of search results #910
|
||||
style: add dark drop shadow to images #912
|
||||
chore: add cross-domain configuration for web component #921
|
||||
refactor: retry if AXUIElementSetAttributeValue() does not work #924
|
||||
refactor(calculator): skip evaluation if expr is in form "num => num" #929
|
||||
chore: use a custom log directory #930
|
||||
chore: bump tauri_nspanel to v2.1 #933
|
||||
refactor: show_coco/hide_coco now use NSPanel's function on macOS #933
|
||||
refactor: procedure that convert_pages() into a func #934
|
||||
|
||||
## 0.8.0 (2025-09-28)
|
||||
|
||||
|
||||
65
src-tauri/Cargo.lock
generated
65
src-tauri/Cargo.lock
generated
@@ -976,7 +976,7 @@ dependencies = [
|
||||
"block",
|
||||
"cocoa-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.24.0",
|
||||
"core-graphics",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
"objc",
|
||||
@@ -1126,19 +1126,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics-types"
|
||||
version = "0.2.0"
|
||||
@@ -1561,7 +1548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"core-graphics 0.24.0",
|
||||
"core-graphics",
|
||||
"dunce",
|
||||
"gdk",
|
||||
"gdkx11",
|
||||
@@ -1656,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.24.0",
|
||||
"core-graphics",
|
||||
"foreign-types-shared 0.3.1",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4126,17 +4113,6 @@ dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-foundation"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||
dependencies = [
|
||||
"block",
|
||||
"objc",
|
||||
"objc_id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
@@ -4479,15 +4455,6 @@ dependencies = [
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_id"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
|
||||
dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
@@ -4709,6 +4676,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "path-clean"
|
||||
version = "1.0.1"
|
||||
@@ -6279,7 +6252,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics 0.24.0",
|
||||
"core-graphics",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -6527,7 +6500,7 @@ dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"block2 0.6.1",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.24.0",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
"dlopen2",
|
||||
@@ -6727,17 +6700,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-nspanel"
|
||||
version = "2.0.1"
|
||||
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a"
|
||||
version = "2.1.0"
|
||||
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2.1#da9c9a8d4eb7f0524a2508988df1a7d9585b4904"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"block",
|
||||
"cocoa",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics 0.25.0",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"objc_id",
|
||||
"objc2 0.6.2",
|
||||
"objc2-app-kit 0.3.1",
|
||||
"objc2-foundation 0.3.1",
|
||||
"pastey",
|
||||
"tauri",
|
||||
]
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ path-clean = "1.0.1"
|
||||
tempfile = "3.23.0"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
objc2 = "0.6.2"
|
||||
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::extension::built_in::window_management::actions::Action;
|
||||
use crate::extension::{ExtensionPermission, ExtensionSettings};
|
||||
use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
@@ -89,6 +90,7 @@ pub(crate) enum ExtensionOnOpenedType {
|
||||
///
|
||||
/// It should be an absolute path or Tauri cannot open it.
|
||||
page: String,
|
||||
ui: Option<ViewExtensionUISettings>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -118,7 +120,7 @@ impl OnOpened {
|
||||
// The URL of a quicklink is nearly useless without such dynamic user
|
||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||
ExtensionOnOpenedType::View { page: _ } => {
|
||||
ExtensionOnOpenedType::View { page: _, ui: _ } => {
|
||||
// We currently don't have URL for this kind of extension.
|
||||
String::from("N/A")
|
||||
}
|
||||
@@ -132,7 +134,7 @@ impl OnOpened {
|
||||
pub(crate) async fn open(
|
||||
tauri_app_handle: AppHandle,
|
||||
on_opened: OnOpened,
|
||||
extra_args: Option<HashMap<String, String>>,
|
||||
extra_args: Option<HashMap<String, Json>>,
|
||||
) -> Result<(), String> {
|
||||
use crate::util::open as homemade_tauri_shell_open;
|
||||
use std::process::Command;
|
||||
@@ -231,7 +233,7 @@ pub(crate) async fn open(
|
||||
}
|
||||
}
|
||||
}
|
||||
ExtensionOnOpenedType::View { page } => {
|
||||
ExtensionOnOpenedType::View { page, ui } => {
|
||||
/*
|
||||
* Emit an event to let the frontend code open this extension.
|
||||
*
|
||||
@@ -243,8 +245,18 @@ pub(crate) async fn open(
|
||||
use serde_json::Value as Json;
|
||||
use serde_json::to_value;
|
||||
|
||||
let page_and_permission: [Json; 2] =
|
||||
[Json::String(page), to_value(permission).unwrap()];
|
||||
let mut extra_args =
|
||||
extra_args.expect("extra_args is needed to open() a view extension");
|
||||
let document = extra_args.remove("document").expect(
|
||||
"extra argument [document] should be provided to open a view extension",
|
||||
);
|
||||
|
||||
let page_and_permission: [Json; 4] = [
|
||||
Json::String(page),
|
||||
to_value(permission).unwrap(),
|
||||
to_value(ui).unwrap(),
|
||||
document,
|
||||
];
|
||||
tauri_app_handle
|
||||
.emit("open_view_extension", page_and_permission)
|
||||
.unwrap();
|
||||
|
||||
@@ -1242,6 +1242,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
||||
enabled,
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
|
||||
@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
|
||||
// will only be evaluated against non-whitespace characters.
|
||||
let query_string = query_string.trim();
|
||||
|
||||
if query_string.is_empty() || query_string.len() == 1 {
|
||||
if query_string.is_empty() {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
@@ -150,6 +150,26 @@ impl SearchSource for CalculatorSource {
|
||||
let query_source = self.get_type();
|
||||
let base_score = self.base_score;
|
||||
let closure = move || -> QueryResponse {
|
||||
let Ok(tokens) = meval::tokenizer::tokenize(&query_string_clone) else {
|
||||
// Invalid expression, return nothing.
|
||||
return QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
};
|
||||
};
|
||||
// If it is only a number, no need to evaluate it as the result is
|
||||
// this number.
|
||||
// Actually, there is no need to return the result back to the users
|
||||
// in such case because letting them know "x = x" is meaningless.
|
||||
if tokens.len() == 1 && matches!(tokens[0], meval::tokenizer::Token::Number(_)) {
|
||||
return QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let res_num = meval::eval_str(&query_string_clone);
|
||||
|
||||
match res_num {
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::ffi::c_ushort;
|
||||
use std::ffi::c_void;
|
||||
use std::ops::Deref;
|
||||
use std::ptr::NonNull;
|
||||
use std::time::Duration;
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSEvent;
|
||||
@@ -34,6 +35,7 @@ use objc2_core_graphics::CGEventType;
|
||||
use objc2_core_graphics::CGMouseButton;
|
||||
use objc2_core_graphics::CGRectGetMidX;
|
||||
use objc2_core_graphics::CGRectGetMinY;
|
||||
use objc2_core_graphics::CGRectIntersectsRect;
|
||||
use objc2_core_graphics::CGWindowID;
|
||||
|
||||
use super::error::Error;
|
||||
@@ -46,12 +48,7 @@ use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
fn intersects(r1: CGRect, r2: CGRect) -> bool {
|
||||
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|
||||
|| r1.origin.y + r1.size.height < r2.origin.y
|
||||
|| r1.origin.x > r2.origin.x + r2.size.width
|
||||
|| r1.origin.y > r2.origin.y + r2.size.height);
|
||||
|
||||
overlapping
|
||||
unsafe { CGRectIntersectsRect(r1, r2) }
|
||||
}
|
||||
|
||||
/// Core graphics APIs use flipped coordinate system, while AppKit uses the
|
||||
@@ -86,6 +83,23 @@ fn get_ui_element_origin(ui_element: &CFRetained<AXUIElement>) -> Result<CGPoint
|
||||
Ok(position_cg_point)
|
||||
}
|
||||
|
||||
/// Send a set origin request to the `ui_element`, return once request is sent.
|
||||
fn set_ui_element_origin_oneshot(
|
||||
ui_element: &CFRetained<AXUIElement>,
|
||||
mut origin: CGPoint,
|
||||
) -> Result<(), Error> {
|
||||
let ptr_to_origin = NonNull::new((&mut origin as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_origin) }.unwrap();
|
||||
let pos_attr = CFString::from_static_str("AXPosition");
|
||||
|
||||
let error = unsafe { ui_element.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to extract an UI element's size.
|
||||
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
|
||||
let mut size_value: *const CFType = std::ptr::null();
|
||||
@@ -110,6 +124,23 @@ fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, E
|
||||
Ok(size_cg_size)
|
||||
}
|
||||
|
||||
/// Send a set size request to the `ui_element`, return once request is sent.
|
||||
fn set_ui_element_size_oneshot(
|
||||
ui_element: &CFRetained<AXUIElement>,
|
||||
mut size: CGSize,
|
||||
) -> Result<(), Error> {
|
||||
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
|
||||
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
|
||||
let size_attr = CFString::from_static_str("AXSize");
|
||||
|
||||
let error = unsafe { ui_element.set_attribute_value(&size_attr, size_value.deref()) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the frontmost/focused window (as an UI element).
|
||||
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
|
||||
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
|
||||
@@ -307,6 +338,10 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
|
||||
|
||||
let window_frame = get_frontmost_window_frame()?;
|
||||
let close_button_frame = get_frontmost_window_close_button_frame()?;
|
||||
let prev_mouse_position = unsafe {
|
||||
let event = CGEvent::new(None);
|
||||
CGEvent::location(event.as_deref())
|
||||
};
|
||||
|
||||
let mouse_cursor_point = CGPoint::new(
|
||||
unsafe { CGRectGetMidX(close_button_frame) },
|
||||
@@ -360,6 +395,9 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
|
||||
}
|
||||
|
||||
// Make a slight delay to make sure the window is grabbed
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// cast is safe as space is in range [1, 16]
|
||||
let hot_key: c_ushort = 118 + space as c_ushort - 1;
|
||||
|
||||
@@ -402,9 +440,30 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
|
||||
);
|
||||
}
|
||||
|
||||
// Make a slight delay to finish the space transition animation
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
/*
|
||||
* Cleanup
|
||||
*/
|
||||
unsafe {
|
||||
// Let go of the window.
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
|
||||
|
||||
// Reset mouse position
|
||||
let mouse_reset_event = {
|
||||
CGEvent::new_mouse_event(
|
||||
None,
|
||||
CGEventType::MouseMoved,
|
||||
prev_mouse_position,
|
||||
CGMouseButton::Left,
|
||||
)
|
||||
};
|
||||
CGEvent::set_flags(mouse_reset_event.as_deref(), CGEventFlags(0));
|
||||
CGEvent::post(
|
||||
CGEventTapLocation::HIDEventTap,
|
||||
mouse_reset_event.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -461,6 +520,9 @@ fn get_frontmost_window_close_button_frame() -> Result<CGRect, Error> {
|
||||
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
|
||||
/// puts a menu bar on every display.
|
||||
///
|
||||
/// Update: This could be wrong, but looks like Apple fixed these 2 bugs in macOS
|
||||
/// 26. At least the buggy behaviors disappear in my test.
|
||||
///
|
||||
///
|
||||
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
|
||||
/// is currently safe to draw your app’s content.
|
||||
@@ -558,27 +620,61 @@ pub fn move_frontmost_window(x: f64, y: f64) -> Result<(), Error> {
|
||||
|
||||
/// Set the frontmost window's frame to the specified frame - adjust size and
|
||||
/// location at the same time.
|
||||
///
|
||||
/// This function **retries** up to `RETRY` times until the set operations
|
||||
/// successfully get performed.
|
||||
///
|
||||
/// # Retry
|
||||
///
|
||||
/// Retry is added because I encountered a case where `AXUIElementSetAttributeValue()`
|
||||
/// does not work in the expected way. When I execute the `NextDisplay` command
|
||||
/// to move the focused window from a big display (2560x1440) to a small display
|
||||
/// (1440*900), the window size could be set to 1460 sometimes. No idea if this
|
||||
/// is a bug of the Accessibility APIs or due to the improper API uses. So we
|
||||
/// retry for `RETRY` times at most to try our beest make it behave correctly.
|
||||
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
|
||||
const RETRY: usize = 5;
|
||||
/// Sleep for 50ms as I don't want to send too many requests to the focused
|
||||
/// app and WindowServer because doing that could make them busy and then
|
||||
/// they won't process my set requests.
|
||||
///
|
||||
/// The above is simply my observation, I don't know how the messaging really
|
||||
/// works under the hood.
|
||||
const SLEEP: Duration = Duration::from_millis(50);
|
||||
|
||||
let frontmost_window = get_frontmost_window()?;
|
||||
|
||||
let mut point = frame.origin;
|
||||
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
|
||||
let pos_attr = CFString::from_static_str("AXPosition");
|
||||
/*
|
||||
* Set window origin
|
||||
*/
|
||||
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
|
||||
for _ in 0..RETRY {
|
||||
std::thread::sleep(SLEEP);
|
||||
|
||||
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
let current = get_ui_element_origin(&frontmost_window)?;
|
||||
if current == frame.origin {
|
||||
break;
|
||||
} else {
|
||||
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut size = frame.size;
|
||||
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
|
||||
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
|
||||
let size_attr = CFString::from_static_str("AXSize");
|
||||
/*
|
||||
* Set window size
|
||||
*/
|
||||
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
|
||||
for _ in 0..RETRY {
|
||||
std::thread::sleep(SLEEP);
|
||||
|
||||
let error = unsafe { frontmost_window.set_attribute_value(&size_attr, size_value.deref()) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
let current = get_ui_element_size(&frontmost_window)?;
|
||||
// For size, we do not check if `current` has the exact same value as
|
||||
// `frame.size` as I have encountered a case where I ask macOS to set
|
||||
// the height to 1550, but the height gets set to 1551.
|
||||
if cgsize_roughly_equal(current, frame.size, 3.0) {
|
||||
break;
|
||||
} else {
|
||||
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -624,6 +720,15 @@ pub fn toggle_fullscreen() -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if `lhs` roughly equals to `rhs`. The Roughness can be controlled by
|
||||
/// argument `tolerance`.
|
||||
fn cgsize_roughly_equal(lhs: CGSize, rhs: CGSize, tolerance: f64) -> bool {
|
||||
let width_diff = (lhs.width - rhs.width).abs();
|
||||
let height_diff = (lhs.height - rhs.height).abs();
|
||||
|
||||
width_diff <= tolerance && height_diff <= tolerance
|
||||
}
|
||||
|
||||
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
@@ -636,3 +741,56 @@ pub(crate) fn get_frontmost_window_last_frame(window_id: CGWindowID) -> Option<C
|
||||
let map = LAST_FRAME.lock().unwrap();
|
||||
map.get(&window_id).cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_intersects_adjacent_rects_x() {
|
||||
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
let r2 = CGRect::new(CGPoint::new(100.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
assert!(
|
||||
!intersects(r1, r2),
|
||||
"Adjacent rects on X should not intersect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersects_adjacent_rects_y() {
|
||||
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
let r2 = CGRect::new(CGPoint::new(0.0, 100.0), CGSize::new(100.0, 100.0));
|
||||
assert!(
|
||||
!intersects(r1, r2),
|
||||
"Adjacent rects on Y should not intersect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersects_overlapping_rects() {
|
||||
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
let r2 = CGRect::new(CGPoint::new(50.0, 50.0), CGSize::new(100.0, 100.0));
|
||||
assert!(intersects(r1, r2), "Overlapping rects should intersect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersects_separate_rects() {
|
||||
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
let r2 = CGRect::new(CGPoint::new(101.0, 101.0), CGSize::new(100.0, 100.0));
|
||||
assert!(!intersects(r1, r2), "Separate rects should not intersect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersects_contained_rect() {
|
||||
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
let r2 = CGRect::new(CGPoint::new(10.0, 10.0), CGSize::new(50.0, 50.0));
|
||||
assert!(intersects(r1, r2), "Contained rect should intersect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersects_identical_rects() {
|
||||
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
let r2 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||
assert!(intersects(r1, r2), "Identical rects should intersect");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
|
||||
pub(crate) const EXTENSION_ID: &str = "Window Management";
|
||||
pub(crate) const EXTENSION_NAME_LOWERCASE: &str = "window management";
|
||||
|
||||
/// JSON file for this extension.
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::EXTENSION_ID;
|
||||
use super::EXTENSION_NAME_LOWERCASE;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::common::{
|
||||
error::SearchError,
|
||||
@@ -81,6 +82,16 @@ impl SearchSource for WindowManagementSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
// An "extension" type extension should return all its
|
||||
// sub-extensions when the query string matches its name.
|
||||
// To do this, we score the extension name and take that
|
||||
// into account.
|
||||
if let Some(main_extension_score) =
|
||||
calculate_text_similarity(&query_string_lowercase, &EXTENSION_NAME_LOWERCASE)
|
||||
{
|
||||
score += main_extension_score;
|
||||
}
|
||||
|
||||
score
|
||||
};
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ pub struct Extension {
|
||||
/// and render. Otherwise, `None`.
|
||||
page: Option<String>,
|
||||
|
||||
ui: Option<ViewExtensionUISettings>,
|
||||
|
||||
/// Permission that this extension requires.
|
||||
permission: Option<ExtensionPermission>,
|
||||
|
||||
@@ -126,6 +128,17 @@ pub struct Extension {
|
||||
version: Option<Json>,
|
||||
}
|
||||
|
||||
/// Settings that control the built-in UI Components
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub(crate) struct ViewExtensionUISettings {
|
||||
/// Show the search bar
|
||||
search_bar: bool,
|
||||
/// Show the filter bar
|
||||
filter_bar: bool,
|
||||
/// Show the footer
|
||||
footer: bool,
|
||||
}
|
||||
|
||||
/// Bundle ID uniquely identifies an extension.
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
|
||||
pub(crate) struct ExtensionBundleId {
|
||||
@@ -265,8 +278,9 @@ impl Extension {
|
||||
let page = self.page.as_ref().unwrap_or_else(|| {
|
||||
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
|
||||
}).clone();
|
||||
let ui = self.ui.clone();
|
||||
|
||||
let extension_on_opened_type = ExtensionOnOpenedType::View { page };
|
||||
let extension_on_opened_type = ExtensionOnOpenedType::View { page, ui };
|
||||
let extension_on_opened = ExtensionOnOpened {
|
||||
ty: extension_on_opened_type,
|
||||
settings,
|
||||
@@ -416,7 +430,7 @@ impl QuicklinkLink {
|
||||
/// if any.
|
||||
pub(crate) fn concatenate_url(
|
||||
&self,
|
||||
user_supplied_args: &Option<HashMap<String, String>>,
|
||||
user_supplied_args: &Option<HashMap<String, Json>>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
for component in self.components.iter() {
|
||||
@@ -428,20 +442,23 @@ impl QuicklinkLink {
|
||||
argument_name,
|
||||
default,
|
||||
} => {
|
||||
let opt_argument_value = {
|
||||
let opt_argument_value: Option<&str> = {
|
||||
let user_supplied_arg = user_supplied_args
|
||||
.as_ref()
|
||||
.and_then(|map| map.get(argument_name.as_str()));
|
||||
|
||||
if user_supplied_arg.is_some() {
|
||||
user_supplied_arg
|
||||
user_supplied_arg.map(|json| {
|
||||
json.as_str()
|
||||
.expect("quicklink should provide string arguments")
|
||||
})
|
||||
} else {
|
||||
default.as_ref()
|
||||
default.as_deref()
|
||||
}
|
||||
};
|
||||
|
||||
let argument_value_str = match opt_argument_value {
|
||||
Some(str) => str.as_str(),
|
||||
Some(str) => str,
|
||||
// None => an empty string
|
||||
None => "",
|
||||
};
|
||||
@@ -963,6 +980,14 @@ pub(crate) fn canonicalize_relative_page_path(
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("this should be invoked on a View extension");
|
||||
|
||||
// Skip HTTP links
|
||||
if let Ok(url) = url::Url::parse(page)
|
||||
&& ["http", "https"].contains(&url.scheme())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let page_path = Path::new(page);
|
||||
|
||||
if page_path.is_relative() {
|
||||
@@ -1741,7 +1766,7 @@ mod tests {
|
||||
],
|
||||
};
|
||||
let mut user_args = HashMap::new();
|
||||
user_args.insert("other_param".to_string(), "value".to_string());
|
||||
user_args.insert("other_param".to_string(), Json::String("value".to_string()));
|
||||
let result = link.concatenate_url(&Some(user_args));
|
||||
assert_eq!(result, "https://www.google.com/search?q=");
|
||||
}
|
||||
@@ -1778,7 +1803,7 @@ mod tests {
|
||||
],
|
||||
};
|
||||
let mut user_args = HashMap::new();
|
||||
user_args.insert("other_param".to_string(), "value".to_string());
|
||||
user_args.insert("other_param".to_string(), Json::String("value".to_string()));
|
||||
let result = link.concatenate_url(&Some(user_args));
|
||||
assert_eq!(result, "https://www.google.com/search?q=rust");
|
||||
}
|
||||
@@ -1801,7 +1826,7 @@ mod tests {
|
||||
],
|
||||
};
|
||||
let mut user_args = HashMap::new();
|
||||
user_args.insert("query".to_string(), "python".to_string());
|
||||
user_args.insert("query".to_string(), Json::String("python".to_string()));
|
||||
let result = link.concatenate_url(&Some(user_args));
|
||||
assert_eq!(result, "https://www.google.com/search?q=python");
|
||||
}
|
||||
|
||||
9
src-tauri/src/extension/third_party/check.rs
vendored
9
src-tauri/src/extension/third_party/check.rs
vendored
@@ -231,6 +231,14 @@ fn check_main_extension_or_sub_extension(
|
||||
));
|
||||
}
|
||||
|
||||
// If field `ui` is Some, then it should be a View
|
||||
if extension.ui.is_some() && extension.r#type != ExtensionType::View {
|
||||
return Err(format!(
|
||||
"invalid {}, field [ui] is set for a non-View extension",
|
||||
identifier
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -267,6 +275,7 @@ mod tests {
|
||||
hotkey: None,
|
||||
enabled: true,
|
||||
page,
|
||||
ui: None,
|
||||
permission: None,
|
||||
settings: None,
|
||||
screenshots: None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::install::{
|
||||
convert_page, filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||
filter_out_incompatible_sub_extensions, is_extension_installed, view_extension_convert_pages,
|
||||
};
|
||||
use crate::extension::third_party::{
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||
@@ -8,7 +9,6 @@ use crate::extension::third_party::{
|
||||
use crate::extension::{
|
||||
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
|
||||
};
|
||||
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
|
||||
use crate::util::platform::Platform;
|
||||
use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
@@ -223,50 +223,11 @@ pub(crate) async fn install_local_extension(
|
||||
|
||||
/*
|
||||
* Call convert_page() to update the page files. This has to be done after
|
||||
* writing the extension files
|
||||
* writing the extension files because we will edit them.
|
||||
*
|
||||
* HTTP links will be skipped.
|
||||
*/
|
||||
let absolute_page_paths: Vec<PathBuf> = {
|
||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||
if page_path.is_relative() {
|
||||
// It is relative to the extension root directory
|
||||
extension_root.join(page_path)
|
||||
} else {
|
||||
page_path.into()
|
||||
}
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::View {
|
||||
let page = extension
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
||||
|
||||
vec![path]
|
||||
} else if extension.r#type.contains_sub_items()
|
||||
&& let Some(ref views) = extension.views
|
||||
{
|
||||
let mut paths = Vec::with_capacity(views.len());
|
||||
|
||||
for view in views.iter() {
|
||||
let page = view
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
||||
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
paths
|
||||
} else {
|
||||
// No pages in this extension
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
for page_path in absolute_page_paths {
|
||||
convert_page(&page_path).await?;
|
||||
}
|
||||
view_extension_convert_pages(&extension, &dest_dir).await?;
|
||||
|
||||
// Canonicalize relative icon and page paths
|
||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||
|
||||
@@ -42,8 +42,10 @@ pub(crate) mod local_extension;
|
||||
pub(crate) mod store;
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::util::platform::Platform;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
@@ -228,6 +230,63 @@ fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String
|
||||
Ok(modified_html)
|
||||
}
|
||||
|
||||
async fn view_extension_convert_pages(
|
||||
extension: &Extension,
|
||||
extension_directory: &Path,
|
||||
) -> Result<(), String> {
|
||||
let pages: Vec<&str> = {
|
||||
if extension.r#type == ExtensionType::View {
|
||||
let page = extension
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
|
||||
vec![page.as_str()]
|
||||
} else if extension.r#type.contains_sub_items()
|
||||
&& let Some(ref views) = extension.views
|
||||
{
|
||||
let mut pages = Vec::with_capacity(views.len());
|
||||
|
||||
for view in views.iter() {
|
||||
let page = view
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
|
||||
pages.push(page.as_str());
|
||||
}
|
||||
|
||||
pages
|
||||
} else {
|
||||
// No pages in this extension
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||
if page_path.is_relative() {
|
||||
// It is relative to the extension root directory
|
||||
extension_root.join(page_path)
|
||||
} else {
|
||||
page_path.into()
|
||||
}
|
||||
}
|
||||
for page in pages {
|
||||
/*
|
||||
* Skip HTTP links
|
||||
*/
|
||||
if let Ok(url) = url::Url::parse(page)
|
||||
&& ["http", "https"].contains(&url.scheme())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = canonicalize_page_path(Path::new(page), &extension_directory);
|
||||
convert_page(&path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -259,6 +318,7 @@ mod tests {
|
||||
enabled: true,
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
|
||||
@@ -10,15 +10,14 @@ use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
use crate::extension::canonicalize_relative_icon_path;
|
||||
use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::get_third_party_extension_directory;
|
||||
use crate::extension::third_party::install::convert_page;
|
||||
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||
use crate::extension::third_party::install::view_extension_convert_pages;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
@@ -26,8 +25,6 @@ use reqwest::StatusCode;
|
||||
use serde_json::Map as JsonObject;
|
||||
use serde_json::Value as Json;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::AppHandle;
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Extension Store";
|
||||
@@ -401,50 +398,11 @@ pub(crate) async fn install_extension_from_store(
|
||||
|
||||
/*
|
||||
* Call convert_page() to update the page files. This has to be done after
|
||||
* writing the extension files
|
||||
* writing the extension files because we will edit them.
|
||||
*
|
||||
* HTTP links will be skipped.
|
||||
*/
|
||||
let absolute_page_paths: Vec<PathBuf> = {
|
||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||
if page_path.is_relative() {
|
||||
// It is relative to the extension root directory
|
||||
extension_root.join(page_path)
|
||||
} else {
|
||||
page_path.into()
|
||||
}
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::View {
|
||||
let page = extension
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
|
||||
|
||||
vec![path]
|
||||
} else if extension.r#type.contains_sub_items()
|
||||
&& let Some(ref views) = extension.views
|
||||
{
|
||||
let mut paths = Vec::with_capacity(views.len());
|
||||
|
||||
for view in views.iter() {
|
||||
let page = view
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
|
||||
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
paths
|
||||
} else {
|
||||
// No pages in this extension
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
for page_path in absolute_page_paths {
|
||||
convert_page(&page_path).await?;
|
||||
}
|
||||
view_extension_convert_pages(&extension, &extension_directory).await?;
|
||||
|
||||
// Canonicalize relative icon and page paths
|
||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||
|
||||
91
src-tauri/src/extension/third_party/mod.rs
vendored
91
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -15,6 +15,7 @@ use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::ExtensionBundleIdBorrowed;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::calculate_text_similarity;
|
||||
use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::util::platform::Platform;
|
||||
@@ -22,6 +23,7 @@ use async_trait::async_trait;
|
||||
use borrowme::ToOwned;
|
||||
use check::general_check;
|
||||
use function_name::named;
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -523,6 +525,24 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
|
||||
"setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type,
|
||||
));
|
||||
let url = on_opened.url();
|
||||
let extension_type_string = extension.r#type.to_string();
|
||||
let document = Document {
|
||||
id: extension.id.clone(),
|
||||
title: Some(extension.name.clone()),
|
||||
icon: Some(extension.icon.clone()),
|
||||
on_opened: Some(on_opened.clone()),
|
||||
url: Some(url),
|
||||
category: Some(extension_type_string.clone()),
|
||||
source: Some(DataSourceReference {
|
||||
id: Some(extension_type_string.clone()),
|
||||
name: Some(extension_type_string.clone()),
|
||||
icon: None,
|
||||
r#type: Some(extension_type_string),
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bundle_id_owned = bundle_id.to_owned();
|
||||
tauri_app_handle
|
||||
@@ -532,9 +552,16 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
let bundle_id_clone = bundle_id_owned.clone();
|
||||
let app_handle_clone = tauri_app_handle.clone();
|
||||
|
||||
let document_clone = document.clone();
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
String::from("document"),
|
||||
serde_json::to_value(&document_clone).unwrap(),
|
||||
);
|
||||
|
||||
let result = open(app_handle_clone, on_opened_clone, Some(args)).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{:?}], error [{}]",
|
||||
@@ -757,11 +784,22 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
let opt_main_extension_lowercase_name =
|
||||
if extension.r#type == ExtensionType::Extension {
|
||||
Some(extension.name.to_lowercase())
|
||||
} else {
|
||||
// None if it is of type `ExtensionType::Group`
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref commands) = extension.commands {
|
||||
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(command, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
if let Some(hit) = extension_to_hit(
|
||||
command,
|
||||
&query_lower,
|
||||
opt_data_source.as_deref(),
|
||||
opt_main_extension_lowercase_name.as_deref(),
|
||||
) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
@@ -769,9 +807,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
for script in scripts.iter().filter(|script| script.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(script, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
if let Some(hit) = extension_to_hit(
|
||||
script,
|
||||
&query_lower,
|
||||
opt_data_source.as_deref(),
|
||||
opt_main_extension_lowercase_name.as_deref(),
|
||||
) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
@@ -783,6 +824,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
quicklink,
|
||||
&query_lower,
|
||||
opt_data_source.as_deref(),
|
||||
opt_main_extension_lowercase_name.as_deref(),
|
||||
) {
|
||||
hits.push(hit);
|
||||
}
|
||||
@@ -791,16 +833,19 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
|
||||
if let Some(ref views) = extension.views {
|
||||
for view in views.iter().filter(|link| link.enabled) {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(view, &query_lower, opt_data_source.as_deref())
|
||||
{
|
||||
if let Some(hit) = extension_to_hit(
|
||||
view,
|
||||
&query_lower,
|
||||
opt_data_source.as_deref(),
|
||||
opt_main_extension_lowercase_name.as_deref(),
|
||||
) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(hit) =
|
||||
extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
|
||||
extension_to_hit(extension, &query_lower, opt_data_source.as_deref(), None)
|
||||
{
|
||||
hits.push(hit);
|
||||
}
|
||||
@@ -839,10 +884,18 @@ pub(crate) async fn uninstall_extension(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Argument `opt_main_extension_lowercase_name`: If `extension` is a sub-extension
|
||||
/// of an `extension` type extension, then this argument contains the lowercase
|
||||
/// name of that extension. Otherwise, None.
|
||||
///
|
||||
/// This argument is needed as an "extension" type extension should return all its
|
||||
/// sub-extensions when the query string matches its name. To do this, we pass the
|
||||
/// extension name, score it and take that into account.
|
||||
pub(crate) fn extension_to_hit(
|
||||
extension: &Extension,
|
||||
query_lower: &str,
|
||||
opt_data_source: Option<&str>,
|
||||
opt_main_extension_lowercase_name: Option<&str>,
|
||||
) -> Option<(Document, f64)> {
|
||||
if !extension.searchable() {
|
||||
return None;
|
||||
@@ -865,14 +918,26 @@ pub(crate) fn extension_to_hit(
|
||||
if let Some(title_score) =
|
||||
calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
|
||||
{
|
||||
total_score += title_score * 1.0; // Weight for title
|
||||
total_score += title_score;
|
||||
}
|
||||
|
||||
// Score based on alias match if available
|
||||
// Alias is considered less important than title, so it gets a lower weight.
|
||||
if let Some(alias) = &extension.alias {
|
||||
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
|
||||
total_score += alias_score * 0.7; // Weight for alias
|
||||
total_score += alias_score;
|
||||
}
|
||||
}
|
||||
|
||||
// An "extension" type extension should return all its
|
||||
// sub-extensions when the query string matches its ID.
|
||||
// To do this, we score the extension ID and take that
|
||||
// into account.
|
||||
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
|
||||
if let Some(main_extension_score) =
|
||||
calculate_text_similarity(&query_lower, main_extension_lowercase_id)
|
||||
{
|
||||
total_score += main_extension_score;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,17 +7,18 @@ mod server;
|
||||
mod settings;
|
||||
mod setup;
|
||||
mod shortcut;
|
||||
mod util;
|
||||
// We need this in main.rs, so it has to be pub
|
||||
pub mod util;
|
||||
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use crate::util::logging::set_up_tauri_logger;
|
||||
use crate::util::prevent_default;
|
||||
use autostart::change_autostart;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
|
||||
@@ -179,6 +180,7 @@ pub fn run() {
|
||||
setup::backend_setup,
|
||||
util::app_lang::update_app_lang,
|
||||
util::path::path_absolute,
|
||||
util::logging::app_log_dir
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -265,35 +267,57 @@ async fn show_coco(app_handle: AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
use tauri_nspanel::ManagerExt;
|
||||
|
||||
// The Window Management (WM) extension (macOS-only) controls the
|
||||
// frontmost window. Setting focus on macOS makes Coco the frontmost
|
||||
// window, which means the WM extension would control Coco instead of other
|
||||
// windows, which is not what we want.
|
||||
//
|
||||
// On Linux/Windows, however, setting focus is a necessity to ensure that
|
||||
// users open Coco's window, then they can start typing, without needing
|
||||
// to click on the window.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = window.set_focus();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
app_handle.run_on_main_thread(move || {
|
||||
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).unwrap();
|
||||
|
||||
panel.show_and_make_key();
|
||||
}).unwrap();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
// The Window Management (WM) extension (macOS-only) controls the
|
||||
// frontmost window. Setting focus on macOS makes Coco the frontmost
|
||||
// window, which means the WM extension would control Coco instead of other
|
||||
// windows, which is not what we want.
|
||||
//
|
||||
// On Linux/Windows, however, setting focus is a necessity to ensure that
|
||||
// users open Coco's window, then they can start typing, without needing
|
||||
// to click on the window.
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
};
|
||||
|
||||
let _ = app_handle.emit("show-coco", ());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn hide_coco(app: AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if let Err(err) = window.hide() {
|
||||
log::error!("Failed to hide the window: {}", err);
|
||||
async fn hide_coco(app_handle: AppHandle) {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
use tauri_nspanel::ManagerExt;
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
app_handle.run_on_main_thread(move || {
|
||||
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).expect("cannot find the main window/panel");
|
||||
panel.hide();
|
||||
}).unwrap();
|
||||
} else {
|
||||
log::debug!("Window successfully hidden.");
|
||||
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).expect("cannot find the main window");
|
||||
|
||||
if let Err(err) = window.hide() {
|
||||
log::error!("Failed to hide the window: {}", err);
|
||||
} else {
|
||||
log::debug!("Window successfully hidden.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Main window not found.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
||||
@@ -430,135 +454,3 @@ async fn hide_check(app_handle: AppHandle) {
|
||||
|
||||
window.hide().unwrap();
|
||||
}
|
||||
|
||||
/// Log format:
|
||||
///
|
||||
/// ```text
|
||||
/// [time] [log level] [file module:line] message
|
||||
/// ```
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
///
|
||||
/// ```text
|
||||
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
|
||||
/// ```
|
||||
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
||||
use log::Level;
|
||||
use log::LevelFilter;
|
||||
use tauri_plugin_log::Builder;
|
||||
|
||||
/// Coco-AI app's default log level.
|
||||
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
|
||||
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
|
||||
|
||||
fn format_log_level(level: Level) -> &'static str {
|
||||
match level {
|
||||
Level::Trace => "TRC",
|
||||
Level::Debug => "DBG",
|
||||
Level::Info => "INF",
|
||||
Level::Warn => "WAR",
|
||||
Level::Error => "ERR",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_target_and_line(record: &log::Record) -> String {
|
||||
let mut str = record.target().to_string();
|
||||
if let Some(line) = record.line() {
|
||||
str.push(':');
|
||||
str.push_str(&line.to_string());
|
||||
}
|
||||
|
||||
str
|
||||
}
|
||||
|
||||
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
|
||||
///
|
||||
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
|
||||
///
|
||||
/// * If this environment variable is not set, use the default log level.
|
||||
/// * If it is set, respect it:
|
||||
///
|
||||
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
|
||||
/// equivalent to `COCO_LOG=coco_lib=trace`
|
||||
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
|
||||
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
|
||||
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
|
||||
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
|
||||
/// * `COCO_LOG=off` turns off all logging for the application
|
||||
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
|
||||
fn dynamic_log_level(mut builder: Builder) -> Builder {
|
||||
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
|
||||
return builder.level(DEFAULT_LOG_LEVEL);
|
||||
};
|
||||
|
||||
builder = builder.level(LevelFilter::Off);
|
||||
|
||||
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
|
||||
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
|
||||
e.to_string_lossy(),
|
||||
LOG_LEVEL_ENV_VAR
|
||||
)
|
||||
});
|
||||
|
||||
// COCO_LOG=[target][=][level][,...]
|
||||
let target_log_levels = log_levels.split(',');
|
||||
for target_log_level in target_log_levels {
|
||||
#[allow(clippy::collapsible_else_if)]
|
||||
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
|
||||
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
|
||||
// Remove the equal sign, we know it takes 1 byte
|
||||
let level = &equal_sign_and_level[1..];
|
||||
|
||||
if let Ok(level) = level.parse::<LevelFilter>() {
|
||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||
builder = builder.level_for(target.to_string(), level);
|
||||
} else {
|
||||
panic!(
|
||||
"log level '{}' set in '{}={}' is invalid",
|
||||
level, target, level
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
|
||||
// This is a level
|
||||
builder = builder.level(level);
|
||||
} else {
|
||||
// This is a target, enable all the logging
|
||||
//
|
||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
|
||||
// that come from Coco in the log file, which helps with debugging.
|
||||
if !tauri::is_dev() {
|
||||
// We have absolutely no guarantee that we (We have control over the Rust
|
||||
// code, but definitely no idea about the libc C code, all the shared objects
|
||||
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
|
||||
unsafe {
|
||||
std::env::set_var("COCO_LOG", "coco_lib=trace");
|
||||
}
|
||||
}
|
||||
|
||||
let mut builder = tauri_plugin_log::Builder::new();
|
||||
builder = builder.format(|out, message, record| {
|
||||
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
||||
let level = format_log_level(record.level());
|
||||
let target_and_line = format_target_and_line(record);
|
||||
out.finish(format_args!(
|
||||
"[{}] [{}] [{}] {}",
|
||||
now, level, target_and_line, message
|
||||
));
|
||||
});
|
||||
builder = dynamic_log_level(builder);
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
@@ -1,42 +1,9 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use coco_lib::util::logging::app_log_dir;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Helper function to return the log directory.
|
||||
///
|
||||
/// This should return the same value as `tauri_app_handle.path().app_log_dir().unwrap()`.
|
||||
fn app_log_dir() -> PathBuf {
|
||||
// This function `app_log_dir()` is for the panic hook, which should be set
|
||||
// before Tauri performs any initialization. At that point, we do not have
|
||||
// access to the identifier provided by Tauri, so we need to define our own
|
||||
// one here.
|
||||
//
|
||||
// NOTE: If you update identifier in the following files, update this one
|
||||
// as well!
|
||||
//
|
||||
// src-tauri/tauri.linux.conf.json
|
||||
// src-tauri/Entitlements.plist
|
||||
// src-tauri/tauri.conf.json
|
||||
// src-tauri/Info.plist
|
||||
const IDENTIFIER: &str = "rs.coco.app";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let path = dirs::home_dir()
|
||||
.expect("cannot find the home directory, Coco should never run in such a environment")
|
||||
.join("Library/Logs")
|
||||
.join(IDENTIFIER);
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let path = dirs::data_local_dir()
|
||||
.expect("app local dir is None, we should not encounter this")
|
||||
.join(IDENTIFIER)
|
||||
.join("logs");
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
/// Set up panic hook to log panic information to a file
|
||||
fn setup_panic_hook() {
|
||||
|
||||
@@ -10,13 +10,10 @@ use function_name::named;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use reqwest::StatusCode;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
#[named]
|
||||
#[tauri::command]
|
||||
pub async fn query_coco_fusion(
|
||||
@@ -187,7 +184,6 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
let query_source_list_len = query_source_trait_object_list.len();
|
||||
for query_source_trait_object in query_source_trait_object_list {
|
||||
let query_source = query_source_trait_object.get_type().clone();
|
||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||
@@ -208,14 +204,8 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
}
|
||||
|
||||
let mut total_hits = 0;
|
||||
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
|
||||
let mut failed_requests = Vec::new();
|
||||
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
|
||||
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
|
||||
|
||||
if query_source_list_len > 1 {
|
||||
need_rerank = true; // If we have more than one source, we need to rerank the hits
|
||||
}
|
||||
let mut all_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
|
||||
|
||||
while let Some((query_source, timeout_result)) = futures.next().await {
|
||||
match timeout_result {
|
||||
@@ -246,12 +236,10 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
document,
|
||||
};
|
||||
|
||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
||||
|
||||
hits_per_source
|
||||
all_hits_grouped_by_source_id
|
||||
.entry(source_id.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push((query_hit, score));
|
||||
.push(query_hit);
|
||||
}
|
||||
}
|
||||
Err(search_error) => {
|
||||
@@ -267,109 +255,117 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
}
|
||||
}
|
||||
|
||||
// Sort hits within each source by score (descending)
|
||||
for hits in hits_per_source.values_mut() {
|
||||
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater));
|
||||
let n_sources = all_hits_grouped_by_source_id.len();
|
||||
|
||||
if n_sources == 0 {
|
||||
return Ok(MultiSourceQueryResponse {
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let total_sources = hits_per_source.len();
|
||||
let max_hits_per_source = if total_sources > 0 {
|
||||
size as usize / total_sources
|
||||
} else {
|
||||
size as usize
|
||||
};
|
||||
/*
|
||||
* Sort hits within each source by score (descending) in case data sources
|
||||
* do not sort them
|
||||
*/
|
||||
for hits in all_hits_grouped_by_source_id.values_mut() {
|
||||
hits.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Greater)
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Collect hits evenly across sources, to ensure:
|
||||
*
|
||||
* 1. All sources have hits returned
|
||||
* 2. Query sources with many hits won't dominate
|
||||
*/
|
||||
let mut final_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
|
||||
let mut pruned: HashMap<&str, &[QueryHits]> = HashMap::new();
|
||||
|
||||
// max_hits_per_source could be 0, then `final_hits_grouped_by_source_id`
|
||||
// would be empty. But we don't need to worry about this case as we will
|
||||
// populate hits later.
|
||||
let max_hits_per_source = size as usize / n_sources;
|
||||
for (source_id, hits) in all_hits_grouped_by_source_id.iter() {
|
||||
let hits_taken = if hits.len() > max_hits_per_source {
|
||||
pruned.insert(&source_id, &hits[max_hits_per_source..]);
|
||||
hits[0..max_hits_per_source].to_vec()
|
||||
} else {
|
||||
hits.clone()
|
||||
};
|
||||
|
||||
final_hits_grouped_by_source_id.insert(source_id.clone(), hits_taken);
|
||||
}
|
||||
|
||||
let final_hits_len = final_hits_grouped_by_source_id
|
||||
.iter()
|
||||
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
|
||||
let pruned_len = pruned
|
||||
.iter()
|
||||
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
|
||||
|
||||
/*
|
||||
* If we still need more hits, take the highest-scoring from `pruned`
|
||||
*
|
||||
* `pruned` contains sorted arrays, we scan it in a way similar to
|
||||
* how n-way-merge-sort extracts the element with the greatest value.
|
||||
*/
|
||||
if final_hits_len < size as usize {
|
||||
let n_need = size as usize - final_hits_len;
|
||||
let n_have = pruned_len;
|
||||
let n_take = n_have.min(n_need);
|
||||
|
||||
for _ in 0..n_take {
|
||||
let mut highest_score_hit: Option<(&str, &QueryHits)> = None;
|
||||
for (source_id, sorted_hits) in pruned.iter_mut() {
|
||||
if sorted_hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let hit = &sorted_hits[0];
|
||||
|
||||
let have_higher_score_hit = match highest_score_hit {
|
||||
Some((_, current_highest_score_hit)) => {
|
||||
hit.score > current_highest_score_hit.score
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
if have_higher_score_hit {
|
||||
highest_score_hit = Some((*source_id, hit));
|
||||
|
||||
// Advance sorted_hits by 1 element, if have
|
||||
if sorted_hits.len() == 1 {
|
||||
*sorted_hits = &[];
|
||||
} else {
|
||||
*sorted_hits = &sorted_hits[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (source_id, hit) = highest_score_hit.expect("`pruned` should contain at least `n_take` elements so `highest_score_hit` should be set");
|
||||
|
||||
final_hits_grouped_by_source_id
|
||||
.get_mut(source_id)
|
||||
.expect("all the source_ids stored in `pruned` come from `final_hits_grouped_by_source_id`, so it should exist")
|
||||
.push(hit.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Re-rank the final hits
|
||||
*/
|
||||
if n_sources > 1 {
|
||||
boosted_levenshtein_rerank(&query_keyword, &mut final_hits_grouped_by_source_id);
|
||||
}
|
||||
|
||||
let mut final_hits = Vec::new();
|
||||
let mut seen_docs = HashSet::new(); // To track documents we've already added
|
||||
|
||||
// Distribute hits fairly across sources
|
||||
for (_source_id, hits) in &mut hits_per_source {
|
||||
let take_count = hits.len().min(max_hits_per_source);
|
||||
for (doc, score) in hits.drain(0..take_count) {
|
||||
if !seen_docs.contains(&doc.document.id) {
|
||||
seen_docs.insert(doc.document.id.clone());
|
||||
log::debug!(
|
||||
"collect doc: {}, {:?}, {}",
|
||||
doc.document.id,
|
||||
doc.document.title,
|
||||
score
|
||||
);
|
||||
final_hits.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("final hits: {:?}", final_hits.len());
|
||||
|
||||
let mut unique_sources = HashSet::new();
|
||||
for hit in &final_hits {
|
||||
if let Some(source) = &hit.source {
|
||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||
unique_sources.insert(&source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Multiple sources found: {:?}, no rerank needed",
|
||||
unique_sources
|
||||
);
|
||||
|
||||
if unique_sources.len() < 1 {
|
||||
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
|
||||
}
|
||||
|
||||
if need_rerank && final_hits.len() > 1 {
|
||||
// Precollect (index, title)
|
||||
let titles_to_score: Vec<(usize, &str)> = final_hits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, hit)| {
|
||||
let source = hit.source.as_ref()?;
|
||||
let title = hit.document.title.as_deref()?;
|
||||
|
||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||
Some((idx, title))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Score them
|
||||
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
|
||||
|
||||
// Sort descending by score
|
||||
let mut scored_hits = scored_hits;
|
||||
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
|
||||
|
||||
// Apply new scores to final_hits
|
||||
for (idx, score) in scored_hits.into_iter().take(size as usize) {
|
||||
final_hits[idx].score = score;
|
||||
}
|
||||
} else if final_hits.len() < size as usize {
|
||||
// If we still need more hits, take the highest-scoring remaining ones
|
||||
|
||||
let remaining_needed = size as usize - final_hits.len();
|
||||
|
||||
// Sort all hits by score descending, removing duplicates by document ID
|
||||
all_hits.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let extra_hits = all_hits
|
||||
.into_iter()
|
||||
.filter(|(source_id, _, _)| hits_per_source.contains_key(source_id)) // Only take from known sources
|
||||
.filter_map(|(_, doc, _)| {
|
||||
if !seen_docs.contains(&doc.document.id) {
|
||||
seen_docs.insert(doc.document.id.clone());
|
||||
Some(doc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.take(remaining_needed)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
final_hits.extend(extra_hits);
|
||||
for (_source_id, hits) in final_hits_grouped_by_source_id {
|
||||
final_hits.extend(hits);
|
||||
}
|
||||
|
||||
// **Sort final hits by score descending**
|
||||
@@ -379,6 +375,11 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Truncate `final_hits` in case it contains more than `size` hits
|
||||
//
|
||||
// Technically, we are safe to not do this. But since it is trivial, double-check it.
|
||||
final_hits.truncate(size as usize);
|
||||
|
||||
if final_hits.len() < 5 {
|
||||
//TODO: Add a recommendation system to suggest more sources
|
||||
log::info!(
|
||||
@@ -395,30 +396,85 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
})
|
||||
}
|
||||
|
||||
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
|
||||
use strsim::levenshtein;
|
||||
use std::collections::HashSet;
|
||||
use strsim::levenshtein;
|
||||
|
||||
fn boosted_levenshtein_rerank(
|
||||
query: &str,
|
||||
all_hits_grouped_by_source_id: &mut HashMap<String, Vec<QueryHits>>,
|
||||
) {
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
titles
|
||||
.into_iter()
|
||||
.map(|(idx, title)| {
|
||||
let mut score = 0.0;
|
||||
for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() {
|
||||
// Skip special sources like calculator
|
||||
if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||
continue;
|
||||
}
|
||||
|
||||
if title.contains(query) {
|
||||
score += 0.4;
|
||||
} else if title.to_lowercase().contains(&query_lower) {
|
||||
score += 0.2;
|
||||
}
|
||||
for hit in hits.iter_mut() {
|
||||
let document_title = hit.document.title.as_deref().unwrap_or("");
|
||||
let document_title_lowercase = document_title.to_lowercase();
|
||||
|
||||
let dist = levenshtein(&query_lower, &title.to_lowercase());
|
||||
let max_len = query_lower.len().max(title.len());
|
||||
if max_len > 0 {
|
||||
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
|
||||
}
|
||||
let new_score = {
|
||||
let mut score = 0.0;
|
||||
|
||||
(idx, score.min(1.0) as f64)
|
||||
})
|
||||
// --- Exact or substring boost ---
|
||||
if document_title.contains(query) {
|
||||
score += 0.4;
|
||||
} else if document_title_lowercase.contains(&query_lower) {
|
||||
score += 0.2;
|
||||
}
|
||||
|
||||
// --- Levenshtein distance (character similarity) ---
|
||||
let dist = levenshtein(&query_lower, &document_title_lowercase);
|
||||
let max_len = query_lower.len().max(document_title.len());
|
||||
let levenshtein_score = if max_len > 0 {
|
||||
(1.0 - (dist as f64 / max_len as f64)) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// --- Jaccard similarity (token overlap) ---
|
||||
let jaccard_score = jaccard_similarity(&query_lower, &document_title_lowercase);
|
||||
|
||||
// --- Combine scores (weights adjustable) ---
|
||||
// Levenshtein emphasizes surface similarity
|
||||
// Jaccard emphasizes term overlap (semantic hint)
|
||||
let hybrid_score = 0.7 * levenshtein_score + 0.3 * jaccard_score;
|
||||
|
||||
// --- Apply hybrid score ---
|
||||
score += hybrid_score;
|
||||
|
||||
// --- Limit score range ---
|
||||
score.min(1.0) as f64
|
||||
};
|
||||
|
||||
hit.score = new_score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute token-based Jaccard similarity
|
||||
fn jaccard_similarity(a: &str, b: &str) -> f32 {
|
||||
let a_tokens: HashSet<_> = tokenize(a).into_iter().collect();
|
||||
let b_tokens: HashSet<_> = tokenize(b).into_iter().collect();
|
||||
|
||||
if a_tokens.is_empty() || b_tokens.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let intersection = a_tokens.intersection(&b_tokens).count() as f32;
|
||||
let union = a_tokens.union(&b_tokens).count() as f32;
|
||||
|
||||
intersection / union
|
||||
}
|
||||
|
||||
/// Basic tokenizer (case-insensitive, alphanumeric words only)
|
||||
fn tokenize(text: &str) -> Vec<String> {
|
||||
text.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
use objc2_app_kit::NSNonactivatingPanelMask;
|
||||
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
||||
use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow};
|
||||
use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel};
|
||||
|
||||
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
||||
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
||||
const WINDOW_MOVED_EVENT: &str = "tauri://move";
|
||||
const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
|
||||
|
||||
tauri_panel! {
|
||||
panel!(NsPanel {
|
||||
config: {
|
||||
is_floating_panel: true,
|
||||
can_become_key_window: true,
|
||||
can_become_main_window: false
|
||||
}
|
||||
})
|
||||
|
||||
panel_event!(NsPanelEventHandler {
|
||||
window_did_become_key(notification: &NSNotification) -> (),
|
||||
window_did_resign_key(notification: &NSNotification) -> (),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn platform(
|
||||
_tauri_app_handle: &AppHandle,
|
||||
@@ -17,68 +29,39 @@ pub fn platform(
|
||||
_check_window: WebviewWindow,
|
||||
) {
|
||||
// Convert ns_window to ns_panel
|
||||
let panel = main_window.to_panel().unwrap();
|
||||
let panel = main_window.to_panel::<NsPanel>().unwrap();
|
||||
|
||||
// set level
|
||||
panel.set_level(PanelLevel::Utility.value());
|
||||
|
||||
// Do not steal focus from other windows
|
||||
//
|
||||
// Cast is safe
|
||||
panel.set_style_mask(NSNonactivatingPanelMask.0 as i32);
|
||||
// Set its level to NSFloatingWindowLevel to ensure it appears in front of
|
||||
// all normal-level windows
|
||||
//
|
||||
// NOTE: some Chinese input methods use a level between NSDockWindowLevel (20)
|
||||
// and NSMainMenuWindowLevel (24), setting our level above NSDockWindowLevel
|
||||
// would block their window
|
||||
panel.set_floating_panel(true);
|
||||
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());
|
||||
|
||||
// Open the window in the active workspace and full screen
|
||||
panel.set_collection_behaviour(
|
||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
|
||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
|
||||
panel.set_collection_behavior(
|
||||
CollectionBehavior::new()
|
||||
.stationary()
|
||||
.move_to_active_space()
|
||||
.full_screen_auxiliary()
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Define the panel's delegate to listen to panel window events
|
||||
let delegate = panel_delegate!(EcoPanelDelegate {
|
||||
window_did_become_key,
|
||||
window_did_resign_key,
|
||||
window_did_resize,
|
||||
window_did_move
|
||||
});
|
||||
let handler = NsPanelEventHandler::new();
|
||||
|
||||
// Set event listeners for the delegate
|
||||
delegate.set_listener(Box::new(move |delegate_name: String| {
|
||||
let window = main_window.clone();
|
||||
handler.window_did_become_key(move |_| {
|
||||
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||
|
||||
let window_move_event = || {
|
||||
if let Ok(position) = main_window.outer_position() {
|
||||
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
|
||||
}
|
||||
};
|
||||
let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
|
||||
});
|
||||
|
||||
match delegate_name.as_str() {
|
||||
// Called when the window gets keyboard focus
|
||||
"window_did_become_key" => {
|
||||
let _ = main_window.emit_to(target, WINDOW_FOCUS_EVENT, true);
|
||||
}
|
||||
// Called when the window loses keyboard focus
|
||||
"window_did_resign_key" => {
|
||||
let _ = main_window.emit_to(target, WINDOW_BLUR_EVENT, true);
|
||||
}
|
||||
// Called when the window size changes
|
||||
"window_did_resize" => {
|
||||
window_move_event();
|
||||
let window = main_window.clone();
|
||||
handler.window_did_resign_key(move |_| {
|
||||
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||
|
||||
if let Ok(size) = main_window.inner_size() {
|
||||
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size);
|
||||
}
|
||||
}
|
||||
// Called when the window position changes
|
||||
"window_did_move" => window_move_event(),
|
||||
_ => (),
|
||||
}
|
||||
}));
|
||||
let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true);
|
||||
});
|
||||
|
||||
// Set the delegate object for the window to handle window events
|
||||
panel.set_delegate(delegate);
|
||||
panel.set_event_handler(Some(handler.as_ref()));
|
||||
}
|
||||
|
||||
189
src-tauri/src/util/logging.rs
Normal file
189
src-tauri/src/util/logging.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use std::path::PathBuf;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
|
||||
/// Return the log directory.
|
||||
///
|
||||
/// We use a custom log directory, which is similar to the one used by
|
||||
/// Tauri, except that the "{bundleIdentifier}" will be "Coco AI" rather
|
||||
/// than the real identifier.
|
||||
///
|
||||
/// We do this because our bundle ID ("rs.coco.app") ends with ".app", log directory
|
||||
/// "/Users/xxx/Library/Logs/rs.coco.app" is mistakenly thought as an application
|
||||
/// by Finder on macOS, making it inconvenient to open. We do not want to change the
|
||||
/// bundle identifier. The data directory, which stores all the data, still
|
||||
/// references it. So doing that will be a breaking change. Using a custom log
|
||||
/// directory make more sense.
|
||||
///
|
||||
/// ### Platform-specific
|
||||
///
|
||||
/// |Platform | Value | Example |
|
||||
/// | --------- | -------------------------------------------------------------------| --------------------------------------------|
|
||||
/// | Linux | `$XDG_DATA_HOME/Coco AI/logs` or `$HOME/.local/share/Coco AI/logs` | `/home/alice/.local/share/Coco AI/logs` |
|
||||
/// | macOS/iOS | `{homeDir}/Library/Logs/Coco AI` | `/Users/Alice/Library/Logs/Coco AI` |
|
||||
/// | Windows | `{FOLDERID_LocalAppData}/Coco AI/logs` | `C:\Users\Alice\AppData\Local\Coco AI\logs` |
|
||||
/// | Android | `{ConfigDir}/logs` | `/data/data/com.tauri.dev/files/logs` |
|
||||
#[tauri::command]
|
||||
pub fn app_log_dir() -> PathBuf {
|
||||
const IDENTIFIER: &str = "Coco AI";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let path = dirs::home_dir()
|
||||
.expect("cannot find the home directory, Coco should never run in such a environment")
|
||||
.join("Library/Logs")
|
||||
.join(IDENTIFIER);
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let path = dirs::data_local_dir()
|
||||
.expect("app local dir is None, we should not encounter this")
|
||||
.join(IDENTIFIER)
|
||||
.join("logs");
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
/// Log format:
|
||||
///
|
||||
/// ```text
|
||||
/// [time] [log level] [file module:line] message
|
||||
/// ```
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
///
|
||||
/// ```text
|
||||
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
|
||||
/// ```
|
||||
pub(crate) fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
||||
use log::Level;
|
||||
use log::LevelFilter;
|
||||
use tauri_plugin_log::Builder;
|
||||
use tauri_plugin_log::Target;
|
||||
use tauri_plugin_log::TargetKind;
|
||||
|
||||
/// Coco-AI app's default log level.
|
||||
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
|
||||
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
|
||||
|
||||
fn format_log_level(level: Level) -> &'static str {
|
||||
match level {
|
||||
Level::Trace => "TRC",
|
||||
Level::Debug => "DBG",
|
||||
Level::Info => "INF",
|
||||
Level::Warn => "WAR",
|
||||
Level::Error => "ERR",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_target_and_line(record: &log::Record) -> String {
|
||||
let mut str = record.target().to_string();
|
||||
if let Some(line) = record.line() {
|
||||
str.push(':');
|
||||
str.push_str(&line.to_string());
|
||||
}
|
||||
|
||||
str
|
||||
}
|
||||
|
||||
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
|
||||
///
|
||||
/// Generally, it mirrors the behavior of `env_logger`. Syntax: `COCO_LOG=[module][=][level][,...]`
|
||||
///
|
||||
/// * If this environment variable is not set, use the default log level.
|
||||
/// * If it is set, respect it:
|
||||
///
|
||||
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
|
||||
/// equivalent to `COCO_LOG=coco_lib=trace`
|
||||
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
|
||||
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
|
||||
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
|
||||
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
|
||||
/// * `COCO_LOG=off` turns off all logging for the application
|
||||
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
|
||||
fn dynamic_log_level(mut builder: Builder) -> Builder {
|
||||
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
|
||||
return builder.level(DEFAULT_LOG_LEVEL);
|
||||
};
|
||||
|
||||
builder = builder.level(LevelFilter::Off);
|
||||
|
||||
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"The value '{}' set in environment variable '{}' is not UTF-8 encoded",
|
||||
// Cannot use `.display()` here because that requires MSRV 1.87.0
|
||||
e.to_string_lossy(),
|
||||
LOG_LEVEL_ENV_VAR
|
||||
)
|
||||
});
|
||||
|
||||
// COCO_LOG=[module][=][level][,...]
|
||||
let module_log_levels = log_levels.split(',');
|
||||
for module_log_level in module_log_levels {
|
||||
#[allow(clippy::collapsible_else_if)]
|
||||
if let Some(char_index) = module_log_level.chars().position(|c| c == '=') {
|
||||
let (module, equal_sign_and_level) = module_log_level.split_at(char_index);
|
||||
// Remove the equal sign, we know it takes 1 byte
|
||||
let level = &equal_sign_and_level[1..];
|
||||
|
||||
if let Ok(level) = level.parse::<LevelFilter>() {
|
||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||
builder = builder.level_for(module.to_string(), level);
|
||||
} else {
|
||||
panic!(
|
||||
"log level '{}' set in '{}={}' is invalid",
|
||||
level, module, level
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if let Ok(level) = module_log_level.parse::<LevelFilter>() {
|
||||
// This is a level
|
||||
builder = builder.level(level);
|
||||
} else {
|
||||
// This is a module, enable all the logging
|
||||
let module = module_log_level;
|
||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||
builder = builder.level_for(module.to_string(), LevelFilter::Trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
|
||||
// that come from Coco in the log file, which helps with debugging.
|
||||
if !tauri::is_dev() {
|
||||
// We have absolutely no guarantee that we (We have control over the Rust
|
||||
// code, but definitely no idea about the libc C code, all the shared objects
|
||||
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
|
||||
unsafe {
|
||||
std::env::set_var("COCO_LOG", "coco_lib=trace");
|
||||
}
|
||||
}
|
||||
|
||||
let mut builder = tauri_plugin_log::Builder::new();
|
||||
builder = builder.format(|out, message, record| {
|
||||
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
||||
let level = format_log_level(record.level());
|
||||
let target_and_line = format_target_and_line(record);
|
||||
out.finish(format_args!(
|
||||
"[{}] [{}] [{}] {}",
|
||||
now, level, target_and_line, message
|
||||
));
|
||||
});
|
||||
builder = dynamic_log_level(builder);
|
||||
|
||||
/*
|
||||
* Use our custom log directory
|
||||
*/
|
||||
// We have no public APIs to update targets in-place, so we need to remove
|
||||
// them all, then bring back the correct ones.
|
||||
builder = builder.clear_targets();
|
||||
builder = builder.target(Target::new(TargetKind::Stdout));
|
||||
builder = builder.target(Target::new(TargetKind::Folder {
|
||||
path: app_log_dir(),
|
||||
// Use the default value, which is "Coco-AI.log"
|
||||
file_name: None,
|
||||
}));
|
||||
|
||||
builder.build()
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub(crate) mod app_lang;
|
||||
pub(crate) mod file;
|
||||
// We need this in main.rs, so it has to be pub
|
||||
pub mod logging;
|
||||
pub(crate) mod path;
|
||||
pub(crate) mod platform;
|
||||
pub(crate) mod prevent_default;
|
||||
|
||||
8
src-tauri/tauri.macos.conf.json
Normal file
8
src-tauri/tauri.macos.conf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"identifier": "rs.coco.app",
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"entitlements": "./Entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
import {
|
||||
@@ -78,7 +80,7 @@ export const Get = <T>(
|
||||
}
|
||||
|
||||
axios
|
||||
.get(baseURL + url, { params })
|
||||
.get(baseURL + url, { params, withCredentials: true })
|
||||
.then((result) => {
|
||||
let res: FcResponse<T>;
|
||||
if (clearFn !== undefined) {
|
||||
@@ -110,10 +112,15 @@ export const Post = <T>(
|
||||
}
|
||||
|
||||
axios
|
||||
.post(baseURL + url, data, {
|
||||
params,
|
||||
headers,
|
||||
} as any)
|
||||
.post(
|
||||
baseURL + url,
|
||||
data,
|
||||
{
|
||||
params,
|
||||
headers,
|
||||
withCredentials: true,
|
||||
} as any
|
||||
)
|
||||
.then((result) => {
|
||||
resolve([null, result.data as FcResponse<T>]);
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function streamPost({
|
||||
...(headersStorage),
|
||||
...(headers || {}),
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
|
||||
@@ -30,19 +30,23 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
return state.setCurrentAssistant;
|
||||
});
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
|
||||
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
||||
|
||||
const [assistants, setAssistants] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
||||
|
||||
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
||||
const setAskAiAssistantId = useSearchStore((state) => {
|
||||
return state.setAskAiAssistantId;
|
||||
});
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
|
||||
const { fetchAssistant } = AssistantFetcher({
|
||||
debounceKeyword,
|
||||
|
||||
@@ -41,6 +41,7 @@ interface ChatAIProps {
|
||||
startPage?: StartPage;
|
||||
formatUrl?: (data: any) => string;
|
||||
instanceId?: string;
|
||||
getChatHistoryChatPage?: () => void;
|
||||
}
|
||||
|
||||
export interface SendMessageParams {
|
||||
@@ -52,6 +53,7 @@ export interface ChatAIRef {
|
||||
init: (params: SendMessageParams) => void;
|
||||
cancelChat: () => void;
|
||||
clearChat: () => void;
|
||||
onSelectChat: (chat: Chat) => void;
|
||||
}
|
||||
|
||||
const ChatAI = memo(
|
||||
@@ -73,6 +75,7 @@ const ChatAI = memo(
|
||||
startPage,
|
||||
formatUrl,
|
||||
instanceId,
|
||||
getChatHistoryChatPage,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -80,6 +83,7 @@ const ChatAI = memo(
|
||||
init: init,
|
||||
cancelChat: () => cancelChat(activeChat),
|
||||
clearChat: clearChat,
|
||||
onSelectChat: onSelectChat,
|
||||
}));
|
||||
|
||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||
@@ -193,7 +197,8 @@ const ChatAI = memo(
|
||||
isDeepThinkActive,
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
showChatHistory
|
||||
showChatHistory,
|
||||
getChatHistoryChatPage,
|
||||
);
|
||||
|
||||
const { dealMsg } = useMessageHandler(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ChatMessage } from "@/components/ChatMessage";
|
||||
import { Greetings } from "./Greetings";
|
||||
import AttachmentList from "@/components/Assistant/AttachmentList";
|
||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||
|
||||
import type { Chat, IChunkData } from "@/types/chat";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
// import SessionFile from "./SessionFile";
|
||||
@@ -45,20 +44,23 @@ export const ChatContent = ({
|
||||
handleSendMessage,
|
||||
formatUrl,
|
||||
}: ChatContentProps) => {
|
||||
const { currentSessionId, setCurrentSessionId } = useConnectStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { uploadAttachments } = useChatStore();
|
||||
const currentSessionId = useConnectStore((state) => state.currentSessionId);
|
||||
const setCurrentSessionId = useConnectStore(
|
||||
(state) => state.setCurrentSessionId
|
||||
);
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
|
||||
const uploadAttachments = useChatStore((state) => state.uploadAttachments);
|
||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
|
||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAtBottom(true);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import HistoryIcon from "@/icons/History";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import type { Chat } from "@/types/chat";
|
||||
@@ -12,6 +9,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
import { AssistantList } from "./AssistantList";
|
||||
import { ServerList } from "./ServerList";
|
||||
import TogglePin from "../Common/TogglePin";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
clearChat: () => void;
|
||||
@@ -34,21 +32,9 @@ export function ChatHeader({
|
||||
showChatHistory = true,
|
||||
assistantIDs,
|
||||
}: ChatHeaderProps) {
|
||||
const { isPinned, setIsPinned, isTauri } = useAppStore();
|
||||
const { isTauri } = useAppStore();
|
||||
|
||||
const { historicalRecords, newSession, fixedWindow, external } =
|
||||
useShortcutsStore();
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const { isPinned } = useAppStore.getState();
|
||||
|
||||
setIsPinned(!isPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
setIsPinned(isPinned);
|
||||
}
|
||||
};
|
||||
const { historicalRecords, newSession, external } = useShortcutsStore();
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -101,16 +87,7 @@ export function ChatHeader({
|
||||
|
||||
{isTauri ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={clsx("inline-flex", {
|
||||
"text-blue-500": isPinned,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
<TogglePin className="inline-flex" />
|
||||
|
||||
<ServerList clearChat={clearChat} />
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ import StatusIndicator from "@/components/Cloud/StatusIndicator";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
|
||||
import {
|
||||
getCurrentWindowService,
|
||||
setCurrentWindowService,
|
||||
} from "@/commands/windowService";
|
||||
|
||||
interface ServerListProps {
|
||||
clearChat: () => void;
|
||||
@@ -33,10 +36,9 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => {
|
||||
return state.cloudSelectService;
|
||||
});
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
|
||||
const { setMessages } = useChatStore();
|
||||
|
||||
@@ -55,7 +57,6 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { refreshServerList } = useServers();
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
@@ -95,8 +96,10 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
} else {
|
||||
setCurrentWindowService({});
|
||||
}
|
||||
}, [currentService?.id, cloudSelectService?.id, serverList]);
|
||||
}, [serverList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiServerId || serverList.length === 0) return;
|
||||
@@ -229,11 +232,11 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={server?.provider?.icon || logoImg}
|
||||
alt={server.name}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = logoImg;
|
||||
|
||||
@@ -37,8 +37,6 @@ const SessionFile = (props: SessionFileProps) => {
|
||||
|
||||
const getUploadedFiles = async () => {
|
||||
if (isTauri) {
|
||||
console.log("sessionId", sessionId);
|
||||
|
||||
const response: any = await platformAdapter.commands(
|
||||
"get_attachment_by_ids",
|
||||
{
|
||||
|
||||
@@ -72,8 +72,6 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
||||
setSettings(response);
|
||||
};
|
||||
|
||||
console.log("currentService", currentService);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings();
|
||||
fetchData();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
|
||||
{icon?.startsWith("font_") ? (
|
||||
<FontIcon name={icon} className="size-6" />
|
||||
) : (
|
||||
<img src={getTypeIcon()} alt={name} className="size-6" />
|
||||
<img src={getTypeIcon()} alt={name} className="size-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
|
||||
)}
|
||||
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@@ -182,9 +182,9 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<button
|
||||
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
|
||||
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("cloud.cancel")}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
<img
|
||||
src={item?.provider?.icon || cocoLogoImg}
|
||||
alt={`${item.name} logo`}
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
className="w-5 h-5 flex-shrink-0 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = cocoLogoImg;
|
||||
|
||||
@@ -24,13 +24,13 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
||||
<img
|
||||
src={userInfo?.avatar}
|
||||
alt=""
|
||||
className="w-6 h-6"
|
||||
className="w-6 h-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||
onError={() => {
|
||||
setImageLoadError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<User className="w-6 h-6 text-gray-500 dark:text-gray-400" />
|
||||
<User className="w-6 h-6 text-gray-500 dark:text-gray-400 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -44,7 +44,7 @@ function CommonIcon({
|
||||
// Handle regular icon types
|
||||
const renderIconByType = (renderType: string) => {
|
||||
if (isNil(isAbsolute)) return null;
|
||||
|
||||
|
||||
switch (renderType) {
|
||||
case "special_icon": {
|
||||
if (item.id === "Calculator") {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface FontIconProps {
|
||||
|
||||
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
||||
return (
|
||||
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
||||
<svg className={`icon dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className || ""}`} style={style} {...rest}>
|
||||
<use xlinkHref={`#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,12 @@ function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return <Component className={className} color={color} />;
|
||||
return (
|
||||
<Component
|
||||
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemedIcon;
|
||||
|
||||
@@ -49,7 +49,13 @@ function UniversalIcon({
|
||||
|
||||
// Render image type icon
|
||||
const renderImageIcon = (src: string) => {
|
||||
const img = <img className={className} src={src} alt="icon" />;
|
||||
const img = (
|
||||
<img
|
||||
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||
src={src}
|
||||
alt="icon"
|
||||
/>
|
||||
);
|
||||
return wrapWithIconWrapper ? (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
{img}
|
||||
@@ -63,7 +69,7 @@ function UniversalIcon({
|
||||
const renderAppIcon = (src: string) => {
|
||||
const img = (
|
||||
<img
|
||||
className={className}
|
||||
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||
src={platformAdapter.convertFileSrc(src)}
|
||||
alt="icon"
|
||||
/>
|
||||
|
||||
47
src/components/Common/Scrollbar.tsx
Normal file
47
src/components/Common/Scrollbar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEventListener } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
const Scrollbar = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
(props, ref) => {
|
||||
const { children, className, ...rest } = props;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
|
||||
|
||||
useEventListener("keydown", (event) => {
|
||||
const { key } = event;
|
||||
|
||||
if (key !== "PageDown" && key !== "PageUp") return;
|
||||
|
||||
if (!containerRef.current) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const delta = key === "PageDown" ? 1 : -1;
|
||||
const el = containerRef.current;
|
||||
|
||||
el.scrollBy({
|
||||
top: delta * el.clientHeight * 0.9,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={containerRef}
|
||||
className={clsx("custom-scrollbar", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Scrollbar;
|
||||
50
src/components/Common/TogglePin.tsx
Normal file
50
src/components/Common/TogglePin.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import clsx from "clsx";
|
||||
import VisibleKey from "./VisibleKey";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
|
||||
interface TogglePinProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
setIsPinnedWeb?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const TogglePin: FC<TogglePinProps> = (props) => {
|
||||
const { className, setIsPinnedWeb } = props;
|
||||
const { isPinned, setIsPinned } = useAppStore();
|
||||
const { fixedWindow } = useShortcutsStore();
|
||||
|
||||
const togglePin = async () => {
|
||||
const { isTauri, isPinned } = useAppStore.getState();
|
||||
|
||||
try {
|
||||
const nextPinned = !isPinned;
|
||||
|
||||
if (!isTauri) {
|
||||
setIsPinnedWeb?.(nextPinned);
|
||||
}
|
||||
|
||||
setIsPinned(nextPinned);
|
||||
} catch (err) {
|
||||
setIsPinned(isPinned);
|
||||
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={clsx(className, {
|
||||
"text-blue-500": isPinned,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default TogglePin;
|
||||
@@ -5,13 +5,10 @@ import clsx from "clsx";
|
||||
|
||||
import CommonIcon from "@/components/Common/Icons/CommonIcon";
|
||||
import Copyright from "@/components/Common/Copyright";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import VisibleKey from "../VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
import source_default_img from "@/assets/images/source_default.png";
|
||||
@@ -19,6 +16,7 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import FontIcon from "../Icons/FontIcon";
|
||||
import TogglePin from "../TogglePin";
|
||||
|
||||
interface FooterProps {
|
||||
setIsPinnedWeb?: (value: boolean) => void;
|
||||
@@ -37,28 +35,11 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
|
||||
const { isTauri, isPinned, setIsPinned } = useAppStore();
|
||||
const { isTauri } = useAppStore();
|
||||
|
||||
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
||||
|
||||
const { fixedWindow, modifierKey } = useShortcutsStore();
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const { isTauri, isPinned } = useAppStore.getState();
|
||||
|
||||
const nextPinned = !isPinned;
|
||||
|
||||
if (!isTauri) {
|
||||
setIsPinnedWeb?.(nextPinned);
|
||||
}
|
||||
|
||||
setIsPinned(nextPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
setIsPinned(isPinned);
|
||||
}
|
||||
};
|
||||
const { modifierKey } = useShortcutsStore();
|
||||
|
||||
const openSetting = useCallback(() => {
|
||||
return platformAdapter.emitEvent("open_settings", "");
|
||||
@@ -88,7 +69,10 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
if (visibleExtensionDetail && selectedExtension) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={selectedExtension.icon} className="size-5" />
|
||||
<img
|
||||
src={selectedExtension.icon}
|
||||
className="size-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||
/>
|
||||
<span className="text-sm">{selectedExtension.name}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -139,17 +123,12 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
{renderLeft()}
|
||||
|
||||
<button
|
||||
onClick={togglePin}
|
||||
<TogglePin
|
||||
className={clsx({
|
||||
"text-blue-500": isPinned,
|
||||
"pl-2": hasUpdate,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
setIsPinnedWeb={setIsPinnedWeb}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -95,4 +95,4 @@ const Footer = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
export default Footer;
|
||||
|
||||
@@ -6,6 +6,9 @@ import { last } from "lodash-es";
|
||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { KeyType } from "ahooks/lib/useKeyPress";
|
||||
|
||||
const keyTriggerMap = new Map<KeyType, number>();
|
||||
|
||||
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
shortcut: string;
|
||||
@@ -60,8 +63,16 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
||||
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
|
||||
}, [openPopover, modifierKeyPressed]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${shortcut}`, (event) => {
|
||||
if (!visibleShortcut) return;
|
||||
useKeyPress(`${modifierKey}.${shortcut}`, (event, key) => {
|
||||
if (!visibleShortcut || event.repeat) return;
|
||||
|
||||
const now = Date.now();
|
||||
const last = keyTriggerMap.get(key) ?? 0;
|
||||
const wait = 100;
|
||||
|
||||
if (now - last < wait) return;
|
||||
|
||||
keyTriggerMap.set(key, now);
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -82,6 +93,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
||||
return "↩︎";
|
||||
}
|
||||
|
||||
if (shortcut === "backspace") {
|
||||
return "⌫";
|
||||
}
|
||||
|
||||
return shortcut;
|
||||
};
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ const AskAi: FC<AskAiProps> = (props) => {
|
||||
unlisten.current = await platformAdapter.listenEvent(
|
||||
"quick-ai-access-client-id",
|
||||
({ payload }) => {
|
||||
console.log("ask_ai", JSON.parse(payload));
|
||||
// console.log("ask_ai", JSON.parse(payload));
|
||||
|
||||
const chunkData = JSON.parse(payload);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import platformAdapter from "@/utils/platformAdapter";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
import type { Assistant } from "@/types/chat";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { canNavigateBack, navigateBack } from "@/utils";
|
||||
import { useKeyPress } from "ahooks";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
interface AssistantManagerProps {
|
||||
isChatMode: boolean;
|
||||
@@ -33,9 +36,9 @@ export function useAssistantManager({
|
||||
setVisibleExtensionStore,
|
||||
setSearchValue,
|
||||
visibleExtensionDetail,
|
||||
setVisibleExtensionDetail,
|
||||
sourceData,
|
||||
setSourceData,
|
||||
setVisibleExtensionDetail,
|
||||
} = useSearchStore();
|
||||
|
||||
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
|
||||
@@ -47,6 +50,7 @@ export function useAssistantManager({
|
||||
}, [quickAiAccessAssistant, selectedAssistant]);
|
||||
|
||||
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
||||
const { modifierKey } = useShortcutsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (goAskAi) return;
|
||||
@@ -76,7 +80,7 @@ export function useAssistantManager({
|
||||
}, [askAI?.id, askAI?.querySource?.id, disabledExtensions]);
|
||||
|
||||
const handleAskAi = useCallback(() => {
|
||||
if (!isTauri) return;
|
||||
if (!isTauri || canNavigateBack()) return;
|
||||
|
||||
if (disabledExtensions.includes("QuickAIAccess")) return;
|
||||
|
||||
@@ -99,39 +103,14 @@ export function useAssistantManager({
|
||||
const handleKeyDownAutoResizeTextarea = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const { key, shiftKey, currentTarget } = e;
|
||||
const { value } = currentTarget;
|
||||
const { value, selectionStart, selectionEnd } = currentTarget;
|
||||
|
||||
if (key === "Backspace" && value === "") {
|
||||
if (goAskAi) {
|
||||
return setGoAskAi(false);
|
||||
}
|
||||
const cursorStart = selectionStart === 0 && selectionEnd === 0;
|
||||
|
||||
if (visibleExtensionDetail) {
|
||||
return setVisibleExtensionDetail(false);
|
||||
}
|
||||
|
||||
if (visibleExtensionStore) {
|
||||
return setVisibleExtensionStore(false);
|
||||
}
|
||||
|
||||
if (sourceData) {
|
||||
return setSourceData(void 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "Tab" && !isChatMode && isTauri) {
|
||||
if (key === "Backspace" && (value === "" || cursorStart)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (visibleExtensionStore) return;
|
||||
|
||||
if (selectedSearchContent?.id === "Extension Store") {
|
||||
changeInput("");
|
||||
setSearchValue("");
|
||||
return setVisibleExtensionStore(true);
|
||||
}
|
||||
|
||||
assistant_get();
|
||||
return handleAskAi();
|
||||
return navigateBack();
|
||||
}
|
||||
|
||||
if (key === "Enter" && !shiftKey) {
|
||||
@@ -147,6 +126,17 @@ export function useAssistantManager({
|
||||
|
||||
handleSubmit();
|
||||
}
|
||||
|
||||
if (key === "Home") {
|
||||
e.preventDefault();
|
||||
return currentTarget.setSelectionRange(0, 0);
|
||||
}
|
||||
|
||||
if (key === "End") {
|
||||
e.preventDefault();
|
||||
const length = currentTarget.value.length;
|
||||
return currentTarget.setSelectionRange(length, length);
|
||||
}
|
||||
},
|
||||
[
|
||||
isChatMode,
|
||||
@@ -161,6 +151,69 @@ export function useAssistantManager({
|
||||
]
|
||||
);
|
||||
|
||||
const clearSearchValue = () => {
|
||||
changeInput("");
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
// useKeyPress("backspace", () => {
|
||||
// console.log("backspace");
|
||||
// dispatchEvent("Backspace", 8, "#search-textarea");
|
||||
// });
|
||||
|
||||
useKeyPress("tab", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { selectedSearchContent, visibleExtensionStore } =
|
||||
useSearchStore.getState();
|
||||
|
||||
console.log("selectedSearchContent", selectedSearchContent);
|
||||
|
||||
const { id, type, category } = selectedSearchContent ?? {};
|
||||
|
||||
if (isChatMode || !isTauri || id === "Calculator") return;
|
||||
|
||||
if (visibleExtensionStore) {
|
||||
clearSearchValue();
|
||||
return setVisibleExtensionDetail(true);
|
||||
}
|
||||
|
||||
if (id === "Extension Store") {
|
||||
clearSearchValue();
|
||||
return setVisibleExtensionStore(true);
|
||||
}
|
||||
|
||||
if (category === "View") {
|
||||
const onOpened = selectedSearchContent?.on_opened;
|
||||
|
||||
if (onOpened?.Extension?.ty?.View) {
|
||||
const { setViewExtensionOpened } = useSearchStore.getState();
|
||||
const viewData = onOpened.Extension.ty.View;
|
||||
const extensionPermission = onOpened.Extension.permission;
|
||||
|
||||
clearSearchValue();
|
||||
return setViewExtensionOpened([
|
||||
viewData.page,
|
||||
extensionPermission,
|
||||
viewData.ui,
|
||||
selectedSearchContent as any,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "AI Assistant") {
|
||||
assistant_get();
|
||||
return handleAskAi();
|
||||
}
|
||||
|
||||
setSourceData(selectedSearchContent);
|
||||
});
|
||||
|
||||
useKeyPress(`${modifierKey}.enter`, () => {
|
||||
assistant_get();
|
||||
return handleAskAi();
|
||||
});
|
||||
|
||||
return {
|
||||
askAI,
|
||||
askAIRef,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useInfiniteScroll } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Data } from "ahooks/lib/useInfiniteScroll/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { SearchHeader } from "./SearchHeader";
|
||||
@@ -11,9 +14,7 @@ import { Get } from "@/api/axiosRequest";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import SearchEmpty from "../Common/SearchEmpty";
|
||||
import { Data } from "ahooks/lib/useInfiniteScroll/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { isNil } from "lodash-es";
|
||||
import Scrollbar from "@/components/Common/Scrollbar";
|
||||
|
||||
interface DocumentListProps {
|
||||
onSelectDocument: (id: string) => void;
|
||||
@@ -297,8 +298,8 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto custom-scrollbar pr-0.5"
|
||||
<Scrollbar
|
||||
className="flex-1 overflow-auto pr-0.5"
|
||||
ref={containerRef}
|
||||
>
|
||||
{data?.list && data.list.length > 0 && (
|
||||
@@ -334,7 +335,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
<SearchEmpty />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
||||
import { SearchSource } from "./SearchSource";
|
||||
import DropdownListItem from "./DropdownListItem";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Scrollbar from "@/components/Common/Scrollbar";
|
||||
|
||||
type ISearchData = Record<string, QueryHits[]>;
|
||||
|
||||
@@ -145,13 +146,14 @@ function DropdownList({
|
||||
handleItemAction,
|
||||
isChatMode,
|
||||
formatUrl,
|
||||
searchData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
<Scrollbar
|
||||
ref={containerRef}
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
|
||||
className="h-full w-full p-2 flex flex-col overflow-y-auto focus:outline-none"
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
aria-label={t("search.header.results")}
|
||||
@@ -188,7 +190,7 @@ function DropdownList({
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("search_extension", result);
|
||||
// console.log("search_extension", result);
|
||||
|
||||
setList(result ?? []);
|
||||
|
||||
|
||||
@@ -18,10 +18,17 @@ import { useAssistantManager } from "./AssistantManager";
|
||||
import InputControls from "./InputControls";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import AudioRecording from "../AudioRecording";
|
||||
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
|
||||
import {
|
||||
canNavigateBack,
|
||||
getUploadedAttachmentsId,
|
||||
isDefaultServer,
|
||||
visibleFilterBar,
|
||||
visibleSearchBar,
|
||||
} from "@/utils";
|
||||
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||
import { SendMessageParams } from "../Assistant/Chat";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (params: SendMessageParams) => void;
|
||||
@@ -88,7 +95,7 @@ export default function ChatInput({
|
||||
|
||||
const { currentAssistant } = useConnectStore();
|
||||
|
||||
const { sourceData, goAskAi } = useSearchStore();
|
||||
const { goAskAi } = useSearchStore();
|
||||
|
||||
const { modifierKey, returnToInput, setModifierKeyPressed } =
|
||||
useShortcutsStore();
|
||||
@@ -103,8 +110,7 @@ export default function ChatInput({
|
||||
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
|
||||
|
||||
const { curChatEnd } = useChatStore();
|
||||
const { setSearchValue, visibleExtensionStore, selectedExtension } =
|
||||
useSearchStore();
|
||||
const { setSearchValue } = useSearchStore();
|
||||
const { uploadAttachments } = useChatStore();
|
||||
|
||||
useTauriFocus({
|
||||
@@ -121,7 +127,7 @@ export default function ChatInput({
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
console.log("handleSubmit", trimmedValue, disabled);
|
||||
// console.log("handleSubmit", trimmedValue, disabled);
|
||||
|
||||
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
|
||||
changeInput("");
|
||||
@@ -237,46 +243,19 @@ export default function ChatInput({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isChatMode &&
|
||||
(sourceData || visibleExtensionStore || selectedExtension) && (
|
||||
<div
|
||||
className={`absolute ${
|
||||
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
|
||||
} left-2`}
|
||||
>
|
||||
<VisibleKey shortcut="←" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
<div
|
||||
className={clsx(
|
||||
`absolute ${
|
||||
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
|
||||
} left-2`,
|
||||
{
|
||||
"left-8": !isChatMode && sourceData,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<VisibleKey shortcut={returnToInput} />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{!isChatMode &&
|
||||
isTauri &&
|
||||
!goAskAi &&
|
||||
askAI &&
|
||||
!disabledExtensions.includes("QuickAIAccess") &&
|
||||
!visibleExtensionStore && (
|
||||
!canNavigateBack() && (
|
||||
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
|
||||
<span>
|
||||
{t("search.askCocoAi.title", {
|
||||
replace: [akiAiTooltipPrefix, askAI.name],
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center justify-center w-8 h-[20px] text-xs rounded-md border border-black/10 dark:border-[#545454]">
|
||||
Tab
|
||||
<div className="flex items-center justify-center px-1 h-[20px] text-xs rounded-md border border-black/10 dark:border-[#545454]">
|
||||
{formatKey(modifierKey)} + {formatKey("Enter")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -330,53 +309,60 @@ export default function ChatInput({
|
||||
return (
|
||||
<div className={`w-full relative`}>
|
||||
<div
|
||||
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`}
|
||||
ref={containerRef}
|
||||
className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx("relative w-full", {
|
||||
"flex items-center gap-2": lineCount === 1,
|
||||
})}
|
||||
>
|
||||
{lineCount === 1 && renderSearchIcon()}
|
||||
{lineCount === 1 && renderSearchIcon()}
|
||||
|
||||
{renderTextarea()}
|
||||
{visibleSearchBar() && (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative w-full p-2 bg-[#ededed] dark:bg-[#202126]",
|
||||
{
|
||||
"flex items-center gap-2": lineCount === 1,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{renderTextarea()}
|
||||
|
||||
{lineCount === 1 && renderExtraIcon()}
|
||||
{lineCount === 1 && renderExtraIcon()}
|
||||
|
||||
{lineCount > 1 && (
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="flex-1">{renderSearchIcon()}</div>
|
||||
{lineCount > 1 && (
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="flex-1">{renderSearchIcon()}</div>
|
||||
|
||||
<div className="self-end">{renderExtraIcon()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="self-end">{renderExtraIcon()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InputControls
|
||||
isChatMode={isChatMode}
|
||||
isChatPage={isChatPage}
|
||||
hasModules={hasModules}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
chatPlaceholder={chatPlaceholder}
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={setIsSearchActive}
|
||||
isDeepThinkActive={isDeepThinkActive}
|
||||
setIsDeepThinkActive={setIsDeepThinkActive}
|
||||
isMCPActive={isMCPActive}
|
||||
setIsMCPActive={setIsMCPActive}
|
||||
changeMode={changeMode}
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
/>
|
||||
{visibleFilterBar() && (
|
||||
<InputControls
|
||||
isChatMode={isChatMode}
|
||||
isChatPage={isChatPage}
|
||||
hasModules={hasModules}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
chatPlaceholder={chatPlaceholder}
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={setIsSearchActive}
|
||||
isDeepThinkActive={isDeepThinkActive}
|
||||
setIsDeepThinkActive={setIsDeepThinkActive}
|
||||
isMCPActive={isMCPActive}
|
||||
setIsMCPActive={setIsMCPActive}
|
||||
changeMode={changeMode}
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import AskAi from "./AskAi";
|
||||
import { useSearch } from "@/hooks/useSearch";
|
||||
import ExtensionStore from "./ExtensionStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import ViewExtension from "./ViewExtension"
|
||||
import ViewExtension from "./ViewExtension";
|
||||
import { visibleFooterBar } from "@/utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
const SearchResultsPanel = memo<{
|
||||
input: string;
|
||||
@@ -47,8 +49,12 @@ const SearchResultsPanel = memo<{
|
||||
}
|
||||
}, [input, isChatMode, performSearch, sourceData]);
|
||||
|
||||
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore, viewExtensionOpened } =
|
||||
useSearchStore();
|
||||
const {
|
||||
setSelectedAssistant,
|
||||
selectedSearchContent,
|
||||
visibleExtensionStore,
|
||||
viewExtensionOpened,
|
||||
} = useSearchStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSearchContent?.type === "AI Assistant") {
|
||||
@@ -164,7 +170,12 @@ function Search({
|
||||
const mainWindowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div ref={mainWindowRef} className={`h-full pb-8 w-full relative`}>
|
||||
<div
|
||||
ref={mainWindowRef}
|
||||
className={clsx("h-full w-full relative", {
|
||||
"pb-8": visibleFooterBar(),
|
||||
})}
|
||||
>
|
||||
<SearchResultsPanel
|
||||
input={input}
|
||||
isChatMode={isChatMode}
|
||||
@@ -173,7 +184,7 @@ function Search({
|
||||
formatUrl={formatUrl}
|
||||
/>
|
||||
|
||||
<Footer setIsPinnedWeb={setIsPinned} />
|
||||
{visibleFooterBar() && <Footer setIsPinnedWeb={setIsPinned} />}
|
||||
|
||||
<ContextMenu formatUrl={formatUrl} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,61 @@
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { ArrowBigLeft, Search, X } from "lucide-react";
|
||||
import { ChevronLeft, Search } from "lucide-react";
|
||||
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import { FC } from "react";
|
||||
import lightDefaultIcon from "@/assets/images/source_default.png";
|
||||
import darkDefaultIcon from "@/assets/images/source_default_dark.png";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { navigateBack, visibleSearchBar } from "@/utils";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface MultilevelWrapperProps {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
|
||||
const { icon, title = "" } = props;
|
||||
const { isDark } = useThemeStore();
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!icon) {
|
||||
return <img src={isDark ? darkDefaultIcon : lightDefaultIcon} />;
|
||||
}
|
||||
|
||||
if (icon.startsWith("font_")) {
|
||||
return <FontIcon name={icon} />;
|
||||
}
|
||||
|
||||
return <img src={icon} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={clsx(
|
||||
"flex items-center h-[40px] gap-1 px-2 border border-[#EDEDED] dark:border-[#202126] rounded-l-lg",
|
||||
{
|
||||
"justify-center": visibleSearchBar(),
|
||||
"w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<VisibleKey shortcut="backspace" onKeyPress={navigateBack}>
|
||||
<ChevronLeft
|
||||
className="size-5 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
|
||||
onClick={navigateBack}
|
||||
/>
|
||||
</VisibleKey>
|
||||
|
||||
<div className="size-5 [&>*]:size-full">{renderIcon()}</div>
|
||||
|
||||
<span className="text-sm whitespace-nowrap">{title}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchIconsProps {
|
||||
lineCount: number;
|
||||
@@ -16,13 +70,11 @@ export default function SearchIcons({
|
||||
}: SearchIconsProps) {
|
||||
const {
|
||||
sourceData,
|
||||
setSourceData,
|
||||
goAskAi,
|
||||
setGoAskAi,
|
||||
visibleExtensionStore,
|
||||
setVisibleExtensionStore,
|
||||
visibleExtensionDetail,
|
||||
setVisibleExtensionDetail,
|
||||
selectedExtension,
|
||||
viewExtensionOpened,
|
||||
} = useSearchStore();
|
||||
|
||||
if (isChatMode) {
|
||||
@@ -30,54 +82,42 @@ export default function SearchIcons({
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (visibleExtensionStore) {
|
||||
if (visibleExtensionDetail && selectedExtension) {
|
||||
const { name, icon } = selectedExtension;
|
||||
|
||||
return <MultilevelWrapper title={name} icon={icon} />;
|
||||
}
|
||||
|
||||
return <MultilevelWrapper title="Extensions Store" icon="font_Store" />;
|
||||
}
|
||||
|
||||
if (goAskAi && assistant) {
|
||||
return (
|
||||
<div className="flex h-8 -my-1 -mx-1">
|
||||
<div className="flex items-center gap-2 pl-2 text-sm bg-white dark:bg-black rounded-l-sm">
|
||||
<div className="flex items-center gap-1 text-[#333] dark:text-[#D8D8D8]">
|
||||
{assistant.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={assistant.icon} className="size-5" />
|
||||
) : (
|
||||
<img src={assistant.icon} className="size-5" />
|
||||
)}
|
||||
<span>{assistant.name}</span>
|
||||
</div>
|
||||
const { name, icon } = assistant;
|
||||
|
||||
<X
|
||||
className="size-4 text-[#999] cursor-pointer"
|
||||
onClick={() => {
|
||||
setGoAskAi(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-4 overflow-hidden">
|
||||
<div className="absolute size-0 border-[16px] border-transparent border-l-white dark:border-l-black"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <MultilevelWrapper title={name} icon={icon} />;
|
||||
}
|
||||
|
||||
if (sourceData || visibleExtensionStore || visibleExtensionDetail) {
|
||||
return (
|
||||
<ArrowBigLeft
|
||||
className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
|
||||
onClick={() => {
|
||||
if (visibleExtensionDetail) {
|
||||
return setVisibleExtensionDetail(false);
|
||||
}
|
||||
if (sourceData) {
|
||||
const { source } = sourceData;
|
||||
const { name, icon } = source;
|
||||
|
||||
if (visibleExtensionStore) {
|
||||
return setVisibleExtensionStore(false);
|
||||
}
|
||||
|
||||
setSourceData(void 0);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <MultilevelWrapper title={name} icon={icon} />;
|
||||
}
|
||||
|
||||
return <Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />;
|
||||
if (viewExtensionOpened) {
|
||||
const { title, icon } = viewExtensionOpened[3];
|
||||
|
||||
const iconPath = icon ? platformAdapter.convertFileSrc(icon) : void 0;
|
||||
|
||||
return <MultilevelWrapper title={title} icon={iconPath} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center pl-2 h-[40px] bg-[#ededed] dark:bg-[#202126]">
|
||||
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (lineCount === 1) {
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/*
|
||||
* ViewExtension.tsx
|
||||
*
|
||||
* View that will be rendered when opening a View extension.
|
||||
*
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import { ExtensionFileSystemPermission, FileSystemAccess } from "../Settings/Extensions";
|
||||
import {
|
||||
ExtensionFileSystemPermission,
|
||||
FileSystemAccess,
|
||||
} from "../Settings/Extensions";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
const ViewExtension: React.FC = () => {
|
||||
const { setViewExtensionOpened, viewExtensionOpened } = useSearchStore();
|
||||
const [pagePath, setPagePath] = useState<string>("");
|
||||
const { viewExtensionOpened } = useSearchStore();
|
||||
const [page, setPage] = useState<string>("");
|
||||
// Complete list of the backend APIs, grouped by their category.
|
||||
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
|
||||
const { setModifierKeyPressed } = useShortcutsStore();
|
||||
|
||||
if (viewExtensionOpened == null) {
|
||||
// When this view gets loaded, this state should not be NULL.
|
||||
@@ -30,9 +28,13 @@ const ViewExtension: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const setupFileUrl = async () => {
|
||||
// The check above ensures viewExtensionOpened is not null here.
|
||||
const filePath = viewExtensionOpened[0];
|
||||
if (filePath) {
|
||||
setPagePath(convertFileSrc(filePath));
|
||||
const page = viewExtensionOpened[0];
|
||||
|
||||
// Only convert to file source if it's a local file path, not a URL
|
||||
if (page.startsWith("http://") || page.startsWith("https://")) {
|
||||
setPage(page);
|
||||
} else {
|
||||
setPage(platformAdapter.convertFileSrc(page));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,9 +43,13 @@ const ViewExtension: React.FC = () => {
|
||||
|
||||
// invoke `apis()` and set the state
|
||||
useEffect(() => {
|
||||
setModifierKeyPressed(false);
|
||||
|
||||
const fetchApis = async () => {
|
||||
try {
|
||||
const availableApis = await invoke("apis") as Record<string, string[]>;
|
||||
const availableApis = (await platformAdapter.invokeBackend(
|
||||
"apis"
|
||||
)) as Record<string, string[]>;
|
||||
setApis(new Map(Object.entries(availableApis)));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch APIs:", error);
|
||||
@@ -53,10 +59,6 @@ const ViewExtension: React.FC = () => {
|
||||
fetchApis();
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
setViewExtensionOpened(null);
|
||||
};
|
||||
|
||||
// White list of the permission entries
|
||||
const permission = viewExtensionOpened[1];
|
||||
|
||||
@@ -108,10 +110,10 @@ const ViewExtension: React.FC = () => {
|
||||
const category = reversedApis.get(command)!;
|
||||
var api = null;
|
||||
if (permission == null) {
|
||||
api = null
|
||||
api = null;
|
||||
} else {
|
||||
api = permission.api
|
||||
};
|
||||
api = permission.api;
|
||||
}
|
||||
if (!apiPermissionCheck(category, command, api)) {
|
||||
source.postMessage(
|
||||
{
|
||||
@@ -126,10 +128,10 @@ const ViewExtension: React.FC = () => {
|
||||
|
||||
var fs = null;
|
||||
if (permission == null) {
|
||||
fs = null
|
||||
fs = null;
|
||||
} else {
|
||||
fs = permission.fs
|
||||
};
|
||||
fs = permission.fs;
|
||||
}
|
||||
if (!(await fsPermissionCheck(command, event.data, fs))) {
|
||||
source.postMessage(
|
||||
{
|
||||
@@ -145,7 +147,12 @@ const ViewExtension: React.FC = () => {
|
||||
if (command === "read_dir") {
|
||||
const { path } = event.data;
|
||||
try {
|
||||
const fileNames: [String] = await invoke("read_dir", { path: path });
|
||||
const fileNames: [String] = await platformAdapter.invokeBackend(
|
||||
"read_dir",
|
||||
{
|
||||
path: path,
|
||||
}
|
||||
);
|
||||
source.postMessage(
|
||||
{
|
||||
id,
|
||||
@@ -171,77 +178,75 @@ const ViewExtension: React.FC = () => {
|
||||
console.info("Coco extension API listener is up");
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
window.removeEventListener("message", messageHandler);
|
||||
};
|
||||
}, [reversedApis, permission]); // Add apiPermissions as dependency
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back to Search</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
src={pagePath}
|
||||
className="w-full h-full border-0"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
src={page}
|
||||
className="w-full h-full border-0"
|
||||
onLoad={(event) => {
|
||||
event.currentTarget.focus();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewExtension;
|
||||
|
||||
|
||||
// Permission check function - TypeScript translation of Rust function
|
||||
const apiPermissionCheck = (category: string, api: string, allowedApis: string[] | null): boolean => {
|
||||
const apiPermissionCheck = (
|
||||
category: string,
|
||||
api: string,
|
||||
allowedApis: string[] | null
|
||||
): boolean => {
|
||||
if (!allowedApis) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const qualifiedApi = `${category}:${api}`;
|
||||
return allowedApis.some(a => a === qualifiedApi);
|
||||
return allowedApis.some((a) => a === qualifiedApi);
|
||||
};
|
||||
|
||||
const extractFsAccessPattern = (command: string, requestPayload: any): [string, FileSystemAccess] => {
|
||||
const extractFsAccessPattern = (
|
||||
command: string,
|
||||
requestPayload: any
|
||||
): [string, FileSystemAccess] => {
|
||||
switch (command) {
|
||||
case "read_dir": {
|
||||
const { path } = requestPayload;
|
||||
const { path } = requestPayload;
|
||||
|
||||
return [path, ["read"]];
|
||||
return [path, ["read"]];
|
||||
}
|
||||
default: {
|
||||
throw new Error(`unknown command ${command}`);
|
||||
throw new Error(`unknown command ${command}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fsPermissionCheck = async (command: string, requestPayload: any, fsPermission: ExtensionFileSystemPermission[] | null): Promise<boolean> => {
|
||||
const fsPermissionCheck = async (
|
||||
command: string,
|
||||
requestPayload: any,
|
||||
fsPermission: ExtensionFileSystemPermission[] | null
|
||||
): Promise<boolean> => {
|
||||
if (!fsPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [ path, access ] = extractFsAccessPattern(command, requestPayload);
|
||||
const clean_path = await invoke("path_absolute", { path: path });
|
||||
const [path, access] = extractFsAccessPattern(command, requestPayload);
|
||||
const clean_path = await platformAdapter.invokeBackend("path_absolute", {
|
||||
path: path,
|
||||
});
|
||||
|
||||
// Walk through fsPermission array to find matching paths
|
||||
for (const permission of fsPermission) {
|
||||
if (permission.path === clean_path) {
|
||||
// Check if all required access permissions are included in the permission's access array
|
||||
const hasAllRequiredAccess = access.every(requiredAccess =>
|
||||
const hasAllRequiredAccess = access.every((requiredAccess) =>
|
||||
permission.access.includes(requiredAccess)
|
||||
);
|
||||
|
||||
|
||||
if (hasAllRequiredAccess) {
|
||||
return true;
|
||||
}
|
||||
@@ -249,4 +254,4 @@ const fsPermissionCheck = async (command: string, requestPayload: any, fsPermiss
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,14 +21,17 @@ import { isLinux, isWin } from "@/utils/platform";
|
||||
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
||||
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useStartupStore } from "@/stores/startupStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||
import type { StartPage } from "@/types/chat";
|
||||
import { hasUploadingAttachment } from "@/utils";
|
||||
import {
|
||||
hasUploadingAttachment,
|
||||
visibleFilterBar,
|
||||
visibleSearchBar,
|
||||
} from "@/utils";
|
||||
|
||||
interface SearchChatProps {
|
||||
isTauri?: boolean;
|
||||
@@ -105,7 +108,6 @@ function SearchChat({
|
||||
|
||||
const [isWin10, setIsWin10] = useState(false);
|
||||
const blurred = useAppStore((state) => state.blurred);
|
||||
const { viewExtensionOpened } = useSearchStore();
|
||||
|
||||
useWindowEvents();
|
||||
|
||||
@@ -289,45 +291,45 @@ function SearchChat({
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* We don't want this inputbox when rendering View extensions */}
|
||||
{/* TODO: figure out a better way to disable this inputbox */}
|
||||
{!viewExtensionOpened && (
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
|
||||
isTransitioned ? "border-t" : "border-b"
|
||||
} border-[#E6E6E6] dark:border-[#272626]`}
|
||||
>
|
||||
<InputBox
|
||||
isChatMode={isChatMode}
|
||||
inputValue={input}
|
||||
onSend={handleSendMessage}
|
||||
disabled={isTyping}
|
||||
disabledChange={cancelChat}
|
||||
changeMode={changeMode}
|
||||
changeInput={setInput}
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={toggleSearchActive}
|
||||
isDeepThinkActive={isDeepThinkActive}
|
||||
setIsDeepThinkActive={toggleDeepThinkActive}
|
||||
isMCPActive={isMCPActive}
|
||||
setIsMCPActive={toggleMCPActive}
|
||||
setupWindowFocusListener={setupWindowFocusListener}
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
hasModules={hasModules}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
chatPlaceholder={chatPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className={clsx(
|
||||
"p-2 w-full flex justify-center transition-all duration-500 border-[#E6E6E6] dark:border-[#272626]",
|
||||
[isTransitioned ? "border-t" : "border-b"],
|
||||
{
|
||||
"min-h-[82px]": visibleSearchBar() && visibleFilterBar(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<InputBox
|
||||
isChatMode={isChatMode}
|
||||
inputValue={input}
|
||||
onSend={handleSendMessage}
|
||||
disabled={isTyping}
|
||||
disabledChange={cancelChat}
|
||||
changeMode={changeMode}
|
||||
changeInput={setInput}
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={toggleSearchActive}
|
||||
isDeepThinkActive={isDeepThinkActive}
|
||||
setIsDeepThinkActive={toggleDeepThinkActive}
|
||||
isMCPActive={isMCPActive}
|
||||
setIsMCPActive={toggleMCPActive}
|
||||
setupWindowFocusListener={setupWindowFocusListener}
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
hasModules={hasModules}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
chatPlaceholder={chatPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
|
||||
@@ -1,55 +1,135 @@
|
||||
import { Globe, Github } from "lucide-react";
|
||||
import {
|
||||
Globe,
|
||||
Github,
|
||||
Rocket,
|
||||
BookOpen,
|
||||
MessageCircleReply,
|
||||
ScrollText,
|
||||
SquareArrowOutUpRight,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
import logoLight from "@/assets/images/logo-text-light.svg";
|
||||
import logoDark from "@/assets/images/logo-text-dark.svg";
|
||||
import lightLogo from "@/assets/images/logo-text-light.svg";
|
||||
import darkLogo from "@/assets/images/logo-text-dark.svg";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { cloneElement, ReactElement, useMemo } from "react";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface Link {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
url?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export default function AboutView() {
|
||||
const { t } = useTranslation();
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const { isDark } = useThemeStore();
|
||||
|
||||
const logo = isDark ? logoDark : logoLight;
|
||||
const links = useMemo<Link[]>(() => {
|
||||
return [
|
||||
{
|
||||
icon: <Rocket />,
|
||||
label: t("settings.about.labels.changelog"),
|
||||
url: "https://coco.rs/en/roadmap",
|
||||
},
|
||||
{
|
||||
icon: <BookOpen />,
|
||||
label: t("settings.about.labels.docs"),
|
||||
url: "https://docs.infinilabs.com/coco-app/main",
|
||||
},
|
||||
{
|
||||
icon: <Github />,
|
||||
label: "GitHub",
|
||||
url: "https://github.com/infinilabs/coco-app",
|
||||
},
|
||||
{
|
||||
icon: <Globe />,
|
||||
label: t("settings.about.labels.officialWebsite"),
|
||||
url: "https://coco.rs",
|
||||
},
|
||||
{
|
||||
icon: <MessageCircleReply />,
|
||||
label: t("settings.about.labels.submitFeedback"),
|
||||
url: "https://github.com/infinilabs/coco-app/issues",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText />,
|
||||
label: t("settings.about.labels.runningLog"),
|
||||
onPress: platformAdapter.openLogDir,
|
||||
},
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const handleClick = (link: Link) => {
|
||||
const { url, onPress } = link;
|
||||
|
||||
if (url) {
|
||||
return OpenURLWithBrowser(url);
|
||||
}
|
||||
|
||||
onPress?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center flex-col h-[calc(100vh-170px)]">
|
||||
<div>
|
||||
<div className="flex h-[calc(100vh-170px)]">
|
||||
<div className="flex flex-col items-center justify-center w-[70%] pr-10 text-[#999] text-sm">
|
||||
<img
|
||||
src={logo}
|
||||
className="w-48 dark:text-white"
|
||||
src={isDark ? darkLogo : lightLogo}
|
||||
className="h-14"
|
||||
alt={t("settings.about.logo")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 text-base font-medium text-[#333] dark:text-white/80">
|
||||
{t("settings.about.slogan")}
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
{t("settings.about.version", {
|
||||
version: process.env.VERSION || "N/A",
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{t("settings.about.copyright", { year: new Date().getFullYear() })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("settings.about.slogan")}
|
||||
</div>
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<button
|
||||
onClick={() => OpenURLWithBrowser("https://coco.rs")}
|
||||
className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
|
||||
aria-label={t("settings.about.website")}
|
||||
>
|
||||
<Globe className="w-3 text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser("https://github.com/infinilabs/coco-app")
|
||||
}
|
||||
className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
|
||||
aria-label={t("settings.about.github")}
|
||||
>
|
||||
<Github className="w-3 text-blue-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("settings.about.version", {
|
||||
version: process.env.VERSION || "N/A",
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 pl-10 border-l border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
{links.map((item) => {
|
||||
const { icon, label } = item;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center justify-between w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{cloneElement(icon, {
|
||||
className: "size-4 text-[#999]",
|
||||
})}
|
||||
|
||||
<span
|
||||
className="text-[#333] dark:text-white/80 cursor-pointer hover:text-[#027FFE] transition"
|
||||
onClick={() => {
|
||||
handleClick(item);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SquareArrowOutUpRight
|
||||
className="text-[#027FFE] size-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
handleClick(item);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("settings.about.copyright", { year: new Date().getFullYear() })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
) : (
|
||||
<img
|
||||
src={platformAdapter.convertFileSrc(icon)}
|
||||
className="size-full"
|
||||
className="size-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface ExtensionPermission {
|
||||
api: string[] | null;
|
||||
}
|
||||
|
||||
export interface ViewExtensionUISettings {
|
||||
search_bar: boolean,
|
||||
filter_bar: boolean,
|
||||
footer: boolean,
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: ExtensionId;
|
||||
type: ExtensionType;
|
||||
|
||||
@@ -29,7 +29,8 @@ export function useChatActions(
|
||||
isDeepThinkActive?: boolean,
|
||||
isMCPActive?: boolean,
|
||||
changeInput?: (val: string) => void,
|
||||
showChatHistory?: boolean
|
||||
showChatHistory?: boolean,
|
||||
getChatHistoryChatPage?: () => void,
|
||||
) {
|
||||
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
|
||||
|
||||
@@ -197,17 +198,17 @@ export function useChatActions(
|
||||
) {
|
||||
try {
|
||||
const response = JSON.parse(msg);
|
||||
console.log("first", response);
|
||||
// console.log("first", response);
|
||||
|
||||
let updatedChat: Chat;
|
||||
if (Array.isArray(response)) {
|
||||
curIdRef.current = response[0]?._id;
|
||||
curSessionIdRef.current = response[0]?._source?.session_id;
|
||||
console.log(
|
||||
"curIdRef-curSessionIdRef-Array",
|
||||
curIdRef.current,
|
||||
curSessionIdRef.current
|
||||
);
|
||||
// console.log(
|
||||
// "curIdRef-curSessionIdRef-Array",
|
||||
// curIdRef.current,
|
||||
// curSessionIdRef.current
|
||||
// );
|
||||
updatedChat = {
|
||||
...updatedChatRef.current,
|
||||
messages: [
|
||||
@@ -215,16 +216,16 @@ export function useChatActions(
|
||||
...(response || []),
|
||||
],
|
||||
};
|
||||
console.log("array", updatedChat, updatedChatRef.current?.messages);
|
||||
// console.log("array", updatedChat, updatedChatRef.current?.messages);
|
||||
} else {
|
||||
const newChat: Chat = response;
|
||||
curIdRef.current = response?.payload?.id;
|
||||
curSessionIdRef.current = response?.payload?.session_id;
|
||||
console.log(
|
||||
"curIdRef-curSessionIdRef",
|
||||
curIdRef.current,
|
||||
curSessionIdRef.current
|
||||
);
|
||||
// console.log(
|
||||
// "curIdRef-curSessionIdRef",
|
||||
// curIdRef.current,
|
||||
// curSessionIdRef.current
|
||||
// );
|
||||
|
||||
newChat._source = {
|
||||
...response?.payload,
|
||||
@@ -252,7 +253,7 @@ export function useChatActions(
|
||||
async (timestamp: number) => {
|
||||
cleanupListeners();
|
||||
|
||||
console.log("setupListeners", clientId, timestamp);
|
||||
// console.log("setupListeners", clientId, timestamp);
|
||||
const unlisten_chat_message = await platformAdapter.listenEvent(
|
||||
`chat-stream-${clientId}-${timestamp}`,
|
||||
(event) => {
|
||||
@@ -300,12 +301,45 @@ export function useChatActions(
|
||||
[setupListeners]
|
||||
);
|
||||
|
||||
const getChatHistory = useCallback(async () => {
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
if (await unrequitable()) {
|
||||
return setChats([]);
|
||||
}
|
||||
|
||||
response = await platformAdapter.commands("chat_history", {
|
||||
serverId: currentService?.id,
|
||||
from: 0,
|
||||
size: 100,
|
||||
query: keyword,
|
||||
});
|
||||
|
||||
response = response ? JSON.parse(response) : null;
|
||||
} else {
|
||||
const [_error, res] = await Get(`/chat/_history`, {
|
||||
from: 0,
|
||||
size: 100,
|
||||
});
|
||||
response = res;
|
||||
}
|
||||
|
||||
const hits = response?.hits?.hits || [];
|
||||
setChats(hits);
|
||||
}, [
|
||||
currentService?.id,
|
||||
keyword,
|
||||
isTauri,
|
||||
currentService?.enabled,
|
||||
isCurrentLogin,
|
||||
]);
|
||||
|
||||
const createNewChat = useCallback(
|
||||
async (params?: SendMessageParams) => {
|
||||
const { message, attachments } = params || {};
|
||||
|
||||
console.log("message", message);
|
||||
console.log("attachments", attachments);
|
||||
// console.log("message", message);
|
||||
// console.log("attachments", attachments);
|
||||
|
||||
if (!message && isEmpty(attachments)) return;
|
||||
|
||||
@@ -325,7 +359,7 @@ export function useChatActions(
|
||||
if (isTauri) {
|
||||
if (!currentService?.id) return;
|
||||
|
||||
console.log("chat_create", clientId, timestamp);
|
||||
// console.log("chat_create", clientId, timestamp);
|
||||
|
||||
await platformAdapter.commands("chat_create", {
|
||||
serverId: currentService?.id,
|
||||
@@ -334,7 +368,7 @@ export function useChatActions(
|
||||
queryParams,
|
||||
clientId: `chat-stream-${clientId}-${timestamp}`,
|
||||
});
|
||||
console.log("_create end", message);
|
||||
// console.log("_create end", message);
|
||||
resetChatState();
|
||||
} else {
|
||||
await streamPost({
|
||||
@@ -342,12 +376,17 @@ export function useChatActions(
|
||||
body: { message },
|
||||
queryParams,
|
||||
onMessage: (line) => {
|
||||
console.log("⏳", line);
|
||||
// console.log("⏳", line);
|
||||
handleChatCreateStreamMessage(line);
|
||||
// append to chat box
|
||||
},
|
||||
});
|
||||
}
|
||||
// console.log("showChatHistory", showChatHistory);
|
||||
|
||||
if (showChatHistory) {
|
||||
getChatHistoryChatPage ? getChatHistoryChatPage() : getChatHistory();
|
||||
}
|
||||
},
|
||||
[
|
||||
isTauri,
|
||||
@@ -360,6 +399,8 @@ export function useChatActions(
|
||||
currentAssistant,
|
||||
chatClose,
|
||||
clientId,
|
||||
showChatHistory,
|
||||
getChatHistory,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -386,7 +427,7 @@ export function useChatActions(
|
||||
|
||||
if (isTauri) {
|
||||
if (!currentService?.id) return;
|
||||
console.log("chat_chat", clientId, timestamp);
|
||||
// console.log("chat_chat", clientId, timestamp);
|
||||
await platformAdapter.commands("chat_chat", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: newChat?._id,
|
||||
@@ -395,7 +436,7 @@ export function useChatActions(
|
||||
attachments,
|
||||
clientId: `chat-stream-${clientId}-${timestamp}`,
|
||||
});
|
||||
console.log("chat_chat end", message, clientId);
|
||||
// console.log("chat_chat end", message, clientId);
|
||||
resetChatState();
|
||||
} else {
|
||||
await streamPost({
|
||||
@@ -403,7 +444,7 @@ export function useChatActions(
|
||||
body: { message },
|
||||
queryParams,
|
||||
onMessage: (line) => {
|
||||
console.log("line", line);
|
||||
// console.log("line", line);
|
||||
handleChatCreateStreamMessage(line);
|
||||
// append to chat box
|
||||
},
|
||||
@@ -468,39 +509,6 @@ export function useChatActions(
|
||||
[currentService?.id, isTauri]
|
||||
);
|
||||
|
||||
const getChatHistory = useCallback(async () => {
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
if (await unrequitable()) {
|
||||
return setChats([]);
|
||||
}
|
||||
|
||||
response = await platformAdapter.commands("chat_history", {
|
||||
serverId: currentService?.id,
|
||||
from: 0,
|
||||
size: 100,
|
||||
query: keyword,
|
||||
});
|
||||
|
||||
response = response ? JSON.parse(response) : null;
|
||||
} else {
|
||||
const [_error, res] = await Get(`/chat/_history`, {
|
||||
from: 0,
|
||||
size: 100,
|
||||
});
|
||||
response = res;
|
||||
}
|
||||
|
||||
const hits = response?.hits?.hits || [];
|
||||
setChats(hits);
|
||||
}, [
|
||||
currentService?.id,
|
||||
keyword,
|
||||
isTauri,
|
||||
currentService?.enabled,
|
||||
isCurrentLogin,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showChatHistory) {
|
||||
getChatHistory();
|
||||
|
||||
160
src/hooks/useChatPanel.ts
Normal file
160
src/hooks/useChatPanel.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { Chat } from "@/types/chat";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { unrequitable } from "@/utils";
|
||||
|
||||
export function useChatPanel() {
|
||||
const {
|
||||
assistantList,
|
||||
setCurrentAssistant,
|
||||
setVisibleStartPage,
|
||||
currentService,
|
||||
} = useConnectStore();
|
||||
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [activeChat, setActiveChat] = useState<Chat | undefined>();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const getChatHistory = useCallback(async () => {
|
||||
try {
|
||||
if (await unrequitable()) {
|
||||
return setChats([]);
|
||||
}
|
||||
let response: any = await platformAdapter.commands("chat_history", {
|
||||
serverId: currentService?.id,
|
||||
from: 0,
|
||||
size: 100,
|
||||
query: keyword,
|
||||
});
|
||||
response = response ? JSON.parse(response) : null;
|
||||
const hits = response?.hits?.hits || [];
|
||||
setChats(hits);
|
||||
} catch (error) {
|
||||
console.error("chat_history:", error);
|
||||
}
|
||||
}, [keyword, currentService?.id]);
|
||||
|
||||
const chatHistory = useCallback(
|
||||
async (chat: Chat) => {
|
||||
try {
|
||||
let response: any = await platformAdapter.commands(
|
||||
"session_chat_history",
|
||||
{
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id || "",
|
||||
from: 0,
|
||||
size: 500,
|
||||
}
|
||||
);
|
||||
response = response ? JSON.parse(response) : null;
|
||||
const hits = response?.hits?.hits || [];
|
||||
|
||||
// set current assistant based on last message
|
||||
const lastAssistantId = hits[hits.length - 1]?._source?.assistant_id;
|
||||
const matchedAssistant = assistantList?.find(
|
||||
(assistant) => assistant._id === lastAssistantId
|
||||
);
|
||||
if (matchedAssistant) {
|
||||
setCurrentAssistant(matchedAssistant);
|
||||
}
|
||||
|
||||
const updatedChat: Chat = {
|
||||
...chat,
|
||||
messages: hits,
|
||||
};
|
||||
setActiveChat(updatedChat);
|
||||
} catch (error) {
|
||||
console.error("session_chat_history:", error);
|
||||
}
|
||||
},
|
||||
[assistantList, currentService?.id, setCurrentAssistant]
|
||||
);
|
||||
|
||||
const onSelectChat = useCallback(
|
||||
async (chat: any) => {
|
||||
try {
|
||||
let response: any = await platformAdapter.commands(
|
||||
"open_session_chat",
|
||||
{
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id,
|
||||
}
|
||||
);
|
||||
response = response ? JSON.parse(response) : null;
|
||||
chatHistory(response);
|
||||
setVisibleStartPage(false);
|
||||
} catch (error) {
|
||||
console.error("open_session_chat:", error);
|
||||
}
|
||||
},
|
||||
[currentService?.id, chatHistory, setVisibleStartPage]
|
||||
);
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (chatId: string) => {
|
||||
if (!currentService?.id) return;
|
||||
|
||||
await platformAdapter.commands(
|
||||
"delete_session_chat",
|
||||
currentService.id,
|
||||
chatId
|
||||
);
|
||||
|
||||
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
|
||||
if (activeChat?._id === chatId) {
|
||||
const remainingChats = chats.filter((chat) => chat._id !== chatId);
|
||||
setActiveChat(remainingChats[0]);
|
||||
}
|
||||
},
|
||||
[currentService?.id, activeChat?._id, chats]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((kw: string) => {
|
||||
setKeyword(kw);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(chatId: string, title: string) => {
|
||||
if (!currentService?.id) return;
|
||||
|
||||
setChats((prev) => {
|
||||
const updatedChats = prev.map((item) => {
|
||||
if (item._id !== chatId) return item;
|
||||
return { ...item, _source: { ...item._source, title } };
|
||||
});
|
||||
return updatedChats;
|
||||
});
|
||||
|
||||
if (activeChat?._id === chatId) {
|
||||
setActiveChat((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, _source: { ...prev._source, title } };
|
||||
});
|
||||
}
|
||||
|
||||
platformAdapter.commands("update_session_chat", {
|
||||
serverId: currentService.id,
|
||||
sessionId: chatId,
|
||||
title,
|
||||
});
|
||||
},
|
||||
[currentService?.id, activeChat?._id]
|
||||
);
|
||||
|
||||
return {
|
||||
chats,
|
||||
setChats,
|
||||
activeChat,
|
||||
setActiveChat,
|
||||
keyword,
|
||||
setKeyword,
|
||||
getChatHistory,
|
||||
chatHistory,
|
||||
onSelectChat,
|
||||
deleteChat,
|
||||
handleSearch,
|
||||
handleRename,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import type { QueryHits, SearchDocument } from "@/types/search";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { isNumber } from "lodash-es";
|
||||
|
||||
interface UseKeyboardNavigationProps {
|
||||
suggests: QueryHits[];
|
||||
@@ -16,6 +17,7 @@ interface UseKeyboardNavigationProps {
|
||||
handleItemAction: (item: SearchDocument) => void;
|
||||
isChatMode: boolean;
|
||||
formatUrl?: (item: any) => string;
|
||||
searchData: Record<string, QueryHits[]>;
|
||||
}
|
||||
|
||||
export function useKeyboardNavigation({
|
||||
@@ -29,6 +31,7 @@ export function useKeyboardNavigation({
|
||||
handleItemAction,
|
||||
isChatMode,
|
||||
formatUrl,
|
||||
searchData,
|
||||
}: UseKeyboardNavigationProps) {
|
||||
const openPopover = useShortcutsStore((state) => state.openPopover);
|
||||
const visibleContextMenu = useSearchStore((state) => {
|
||||
@@ -47,6 +50,20 @@ export function useKeyboardNavigation({
|
||||
return metaKeyPressed || ctrlKeyPressed || altKeyPressed;
|
||||
};
|
||||
|
||||
const getGroupContext = () => {
|
||||
const groupEntries = Object.entries(searchData);
|
||||
const groupIndex = groupEntries.findIndex(([_, value]) => {
|
||||
return value.some((item) => {
|
||||
return item.document.index === selectedIndex;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
groupEntries,
|
||||
groupIndex,
|
||||
};
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (isChatMode || !suggests.length || openPopover || visibleContextMenu) {
|
||||
@@ -59,13 +76,28 @@ export function useKeyboardNavigation({
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
// console.log("ArrowUp pressed", selectedIndex, suggests.length);
|
||||
|
||||
let nextIndex: number | undefined = void 0;
|
||||
|
||||
if (modifierKeyPressed) {
|
||||
const { groupEntries, groupIndex } = getGroupContext();
|
||||
|
||||
const nextGroupIndex =
|
||||
groupIndex > 0 ? groupIndex - 1 : groupEntries.length - 1;
|
||||
|
||||
nextIndex = groupEntries[nextGroupIndex][1][0].document.index;
|
||||
}
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
if (prev == null) {
|
||||
return Math.min(...indexes);
|
||||
}
|
||||
|
||||
const nextIndex = prev - 1;
|
||||
if (isNumber(nextIndex)) {
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
nextIndex = prev - 1;
|
||||
|
||||
if (indexes.includes(nextIndex)) {
|
||||
return nextIndex;
|
||||
@@ -75,13 +107,28 @@ export function useKeyboardNavigation({
|
||||
});
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
//console.log("ArrowDown pressed", selectedIndex, suggests.length);
|
||||
|
||||
let nextIndex: number | undefined = void 0;
|
||||
|
||||
if (modifierKeyPressed) {
|
||||
const { groupEntries, groupIndex } = getGroupContext();
|
||||
|
||||
const nextGroupIndex =
|
||||
groupIndex < groupEntries.length - 1 ? groupIndex + 1 : 0;
|
||||
|
||||
nextIndex = groupEntries[nextGroupIndex][1][0].document.index;
|
||||
}
|
||||
|
||||
setSelectedIndex((prev) => {
|
||||
if (prev == null) {
|
||||
return Math.min(...indexes);
|
||||
}
|
||||
|
||||
const nextIndex = prev + 1;
|
||||
if (isNumber(nextIndex)) {
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
nextIndex = prev + 1;
|
||||
|
||||
if (indexes.includes(nextIndex)) {
|
||||
return nextIndex;
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useMessageHandler(
|
||||
}
|
||||
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
console.log("AI response timeout");
|
||||
// console.log("AI response timeout");
|
||||
setTimedoutShow(true);
|
||||
onCancel();
|
||||
}, (connectionTimeout ?? 120) * 1000);
|
||||
@@ -108,7 +108,7 @@ export function useMessageHandler(
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
setCurChatEnd(true);
|
||||
console.log("AI finished output");
|
||||
// console.log("AI finished output");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
import { debounce, orderBy } from "lodash-es";
|
||||
|
||||
import type {
|
||||
QueryHits,
|
||||
@@ -65,7 +65,9 @@ export function useSearch() {
|
||||
response: MultiSourceQueryResponse,
|
||||
searchInput: string
|
||||
) => {
|
||||
const data = response?.hits || [];
|
||||
const hits = response?.hits ?? [];
|
||||
|
||||
const data = orderBy(hits, "score", "desc");
|
||||
|
||||
const searchData = data.reduce(
|
||||
(acc: SearchDataBySource, item: QueryHits) => {
|
||||
@@ -196,7 +198,7 @@ export function useSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("_suggest", searchInput, response);
|
||||
//console.log("_suggest", searchInput, response);
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
|
||||
export const useServers = () => {
|
||||
const setServerList = useConnectStore((state) => state.setServerList);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const cloudSelectService = useConnectStore((state) => {
|
||||
return state.cloudSelectService;
|
||||
});
|
||||
|
||||
const getAllServerList = async () => {
|
||||
try {
|
||||
@@ -30,6 +26,8 @@ export const useServers = () => {
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch server list:", error);
|
||||
setServerList([]);
|
||||
} finally {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,7 +71,7 @@ export const useServers = () => {
|
||||
await setCurrentWindowService({ ...service, enabled });
|
||||
await getAllServerList();
|
||||
},
|
||||
[currentService, cloudSelectService]
|
||||
[]
|
||||
);
|
||||
|
||||
const removeServer = useCallback(
|
||||
@@ -81,7 +79,7 @@ export const useServers = () => {
|
||||
await platformAdapter.commands("remove_coco_server", id);
|
||||
await getAllServerList();
|
||||
},
|
||||
[currentService?.id, cloudSelectService?.id]
|
||||
[]
|
||||
);
|
||||
|
||||
const logoutServer = useCallback(async (id: string) => {
|
||||
@@ -92,7 +90,7 @@ export const useServers = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getAllServerList();
|
||||
}, [currentService?.enabled, cloudSelectService?.enabled]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
getAllServerList,
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function useSettingsWindow() {
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = platformAdapter.listenEvent("open_settings", async (event) => {
|
||||
console.log("open_settings event received:", event);
|
||||
//console.log("open_settings event received:", event);
|
||||
const tab = event.payload as string | "";
|
||||
|
||||
platformAdapter.emitEvent("tab_index", tab);
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useStreamChat = (options: Options) => {
|
||||
unlistenRef.current = await platformAdapter.listenEvent(
|
||||
clientId,
|
||||
({ payload }) => {
|
||||
console.log(clientId, JSON.parse(payload));
|
||||
//console.log(clientId, JSON.parse(payload));
|
||||
|
||||
const chunkData = JSON.parse(payload);
|
||||
|
||||
|
||||
@@ -117,7 +117,8 @@ export const useSyncStore = () => {
|
||||
const setShowTooltip = useAppStore((state) => state.setShowTooltip);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setLanguage = useAppStore((state) => state.setLanguage);
|
||||
const { setCurrentService } = useConnectStore();
|
||||
|
||||
const setServerListSilently = useConnectStore((state) => state.setServerListSilently);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resetFixedWindow) {
|
||||
@@ -185,7 +186,6 @@ export const useSyncStore = () => {
|
||||
connectionTimeout,
|
||||
querySourceTimeout,
|
||||
allowSelfSignature,
|
||||
currentService,
|
||||
} = payload;
|
||||
if (isNumber(connectionTimeout)) {
|
||||
setConnectionTimeout(connectionTimeout);
|
||||
@@ -194,7 +194,6 @@ export const useSyncStore = () => {
|
||||
setQueryTimeout(querySourceTimeout);
|
||||
}
|
||||
setAllowSelfSignature(allowSelfSignature);
|
||||
setCurrentService(currentService);
|
||||
}),
|
||||
|
||||
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {
|
||||
@@ -235,6 +234,10 @@ export const useSyncStore = () => {
|
||||
setEndpoint(endpoint);
|
||||
setLanguage(language);
|
||||
}),
|
||||
|
||||
platformAdapter.listenEvent("server-list-changed", ({ payload }) => {
|
||||
setServerListSilently(payload);
|
||||
}),
|
||||
]);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -39,7 +39,14 @@
|
||||
"website": "Visit Website",
|
||||
"github": "Visit GitHub",
|
||||
"version": "Version {{version}}",
|
||||
"copyright": "©{{year}} INFINI Labs, All Rights Reserved."
|
||||
"copyright": "©{{year}} INFINI Labs, All Rights Reserved.",
|
||||
"labels": {
|
||||
"changelog": "What’s New",
|
||||
"docs": "Docs",
|
||||
"officialWebsite": "Official Website",
|
||||
"submitFeedback": "Submit Feedback",
|
||||
"runningLog": "View App Logs"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"startup": {
|
||||
|
||||
@@ -39,7 +39,14 @@
|
||||
"website": "访问官网",
|
||||
"github": "访问 GitHub",
|
||||
"version": "版本 {{version}}",
|
||||
"copyright": "©{{year}} INFINI Labs,保留所有权利。"
|
||||
"copyright": "©{{year}} INFINI Labs,保留所有权利。",
|
||||
"labels": {
|
||||
"changelog": "更新日志",
|
||||
"docs": "帮助文档",
|
||||
"officialWebsite": "官方网站",
|
||||
"submitFeedback": "提交反馈",
|
||||
"runningLog": "运行日志"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"startup": {
|
||||
|
||||
@@ -35,12 +35,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
||||
setIsTauri(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
setCurrentAssistant,
|
||||
assistantList,
|
||||
setVisibleStartPage,
|
||||
currentService,
|
||||
} = useConnectStore();
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const chatAIRef = useRef<ChatAIRef>(null);
|
||||
|
||||
@@ -77,7 +72,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
||||
query: keyword,
|
||||
});
|
||||
response = response ? JSON.parse(response) : null;
|
||||
console.log("_history", response);
|
||||
// console.log("_history", response);
|
||||
const hits = response?.hits?.hits || [];
|
||||
setChats(hits);
|
||||
} catch (error) {
|
||||
@@ -112,39 +107,6 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
||||
chatAIRef.current?.init(params);
|
||||
};
|
||||
|
||||
const chatHistory = async (chat: typeChat) => {
|
||||
try {
|
||||
let response: any = await platformAdapter.commands(
|
||||
"session_chat_history",
|
||||
{
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id || "",
|
||||
from: 0,
|
||||
size: 500,
|
||||
}
|
||||
);
|
||||
response = response ? JSON.parse(response) : null;
|
||||
console.log("id_history", response);
|
||||
const hits = response?.hits?.hits || [];
|
||||
// set current assistant
|
||||
const lastAssistantId = hits[hits.length - 1]?._source?.assistant_id;
|
||||
const matchedAssistant = assistantList?.find(
|
||||
(assistant) => assistant._id === lastAssistantId
|
||||
);
|
||||
if (matchedAssistant) {
|
||||
setCurrentAssistant(matchedAssistant);
|
||||
}
|
||||
//
|
||||
const updatedChat: typeChat = {
|
||||
...chat,
|
||||
messages: hits,
|
||||
};
|
||||
setActiveChat(updatedChat);
|
||||
} catch (error) {
|
||||
console.error("session_chat_history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const chatClose = async () => {
|
||||
if (!activeChat?._id) return;
|
||||
try {
|
||||
@@ -153,26 +115,14 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
||||
sessionId: activeChat?._id,
|
||||
});
|
||||
response = response ? JSON.parse(response) : null;
|
||||
console.log("_close", response);
|
||||
// console.log("_close", response);
|
||||
} catch (error) {
|
||||
console.error("close_session_chat:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectChat = async (chat: any) => {
|
||||
chatClose();
|
||||
try {
|
||||
let response: any = await platformAdapter.commands("open_session_chat", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id,
|
||||
});
|
||||
response = response ? JSON.parse(response) : null;
|
||||
console.log("_open", response);
|
||||
chatHistory(response);
|
||||
setVisibleStartPage(false);
|
||||
} catch (error) {
|
||||
console.error("open_session_chat:", error);
|
||||
}
|
||||
chatAIRef.current?.onSelectChat(chat);
|
||||
};
|
||||
|
||||
const cancelChat = async () => {
|
||||
@@ -317,6 +267,8 @@ export default function StandaloneChat({}: StandaloneChatProps) {
|
||||
isChatPage={isChatPage}
|
||||
getFileUrl={getFileUrl}
|
||||
changeInput={setInput}
|
||||
showChatHistory={true}
|
||||
getChatHistoryChatPage={getChatHistory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
function MainApp() {
|
||||
const { setIsTauri } = useAppStore();
|
||||
const { setViewExtensionOpened } = useSearchStore();
|
||||
const { setViewExtensionOpened } = useSearchStore();
|
||||
|
||||
useEffect(() => {
|
||||
setIsTauri(true);
|
||||
@@ -20,10 +20,11 @@ function MainApp() {
|
||||
//
|
||||
// Events will be sent when users try to open a View extension via hotkey,
|
||||
// whose payload contains the needed information to load the View page.
|
||||
platformAdapter.listenEvent("open_view_extension", async ({ payload: view_extension_page_and_permission } ) => {
|
||||
await platformAdapter.showWindow();
|
||||
setViewExtensionOpened(view_extension_page_and_permission);
|
||||
})
|
||||
platformAdapter.listenEvent("open_view_extension", async ({ payload }) => {
|
||||
await platformAdapter.showWindow();
|
||||
|
||||
setViewExtensionOpened(payload);
|
||||
});
|
||||
}, []);
|
||||
const { synthesizeItem } = useChatStore();
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type keyArrayObject = {
|
||||
export type IConnectStore = {
|
||||
serverList: Server[];
|
||||
setServerList: (servers: Server[]) => void;
|
||||
setServerListSilently: (servers: Server[]) => void;
|
||||
currentService: Server;
|
||||
setCurrentService: (service: Server) => void;
|
||||
cloudSelectService: Server;
|
||||
@@ -44,7 +45,15 @@ export const useConnectStore = create<IConnectStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
serverList: [],
|
||||
setServerList: (serverList: Server[]) => {
|
||||
setServerList: async(serverList: Server[]) => {
|
||||
set(
|
||||
produce((draft) => {
|
||||
draft.serverList = serverList;
|
||||
})
|
||||
);
|
||||
await platformAdapter.emitEvent("server-list-changed", serverList);
|
||||
},
|
||||
setServerListSilently: (serverList: Server[]) => {
|
||||
set(
|
||||
produce((draft) => {
|
||||
draft.serverList = serverList;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ExtensionId } from "@/components/Settings/Extensions";
|
||||
import { create } from "zustand";
|
||||
import { persist, subscribeWithSelector } from "zustand/middleware";
|
||||
|
||||
import { ExtensionId } from "@/components/Settings/Extensions";
|
||||
|
||||
export type IExtensionsStore = {
|
||||
quickAiAccessServer?: any;
|
||||
setQuickAiAccessServer: (quickAiAccessServer?: any) => void;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { SearchExtensionItem } from "@/components/Search/ExtensionStore";
|
||||
import { ExtensionPermission } from "@/components/Settings/Extensions";
|
||||
import {
|
||||
ExtensionPermission,
|
||||
ViewExtensionUISettings,
|
||||
} from "@/components/Settings/Extensions";
|
||||
import { SearchDocument } from "@/types/search";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type ViewExtensionOpened = [
|
||||
string,
|
||||
ExtensionPermission | null,
|
||||
ViewExtensionUISettings | null,
|
||||
SearchDocument
|
||||
];
|
||||
|
||||
export type ISearchStore = {
|
||||
sourceData: any;
|
||||
setSourceData: (sourceData: any) => void;
|
||||
@@ -45,10 +56,13 @@ export type ISearchStore = {
|
||||
|
||||
// When we open a View extension, we set this to a non-null value.
|
||||
//
|
||||
// The first array element is the path to the page that we should load, the
|
||||
// second element is the permission that this extension requires.
|
||||
viewExtensionOpened: [string, ExtensionPermission | null] | null;
|
||||
setViewExtensionOpened: (showViewExtension: [string, ExtensionPermission | null] | null) => void;
|
||||
// Arguments
|
||||
//
|
||||
// The first array element is the path to the page that we should load
|
||||
// The second element is the permission that this extension requires.
|
||||
// The third argument is the UI Settings
|
||||
viewExtensionOpened?: ViewExtensionOpened;
|
||||
setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void;
|
||||
};
|
||||
|
||||
export const useSearchStore = create<ISearchStore>()(
|
||||
@@ -114,7 +128,6 @@ export const useSearchStore = create<ISearchStore>()(
|
||||
setVisibleExtensionDetail: (visibleExtensionDetail) => {
|
||||
return set({ visibleExtensionDetail });
|
||||
},
|
||||
viewExtensionOpened: null,
|
||||
setViewExtensionOpened: (viewExtensionOpened) => {
|
||||
return set({ viewExtensionOpened });
|
||||
},
|
||||
|
||||
@@ -6,7 +6,8 @@ import { IStartupStore } from "@/stores/startupStore";
|
||||
import { AppTheme } from "@/types/index";
|
||||
import { SearchDocument } from "./search";
|
||||
import { IAppStore } from "@/stores/appStore";
|
||||
import { ExtensionPermission } from "@/components/Settings/Extensions";
|
||||
import type { Server } from "@/types/server";
|
||||
import { ViewExtensionOpened } from "@/stores/searchStore";
|
||||
|
||||
export interface EventPayloads {
|
||||
"theme-changed": string;
|
||||
@@ -48,7 +49,8 @@ export interface EventPayloads {
|
||||
"check-update": any;
|
||||
oauth_success: any;
|
||||
extension_install_success: any;
|
||||
"open_view_extension": [string, ExtensionPermission];
|
||||
open_view_extension: ViewExtensionOpened;
|
||||
"server-list-changed": Server[];
|
||||
}
|
||||
|
||||
// Window operation interface
|
||||
@@ -127,6 +129,7 @@ export interface SystemOperations {
|
||||
queryParams: string[]
|
||||
) => Promise<any[]>;
|
||||
fetchAssistant: (serverId: string, queryParams: string[]) => Promise<any>;
|
||||
openLogDir: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Base platform adapter interface
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { getCurrentWindowService } from "@/commands/windowService";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
|
||||
// 1
|
||||
export async function copyToClipboard(text: string) {
|
||||
@@ -212,3 +213,116 @@ export const getUploadedAttachmentsId = () => {
|
||||
.map((item) => item.attachmentId)
|
||||
.filter((id) => !isNil(id));
|
||||
};
|
||||
|
||||
export const canNavigateBack = () => {
|
||||
const {
|
||||
goAskAi,
|
||||
visibleExtensionStore,
|
||||
visibleExtensionDetail,
|
||||
viewExtensionOpened,
|
||||
sourceData,
|
||||
} = useSearchStore.getState();
|
||||
|
||||
return (
|
||||
goAskAi ||
|
||||
visibleExtensionStore ||
|
||||
visibleExtensionDetail ||
|
||||
viewExtensionOpened ||
|
||||
sourceData
|
||||
);
|
||||
};
|
||||
|
||||
export const navigateBack = () => {
|
||||
const {
|
||||
goAskAi,
|
||||
visibleExtensionStore,
|
||||
visibleExtensionDetail,
|
||||
viewExtensionOpened,
|
||||
setGoAskAi,
|
||||
setVisibleExtensionDetail,
|
||||
setVisibleExtensionStore,
|
||||
setSourceData,
|
||||
setViewExtensionOpened,
|
||||
} = useSearchStore.getState();
|
||||
|
||||
if (goAskAi) {
|
||||
return setGoAskAi(false);
|
||||
}
|
||||
|
||||
if (visibleExtensionDetail) {
|
||||
return setVisibleExtensionDetail(false);
|
||||
}
|
||||
|
||||
if (visibleExtensionStore) {
|
||||
return setVisibleExtensionStore(false);
|
||||
}
|
||||
|
||||
if (viewExtensionOpened) {
|
||||
return setViewExtensionOpened(void 0);
|
||||
}
|
||||
|
||||
setSourceData(void 0);
|
||||
};
|
||||
|
||||
export const dispatchEvent = (
|
||||
key: string,
|
||||
keyCode: number,
|
||||
selector?: string
|
||||
) => {
|
||||
let target: HTMLElement | Window = window;
|
||||
|
||||
if (isString(selector)) {
|
||||
target = document.querySelector(selector) as HTMLElement;
|
||||
|
||||
if (document.activeElement === target) return;
|
||||
|
||||
target.focus();
|
||||
}
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key,
|
||||
code: key,
|
||||
keyCode,
|
||||
which: keyCode,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
target.dispatchEvent(event);
|
||||
};
|
||||
|
||||
export const visibleSearchBar = () => {
|
||||
const { viewExtensionOpened, visibleExtensionDetail } =
|
||||
useSearchStore.getState();
|
||||
|
||||
if (visibleExtensionDetail) return false;
|
||||
|
||||
if (isNil(viewExtensionOpened)) return true;
|
||||
|
||||
const [, , ui] = viewExtensionOpened;
|
||||
|
||||
return ui?.search_bar ?? true;
|
||||
};
|
||||
|
||||
export const visibleFilterBar = () => {
|
||||
const { viewExtensionOpened, visibleExtensionDetail } =
|
||||
useSearchStore.getState();
|
||||
|
||||
if (visibleExtensionDetail) return false;
|
||||
|
||||
if (isNil(viewExtensionOpened)) return true;
|
||||
|
||||
const [, , ui] = viewExtensionOpened;
|
||||
|
||||
return ui?.filter_bar ?? true;
|
||||
};
|
||||
|
||||
export const visibleFooterBar = () => {
|
||||
const { viewExtensionOpened } = useSearchStore.getState();
|
||||
|
||||
if (isNil(viewExtensionOpened)) return true;
|
||||
|
||||
const [, , ui] = viewExtensionOpened;
|
||||
|
||||
return ui?.footer ?? true;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export const KEY_SYMBOLS: Record<string, string> = {
|
||||
// Special keys
|
||||
Space: "Space",
|
||||
space: "Space",
|
||||
Enter: "↵",
|
||||
Enter: "↩︎",
|
||||
Backspace: "⌫",
|
||||
Delete: "Del",
|
||||
Escape: "Esc",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenDialogOptions } from "@tauri-apps/plugin-dialog";
|
||||
import { isWindows10 } from "tauri-plugin-windows-version-api";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import { metadata } from "tauri-plugin-fs-pro-api";
|
||||
import { error } from "@tauri-apps/plugin-log";
|
||||
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
import type { BasePlatformAdapter } from "@/types/platform";
|
||||
import type { AppTheme } from "@/types/index";
|
||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||
import { copyToClipboard, OpenURLWithBrowser } from ".";
|
||||
import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from ".";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { unrequitable } from "@/utils";
|
||||
|
||||
export interface TauriPlatformAdapter extends BasePlatformAdapter {
|
||||
@@ -24,6 +23,7 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
|
||||
) => Promise<string | string[] | null>;
|
||||
metadata: typeof metadata;
|
||||
error: typeof error;
|
||||
openLogDir: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Create Tauri adapter functions
|
||||
@@ -258,35 +258,12 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
|
||||
console.log("openSearchItem", data);
|
||||
|
||||
// Extension store needs to be opened in a different way
|
||||
if (data?.type === "AI Assistant" || data?.id === "Extension Store") {
|
||||
const textarea = document.querySelector("#search-textarea");
|
||||
|
||||
if (!(textarea instanceof HTMLTextAreaElement)) return;
|
||||
|
||||
textarea.focus();
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Tab",
|
||||
code: "Tab",
|
||||
keyCode: 9,
|
||||
which: 9,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
return textarea.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// View extension should be handled separately as it needs frontend to open
|
||||
// a page
|
||||
const onOpened = data?.on_opened;
|
||||
if (onOpened?.Extension?.ty?.View) {
|
||||
const { setViewExtensionOpened } = useSearchStore.getState();
|
||||
const viewData = onOpened.Extension.ty.View;
|
||||
const extensionPermission = onOpened.Extension.permission;
|
||||
|
||||
setViewExtensionOpened([viewData.page, extensionPermission]);
|
||||
return;
|
||||
if (
|
||||
data?.type === "AI Assistant" ||
|
||||
data?.id === "Extension Store" ||
|
||||
data?.category === "View"
|
||||
) {
|
||||
return dispatchEvent("Tab", 9);
|
||||
}
|
||||
|
||||
const hideCoco = () => {
|
||||
@@ -352,5 +329,13 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
|
||||
const window = await windowWrapper.getWebviewWindow();
|
||||
return window.label;
|
||||
},
|
||||
|
||||
async openLogDir() {
|
||||
const { revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
|
||||
const logDir: string = await invoke("app_log_dir");
|
||||
|
||||
revealItemInDir(logDir);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface WebPlatformAdapter extends BasePlatformAdapter {
|
||||
openFileDialog: (options: any) => Promise<string | string[] | null>;
|
||||
metadata: (path: string, options: any) => Promise<Record<string, any>>;
|
||||
error: (message: string) => void;
|
||||
openLogDir: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Create Web adapter functions
|
||||
@@ -261,17 +262,20 @@ export const createWebAdapter = (): WebPlatformAdapter => {
|
||||
`/assistant/_search?${queryParams?.join("&")}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("_search", error);
|
||||
return {};
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
|
||||
async getCurrentWindowLabel() {
|
||||
return "web";
|
||||
},
|
||||
|
||||
async openLogDir() {
|
||||
console.log("openLogDir is not supported in web environment");
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { writeFileSync, readFileSync } from 'fs';
|
||||
import { writeFileSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
const projectPackageJson = JSON.parse(
|
||||
readFileSync(join(__dirname, 'package.json'), 'utf-8')
|
||||
);
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const entries = readdirSync(dir);
|
||||
const files: string[] = [];
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...walk(full));
|
||||
} else {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function hasTauriRefs(content: string): boolean {
|
||||
return /@tauri-apps|tauri-plugin/i.test(content);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/pages/web/index.tsx'],
|
||||
format: ['esm'],
|
||||
@@ -66,13 +85,25 @@ export default defineConfig({
|
||||
outDir: 'out/search-chat',
|
||||
|
||||
async onSuccess() {
|
||||
const outDir = join(__dirname, 'out/search-chat');
|
||||
const files = walk(outDir).filter(f => /\.(m?js|cjs)$/i.test(f));
|
||||
const tauriFiles = files.filter(f => {
|
||||
const content = readFileSync(f, 'utf-8');
|
||||
return hasTauriRefs(content);
|
||||
});
|
||||
|
||||
if (tauriFiles.length) {
|
||||
throw new Error(
|
||||
`Build output contains Tauri references:\n${tauriFiles.map(f => ` - ${f}`).join('\n')}`
|
||||
);
|
||||
}
|
||||
const projectPackageJson = JSON.parse(
|
||||
readFileSync(join(__dirname, 'package.json'), 'utf-8')
|
||||
);
|
||||
|
||||
const packageJson = {
|
||||
name: "@infinilabs/search-chat",
|
||||
version: "1.2.38",
|
||||
version: "1.2.46",
|
||||
main: "index.js",
|
||||
module: "index.js",
|
||||
type: "module",
|
||||
|
||||
Reference in New Issue
Block a user