mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 19:47: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: data sources support displaying customized icons #432
|
||||||
- feat: add shortcut key conflict hint and reset function #442
|
- feat: add shortcut key conflict hint and reset function #442
|
||||||
- feat: updated to include error message #465
|
- feat: updated to include error message #465
|
||||||
|
- feat: support third party extensions #572
|
||||||
|
- feat: support ai overview #572
|
||||||
|
|
||||||
### Bug fix
|
### Bug fix
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||||
"tauri-plugin-windows-version-api": "^2.0.0",
|
"tauri-plugin-windows-version-api": "^2.0.0",
|
||||||
|
"type-fest": "^4.41.0",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"wavesurfer.js": "^7.9.5",
|
"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:
|
tauri-plugin-windows-version-api:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
|
type-fest:
|
||||||
|
specifier: ^4.41.0
|
||||||
|
version: 4.41.0
|
||||||
use-debounce:
|
use-debounce:
|
||||||
specifier: ^10.0.4
|
specifier: ^10.0.4
|
||||||
version: 10.0.4(react@18.3.1)
|
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"
|
name = "coco"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"applications",
|
"applications",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.13.1",
|
"base64 0.13.1",
|
||||||
"chinese-number",
|
"chinese-number",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"derive_more 2.0.1",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"enigo",
|
"enigo",
|
||||||
|
"function_name",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hostname",
|
"hostname",
|
||||||
@@ -847,6 +850,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_plain",
|
||||||
"strsim 0.10.0",
|
"strsim 0.10.0",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -1291,6 +1295,27 @@ dependencies = [
|
|||||||
"syn 2.0.101",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -1826,6 +1851,21 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "funty"
|
name = "funty"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -5328,7 +5368,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"derive_more",
|
"derive_more 0.99.20",
|
||||||
"fxhash",
|
"fxhash",
|
||||||
"log",
|
"log",
|
||||||
"matches",
|
"matches",
|
||||||
@@ -5403,6 +5443,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_plain"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -7040,6 +7089,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ chinese-number = "0.7"
|
|||||||
num2words = "1"
|
num2words = "1"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
chrono = "0.4.41"
|
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]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||||
|
|||||||
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]
|
[toolchain]
|
||||||
channel = "nightly-2024-10-29"
|
channel = "nightly-2025-02-28"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::hide_coco;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RichLabel {
|
pub struct RichLabel {
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
@@ -29,6 +31,72 @@ pub struct EditorInfo {
|
|||||||
pub timestamp: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Document {
|
pub struct Document {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -48,6 +116,8 @@ pub struct Document {
|
|||||||
pub thumbnail: Option<String>,
|
pub thumbnail: Option<String>,
|
||||||
pub cover: Option<String>,
|
pub cover: Option<String>,
|
||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
|
/// What will happen if we open this document.
|
||||||
|
pub on_opened: Option<OnOpened>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub size: Option<i64>,
|
pub size: Option<i64>,
|
||||||
pub metadata: Option<HashMap<String, serde_json::Value>>,
|
pub metadata: Option<HashMap<String, serde_json::Value>>,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::common::error::SearchError;
|
use crate::common::error::SearchError;
|
||||||
// use std::{future::Future, pin::Pin};
|
|
||||||
use crate::common::search::SearchQuery;
|
use crate::common::search::SearchQuery;
|
||||||
use crate::common::search::{QueryResponse, QuerySource};
|
use crate::common::search::{QueryResponse, QuerySource};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -10,4 +9,3 @@ pub trait SearchSource: Send + Sync {
|
|||||||
|
|
||||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
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::pizza_engine_runtime::SearchSourceState;
|
||||||
use super::super::Task;
|
use super::super::pizza_engine_runtime::Task;
|
||||||
use super::super::RUNTIME_TX;
|
use super::super::pizza_engine_runtime::RUNTIME_TX;
|
||||||
use super::AppEntry;
|
use super::super::Extension;
|
||||||
use super::AppMetadata;
|
use super::AppMetadata;
|
||||||
use crate::common::document::{DataSourceReference, Document};
|
use crate::common::document::{DataSourceReference, Document, OnOpened};
|
||||||
use crate::common::error::SearchError;
|
use crate::common::error::SearchError;
|
||||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||||
use crate::common::traits::SearchSource;
|
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::util::open;
|
||||||
use crate::GLOBAL_TAURI_APP_HANDLE;
|
use crate::GLOBAL_TAURI_APP_HANDLE;
|
||||||
use applications::{App, AppTrait};
|
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>>) {
|
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
|
||||||
let callback = self.callback.take().unwrap();
|
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
|
// TODO: search via alias, implement this when Pizza engine supports update
|
||||||
let dsl = format!(
|
let dsl = format!(
|
||||||
@@ -551,19 +552,24 @@ fn pizza_engine_hits_to_coco_hits(
|
|||||||
FieldValue::Text(string) => string,
|
FieldValue::Text(string) => string,
|
||||||
_ => unreachable!("field icon is of type Text"),
|
_ => 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 {
|
let coco_document = Document {
|
||||||
source: Some(DataSourceReference {
|
source: Some(DataSourceReference {
|
||||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||||
name: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
|
name: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
|
||||||
id: 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(),
|
id: app_path.clone(),
|
||||||
category: Some("Application".to_string()),
|
category: Some("Application".to_string()),
|
||||||
title: Some(app_name.clone()),
|
title: Some(app_name.clone()),
|
||||||
url: Some(app_path),
|
|
||||||
icon: Some(app_icon_path),
|
icon: Some(app_icon_path),
|
||||||
|
on_opened: Some(on_opened),
|
||||||
|
url: Some(url),
|
||||||
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -574,12 +580,7 @@ fn pizza_engine_hits_to_coco_hits(
|
|||||||
coco_hits
|
coco_hits
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
|
||||||
pub async fn set_app_alias<R: Runtime>(
|
|
||||||
tauri_app_handle: AppHandle<R>,
|
|
||||||
app_path: String,
|
|
||||||
alias: String,
|
|
||||||
) {
|
|
||||||
let store = tauri_app_handle
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_ALIAS)
|
.store(TAURI_STORE_APP_ALIAS)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn register_app_hotkey<R: Runtime>(
|
||||||
pub async fn register_app_hotkey<R: Runtime>(
|
tauri_app_handle: &AppHandle<R>,
|
||||||
tauri_app_handle: AppHandle<R>,
|
app_path: &str,
|
||||||
app_path: String,
|
hotkey: &str,
|
||||||
hotkey: String,
|
|
||||||
) -> Result<(), String> {
|
) -> 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
|
let app_hotkey_store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_HOTKEY)
|
.store(TAURI_STORE_APP_HOTKEY)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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
|
tauri_app_handle
|
||||||
.global_shortcut()
|
.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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn unregister_app_hotkey<R: Runtime>(
|
||||||
pub async fn unregister_app_hotkey<R: Runtime>(
|
tauri_app_handle: &AppHandle<R>,
|
||||||
tauri_app_handle: AppHandle<R>,
|
app_path: &str,
|
||||||
app_path: String,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let app_hotkey_store = tauri_app_handle
|
let app_hotkey_store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_HOTKEY)
|
.store(TAURI_STORE_APP_HOTKEY)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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 Some(hotkey) = app_hotkey_store.get(app_path) else {
|
||||||
let error_msg = format!(
|
warn!(
|
||||||
"unregister an Application hotkey that does not exist app: [{}]",
|
"unregister an Application hotkey that does not exist app: [{}]",
|
||||||
app_path,
|
app_path,
|
||||||
);
|
);
|
||||||
warn!("{}", error_msg);
|
return Ok(());
|
||||||
return Err(error_msg);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let hotkey = match hotkey {
|
let hotkey = match hotkey {
|
||||||
@@ -692,11 +693,18 @@ pub async fn unregister_app_hotkey<R: Runtime>(
|
|||||||
_ => unreachable!("hotkey should be stored in a string"),
|
_ => 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 {
|
if !deleted {
|
||||||
return Err("failed to delete application hotkey from store".into());
|
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
|
tauri_app_handle
|
||||||
.global_shortcut()
|
.global_shortcut()
|
||||||
.unregister(hotkey.as_str())
|
.unregister(hotkey.as_str())
|
||||||
@@ -705,7 +713,7 @@ pub async fn unregister_app_hotkey<R: Runtime>(
|
|||||||
Ok(())
|
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
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
@@ -732,10 +740,19 @@ fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<Stri
|
|||||||
disabled_app_list
|
disabled_app_list
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn is_app_search_enabled(app_path: &str) -> bool {
|
||||||
pub async fn disable_app_search<R: Runtime>(
|
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
|
||||||
tauri_app_handle: AppHandle<R>,
|
.get()
|
||||||
app_path: String,
|
.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> {
|
) -> Result<(), String> {
|
||||||
let store = tauri_app_handle
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.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);
|
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!(
|
return Err(format!(
|
||||||
"trying to disable an app that is disabled [{}]",
|
"trying to disable an app that is disabled [{}]",
|
||||||
app_path
|
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);
|
store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn enable_app_search<R: Runtime>(
|
||||||
pub async fn enable_app_search<R: Runtime>(
|
tauri_app_handle: &AppHandle<R>,
|
||||||
tauri_app_handle: AppHandle<R>,
|
app_path: &str,
|
||||||
app_path: String,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store = tauri_app_handle
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.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]
|
#[tauri::command]
|
||||||
pub async fn get_app_list<R: Runtime>(
|
pub async fn get_app_list<R: Runtime>(
|
||||||
tauri_app_handle: AppHandle<R>,
|
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 search_paths = get_app_search_path(tauri_app_handle.clone()).await;
|
||||||
let apps = list_app_in(search_paths)?;
|
let apps = list_app_in(search_paths)?;
|
||||||
|
|
||||||
@@ -910,14 +929,12 @@ pub async fn get_app_list<R: Runtime>(
|
|||||||
let store = tauri_app_handle
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_APP_HOTKEY)
|
.store(TAURI_STORE_APP_HOTKEY)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", 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,
|
Json::String(s) => s,
|
||||||
_ => unreachable!("app hotkey should be stored in a string"),
|
_ => 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
|
let store = tauri_app_handle
|
||||||
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
|
||||||
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
|
.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"),
|
_ => unreachable!("disabled app list should be stored in an array"),
|
||||||
};
|
};
|
||||||
|
|
||||||
disabled_app_list.contains(&path)
|
!disabled_app_list.contains(&path)
|
||||||
};
|
};
|
||||||
|
|
||||||
let app_entry = AppEntry {
|
let app_entry = Extension {
|
||||||
path,
|
id: path,
|
||||||
name,
|
title: name,
|
||||||
icon_path,
|
platforms: None,
|
||||||
alias,
|
// 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,
|
hotkey,
|
||||||
is_disabled,
|
enabled,
|
||||||
|
settings: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
app_entries.push(app_entry);
|
app_entries.push(app_entry);
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
|
use super::super::Extension;
|
||||||
|
use super::AppMetadata;
|
||||||
use crate::common::error::SearchError;
|
use crate::common::error::SearchError;
|
||||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||||
use crate::common::traits::SearchSource;
|
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 async_trait::async_trait;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
use super::AppEntry;
|
|
||||||
use super::AppMetadata;
|
|
||||||
|
|
||||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||||
|
|
||||||
@@ -39,46 +39,45 @@ impl SearchSource for ApplicationSearchSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
|
||||||
pub async fn set_app_alias(_app_path: String, _alias: String) -> Result<(), String> {
|
|
||||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn register_app_hotkey<R: Runtime>(
|
||||||
pub async fn register_app_hotkey<R: Runtime>(
|
_tauri_app_handle: &AppHandle<R>,
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_app_path: &str,
|
||||||
_app_path: String,
|
_hotkey: &str,
|
||||||
_hotkey: String,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn unregister_app_hotkey<R: Runtime>(
|
||||||
pub async fn unregister_app_hotkey<R: Runtime>(
|
_tauri_app_handle: &AppHandle<R>,
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_app_path: &str,
|
||||||
_app_path: String,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn disable_app_search<R: Runtime>(
|
||||||
pub async fn disable_app_search<R: Runtime>(
|
_tauri_app_handle: &AppHandle<R>,
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_app_path: &str,
|
||||||
_app_path: String,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// no-op
|
// no-op
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn enable_app_search<R: Runtime>(
|
||||||
pub async fn enable_app_search<R: Runtime>(
|
_tauri_app_handle: &AppHandle<R>,
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_app_path: &str,
|
||||||
_app_path: String,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// no-op
|
// no-op
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_app_search_enabled(_app_path: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_app_search_path<R: Runtime>(
|
pub async fn add_app_search_path<R: Runtime>(
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_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()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_list<R: Runtime>(
|
pub async fn get_app_list<R: Runtime>(
|
||||||
_tauri_app_handle: AppHandle<R>,
|
_tauri_app_handle: AppHandle<R>,
|
||||||
) -> Result<Vec<AppEntry>, String> {
|
) -> Result<Vec<Extension>, String> {
|
||||||
// Return an empty list
|
// Return an empty list
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
document::{DataSourceReference, Document},
|
document::{DataSourceReference, Document},
|
||||||
error::SearchError,
|
error::SearchError,
|
||||||
@@ -146,7 +146,7 @@ impl SearchSource for CalculatorSource {
|
|||||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||||
name: Some(DATA_SOURCE_ID.into()),
|
name: Some(DATA_SOURCE_ID.into()),
|
||||||
id: Some(DATA_SOURCE_ID.into()),
|
id: Some(DATA_SOURCE_ID.into()),
|
||||||
icon: None,
|
icon: Some(String::from("font_Calculator")),
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..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 assistant;
|
||||||
mod autostart;
|
mod autostart;
|
||||||
mod common;
|
mod common;
|
||||||
mod local;
|
mod extension;
|
||||||
mod search;
|
mod search;
|
||||||
mod server;
|
mod server;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -142,25 +142,24 @@ pub fn run() {
|
|||||||
server::attachment::get_attachment,
|
server::attachment::get_attachment,
|
||||||
server::attachment::delete_attachment,
|
server::attachment::delete_attachment,
|
||||||
server::transcription::transcription,
|
server::transcription::transcription,
|
||||||
util::open,
|
|
||||||
server::system_settings::get_system_settings,
|
server::system_settings::get_system_settings,
|
||||||
simulate_mouse_click,
|
simulate_mouse_click,
|
||||||
local::get_disabled_local_query_sources,
|
extension::built_in::application::get_app_list,
|
||||||
local::enable_local_query_source,
|
extension::built_in::application::get_app_search_path,
|
||||||
local::disable_local_query_source,
|
extension::built_in::application::get_app_metadata,
|
||||||
local::application::get_app_list,
|
extension::built_in::application::add_app_search_path,
|
||||||
local::application::get_app_search_path,
|
extension::built_in::application::remove_app_search_path,
|
||||||
local::application::get_app_metadata,
|
extension::list_extensions,
|
||||||
local::application::set_app_alias,
|
extension::enable_extension,
|
||||||
local::application::register_app_hotkey,
|
extension::disable_extension,
|
||||||
local::application::unregister_app_hotkey,
|
extension::set_extension_alias,
|
||||||
local::application::disable_app_search,
|
extension::register_extension_hotkey,
|
||||||
local::application::enable_app_search,
|
extension::unregister_extension_hotkey,
|
||||||
local::application::add_app_search_path,
|
extension::is_extension_enabled,
|
||||||
local::application::remove_app_search_path,
|
|
||||||
settings::set_allow_self_signature,
|
settings::set_allow_self_signature,
|
||||||
settings::get_allow_self_signature,
|
settings::get_allow_self_signature,
|
||||||
assistant::ask_ai
|
assistant::ask_ai,
|
||||||
|
crate::common::document::open,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@@ -262,7 +261,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
local::start_pizza_engine_runtime();
|
extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -418,7 +417,11 @@ fn open_settings(app: &tauri::AppHandle) {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
|
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::connector::refresh_all_connectors(&app_handle).await;
|
||||||
let _ = server::datasource::refresh_all_datasources(&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::{
|
use crate::common::search::{
|
||||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||||
};
|
};
|
||||||
use crate::local;
|
|
||||||
use futures::stream::FuturesUnordered;
|
use futures::stream::FuturesUnordered;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
@@ -20,7 +19,10 @@ pub async fn query_coco_fusion<R: Runtime>(
|
|||||||
query_strings: HashMap<String, String>,
|
query_strings: HashMap<String, String>,
|
||||||
query_timeout: u64,
|
query_timeout: u64,
|
||||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
) -> 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");
|
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 sources_future = search_sources.get_sources();
|
||||||
let mut futures = FuturesUnordered::new();
|
let mut futures = FuturesUnordered::new();
|
||||||
let mut sources = HashMap::new();
|
|
||||||
|
|
||||||
let sources_list = sources_future.await;
|
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 = SearchQuery::new(from, size, query_strings.clone());
|
||||||
let query_source_clone = query_source.clone(); // Clone Arc to avoid ownership issues
|
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 {
|
timeout(timeout_duration, async {
|
||||||
query_source_clone.search(query).await
|
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();
|
let mut unique_sources = HashSet::new();
|
||||||
for hit in &final_hits {
|
for hit in &final_hits {
|
||||||
if let Some(source) = &hit.source {
|
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);
|
unique_sources.insert(&source.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Multiple sources found: {:?}, no rerank needed",
|
"Multiple sources found: {:?}, no rerank needed",
|
||||||
unique_sources
|
unique_sources
|
||||||
);
|
);
|
||||||
|
|
||||||
if unique_sources.len() < 1 {
|
if unique_sources.len() < 1 {
|
||||||
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
|
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
|
||||||
}
|
}
|
||||||
|
|
||||||
if need_rerank && final_hits.len() > 1 {
|
if need_rerank && final_hits.len() > 1 {
|
||||||
|
|
||||||
// Precollect (index, title)
|
// Precollect (index, title)
|
||||||
let titles_to_score: Vec<(usize, &str)> = final_hits
|
let titles_to_score: Vec<(usize, &str)> = final_hits
|
||||||
.iter()
|
.iter()
|
||||||
@@ -184,7 +182,7 @@ pub async fn query_coco_fusion<R: Runtime>(
|
|||||||
let source = hit.source.as_ref()?;
|
let source = hit.source.as_ref()?;
|
||||||
let title = hit.document.title.as_deref()?;
|
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))
|
Some((idx, title))
|
||||||
} else {
|
} else {
|
||||||
None
|
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) {
|
for (idx, score) in scored_hits.into_iter().take(size as usize) {
|
||||||
final_hits[idx].score = score;
|
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();
|
let remaining_needed = size as usize - final_hits.len();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::common::document::Document;
|
use crate::common::document::{Document, OnOpened};
|
||||||
use crate::common::error::SearchError;
|
use crate::common::error::SearchError;
|
||||||
use crate::common::http::get_response_body_text;
|
use crate::common::http::get_response_body_text;
|
||||||
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
|
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)
|
self.docs.into_iter().map(|(_, doc, _)| doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +103,7 @@ impl SearchSource for CocoSearchSource {
|
|||||||
query_args.insert(key, JsonValue::String(value));
|
query_args.insert(key, JsonValue::String(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = HttpClient::get(
|
let response = HttpClient::get(&self.server.id, &url, Some(query_args))
|
||||||
&self.server.id,
|
|
||||||
&url,
|
|
||||||
Some(query_args),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SearchError::HttpError(format!("{}", e)))?;
|
.map_err(|e| SearchError::HttpError(format!("{}", e)))?;
|
||||||
|
|
||||||
@@ -116,7 +112,6 @@ impl SearchSource for CocoSearchSource {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| SearchError::ParseError(e))?;
|
.map_err(|e| SearchError::ParseError(e))?;
|
||||||
|
|
||||||
|
|
||||||
// Check if the response body is empty
|
// Check if the response body is empty
|
||||||
if !response_body.is_empty() {
|
if !response_body.is_empty() {
|
||||||
// Parse the search response from the body text
|
// Parse the search response from the body text
|
||||||
@@ -125,14 +120,21 @@ impl SearchSource for CocoSearchSource {
|
|||||||
|
|
||||||
// Process the parsed response
|
// Process the parsed response
|
||||||
total_hits = parsed.hits.total.value as usize;
|
total_hits = parsed.hits.total.value as usize;
|
||||||
hits = parsed
|
for hit in parsed.hits.hits {
|
||||||
.hits
|
let mut document = hit._source;
|
||||||
.hits
|
// Default _score to 0.0 if None
|
||||||
.into_iter()
|
let score = hit._score.unwrap_or(0.0);
|
||||||
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
// Return the final result
|
||||||
Ok(QueryResponse {
|
Ok(QueryResponse {
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
|
|||||||
//
|
//
|
||||||
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
// tauri_plugin_shell::open() is deprecated, but we still use it.
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
#[tauri::command]
|
|
||||||
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
|
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
|
||||||
if cfg!(target_os = "linux") {
|
if cfg!(target_os = "linux") {
|
||||||
let borrowed_path = Path::new(&path);
|
let borrowed_path = Path::new(&path);
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
"url": "/ui/settings",
|
"url": "/ui/settings",
|
||||||
"width": 1000,
|
"width": 1000,
|
||||||
"height": 700,
|
"height": 700,
|
||||||
|
"minHeight": 700,
|
||||||
|
"minWidth": 1000,
|
||||||
"center": true,
|
"center": true,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"maximizable": false,
|
"maximizable": false,
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resources": ["assets", "icons"]
|
"resources": ["assets/**/*", "icons"]
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"features": {
|
"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 { ChevronDownIcon, RefreshCw } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isNil } from "lodash-es";
|
import { isNil } from "lodash-es";
|
||||||
@@ -16,6 +16,7 @@ import PopoverInput from "@/components/Common/PopoverInput";
|
|||||||
import { AssistantFetcher } from "./AssistantFetcher";
|
import { AssistantFetcher } from "./AssistantFetcher";
|
||||||
import AssistantItem from "./AssistantItem";
|
import AssistantItem from "./AssistantItem";
|
||||||
import Pagination from "@/components/Common/Pagination";
|
import Pagination from "@/components/Common/Pagination";
|
||||||
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
|
||||||
interface AssistantListProps {
|
interface AssistantListProps {
|
||||||
assistantIDs?: string[];
|
assistantIDs?: string[];
|
||||||
@@ -37,6 +38,11 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
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({
|
const { fetchAssistant } = AssistantFetcher({
|
||||||
debounceKeyword,
|
debounceKeyword,
|
||||||
@@ -62,6 +68,19 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
|
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
|
||||||
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
|
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(
|
useKeyPress(
|
||||||
["uparrow", "downarrow", "enter"],
|
["uparrow", "downarrow", "enter"],
|
||||||
(event, key) => {
|
(event, key) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { COPY_BUTTON_ID } from "@/constants";
|
import { COPY_BUTTON_ID } from "@/constants";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -14,6 +15,8 @@ interface MessageActionsProps {
|
|||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
question?: string;
|
question?: string;
|
||||||
|
actionClassName?: string;
|
||||||
|
actionIconSize?: number;
|
||||||
onResend?: () => void;
|
onResend?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +26,8 @@ export const MessageActions = ({
|
|||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
question,
|
question,
|
||||||
|
actionClassName,
|
||||||
|
actionIconSize,
|
||||||
onResend,
|
onResend,
|
||||||
}: MessageActionsProps) => {
|
}: MessageActionsProps) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -89,7 +94,7 @@ export const MessageActions = ({
|
|||||||
const goAskAi = useSearchStore((state) => state.goAskAi);
|
const goAskAi = useSearchStore((state) => state.goAskAi);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 mt-2">
|
<div className={clsx("flex items-center gap-1 mt-2", actionClassName)}>
|
||||||
{!isRefreshOnly && (
|
{!isRefreshOnly && (
|
||||||
<button
|
<button
|
||||||
id={goAskAi ? COPY_BUTTON_ID : ""}
|
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"
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{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>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -116,6 +133,10 @@ export const MessageActions = ({
|
|||||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
width: actionIconSize,
|
||||||
|
height: actionIconSize,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -132,6 +153,10 @@ export const MessageActions = ({
|
|||||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
width: actionIconSize,
|
||||||
|
height: actionIconSize,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -146,6 +171,10 @@ export const MessageActions = ({
|
|||||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
width: actionIconSize,
|
||||||
|
height: actionIconSize,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -162,6 +191,10 @@ export const MessageActions = ({
|
|||||||
? "text-[#1990FF] dark:text-[#1990FF]"
|
? "text-[#1990FF] dark:text-[#1990FF]"
|
||||||
: "text-[#666666] dark:text-[#A3A3A3]"
|
: "text-[#666666] dark:text-[#A3A3A3]"
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
width: actionIconSize,
|
||||||
|
height: actionIconSize,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ interface ChatMessageProps {
|
|||||||
onResend?: (value: string) => void;
|
onResend?: (value: string) => void;
|
||||||
loadingStep?: Record<string, boolean>;
|
loadingStep?: Record<string, boolean>;
|
||||||
hide_assistant?: boolean;
|
hide_assistant?: boolean;
|
||||||
|
rootClassName?: string;
|
||||||
|
actionClassName?: string;
|
||||||
|
actionIconSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatMessage = memo(function ChatMessage({
|
export const ChatMessage = memo(function ChatMessage({
|
||||||
@@ -45,6 +48,9 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
onResend,
|
onResend,
|
||||||
loadingStep,
|
loadingStep,
|
||||||
hide_assistant = false,
|
hide_assistant = false,
|
||||||
|
rootClassName,
|
||||||
|
actionClassName,
|
||||||
|
actionIconSize,
|
||||||
}: ChatMessageProps) {
|
}: ChatMessageProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -144,6 +150,8 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
id={message._id}
|
id={message._id}
|
||||||
content={messageContent || response?.message_chunk || ""}
|
content={messageContent || response?.message_chunk || ""}
|
||||||
question={question}
|
question={question}
|
||||||
|
actionClassName={actionClassName}
|
||||||
|
actionIconSize={actionIconSize}
|
||||||
onResend={() => {
|
onResend={() => {
|
||||||
onResend && onResend(question);
|
onResend && onResend(question);
|
||||||
}}
|
}}
|
||||||
@@ -166,7 +174,8 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
[isAssistant ? "justify-start" : "justify-end"],
|
[isAssistant ? "justify-start" : "justify-end"],
|
||||||
{
|
{
|
||||||
hidden: visibleStartPage,
|
hidden: visibleStartPage,
|
||||||
}
|
},
|
||||||
|
rootClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<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 { noop } from "lodash-es";
|
||||||
|
|
||||||
import { ChatMessage } from "../ChatMessage";
|
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 { useSearchStore } from "@/stores/searchStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
||||||
@@ -75,6 +75,9 @@ const AskAi = () => {
|
|||||||
return state.setAskAiServerId;
|
return state.setAskAiServerId;
|
||||||
});
|
});
|
||||||
const state = useReactive<State>({});
|
const state = useReactive<State>({});
|
||||||
|
const setAskAiAssistantId = useSearchStore((state) => {
|
||||||
|
return state.setAskAiAssistantId;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.serverId) return;
|
if (state.serverId) return;
|
||||||
@@ -97,12 +100,10 @@ const AskAi = () => {
|
|||||||
useMount(async () => {
|
useMount(async () => {
|
||||||
try {
|
try {
|
||||||
unlisten.current = await platformAdapter.listenEvent(
|
unlisten.current = await platformAdapter.listenEvent(
|
||||||
ASK_AI_CLIENT_ID,
|
"quick-ai-access-client-id",
|
||||||
({ payload }) => {
|
({ payload }) => {
|
||||||
console.log("ask_ai", JSON.parse(payload));
|
console.log("ask_ai", JSON.parse(payload));
|
||||||
|
|
||||||
setIsTyping(true);
|
|
||||||
|
|
||||||
const chunkData = JSON.parse(payload);
|
const chunkData = JSON.parse(payload);
|
||||||
|
|
||||||
if (chunkData?._id) {
|
if (chunkData?._id) {
|
||||||
@@ -115,6 +116,13 @@ const AskAi = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the chunk data does not contain a message_chunk, we ignore it
|
||||||
|
if (!chunkData.message_chunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTyping(true);
|
||||||
|
|
||||||
setLoadingStep(() => ({
|
setLoadingStep(() => ({
|
||||||
query_intent: false,
|
query_intent: false,
|
||||||
tools: false,
|
tools: false,
|
||||||
@@ -164,15 +172,12 @@ const AskAi = () => {
|
|||||||
|
|
||||||
const { serverId, assistantId } = state;
|
const { serverId, assistantId } = state;
|
||||||
|
|
||||||
console.log("serverId", serverId);
|
|
||||||
console.log("assistantId", assistantId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await platformAdapter.invokeBackend("ask_ai", {
|
await platformAdapter.invokeBackend("ask_ai", {
|
||||||
message: askAiMessage,
|
message: askAiMessage,
|
||||||
serverId,
|
serverId,
|
||||||
assistantId,
|
assistantId,
|
||||||
clientId: ASK_AI_CLIENT_ID,
|
clientId: "quick-ai-access-client-id",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError(String(error));
|
addError(String(error));
|
||||||
@@ -184,7 +189,7 @@ const AskAi = () => {
|
|||||||
|
|
||||||
if (isTyping) return;
|
if (isTyping) return;
|
||||||
|
|
||||||
const { serverId } = state;
|
const { serverId, assistantId } = state;
|
||||||
|
|
||||||
if ((isMac && metaKey) || (!isMac && ctrlKey)) {
|
if ((isMac && metaKey) || (!isMac && ctrlKey)) {
|
||||||
await platformAdapter.commands("open_session_chat", {
|
await platformAdapter.commands("open_session_chat", {
|
||||||
@@ -195,7 +200,8 @@ const AskAi = () => {
|
|||||||
platformAdapter.emitEvent("toggle-to-chat-mode");
|
platformAdapter.emitEvent("toggle-to-chat-mode");
|
||||||
|
|
||||||
setAskAiServerId(serverId);
|
setAskAiServerId(serverId);
|
||||||
return setAskAiSessionId(sessionIdRef.current);
|
setAskAiSessionId(sessionIdRef.current);
|
||||||
|
return setAskAiAssistantId(assistantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyButton = document.getElementById(COPY_BUTTON_ID);
|
const copyButton = document.getElementById(COPY_BUTTON_ID);
|
||||||
|
|||||||
@@ -38,16 +38,16 @@ export function useAssistantManager({
|
|||||||
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
||||||
|
|
||||||
const assistant_get = useCallback(async () => {
|
const assistant_get = useCallback(async () => {
|
||||||
|
if (!askAI?.id) return;
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
|
if (!askAI?.querySource?.id) return;
|
||||||
const res = await platformAdapter.commands("assistant_get", {
|
const res = await platformAdapter.commands("assistant_get", {
|
||||||
serverId: askAI?.querySource?.id,
|
serverId: askAI?.querySource?.id,
|
||||||
assistantId: askAI?.id,
|
assistantId: askAI?.id,
|
||||||
});
|
});
|
||||||
setAssistantDetail(res);
|
setAssistantDetail(res);
|
||||||
} else {
|
} else {
|
||||||
const [error, res]: any = await Get(`/assistant/${askAI?.id}`, {
|
const [error, res]: any = await Get(`/assistant/${askAI?.id}`);
|
||||||
id: askAI?.id,
|
|
||||||
});
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("assistant", error);
|
console.error("assistant", error);
|
||||||
return;
|
return;
|
||||||
@@ -57,6 +57,8 @@ export function useAssistantManager({
|
|||||||
}, [askAI]);
|
}, [askAI]);
|
||||||
|
|
||||||
const handleAskAi = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleAskAi = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!isTauri) return;
|
||||||
|
|
||||||
askAIRef.current = cloneDeep(askAI);
|
askAIRef.current = cloneDeep(askAI);
|
||||||
|
|
||||||
if (!askAIRef.current) return;
|
if (!askAIRef.current) return;
|
||||||
@@ -67,7 +69,6 @@ export function useAssistantManager({
|
|||||||
|
|
||||||
if (!selectedAssistant && isEmpty(value)) return;
|
if (!selectedAssistant && isEmpty(value)) return;
|
||||||
|
|
||||||
assistant_get();
|
|
||||||
changeInput("");
|
changeInput("");
|
||||||
setAskAiMessage(!goAskAi && selectedAssistant ? "" : value);
|
setAskAiMessage(!goAskAi && selectedAssistant ? "" : value);
|
||||||
setGoAskAi(true);
|
setGoAskAi(true);
|
||||||
@@ -84,7 +85,9 @@ export function useAssistantManager({
|
|||||||
return setGoAskAi(false);
|
return setGoAskAi(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Tab" && !isChatMode) {
|
if (key === "Tab" && !isChatMode && isTauri) {
|
||||||
|
assistant_get();
|
||||||
|
|
||||||
return handleAskAi(e);
|
return handleAskAi(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useBoolean } from "ahooks";
|
import { useBoolean, useDebounceFn } from "ahooks";
|
||||||
import {
|
import {
|
||||||
useRef,
|
useRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
interface AutoResizeTextareaProps {
|
||||||
input: string;
|
input: string;
|
||||||
setInput: (value: string) => void;
|
setInput: (value: string) => void;
|
||||||
@@ -37,6 +41,77 @@ const AutoResizeTextarea = forwardRef<
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [isComposition, { setTrue, setFalse }] = useBoolean();
|
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
|
// Expose methods to the parent via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
reset: () => {
|
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>) => {
|
const handleKeyPress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (isComposition) {
|
if (isComposition) {
|
||||||
return event.stopPropagation();
|
return event.stopPropagation();
|
||||||
@@ -62,18 +130,6 @@ const AutoResizeTextarea = forwardRef<
|
|||||||
handleKeyDown?.(event);
|
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 (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -82,9 +138,7 @@ const AutoResizeTextarea = forwardRef<
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
spellCheck="false"
|
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"
|
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={
|
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
|
||||||
chatPlaceholder || t("search.textarea.placeholder")
|
|
||||||
}
|
|
||||||
aria-label={t("search.textarea.ariaLabel")}
|
aria-label={t("search.textarea.ariaLabel")}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@@ -96,7 +150,8 @@ const AutoResizeTextarea = forwardRef<
|
|||||||
rows={1}
|
rows={1}
|
||||||
style={{
|
style={{
|
||||||
resize: "none", // Prevent manual resize
|
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)
|
maxHeight: "13.5rem", // Limit height to 9 rows (9 * 1.5 line-height)
|
||||||
lineHeight: "1.5rem", // Line height to match row 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 clsx from "clsx";
|
||||||
import { isNil, lowerCase, noop } from "lodash-es";
|
import { isNil, lowerCase, noop } from "lodash-es";
|
||||||
import { Copy, Link, SquareArrowOutUpRight } from "lucide-react";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
|
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
|
import { copyToClipboard } from "@/utils";
|
||||||
import { isMac } from "@/utils/platform";
|
import { isMac } from "@/utils/platform";
|
||||||
import { CONTEXT_MENU_PANEL_ID } from "@/constants";
|
import { CONTEXT_MENU_PANEL_ID } from "@/constants";
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { Input } from "@headlessui/react";
|
import { Input } from "@headlessui/react";
|
||||||
import VisibleKey from "../Common/VisibleKey";
|
import VisibleKey from "../Common/VisibleKey";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
activeMenuIndex: number;
|
activeMenuIndex: number;
|
||||||
@@ -22,7 +23,7 @@ interface ContextMenuProps {
|
|||||||
hideCoco?: () => void;
|
hideCoco?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
const ContextMenu: FC<ContextMenuProps> = () => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const state = useReactive<State>({
|
const state = useReactive<State>({
|
||||||
@@ -52,9 +53,15 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
|||||||
const menus = useCreation(() => {
|
const menus = useCreation(() => {
|
||||||
if (isNil(selectedSearchContent)) return [];
|
if (isNil(selectedSearchContent)) return [];
|
||||||
|
|
||||||
const { url, category, payload } = selectedSearchContent;
|
const { url, category, payload, on_opened } = selectedSearchContent;
|
||||||
const { query, result } = payload ?? {};
|
const { query, result } = payload ?? {};
|
||||||
|
|
||||||
|
if (category === "AI Overview") {
|
||||||
|
setSearchMenus([]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const menus = [
|
const menus = [
|
||||||
{
|
{
|
||||||
name: t("search.contextMenu.open"),
|
name: t("search.contextMenu.open"),
|
||||||
@@ -63,9 +70,9 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
|||||||
shortcut: "enter",
|
shortcut: "enter",
|
||||||
hide: category === "Calculator",
|
hide: category === "Calculator",
|
||||||
clickEvent: () => {
|
clickEvent: () => {
|
||||||
OpenURLWithBrowser(url);
|
if (on_opened) {
|
||||||
|
platformAdapter.invokeBackend("open", { onOpened: on_opened });
|
||||||
hideCoco && hideCoco();
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -182,104 +189,106 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
searchMenus.length > 0 && (
|
||||||
{visibleContextMenu && (
|
<>
|
||||||
<div
|
{visibleContextMenu && (
|
||||||
className="fixed inset-0"
|
<div
|
||||||
onContextMenu={(event) => {
|
className="fixed inset-0"
|
||||||
event?.preventDefault();
|
onContextMenu={(event) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
setVisibleContextMenu(false);
|
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,
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<div className="text-[#999] dark:text-[#666] truncate">{title}</div>
|
|
||||||
|
|
||||||
<ul className="flex flex-col -mx-2 p-0">
|
<div
|
||||||
{searchMenus.map((item, index) => {
|
ref={containerRef}
|
||||||
const { name, icon, keys, clickEvent } = item;
|
id={visibleContextMenu ? CONTEXT_MENU_PANEL_ID : ""}
|
||||||
|
className={clsx(
|
||||||
return (
|
"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",
|
||||||
<li
|
{
|
||||||
key={name}
|
"!scale-100": visibleContextMenu,
|
||||||
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 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>
|
||||||
</div>
|
</>
|
||||||
</>
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { SearchHeader } from "./SearchHeader";
|
|||||||
import noDataImg from "@/assets/coconut-tree.png";
|
import noDataImg from "@/assets/coconut-tree.png";
|
||||||
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
|
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||||
import SearchListItem from "./SearchListItem";
|
import SearchListItem from "./SearchListItem";
|
||||||
import { OpenURLWithBrowser } from "@/utils/index";
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { Get } from "@/api/axiosRequest";
|
import { Get } from "@/api/axiosRequest";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
@@ -170,7 +169,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
const handleEnter = () => {
|
const handleEnter = () => {
|
||||||
if (selectedItem === null) return;
|
if (selectedItem === null) return;
|
||||||
const item = data.list[selectedItem]?.document;
|
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) {
|
switch (e.key) {
|
||||||
@@ -233,9 +234,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
isSelected={selectedItem === index}
|
isSelected={selectedItem === index}
|
||||||
currentIndex={index}
|
currentIndex={index}
|
||||||
onMouseEnter={() => onMouseEnter(index, hit.document)}
|
onMouseEnter={() => onMouseEnter(index, hit.document)}
|
||||||
onItemClick={() =>
|
onItemClick={() => {
|
||||||
hit.document?.url && OpenURLWithBrowser(hit.document.url)
|
if (hit.document?.on_opened) {
|
||||||
}
|
platformAdapter.invokeBackend("open", {
|
||||||
|
onOpened: hit.document.on_opened,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
showListRight={viewMode === "list"}
|
showListRight={viewMode === "list"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { useDebounceFn, useUnmount } from "ahooks";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { OpenURLWithBrowser } from "@/utils/index";
|
|
||||||
import ErrorSearch from "@/components/Common/ErrorNotification/ErrorSearch";
|
import ErrorSearch from "@/components/Common/ErrorNotification/ErrorSearch";
|
||||||
import type { QueryHits, SearchDocument, FailedRequest } from "@/types/search";
|
import type { QueryHits, SearchDocument, FailedRequest } from "@/types/search";
|
||||||
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
||||||
import { SearchSource } from "./SearchSource";
|
import { SearchSource } from "./SearchSource";
|
||||||
import DropdownListItem from "./DropdownListItem";
|
import DropdownListItem from "./DropdownListItem";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
type ISearchData = Record<string, QueryHits[]>;
|
type ISearchData = Record<string, QueryHits[]>;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ function DropdownList({
|
|||||||
searchData,
|
searchData,
|
||||||
isError,
|
isError,
|
||||||
isChatMode,
|
isChatMode,
|
||||||
globalItemIndexMap
|
globalItemIndexMap,
|
||||||
}: DropdownListProps) {
|
}: DropdownListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -43,7 +43,6 @@ function DropdownList({
|
|||||||
const [selectedName, setSelectedName] = useState<string>("");
|
const [selectedName, setSelectedName] = useState<string>("");
|
||||||
const [showIndex, setShowIndex] = useState<boolean>(false);
|
const [showIndex, setShowIndex] = useState<boolean>(false);
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setSourceData,
|
setSourceData,
|
||||||
setSelectedSearchContent,
|
setSelectedSearchContent,
|
||||||
@@ -57,7 +56,14 @@ function DropdownList({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleItemAction = useCallback((item: SearchDocument) => {
|
const handleItemAction = useCallback((item: SearchDocument) => {
|
||||||
if (!item || item.category === "Calculator") return;
|
if (
|
||||||
|
!item ||
|
||||||
|
item.category === "Calculator" ||
|
||||||
|
item.category === "AI Overview"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSourceData(item);
|
setSourceData(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -69,17 +75,19 @@ function DropdownList({
|
|||||||
|
|
||||||
const memoizedCallbacks = useMemo(() => {
|
const memoizedCallbacks = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
onMouseEnter: (index: number, item: SearchDocument) => () => {
|
onMouseEnter: (index: number, item: SearchDocument) => {
|
||||||
console.log("onMouseEnter", index);
|
setVisibleContextMenu(false);
|
||||||
setSelectedIndex(index);
|
setSelectedIndex(index);
|
||||||
setSelectedSearchContent(item);
|
setSelectedSearchContent(item);
|
||||||
},
|
},
|
||||||
onItemClick: (item: SearchDocument) => () => {
|
onItemClick: (item: SearchDocument) => {
|
||||||
if (item?.url) {
|
if (item?.on_opened) {
|
||||||
OpenURLWithBrowser(item.url);
|
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
goToTwoPage: (item: SearchDocument) => () => setSourceData(item),
|
goToTwoPage: (item: SearchDocument) => {
|
||||||
|
setSourceData(item);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ function DropdownList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = globalItemIndexMap[selectedIndex]
|
const item = globalItemIndexMap[selectedIndex];
|
||||||
setSelectedSearchContent(item);
|
setSelectedSearchContent(item);
|
||||||
if (item?.source?.id === "assistant") {
|
if (item?.source?.id === "assistant") {
|
||||||
setSelectedAssistant({
|
setSelectedAssistant({
|
||||||
@@ -161,7 +169,7 @@ function DropdownList({
|
|||||||
|
|
||||||
{Object.entries(searchData).map(([sourceName, items]) => (
|
{Object.entries(searchData).map(([sourceName, items]) => (
|
||||||
<div key={sourceName}>
|
<div key={sourceName}>
|
||||||
{showSource && (
|
{showSource && items[0].document.category !== "AI Overview" && (
|
||||||
<SearchSource
|
<SearchSource
|
||||||
sourceName={sourceName}
|
sourceName={sourceName}
|
||||||
items={items}
|
items={items}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from "clsx";
|
|||||||
import Calculator from "./Calculator";
|
import Calculator from "./Calculator";
|
||||||
import SearchListItem from "./SearchListItem";
|
import SearchListItem from "./SearchListItem";
|
||||||
import type { SearchDocument } from "@/types/search";
|
import type { SearchDocument } from "@/types/search";
|
||||||
|
import AiOverview from "./AiOverview";
|
||||||
|
|
||||||
interface DropdownListItemProps {
|
interface DropdownListItemProps {
|
||||||
item: SearchDocument;
|
item: SearchDocument;
|
||||||
@@ -11,7 +12,7 @@ interface DropdownListItemProps {
|
|||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
showIndex: boolean;
|
showIndex: boolean;
|
||||||
memoizedCallbacks: {
|
memoizedCallbacks: {
|
||||||
onMouseEnter: (index: number, item: SearchDocument) => () => void;
|
onMouseEnter: (index: number, item: SearchDocument) => void;
|
||||||
onItemClick: (item: SearchDocument) => void;
|
onItemClick: (item: SearchDocument) => void;
|
||||||
goToTwoPage: (item: SearchDocument) => void;
|
goToTwoPage: (item: SearchDocument) => void;
|
||||||
};
|
};
|
||||||
@@ -30,14 +31,17 @@ const DropdownListItem = memo(
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: DropdownListItemProps) => {
|
}: DropdownListItemProps) => {
|
||||||
const isCalculator = item.category === "Calculator";
|
const isCalculator = item.category === "Calculator";
|
||||||
|
const isAiOverview = item.category === "AI Overview";
|
||||||
const isSelected = selectedIndex === currentIndex;
|
const isSelected = selectedIndex === currentIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onContextMenu={onContextMenu}>
|
<div onContextMenu={onContextMenu}>
|
||||||
{isCalculator ? (
|
{isCalculator || isAiOverview ? (
|
||||||
<div
|
<div
|
||||||
ref={(el) => (itemRefs.current[currentIndex] = el)}
|
ref={(el) => (itemRefs.current[currentIndex] = el)}
|
||||||
onMouseEnter={memoizedCallbacks.onMouseEnter(currentIndex, item)}
|
onMouseEnter={() => {
|
||||||
|
memoizedCallbacks.onMouseEnter(currentIndex, item);
|
||||||
|
}}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
id={`search-item-${currentIndex}`}
|
id={`search-item-${currentIndex}`}
|
||||||
@@ -45,7 +49,9 @@ const DropdownListItem = memo(
|
|||||||
"bg-[#EDEDED] dark:bg-[#202126]": isSelected,
|
"bg-[#EDEDED] dark:bg-[#202126]": isSelected,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Calculator item={item} isSelected={isSelected} />
|
{isCalculator && <Calculator item={item} isSelected={isSelected} />}
|
||||||
|
|
||||||
|
{isAiOverview && <AiOverview message={item?.payload?.message} />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SearchListItem
|
<SearchListItem
|
||||||
@@ -53,9 +59,15 @@ const DropdownListItem = memo(
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
currentIndex={currentIndex}
|
currentIndex={currentIndex}
|
||||||
showIndex={showIndex}
|
showIndex={showIndex}
|
||||||
onMouseEnter={memoizedCallbacks.onMouseEnter(currentIndex, item)}
|
onMouseEnter={() => {
|
||||||
onItemClick={() => memoizedCallbacks.onItemClick(item)}
|
memoizedCallbacks.onMouseEnter(currentIndex, item);
|
||||||
goToTwoPage={() => memoizedCallbacks.goToTwoPage(item)}
|
}}
|
||||||
|
onItemClick={() => {
|
||||||
|
memoizedCallbacks.onItemClick(item);
|
||||||
|
}}
|
||||||
|
goToTwoPage={() => {
|
||||||
|
memoizedCallbacks.goToTwoPage(item);
|
||||||
|
}}
|
||||||
itemRef={(el) => (itemRefs.current[currentIndex] = el)}
|
itemRef={(el) => (itemRefs.current[currentIndex] = el)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ChatIcons from "./ChatIcons";
|
|||||||
import { useKeyboardHandlers } from "@/hooks/useKeyboardHandlers";
|
import { useKeyboardHandlers } from "@/hooks/useKeyboardHandlers";
|
||||||
import { useAssistantManager } from "./AssistantManager";
|
import { useAssistantManager } from "./AssistantManager";
|
||||||
import InputControls from "./InputControls";
|
import InputControls from "./InputControls";
|
||||||
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
@@ -79,6 +80,7 @@ export default function ChatInput({
|
|||||||
|
|
||||||
const showTooltip = useAppStore((state) => state.showTooltip);
|
const showTooltip = useAppStore((state) => state.showTooltip);
|
||||||
const setBlurred = useAppStore((state) => state.setBlurred);
|
const setBlurred = useAppStore((state) => state.setBlurred);
|
||||||
|
const isTauri = useAppStore((state) => state.isTauri);
|
||||||
|
|
||||||
const { sourceData, goAskAi } = useSearchStore();
|
const { sourceData, goAskAi } = useSearchStore();
|
||||||
|
|
||||||
@@ -154,16 +156,12 @@ export default function ChatInput({
|
|||||||
};
|
};
|
||||||
}, [isChatMode]);
|
}, [isChatMode]);
|
||||||
|
|
||||||
const {
|
const { askAI, askAIRef, assistantDetail, handleKeyDownAutoResizeTextarea } =
|
||||||
askAI,
|
useAssistantManager({
|
||||||
askAIRef,
|
isChatMode,
|
||||||
assistantDetail,
|
handleSubmit,
|
||||||
handleKeyDownAutoResizeTextarea,
|
changeInput,
|
||||||
} = useAssistantManager({
|
});
|
||||||
isChatMode,
|
|
||||||
handleSubmit,
|
|
||||||
changeInput,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [lineCount, setLineCount] = useState(1);
|
const [lineCount, setLineCount] = useState(1);
|
||||||
|
|
||||||
@@ -181,6 +179,10 @@ export default function ChatInput({
|
|||||||
};
|
};
|
||||||
}, [currentAssistant]);
|
}, [currentAssistant]);
|
||||||
|
|
||||||
|
const disabledExtensions = useExtensionsStore((state) => {
|
||||||
|
return state.disabledExtensions;
|
||||||
|
});
|
||||||
|
|
||||||
const renderSearchIcon = () => (
|
const renderSearchIcon = () => (
|
||||||
<SearchIcons
|
<SearchIcons
|
||||||
lineCount={lineCount}
|
lineCount={lineCount}
|
||||||
@@ -225,18 +227,22 @@ export default function ChatInput({
|
|||||||
</div>
|
</div>
|
||||||
)} */}
|
)} */}
|
||||||
|
|
||||||
{!isChatMode && !goAskAi && askAI && (
|
{!isChatMode &&
|
||||||
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
|
isTauri &&
|
||||||
<span>
|
!goAskAi &&
|
||||||
{t("search.askCocoAi.title", {
|
askAI &&
|
||||||
replace: [askAI.name],
|
!disabledExtensions.includes("QuickAIAccess") && (
|
||||||
})}
|
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
|
||||||
</span>
|
<span>
|
||||||
<div className="flex items-center justify-center w-8 h-[20px] text-xs rounded-md border border-black/10 dark:border-[#545454]">
|
{t("search.askCocoAi.title", {
|
||||||
Tab
|
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>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* <AudioRecording
|
{/* <AudioRecording
|
||||||
key={isChatMode ? "chat" : "search"}
|
key={isChatMode ? "chat" : "search"}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Brain } from "lucide-react";
|
import { Brain, Sparkles } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
import VisibleKey from "@/components/Common/VisibleKey";
|
import VisibleKey from "@/components/Common/VisibleKey";
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
// import InputExtra from "./InputExtra";
|
// import InputExtra from "./InputExtra";
|
||||||
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
|
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
|
||||||
|
|
||||||
@@ -205,6 +207,22 @@ const InputControls = ({
|
|||||||
[assistantConfig]
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
@@ -286,7 +304,34 @@ const InputControls = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ const SearchResultsPanel = memo<{
|
|||||||
const { sourceData, goAskAi } = useSearchStore();
|
const { sourceData, goAskAi } = useSearchStore();
|
||||||
|
|
||||||
const searchState = useSearch();
|
const searchState = useSearch();
|
||||||
const { suggests, searchData, isError, isSearchComplete, globalItemIndexMap, performSearch } = searchState;
|
const {
|
||||||
|
suggests,
|
||||||
|
searchData,
|
||||||
|
isError,
|
||||||
|
isSearchComplete,
|
||||||
|
globalItemIndexMap,
|
||||||
|
performSearch,
|
||||||
|
} = searchState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isChatMode && input) {
|
if (!isChatMode && input) {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export default function SearchIcons({
|
|||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (goAskAi && assistant) {
|
if (goAskAi && assistant) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-8 -my-1">
|
<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">
|
<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]">
|
<div className="flex items-center gap-1 text-[#333] dark:text-[#D8D8D8]">
|
||||||
{assistant.icon?.startsWith("font_") ? (
|
{assistant.icon?.startsWith("font_") ? (
|
||||||
<FontIcon name={assistant.icon} className="size-5" />
|
<FontIcon name={assistant.icon} className="size-5" />
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
|
|||||||
onGoToTwoPage,
|
onGoToTwoPage,
|
||||||
}) => {
|
}) => {
|
||||||
const isDark = useThemeStore((state) => state.isDark);
|
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 (
|
return (
|
||||||
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
|
<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}
|
defaultIcon={isDark ? source_default_dark_img : source_default_img}
|
||||||
className="w-4 h-4"
|
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>
|
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||||
{!hideArrow && (
|
{!hideArrow && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,84 +1,124 @@
|
|||||||
import {
|
import { FC, MouseEvent, useContext } from "react";
|
||||||
cloneElement,
|
import { Extension, ExtensionId, ExtensionsContext } from "../..";
|
||||||
FC,
|
import { useReactive } from "ahooks";
|
||||||
Fragment,
|
|
||||||
MouseEvent,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { ExtensionsContext, Plugin } from "../..";
|
|
||||||
import { useMount } from "ahooks";
|
|
||||||
import { ChevronRight, LoaderCircle } from "lucide-react";
|
import { ChevronRight, LoaderCircle } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { isArray, isFunction } from "lodash-es";
|
import { isArray, startCase, sortBy } from "lodash-es";
|
||||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import Shortcut from "../Shortcut";
|
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||||
import { useTranslation } from "react-i18next";
|
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 Content = () => {
|
||||||
const { plugins } = useContext(ExtensionsContext);
|
const { rootState } = useContext(ExtensionsContext);
|
||||||
|
|
||||||
return plugins.map((item) => {
|
return rootState.extensions.map((item) => {
|
||||||
return <Item key={item.id} {...item} level={1} />;
|
const { id } = item;
|
||||||
|
|
||||||
|
return <Item key={id} {...item} level={1} extensionId={id} />;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Item: FC<Plugin & { level: number }> = (props) => {
|
interface ItemProps extends Extension {
|
||||||
const {
|
level: number;
|
||||||
id,
|
extensionId: ExtensionId;
|
||||||
icon,
|
}
|
||||||
name,
|
|
||||||
children,
|
interface ItemState {
|
||||||
type = "Extension",
|
loading: boolean;
|
||||||
manualLoad,
|
expanded: boolean;
|
||||||
level = 1,
|
subExtensions?: Extension[];
|
||||||
} = props;
|
}
|
||||||
const { activeId, setActiveId, setPlugins } = useContext(ExtensionsContext);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
|
||||||
const [expanded, setExpanded] = useState(false);
|
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 { t } = useTranslation();
|
||||||
const hasChildren = isArray(children);
|
const disabledExtensions = useExtensionsStore((state) => {
|
||||||
|
return state.disabledExtensions;
|
||||||
|
});
|
||||||
|
const setDisabledExtensions = useExtensionsStore((state) => {
|
||||||
|
return state.setDisabledExtensions;
|
||||||
|
});
|
||||||
|
|
||||||
const handleLoadChildren = async () => {
|
const hasSubExtensions = () => {
|
||||||
setLoading(true);
|
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 () => {
|
const getSubExtensions = async () => {
|
||||||
if (!manualLoad) {
|
state.loading = true;
|
||||||
handleLoadChildren();
|
|
||||||
|
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) => {
|
const handleExpand = async (event: MouseEvent) => {
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
|
|
||||||
if (expanded) {
|
if (state.expanded) {
|
||||||
setExpanded(false);
|
state.expanded = false;
|
||||||
} else {
|
} else {
|
||||||
if (manualLoad) {
|
state.subExtensions = await getSubExtensions();
|
||||||
await handleLoadChildren();
|
|
||||||
}
|
|
||||||
|
|
||||||
setExpanded(true);
|
state.expanded = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editable = () => {
|
||||||
|
return (
|
||||||
|
type !== "group" &&
|
||||||
|
type !== "calculator" &&
|
||||||
|
type !== "extension" &&
|
||||||
|
type !== "ai_extension"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderAlias = () => {
|
const renderAlias = () => {
|
||||||
const { alias, onAliasChange } = props;
|
const { alias } = props;
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
if (isFunction(onAliasChange)) {
|
platformAdapter.invokeBackend("set_extension_alias", {
|
||||||
return onAliasChange(value);
|
extensionId,
|
||||||
}
|
alias: value,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFunction(onAliasChange)) {
|
if (editable()) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="-translate-x-2"
|
className="-translate-x-2"
|
||||||
@@ -102,15 +142,22 @@ const Item: FC<Plugin & { level: number }> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderHotkey = () => {
|
const renderHotkey = () => {
|
||||||
const { hotkey, onHotkeyChange } = props;
|
const { hotkey } = props;
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
if (isFunction(onHotkeyChange)) {
|
if (value) {
|
||||||
return onHotkeyChange(value);
|
platformAdapter.invokeBackend("register_extension_hotkey", {
|
||||||
|
extensionId,
|
||||||
|
hotkey: value,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
platformAdapter.invokeBackend("unregister_extension_hotkey", {
|
||||||
|
extensionId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFunction(onHotkeyChange)) {
|
if (editable()) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="-translate-x-2"
|
className="-translate-x-2"
|
||||||
@@ -131,39 +178,36 @@ const Item: FC<Plugin & { level: number }> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderSwitch = () => {
|
const renderSwitch = () => {
|
||||||
const { enabled = true, onEnabledChange } = props;
|
const { enabled } = props;
|
||||||
|
|
||||||
const handleChange = (value: boolean) => {
|
const handleChange = (value: boolean) => {
|
||||||
if (isFunction(onEnabledChange)) {
|
if (value) {
|
||||||
return onEnabledChange(value);
|
setDisabledExtensions(
|
||||||
}
|
disabledExtensions.filter((item) => item !== extensionId)
|
||||||
|
);
|
||||||
|
|
||||||
const command = `${value ? "enable" : "disable"}_local_query_source`;
|
platformAdapter.invokeBackend("enable_extension", {
|
||||||
|
extensionId,
|
||||||
platformAdapter.invokeBackend(command, {
|
|
||||||
querySourceId: id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setPlugins((prevPlugins) => {
|
|
||||||
return prevPlugins.map((item) => {
|
|
||||||
if (item.id === id) {
|
|
||||||
return { ...item, enabled: value };
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
|
setDisabledExtensions([...disabledExtensions, extensionId]);
|
||||||
|
|
||||||
|
platformAdapter.invokeBackend("disable_extension", {
|
||||||
|
extensionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="flex items-center justify-end"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
label={id}
|
label={id}
|
||||||
checked={Boolean(enabled)}
|
defaultChecked={enabled}
|
||||||
className="scale-75"
|
className="scale-75"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
@@ -171,71 +215,98 @@ const Item: FC<Plugin & { level: number }> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderType = () => {
|
||||||
<Fragment key={id}>
|
if (type === "ai_extension") {
|
||||||
<div
|
return "AI Extension";
|
||||||
className={clsx("-mx-2 px-2 text-sm rounded-md", {
|
}
|
||||||
"bg-[#f0f6fe] dark:bg-gray-700": id === activeId,
|
|
||||||
})}
|
return startCase(type);
|
||||||
>
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isArray(platforms)) {
|
||||||
|
const currentPlatform = platform();
|
||||||
|
|
||||||
|
if (currentPlatform && !platforms.includes(currentPlatform)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between gap-2 h-8"
|
className={clsx("-mx-2 px-2 text-sm rounded-md", {
|
||||||
onClick={() => {
|
"bg-[#f0f6fe] dark:bg-gray-700":
|
||||||
setActiveId(id);
|
id === rootState.activeExtension?.id,
|
||||||
}}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex items-center gap-1 overflow-hidden"
|
className="flex items-center justify-between gap-2 h-8"
|
||||||
style={{ paddingLeft: (level - 1) * 20 }}
|
onClick={() => {
|
||||||
|
rootState.activeExtension = props;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-4 h-4">
|
<div
|
||||||
{hasChildren && (
|
className="flex-1 flex items-center gap-1 overflow-hidden"
|
||||||
<>
|
style={{ paddingLeft: (level - 1) * 20 }}
|
||||||
{loading ? (
|
>
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
<div className="min-w-4 h-4">
|
||||||
) : (
|
{hasSubExtensions() && (
|
||||||
<ChevronRight
|
<>
|
||||||
onClick={handleExpand}
|
{state.loading ? (
|
||||||
className={clsx("size-4 transition cursor-pointer", {
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
"rotate-90": expanded,
|
) : (
|
||||||
})}
|
<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>
|
</div>
|
||||||
|
|
||||||
{cloneElement(icon, {
|
<div className="w-4/6 flex items-center text-[#999]">
|
||||||
className: clsx("size-4", icon.props.className),
|
<div className="flex-1">{renderType()}</div>
|
||||||
})}
|
<div className="flex-1">{renderAlias()}</div>
|
||||||
|
<div className="flex-1">{renderHotkey()}</div>
|
||||||
<div className="truncate">{name}</div>
|
<div className="w-16">{renderSwitch()}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasChildren && (
|
<div className={clsx({ hidden: !state.expanded })}>
|
||||||
<div
|
{state.subExtensions?.map((item) => {
|
||||||
className={clsx({
|
return (
|
||||||
hidden: !expanded,
|
<Item
|
||||||
})}
|
key={item.id}
|
||||||
>
|
{...item}
|
||||||
{children.map((item) => {
|
level={level + 1}
|
||||||
return <Item key={item.id} {...item} level={level + 1} />;
|
extensionId={`${id}.${item.id}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</Fragment>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
return renderContent();
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Content;
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "ahooks";
|
import { useAsyncEffect } from "ahooks";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { ExtensionsContext, Plugin, type ExtensionsContextType } from "../../../index";
|
import { ExtensionsContext } from "../../../index";
|
||||||
|
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,46 +17,25 @@ interface Metadata {
|
|||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeId, plugins } = useContext(ExtensionsContext) as ExtensionsContextType;
|
const { rootState } = useContext(ExtensionsContext);
|
||||||
|
|
||||||
const [appMetadata, setAppMetadata] = useState<Metadata>();
|
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 () => {
|
useAsyncEffect(async () => {
|
||||||
if (!activeId || !currentPlugin) return;
|
if (!rootState.activeExtension) return;
|
||||||
|
|
||||||
|
const { id, title } = rootState.activeExtension;
|
||||||
|
|
||||||
const appMetadata = await platformAdapter.invokeBackend<Metadata>(
|
const appMetadata = await platformAdapter.invokeBackend<Metadata>(
|
||||||
"get_app_metadata",
|
"get_app_metadata",
|
||||||
{
|
{
|
||||||
appName: currentPlugin.name,
|
appPath: id,
|
||||||
appPath: activeId
|
appName: title,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
setAppMetadata(appMetadata);
|
setAppMetadata(appMetadata);
|
||||||
}, [activeId, currentPlugin]);
|
}, [rootState.activeExtension?.id]);
|
||||||
|
|
||||||
const metadata = useMemo(() => {
|
const metadata = useMemo(() => {
|
||||||
if (!appMetadata) return [];
|
if (!appMetadata) return [];
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const Applications = () => {
|
|||||||
<div className="flex items-center gap-1 flex-1 overflow-hidden">
|
<div className="flex items-center gap-1 flex-1 overflow-hidden">
|
||||||
<Folder className="size-4" />
|
<Folder className="size-4" />
|
||||||
|
|
||||||
<span className="truncate">{item}</span>
|
<span className="flex-1 truncate">{item}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<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 FontIcon from "@/components/Common/Icons/FontIcon";
|
||||||
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
|
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useAsyncEffect, useMount } from "ahooks";
|
import { useAsyncEffect, useMount } from "ahooks";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { FC, useMemo, useState } from "react";
|
||||||
|
import { ExtensionId } from "../../..";
|
||||||
|
|
||||||
const QuickAiAccess = () => {
|
interface SharedAiProps {
|
||||||
const quickAiAccessServer = useExtensionsStore((state) => {
|
id: ExtensionId;
|
||||||
return state.quickAiAccessServer;
|
server?: any;
|
||||||
});
|
setServer: (server: any) => void;
|
||||||
const setQuickAiAccessServer = useExtensionsStore((state) => {
|
assistant?: any;
|
||||||
return state.setQuickAiAccessServer;
|
setAssistant: (assistant: any) => void;
|
||||||
});
|
}
|
||||||
const quickAiAccessAssistant = useExtensionsStore((state) => {
|
|
||||||
return state.quickAiAccessAssistant;
|
const SharedAi: FC<SharedAiProps> = (props) => {
|
||||||
});
|
const { id, server, setServer, assistant, setAssistant } = props;
|
||||||
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
|
|
||||||
return state.setQuickAiAccessAssistant;
|
const [serverList, setServerList] = useState<any[]>([server]);
|
||||||
});
|
const [assistantList, setAssistantList] = useState<any[]>([assistant]);
|
||||||
const [serverList, setServerList] = useState<any[]>([]);
|
|
||||||
const [assistantList, setAssistantList] = useState<any[]>([]);
|
|
||||||
const addError = useAppStore((state) => state.addError);
|
const addError = useAppStore((state) => state.addError);
|
||||||
const { fetchAssistant } = AssistantFetcher({});
|
const { fetchAssistant } = AssistantFetcher({});
|
||||||
|
|
||||||
@@ -33,9 +31,9 @@ const QuickAiAccess = () => {
|
|||||||
|
|
||||||
setServerList(data);
|
setServerList(data);
|
||||||
|
|
||||||
if (quickAiAccessServer) return;
|
if (server) return;
|
||||||
|
|
||||||
setQuickAiAccessServer(data[0]);
|
setServer(data[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError(String(error));
|
addError(String(error));
|
||||||
}
|
}
|
||||||
@@ -43,77 +41,74 @@ const QuickAiAccess = () => {
|
|||||||
|
|
||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
try {
|
try {
|
||||||
if (!quickAiAccessServer) return;
|
if (!server) return;
|
||||||
|
|
||||||
const data = await fetchAssistant({
|
const data = await fetchAssistant({
|
||||||
current: 1,
|
current: 1,
|
||||||
pageSize: 1000,
|
pageSize: 1000,
|
||||||
serverId: quickAiAccessServer.id,
|
serverId: server.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = data.list.map((item: any) => item._source);
|
const list = data.list.map((item: any) => item._source);
|
||||||
|
|
||||||
setAssistantList(list);
|
setAssistantList(list);
|
||||||
|
|
||||||
if (quickAiAccessAssistant) {
|
if (assistant) {
|
||||||
const matched = list.find((item: any) => {
|
const matched = list.find((item: any) => {
|
||||||
return item.id === quickAiAccessAssistant.id;
|
return item.id === assistant.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
return setQuickAiAccessAssistant(matched);
|
return setAssistant(matched);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuickAiAccessAssistant(list[0]);
|
setAssistant(list[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError(String(error));
|
addError(String(error));
|
||||||
}
|
}
|
||||||
}, [quickAiAccessServer]);
|
}, [server]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = useExtensionsStore.subscribe((state) => {
|
|
||||||
platformAdapter.emitEvent("change-extensions-store", state);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectList = useMemo(() => {
|
const selectList = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "Coco Server",
|
label: "Coco Server",
|
||||||
value: quickAiAccessServer?.id,
|
value: server?.id,
|
||||||
icon: quickAiAccessServer?.provider?.icon,
|
icon: server?.provider?.icon,
|
||||||
data: serverList,
|
data: serverList,
|
||||||
onChange: (value: string) => {
|
onChange: (value: string) => {
|
||||||
const matched = serverList.find((item) => item.id === value);
|
const matched = serverList.find((item) => item.id === value);
|
||||||
|
|
||||||
setQuickAiAccessServer(matched);
|
setServer(matched);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "AI Assistant",
|
label: "AI Assistant",
|
||||||
value: quickAiAccessAssistant?.id,
|
value: assistant?.id,
|
||||||
icon: quickAiAccessAssistant?.icon,
|
icon: assistant?.icon,
|
||||||
data: assistantList,
|
data: assistantList,
|
||||||
onChange: (value: string) => {
|
onChange: (value: string) => {
|
||||||
const matched = assistantList.find((item) => item.id === value);
|
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 (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="text-[#999]">
|
<div className="text-[#999]">{renderDescription()}</div>
|
||||||
Quick AI access allows you to start a conversation immediately from the
|
|
||||||
search box using the tab key.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-[#333] dark:text-white/90">LinkedAssistant</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 { useContext } from "react";
|
||||||
import { ExtensionsContext, Plugin } from "../..";
|
|
||||||
|
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 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) => {
|
const renderContent = () => {
|
||||||
for (const plugin of plugins) {
|
if (!rootState.activeExtension) return;
|
||||||
const { children = [] } = plugin;
|
|
||||||
|
|
||||||
if (plugin.id === id) {
|
const { id, type } = rootState.activeExtension;
|
||||||
return plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (children.length > 0) {
|
if (id === "Applications") {
|
||||||
const matched = findPlugin(children, id) as Plugin;
|
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 (
|
return (
|
||||||
<div className="flex-1 h-full overflow-auto">
|
<div className="flex-1 h-full overflow-auto">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{currentPlugin?.name}
|
{rootState.activeExtension?.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="pr-4">{currentPlugin?.detail}</div>
|
<div className="pr-4 pb-4">{renderContent()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,210 +1,107 @@
|
|||||||
import {
|
import { createContext, useEffect } from "react";
|
||||||
createContext,
|
import { useMount, useReactive } from "ahooks";
|
||||||
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 { useTranslation } from "react-i18next";
|
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 platformAdapter from "@/utils/platformAdapter";
|
||||||
import Content from "./components/Content";
|
import Content from "./components/Content";
|
||||||
import Details from "./components/Details";
|
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 {
|
export type ExtensionId = LiteralUnion<
|
||||||
path: string;
|
"Applications" | "Calculator" | "QuickAIAccess" | "AIOverview",
|
||||||
name: string;
|
string
|
||||||
iconPath: string;
|
>;
|
||||||
alias: string;
|
|
||||||
hotkey: string;
|
type ExtensionType =
|
||||||
isDisabled: boolean;
|
| "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 {
|
interface ExtensionQuickLink {
|
||||||
id: string;
|
link: string;
|
||||||
icon: ReactElement;
|
}
|
||||||
name: ReactNode;
|
|
||||||
type?: "Group" | "Extension" | "Application";
|
export interface Extension {
|
||||||
|
id: ExtensionId;
|
||||||
|
type: ExtensionType;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
hotkey?: string;
|
hotkey?: string;
|
||||||
enabled?: boolean;
|
enabled: boolean;
|
||||||
detail?: ReactNode;
|
platforms?: ExtensionPlatform[];
|
||||||
children?: Plugin[];
|
action: ExtensionAction;
|
||||||
manualLoad?: boolean;
|
quick_link: ExtensionQuickLink;
|
||||||
loadChildren?: () => Promise<void>;
|
commands?: Extension[];
|
||||||
onAliasChange?: (alias: string) => void;
|
scripts?: Extension[];
|
||||||
onHotkeyChange?: (hotkey: string) => void;
|
quick_links?: Extension[];
|
||||||
onEnabledChange?: (enabled: boolean) => void;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionsContextType {
|
interface State {
|
||||||
plugins: Plugin[];
|
extensions: Extension[];
|
||||||
setPlugins: Dispatch<SetStateAction<Plugin[]>>;
|
activeExtension?: Extension;
|
||||||
activeId?: string;
|
|
||||||
setActiveId: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExtensionsContext = createContext<ExtensionsContextType>({
|
const INITIAL_STATE: State = {
|
||||||
plugins: [],
|
extensions: [],
|
||||||
setPlugins: noop,
|
};
|
||||||
setActiveId: noop,
|
|
||||||
|
export const ExtensionsContext = createContext<{ rootState: State }>({
|
||||||
|
rootState: INITIAL_STATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Extensions = () => {
|
export const Extensions = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [apps, setApps] = useState<IApplication[]>([]);
|
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
|
||||||
const [disabled, setDisabled] = useState<string[]>([]);
|
const setDisabledExtensions = useExtensionsStore((state) => {
|
||||||
const [activeId, setActiveId] = useState<string>();
|
return state.setDisabledExtensions;
|
||||||
|
|
||||||
useMount(async () => {
|
|
||||||
const disabled = await platformAdapter.invokeBackend<string[]>(
|
|
||||||
"get_disabled_local_query_sources"
|
|
||||||
);
|
|
||||||
|
|
||||||
setDisabled(disabled);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadApps = async () => {
|
useMount(async () => {
|
||||||
const apps = await platformAdapter.invokeBackend<IApplication[]>(
|
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
|
||||||
"get_app_list"
|
"list_extensions"
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedApps = apps.sort((a, b) => {
|
const extensions = result[1];
|
||||||
return a.name.localeCompare(b.name, undefined, {
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setApps(sortedApps);
|
const disabledExtensions = extensions.filter((item) => !item.enabled);
|
||||||
};
|
|
||||||
|
|
||||||
const presetPlugins = useMemo<Plugin[]>(() => {
|
setDisabledExtensions(disabledExtensions.map((item) => item.id));
|
||||||
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 />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (apps.length > 0) {
|
state.extensions = sortBy(extensions, ["title"]);
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlugins(presetPlugins);
|
const unsubscribe = useExtensionsStore.subscribe((state) => {
|
||||||
}, [presetPlugins]);
|
platformAdapter.emitEvent("change-extensions-store", state);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPlugins((prevPlugins) => {
|
|
||||||
return prevPlugins.map((item) => {
|
|
||||||
if (disabled.includes(item.id)) {
|
|
||||||
return { ...item, enabled: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, [disabled]);
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExtensionsContext.Provider
|
<ExtensionsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
plugins,
|
rootState: state,
|
||||||
setPlugins,
|
|
||||||
activeId,
|
|
||||||
setActiveId,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4">
|
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4">
|
||||||
@@ -217,7 +114,7 @@ const Extensions = () => {
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-1">{t("settings.extensions.list.name")}</div>
|
<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">
|
<div className="flex-1">
|
||||||
{t("settings.extensions.list.type")}
|
{t("settings.extensions.list.type")}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +124,7 @@ const Extensions = () => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{t("settings.extensions.list.hotkey")}
|
{t("settings.extensions.list.hotkey")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-right">
|
<div className="w-16 text-right whitespace-nowrap">
|
||||||
{t("settings.extensions.list.enabled")}
|
{t("settings.extensions.list.enabled")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// import { Select, SelectProps } from "@headlessui/react";
|
|
||||||
import { Select, SelectProps } from "@headlessui/react";
|
import { Select, SelectProps } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { isArray } from "lodash-es";
|
||||||
import { ChevronDownIcon } from "lucide-react";
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const renderOptions = () => {
|
const renderOptions = () => {
|
||||||
if (data) {
|
if (isArray(data)) {
|
||||||
return data.map((item) => {
|
return data.map((item) => {
|
||||||
return (
|
return (
|
||||||
<option key={item[valueField]} value={item[valueField]}>
|
<option key={item?.[valueField]} value={item?.[valueField]}>
|
||||||
{item[labelField]}
|
{item?.[labelField]}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,35 +1,27 @@
|
|||||||
import { Switch } from "@headlessui/react";
|
import { Switch, SwitchProps } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface SettingsToggleProps {
|
interface SettingsToggleProps extends SwitchProps {
|
||||||
checked: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsToggle({
|
export default function SettingsToggle(props: SettingsToggleProps) {
|
||||||
checked,
|
const { label, className, ...rest } = props;
|
||||||
onChange,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
}: SettingsToggleProps) {
|
|
||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
{...rest}
|
||||||
onChange={onChange}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
`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`,
|
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`,
|
||||||
[checked ? "bg-blue-600" : "bg-gray-200 dark:bg-gray-700"],
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="sr-only">{label}</span>
|
<span className="sr-only">{label}</span>
|
||||||
<span
|
<span
|
||||||
className={`${checked ? "translate-x-5" : "translate-x-0"}
|
className="pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow
|
||||||
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"
|
||||||
ring-0 transition duration-200 ease-in-out`}
|
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</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 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";
|
export const COPY_BUTTON_ID = "copy-button";
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { useCallback, useEffect } from 'react';
|
|||||||
|
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { isMetaOrCtrlKey, metaOrCtrlKey } from '@/utils/keyboardUtils';
|
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 type { QueryHits, SearchDocument } from "@/types/search";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
interface UseKeyboardNavigationProps {
|
interface UseKeyboardNavigationProps {
|
||||||
suggests: QueryHits[];
|
suggests: QueryHits[];
|
||||||
@@ -67,12 +68,11 @@ export function useKeyboardNavigation({
|
|||||||
if (
|
if (
|
||||||
e.key === "Enter" &&
|
e.key === "Enter" &&
|
||||||
!e.shiftKey &&
|
!e.shiftKey &&
|
||||||
selectedIndex !== null &&
|
selectedIndex !== null
|
||||||
isMetaOrCtrlKey(e)
|
|
||||||
) {
|
) {
|
||||||
const item = globalItemIndexMap[selectedIndex];
|
const item = globalItemIndexMap[selectedIndex];
|
||||||
if (item?.url) {
|
if (item?.on_opened) {
|
||||||
OpenURLWithBrowser(item?.url);
|
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
|
||||||
} else {
|
} else {
|
||||||
copyToClipboard(item?.payload?.result?.value);
|
copyToClipboard(item?.payload?.result?.value);
|
||||||
}
|
}
|
||||||
@@ -85,8 +85,8 @@ export function useKeyboardNavigation({
|
|||||||
|
|
||||||
const item = globalItemIndexMap[index];
|
const item = globalItemIndexMap[index];
|
||||||
|
|
||||||
if (item?.url) {
|
if (item?.on_opened) {
|
||||||
OpenURLWithBrowser(item?.url);
|
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) => {
|
const useScript = (src: string, onError?: () => void) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -6,7 +6,7 @@ const useScript = (src: string, onError?: () => void) => {
|
|||||||
return; // Prevent duplicate script loading
|
return; // Prevent duplicate script loading
|
||||||
}
|
}
|
||||||
|
|
||||||
const script = document.createElement('script');
|
const script = document.createElement("script");
|
||||||
script.src = src;
|
script.src = src;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
|
|
||||||
@@ -25,24 +25,27 @@ const useScript = (src: string, onError?: () => void) => {
|
|||||||
|
|
||||||
export default useScript;
|
export default useScript;
|
||||||
|
|
||||||
|
|
||||||
export const useIconfontScript = () => {
|
export const useIconfontScript = () => {
|
||||||
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
|
||||||
|
|
||||||
const [useLocalFallback, setUseLocalFallback] = useState(false);
|
const [useLocalFallback, setUseLocalFallback] = useState(false);
|
||||||
|
|
||||||
let baseURL = appStore.state?.endpoint_http
|
let baseURL = appStore.state?.endpoint_http;
|
||||||
if (!baseURL || baseURL === "undefined") {
|
if (!baseURL || baseURL === "undefined") {
|
||||||
baseURL = "";
|
baseURL = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useLocalFallback || baseURL === "") {
|
if (useLocalFallback || baseURL === "") {
|
||||||
useScript('/assets/fonts/icons/iconfont.js');
|
useScript("/assets/fonts/icons/iconfont.js");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
useScript(`${baseURL}/assets/fonts/icons/iconfont.js`, () => {
|
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);
|
setUseLocalFallback(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useScript("/assets/fonts/icons/extension.js");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useRef } from "react";
|
||||||
import { debounce } from 'lodash-es';
|
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 platformAdapter from "@/utils/platformAdapter";
|
||||||
import { Get } from "@/api/axiosRequest";
|
import { Get } from "@/api/axiosRequest";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
|
|
||||||
interface SearchState {
|
interface SearchState {
|
||||||
isError: FailedRequest[];
|
isError: FailedRequest[];
|
||||||
@@ -21,6 +28,25 @@ interface SearchDataBySource {
|
|||||||
|
|
||||||
export function useSearch() {
|
export function useSearch() {
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
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();
|
const { querySourceTimeout } = useConnectStore();
|
||||||
|
|
||||||
@@ -29,22 +55,28 @@ export function useSearch() {
|
|||||||
suggests: [],
|
suggests: [],
|
||||||
searchData: {},
|
searchData: {},
|
||||||
isSearchComplete: false,
|
isSearchComplete: false,
|
||||||
globalItemIndexMap: {}
|
globalItemIndexMap: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearchResponse = (response: MultiSourceQueryResponse) => {
|
const handleSearchResponse = (
|
||||||
|
response: MultiSourceQueryResponse,
|
||||||
|
searchInput: string
|
||||||
|
) => {
|
||||||
const data = response?.hits || [];
|
const data = response?.hits || [];
|
||||||
|
|
||||||
const searchData = data.reduce((acc: SearchDataBySource, item: QueryHits) => {
|
const searchData = data.reduce(
|
||||||
const name = item?.document?.source?.name;
|
(acc: SearchDataBySource, item: QueryHits) => {
|
||||||
if (name) {
|
const name = item?.document?.source?.name;
|
||||||
if (!acc[name]) {
|
if (name) {
|
||||||
acc[name] = [];
|
if (!acc[name]) {
|
||||||
|
acc[name] = [];
|
||||||
|
}
|
||||||
|
acc[name].push(item);
|
||||||
}
|
}
|
||||||
acc[name].push(item);
|
return acc;
|
||||||
}
|
},
|
||||||
return acc;
|
{}
|
||||||
}, {});
|
);
|
||||||
|
|
||||||
// Update indices and map
|
// Update indices and map
|
||||||
//console.log("_search response", data, searchData);
|
//console.log("_search response", data, searchData);
|
||||||
@@ -54,10 +86,65 @@ export function useSearch() {
|
|||||||
searchData[sourceName].map((item: QueryHits) => {
|
searchData[sourceName].map((item: QueryHits) => {
|
||||||
item.document.querySource = item?.source;
|
item.document.querySource = item?.source;
|
||||||
const index = globalIndex++;
|
const index = globalIndex++;
|
||||||
item.document.index = index
|
item.document.index = index;
|
||||||
globalItemIndexMap[index] = item.document;
|
globalItemIndexMap[index] = item.document;
|
||||||
return item;
|
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({
|
setSearchState({
|
||||||
@@ -69,47 +156,65 @@ export function useSearch() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const performSearch = useCallback(async (searchInput: string) => {
|
const performSearch = useCallback(
|
||||||
if (!searchInput) {
|
async (searchInput: string) => {
|
||||||
setSearchState(prev => ({ ...prev, suggests: [] }));
|
if (!searchInput) {
|
||||||
return;
|
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
console.log("_suggest", searchInput, response);
|
||||||
}, [querySourceTimeout, isTauri]);
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSearchResponse(response, searchInput);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
querySourceTimeout,
|
||||||
|
isTauri,
|
||||||
|
enabledAiOverview,
|
||||||
|
aiOverviewServer,
|
||||||
|
aiOverviewAssistant,
|
||||||
|
disabledExtensions,
|
||||||
|
aiOverviewCharLen,
|
||||||
|
aiOverviewDelay,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const debouncedSearch = useMemo(
|
const debouncedSearch = useMemo(
|
||||||
() => debounce(performSearch, 300),
|
() => debounce(performSearch, 300),
|
||||||
@@ -118,6 +223,6 @@ export function useSearch() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...searchState,
|
...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) => {
|
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
|
||||||
return state.setQuickAiAccessAssistant;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!resetFixedWindow) {
|
if (!resetFixedWindow) {
|
||||||
@@ -174,10 +189,23 @@ export const useSyncStore = () => {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
platformAdapter.listenEvent("change-extensions-store", ({ payload }) => {
|
platformAdapter.listenEvent("change-extensions-store", ({ payload }) => {
|
||||||
const { quickAiAccessServer, quickAiAccessAssistant } = payload;
|
const {
|
||||||
|
quickAiAccessServer,
|
||||||
|
quickAiAccessAssistant,
|
||||||
|
aiOverviewServer,
|
||||||
|
aiOverviewAssistant,
|
||||||
|
disabledExtensions,
|
||||||
|
aiOverviewCharLen,
|
||||||
|
aiOverviewDelay,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
setQuickAiAccessServer(quickAiAccessServer);
|
setQuickAiAccessServer(quickAiAccessServer);
|
||||||
setQuickAiAccessAssistant(quickAiAccessAssistant);
|
setQuickAiAccessAssistant(quickAiAccessAssistant);
|
||||||
|
setAiOverviewServer(aiOverviewServer);
|
||||||
|
setAiOverviewAssistant(aiOverviewAssistant);
|
||||||
|
setDisabledExtensions(disabledExtensions);
|
||||||
|
setAiOverviewCharLen(aiOverviewCharLen);
|
||||||
|
setAiOverviewDelay(aiOverviewDelay);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
10
src/main.css
10
src/main.css
@@ -250,4 +250,14 @@
|
|||||||
-ms-user-select: text;
|
-ms-user-select: text;
|
||||||
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 ErrorNotification from "@/components/Common/ErrorNotification";
|
||||||
import { useModifierKeyPress } from "@/hooks/useModifierKeyPress";
|
import { useModifierKeyPress } from "@/hooks/useModifierKeyPress";
|
||||||
import { useIconfontScript } from "@/hooks/useScript";
|
import { useIconfontScript } from "@/hooks/useScript";
|
||||||
|
import { Extension } from "@/components/Settings/Extensions";
|
||||||
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -119,6 +121,20 @@ export default function Layout() {
|
|||||||
|
|
||||||
useIconfontScript();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ExtensionId } from "@/components/Settings/Extensions";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist, subscribeWithSelector } from "zustand/middleware";
|
import { persist, subscribeWithSelector } from "zustand/middleware";
|
||||||
|
|
||||||
@@ -6,6 +7,16 @@ export type IExtensionsStore = {
|
|||||||
setQuickAiAccessServer: (quickAiAccessServer?: any) => void;
|
setQuickAiAccessServer: (quickAiAccessServer?: any) => void;
|
||||||
quickAiAccessAssistant?: any;
|
quickAiAccessAssistant?: any;
|
||||||
setQuickAiAccessAssistant: (quickAiAccessAssistant?: any) => void;
|
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>()(
|
export const useExtensionsStore = create<IExtensionsStore>()(
|
||||||
@@ -18,12 +29,34 @@ export const useExtensionsStore = create<IExtensionsStore>()(
|
|||||||
setQuickAiAccessAssistant(quickAiAccessAssistant) {
|
setQuickAiAccessAssistant(quickAiAccessAssistant) {
|
||||||
return set({ 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",
|
name: "extensions-store",
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
quickAiAccessServer: state.quickAiAccessServer,
|
quickAiAccessServer: state.quickAiAccessServer,
|
||||||
quickAiAccessAssistant: state.quickAiAccessAssistant,
|
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;
|
setSelectedAssistant: (selectedAssistant?: any) => void;
|
||||||
askAiServerId?: string;
|
askAiServerId?: string;
|
||||||
setAskAiServerId: (askAiServerId?: string) => void;
|
setAskAiServerId: (askAiServerId?: string) => void;
|
||||||
|
enabledAiOverview: boolean;
|
||||||
|
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
|
||||||
|
askAiAssistantId?: string;
|
||||||
|
setAskAiAssistantId: (askAiAssistantId?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSearchStore = create<ISearchStore>()(
|
export const useSearchStore = create<ISearchStore>()(
|
||||||
@@ -59,6 +63,13 @@ export const useSearchStore = create<ISearchStore>()(
|
|||||||
setAskAiServerId: (askAiServerId) => {
|
setAskAiServerId: (askAiServerId) => {
|
||||||
return set({ askAiServerId });
|
return set({ askAiServerId });
|
||||||
},
|
},
|
||||||
|
enabledAiOverview: false,
|
||||||
|
setEnabledAiOverview: (enabledAiOverview) => {
|
||||||
|
return set({ enabledAiOverview });
|
||||||
|
},
|
||||||
|
setAskAiAssistantId: (askAiAssistantId) => {
|
||||||
|
return set({ askAiAssistantId });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "search-store",
|
name: "search-store",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ASK_AI_CLIENT_ID } from "@/constants";
|
|
||||||
import { IAppearanceStore } from "@/stores/appearanceStore";
|
import { IAppearanceStore } from "@/stores/appearanceStore";
|
||||||
import { IConnectStore } from "@/stores/connectStore";
|
import { IConnectStore } from "@/stores/connectStore";
|
||||||
import { IExtensionsStore } from "@/stores/extensionsStore";
|
import { IExtensionsStore } from "@/stores/extensionsStore";
|
||||||
@@ -38,9 +37,10 @@ export interface EventPayloads {
|
|||||||
"change-shortcuts-store": IShortcutsStore;
|
"change-shortcuts-store": IShortcutsStore;
|
||||||
"change-connect-store": IConnectStore;
|
"change-connect-store": IConnectStore;
|
||||||
"change-appearance-store": IAppearanceStore;
|
"change-appearance-store": IAppearanceStore;
|
||||||
[ASK_AI_CLIENT_ID]: any;
|
|
||||||
"toggle-to-chat-mode": void;
|
"toggle-to-chat-mode": void;
|
||||||
"change-extensions-store": IExtensionsStore;
|
"change-extensions-store": IExtensionsStore;
|
||||||
|
"quick-ai-access-client-id": any;
|
||||||
|
"ai-overview-client-id": any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window operation interface
|
// Window operation interface
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface QueryHits {
|
|||||||
|
|
||||||
export interface QuerySource {
|
export interface QuerySource {
|
||||||
type: string; // coco-server/local/ etc.
|
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.
|
name: string; // coco server's name, local computer name, etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export interface SearchDocument {
|
|||||||
querySource?: QuerySource;
|
querySource?: QuerySource;
|
||||||
index?: number; // Index in the current search result
|
index?: number; // Index in the current search result
|
||||||
globalIndex?: number;
|
globalIndex?: number;
|
||||||
|
on_opened?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RichLabel {
|
export interface RichLabel {
|
||||||
|
|||||||
@@ -113,3 +113,7 @@ export const closeHistoryPanel = () => {
|
|||||||
button.click();
|
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("isWin", isWin);
|
||||||
console.log("isLinux", isLinux);
|
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() {
|
export function family() {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
|||||||
@@ -207,8 +207,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async openExternal(url) {
|
async openExternal(url) {
|
||||||
const { invoke } = await import("@tauri-apps/api/core");
|
const { open } = await import("@tauri-apps/plugin-shell");
|
||||||
return invoke("open", { path: url });
|
|
||||||
|
open(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
isWindows10,
|
isWindows10,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default {
|
|||||||
2000: "2000",
|
2000: "2000",
|
||||||
},
|
},
|
||||||
screens: {
|
screens: {
|
||||||
'mobile': {'max': '679px'},
|
mobile: { max: "679px" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
const packageJson = {
|
const packageJson = {
|
||||||
name: "@infinilabs/search-chat",
|
name: "@infinilabs/search-chat",
|
||||||
version: "1.2.5",
|
version: "1.2.8",
|
||||||
main: "index.js",
|
main: "index.js",
|
||||||
module: "index.js",
|
module: "index.js",
|
||||||
type: "module",
|
type: "module",
|
||||||
|
|||||||
Reference in New Issue
Block a user