mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 03:27:43 +01:00
feat: support third party extensions (#572)
* refactor: support third party extensions * fix tests * fix: assistant_get error * aaa * bbb * ccc * ddd * fix: aa * fix: aa * sss * fix:asds * eee * refactor: loosen restriction of query string length * fix: input auto * feat: add ai overview trigger condition configuration * refactor: continue chatting to select the corresponding mini-helper * chore: settings width height * aaa --------- Co-authored-by: Steve Lau <stevelauc@outlook.com> Co-authored-by: rain <15911122312@163.com>
This commit is contained in:
@@ -111,6 +111,8 @@ Information about release notes of Coco Server is provided here.
|
||||
- feat: data sources support displaying customized icons #432
|
||||
- feat: add shortcut key conflict hint and reset function #442
|
||||
- feat: updated to include error message #465
|
||||
- feat: support third party extensions #572
|
||||
- feat: support ai overview #572
|
||||
|
||||
### Bug fix
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||
"tauri-plugin-windows-version-api": "^2.0.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"wavesurfer.js": "^7.9.5",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -140,6 +140,9 @@ importers:
|
||||
tauri-plugin-windows-version-api:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
type-fest:
|
||||
specifier: ^4.41.0
|
||||
version: 4.41.0
|
||||
use-debounce:
|
||||
specifier: ^10.0.4
|
||||
version: 10.0.4(react@18.3.1)
|
||||
|
||||
1
public/assets/fonts/icons/extension.js
Normal file
1
public/assets/fonts/icons/extension.js
Normal file
File diff suppressed because one or more lines are too long
57
src-tauri/Cargo.lock
generated
57
src-tauri/Cargo.lock
generated
@@ -823,13 +823,16 @@ dependencies = [
|
||||
name = "coco"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"applications",
|
||||
"async-trait",
|
||||
"base64 0.13.1",
|
||||
"chinese-number",
|
||||
"chrono",
|
||||
"derive_more 2.0.1",
|
||||
"dirs 5.0.1",
|
||||
"enigo",
|
||||
"function_name",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
@@ -847,6 +850,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_plain",
|
||||
"strsim 0.10.0",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -1291,6 +1295,27 @@ dependencies = [
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1826,6 +1851,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "function_name"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1ab577a896d09940b5fe12ec5ae71f9d8211fff62c919c03a3750a9901e98a7"
|
||||
dependencies = [
|
||||
"function_name-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "function_name-proc-macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673464e1e314dd67a0fd9544abc99e8eb28d0c7e3b69b033bcff9b2d00b87333"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -5328,7 +5368,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"matches",
|
||||
@@ -5403,6 +5443,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_plain"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -7040,6 +7089,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -93,6 +93,10 @@ chinese-number = "0.7"
|
||||
num2words = "1"
|
||||
tauri-plugin-log = "2"
|
||||
chrono = "0.4.41"
|
||||
serde_plain = "1.0.2"
|
||||
derive_more = { version = "2.0.1", features = ["display"] }
|
||||
anyhow = "1.0.98"
|
||||
function_name = "0.3.0"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
|
||||
8
src-tauri/assets/extension/AIOverview/plugin.json
Normal file
8
src-tauri/assets/extension/AIOverview/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "AIOverview",
|
||||
"title": "AI Overview",
|
||||
"description": "...",
|
||||
"icon": "font_a-AIOverview",
|
||||
"type": "ai_extension",
|
||||
"enabled": true
|
||||
}
|
||||
9
src-tauri/assets/extension/Applications/plugin.json
Normal file
9
src-tauri/assets/extension/Applications/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "Applications",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"title": "Applications",
|
||||
"description": "...",
|
||||
"icon": "font_Application",
|
||||
"type": "group",
|
||||
"enabled": true
|
||||
}
|
||||
9
src-tauri/assets/extension/Calculator/plugin.json
Normal file
9
src-tauri/assets/extension/Calculator/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "Calculator",
|
||||
"title": "Calculator",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"description": "...",
|
||||
"icon": "font_Calculator",
|
||||
"type": "calculator",
|
||||
"enabled": true
|
||||
}
|
||||
8
src-tauri/assets/extension/QuickAIAccess/plugin.json
Normal file
8
src-tauri/assets/extension/QuickAIAccess/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "QuickAIAccess",
|
||||
"title": "Quick AI Access",
|
||||
"description": "...",
|
||||
"icon": "font_a-QuickAIAccess",
|
||||
"type": "ai_extension",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2024-10-29"
|
||||
channel = "nightly-2025-02-28"
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::hide_coco;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RichLabel {
|
||||
pub label: Option<String>,
|
||||
@@ -29,6 +31,72 @@ pub struct EditorInfo {
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
/// Defines the action that would be performed when a document gets opened.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum OnOpened {
|
||||
/// Launch the application
|
||||
Application { app_path: String },
|
||||
/// Open the URL.
|
||||
Document { url: String },
|
||||
/// Spawn a child process to run the `CommandAction`.
|
||||
Command {
|
||||
action: crate::extension::CommandAction,
|
||||
},
|
||||
}
|
||||
|
||||
impl OnOpened {
|
||||
pub(crate) fn url(&self) -> String {
|
||||
match self {
|
||||
Self::Application { app_path } => app_path.clone(),
|
||||
Self::Document { url } => url.clone(),
|
||||
Self::Command { action } => {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
ret.push_str(action.args.join(WHITESPACE).as_str());
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> {
|
||||
log::debug!("open({})", on_opened.url());
|
||||
|
||||
use crate::util::open as homemade_tauri_shell_open;
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use std::process::Command;
|
||||
|
||||
let global_tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
match on_opened {
|
||||
OnOpened::Application { app_path } => {
|
||||
homemade_tauri_shell_open(global_tauri_app_handle.clone(), app_path).await?
|
||||
}
|
||||
OnOpened::Document { url } => {
|
||||
homemade_tauri_shell_open(global_tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
OnOpened::Command { action } => {
|
||||
let mut cmd = Command::new(action.exec);
|
||||
cmd.args(action.args);
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Command failed, stderr [{}]",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hide_coco(global_tauri_app_handle.clone()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Document {
|
||||
pub id: String,
|
||||
@@ -48,6 +116,8 @@ pub struct Document {
|
||||
pub thumbnail: Option<String>,
|
||||
pub cover: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// What will happen if we open this document.
|
||||
pub on_opened: Option<OnOpened>,
|
||||
pub url: Option<String>,
|
||||
pub size: Option<i64>,
|
||||
pub metadata: Option<HashMap<String, serde_json::Value>>,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::common::error::SearchError;
|
||||
// use std::{future::Future, pin::Pin};
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::search::{QueryResponse, QuerySource};
|
||||
use async_trait::async_trait;
|
||||
@@ -10,4 +9,3 @@ pub trait SearchSource: Send + Sync {
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
||||
}
|
||||
|
||||
|
||||
1
src-tauri/src/extension/built_in/ai_overview.rs
Normal file
1
src-tauri/src/extension/built_in/ai_overview.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(super) const EXTENSION_ID: &str = "AIOverview";
|
||||
@@ -1,13 +1,14 @@
|
||||
use super::super::SearchSourceState;
|
||||
use super::super::Task;
|
||||
use super::super::RUNTIME_TX;
|
||||
use super::AppEntry;
|
||||
use super::super::pizza_engine_runtime::SearchSourceState;
|
||||
use super::super::pizza_engine_runtime::Task;
|
||||
use super::super::pizza_engine_runtime::RUNTIME_TX;
|
||||
use super::super::Extension;
|
||||
use super::AppMetadata;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::common::document::{DataSourceReference, Document, OnOpened};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::util::open;
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use applications::{App, AppTrait};
|
||||
@@ -326,7 +327,7 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
|
||||
|
||||
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
|
||||
let callback = self.callback.take().unwrap();
|
||||
let disabled_app_list = get_disabled_app_list(self.tauri_app_handle.clone());
|
||||
let disabled_app_list = get_disabled_app_list(&self.tauri_app_handle);
|
||||
|
||||
// TODO: search via alias, implement this when Pizza engine supports update
|
||||
let dsl = format!(
|
||||
@@ -551,19 +552,24 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
FieldValue::Text(string) => string,
|
||||
_ => unreachable!("field icon is of type Text"),
|
||||
};
|
||||
let on_opened = OnOpened::Application {
|
||||
app_path: app_path.clone(),
|
||||
};
|
||||
let url = on_opened.url();
|
||||
|
||||
let coco_document = Document {
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
|
||||
id: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
|
||||
icon: None,
|
||||
icon: Some(String::from("font_Application")),
|
||||
}),
|
||||
id: app_path.clone(),
|
||||
category: Some("Application".to_string()),
|
||||
title: Some(app_name.clone()),
|
||||
url: Some(app_path),
|
||||
icon: Some(app_icon_path),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
@@ -574,12 +580,7 @@ fn pizza_engine_hits_to_coco_hits(
|
||||
coco_hits
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_app_alias<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
alias: String,
|
||||
) {
|
||||
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_ALIAS)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
|
||||
@@ -649,42 +650,42 @@ fn register_app_hotkey_upon_start<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
hotkey: String,
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
// Ignore the error as it may not be registered
|
||||
unregister_app_hotkey(tauri_app_handle, app_path)?;
|
||||
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
|
||||
app_hotkey_store.set(app_path.clone(), hotkey.as_str());
|
||||
app_hotkey_store.set(app_path, hotkey);
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey.as_str(), app_hotkey_handler(app_path))
|
||||
.on_shortcut(hotkey, app_hotkey_handler(app_path.into()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let app_hotkey_store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
|
||||
let Some(hotkey) = app_hotkey_store.get(app_path.as_str()) else {
|
||||
let error_msg = format!(
|
||||
let Some(hotkey) = app_hotkey_store.get(app_path) else {
|
||||
warn!(
|
||||
"unregister an Application hotkey that does not exist app: [{}]",
|
||||
app_path,
|
||||
);
|
||||
warn!("{}", error_msg);
|
||||
return Err(error_msg);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let hotkey = match hotkey {
|
||||
@@ -692,11 +693,18 @@ pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
_ => unreachable!("hotkey should be stored in a string"),
|
||||
};
|
||||
|
||||
let deleted = app_hotkey_store.delete(app_path.as_str());
|
||||
let deleted = app_hotkey_store.delete(app_path);
|
||||
if !deleted {
|
||||
return Err("failed to delete application hotkey from store".into());
|
||||
}
|
||||
|
||||
if !tauri_app_handle
|
||||
.global_shortcut()
|
||||
.is_registered(hotkey.as_str())
|
||||
{
|
||||
panic!("inconsistent state, tauri store a hotkey is stored in the tauri store but it is not registered");
|
||||
}
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.unregister(hotkey.as_str())
|
||||
@@ -705,7 +713,7 @@ pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> {
|
||||
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -732,10 +740,19 @@ fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<Stri
|
||||
disabled_app_list
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_app_search<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
pub fn is_app_search_enabled(app_path: &str) -> bool {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
let disabled_app_list = get_disabled_app_list(tauri_app_handle);
|
||||
|
||||
disabled_app_list.iter().all(|path| path != app_path)
|
||||
}
|
||||
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
@@ -748,24 +765,26 @@ pub async fn disable_app_search<R: Runtime>(
|
||||
|
||||
let mut disabled_app_list = get_disabled_app_list(tauri_app_handle);
|
||||
|
||||
if disabled_app_list.contains(&app_path) {
|
||||
if disabled_app_list
|
||||
.iter()
|
||||
.any(|disabled_app| disabled_app == app_path)
|
||||
{
|
||||
return Err(format!(
|
||||
"trying to disable an app that is disabled [{}]",
|
||||
app_path
|
||||
));
|
||||
}
|
||||
|
||||
disabled_app_list.push(app_path);
|
||||
disabled_app_list.push(app_path.into());
|
||||
|
||||
store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_app_search<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
app_path: String,
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
@@ -879,7 +898,7 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<AppEntry>, String> {
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||
let apps = list_app_in(search_paths)?;
|
||||
|
||||
@@ -910,14 +929,12 @@ pub async fn get_app_list<R: Runtime>(
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_APP_HOTKEY)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
let opt_string = store.get(&path).map(|json| match json {
|
||||
store.get(&path).map(|json| match json {
|
||||
Json::String(s) => s,
|
||||
_ => unreachable!("app hotkey should be stored in a string"),
|
||||
});
|
||||
|
||||
opt_string.unwrap_or(String::new())
|
||||
})
|
||||
};
|
||||
let is_disabled = {
|
||||
let enabled = {
|
||||
let store = tauri_app_handle
|
||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
||||
@@ -942,16 +959,26 @@ pub async fn get_app_list<R: Runtime>(
|
||||
_ => unreachable!("disabled app list should be stored in an array"),
|
||||
};
|
||||
|
||||
disabled_app_list.contains(&path)
|
||||
!disabled_app_list.contains(&path)
|
||||
};
|
||||
|
||||
let app_entry = AppEntry {
|
||||
path,
|
||||
name,
|
||||
icon_path,
|
||||
alias,
|
||||
let app_entry = Extension {
|
||||
id: path,
|
||||
title: name,
|
||||
platforms: None,
|
||||
// Leave it empty as it won't be used
|
||||
description: String::new(),
|
||||
icon: icon_path,
|
||||
r#type: ExtensionType::Application,
|
||||
action: None,
|
||||
quick_link: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quick_links: None,
|
||||
alias: Some(alias),
|
||||
hotkey,
|
||||
is_disabled,
|
||||
enabled,
|
||||
settings: None,
|
||||
};
|
||||
|
||||
app_entries.push(app_entry);
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::super::Extension;
|
||||
use super::AppMetadata;
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use async_trait::async_trait;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use super::AppEntry;
|
||||
use super::AppMetadata;
|
||||
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
@@ -39,46 +39,45 @@ impl SearchSource for ApplicationSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_app_alias(_app_path: String, _alias: String) -> Result<(), String> {
|
||||
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
_hotkey: String,
|
||||
pub fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
_hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
pub fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
pub fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
pub fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: &AppHandle<R>,
|
||||
_app_path: &str,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_app_search_enabled(_app_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
@@ -103,11 +102,10 @@ pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) ->
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<AppEntry>, String> {
|
||||
) -> Result<Vec<Extension>, String> {
|
||||
// Return an empty list
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::common::{
|
||||
document::{DataSourceReference, Document},
|
||||
error::SearchError,
|
||||
@@ -116,7 +116,7 @@ impl SearchSource for CalculatorSource {
|
||||
});
|
||||
};
|
||||
|
||||
// Trim the leading and tailing whitespace so that our later if condition
|
||||
// Trim the leading and tailing whitespace so that our later if condition
|
||||
// will only be evaluated against non-whitespace characters.
|
||||
let query_string = query_string.trim();
|
||||
|
||||
@@ -146,7 +146,7 @@ impl SearchSource for CalculatorSource {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
icon: Some(String::from("font_Calculator")),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
1
src-tauri/src/extension/built_in/file_system.rs
Normal file
1
src-tauri/src/extension/built_in/file_system.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
310
src-tauri/src/extension/built_in/mod.rs
Normal file
310
src-tauri/src/extension/built_in/mod.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
//! Built-in extensions and related stuff.
|
||||
|
||||
pub mod ai_overview;
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
pub mod file_system;
|
||||
pub mod pizza_engine_runtime;
|
||||
pub mod quick_ai_access;
|
||||
|
||||
use super::Extension;
|
||||
use crate::extension::{alter_extension_json_file, load_extension_from_json_file};
|
||||
use crate::{SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::Manager;
|
||||
|
||||
pub(crate) static BUILT_IN_EXTENSION_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| {
|
||||
let mut resource_dir = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set")
|
||||
.path()
|
||||
.resolve("assets", BaseDirectory::Resource)
|
||||
.expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
resource_dir.push("extension");
|
||||
|
||||
resource_dir
|
||||
});
|
||||
|
||||
pub(super) async fn init_built_in_extension(
|
||||
extension: &Extension,
|
||||
search_source_registry: &SearchSourceRegistry,
|
||||
) {
|
||||
log::trace!("initializing built-in extensions");
|
||||
|
||||
if extension.id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
search_source_registry
|
||||
.register_source(application::ApplicationSearchSource)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
|
||||
if extension.id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
search_source_registry
|
||||
.register_source(calculator_search)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_extension_built_in(extension_id: &str) -> bool {
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id.starts_with(&format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
return true;
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_built_in_extension(extension_id: &str) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let update_extension = |extension: &mut Extension| -> Result<(), String> {
|
||||
extension.enabled = true;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
search_source_registry_tauri_state
|
||||
.register_source(application::ApplicationSearchSource)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if this is an application
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::enable_app_search(tauri_app_handle, app_path)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
search_source_registry_tauri_state
|
||||
.register_source(calculator_search)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn disable_built_in_extension(extension_id: &str) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let update_extension = |extension: &mut Extension| -> Result<(), String> {
|
||||
extension.enabled = false;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if this is an application
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::disable_app_search(tauri_app_handle, app_path)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
alter_extension_json_file(
|
||||
&BUILT_IN_EXTENSION_DIRECTORY.as_path(),
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_built_in_extension_alias(extension_id: &str, alias: &str) {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::set_app_alias(tauri_app_handle, app_path, alias);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register_built_in_extension_hotkey(
|
||||
extension_id: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unregister_built_in_extension_hotkey(extension_id: &str) -> Result<(), String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_built_in_extension_enabled(extension_id: &str) -> Result<bool, String> {
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
if extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
// Check if this is an application
|
||||
let application_prefix = format!(
|
||||
"{}.",
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
);
|
||||
if extension_id.starts_with(&application_prefix) {
|
||||
let app_path = &extension_id[application_prefix.len()..];
|
||||
return Ok(application::is_app_search_enabled(app_path));
|
||||
}
|
||||
|
||||
if extension_id == calculator::DATA_SOURCE_ID {
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
if extension_id == quick_ai_access::EXTENSION_ID {
|
||||
let extension =
|
||||
load_extension_from_json_file(&BUILT_IN_EXTENSION_DIRECTORY.as_path(), extension_id)?;
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
|
||||
if extension_id == ai_overview::EXTENSION_ID {
|
||||
let extension =
|
||||
load_extension_from_json_file(&BUILT_IN_EXTENSION_DIRECTORY.as_path(), extension_id)?;
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
|
||||
unreachable!("extension [{}] is not a built-in extension", extension_id)
|
||||
}
|
||||
51
src-tauri/src/extension/built_in/pizza_engine_runtime.rs
Normal file
51
src-tauri/src/extension/built_in/pizza_engine_runtime.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! We use Pizza Engine to index applications and local files. The engine will be
|
||||
//! run in the thread/runtime defined in this file.
|
||||
//!
|
||||
//! # Why such a thread/runtime is needed
|
||||
//!
|
||||
//! Generally, Tokio async runtime requires all the async tasks running on it to be
|
||||
//! `Send` and `Sync`, but the async tasks created by Pizza Engine are not,
|
||||
//! which forces us to create a dedicated thread/runtime to execute them.
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub(crate) trait SearchSourceState {
|
||||
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
|
||||
fn as_mut_any(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub(crate) trait Task: Send + Sync {
|
||||
fn search_source_id(&self) -> &'static str;
|
||||
|
||||
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
|
||||
}
|
||||
|
||||
pub(crate) static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> =
|
||||
OnceLock::new();
|
||||
|
||||
pub(crate) fn start_pizza_engine_runtime() {
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let main = async {
|
||||
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> = HashMap::new();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
RUNTIME_TX.set(tx).unwrap();
|
||||
|
||||
while let Some(mut task) = rx.recv().await {
|
||||
let opt_search_source_state = match states.entry(task.search_source_id().into()) {
|
||||
Entry::Occupied(o) => o.into_mut(),
|
||||
Entry::Vacant(v) => v.insert(None),
|
||||
};
|
||||
task.exec(opt_search_source_state).await;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(main);
|
||||
});
|
||||
}
|
||||
1
src-tauri/src/extension/built_in/quick_ai_access.rs
Normal file
1
src-tauri/src/extension/built_in/quick_ai_access.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(super) const EXTENSION_ID: &str = "QuickAIAccess";
|
||||
825
src-tauri/src/extension/mod.rs
Normal file
825
src-tauri/src/extension/mod.rs
Normal file
@@ -0,0 +1,825 @@
|
||||
pub(crate) mod built_in;
|
||||
mod third_party;
|
||||
|
||||
use crate::common::document::OnOpened;
|
||||
use crate::{common::register::SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE};
|
||||
use anyhow::Context;
|
||||
use derive_more::Display;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use tauri::Manager;
|
||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
||||
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
enum Platform {
|
||||
#[display("macOS")]
|
||||
Macos,
|
||||
#[display("Linux")]
|
||||
Linux,
|
||||
#[display("windows")]
|
||||
Windows,
|
||||
}
|
||||
|
||||
/// Helper function to determine the current platform.
|
||||
fn current_platform() -> Platform {
|
||||
let os_str = std::env::consts::OS;
|
||||
serde_plain::from_str(os_str).unwrap_or_else(|_e| {
|
||||
panic!("std::env::consts::OS is [{}], which is not a valid value for [enum Platform], valid values: ['macos', 'linux', 'windows']", os_str)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Extension {
|
||||
/// Unique extension identifier.
|
||||
id: String,
|
||||
/// Extension name.
|
||||
title: String,
|
||||
/// Platforms supported by this extension.
|
||||
///
|
||||
/// If `None`, then this extension can be used on all the platforms.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
platforms: Option<HashSet<Platform>>,
|
||||
/// Extension description.
|
||||
description: String,
|
||||
//// Specify the icon for this extension, multi options are available:
|
||||
///
|
||||
/// 1. It can be a path to the icon file, the path can be
|
||||
///
|
||||
/// * relative (relative to the "assets" directory)
|
||||
/// * absolute
|
||||
/// 2. It can be a font class code, e.g., 'font_coco', if you want to use
|
||||
/// Coco's built-in icons.
|
||||
///
|
||||
/// In cases where your icon file is named similarly to a font class code, Coco
|
||||
/// will treat it as an icon file if it exists, i.e., if file `<extension>/assets/font_coco`
|
||||
/// exists, then Coco will use this file rather than the built-in 'font_coco' icon.
|
||||
icon: String,
|
||||
r#type: ExtensionType,
|
||||
/// If this is a Command extension, then action defines the operation to execute
|
||||
/// when the it is triggered.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
action: Option<CommandAction>,
|
||||
/// The link to open if this is a QuickLink extension.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quick_link: Option<QuickLink>,
|
||||
|
||||
// If this extension is of type Group or Extension, then it behaves like a
|
||||
// directory, i.e., it could contain sub items.
|
||||
commands: Option<Vec<Extension>>,
|
||||
scripts: Option<Vec<Extension>>,
|
||||
quick_links: Option<Vec<Extension>>,
|
||||
|
||||
/// The alias of the extension.
|
||||
///
|
||||
/// Extension of type Group and Extension cannot have alias.
|
||||
///
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alias: Option<String>,
|
||||
/// The hotkey of the extension.
|
||||
///
|
||||
/// Extension of type Group and Extension cannot have hotkey.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hotkey: Option<String>,
|
||||
|
||||
/// Is this extension enabled.
|
||||
enabled: bool,
|
||||
|
||||
/// Extension settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
settings: Option<Json>,
|
||||
}
|
||||
|
||||
impl Extension {
|
||||
/// Whether this extension could be searched.
|
||||
pub(crate) fn searchable(&self) -> bool {
|
||||
self.on_opened().is_some()
|
||||
}
|
||||
/// Return what will happen when we open this extension.
|
||||
///
|
||||
/// `None` if it cannot be opened.
|
||||
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
|
||||
match self.r#type {
|
||||
ExtensionType::Group => None,
|
||||
ExtensionType::Extension => None,
|
||||
ExtensionType::Command => Some(OnOpened::Command {
|
||||
action: self.action.clone().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
|
||||
)
|
||||
}),
|
||||
}),
|
||||
ExtensionType::Application => Some(OnOpened::Application {
|
||||
app_path: self.id.clone(),
|
||||
}),
|
||||
ExtensionType::Script => todo!("not supported yet"),
|
||||
ExtensionType::Quicklink => todo!("not supported yet"),
|
||||
ExtensionType::Setting => todo!("not supported yet"),
|
||||
ExtensionType::Calculator => None,
|
||||
ExtensionType::AiExtension => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform `how` against the extension specified by `extension_id`.
|
||||
///
|
||||
/// Please note that `extension_id` could point to a sub extension.
|
||||
pub(crate) fn modify(
|
||||
&mut self,
|
||||
extension_id: &str,
|
||||
how: impl FnOnce(&mut Self) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
assert_eq!(
|
||||
parent_extension_id, self.id,
|
||||
"modify() should be invoked against a parent extension"
|
||||
);
|
||||
|
||||
let Some(sub_extension_id) = opt_sub_extension_id else {
|
||||
how(self)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Search in commands
|
||||
if let Some(ref mut commands) = self.commands {
|
||||
if let Some(command) = commands.iter_mut().find(|cmd| cmd.id == sub_extension_id) {
|
||||
how(command)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Search in scripts
|
||||
if let Some(ref mut scripts) = self.scripts {
|
||||
if let Some(script) = scripts.iter_mut().find(|scr| scr.id == sub_extension_id) {
|
||||
how(script)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Search in quick_links
|
||||
if let Some(ref mut quick_links) = self.quick_links {
|
||||
if let Some(link) = quick_links
|
||||
.iter_mut()
|
||||
.find(|lnk| lnk.id == sub_extension_id)
|
||||
{
|
||||
how(link)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"extension [{}] not found in {:?}",
|
||||
extension_id, self
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the extension specified by `extension_id`.
|
||||
///
|
||||
/// Please note that `extension_id` could point to a sub extension.
|
||||
pub(crate) fn get_extension_mut(&mut self, extension_id: &str) -> Option<&mut Self> {
|
||||
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
if parent_extension_id != self.id {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(sub_extension_id) = opt_sub_extension_id else {
|
||||
return Some(self);
|
||||
};
|
||||
|
||||
self.get_sub_extension_mut(sub_extension_id)
|
||||
}
|
||||
|
||||
pub(crate) fn get_sub_extension_mut(&mut self, sub_extension_id: &str) -> Option<&mut Self> {
|
||||
if !self.r#type.contains_sub_items() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(ref mut commands) = self.commands {
|
||||
if let Some(sub_ext) = commands.iter_mut().find(|cmd| cmd.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref mut scripts) = self.scripts {
|
||||
if let Some(sub_ext) = scripts
|
||||
.iter_mut()
|
||||
.find(|script| script.id == sub_extension_id)
|
||||
{
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref mut quick_links) = self.quick_links {
|
||||
if let Some(sub_ext) = quick_links
|
||||
.iter_mut()
|
||||
.find(|link| link.id == sub_extension_id)
|
||||
{
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct CommandAction {
|
||||
pub(crate) exec: String,
|
||||
pub(crate) args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct QuickLink {
|
||||
link: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize, Serialize, Clone, Display)]
|
||||
#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))]
|
||||
pub enum ExtensionType {
|
||||
#[display("Group")]
|
||||
Group,
|
||||
#[display("Extension")]
|
||||
Extension,
|
||||
#[display("Command")]
|
||||
Command,
|
||||
#[display("Application")]
|
||||
Application,
|
||||
#[display("Script")]
|
||||
Script,
|
||||
#[display("Quicklink")]
|
||||
Quicklink,
|
||||
#[display("Setting")]
|
||||
Setting,
|
||||
#[display("Calculator")]
|
||||
Calculator,
|
||||
#[display("AI Extension")]
|
||||
AiExtension,
|
||||
}
|
||||
|
||||
impl ExtensionType {
|
||||
pub(crate) fn contains_sub_items(&self) -> bool {
|
||||
self == &Self::Group || self == &Self::Extension
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_relative_icon_path(
|
||||
extension_dir: &Path,
|
||||
extension: &mut Extension,
|
||||
) -> Result<(), String> {
|
||||
fn _canonicalize_relative_icon_path(
|
||||
extension_dir: &Path,
|
||||
extension: &mut Extension,
|
||||
) -> Result<(), String> {
|
||||
let icon_str = &extension.icon;
|
||||
let icon_path = Path::new(icon_str);
|
||||
|
||||
if icon_path.is_relative() {
|
||||
let absolute_icon_path = {
|
||||
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
|
||||
assets_directory.push(icon_path);
|
||||
|
||||
assets_directory
|
||||
};
|
||||
|
||||
if absolute_icon_path.try_exists().map_err(|e| e.to_string())? {
|
||||
extension.icon = absolute_icon_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
_canonicalize_relative_icon_path(extension_dir, extension)?;
|
||||
|
||||
if let Some(commands) = &mut extension.commands {
|
||||
for command in commands {
|
||||
_canonicalize_relative_icon_path(extension_dir, command)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(scripts) = &mut extension.scripts {
|
||||
for script in scripts {
|
||||
_canonicalize_relative_icon_path(extension_dir, script)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(quick_links) = &mut extension.quick_links {
|
||||
for quick_link in quick_links {
|
||||
_canonicalize_relative_icon_path(extension_dir, quick_link)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_extensions_under_directory(directory: &Path) -> Result<(bool, Vec<Extension>), String> {
|
||||
let mut found_invalid_extensions = false;
|
||||
|
||||
let extension_directory = std::fs::read_dir(&directory).map_err(|e| e.to_string())?;
|
||||
let current_platform = current_platform();
|
||||
|
||||
let mut extensions = Vec::new();
|
||||
for res_extension_dir in extension_directory {
|
||||
let extension_dir = res_extension_dir.map_err(|e| e.to_string())?;
|
||||
let file_type = extension_dir.file_type().map_err(|e| e.to_string())?;
|
||||
if !file_type.is_dir() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension [{}]: a valid extension should be a directory, but it is not",
|
||||
extension_dir.file_name().display()
|
||||
);
|
||||
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin_json_file_path = {
|
||||
let mut path = extension_dir.path();
|
||||
path.push(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
path
|
||||
};
|
||||
|
||||
if !plugin_json_file_path.is_file() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: extension file [{}] should be a JSON file, but it is not",
|
||||
extension_dir.file_name().display(),
|
||||
plugin_json_file_path.display()
|
||||
);
|
||||
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut extension = match serde_json::from_reader::<_, Extension>(
|
||||
std::fs::File::open(&plugin_json_file_path).map_err(|e| e.to_string())?,
|
||||
) {
|
||||
Ok(extension) => extension,
|
||||
Err(e) => {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: extension file [{}] is invalid, error: '{}'",
|
||||
extension_dir.file_name().display(),
|
||||
plugin_json_file_path.display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
||||
canonicalize_relative_icon_path(&extension_dir.path(), &mut extension)?;
|
||||
|
||||
if !validate_extension(
|
||||
&extension,
|
||||
&extension_dir.file_name(),
|
||||
&extensions,
|
||||
current_platform,
|
||||
) {
|
||||
found_invalid_extensions = true;
|
||||
// Skip invalid extension
|
||||
continue;
|
||||
}
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"loaded extensions: {:?}",
|
||||
extensions
|
||||
.iter()
|
||||
.map(|ext| ext.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
Ok((found_invalid_extensions, extensions))
|
||||
}
|
||||
|
||||
/// Return value:
|
||||
///
|
||||
/// * boolean: indicates if we found any invalid extensions
|
||||
/// * Vec<Extension>: loaded extensions
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_extensions() -> Result<(bool, Vec<Extension>), String> {
|
||||
log::trace!("loading extensions");
|
||||
|
||||
let third_party_dir = third_party::THIRD_PARTY_EXTENSION_DIRECTORY.as_path();
|
||||
if !third_party_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
tokio::fs::create_dir_all(third_party_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let (third_party_found_invalid_extension, mut third_party_extensions) =
|
||||
list_extensions_under_directory(third_party_dir)?;
|
||||
|
||||
let built_in_dir = built_in::BUILT_IN_EXTENSION_DIRECTORY.as_path();
|
||||
let (built_in_found_invalid_extension, built_in_extensions) =
|
||||
list_extensions_under_directory(built_in_dir)?;
|
||||
|
||||
let found_invalid_extension =
|
||||
third_party_found_invalid_extension || built_in_found_invalid_extension;
|
||||
let extensions = {
|
||||
third_party_extensions.extend(built_in_extensions);
|
||||
|
||||
third_party_extensions
|
||||
};
|
||||
|
||||
Ok((found_invalid_extension, extensions))
|
||||
}
|
||||
|
||||
/// Helper function to validate `extension`, return `true` if it is valid.
|
||||
fn validate_extension(
|
||||
extension: &Extension,
|
||||
extension_dir_name: &OsStr,
|
||||
listed_extensions: &[Extension],
|
||||
current_platform: Platform,
|
||||
) -> bool {
|
||||
if OsStr::new(&extension.id) != extension_dir_name {
|
||||
log::warn!(
|
||||
"invalid extension []: id [{}] and extension directory name [{}] do not match",
|
||||
extension.id,
|
||||
extension_dir_name.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension ID should be unique
|
||||
if listed_extensions.iter().any(|ext| ext.id == extension.id) {
|
||||
log::warn!(
|
||||
"invalid extension []: extension with id [{}] already exists",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !validate_extension_or_sub_item(extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extension is incompatible
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
log::warn!("extension [{}] is not compatible with the current platform [{}], it is available to {:?}", extension.id, current_platform, platforms.iter().map(|os|os.to_string()).collect::<Vec<_>>());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if !validate_sub_items(&extension.id, commands) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
if !validate_sub_items(&extension.id, scripts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref quick_links) = extension.quick_links {
|
||||
if !validate_sub_items(&extension.id, quick_links) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks that can be performed against an extension or a sub item.
|
||||
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
|
||||
// Only
|
||||
//
|
||||
// 1. letters
|
||||
// 2. hyphens
|
||||
// 3. numbers
|
||||
//
|
||||
// are allowed in the ID.
|
||||
if !extension
|
||||
.id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphabetic() || c == '-')
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], [id] should contain only letters, numbers, or hyphens",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [action] is set for a non-Command extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Command && extension.action.is_none() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [action] should be set for a Command extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If field `quick_link` is Some, then it should be a QuickLink
|
||||
if extension.quick_link.is_some() && extension.r#type != ExtensionType::Quicklink {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quick_link] is set for a non-QuickLink extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if extension.r#type == ExtensionType::Quicklink && extension.quick_link.is_none() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], [quick_link] should be set for a QuickLink extension",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Group and Extension cannot have alias
|
||||
if extension.alias.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have alias",
|
||||
extension.id,
|
||||
extension.r#type
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Group and Extension cannot have hotkey
|
||||
if extension.hotkey.is_some() {
|
||||
if extension.r#type == ExtensionType::Group || extension.r#type == ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], extension of type [{:?}] cannot have hotkey",
|
||||
extension.id,
|
||||
extension.r#type
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if extension.commands.is_some()
|
||||
|| extension.scripts.is_some()
|
||||
|| extension.quick_links.is_some()
|
||||
{
|
||||
if extension.r#type != ExtensionType::Group && extension.r#type != ExtensionType::Extension
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], only extension of type [Group] and [Extension] can have sub-items",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Helper function to check sub-items.
|
||||
fn validate_sub_items(extension_id: &str, sub_items: &[Extension]) -> bool {
|
||||
for (sub_item_index, sub_item) in sub_items.iter().enumerate() {
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if sub_item.action.is_some() && sub_item.r#type != ExtensionType::Command {
|
||||
log::warn!(
|
||||
"invalid extension sub-item [{}-{}]: [action] is set for a non-Command extension",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if sub_item.r#type == ExtensionType::Group || sub_item.r#type == ExtensionType::Extension {
|
||||
log::warn!(
|
||||
"invalid extension sub-item [{}-{}]: sub-item should not be of type [Group] or [Extension]",
|
||||
extension_id, sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let sub_item_with_same_id_count = sub_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_idx, ext)| ext.id == sub_item.id)
|
||||
.filter(|(idx, _ext)| *idx != sub_item_index)
|
||||
.count();
|
||||
if sub_item_with_same_id_count != 0 {
|
||||
log::warn!(
|
||||
"invalid extension [{}]: found more than one sub-items with the same ID [{}]",
|
||||
extension_id,
|
||||
sub_item.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !validate_extension_or_sub_item(sub_item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if sub_item.platforms.is_some() {
|
||||
log::warn!(
|
||||
"invalid extension [{}]: key [platforms] should not be set in sub-items",
|
||||
extension_id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<(), String> {
|
||||
log::trace!("initializing extensions");
|
||||
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let search_source_registry_tauri_state = tauri_app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
built_in::application::ApplicationSearchSource::init(tauri_app_handle.clone()).await?;
|
||||
|
||||
// Init the built-in enabled extensions
|
||||
for built_in_extension in extensions
|
||||
.extract_if(.., |ext| built_in::is_extension_built_in(&ext.id))
|
||||
.filter(|ext| ext.enabled)
|
||||
{
|
||||
built_in::init_built_in_extension(&built_in_extension, &search_source_registry_tauri_state)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Now the third-party extensions
|
||||
let third_party_search_source = third_party::ThirdPartyExtensionsSearchSource::new(extensions);
|
||||
third_party_search_source
|
||||
.restore_extensions_hotkey()
|
||||
.await?;
|
||||
let third_party_search_source_clone = third_party_search_source.clone();
|
||||
// Set the global search source so that we can access it in `#[tauri::command]`s
|
||||
// ignore the result because this function will be invoked twice, which
|
||||
// means this global variable will be set twice.
|
||||
let _ = THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.set(third_party_search_source_clone);
|
||||
search_source_registry_tauri_state
|
||||
.register_source(third_party_search_source)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn enable_extension(extension_id: String) -> Result<(), String> {
|
||||
println!("enable_extension: {}", extension_id);
|
||||
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::enable_built_in_extension(&extension_id).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").enable_extension(&extension_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn disable_extension(extension_id: String) -> Result<(), String> {
|
||||
println!("disable_extension: {}", extension_id);
|
||||
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::disable_built_in_extension(&extension_id).await?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").disable_extension(&extension_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn set_extension_alias(extension_id: String, alias: String) -> Result<(), String> {
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::set_built_in_extension_alias(&extension_id, &alias);
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&extension_id, &alias).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn register_extension_hotkey(
|
||||
extension_id: String,
|
||||
hotkey: String,
|
||||
) -> Result<(), String> {
|
||||
println!("register_extension_hotkey: {}, {}", extension_id, hotkey);
|
||||
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::register_built_in_extension_hotkey(&extension_id, &hotkey)?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").register_extension_hotkey(&extension_id, &hotkey).await
|
||||
}
|
||||
|
||||
/// NOTE: this function won't error out if the extension specified by `extension_id`
|
||||
/// has no hotkey set because we need it to behave like this.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn unregister_extension_hotkey(extension_id: String) -> Result<(), String> {
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
built_in::unregister_built_in_extension_hotkey(&extension_id)?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").unregister_extension_hotkey(&extension_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn is_extension_enabled(extension_id: String) -> Result<bool, String> {
|
||||
if built_in::is_extension_built_in(&extension_id) {
|
||||
return built_in::is_built_in_extension_enabled(&extension_id).await;
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").is_extension_enabled(&extension_id).await
|
||||
}
|
||||
|
||||
fn split_extension_id(extension_id: &str) -> (&str, Option<&str>) {
|
||||
match extension_id.find('.') {
|
||||
Some(idx) => (&extension_id[..idx], Some(&extension_id[idx + 1..])),
|
||||
None => (extension_id, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_extension_from_json_file(
|
||||
extension_directory: &Path,
|
||||
extension_id: &str,
|
||||
) -> Result<Extension, String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
let json_file_path = {
|
||||
let mut extension_directory_path = extension_directory.join(parent_extension_id);
|
||||
extension_directory_path.push(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
extension_directory_path
|
||||
};
|
||||
|
||||
let mut extension = serde_json::from_reader::<_, Extension>(
|
||||
std::fs::File::open(&json_file_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the [{}] file for extension [{}] is missing or broken",
|
||||
PLUGIN_JSON_FILE_NAME, parent_extension_id
|
||||
)
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
canonicalize_relative_icon_path(extension_directory, &mut extension)?;
|
||||
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
fn alter_extension_json_file(
|
||||
extension_directory: &Path,
|
||||
extension_id: &str,
|
||||
how: impl Fn(&mut Extension) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
log::debug!(
|
||||
"altering extension JSON file for extension [{}]",
|
||||
extension_id
|
||||
);
|
||||
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
let json_file_path = {
|
||||
let mut extension_directory_path = extension_directory.join(parent_extension_id);
|
||||
extension_directory_path.push(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
extension_directory_path
|
||||
};
|
||||
|
||||
let mut extension = serde_json::from_reader::<_, Extension>(
|
||||
std::fs::File::open(&json_file_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the [{}] file for extension [{}] is missing or broken",
|
||||
PLUGIN_JSON_FILE_NAME, parent_extension_id
|
||||
)
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
extension.modify(extension_id, how)?;
|
||||
|
||||
std::fs::write(
|
||||
&json_file_path,
|
||||
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
733
src-tauri/src/extension/third_party.rs
Normal file
733
src-tauri/src/extension/third_party.rs
Normal file
@@ -0,0 +1,733 @@
|
||||
use super::alter_extension_json_file;
|
||||
use super::Extension;
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::common::document::open;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::common::document::Document;
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::QueryResponse;
|
||||
use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::split_extension_id;
|
||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||
use async_trait::async_trait;
|
||||
use function_name::named;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::async_runtime;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub(crate) static THIRD_PARTY_EXTENSION_DIRECTORY: LazyLock<PathBuf> = LazyLock::new(|| {
|
||||
let mut app_data_dir = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set")
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect(
|
||||
"User home directory not found, which should be impossible on desktop environments",
|
||||
);
|
||||
app_data_dir.push("extension");
|
||||
|
||||
app_data_dir
|
||||
});
|
||||
|
||||
/// All the third-party extensions will be registered as one search source.
|
||||
///
|
||||
/// Since some `#[tauri::command]`s need to access it, we store it in a global
|
||||
/// static variable as well.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ThirdPartyExtensionsSearchSource {
|
||||
inner: Arc<ThirdPartyExtensionsSearchSourceInner>,
|
||||
}
|
||||
|
||||
impl ThirdPartyExtensionsSearchSource {
|
||||
pub(super) fn new(extensions: Vec<Extension>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ThirdPartyExtensionsSearchSourceInner {
|
||||
extensions: RwLock::new(extensions),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn enable_extension(&self, extension_id: &str) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
if ext.enabled {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that is already enabled [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
}
|
||||
ext.enabled = true;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn disable_extension(&self, extension_id: &str) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
if !ext.enabled {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that is already enabled [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
}
|
||||
ext.enabled = false;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn set_extension_alias(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
alias: &str,
|
||||
) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
log::warn!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
ext.alias = Some(alias.to_string());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn restore_extensions_hotkey(&self) -> Result<(), String> {
|
||||
fn set_up_hotkey<R: tauri::Runtime>(
|
||||
tauri_app_handle: &tauri::AppHandle<R>,
|
||||
extension: &Extension,
|
||||
) -> Result<(), String> {
|
||||
if let Some(ref hotkey) = extension.hotkey {
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
|
||||
|
||||
let extension_id_clone = extension.id.clone();
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey.as_str(), move |_tauri_app_handle, _hotkey, event| {
|
||||
let on_opened_clone = on_opened.clone();
|
||||
let extension_id_clone = extension_id_clone.clone();
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(on_opened_clone).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
extension_id_clone,
|
||||
msg
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
|
||||
for extension in extensions_read_lock.iter() {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
if let Some(commands) = &extension.commands {
|
||||
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
||||
set_up_hotkey(tauri_app_handle, command)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(scripts) = &extension.scripts {
|
||||
for script in scripts.iter().filter(|script| script.enabled) {
|
||||
set_up_hotkey(tauri_app_handle, script)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(quick_links) = &extension.quick_links {
|
||||
for quick_link in quick_links.iter().filter(|link| link.enabled) {
|
||||
set_up_hotkey(tauri_app_handle, quick_link)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set_up_hotkey(tauri_app_handle, extension)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn register_extension_hotkey(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
self.unregister_extension_hotkey(extension_id).await?;
|
||||
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let mut extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
let update_extension = |ext: &mut Extension| -> Result<(), String> {
|
||||
ext.hotkey = Some(hotkey.into());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Update extension (memory and file)
|
||||
extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
// To make borrow checker happy
|
||||
let extension_dbg_string = format!("{:?}", extension);
|
||||
extension = match extension.get_extension_mut(extension_id) {
|
||||
Some(ext) => ext,
|
||||
None => {
|
||||
panic!(
|
||||
"extension [{}] should be found in {}",
|
||||
extension_id, extension_dbg_string
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Set hotkey
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
|
||||
"setting hotkey for an extension that cannot be opened, extension ID [{}], extension type [{:?}]", extension_id, extension.r#type,
|
||||
));
|
||||
|
||||
let extension_id_clone = extension_id.to_string();
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey, move |_tauri_app_handle, _hotkey, event| {
|
||||
let on_opened_clone = on_opened.clone();
|
||||
let extension_id_clone = extension_id_clone.clone();
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(on_opened_clone).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
extension_id_clone,
|
||||
msg
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// NOTE: this function won't error out if the extension specified by `extension_id`
|
||||
/// has no hotkey set because we need it to behave like this.
|
||||
#[named]
|
||||
pub(super) async fn unregister_extension_hotkey(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let (parent_extension_id, _opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let mut extensions_write_lock = self.inner.extensions.write().await;
|
||||
let opt_index = extensions_write_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let parent_extension = extensions_write_lock
|
||||
.get_mut(index)
|
||||
.expect("just checked this extension exists");
|
||||
let Some(extension) = parent_extension.get_extension_mut(extension_id) else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let Some(hotkey) = extension.hotkey.clone() else {
|
||||
log::warn!(
|
||||
"extension [{}] has no hotkey set, but we are trying to unregister it",
|
||||
extension_id
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let update_extension = |extension: &mut Extension| -> Result<(), String> {
|
||||
extension.hotkey = None;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
parent_extension.modify(extension_id, update_extension)?;
|
||||
alter_extension_json_file(
|
||||
&THIRD_PARTY_EXTENSION_DIRECTORY,
|
||||
extension_id,
|
||||
update_extension,
|
||||
)?;
|
||||
|
||||
// Set hotkey
|
||||
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||
.get()
|
||||
.expect("global tauri app handle not set");
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.unregister(hotkey.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[named]
|
||||
pub(super) async fn is_extension_enabled(&self, extension_id: &str) -> Result<bool, String> {
|
||||
let (parent_extension_id, opt_sub_extension_id) = split_extension_id(extension_id);
|
||||
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
let opt_index = extensions_read_lock
|
||||
.iter()
|
||||
.position(|ext| ext.id == parent_extension_id);
|
||||
|
||||
let Some(index) = opt_index else {
|
||||
return Err(format!(
|
||||
"{} invoked with an extension that does not exist [{}]",
|
||||
function_name!(),
|
||||
extension_id
|
||||
));
|
||||
};
|
||||
|
||||
let extension = extensions_read_lock
|
||||
.get(index)
|
||||
.expect("just checked this extension exists");
|
||||
|
||||
if let Some(sub_extension_id) = opt_sub_extension_id {
|
||||
// For a sub-extension, it is enabled iff:
|
||||
//
|
||||
// 1. Its parent extension is enabled, and
|
||||
// 2. It is enabled
|
||||
if !extension.enabled {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if let Some(sub_ext) = commands.iter().find(|cmd| cmd.id == sub_extension_id) {
|
||||
return Ok(sub_ext.enabled);
|
||||
}
|
||||
}
|
||||
if let Some(ref scripts) = extension.scripts {
|
||||
if let Some(sub_ext) = scripts.iter().find(|script| script.id == sub_extension_id) {
|
||||
return Ok(sub_ext.enabled);
|
||||
}
|
||||
}
|
||||
if let Some(ref commands) = extension.commands {
|
||||
if let Some(sub_ext) = commands
|
||||
.iter()
|
||||
.find(|quick_link| quick_link.id == sub_extension_id)
|
||||
{
|
||||
return Ok(sub_ext.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"{} invoked with a sub-extension that does not exist [{}/{}]",
|
||||
function_name!(),
|
||||
parent_extension_id,
|
||||
sub_extension_id
|
||||
))
|
||||
} else {
|
||||
Ok(extension.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
|
||||
OnceLock::new();
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ThirdPartyExtensionsSearchSourceInner {
|
||||
extensions: RwLock<Vec<Extension>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or("My Computer".into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: "extensions".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let Some(query_string) = query.query_strings.get("query") else {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let mut hits = Vec::new();
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
let query_lower = query_string.to_lowercase();
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
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) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref quick_links) = extension.quick_links {
|
||||
for quick_link in quick_links.iter().filter(|link| link.enabled) {
|
||||
if let Some(hit) = extension_to_hit(quick_link, &query_lower) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(hit) = extension_to_hit(extension, &query_lower) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_hits = hits.len();
|
||||
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_to_hit(extension: &Extension, query_lower: &str) -> Option<(Document, f64)> {
|
||||
if !extension.searchable() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut total_score = 0.0;
|
||||
|
||||
// Score based on title match
|
||||
// Title is considered more important, so it gets a higher weight.
|
||||
if let Some(title_score) =
|
||||
calculate_text_similarity(&query_lower, &extension.title.to_lowercase())
|
||||
{
|
||||
total_score += title_score * 1.0; // Weight for title
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Only include if there's some relevance (score is meaningfully positive)
|
||||
if total_score > 0.01 {
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"extension (id [{}], type [{:?}]) is searchable, and should have a valid on_opened",
|
||||
extension.id, extension.r#type
|
||||
)
|
||||
});
|
||||
let url = on_opened.url();
|
||||
|
||||
let document = Document {
|
||||
id: extension.id.clone(),
|
||||
title: Some(extension.title.clone()),
|
||||
icon: Some(extension.icon.clone()),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
category: Some(extension.r#type.to_string()),
|
||||
source: Some(DataSourceReference {
|
||||
id: Some(format!("{:?}", extension.r#type)),
|
||||
name: Some(format!("{:?}", extension.r#type)),
|
||||
icon: None,
|
||||
r#type: Some(format!("{:?}", extension.r#type)),
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Some((document, total_score))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||
// Assumes query and text are already lowercased.
|
||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||
if query.is_empty() || text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if text == query {
|
||||
return Some(1.0); // Perfect match
|
||||
}
|
||||
|
||||
let query_len = query.len() as f64;
|
||||
let text_len = text.len() as f64;
|
||||
let ratio = query_len / text_len;
|
||||
let mut score: f64 = 0.0;
|
||||
|
||||
// Case 1: Text starts with the query (prefix match)
|
||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||
if text.starts_with(query) {
|
||||
score = score.max(0.5 + 0.4 * ratio);
|
||||
}
|
||||
|
||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||
if text.contains(query) {
|
||||
score = score.max(0.3 + 0.3 * ratio);
|
||||
}
|
||||
|
||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||
if score < 0.2 {
|
||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||
Some(score.min(0.95))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Helper function for approximate floating point comparison
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||
assert_eq!(calculate_text_similarity("", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_perfect_match() {
|
||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_match() {
|
||||
// For "te" and "text":
|
||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||
let score = calculate_text_similarity("te", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.7));
|
||||
|
||||
// For "tex" and "text":
|
||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_match() {
|
||||
// For "ex" and "text":
|
||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.45));
|
||||
|
||||
// Prefix should score higher than substring
|
||||
assert!(
|
||||
calculate_text_similarity("te", "text").unwrap()
|
||||
> calculate_text_similarity("ex", "text").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_presence() {
|
||||
// Characters present but not in sequence
|
||||
// "tac" in "contact" - not a substring, but all chars exist
|
||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||
|
||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||
|
||||
// Should not apply if some characters are missing
|
||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_scenarios() {
|
||||
// Test that character presence fallback doesn't override higher scores
|
||||
// "tex" is a prefix of "text" with score 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
|
||||
// Test a case where the characters exist but it's already a substring
|
||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_similarity() {
|
||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_capping() {
|
||||
// Use a long query that's a prefix of a slightly longer text
|
||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||
|
||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
|
||||
// Verify that non-perfect matches are capped at 0.95
|
||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
mod assistant;
|
||||
mod autostart;
|
||||
mod common;
|
||||
mod local;
|
||||
mod extension;
|
||||
mod search;
|
||||
mod server;
|
||||
mod settings;
|
||||
@@ -142,25 +142,24 @@ pub fn run() {
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription,
|
||||
util::open,
|
||||
server::system_settings::get_system_settings,
|
||||
simulate_mouse_click,
|
||||
local::get_disabled_local_query_sources,
|
||||
local::enable_local_query_source,
|
||||
local::disable_local_query_source,
|
||||
local::application::get_app_list,
|
||||
local::application::get_app_search_path,
|
||||
local::application::get_app_metadata,
|
||||
local::application::set_app_alias,
|
||||
local::application::register_app_hotkey,
|
||||
local::application::unregister_app_hotkey,
|
||||
local::application::disable_app_search,
|
||||
local::application::enable_app_search,
|
||||
local::application::add_app_search_path,
|
||||
local::application::remove_app_search_path,
|
||||
extension::built_in::application::get_app_list,
|
||||
extension::built_in::application::get_app_search_path,
|
||||
extension::built_in::application::get_app_metadata,
|
||||
extension::built_in::application::add_app_search_path,
|
||||
extension::built_in::application::remove_app_search_path,
|
||||
extension::list_extensions,
|
||||
extension::enable_extension,
|
||||
extension::disable_extension,
|
||||
extension::set_extension_alias,
|
||||
extension::register_extension_hotkey,
|
||||
extension::unregister_extension_hotkey,
|
||||
extension::is_extension_enabled,
|
||||
settings::set_allow_self_signature,
|
||||
settings::get_allow_self_signature,
|
||||
assistant::ask_ai
|
||||
assistant::ask_ai,
|
||||
crate::common::document::open,
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -262,7 +261,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
.await;
|
||||
}
|
||||
|
||||
local::start_pizza_engine_runtime();
|
||||
extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -418,7 +417,11 @@ fn open_settings(app: &tauri::AppHandle) {
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
|
||||
local::init_local_search_source(&app_handle).await?;
|
||||
let (_found_invalid_extensions, extensions) = extension::list_extensions()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
extension::init_extensions(extensions).await?;
|
||||
|
||||
let _ = server::connector::refresh_all_connectors(&app_handle).await;
|
||||
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
pub mod file_system;
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use serde_json::Value as Json;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
pub const TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE: &str = "local_query_source_enabled_state";
|
||||
|
||||
trait SearchSourceState {
|
||||
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
|
||||
fn as_mut_any(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
trait Task: Send + Sync {
|
||||
fn search_source_id(&self) -> &'static str;
|
||||
|
||||
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
|
||||
}
|
||||
|
||||
static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> = OnceLock::new();
|
||||
|
||||
pub(crate) fn start_pizza_engine_runtime() {
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let main = async {
|
||||
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> = HashMap::new();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
RUNTIME_TX.set(tx).unwrap();
|
||||
|
||||
while let Some(mut task) = rx.recv().await {
|
||||
let opt_search_source_state = match states.entry(task.search_source_id().into()) {
|
||||
Entry::Occupied(o) => o.into_mut(),
|
||||
Entry::Vacant(v) => v.insert(None),
|
||||
};
|
||||
task.exec(opt_search_source_state).await;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(main);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn init_local_search_source<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if enabled_status_store.is_empty() {
|
||||
enabled_status_store.set(
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
|
||||
Json::Bool(true),
|
||||
);
|
||||
enabled_status_store.set(calculator::DATA_SOURCE_ID, Json::Bool(true));
|
||||
}
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
application::ApplicationSearchSource::init(app_handle.clone()).await?;
|
||||
|
||||
for (id, enabled) in enabled_status_store.entries() {
|
||||
let enabled = match enabled {
|
||||
Json::Bool(b) => b,
|
||||
_ => unreachable!("enabled state should be stored as a boolean"),
|
||||
};
|
||||
|
||||
if enabled {
|
||||
if id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
registry
|
||||
.register_source(application::ApplicationSearchSource)
|
||||
.await;
|
||||
}
|
||||
|
||||
if id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
registry.register_source(calculator_search).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_disabled_local_query_sources<R: Runtime>(app_handle: AppHandle<R>) -> Vec<String> {
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
let mut disabled_local_query_sources = Vec::new();
|
||||
|
||||
for (id, enabled) in enabled_status_store.entries() {
|
||||
let enabled = match enabled {
|
||||
Json::Bool(b) => b,
|
||||
_ => unreachable!("enabled state should be stored as a boolean"),
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
disabled_local_query_sources.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
disabled_local_query_sources
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_local_query_source<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query_source_id: String,
|
||||
) {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
if query_source_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
let application_search = application::ApplicationSearchSource;
|
||||
registry.register_source(application_search).await;
|
||||
}
|
||||
if query_source_id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
registry.register_source(calculator_search).await;
|
||||
}
|
||||
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
enabled_status_store.set(query_source_id, Json::Bool(true));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_local_query_source<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query_source_id: String,
|
||||
) {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.remove_source(&query_source_id).await;
|
||||
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
enabled_status_store.set(query_source_id, Json::Bool(false));
|
||||
}
|
||||
@@ -3,7 +3,6 @@ use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::local;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use std::cmp::Reverse;
|
||||
@@ -20,7 +19,10 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
let query_keyword = query_strings.get("query").unwrap_or(&"".to_string()).clone();
|
||||
let query_keyword = query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone();
|
||||
|
||||
let query_source_to_search = query_strings.get("querysource");
|
||||
|
||||
@@ -28,7 +30,6 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
|
||||
let sources_future = search_sources.get_sources();
|
||||
let mut futures = FuturesUnordered::new();
|
||||
let mut sources = HashMap::new();
|
||||
|
||||
let sources_list = sources_future.await;
|
||||
|
||||
@@ -52,8 +53,6 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
sources.insert(query_source_type.id.clone(), query_source_type);
|
||||
|
||||
let query = SearchQuery::new(from, size, query_strings.clone());
|
||||
let query_source_clone = query_source.clone(); // Clone Arc to avoid ownership issues
|
||||
|
||||
@@ -62,7 +61,7 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
timeout(timeout_duration, async {
|
||||
query_source_clone.search(query).await
|
||||
})
|
||||
.await
|
||||
.await
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -159,23 +158,22 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
let mut unique_sources = HashSet::new();
|
||||
for hit in &final_hits {
|
||||
if let Some(source) = &hit.source {
|
||||
if source.id != local::calculator::DATA_SOURCE_ID {
|
||||
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
|
||||
);
|
||||
"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()
|
||||
@@ -184,7 +182,7 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
let source = hit.source.as_ref()?;
|
||||
let title = hit.document.title.as_deref()?;
|
||||
|
||||
if source.id != local::calculator::DATA_SOURCE_ID {
|
||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||
Some((idx, title))
|
||||
} else {
|
||||
None
|
||||
@@ -203,7 +201,8 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
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
|
||||
} 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();
|
||||
|
||||
@@ -275,4 +274,4 @@ fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(u
|
||||
(idx, score.min(1.0) as f64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::common::document::Document;
|
||||
use crate::common::document::{Document, OnOpened};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
|
||||
@@ -45,7 +45,7 @@ impl DocumentsSizedCollector {
|
||||
}
|
||||
}
|
||||
|
||||
fn documents(self) -> impl ExactSizeIterator<Item=Document> {
|
||||
fn documents(self) -> impl ExactSizeIterator<Item = Document> {
|
||||
self.docs.into_iter().map(|(_, doc, _)| doc)
|
||||
}
|
||||
|
||||
@@ -103,11 +103,7 @@ impl SearchSource for CocoSearchSource {
|
||||
query_args.insert(key, JsonValue::String(value));
|
||||
}
|
||||
|
||||
let response = HttpClient::get(
|
||||
&self.server.id,
|
||||
&url,
|
||||
Some(query_args),
|
||||
)
|
||||
let response = HttpClient::get(&self.server.id, &url, Some(query_args))
|
||||
.await
|
||||
.map_err(|e| SearchError::HttpError(format!("{}", e)))?;
|
||||
|
||||
@@ -116,7 +112,6 @@ impl SearchSource for CocoSearchSource {
|
||||
.await
|
||||
.map_err(|e| SearchError::ParseError(e))?;
|
||||
|
||||
|
||||
// Check if the response body is empty
|
||||
if !response_body.is_empty() {
|
||||
// Parse the search response from the body text
|
||||
@@ -125,14 +120,21 @@ impl SearchSource for CocoSearchSource {
|
||||
|
||||
// Process the parsed response
|
||||
total_hits = parsed.hits.total.value as usize;
|
||||
hits = parsed
|
||||
.hits
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
|
||||
.collect();
|
||||
}
|
||||
for hit in parsed.hits.hits {
|
||||
let mut document = hit._source;
|
||||
// Default _score to 0.0 if None
|
||||
let score = hit._score.unwrap_or(0.0);
|
||||
|
||||
let on_opened = document
|
||||
.url
|
||||
.as_ref()
|
||||
.map(|url| OnOpened::Document { url: url.clone() });
|
||||
// Set the `on_opened` field as it won't be returned from Coco server
|
||||
document.on_opened = on_opened;
|
||||
|
||||
hits.push((document, score));
|
||||
}
|
||||
}
|
||||
|
||||
// Return the final result
|
||||
Ok(QueryResponse {
|
||||
|
||||
@@ -67,7 +67,6 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
||||
//
|
||||
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
||||
#[allow(deprecated)]
|
||||
#[tauri::command]
|
||||
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let borrowed_path = Path::new(&path);
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"url": "/ui/settings",
|
||||
"width": 1000,
|
||||
"height": 700,
|
||||
"minHeight": 700,
|
||||
"minWidth": 1000,
|
||||
"center": true,
|
||||
"transparent": true,
|
||||
"maximizable": false,
|
||||
@@ -105,7 +107,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": ["assets", "icons"]
|
||||
"resources": ["assets/**/*", "icons"]
|
||||
},
|
||||
"plugins": {
|
||||
"features": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { ChevronDownIcon, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isNil } from "lodash-es";
|
||||
@@ -16,6 +16,7 @@ import PopoverInput from "@/components/Common/PopoverInput";
|
||||
import { AssistantFetcher } from "./AssistantFetcher";
|
||||
import AssistantItem from "./AssistantItem";
|
||||
import Pagination from "@/components/Common/Pagination";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
|
||||
interface AssistantListProps {
|
||||
assistantIDs?: string[];
|
||||
@@ -37,6 +38,11 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
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,
|
||||
@@ -62,6 +68,19 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
|
||||
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiAssistantId || assistantList.length === 0) return;
|
||||
|
||||
const matched = assistantList.find((item) => {
|
||||
return item._id === askAiAssistantId;
|
||||
});
|
||||
|
||||
if (!matched) return;
|
||||
|
||||
setCurrentAssistant(matched);
|
||||
setAskAiAssistantId(void 0);
|
||||
}, [assistantList, askAiAssistantId]);
|
||||
|
||||
useKeyPress(
|
||||
["uparrow", "downarrow", "enter"],
|
||||
(event, key) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { COPY_BUTTON_ID } from "@/constants";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
@@ -14,6 +15,8 @@ interface MessageActionsProps {
|
||||
id: string;
|
||||
content: string;
|
||||
question?: string;
|
||||
actionClassName?: string;
|
||||
actionIconSize?: number;
|
||||
onResend?: () => void;
|
||||
}
|
||||
|
||||
@@ -23,6 +26,8 @@ export const MessageActions = ({
|
||||
id,
|
||||
content,
|
||||
question,
|
||||
actionClassName,
|
||||
actionIconSize,
|
||||
onResend,
|
||||
}: MessageActionsProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -89,7 +94,7 @@ export const MessageActions = ({
|
||||
const goAskAi = useSearchStore((state) => state.goAskAi);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<div className={clsx("flex items-center gap-1 mt-2", actionClassName)}>
|
||||
{!isRefreshOnly && (
|
||||
<button
|
||||
id={goAskAi ? COPY_BUTTON_ID : ""}
|
||||
@@ -97,9 +102,21 @@ export const MessageActions = ({
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" />
|
||||
<Check
|
||||
className="w-4 h-4 text-[#38C200] dark:text-[#38C200]"
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]" />
|
||||
<Copy
|
||||
className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]"
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -116,6 +133,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
@@ -132,6 +153,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
@@ -146,6 +171,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
@@ -162,6 +191,10 @@ export const MessageActions = ({
|
||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||
}`}
|
||||
style={{
|
||||
width: actionIconSize,
|
||||
height: actionIconSize,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,9 @@ interface ChatMessageProps {
|
||||
onResend?: (value: string) => void;
|
||||
loadingStep?: Record<string, boolean>;
|
||||
hide_assistant?: boolean;
|
||||
rootClassName?: string;
|
||||
actionClassName?: string;
|
||||
actionIconSize?: number;
|
||||
}
|
||||
|
||||
export const ChatMessage = memo(function ChatMessage({
|
||||
@@ -45,6 +48,9 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
onResend,
|
||||
loadingStep,
|
||||
hide_assistant = false,
|
||||
rootClassName,
|
||||
actionClassName,
|
||||
actionIconSize,
|
||||
}: ChatMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -144,6 +150,8 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
id={message._id}
|
||||
content={messageContent || response?.message_chunk || ""}
|
||||
question={question}
|
||||
actionClassName={actionClassName}
|
||||
actionIconSize={actionIconSize}
|
||||
onResend={() => {
|
||||
onResend && onResend(question);
|
||||
}}
|
||||
@@ -166,7 +174,8 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
[isAssistant ? "justify-start" : "justify-end"],
|
||||
{
|
||||
hidden: visibleStartPage,
|
||||
}
|
||||
},
|
||||
rootClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
interface AiSummaryIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const AiSummaryIcon: FC<AiSummaryIconProps> = (props) => {
|
||||
const { size = 16, color } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<title>编组 3</title>
|
||||
<g
|
||||
id="AI-搜索"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g id="ai--总结-回答2" transform="translate(-15, -63)">
|
||||
<g id="编组-3" transform="translate(15, 63)">
|
||||
<g
|
||||
id="aimofabang"
|
||||
transform="translate(1, 0)"
|
||||
fill={color}
|
||||
fill-rule="nonzero"
|
||||
>
|
||||
<path
|
||||
d="M3.08348835,0.285085421 C3.00233182,-0.0950284736 2.440033,-0.0950284736 2.35887647,0.285085421 C2.18061805,1.12428797 1.50051915,1.78010315 0.630242361,1.95199665 C0.236053497,2.03025539 0.236053497,2.57359466 0.630242361,2.65073542 C1.49977662,2.82290466 2.18149148,3.47916012 2.35887647,4.31876463 C2.440033,4.69887852 3.0034912,4.69887852 3.08348835,4.31876463 C3.26150444,3.47881148 3.94222666,2.82239524 4.81328184,2.65073542 C5.20747071,2.57247668 5.20747071,2.03025539 4.81328184,1.95199665 C3.94288543,1.78066611 3.26233034,1.12529073 3.08348835,0.286203403 L3.08348835,0.285085421 Z M11.9295502,2.98277609 C11.326033,2.40089874 10.347638,2.40089874 9.74412078,2.98277609 L8.37953169,4.30087692 L8.35054721,4.32882647 L10.5359766,6.4373406 L10.5649611,6.40827307 L11.931869,5.09129022 C12.5347461,4.50993956 12.5347461,3.56524473 11.931869,2.98277609 L11.9295502,2.98277609 Z M9.71513631,7.2277539 L7.52854749,5.11923977 L0.452857372,11.9422842 C-0.150952457,12.5245343 -0.150952457,13.4685482 0.452857372,14.0507983 C1.0566672,14.6330484 2.03563636,14.6330484 2.63944619,14.0507983 L9.71513631,7.2277539 Z M11.5875334,10.9808196 C11.5933303,10.957342 11.6281117,10.957342 11.6339086,10.9808196 C11.8982395,12.228618 12.9092096,13.2039302 14.2030925,13.4593858 C14.2285988,13.4649757 14.2285988,13.4985152 14.2030925,13.5041051 C12.9095391,13.7593961 11.8986526,14.7341878 11.6339086,15.9815533 C11.6281117,16.0061489 11.5933303,16.0061489 11.5875334,15.9815533 C11.3226187,14.73387 10.3111924,13.7589978 9.01719014,13.5041051 C8.99284318,13.4985152 8.99284318,13.4649757 9.01719014,13.4593858 C10.3115219,13.2043286 11.3230319,12.2289358 11.5875334,10.9808196 L11.5875334,10.9808196 Z"
|
||||
id="形状"
|
||||
></path>
|
||||
</g>
|
||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiSummaryIcon;
|
||||
91
src/components/Search/AiOverview.tsx
Normal file
91
src/components/Search/AiOverview.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ChevronUp, Sparkles } from "lucide-react";
|
||||
import { FC, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { ChatMessage } from "../ChatMessage";
|
||||
|
||||
interface AiSummaryProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const AiOverview: FC<AiSummaryProps> = (props) => {
|
||||
const { message } = props;
|
||||
const aiOverviewServer = useExtensionsStore((state) => {
|
||||
return state.aiOverviewServer;
|
||||
});
|
||||
const aiOverviewAssistant = useExtensionsStore((state) => {
|
||||
return state.aiOverviewAssistant;
|
||||
});
|
||||
|
||||
const [expand, setExpand] = useState(true);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const { isTyping, chunkData, loadingStep } = useStreamChat({
|
||||
message,
|
||||
clientId: "ai-overview-client-id",
|
||||
server: aiOverviewServer,
|
||||
assistant: aiOverviewAssistant,
|
||||
setVisible,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-2 relative max-h-[210px] px-4 py-3 rounded-[4px] text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-[#141414] shadow-[0_4px_8px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_20px_rgba(255,255,255,0.2)]",
|
||||
{
|
||||
"hidden -m-2": !visible,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-md cursor-pointer dark:border-[#282828]"
|
||||
onClick={() => {
|
||||
setExpand(!expand);
|
||||
}}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex item-center gap-1">
|
||||
<Sparkles className="size-4 text-[#881c94]" />
|
||||
<span className="text-xs font-semibold">AI Overview</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("flex-1 overflow-auto text-sm hide-scrollbar", {
|
||||
hidden: !expand,
|
||||
})}
|
||||
>
|
||||
<div className="-ml-11 -mr-4 user-select-text">
|
||||
<ChatMessage
|
||||
key="current"
|
||||
hide_assistant
|
||||
message={{
|
||||
_id: "current",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: "",
|
||||
question: "",
|
||||
},
|
||||
}}
|
||||
{...chunkData}
|
||||
isTyping={isTyping}
|
||||
loadingStep={loadingStep}
|
||||
rootClassName="!py-0"
|
||||
actionClassName="absolute bottom-3 left-3 !m-0"
|
||||
actionIconSize={12}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("min-h-[20px]", {
|
||||
hidden: !expand || isTyping,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiOverview;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ChevronUp, Copy, SquareArrowOutUpRight, Volume2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import AiSummaryIcon from "../Common/Icons/AiSummaryIcon";
|
||||
import clsx from "clsx";
|
||||
import Markdown from "../ChatMessage/Markdown";
|
||||
|
||||
const AiSummary = () => {
|
||||
const [expand, setExpand] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 relative max-h-[210px] px-4 py-3 rounded-[4px] text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-[#141414] shadow-[0_4px_8px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_20px_rgba(255,255,255,0.2)]">
|
||||
<div
|
||||
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-md cursor-pointer dark:border-[#282828]"
|
||||
onClick={() => {
|
||||
setExpand(!expand);
|
||||
}}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex item-center gap-1">
|
||||
<AiSummaryIcon color="#881c94" />
|
||||
<span className="text-xs font-semibold">AI Summarize</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("flex-1 overflow-auto text-sm", {
|
||||
hidden: !expand,
|
||||
})}
|
||||
>
|
||||
<Markdown content={"AI Summarize"} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("flex gap-3", {
|
||||
hidden: !expand,
|
||||
})}
|
||||
>
|
||||
<Copy className="size-3 cursor-pointer" />
|
||||
|
||||
<Volume2 className="size-3 cursor-pointer" />
|
||||
|
||||
<SquareArrowOutUpRight className="size-3 cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiSummary;
|
||||
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { noop } from "lodash-es";
|
||||
|
||||
import { ChatMessage } from "../ChatMessage";
|
||||
import { ASK_AI_CLIENT_ID, COPY_BUTTON_ID } from "@/constants";
|
||||
import { COPY_BUTTON_ID } from "@/constants";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
||||
@@ -75,6 +75,9 @@ const AskAi = () => {
|
||||
return state.setAskAiServerId;
|
||||
});
|
||||
const state = useReactive<State>({});
|
||||
const setAskAiAssistantId = useSearchStore((state) => {
|
||||
return state.setAskAiAssistantId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.serverId) return;
|
||||
@@ -97,12 +100,10 @@ const AskAi = () => {
|
||||
useMount(async () => {
|
||||
try {
|
||||
unlisten.current = await platformAdapter.listenEvent(
|
||||
ASK_AI_CLIENT_ID,
|
||||
"quick-ai-access-client-id",
|
||||
({ payload }) => {
|
||||
console.log("ask_ai", JSON.parse(payload));
|
||||
|
||||
setIsTyping(true);
|
||||
|
||||
const chunkData = JSON.parse(payload);
|
||||
|
||||
if (chunkData?._id) {
|
||||
@@ -115,6 +116,13 @@ const AskAi = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the chunk data does not contain a message_chunk, we ignore it
|
||||
if (!chunkData.message_chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTyping(true);
|
||||
|
||||
setLoadingStep(() => ({
|
||||
query_intent: false,
|
||||
tools: false,
|
||||
@@ -164,15 +172,12 @@ const AskAi = () => {
|
||||
|
||||
const { serverId, assistantId } = state;
|
||||
|
||||
console.log("serverId", serverId);
|
||||
console.log("assistantId", assistantId);
|
||||
|
||||
try {
|
||||
await platformAdapter.invokeBackend("ask_ai", {
|
||||
message: askAiMessage,
|
||||
serverId,
|
||||
assistantId,
|
||||
clientId: ASK_AI_CLIENT_ID,
|
||||
clientId: "quick-ai-access-client-id",
|
||||
});
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
@@ -184,7 +189,7 @@ const AskAi = () => {
|
||||
|
||||
if (isTyping) return;
|
||||
|
||||
const { serverId } = state;
|
||||
const { serverId, assistantId } = state;
|
||||
|
||||
if ((isMac && metaKey) || (!isMac && ctrlKey)) {
|
||||
await platformAdapter.commands("open_session_chat", {
|
||||
@@ -195,7 +200,8 @@ const AskAi = () => {
|
||||
platformAdapter.emitEvent("toggle-to-chat-mode");
|
||||
|
||||
setAskAiServerId(serverId);
|
||||
return setAskAiSessionId(sessionIdRef.current);
|
||||
setAskAiSessionId(sessionIdRef.current);
|
||||
return setAskAiAssistantId(assistantId);
|
||||
}
|
||||
|
||||
const copyButton = document.getElementById(COPY_BUTTON_ID);
|
||||
|
||||
@@ -38,16 +38,16 @@ export function useAssistantManager({
|
||||
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
||||
|
||||
const assistant_get = useCallback(async () => {
|
||||
if (!askAI?.id) return;
|
||||
if (isTauri) {
|
||||
if (!askAI?.querySource?.id) return;
|
||||
const res = await platformAdapter.commands("assistant_get", {
|
||||
serverId: askAI?.querySource?.id,
|
||||
assistantId: askAI?.id,
|
||||
});
|
||||
setAssistantDetail(res);
|
||||
} else {
|
||||
const [error, res]: any = await Get(`/assistant/${askAI?.id}`, {
|
||||
id: askAI?.id,
|
||||
});
|
||||
const [error, res]: any = await Get(`/assistant/${askAI?.id}`);
|
||||
if (error) {
|
||||
console.error("assistant", error);
|
||||
return;
|
||||
@@ -57,6 +57,8 @@ export function useAssistantManager({
|
||||
}, [askAI]);
|
||||
|
||||
const handleAskAi = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!isTauri) return;
|
||||
|
||||
askAIRef.current = cloneDeep(askAI);
|
||||
|
||||
if (!askAIRef.current) return;
|
||||
@@ -67,7 +69,6 @@ export function useAssistantManager({
|
||||
|
||||
if (!selectedAssistant && isEmpty(value)) return;
|
||||
|
||||
assistant_get();
|
||||
changeInput("");
|
||||
setAskAiMessage(!goAskAi && selectedAssistant ? "" : value);
|
||||
setGoAskAi(true);
|
||||
@@ -84,7 +85,9 @@ export function useAssistantManager({
|
||||
return setGoAskAi(false);
|
||||
}
|
||||
|
||||
if (key === "Tab" && !isChatMode) {
|
||||
if (key === "Tab" && !isChatMode && isTauri) {
|
||||
assistant_get();
|
||||
|
||||
return handleAskAi(e);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useBoolean } from "ahooks";
|
||||
import { useBoolean, useDebounceFn } from "ahooks";
|
||||
import {
|
||||
useRef,
|
||||
useImperativeHandle,
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LINE_HEIGHT = 24; // 1.5rem
|
||||
const MAX_FIRST_LINE_WIDTH = 470; // Width in pixels for first line
|
||||
const MAX_HEIGHT = 240; // 15rem
|
||||
|
||||
interface AutoResizeTextareaProps {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
@@ -37,6 +41,77 @@ const AutoResizeTextarea = forwardRef<
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isComposition, { setTrue, setFalse }] = useBoolean();
|
||||
|
||||
// Memoize resize logic
|
||||
const { run: debouncedResize } = useDebounceFn(
|
||||
() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textarea.style.height = "auto";
|
||||
|
||||
// Create a hidden span to measure first line width
|
||||
const span = document.createElement("span");
|
||||
span.style.visibility = "hidden";
|
||||
span.style.position = "absolute";
|
||||
span.style.whiteSpace = "pre";
|
||||
span.style.font = window.getComputedStyle(textarea).font;
|
||||
|
||||
// Get first line content
|
||||
const content = textarea.value;
|
||||
const firstLineEnd =
|
||||
content.indexOf("\n") === -1 ? content.length : content.indexOf("\n");
|
||||
span.textContent = content.slice(0, firstLineEnd);
|
||||
document.body.appendChild(span);
|
||||
|
||||
// Calculate lines based on first line width
|
||||
const firstLineWidth = span.offsetWidth;
|
||||
document.body.removeChild(span);
|
||||
|
||||
// Start with 1 line
|
||||
let lines = 1;
|
||||
|
||||
// Add a line if first line exceeds max width
|
||||
if (firstLineWidth > MAX_FIRST_LINE_WIDTH) {
|
||||
lines += 1;
|
||||
}
|
||||
|
||||
// Add lines based on scrollHeight for remaining content
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
const remainingLines = Math.floor(
|
||||
(scrollHeight - LINE_HEIGHT) / LINE_HEIGHT
|
||||
);
|
||||
lines += Math.max(0, remainingLines);
|
||||
|
||||
// Calculate final height
|
||||
const newHeight = Math.min(lines * LINE_HEIGHT, MAX_HEIGHT);
|
||||
|
||||
// Only update if height actually changed
|
||||
if (textarea.style.height !== `${newHeight}px`) {
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
onLineCountChange?.(lines);
|
||||
}
|
||||
},
|
||||
{ wait: 100 }
|
||||
);
|
||||
|
||||
// Handle input changes and initial setup
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
debouncedResize();
|
||||
}
|
||||
}, [input, debouncedResize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
// Set cursor position to end
|
||||
const length = textareaRef.current?.value.length || 0;
|
||||
textareaRef.current?.setSelectionRange(length, length);
|
||||
});
|
||||
}
|
||||
}, [lineCount]);
|
||||
|
||||
// Expose methods to the parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
@@ -47,13 +122,6 @@ const AutoResizeTextarea = forwardRef<
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
const length = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(length, length);
|
||||
}
|
||||
}, [lineCount]);
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isComposition) {
|
||||
return event.stopPropagation();
|
||||
@@ -62,18 +130,6 @@ const AutoResizeTextarea = forwardRef<
|
||||
handleKeyDown?.(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
const newHeight = Math.min(textareaRef.current.scrollHeight, 15 * 16); // 15rem ≈ 15 * 16px
|
||||
textareaRef.current.style.height = `${newHeight}px`;
|
||||
|
||||
const lineHeight = 24; // 1.5rem = 24px
|
||||
const lineCount = Math.ceil(newHeight / lineHeight);
|
||||
onLineCountChange?.(lineCount);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -82,9 +138,7 @@ const AutoResizeTextarea = forwardRef<
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
className="text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar"
|
||||
placeholder={
|
||||
chatPlaceholder || t("search.textarea.placeholder")
|
||||
}
|
||||
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
|
||||
aria-label={t("search.textarea.ariaLabel")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -96,7 +150,8 @@ const AutoResizeTextarea = forwardRef<
|
||||
rows={1}
|
||||
style={{
|
||||
resize: "none", // Prevent manual resize
|
||||
overflow: "auto", // Enable scrollbars when needed
|
||||
overflow: "auto",
|
||||
minHeight: "1.5rem",
|
||||
maxHeight: "13.5rem", // Limit height to 9 rows (9 * 1.5 line-height)
|
||||
lineHeight: "1.5rem", // Line height to match row height
|
||||
}}
|
||||
|
||||
@@ -2,17 +2,18 @@ import { useClickAway, useCreation, useReactive } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import { isNil, lowerCase, noop } from "lodash-es";
|
||||
import { Copy, Link, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { cloneElement, useEffect, useRef, useState } from "react";
|
||||
import { cloneElement, FC, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
|
||||
import { copyToClipboard } from "@/utils";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import { CONTEXT_MENU_PANEL_ID } from "@/constants";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { Input } from "@headlessui/react";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface State {
|
||||
activeMenuIndex: number;
|
||||
@@ -22,7 +23,7 @@ interface ContextMenuProps {
|
||||
hideCoco?: () => void;
|
||||
}
|
||||
|
||||
const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
const ContextMenu: FC<ContextMenuProps> = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { t, i18n } = useTranslation();
|
||||
const state = useReactive<State>({
|
||||
@@ -52,9 +53,15 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
const menus = useCreation(() => {
|
||||
if (isNil(selectedSearchContent)) return [];
|
||||
|
||||
const { url, category, payload } = selectedSearchContent;
|
||||
const { url, category, payload, on_opened } = selectedSearchContent;
|
||||
const { query, result } = payload ?? {};
|
||||
|
||||
if (category === "AI Overview") {
|
||||
setSearchMenus([]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const menus = [
|
||||
{
|
||||
name: t("search.contextMenu.open"),
|
||||
@@ -63,9 +70,9 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
shortcut: "enter",
|
||||
hide: category === "Calculator",
|
||||
clickEvent: () => {
|
||||
OpenURLWithBrowser(url);
|
||||
|
||||
hideCoco && hideCoco();
|
||||
if (on_opened) {
|
||||
platformAdapter.invokeBackend("open", { onOpened: on_opened });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -182,104 +189,106 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleContextMenu && (
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
onContextMenu={(event) => {
|
||||
event?.preventDefault();
|
||||
searchMenus.length > 0 && (
|
||||
<>
|
||||
{visibleContextMenu && (
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
onContextMenu={(event) => {
|
||||
event?.preventDefault();
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
id={visibleContextMenu ? CONTEXT_MENU_PANEL_ID : ""}
|
||||
className={clsx(
|
||||
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg shadow-xs border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
|
||||
{
|
||||
"!scale-100": visibleContextMenu,
|
||||
}
|
||||
setVisibleContextMenu(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="text-[#999] dark:text-[#666] truncate">{title}</div>
|
||||
|
||||
<ul className="flex flex-col -mx-2 p-0">
|
||||
{searchMenus.map((item, index) => {
|
||||
const { name, icon, keys, clickEvent } = item;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
"flex justify-between items-center gap-2 px-2 py-2 rounded-lg cursor-pointer",
|
||||
{
|
||||
"bg-[#EDEDED] dark:bg-[#202126]":
|
||||
index === state.activeMenuIndex,
|
||||
}
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
state.activeMenuIndex = index;
|
||||
}}
|
||||
onClick={() => handleClick(clickEvent)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-black/80 dark:text-white/80">
|
||||
{cloneElement(icon, { className: "size-4" })}
|
||||
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[4px] text-black/60 dark:text-white/60">
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className={clsx(
|
||||
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
|
||||
{
|
||||
"px-1": key.length > 1,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="-mx-3 p-2 border-t border-[#E6E6E6] dark:border-[#262626]">
|
||||
{visibleContextMenu && (
|
||||
<VisibleKey
|
||||
shortcut="F"
|
||||
shortcutClassName="left-3"
|
||||
onKeyPress={() => {
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
placeholder={t("search.contextMenu.search")}
|
||||
className="w-full bg-transparent"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
|
||||
const searchMenus = menus.filter((item) => {
|
||||
return lowerCase(item.name).includes(lowerCase(value));
|
||||
});
|
||||
|
||||
setSearchMenus(searchMenus);
|
||||
}}
|
||||
/>
|
||||
</VisibleKey>
|
||||
<div
|
||||
ref={containerRef}
|
||||
id={visibleContextMenu ? CONTEXT_MENU_PANEL_ID : ""}
|
||||
className={clsx(
|
||||
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg shadow-xs border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
|
||||
{
|
||||
"!scale-100": visibleContextMenu,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="text-[#999] dark:text-[#666] truncate">{title}</div>
|
||||
|
||||
<ul className="flex flex-col -mx-2 p-0">
|
||||
{searchMenus.map((item, index) => {
|
||||
const { name, icon, keys, clickEvent } = item;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
"flex justify-between items-center gap-2 px-2 py-2 rounded-lg cursor-pointer",
|
||||
{
|
||||
"bg-[#EDEDED] dark:bg-[#202126]":
|
||||
index === state.activeMenuIndex,
|
||||
}
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
state.activeMenuIndex = index;
|
||||
}}
|
||||
onClick={() => handleClick(clickEvent)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-black/80 dark:text-white/80">
|
||||
{cloneElement(icon, { className: "size-4" })}
|
||||
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[4px] text-black/60 dark:text-white/60">
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className={clsx(
|
||||
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
|
||||
{
|
||||
"px-1": key.length > 1,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="-mx-3 p-2 border-t border-[#E6E6E6] dark:border-[#262626]">
|
||||
{visibleContextMenu && (
|
||||
<VisibleKey
|
||||
shortcut="F"
|
||||
shortcutClassName="left-3"
|
||||
onKeyPress={() => {
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
placeholder={t("search.contextMenu.search")}
|
||||
className="w-full bg-transparent"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
|
||||
const searchMenus = menus.filter((item) => {
|
||||
return lowerCase(item.name).includes(lowerCase(value));
|
||||
});
|
||||
|
||||
setSearchMenus(searchMenus);
|
||||
}}
|
||||
/>
|
||||
</VisibleKey>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { SearchHeader } from "./SearchHeader";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
@@ -170,7 +169,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
const handleEnter = () => {
|
||||
if (selectedItem === null) return;
|
||||
const item = data.list[selectedItem]?.document;
|
||||
item?.url && OpenURLWithBrowser(item.url);
|
||||
if (item?.on_opened) {
|
||||
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
|
||||
}
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
@@ -233,9 +234,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
isSelected={selectedItem === index}
|
||||
currentIndex={index}
|
||||
onMouseEnter={() => onMouseEnter(index, hit.document)}
|
||||
onItemClick={() =>
|
||||
hit.document?.url && OpenURLWithBrowser(hit.document.url)
|
||||
}
|
||||
onItemClick={() => {
|
||||
if (hit.document?.on_opened) {
|
||||
platformAdapter.invokeBackend("open", {
|
||||
onOpened: hit.document.on_opened,
|
||||
});
|
||||
}
|
||||
}}
|
||||
showListRight={viewMode === "list"}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,12 +10,12 @@ import { useDebounceFn, useUnmount } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
import ErrorSearch from "@/components/Common/ErrorNotification/ErrorSearch";
|
||||
import type { QueryHits, SearchDocument, FailedRequest } from "@/types/search";
|
||||
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
||||
import { SearchSource } from "./SearchSource";
|
||||
import DropdownListItem from "./DropdownListItem";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
type ISearchData = Record<string, QueryHits[]>;
|
||||
|
||||
@@ -33,7 +33,7 @@ function DropdownList({
|
||||
searchData,
|
||||
isError,
|
||||
isChatMode,
|
||||
globalItemIndexMap
|
||||
globalItemIndexMap,
|
||||
}: DropdownListProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -43,7 +43,6 @@ function DropdownList({
|
||||
const [selectedName, setSelectedName] = useState<string>("");
|
||||
const [showIndex, setShowIndex] = useState<boolean>(false);
|
||||
|
||||
|
||||
const {
|
||||
setSourceData,
|
||||
setSelectedSearchContent,
|
||||
@@ -57,7 +56,14 @@ function DropdownList({
|
||||
);
|
||||
|
||||
const handleItemAction = useCallback((item: SearchDocument) => {
|
||||
if (!item || item.category === "Calculator") return;
|
||||
if (
|
||||
!item ||
|
||||
item.category === "Calculator" ||
|
||||
item.category === "AI Overview"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSourceData(item);
|
||||
}, []);
|
||||
|
||||
@@ -69,17 +75,19 @@ function DropdownList({
|
||||
|
||||
const memoizedCallbacks = useMemo(() => {
|
||||
return {
|
||||
onMouseEnter: (index: number, item: SearchDocument) => () => {
|
||||
console.log("onMouseEnter", index);
|
||||
onMouseEnter: (index: number, item: SearchDocument) => {
|
||||
setVisibleContextMenu(false);
|
||||
setSelectedIndex(index);
|
||||
setSelectedSearchContent(item);
|
||||
},
|
||||
onItemClick: (item: SearchDocument) => () => {
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item.url);
|
||||
onItemClick: (item: SearchDocument) => {
|
||||
if (item?.on_opened) {
|
||||
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
|
||||
}
|
||||
},
|
||||
goToTwoPage: (item: SearchDocument) => () => setSourceData(item),
|
||||
goToTwoPage: (item: SearchDocument) => {
|
||||
setSourceData(item);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -94,7 +102,7 @@ function DropdownList({
|
||||
return;
|
||||
}
|
||||
|
||||
const item = globalItemIndexMap[selectedIndex]
|
||||
const item = globalItemIndexMap[selectedIndex];
|
||||
setSelectedSearchContent(item);
|
||||
if (item?.source?.id === "assistant") {
|
||||
setSelectedAssistant({
|
||||
@@ -161,7 +169,7 @@ function DropdownList({
|
||||
|
||||
{Object.entries(searchData).map(([sourceName, items]) => (
|
||||
<div key={sourceName}>
|
||||
{showSource && (
|
||||
{showSource && items[0].document.category !== "AI Overview" && (
|
||||
<SearchSource
|
||||
sourceName={sourceName}
|
||||
items={items}
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from "clsx";
|
||||
import Calculator from "./Calculator";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
import type { SearchDocument } from "@/types/search";
|
||||
import AiOverview from "./AiOverview";
|
||||
|
||||
interface DropdownListItemProps {
|
||||
item: SearchDocument;
|
||||
@@ -11,7 +12,7 @@ interface DropdownListItemProps {
|
||||
currentIndex: number;
|
||||
showIndex: boolean;
|
||||
memoizedCallbacks: {
|
||||
onMouseEnter: (index: number, item: SearchDocument) => () => void;
|
||||
onMouseEnter: (index: number, item: SearchDocument) => void;
|
||||
onItemClick: (item: SearchDocument) => void;
|
||||
goToTwoPage: (item: SearchDocument) => void;
|
||||
};
|
||||
@@ -30,14 +31,17 @@ const DropdownListItem = memo(
|
||||
onContextMenu,
|
||||
}: DropdownListItemProps) => {
|
||||
const isCalculator = item.category === "Calculator";
|
||||
const isAiOverview = item.category === "AI Overview";
|
||||
const isSelected = selectedIndex === currentIndex;
|
||||
|
||||
return (
|
||||
<div onContextMenu={onContextMenu}>
|
||||
{isCalculator ? (
|
||||
{isCalculator || isAiOverview ? (
|
||||
<div
|
||||
ref={(el) => (itemRefs.current[currentIndex] = el)}
|
||||
onMouseEnter={memoizedCallbacks.onMouseEnter(currentIndex, item)}
|
||||
onMouseEnter={() => {
|
||||
memoizedCallbacks.onMouseEnter(currentIndex, item);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={`search-item-${currentIndex}`}
|
||||
@@ -45,7 +49,9 @@ const DropdownListItem = memo(
|
||||
"bg-[#EDEDED] dark:bg-[#202126]": isSelected,
|
||||
})}
|
||||
>
|
||||
<Calculator item={item} isSelected={isSelected} />
|
||||
{isCalculator && <Calculator item={item} isSelected={isSelected} />}
|
||||
|
||||
{isAiOverview && <AiOverview message={item?.payload?.message} />}
|
||||
</div>
|
||||
) : (
|
||||
<SearchListItem
|
||||
@@ -53,9 +59,15 @@ const DropdownListItem = memo(
|
||||
isSelected={isSelected}
|
||||
currentIndex={currentIndex}
|
||||
showIndex={showIndex}
|
||||
onMouseEnter={memoizedCallbacks.onMouseEnter(currentIndex, item)}
|
||||
onItemClick={() => memoizedCallbacks.onItemClick(item)}
|
||||
goToTwoPage={() => memoizedCallbacks.goToTwoPage(item)}
|
||||
onMouseEnter={() => {
|
||||
memoizedCallbacks.onMouseEnter(currentIndex, item);
|
||||
}}
|
||||
onItemClick={() => {
|
||||
memoizedCallbacks.onItemClick(item);
|
||||
}}
|
||||
goToTwoPage={() => {
|
||||
memoizedCallbacks.goToTwoPage(item);
|
||||
}}
|
||||
itemRef={(el) => (itemRefs.current[currentIndex] = el)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import ChatIcons from "./ChatIcons";
|
||||
import { useKeyboardHandlers } from "@/hooks/useKeyboardHandlers";
|
||||
import { useAssistantManager } from "./AssistantManager";
|
||||
import InputControls from "./InputControls";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
@@ -79,6 +80,7 @@ export default function ChatInput({
|
||||
|
||||
const showTooltip = useAppStore((state) => state.showTooltip);
|
||||
const setBlurred = useAppStore((state) => state.setBlurred);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
|
||||
const { sourceData, goAskAi } = useSearchStore();
|
||||
|
||||
@@ -154,16 +156,12 @@ export default function ChatInput({
|
||||
};
|
||||
}, [isChatMode]);
|
||||
|
||||
const {
|
||||
askAI,
|
||||
askAIRef,
|
||||
assistantDetail,
|
||||
handleKeyDownAutoResizeTextarea,
|
||||
} = useAssistantManager({
|
||||
isChatMode,
|
||||
handleSubmit,
|
||||
changeInput,
|
||||
});
|
||||
const { askAI, askAIRef, assistantDetail, handleKeyDownAutoResizeTextarea } =
|
||||
useAssistantManager({
|
||||
isChatMode,
|
||||
handleSubmit,
|
||||
changeInput,
|
||||
});
|
||||
|
||||
const [lineCount, setLineCount] = useState(1);
|
||||
|
||||
@@ -181,6 +179,10 @@ export default function ChatInput({
|
||||
};
|
||||
}, [currentAssistant]);
|
||||
|
||||
const disabledExtensions = useExtensionsStore((state) => {
|
||||
return state.disabledExtensions;
|
||||
});
|
||||
|
||||
const renderSearchIcon = () => (
|
||||
<SearchIcons
|
||||
lineCount={lineCount}
|
||||
@@ -225,18 +227,22 @@ export default function ChatInput({
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{!isChatMode && !goAskAi && askAI && (
|
||||
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
|
||||
<span>
|
||||
{t("search.askCocoAi.title", {
|
||||
replace: [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
|
||||
{!isChatMode &&
|
||||
isTauri &&
|
||||
!goAskAi &&
|
||||
askAI &&
|
||||
!disabledExtensions.includes("QuickAIAccess") && (
|
||||
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
|
||||
<span>
|
||||
{t("search.askCocoAi.title", {
|
||||
replace: [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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* <AudioRecording
|
||||
key={isChatMode ? "chat" : "search"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Brain } from "lucide-react";
|
||||
import { Brain, Sparkles } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
// import InputExtra from "./InputExtra";
|
||||
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
|
||||
|
||||
@@ -205,6 +207,22 @@ const InputControls = ({
|
||||
[assistantConfig]
|
||||
);
|
||||
|
||||
const enabledAiOverview = useSearchStore((state) => {
|
||||
return state.enabledAiOverview;
|
||||
});
|
||||
const setEnabledAiOverview = useSearchStore((state) => {
|
||||
return state.setEnabledAiOverview;
|
||||
});
|
||||
const disabledExtensions = useExtensionsStore((state) => {
|
||||
return state.disabledExtensions;
|
||||
});
|
||||
const aiOverviewServer = useExtensionsStore((state) => {
|
||||
return state.aiOverviewServer;
|
||||
});
|
||||
const aiOverviewAssistant = useExtensionsStore((state) => {
|
||||
return state.aiOverviewAssistant;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
@@ -286,7 +304,34 @@ const InputControls = ({
|
||||
</div>
|
||||
) : (
|
||||
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
|
||||
{/* <AiSummaryIcon color={"#881c94"} /> */}
|
||||
{!disabledExtensions.includes("AIOverview") &&
|
||||
isTauri &&
|
||||
aiOverviewServer &&
|
||||
aiOverviewAssistant && (
|
||||
<div
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
|
||||
[
|
||||
enabledAiOverview
|
||||
? "text-[#881c94]"
|
||||
: "text-[#333] dark:text-[#d8d8d8]",
|
||||
],
|
||||
{
|
||||
"bg-[#881C94]/20 dark:bg-[#202126]": enabledAiOverview,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
setEnabledAiOverview(!enabledAiOverview);
|
||||
}}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
<span
|
||||
className={clsx("text-xs", { hidden: !enabledAiOverview })}
|
||||
>
|
||||
AI Overview
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,14 @@ const SearchResultsPanel = memo<{
|
||||
const { sourceData, goAskAi } = useSearchStore();
|
||||
|
||||
const searchState = useSearch();
|
||||
const { suggests, searchData, isError, isSearchComplete, globalItemIndexMap, performSearch } = searchState;
|
||||
const {
|
||||
suggests,
|
||||
searchData,
|
||||
isError,
|
||||
isSearchComplete,
|
||||
globalItemIndexMap,
|
||||
performSearch,
|
||||
} = searchState;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatMode && input) {
|
||||
|
||||
@@ -23,8 +23,8 @@ export default function SearchIcons({
|
||||
const renderContent = () => {
|
||||
if (goAskAi && assistant) {
|
||||
return (
|
||||
<div className="flex h-8 -my-1">
|
||||
<div className="flex items-center gap-2 pl-2 text-sm bg-white dark:bg-black">
|
||||
<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" />
|
||||
|
||||
@@ -25,7 +25,9 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
|
||||
onGoToTwoPage,
|
||||
}) => {
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const hideArrow = items[0]?.document.category === "Calculator";
|
||||
const hideArrow =
|
||||
items[0]?.document.category === "Calculator" ||
|
||||
items[0]?.document.category === "AI Overview";
|
||||
|
||||
return (
|
||||
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
|
||||
@@ -36,7 +38,7 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
|
||||
defaultIcon={isDark ? source_default_dark_img : source_default_img}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{sourceName} - {items[0]?.source?.name}
|
||||
{sourceName} {items[0]?.source?.name && `- ${items[0].source.name}`}
|
||||
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||
{!hideArrow && (
|
||||
<>
|
||||
|
||||
@@ -1,84 +1,124 @@
|
||||
import {
|
||||
cloneElement,
|
||||
FC,
|
||||
Fragment,
|
||||
MouseEvent,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ExtensionsContext, Plugin } from "../..";
|
||||
import { useMount } from "ahooks";
|
||||
import { FC, MouseEvent, useContext } from "react";
|
||||
import { Extension, ExtensionId, ExtensionsContext } from "../..";
|
||||
import { useReactive } from "ahooks";
|
||||
import { ChevronRight, LoaderCircle } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { isArray, isFunction } from "lodash-es";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import { isArray, startCase, sortBy } from "lodash-es";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Shortcut from "../Shortcut";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Shortcut from "../Shortcut";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import { platform } from "@/utils/platform";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
const Content = () => {
|
||||
const { plugins } = useContext(ExtensionsContext);
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
|
||||
return plugins.map((item) => {
|
||||
return <Item key={item.id} {...item} level={1} />;
|
||||
return rootState.extensions.map((item) => {
|
||||
const { id } = item;
|
||||
|
||||
return <Item key={id} {...item} level={1} extensionId={id} />;
|
||||
});
|
||||
};
|
||||
|
||||
const Item: FC<Plugin & { level: number }> = (props) => {
|
||||
const {
|
||||
id,
|
||||
icon,
|
||||
name,
|
||||
children,
|
||||
type = "Extension",
|
||||
manualLoad,
|
||||
level = 1,
|
||||
} = props;
|
||||
const { activeId, setActiveId, setPlugins } = useContext(ExtensionsContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
interface ItemProps extends Extension {
|
||||
level: number;
|
||||
extensionId: ExtensionId;
|
||||
}
|
||||
|
||||
interface ItemState {
|
||||
loading: boolean;
|
||||
expanded: boolean;
|
||||
subExtensions?: Extension[];
|
||||
}
|
||||
|
||||
const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
|
||||
Applications: "get_app_list",
|
||||
};
|
||||
|
||||
const Item: FC<ItemProps> = (props) => {
|
||||
const { id, icon, title, type, level, extensionId, platforms } = props;
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
const state = useReactive<ItemState>({
|
||||
loading: false,
|
||||
expanded: false,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const hasChildren = isArray(children);
|
||||
const disabledExtensions = useExtensionsStore((state) => {
|
||||
return state.disabledExtensions;
|
||||
});
|
||||
const setDisabledExtensions = useExtensionsStore((state) => {
|
||||
return state.setDisabledExtensions;
|
||||
});
|
||||
|
||||
const handleLoadChildren = async () => {
|
||||
setLoading(true);
|
||||
const hasSubExtensions = () => {
|
||||
const { commands, scripts, quick_links } = props;
|
||||
|
||||
await props.loadChildren?.();
|
||||
if (subExtensionCommand[id]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
if (isArray(commands) || isArray(scripts) || isArray(quick_links)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
useMount(async () => {
|
||||
if (!manualLoad) {
|
||||
handleLoadChildren();
|
||||
const getSubExtensions = async () => {
|
||||
state.loading = true;
|
||||
|
||||
const { commands, scripts, quick_links } = props;
|
||||
|
||||
let subExtensions: Extension[] = [];
|
||||
|
||||
const command = subExtensionCommand[id];
|
||||
|
||||
if (command) {
|
||||
subExtensions = await platformAdapter.invokeBackend<Extension[]>(command);
|
||||
} else {
|
||||
subExtensions = [commands, scripts, quick_links].filter(isArray).flat();
|
||||
}
|
||||
});
|
||||
|
||||
state.loading = false;
|
||||
|
||||
return sortBy(subExtensions, ["title"]);
|
||||
};
|
||||
|
||||
const handleExpand = async (event: MouseEvent) => {
|
||||
event?.stopPropagation();
|
||||
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
if (state.expanded) {
|
||||
state.expanded = false;
|
||||
} else {
|
||||
if (manualLoad) {
|
||||
await handleLoadChildren();
|
||||
}
|
||||
state.subExtensions = await getSubExtensions();
|
||||
|
||||
setExpanded(true);
|
||||
state.expanded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const editable = () => {
|
||||
return (
|
||||
type !== "group" &&
|
||||
type !== "calculator" &&
|
||||
type !== "extension" &&
|
||||
type !== "ai_extension"
|
||||
);
|
||||
};
|
||||
|
||||
const renderAlias = () => {
|
||||
const { alias, onAliasChange } = props;
|
||||
const { alias } = props;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (isFunction(onAliasChange)) {
|
||||
return onAliasChange(value);
|
||||
}
|
||||
platformAdapter.invokeBackend("set_extension_alias", {
|
||||
extensionId,
|
||||
alias: value,
|
||||
});
|
||||
};
|
||||
|
||||
if (isFunction(onAliasChange)) {
|
||||
if (editable()) {
|
||||
return (
|
||||
<div
|
||||
className="-translate-x-2"
|
||||
@@ -102,15 +142,22 @@ const Item: FC<Plugin & { level: number }> = (props) => {
|
||||
};
|
||||
|
||||
const renderHotkey = () => {
|
||||
const { hotkey, onHotkeyChange } = props;
|
||||
const { hotkey } = props;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (isFunction(onHotkeyChange)) {
|
||||
return onHotkeyChange(value);
|
||||
if (value) {
|
||||
platformAdapter.invokeBackend("register_extension_hotkey", {
|
||||
extensionId,
|
||||
hotkey: value,
|
||||
});
|
||||
} else {
|
||||
platformAdapter.invokeBackend("unregister_extension_hotkey", {
|
||||
extensionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isFunction(onHotkeyChange)) {
|
||||
if (editable()) {
|
||||
return (
|
||||
<div
|
||||
className="-translate-x-2"
|
||||
@@ -131,39 +178,36 @@ const Item: FC<Plugin & { level: number }> = (props) => {
|
||||
};
|
||||
|
||||
const renderSwitch = () => {
|
||||
const { enabled = true, onEnabledChange } = props;
|
||||
const { enabled } = props;
|
||||
|
||||
const handleChange = (value: boolean) => {
|
||||
if (isFunction(onEnabledChange)) {
|
||||
return onEnabledChange(value);
|
||||
}
|
||||
if (value) {
|
||||
setDisabledExtensions(
|
||||
disabledExtensions.filter((item) => item !== extensionId)
|
||||
);
|
||||
|
||||
const command = `${value ? "enable" : "disable"}_local_query_source`;
|
||||
|
||||
platformAdapter.invokeBackend(command, {
|
||||
querySourceId: id,
|
||||
});
|
||||
|
||||
setPlugins((prevPlugins) => {
|
||||
return prevPlugins.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, enabled: value };
|
||||
}
|
||||
|
||||
return item;
|
||||
platformAdapter.invokeBackend("enable_extension", {
|
||||
extensionId,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setDisabledExtensions([...disabledExtensions, extensionId]);
|
||||
|
||||
platformAdapter.invokeBackend("disable_extension", {
|
||||
extensionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<SettingsToggle
|
||||
label={id}
|
||||
checked={Boolean(enabled)}
|
||||
defaultChecked={enabled}
|
||||
className="scale-75"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
@@ -171,71 +215,98 @@ const Item: FC<Plugin & { level: number }> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={id}>
|
||||
<div
|
||||
className={clsx("-mx-2 px-2 text-sm rounded-md", {
|
||||
"bg-[#f0f6fe] dark:bg-gray-700": id === activeId,
|
||||
})}
|
||||
>
|
||||
const renderType = () => {
|
||||
if (type === "ai_extension") {
|
||||
return "AI Extension";
|
||||
}
|
||||
|
||||
return startCase(type);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isArray(platforms)) {
|
||||
const currentPlatform = platform();
|
||||
|
||||
if (currentPlatform && !platforms.includes(currentPlatform)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 h-8"
|
||||
onClick={() => {
|
||||
setActiveId(id);
|
||||
}}
|
||||
className={clsx("-mx-2 px-2 text-sm rounded-md", {
|
||||
"bg-[#f0f6fe] dark:bg-gray-700":
|
||||
id === rootState.activeExtension?.id,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="flex-1 flex items-center gap-1 overflow-hidden"
|
||||
style={{ paddingLeft: (level - 1) * 20 }}
|
||||
className="flex items-center justify-between gap-2 h-8"
|
||||
onClick={() => {
|
||||
rootState.activeExtension = props;
|
||||
}}
|
||||
>
|
||||
<div className="min-w-4 h-4">
|
||||
{hasChildren && (
|
||||
<>
|
||||
{loading ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
onClick={handleExpand}
|
||||
className={clsx("size-4 transition cursor-pointer", {
|
||||
"rotate-90": expanded,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="flex-1 flex items-center gap-1 overflow-hidden"
|
||||
style={{ paddingLeft: (level - 1) * 20 }}
|
||||
>
|
||||
<div className="min-w-4 h-4">
|
||||
{hasSubExtensions() && (
|
||||
<>
|
||||
{state.loading ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
onClick={handleExpand}
|
||||
className={clsx("size-4 transition cursor-pointer", {
|
||||
"rotate-90": state.expanded,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="size-4">
|
||||
{icon.startsWith("font_") ? (
|
||||
<FontIcon name={icon} className="size-full" />
|
||||
) : (
|
||||
<img
|
||||
src={platformAdapter.convertFileSrc(icon)}
|
||||
className="size-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="truncate">{title}</div>
|
||||
</div>
|
||||
|
||||
{cloneElement(icon, {
|
||||
className: clsx("size-4", icon.props.className),
|
||||
})}
|
||||
|
||||
<div className="truncate">{name}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-3/5 flex items-center text-[#999]">
|
||||
<div className="flex-1">{type}</div>
|
||||
<div className="flex-1">{renderAlias()}</div>
|
||||
<div className="flex-1">{renderHotkey()}</div>
|
||||
<div className="flex-1 flex items-center justify-end">
|
||||
{renderSwitch()}
|
||||
<div className="w-4/6 flex items-center text-[#999]">
|
||||
<div className="flex-1">{renderType()}</div>
|
||||
<div className="flex-1">{renderAlias()}</div>
|
||||
<div className="flex-1">{renderHotkey()}</div>
|
||||
<div className="w-16">{renderSwitch()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<div
|
||||
className={clsx({
|
||||
hidden: !expanded,
|
||||
})}
|
||||
>
|
||||
{children.map((item) => {
|
||||
return <Item key={item.id} {...item} level={level + 1} />;
|
||||
<div className={clsx({ hidden: !state.expanded })}>
|
||||
{state.subExtensions?.map((item) => {
|
||||
return (
|
||||
<Item
|
||||
key={item.id}
|
||||
{...item}
|
||||
level={level + 1}
|
||||
extensionId={`${id}.${item.id}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return renderContent();
|
||||
};
|
||||
|
||||
export default Content;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import SharedAi from "../SharedAi";
|
||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||
|
||||
const AiOverview = () => {
|
||||
const aiOverviewServer = useExtensionsStore((state) => {
|
||||
return state.aiOverviewServer;
|
||||
});
|
||||
const setAiOverviewServer = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewServer;
|
||||
});
|
||||
const aiOverviewAssistant = useExtensionsStore((state) => {
|
||||
return state.aiOverviewAssistant;
|
||||
});
|
||||
const setAiOverviewAssistant = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewAssistant;
|
||||
});
|
||||
const aiOverviewCharLen = useExtensionsStore((state) => {
|
||||
return state.aiOverviewCharLen;
|
||||
});
|
||||
const setAiOverviewCharLen = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewCharLen;
|
||||
});
|
||||
const aiOverviewDelay = useExtensionsStore((state) => {
|
||||
return state.aiOverviewDelay;
|
||||
});
|
||||
const setAiOverviewDelay = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewDelay;
|
||||
});
|
||||
|
||||
const inputList = [
|
||||
{
|
||||
label: "Minimum Input Length(characters)",
|
||||
value: aiOverviewCharLen,
|
||||
onChange: setAiOverviewCharLen,
|
||||
},
|
||||
{
|
||||
label: "Delay After Typing Stops(seconds)",
|
||||
value: aiOverviewDelay,
|
||||
onChange: setAiOverviewDelay,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SharedAi
|
||||
key="AIOverview"
|
||||
id="AIOverview"
|
||||
server={aiOverviewServer}
|
||||
setServer={setAiOverviewServer}
|
||||
assistant={aiOverviewAssistant}
|
||||
setAssistant={setAiOverviewAssistant}
|
||||
/>
|
||||
|
||||
<div className="text-sm">
|
||||
<div className="mt-6 text-[#333] dark:text-white/90">
|
||||
AI Overview Trigger
|
||||
</div>
|
||||
|
||||
<div className="pt-2 pb-4 text-[#999]">
|
||||
AI Overview will be triggered when both conditions are met.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{inputList.map((item) => {
|
||||
const { label, value, onChange } = item;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-[#666] dark:text-white/70">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<SettingsInput
|
||||
type="number"
|
||||
value={value}
|
||||
className="w-full"
|
||||
onChange={(value) => {
|
||||
onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiOverview;
|
||||
@@ -4,7 +4,7 @@ import dayjs from "dayjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { ExtensionsContext, Plugin, type ExtensionsContextType } from "../../../index";
|
||||
import { ExtensionsContext } from "../../../index";
|
||||
|
||||
interface Metadata {
|
||||
name: string;
|
||||
@@ -17,46 +17,25 @@ interface Metadata {
|
||||
|
||||
const App = () => {
|
||||
const { t } = useTranslation();
|
||||
const { activeId, plugins } = useContext(ExtensionsContext) as ExtensionsContextType;
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
|
||||
const [appMetadata, setAppMetadata] = useState<Metadata>();
|
||||
|
||||
const findPlugin = (plugins: Plugin[], id: string) => {
|
||||
for (const plugin of plugins) {
|
||||
const { children = [] } = plugin;
|
||||
|
||||
if (plugin.id === id) {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
const matched = findPlugin(children, id) as Plugin;
|
||||
|
||||
if (!matched) continue;
|
||||
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const currentPlugin = useMemo(() => {
|
||||
if (!activeId) return;
|
||||
return findPlugin(plugins, activeId);
|
||||
}, [activeId, plugins]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!activeId || !currentPlugin) return;
|
||||
if (!rootState.activeExtension) return;
|
||||
|
||||
const { id, title } = rootState.activeExtension;
|
||||
|
||||
const appMetadata = await platformAdapter.invokeBackend<Metadata>(
|
||||
"get_app_metadata",
|
||||
{
|
||||
appName: currentPlugin.name,
|
||||
appPath: activeId
|
||||
appPath: id,
|
||||
appName: title,
|
||||
}
|
||||
);
|
||||
|
||||
setAppMetadata(appMetadata);
|
||||
}, [activeId, currentPlugin]);
|
||||
}, [rootState.activeExtension?.id]);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!appMetadata) return [];
|
||||
|
||||
@@ -99,7 +99,7 @@ const Applications = () => {
|
||||
<div className="flex items-center gap-1 flex-1 overflow-hidden">
|
||||
<Folder className="size-4" />
|
||||
|
||||
<span className="truncate">{item}</span>
|
||||
<span className="flex-1 truncate">{item}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -2,26 +2,24 @@ import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAsyncEffect, useMount } from "ahooks";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import { ExtensionId } from "../../..";
|
||||
|
||||
const QuickAiAccess = () => {
|
||||
const quickAiAccessServer = useExtensionsStore((state) => {
|
||||
return state.quickAiAccessServer;
|
||||
});
|
||||
const setQuickAiAccessServer = useExtensionsStore((state) => {
|
||||
return state.setQuickAiAccessServer;
|
||||
});
|
||||
const quickAiAccessAssistant = useExtensionsStore((state) => {
|
||||
return state.quickAiAccessAssistant;
|
||||
});
|
||||
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
|
||||
return state.setQuickAiAccessAssistant;
|
||||
});
|
||||
const [serverList, setServerList] = useState<any[]>([]);
|
||||
const [assistantList, setAssistantList] = useState<any[]>([]);
|
||||
interface SharedAiProps {
|
||||
id: ExtensionId;
|
||||
server?: any;
|
||||
setServer: (server: any) => void;
|
||||
assistant?: any;
|
||||
setAssistant: (assistant: any) => void;
|
||||
}
|
||||
|
||||
const SharedAi: FC<SharedAiProps> = (props) => {
|
||||
const { id, server, setServer, assistant, setAssistant } = props;
|
||||
|
||||
const [serverList, setServerList] = useState<any[]>([server]);
|
||||
const [assistantList, setAssistantList] = useState<any[]>([assistant]);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const { fetchAssistant } = AssistantFetcher({});
|
||||
|
||||
@@ -33,9 +31,9 @@ const QuickAiAccess = () => {
|
||||
|
||||
setServerList(data);
|
||||
|
||||
if (quickAiAccessServer) return;
|
||||
if (server) return;
|
||||
|
||||
setQuickAiAccessServer(data[0]);
|
||||
setServer(data[0]);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
@@ -43,77 +41,74 @@ const QuickAiAccess = () => {
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
if (!quickAiAccessServer) return;
|
||||
if (!server) return;
|
||||
|
||||
const data = await fetchAssistant({
|
||||
current: 1,
|
||||
pageSize: 1000,
|
||||
serverId: quickAiAccessServer.id,
|
||||
serverId: server.id,
|
||||
});
|
||||
|
||||
const list = data.list.map((item: any) => item._source);
|
||||
|
||||
setAssistantList(list);
|
||||
|
||||
if (quickAiAccessAssistant) {
|
||||
if (assistant) {
|
||||
const matched = list.find((item: any) => {
|
||||
return item.id === quickAiAccessAssistant.id;
|
||||
return item.id === assistant.id;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
return setQuickAiAccessAssistant(matched);
|
||||
return setAssistant(matched);
|
||||
}
|
||||
}
|
||||
|
||||
setQuickAiAccessAssistant(list[0]);
|
||||
setAssistant(list[0]);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
}, [quickAiAccessServer]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useExtensionsStore.subscribe((state) => {
|
||||
platformAdapter.emitEvent("change-extensions-store", state);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}, [server]);
|
||||
|
||||
const selectList = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: "Coco Server",
|
||||
value: quickAiAccessServer?.id,
|
||||
icon: quickAiAccessServer?.provider?.icon,
|
||||
value: server?.id,
|
||||
icon: server?.provider?.icon,
|
||||
data: serverList,
|
||||
onChange: (value: string) => {
|
||||
const matched = serverList.find((item) => item.id === value);
|
||||
|
||||
setQuickAiAccessServer(matched);
|
||||
setServer(matched);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "AI Assistant",
|
||||
value: quickAiAccessAssistant?.id,
|
||||
icon: quickAiAccessAssistant?.icon,
|
||||
value: assistant?.id,
|
||||
icon: assistant?.icon,
|
||||
data: assistantList,
|
||||
onChange: (value: string) => {
|
||||
const matched = assistantList.find((item) => item.id === value);
|
||||
|
||||
setQuickAiAccessAssistant(matched);
|
||||
setAssistant(matched);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [serverList, assistantList, quickAiAccessServer, quickAiAccessAssistant]);
|
||||
}, [serverList, assistantList, server, assistant]);
|
||||
|
||||
const renderDescription = () => {
|
||||
if (id === "QuickAIAccess") {
|
||||
return "Quick AI access allows you to start a conversation immediately from the search box using the tab key.";
|
||||
}
|
||||
|
||||
if (id === "AIOverview") {
|
||||
return "AI Summarize generates concise summaries based on your search results, helping you quickly grasp key information without reading every document.";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="text-[#999]">
|
||||
Quick AI access allows you to start a conversation immediately from the
|
||||
search box using the tab key.
|
||||
</div>
|
||||
<div className="text-[#999]">{renderDescription()}</div>
|
||||
|
||||
<div className="mt-6 text-[#333] dark:text-white/90">LinkedAssistant</div>
|
||||
|
||||
@@ -147,4 +142,4 @@ const QuickAiAccess = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAiAccess;
|
||||
export default SharedAi;
|
||||
@@ -1,40 +1,65 @@
|
||||
import { useContext, useMemo } from "react";
|
||||
import { ExtensionsContext, Plugin } from "../..";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { ExtensionsContext } from "../..";
|
||||
import Applications from "./Applications";
|
||||
import Application from "./Application";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import SharedAi from "./SharedAi";
|
||||
import AiOverview from "./AiOverview";
|
||||
|
||||
const Details = () => {
|
||||
const { plugins, activeId } = useContext(ExtensionsContext);
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
const quickAiAccessServer = useExtensionsStore((state) => {
|
||||
return state.quickAiAccessServer;
|
||||
});
|
||||
const setQuickAiAccessServer = useExtensionsStore((state) => {
|
||||
return state.setQuickAiAccessServer;
|
||||
});
|
||||
const quickAiAccessAssistant = useExtensionsStore((state) => {
|
||||
return state.quickAiAccessAssistant;
|
||||
});
|
||||
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
|
||||
return state.setQuickAiAccessAssistant;
|
||||
});
|
||||
|
||||
const findPlugin = (plugins: Plugin[], id: string) => {
|
||||
for (const plugin of plugins) {
|
||||
const { children = [] } = plugin;
|
||||
const renderContent = () => {
|
||||
if (!rootState.activeExtension) return;
|
||||
|
||||
if (plugin.id === id) {
|
||||
return plugin;
|
||||
}
|
||||
const { id, type } = rootState.activeExtension;
|
||||
|
||||
if (children.length > 0) {
|
||||
const matched = findPlugin(children, id) as Plugin;
|
||||
if (id === "Applications") {
|
||||
return <Applications />;
|
||||
}
|
||||
|
||||
if (!matched) continue;
|
||||
if (type === "application") {
|
||||
return <Application />;
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
if (id === "QuickAIAccess") {
|
||||
return (
|
||||
<SharedAi
|
||||
key="QuickAIAccess"
|
||||
id="QuickAIAccess"
|
||||
server={quickAiAccessServer}
|
||||
setServer={setQuickAiAccessServer}
|
||||
assistant={quickAiAccessAssistant}
|
||||
setAssistant={setQuickAiAccessAssistant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (id === "AIOverview") {
|
||||
return <AiOverview />;
|
||||
}
|
||||
};
|
||||
|
||||
const currentPlugin = useMemo(() => {
|
||||
if (!activeId) return;
|
||||
|
||||
return findPlugin(plugins, activeId);
|
||||
}, [activeId, plugins]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full overflow-auto">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{currentPlugin?.name}
|
||||
{rootState.activeExtension?.title}
|
||||
</h2>
|
||||
|
||||
<div className="pr-4">{currentPlugin?.detail}</div>
|
||||
<div className="pr-4 pb-4">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,210 +1,107 @@
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Bot, Calculator, Folder } from "lucide-react";
|
||||
import { noop } from "lodash-es";
|
||||
import { useMount } from "ahooks";
|
||||
import { createContext, useEffect } from "react";
|
||||
import { useMount, useReactive } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LiteralUnion } from "type-fest";
|
||||
|
||||
import ApplicationsDetail from "./components/Details/Applications";
|
||||
import Application from "./components/Details/Application";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import Content from "./components/Content";
|
||||
import Details from "./components/Details";
|
||||
import QuickAiAccess from "./components/Details/QuickAiAccess";
|
||||
import { cloneDeep, sortBy } from "lodash-es";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
export interface IApplication {
|
||||
path: string;
|
||||
name: string;
|
||||
iconPath: string;
|
||||
alias: string;
|
||||
hotkey: string;
|
||||
isDisabled: boolean;
|
||||
export type ExtensionId = LiteralUnion<
|
||||
"Applications" | "Calculator" | "QuickAIAccess" | "AIOverview",
|
||||
string
|
||||
>;
|
||||
|
||||
type ExtensionType =
|
||||
| "group"
|
||||
| "extension"
|
||||
| "application"
|
||||
| "script"
|
||||
| "quick_link"
|
||||
| "setting"
|
||||
| "calculator"
|
||||
| "command"
|
||||
| "ai_extension";
|
||||
|
||||
export type ExtensionPlatform = "windows" | "macos" | "linux";
|
||||
|
||||
interface ExtensionAction {
|
||||
exec: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
id: string;
|
||||
icon: ReactElement;
|
||||
name: ReactNode;
|
||||
type?: "Group" | "Extension" | "Application";
|
||||
interface ExtensionQuickLink {
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: ExtensionId;
|
||||
type: ExtensionType;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
alias?: string;
|
||||
hotkey?: string;
|
||||
enabled?: boolean;
|
||||
detail?: ReactNode;
|
||||
children?: Plugin[];
|
||||
manualLoad?: boolean;
|
||||
loadChildren?: () => Promise<void>;
|
||||
onAliasChange?: (alias: string) => void;
|
||||
onHotkeyChange?: (hotkey: string) => void;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
enabled: boolean;
|
||||
platforms?: ExtensionPlatform[];
|
||||
action: ExtensionAction;
|
||||
quick_link: ExtensionQuickLink;
|
||||
commands?: Extension[];
|
||||
scripts?: Extension[];
|
||||
quick_links?: Extension[];
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExtensionsContextType {
|
||||
plugins: Plugin[];
|
||||
setPlugins: Dispatch<SetStateAction<Plugin[]>>;
|
||||
activeId?: string;
|
||||
setActiveId: (id: string) => void;
|
||||
interface State {
|
||||
extensions: Extension[];
|
||||
activeExtension?: Extension;
|
||||
}
|
||||
|
||||
export const ExtensionsContext = createContext<ExtensionsContextType>({
|
||||
plugins: [],
|
||||
setPlugins: noop,
|
||||
setActiveId: noop,
|
||||
const INITIAL_STATE: State = {
|
||||
extensions: [],
|
||||
};
|
||||
|
||||
export const ExtensionsContext = createContext<{ rootState: State }>({
|
||||
rootState: INITIAL_STATE,
|
||||
});
|
||||
|
||||
const Extensions = () => {
|
||||
export const Extensions = () => {
|
||||
const { t } = useTranslation();
|
||||
const [apps, setApps] = useState<IApplication[]>([]);
|
||||
const [disabled, setDisabled] = useState<string[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>();
|
||||
|
||||
useMount(async () => {
|
||||
const disabled = await platformAdapter.invokeBackend<string[]>(
|
||||
"get_disabled_local_query_sources"
|
||||
);
|
||||
|
||||
setDisabled(disabled);
|
||||
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
|
||||
const setDisabledExtensions = useExtensionsStore((state) => {
|
||||
return state.setDisabledExtensions;
|
||||
});
|
||||
|
||||
const loadApps = async () => {
|
||||
const apps = await platformAdapter.invokeBackend<IApplication[]>(
|
||||
"get_app_list"
|
||||
useMount(async () => {
|
||||
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
|
||||
"list_extensions"
|
||||
);
|
||||
|
||||
const sortedApps = apps.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
const extensions = result[1];
|
||||
|
||||
setApps(sortedApps);
|
||||
};
|
||||
const disabledExtensions = extensions.filter((item) => !item.enabled);
|
||||
|
||||
const presetPlugins = useMemo<Plugin[]>(() => {
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
id: "Applications",
|
||||
icon: <Folder />,
|
||||
name: t("settings.extensions.application.title"),
|
||||
type: "Group",
|
||||
detail: <ApplicationsDetail />,
|
||||
children: [],
|
||||
manualLoad: true,
|
||||
loadChildren: loadApps,
|
||||
},
|
||||
{
|
||||
id: "Calculator",
|
||||
icon: <Calculator />,
|
||||
name: t("settings.extensions.calculator.title"),
|
||||
},
|
||||
{
|
||||
id: "QuickAiAccess",
|
||||
icon: <Bot />,
|
||||
name: "Quick AI Access",
|
||||
detail: <QuickAiAccess />,
|
||||
},
|
||||
];
|
||||
setDisabledExtensions(disabledExtensions.map((item) => item.id));
|
||||
|
||||
if (apps.length > 0) {
|
||||
for (const app of apps) {
|
||||
const { path, iconPath, isDisabled } = app;
|
||||
|
||||
plugins[0].children?.push({
|
||||
...app,
|
||||
id: path,
|
||||
type: "Application",
|
||||
icon: (
|
||||
<img
|
||||
src={platformAdapter.convertFileSrc(iconPath)}
|
||||
className="size-5"
|
||||
/>
|
||||
),
|
||||
enabled: !isDisabled,
|
||||
detail: <Application />,
|
||||
onAliasChange(alias) {
|
||||
platformAdapter.invokeBackend("set_app_alias", {
|
||||
appPath: path,
|
||||
alias,
|
||||
});
|
||||
|
||||
const nextApps = apps.map((item) => {
|
||||
if (item.path !== path) return item;
|
||||
|
||||
return { ...item, alias };
|
||||
});
|
||||
|
||||
setApps(nextApps);
|
||||
},
|
||||
onHotkeyChange(hotkey) {
|
||||
const command = `${hotkey ? "register" : "unregister"}_app_hotkey`;
|
||||
|
||||
platformAdapter.invokeBackend(command, {
|
||||
appPath: path,
|
||||
hotkey,
|
||||
});
|
||||
|
||||
const nextApps = apps.map((item) => {
|
||||
if (item.path !== path) return item;
|
||||
|
||||
return { ...item, hotkey };
|
||||
});
|
||||
|
||||
setApps(nextApps);
|
||||
},
|
||||
onEnabledChange(enabled) {
|
||||
const command = `${enabled ? "enable" : "disable"}_app_search`;
|
||||
|
||||
platformAdapter.invokeBackend(command, {
|
||||
appPath: path,
|
||||
});
|
||||
|
||||
const nextApps = apps.map((item) => {
|
||||
if (item.path !== path) return item;
|
||||
|
||||
return { ...item, isDisabled: !enabled };
|
||||
});
|
||||
|
||||
setApps(nextApps);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}, [apps]);
|
||||
|
||||
const [plugins, setPlugins] = useState<Plugin[]>(presetPlugins);
|
||||
state.extensions = sortBy(extensions, ["title"]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPlugins(presetPlugins);
|
||||
}, [presetPlugins]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlugins((prevPlugins) => {
|
||||
return prevPlugins.map((item) => {
|
||||
if (disabled.includes(item.id)) {
|
||||
return { ...item, enabled: false };
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
const unsubscribe = useExtensionsStore.subscribe((state) => {
|
||||
platformAdapter.emitEvent("change-extensions-store", state);
|
||||
});
|
||||
}, [disabled]);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<ExtensionsContext.Provider
|
||||
value={{
|
||||
plugins,
|
||||
setPlugins,
|
||||
activeId,
|
||||
setActiveId,
|
||||
rootState: state,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4">
|
||||
@@ -217,7 +114,7 @@ const Extensions = () => {
|
||||
<div className="flex">
|
||||
<div className="flex-1">{t("settings.extensions.list.name")}</div>
|
||||
|
||||
<div className="w-3/5 flex">
|
||||
<div className="w-4/6 flex">
|
||||
<div className="flex-1">
|
||||
{t("settings.extensions.list.type")}
|
||||
</div>
|
||||
@@ -227,7 +124,7 @@ const Extensions = () => {
|
||||
<div className="flex-1">
|
||||
{t("settings.extensions.list.hotkey")}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="w-16 text-right whitespace-nowrap">
|
||||
{t("settings.extensions.list.enabled")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// import { Select, SelectProps } from "@headlessui/react";
|
||||
import { Select, SelectProps } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { isArray } from "lodash-es";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { FC } from "react";
|
||||
|
||||
@@ -24,11 +24,11 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
||||
} = props;
|
||||
|
||||
const renderOptions = () => {
|
||||
if (data) {
|
||||
if (isArray(data)) {
|
||||
return data.map((item) => {
|
||||
return (
|
||||
<option key={item[valueField]} value={item[valueField]}>
|
||||
{item[labelField]}
|
||||
<option key={item?.[valueField]} value={item?.[valueField]}>
|
||||
{item?.[labelField]}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { Switch, SwitchProps } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface SettingsToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
interface SettingsToggleProps extends SwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SettingsToggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
}: SettingsToggleProps) {
|
||||
export default function SettingsToggle(props: SettingsToggleProps) {
|
||||
const { label, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
className={clsx(
|
||||
`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`,
|
||||
[checked ? "bg-blue-600" : "bg-gray-200 dark:bg-gray-700"],
|
||||
`group relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 bg-gray-200 dark:bg-gray-700 data-[checked]:bg-blue-600`,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<span
|
||||
className={`${checked ? "translate-x-5" : "translate-x-0"}
|
||||
pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow
|
||||
ring-0 transition duration-200 ease-in-out`}
|
||||
className="pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow
|
||||
ring-0 transition duration-200 ease-in-out translate-x-0 group-data-[checked]:translate-x-5"
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,4 @@ export const HISTORY_PANEL_ID = "headlessui-popover-panel:history-panel";
|
||||
|
||||
export const CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
|
||||
|
||||
export const ASK_AI_CLIENT_ID = "ask-ai-client";
|
||||
|
||||
export const COPY_BUTTON_ID = "copy-button";
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { isMetaOrCtrlKey, metaOrCtrlKey } from '@/utils/keyboardUtils';
|
||||
import { copyToClipboard, OpenURLWithBrowser } from "@/utils/index";
|
||||
import { copyToClipboard } from "@/utils/index";
|
||||
import type { QueryHits, SearchDocument } from "@/types/search";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface UseKeyboardNavigationProps {
|
||||
suggests: QueryHits[];
|
||||
@@ -67,12 +68,11 @@ export function useKeyboardNavigation({
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
selectedIndex !== null &&
|
||||
isMetaOrCtrlKey(e)
|
||||
selectedIndex !== null
|
||||
) {
|
||||
const item = globalItemIndexMap[selectedIndex];
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
if (item?.on_opened) {
|
||||
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
|
||||
} else {
|
||||
copyToClipboard(item?.payload?.result?.value);
|
||||
}
|
||||
@@ -85,8 +85,8 @@ export function useKeyboardNavigation({
|
||||
|
||||
const item = globalItemIndexMap[index];
|
||||
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
if (item?.on_opened) {
|
||||
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const useScript = (src: string, onError?: () => void) => {
|
||||
useEffect(() => {
|
||||
@@ -6,7 +6,7 @@ const useScript = (src: string, onError?: () => void) => {
|
||||
return; // Prevent duplicate script loading
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
|
||||
@@ -25,24 +25,27 @@ const useScript = (src: string, onError?: () => void) => {
|
||||
|
||||
export default useScript;
|
||||
|
||||
|
||||
export const useIconfontScript = () => {
|
||||
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
||||
|
||||
const [useLocalFallback, setUseLocalFallback] = useState(false);
|
||||
|
||||
let baseURL = appStore.state?.endpoint_http
|
||||
let baseURL = appStore.state?.endpoint_http;
|
||||
if (!baseURL || baseURL === "undefined") {
|
||||
baseURL = "";
|
||||
}
|
||||
|
||||
if (useLocalFallback || baseURL === "") {
|
||||
useScript('/assets/fonts/icons/iconfont.js');
|
||||
useScript("/assets/fonts/icons/iconfont.js");
|
||||
return;
|
||||
}
|
||||
|
||||
useScript(`${baseURL}/assets/fonts/icons/iconfont.js`, () => {
|
||||
console.log("Remote iconfont loading failed, falling back to local resource");
|
||||
console.log(
|
||||
"Remote iconfont loading failed, falling back to local resource"
|
||||
);
|
||||
setUseLocalFallback(true);
|
||||
});
|
||||
|
||||
useScript("/assets/fonts/icons/extension.js");
|
||||
};
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
import type { QueryHits, MultiSourceQueryResponse, FailedRequest, SearchDocument } from '@/types/search';
|
||||
import type {
|
||||
QueryHits,
|
||||
MultiSourceQueryResponse,
|
||||
FailedRequest,
|
||||
SearchDocument,
|
||||
} from "@/types/search";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
interface SearchState {
|
||||
isError: FailedRequest[];
|
||||
@@ -21,6 +28,25 @@ interface SearchDataBySource {
|
||||
|
||||
export function useSearch() {
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const enabledAiOverview = useSearchStore((state) => {
|
||||
return state.enabledAiOverview;
|
||||
});
|
||||
const aiOverviewServer = useExtensionsStore((state) => {
|
||||
return state.aiOverviewServer;
|
||||
});
|
||||
const aiOverviewAssistant = useExtensionsStore((state) => {
|
||||
return state.aiOverviewAssistant;
|
||||
});
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const disabledExtensions = useExtensionsStore((state) => {
|
||||
return state.disabledExtensions;
|
||||
});
|
||||
const aiOverviewCharLen = useExtensionsStore((state) => {
|
||||
return state.aiOverviewCharLen;
|
||||
});
|
||||
const aiOverviewDelay = useExtensionsStore((state) => {
|
||||
return state.aiOverviewDelay;
|
||||
});
|
||||
|
||||
const { querySourceTimeout } = useConnectStore();
|
||||
|
||||
@@ -29,22 +55,28 @@ export function useSearch() {
|
||||
suggests: [],
|
||||
searchData: {},
|
||||
isSearchComplete: false,
|
||||
globalItemIndexMap: {}
|
||||
globalItemIndexMap: {},
|
||||
});
|
||||
|
||||
const handleSearchResponse = (response: MultiSourceQueryResponse) => {
|
||||
const handleSearchResponse = (
|
||||
response: MultiSourceQueryResponse,
|
||||
searchInput: string
|
||||
) => {
|
||||
const data = response?.hits || [];
|
||||
|
||||
const searchData = data.reduce((acc: SearchDataBySource, item: QueryHits) => {
|
||||
const name = item?.document?.source?.name;
|
||||
if (name) {
|
||||
if (!acc[name]) {
|
||||
acc[name] = [];
|
||||
const searchData = data.reduce(
|
||||
(acc: SearchDataBySource, item: QueryHits) => {
|
||||
const name = item?.document?.source?.name;
|
||||
if (name) {
|
||||
if (!acc[name]) {
|
||||
acc[name] = [];
|
||||
}
|
||||
acc[name].push(item);
|
||||
}
|
||||
acc[name].push(item);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
// Update indices and map
|
||||
//console.log("_search response", data, searchData);
|
||||
@@ -54,10 +86,65 @@ export function useSearch() {
|
||||
searchData[sourceName].map((item: QueryHits) => {
|
||||
item.document.querySource = item?.source;
|
||||
const index = globalIndex++;
|
||||
item.document.index = index
|
||||
item.document.index = index;
|
||||
globalItemIndexMap[index] = item.document;
|
||||
return item;
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const filteredData = data.filter((item: any) => {
|
||||
return (
|
||||
item?.document?.type !== "AI Assistant" &&
|
||||
item?.document?.category !== "Calculator" &&
|
||||
item?.document?.category !== "Application"
|
||||
);
|
||||
});
|
||||
|
||||
console.log("aiOverviewCharLen", aiOverviewCharLen);
|
||||
console.log("aiOverviewDelay", aiOverviewDelay);
|
||||
|
||||
if (
|
||||
searchInput.length >= aiOverviewCharLen &&
|
||||
isTauri &&
|
||||
enabledAiOverview &&
|
||||
aiOverviewServer &&
|
||||
aiOverviewAssistant &&
|
||||
filteredData.length > 5 &&
|
||||
!disabledExtensions.includes("AIOverview")
|
||||
) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
const id = "AI Overview";
|
||||
|
||||
const payload = {
|
||||
source: {
|
||||
id,
|
||||
type: id,
|
||||
},
|
||||
document: {
|
||||
index: 1000000,
|
||||
id,
|
||||
category: id,
|
||||
payload: {
|
||||
message: JSON.stringify({
|
||||
query: searchInput,
|
||||
result: filteredData,
|
||||
}),
|
||||
},
|
||||
source: {
|
||||
icon: "font_a-AIOverview",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
suggests: prev.suggests.concat(payload as any),
|
||||
searchData: {
|
||||
[id]: [payload as any],
|
||||
...prev.searchData,
|
||||
},
|
||||
}));
|
||||
}, aiOverviewDelay * 1000);
|
||||
}
|
||||
|
||||
setSearchState({
|
||||
@@ -69,47 +156,65 @@ export function useSearch() {
|
||||
});
|
||||
};
|
||||
|
||||
const performSearch = useCallback(async (searchInput: string) => {
|
||||
if (!searchInput) {
|
||||
setSearchState(prev => ({ ...prev, suggests: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
let response: MultiSourceQueryResponse;
|
||||
if (isTauri) {
|
||||
response = await platformAdapter.commands("query_coco_fusion", {
|
||||
from: 0,
|
||||
size: 10,
|
||||
queryStrings: { query: searchInput },
|
||||
queryTimeout: querySourceTimeout,
|
||||
});
|
||||
} else {
|
||||
const [error, res]: any = await Get(`/query/_search?query=${searchInput}`);
|
||||
if (error) {
|
||||
console.error("_search", error);
|
||||
response = { failed: [], hits: [], total_hits: 0 };
|
||||
} else {
|
||||
const hits =
|
||||
res?.hits?.hits?.map((hit: any) => ({
|
||||
document: {
|
||||
...hit._source,
|
||||
},
|
||||
score: hit._score || 0,
|
||||
source: hit._source.source || null,
|
||||
})) || [];
|
||||
const total = res?.hits?.total?.value || 0;
|
||||
response = {
|
||||
failed: [],
|
||||
hits: hits,
|
||||
total_hits: total,
|
||||
};
|
||||
const performSearch = useCallback(
|
||||
async (searchInput: string) => {
|
||||
if (!searchInput) {
|
||||
setSearchState((prev) => ({ ...prev, suggests: [] }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("_suggest", searchInput, response);
|
||||
let response: MultiSourceQueryResponse;
|
||||
if (isTauri) {
|
||||
response = await platformAdapter.commands("query_coco_fusion", {
|
||||
from: 0,
|
||||
size: 10,
|
||||
queryStrings: { query: searchInput },
|
||||
queryTimeout: querySourceTimeout,
|
||||
});
|
||||
} else {
|
||||
const [error, res]: any = await Get(
|
||||
`/query/_search?query=${searchInput}`
|
||||
);
|
||||
if (error) {
|
||||
console.error("_search", error);
|
||||
response = { failed: [], hits: [], total_hits: 0 };
|
||||
} else {
|
||||
const hits =
|
||||
res?.hits?.hits?.map((hit: any) => ({
|
||||
document: {
|
||||
...hit._source,
|
||||
},
|
||||
score: hit._score || 0,
|
||||
source: hit._source.source || null,
|
||||
})) || [];
|
||||
const total = res?.hits?.total?.value || 0;
|
||||
response = {
|
||||
failed: [],
|
||||
hits: hits,
|
||||
total_hits: total,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchResponse(response);
|
||||
}, [querySourceTimeout, isTauri]);
|
||||
console.log("_suggest", searchInput, response);
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
handleSearchResponse(response, searchInput);
|
||||
},
|
||||
[
|
||||
querySourceTimeout,
|
||||
isTauri,
|
||||
enabledAiOverview,
|
||||
aiOverviewServer,
|
||||
aiOverviewAssistant,
|
||||
disabledExtensions,
|
||||
aiOverviewCharLen,
|
||||
aiOverviewDelay,
|
||||
]
|
||||
);
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() => debounce(performSearch, 300),
|
||||
@@ -118,6 +223,6 @@ export function useSearch() {
|
||||
|
||||
return {
|
||||
...searchState,
|
||||
performSearch: debouncedSearch
|
||||
performSearch: debouncedSearch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
135
src/hooks/useStreamChat.ts
Normal file
135
src/hooks/useStreamChat.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { EventPayloads } from "@/types/platform";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAsyncEffect, useMount, useReactive, useUnmount } from "ahooks";
|
||||
import { noop } from "lodash-es";
|
||||
import { useRef, useState } from "react";
|
||||
import useMessageChunkData from "./useMessageChunkData";
|
||||
|
||||
interface Options {
|
||||
message: string;
|
||||
clientId: keyof EventPayloads;
|
||||
server?: any;
|
||||
assistant?: any;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
sessionId?: string;
|
||||
isTyping?: boolean;
|
||||
}
|
||||
|
||||
export const useStreamChat = (options: Options) => {
|
||||
const { message, clientId, server, assistant, setVisible } = options;
|
||||
|
||||
const unlistenRef = useRef<() => void>(noop);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const state = useReactive<State>({
|
||||
isTyping: true,
|
||||
});
|
||||
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
|
||||
query_intent: false,
|
||||
tools: false,
|
||||
fetch_source: false,
|
||||
pick_source: false,
|
||||
deep_read: false,
|
||||
think: false,
|
||||
response: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: chunkData,
|
||||
handlers,
|
||||
clearAllChunkData,
|
||||
} = useMessageChunkData();
|
||||
|
||||
useMount(async () => {
|
||||
try {
|
||||
unlistenRef.current = await platformAdapter.listenEvent(
|
||||
clientId,
|
||||
({ payload }) => {
|
||||
console.log(clientId, JSON.parse(payload));
|
||||
|
||||
const chunkData = JSON.parse(payload);
|
||||
|
||||
if (chunkData?._id) {
|
||||
state.sessionId = chunkData._id;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.sessionId !== chunkData.session_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the chunk data does not contain a message_chunk, we ignore it
|
||||
if (chunkData.message_chunk) {
|
||||
setVisible(true);
|
||||
}
|
||||
|
||||
state.isTyping = true;
|
||||
|
||||
setLoadingStep(() => ({
|
||||
query_intent: false,
|
||||
tools: false,
|
||||
fetch_source: false,
|
||||
pick_source: false,
|
||||
deep_read: false,
|
||||
think: false,
|
||||
response: false,
|
||||
[chunkData.chunk_type]: true,
|
||||
}));
|
||||
|
||||
if (chunkData.chunk_type === "query_intent") {
|
||||
handlers.deal_query_intent(chunkData);
|
||||
} else if (chunkData.chunk_type === "tools") {
|
||||
handlers.deal_tools(chunkData);
|
||||
} else if (chunkData.chunk_type === "fetch_source") {
|
||||
handlers.deal_fetch_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "pick_source") {
|
||||
handlers.deal_pick_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "deep_read") {
|
||||
handlers.deal_deep_read(chunkData);
|
||||
} else if (chunkData.chunk_type === "think") {
|
||||
handlers.deal_think(chunkData);
|
||||
} else if (chunkData.chunk_type === "response") {
|
||||
handlers.deal_response(chunkData);
|
||||
} else if (chunkData.chunk_type === "reply_end") {
|
||||
console.log("AI finished output");
|
||||
state.isTyping = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!message || !server || !assistant) return;
|
||||
|
||||
clearAllChunkData();
|
||||
|
||||
try {
|
||||
await platformAdapter.invokeBackend("ask_ai", {
|
||||
message,
|
||||
clientId,
|
||||
serverId: server.id,
|
||||
assistantId: assistant.id,
|
||||
});
|
||||
} catch (error) {
|
||||
addError(String(error));
|
||||
}
|
||||
}, [message, server, assistant]);
|
||||
|
||||
useUnmount(() => {
|
||||
unlistenRef.current();
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
chunkData,
|
||||
loadingStep,
|
||||
};
|
||||
};
|
||||
@@ -93,6 +93,21 @@ export const useSyncStore = () => {
|
||||
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
|
||||
return state.setQuickAiAccessAssistant;
|
||||
});
|
||||
const setAiOverviewServer = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewServer;
|
||||
});
|
||||
const setAiOverviewAssistant = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewAssistant;
|
||||
});
|
||||
const setDisabledExtensions = useExtensionsStore((state) => {
|
||||
return state.setDisabledExtensions;
|
||||
});
|
||||
const setAiOverviewCharLen = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewCharLen;
|
||||
});
|
||||
const setAiOverviewDelay = useExtensionsStore((state) => {
|
||||
return state.setAiOverviewDelay;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!resetFixedWindow) {
|
||||
@@ -174,10 +189,23 @@ export const useSyncStore = () => {
|
||||
}),
|
||||
|
||||
platformAdapter.listenEvent("change-extensions-store", ({ payload }) => {
|
||||
const { quickAiAccessServer, quickAiAccessAssistant } = payload;
|
||||
const {
|
||||
quickAiAccessServer,
|
||||
quickAiAccessAssistant,
|
||||
aiOverviewServer,
|
||||
aiOverviewAssistant,
|
||||
disabledExtensions,
|
||||
aiOverviewCharLen,
|
||||
aiOverviewDelay,
|
||||
} = payload;
|
||||
|
||||
setQuickAiAccessServer(quickAiAccessServer);
|
||||
setQuickAiAccessAssistant(quickAiAccessAssistant);
|
||||
setAiOverviewServer(aiOverviewServer);
|
||||
setAiOverviewAssistant(aiOverviewAssistant);
|
||||
setDisabledExtensions(disabledExtensions);
|
||||
setAiOverviewCharLen(aiOverviewCharLen);
|
||||
setAiOverviewDelay(aiOverviewDelay);
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
12
src/main.css
12
src/main.css
@@ -241,7 +241,7 @@
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.user-select-text {
|
||||
-webkit-touch-callout: text;
|
||||
-webkit-user-select: text;
|
||||
@@ -250,4 +250,14 @@
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
overflow: auto;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { AppTheme } from "@/types/index";
|
||||
import ErrorNotification from "@/components/Common/ErrorNotification";
|
||||
import { useModifierKeyPress } from "@/hooks/useModifierKeyPress";
|
||||
import { useIconfontScript } from "@/hooks/useScript";
|
||||
import { Extension } from "@/components/Settings/Extensions";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
@@ -119,6 +121,20 @@ export default function Layout() {
|
||||
|
||||
useIconfontScript();
|
||||
|
||||
const setDisabledExtensions = useExtensionsStore((state) => {
|
||||
return state.setDisabledExtensions;
|
||||
});
|
||||
|
||||
useMount(async () => {
|
||||
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
|
||||
"list_extensions"
|
||||
);
|
||||
|
||||
const disabledExtensions = result[1].filter((item) => !item.enabled);
|
||||
|
||||
setDisabledExtensions(disabledExtensions.map((item) => item.id));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExtensionId } from "@/components/Settings/Extensions";
|
||||
import { create } from "zustand";
|
||||
import { persist, subscribeWithSelector } from "zustand/middleware";
|
||||
|
||||
@@ -6,6 +7,16 @@ export type IExtensionsStore = {
|
||||
setQuickAiAccessServer: (quickAiAccessServer?: any) => void;
|
||||
quickAiAccessAssistant?: any;
|
||||
setQuickAiAccessAssistant: (quickAiAccessAssistant?: any) => void;
|
||||
aiOverviewServer?: any;
|
||||
setAiOverviewServer: (aiOverviewServer?: any) => void;
|
||||
aiOverviewAssistant?: any;
|
||||
setAiOverviewAssistant: (aiOverviewAssistant?: any) => void;
|
||||
disabledExtensions: ExtensionId[];
|
||||
setDisabledExtensions: (disabledExtensions?: string[]) => void;
|
||||
aiOverviewCharLen: number;
|
||||
setAiOverviewCharLen: (aiOverviewCharLen: number) => void;
|
||||
aiOverviewDelay: number;
|
||||
setAiOverviewDelay: (aiOverviewDelay: number) => void;
|
||||
};
|
||||
|
||||
export const useExtensionsStore = create<IExtensionsStore>()(
|
||||
@@ -18,12 +29,34 @@ export const useExtensionsStore = create<IExtensionsStore>()(
|
||||
setQuickAiAccessAssistant(quickAiAccessAssistant) {
|
||||
return set({ quickAiAccessAssistant });
|
||||
},
|
||||
setAiOverviewServer(aiOverviewServer) {
|
||||
return set({ aiOverviewServer });
|
||||
},
|
||||
setAiOverviewAssistant(aiOverviewAssistant) {
|
||||
return set({ aiOverviewAssistant });
|
||||
},
|
||||
disabledExtensions: [],
|
||||
setDisabledExtensions(disabledExtensions) {
|
||||
return set({ disabledExtensions });
|
||||
},
|
||||
aiOverviewCharLen: 10,
|
||||
setAiOverviewCharLen(aiOverviewCharLen) {
|
||||
return set({ aiOverviewCharLen });
|
||||
},
|
||||
aiOverviewDelay: 2,
|
||||
setAiOverviewDelay(aiOverviewDelay) {
|
||||
return set({ aiOverviewDelay });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "extensions-store",
|
||||
partialize: (state) => ({
|
||||
quickAiAccessServer: state.quickAiAccessServer,
|
||||
quickAiAccessAssistant: state.quickAiAccessAssistant,
|
||||
aiOverviewServer: state.aiOverviewServer,
|
||||
aiOverviewAssistant: state.aiOverviewAssistant,
|
||||
aiOverviewCharLen: state.aiOverviewCharLen,
|
||||
aiOverviewDelay: state.aiOverviewDelay,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -24,6 +24,10 @@ export type ISearchStore = {
|
||||
setSelectedAssistant: (selectedAssistant?: any) => void;
|
||||
askAiServerId?: string;
|
||||
setAskAiServerId: (askAiServerId?: string) => void;
|
||||
enabledAiOverview: boolean;
|
||||
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
|
||||
askAiAssistantId?: string;
|
||||
setAskAiAssistantId: (askAiAssistantId?: string) => void;
|
||||
};
|
||||
|
||||
export const useSearchStore = create<ISearchStore>()(
|
||||
@@ -59,6 +63,13 @@ export const useSearchStore = create<ISearchStore>()(
|
||||
setAskAiServerId: (askAiServerId) => {
|
||||
return set({ askAiServerId });
|
||||
},
|
||||
enabledAiOverview: false,
|
||||
setEnabledAiOverview: (enabledAiOverview) => {
|
||||
return set({ enabledAiOverview });
|
||||
},
|
||||
setAskAiAssistantId: (askAiAssistantId) => {
|
||||
return set({ askAiAssistantId });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "search-store",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ASK_AI_CLIENT_ID } from "@/constants";
|
||||
import { IAppearanceStore } from "@/stores/appearanceStore";
|
||||
import { IConnectStore } from "@/stores/connectStore";
|
||||
import { IExtensionsStore } from "@/stores/extensionsStore";
|
||||
@@ -38,9 +37,10 @@ export interface EventPayloads {
|
||||
"change-shortcuts-store": IShortcutsStore;
|
||||
"change-connect-store": IConnectStore;
|
||||
"change-appearance-store": IAppearanceStore;
|
||||
[ASK_AI_CLIENT_ID]: any;
|
||||
"toggle-to-chat-mode": void;
|
||||
"change-extensions-store": IExtensionsStore;
|
||||
"quick-ai-access-client-id": any;
|
||||
"ai-overview-client-id": any;
|
||||
}
|
||||
|
||||
// Window operation interface
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface QueryHits {
|
||||
|
||||
export interface QuerySource {
|
||||
type: string; // coco-server/local/ etc.
|
||||
id: string; // coco server's id
|
||||
id: string; // coco server's id
|
||||
name: string; // coco server's name, local computer name, etc.
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface SearchDocument {
|
||||
querySource?: QuerySource;
|
||||
index?: number; // Index in the current search result
|
||||
globalIndex?: number;
|
||||
on_opened?: any;
|
||||
}
|
||||
|
||||
export interface RichLabel {
|
||||
@@ -74,4 +75,4 @@ export interface MultiSourceQueryResponse {
|
||||
failed: FailedRequest[];
|
||||
hits: QueryHits[];
|
||||
total_hits: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,3 +113,7 @@ export const closeHistoryPanel = () => {
|
||||
button.click();
|
||||
}
|
||||
};
|
||||
|
||||
// export const sortByFirstLetter = <T>(list: T[], key: keyof T) => {
|
||||
// return list.sort((a, b) => {});
|
||||
// };
|
||||
|
||||
@@ -13,6 +13,18 @@ console.log("isMac", isMac);
|
||||
console.log("isWin", isWin);
|
||||
console.log("isLinux", isLinux);
|
||||
|
||||
export function platform() {
|
||||
if (isWin) {
|
||||
return "windows";
|
||||
} else if (isMac) {
|
||||
return "macos";
|
||||
} else if (isLinux) {
|
||||
return "linux";
|
||||
}
|
||||
|
||||
return void 0;
|
||||
}
|
||||
|
||||
export function family() {
|
||||
if (isWeb) {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
|
||||
@@ -207,8 +207,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
|
||||
},
|
||||
|
||||
async openExternal(url) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke("open", { path: url });
|
||||
const { open } = await import("@tauri-apps/plugin-shell");
|
||||
|
||||
open(url);
|
||||
},
|
||||
|
||||
isWindows10,
|
||||
|
||||
@@ -56,7 +56,7 @@ export default {
|
||||
2000: "2000",
|
||||
},
|
||||
screens: {
|
||||
'mobile': {'max': '679px'},
|
||||
mobile: { max: "679px" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ export default defineConfig({
|
||||
|
||||
const packageJson = {
|
||||
name: "@infinilabs/search-chat",
|
||||
version: "1.2.5",
|
||||
version: "1.2.8",
|
||||
main: "index.js",
|
||||
module: "index.js",
|
||||
type: "module",
|
||||
|
||||
Reference in New Issue
Block a user