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:
ayangweb
2025-05-30 17:18:52 +08:00
committed by GitHub
parent 51b0a2a545
commit c471a83821
75 changed files with 3674 additions and 1099 deletions

View File

@@ -111,6 +111,8 @@ Information about release notes of Coco Server is provided here.
- feat: data sources support displaying customized icons #432
- feat: add shortcut key conflict hint and reset function #442
- feat: updated to include error message #465
- feat: support third party extensions #572
- feat: support ai overview #572
### Bug fix

View File

@@ -62,6 +62,7 @@
"tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.2.0",
"tauri-plugin-windows-version-api": "^2.0.0",
"type-fest": "^4.41.0",
"use-debounce": "^10.0.4",
"uuid": "^11.1.0",
"wavesurfer.js": "^7.9.5",

3
pnpm-lock.yaml generated
View File

@@ -140,6 +140,9 @@ importers:
tauri-plugin-windows-version-api:
specifier: ^2.0.0
version: 2.0.0
type-fest:
specifier: ^4.41.0
version: 4.41.0
use-debounce:
specifier: ^10.0.4
version: 10.0.4(react@18.3.1)

File diff suppressed because one or more lines are too long

57
src-tauri/Cargo.lock generated
View File

@@ -823,13 +823,16 @@ dependencies = [
name = "coco"
version = "0.4.0"
dependencies = [
"anyhow",
"applications",
"async-trait",
"base64 0.13.1",
"chinese-number",
"chrono",
"derive_more 2.0.1",
"dirs 5.0.1",
"enigo",
"function_name",
"futures",
"futures-util",
"hostname",
@@ -847,6 +850,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"serde_plain",
"strsim 0.10.0",
"tauri",
"tauri-build",
@@ -1291,6 +1295,27 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
"unicode-xid",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1826,6 +1851,21 @@ dependencies = [
"libc",
]
[[package]]
name = "function_name"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1ab577a896d09940b5fe12ec5ae71f9d8211fff62c919c03a3750a9901e98a7"
dependencies = [
"function_name-proc-macro",
]
[[package]]
name = "function_name-proc-macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673464e1e314dd67a0fd9544abc99e8eb28d0c7e3b69b033bcff9b2d00b87333"
[[package]]
name = "funty"
version = "2.0.0"
@@ -5328,7 +5368,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
dependencies = [
"bitflags 1.3.2",
"cssparser",
"derive_more",
"derive_more 0.99.20",
"fxhash",
"log",
"matches",
@@ -5403,6 +5443,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -7040,6 +7089,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"

View File

@@ -93,6 +93,10 @@ chinese-number = "0.7"
num2words = "1"
tauri-plugin-log = "2"
chrono = "0.4.41"
serde_plain = "1.0.2"
derive_more = { version = "2.0.1", features = ["display"] }
anyhow = "1.0.98"
function_name = "0.3.0"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }

View File

@@ -0,0 +1,8 @@
{
"id": "AIOverview",
"title": "AI Overview",
"description": "...",
"icon": "font_a-AIOverview",
"type": "ai_extension",
"enabled": true
}

View File

@@ -0,0 +1,9 @@
{
"id": "Applications",
"platforms": ["macos", "linux", "windows"],
"title": "Applications",
"description": "...",
"icon": "font_Application",
"type": "group",
"enabled": true
}

View File

@@ -0,0 +1,9 @@
{
"id": "Calculator",
"title": "Calculator",
"platforms": ["macos", "linux", "windows"],
"description": "...",
"icon": "font_Calculator",
"type": "calculator",
"enabled": true
}

View File

@@ -0,0 +1,8 @@
{
"id": "QuickAIAccess",
"title": "Quick AI Access",
"description": "...",
"icon": "font_a-QuickAIAccess",
"type": "ai_extension",
"enabled": true
}

View File

@@ -1,2 +1,2 @@
[toolchain]
channel = "nightly-2024-10-29"
channel = "nightly-2025-02-28"

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::hide_coco;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
pub label: Option<String>,
@@ -29,6 +31,72 @@ pub struct EditorInfo {
pub timestamp: Option<String>,
}
/// Defines the action that would be performed when a document gets opened.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum OnOpened {
/// Launch the application
Application { app_path: String },
/// Open the URL.
Document { url: String },
/// Spawn a child process to run the `CommandAction`.
Command {
action: crate::extension::CommandAction,
},
}
impl OnOpened {
pub(crate) fn url(&self) -> String {
match self {
Self::Application { app_path } => app_path.clone(),
Self::Document { url } => url.clone(),
Self::Command { action } => {
const WHITESPACE: &str = " ";
let mut ret = action.exec.clone();
ret.push_str(WHITESPACE);
ret.push_str(action.args.join(WHITESPACE).as_str());
ret
}
}
}
}
#[tauri::command]
pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> {
log::debug!("open({})", on_opened.url());
use crate::util::open as homemade_tauri_shell_open;
use crate::GLOBAL_TAURI_APP_HANDLE;
use std::process::Command;
let global_tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
match on_opened {
OnOpened::Application { app_path } => {
homemade_tauri_shell_open(global_tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
homemade_tauri_shell_open(global_tauri_app_handle.clone(), url).await?
}
OnOpened::Command { action } => {
let mut cmd = Command::new(action.exec);
cmd.args(action.args);
let output = cmd.output().map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
}
hide_coco(global_tauri_app_handle.clone()).await;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Document {
pub id: String,
@@ -48,6 +116,8 @@ pub struct Document {
pub thumbnail: Option<String>,
pub cover: Option<String>,
pub tags: Option<Vec<String>>,
/// What will happen if we open this document.
pub on_opened: Option<OnOpened>,
pub url: Option<String>,
pub size: Option<i64>,
pub metadata: Option<HashMap<String, serde_json::Value>>,

View File

@@ -1,5 +1,4 @@
use crate::common::error::SearchError;
// use std::{future::Future, pin::Pin};
use crate::common::search::SearchQuery;
use crate::common::search::{QueryResponse, QuerySource};
use async_trait::async_trait;
@@ -10,4 +9,3 @@ pub trait SearchSource: Send + Sync {
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
}

View File

@@ -0,0 +1 @@
pub(super) const EXTENSION_ID: &str = "AIOverview";

View File

@@ -1,13 +1,14 @@
use super::super::SearchSourceState;
use super::super::Task;
use super::super::RUNTIME_TX;
use super::AppEntry;
use super::super::pizza_engine_runtime::SearchSourceState;
use super::super::pizza_engine_runtime::Task;
use super::super::pizza_engine_runtime::RUNTIME_TX;
use super::super::Extension;
use super::AppMetadata;
use crate::common::document::{DataSourceReference, Document};
use crate::common::document::{DataSourceReference, Document, OnOpened};
use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use crate::extension::ExtensionType;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::util::open;
use crate::GLOBAL_TAURI_APP_HANDLE;
use applications::{App, AppTrait};
@@ -326,7 +327,7 @@ impl<R: Runtime> Task for SearchApplicationsTask<R> {
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>) {
let callback = self.callback.take().unwrap();
let disabled_app_list = get_disabled_app_list(self.tauri_app_handle.clone());
let disabled_app_list = get_disabled_app_list(&self.tauri_app_handle);
// TODO: search via alias, implement this when Pizza engine supports update
let dsl = format!(
@@ -551,19 +552,24 @@ fn pizza_engine_hits_to_coco_hits(
FieldValue::Text(string) => string,
_ => unreachable!("field icon is of type Text"),
};
let on_opened = OnOpened::Application {
app_path: app_path.clone(),
};
let url = on_opened.url();
let coco_document = Document {
source: Some(DataSourceReference {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
id: Some(QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into()),
icon: None,
icon: Some(String::from("font_Application")),
}),
id: app_path.clone(),
category: Some("Application".to_string()),
title: Some(app_name.clone()),
url: Some(app_path),
icon: Some(app_icon_path),
on_opened: Some(on_opened),
url: Some(url),
..Default::default()
};
@@ -574,12 +580,7 @@ fn pizza_engine_hits_to_coco_hits(
coco_hits
}
#[tauri::command]
pub async fn set_app_alias<R: Runtime>(
tauri_app_handle: AppHandle<R>,
app_path: String,
alias: String,
) {
pub fn set_app_alias<R: Runtime>(tauri_app_handle: &AppHandle<R>, app_path: &str, alias: &str) {
let store = tauri_app_handle
.store(TAURI_STORE_APP_ALIAS)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_ALIAS));
@@ -649,42 +650,42 @@ fn register_app_hotkey_upon_start<R: Runtime>(
Ok(())
}
#[tauri::command]
pub async fn register_app_hotkey<R: Runtime>(
tauri_app_handle: AppHandle<R>,
app_path: String,
hotkey: String,
pub fn register_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
hotkey: &str,
) -> Result<(), String> {
// Ignore the error as it may not be registered
unregister_app_hotkey(tauri_app_handle, app_path)?;
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
app_hotkey_store.set(app_path.clone(), hotkey.as_str());
app_hotkey_store.set(app_path, hotkey);
tauri_app_handle
.global_shortcut()
.on_shortcut(hotkey.as_str(), app_hotkey_handler(app_path))
.on_shortcut(hotkey, app_hotkey_handler(app_path.into()))
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn unregister_app_hotkey<R: Runtime>(
tauri_app_handle: AppHandle<R>,
app_path: String,
pub fn unregister_app_hotkey<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
let app_hotkey_store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let Some(hotkey) = app_hotkey_store.get(app_path.as_str()) else {
let error_msg = format!(
let Some(hotkey) = app_hotkey_store.get(app_path) else {
warn!(
"unregister an Application hotkey that does not exist app: [{}]",
app_path,
);
warn!("{}", error_msg);
return Err(error_msg);
return Ok(());
};
let hotkey = match hotkey {
@@ -692,11 +693,18 @@ pub async fn unregister_app_hotkey<R: Runtime>(
_ => unreachable!("hotkey should be stored in a string"),
};
let deleted = app_hotkey_store.delete(app_path.as_str());
let deleted = app_hotkey_store.delete(app_path);
if !deleted {
return Err("failed to delete application hotkey from store".into());
}
if !tauri_app_handle
.global_shortcut()
.is_registered(hotkey.as_str())
{
panic!("inconsistent state, tauri store a hotkey is stored in the tauri store but it is not registered");
}
tauri_app_handle
.global_shortcut()
.unregister(hotkey.as_str())
@@ -705,7 +713,7 @@ pub async fn unregister_app_hotkey<R: Runtime>(
Ok(())
}
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<String> {
fn get_disabled_app_list<R: Runtime>(tauri_app_handle: &AppHandle<R>) -> Vec<String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| {
@@ -732,10 +740,19 @@ fn get_disabled_app_list<R: Runtime>(tauri_app_handle: AppHandle<R>) -> Vec<Stri
disabled_app_list
}
#[tauri::command]
pub async fn disable_app_search<R: Runtime>(
tauri_app_handle: AppHandle<R>,
app_path: String,
pub fn is_app_search_enabled(app_path: &str) -> bool {
let tauri_app_handle = GLOBAL_TAURI_APP_HANDLE
.get()
.expect("global tauri app handle not set");
let disabled_app_list = get_disabled_app_list(tauri_app_handle);
disabled_app_list.iter().all(|path| path != app_path)
}
pub fn disable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
@@ -748,24 +765,26 @@ pub async fn disable_app_search<R: Runtime>(
let mut disabled_app_list = get_disabled_app_list(tauri_app_handle);
if disabled_app_list.contains(&app_path) {
if disabled_app_list
.iter()
.any(|disabled_app| disabled_app == app_path)
{
return Err(format!(
"trying to disable an app that is disabled [{}]",
app_path
));
}
disabled_app_list.push(app_path);
disabled_app_list.push(app_path.into());
store.set(TAURI_STORE_KEY_DISABLED_APP_LIST, disabled_app_list);
Ok(())
}
#[tauri::command]
pub async fn enable_app_search<R: Runtime>(
tauri_app_handle: AppHandle<R>,
app_path: String,
pub fn enable_app_search<R: Runtime>(
tauri_app_handle: &AppHandle<R>,
app_path: &str,
) -> Result<(), String> {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
@@ -879,7 +898,7 @@ pub async fn get_app_search_path<R: Runtime>(tauri_app_handle: AppHandle<R>) ->
#[tauri::command]
pub async fn get_app_list<R: Runtime>(
tauri_app_handle: AppHandle<R>,
) -> Result<Vec<AppEntry>, String> {
) -> Result<Vec<Extension>, String> {
let search_paths = get_app_search_path(tauri_app_handle.clone()).await;
let apps = list_app_in(search_paths)?;
@@ -910,14 +929,12 @@ pub async fn get_app_list<R: Runtime>(
let store = tauri_app_handle
.store(TAURI_STORE_APP_HOTKEY)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
let opt_string = store.get(&path).map(|json| match json {
store.get(&path).map(|json| match json {
Json::String(s) => s,
_ => unreachable!("app hotkey should be stored in a string"),
});
opt_string.unwrap_or(String::new())
})
};
let is_disabled = {
let enabled = {
let store = tauri_app_handle
.store(TAURI_STORE_DISABLED_APP_LIST_AND_SEARCH_PATH)
.unwrap_or_else(|_| panic!("store [{}] not found/loaded", TAURI_STORE_APP_HOTKEY));
@@ -942,16 +959,26 @@ pub async fn get_app_list<R: Runtime>(
_ => unreachable!("disabled app list should be stored in an array"),
};
disabled_app_list.contains(&path)
!disabled_app_list.contains(&path)
};
let app_entry = AppEntry {
path,
name,
icon_path,
alias,
let app_entry = Extension {
id: path,
title: name,
platforms: None,
// Leave it empty as it won't be used
description: String::new(),
icon: icon_path,
r#type: ExtensionType::Application,
action: None,
quick_link: None,
commands: None,
scripts: None,
quick_links: None,
alias: Some(alias),
hotkey,
is_disabled,
enabled,
settings: None,
};
app_entries.push(app_entry);

View File

@@ -1,11 +1,11 @@
use super::super::Extension;
use super::AppMetadata;
use crate::common::error::SearchError;
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
use crate::common::traits::SearchSource;
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use async_trait::async_trait;
use tauri::{AppHandle, Runtime};
use super::AppEntry;
use super::AppMetadata;
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
@@ -39,46 +39,45 @@ impl SearchSource for ApplicationSearchSource {
}
}
#[tauri::command]
pub async fn set_app_alias(_app_path: String, _alias: String) -> Result<(), String> {
pub fn set_app_alias<R: Runtime>(_tauri_app_handle: &AppHandle<R>, _app_path: &str, _alias: &str) {
unreachable!("app list should be empty, there is no way this can be invoked")
}
#[tauri::command]
pub async fn register_app_hotkey<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
_hotkey: String,
pub fn register_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
_hotkey: &str,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
#[tauri::command]
pub async fn unregister_app_hotkey<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
pub fn unregister_app_hotkey<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
unreachable!("app list should be empty, there is no way this can be invoked")
}
#[tauri::command]
pub async fn disable_app_search<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
pub fn disable_app_search<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
// no-op
Ok(())
}
#[tauri::command]
pub async fn enable_app_search<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
_app_path: String,
pub fn enable_app_search<R: Runtime>(
_tauri_app_handle: &AppHandle<R>,
_app_path: &str,
) -> Result<(), String> {
// no-op
Ok(())
}
pub fn is_app_search_enabled(_app_path: &str) -> bool {
false
}
#[tauri::command]
pub async fn add_app_search_path<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
@@ -103,11 +102,10 @@ pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) ->
Vec::new()
}
#[tauri::command]
pub async fn get_app_list<R: Runtime>(
_tauri_app_handle: AppHandle<R>,
) -> Result<Vec<AppEntry>, String> {
) -> Result<Vec<Extension>, String> {
// Return an empty list
Ok(Vec::new())
}

View File

@@ -1,4 +1,4 @@
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use crate::common::{
document::{DataSourceReference, Document},
error::SearchError,
@@ -146,7 +146,7 @@ impl SearchSource for CalculatorSource {
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
name: Some(DATA_SOURCE_ID.into()),
id: Some(DATA_SOURCE_ID.into()),
icon: None,
icon: Some(String::from("font_Calculator")),
}),
..Default::default()
};

View File

@@ -0,0 +1 @@

View 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)
}

View 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);
});
}

View File

@@ -0,0 +1 @@
pub(super) const EXTENSION_ID: &str = "QuickAIAccess";

View 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(&current_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(())
}

View 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);
}
}

View File

@@ -1,7 +1,7 @@
mod assistant;
mod autostart;
mod common;
mod local;
mod extension;
mod search;
mod server;
mod settings;
@@ -142,25 +142,24 @@ pub fn run() {
server::attachment::get_attachment,
server::attachment::delete_attachment,
server::transcription::transcription,
util::open,
server::system_settings::get_system_settings,
simulate_mouse_click,
local::get_disabled_local_query_sources,
local::enable_local_query_source,
local::disable_local_query_source,
local::application::get_app_list,
local::application::get_app_search_path,
local::application::get_app_metadata,
local::application::set_app_alias,
local::application::register_app_hotkey,
local::application::unregister_app_hotkey,
local::application::disable_app_search,
local::application::enable_app_search,
local::application::add_app_search_path,
local::application::remove_app_search_path,
extension::built_in::application::get_app_list,
extension::built_in::application::get_app_search_path,
extension::built_in::application::get_app_metadata,
extension::built_in::application::add_app_search_path,
extension::built_in::application::remove_app_search_path,
extension::list_extensions,
extension::enable_extension,
extension::disable_extension,
extension::set_extension_alias,
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
assistant::ask_ai
assistant::ask_ai,
crate::common::document::open,
])
.setup(|app| {
let app_handle = app.handle().clone();
@@ -262,7 +261,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
.await;
}
local::start_pizza_engine_runtime();
extension::built_in::pizza_engine_runtime::start_pizza_engine_runtime();
}
#[tauri::command]
@@ -418,7 +417,11 @@ fn open_settings(app: &tauri::AppHandle) {
#[tauri::command]
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
local::init_local_search_source(&app_handle).await?;
let (_found_invalid_extensions, extensions) = extension::list_extensions()
.await
.map_err(|e| e.to_string())?;
extension::init_extensions(extensions).await?;
let _ = server::connector::refresh_all_connectors(&app_handle).await;
let _ = server::datasource::refresh_all_datasources(&app_handle).await;

View File

@@ -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));
}

View File

@@ -3,7 +3,6 @@ use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
};
use crate::local;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use std::cmp::Reverse;
@@ -20,7 +19,10 @@ pub async fn query_coco_fusion<R: Runtime>(
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
let query_keyword = query_strings.get("query").unwrap_or(&"".to_string()).clone();
let query_keyword = query_strings
.get("query")
.unwrap_or(&"".to_string())
.clone();
let query_source_to_search = query_strings.get("querysource");
@@ -28,7 +30,6 @@ pub async fn query_coco_fusion<R: Runtime>(
let sources_future = search_sources.get_sources();
let mut futures = FuturesUnordered::new();
let mut sources = HashMap::new();
let sources_list = sources_future.await;
@@ -52,8 +53,6 @@ pub async fn query_coco_fusion<R: Runtime>(
}
}
sources.insert(query_source_type.id.clone(), query_source_type);
let query = SearchQuery::new(from, size, query_strings.clone());
let query_source_clone = query_source.clone(); // Clone Arc to avoid ownership issues
@@ -159,7 +158,7 @@ pub async fn query_coco_fusion<R: Runtime>(
let mut unique_sources = HashSet::new();
for hit in &final_hits {
if let Some(source) = &hit.source {
if source.id != local::calculator::DATA_SOURCE_ID {
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
unique_sources.insert(&source.id);
}
}
@@ -175,7 +174,6 @@ pub async fn query_coco_fusion<R: Runtime>(
}
if need_rerank && final_hits.len() > 1 {
// Precollect (index, title)
let titles_to_score: Vec<(usize, &str)> = final_hits
.iter()
@@ -184,7 +182,7 @@ pub async fn query_coco_fusion<R: Runtime>(
let source = hit.source.as_ref()?;
let title = hit.document.title.as_deref()?;
if source.id != local::calculator::DATA_SOURCE_ID {
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
Some((idx, title))
} else {
None
@@ -203,7 +201,8 @@ pub async fn query_coco_fusion<R: Runtime>(
for (idx, score) in scored_hits.into_iter().take(size as usize) {
final_hits[idx].score = score;
}
} else if final_hits.len() < size as usize { // If we still need more hits, take the highest-scoring remaining ones
} else if final_hits.len() < size as usize {
// If we still need more hits, take the highest-scoring remaining ones
let remaining_needed = size as usize - final_hits.len();

View File

@@ -1,4 +1,4 @@
use crate::common::document::Document;
use crate::common::document::{Document, OnOpened};
use crate::common::error::SearchError;
use crate::common::http::get_response_body_text;
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
@@ -103,11 +103,7 @@ impl SearchSource for CocoSearchSource {
query_args.insert(key, JsonValue::String(value));
}
let response = HttpClient::get(
&self.server.id,
&url,
Some(query_args),
)
let response = HttpClient::get(&self.server.id, &url, Some(query_args))
.await
.map_err(|e| SearchError::HttpError(format!("{}", e)))?;
@@ -116,7 +112,6 @@ impl SearchSource for CocoSearchSource {
.await
.map_err(|e| SearchError::ParseError(e))?;
// Check if the response body is empty
if !response_body.is_empty() {
// Parse the search response from the body text
@@ -125,14 +120,21 @@ impl SearchSource for CocoSearchSource {
// Process the parsed response
total_hits = parsed.hits.total.value as usize;
hits = parsed
.hits
.hits
.into_iter()
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
.collect();
}
for hit in parsed.hits.hits {
let mut document = hit._source;
// Default _score to 0.0 if None
let score = hit._score.unwrap_or(0.0);
let on_opened = document
.url
.as_ref()
.map(|url| OnOpened::Document { url: url.clone() });
// Set the `on_opened` field as it won't be returned from Coco server
document.on_opened = on_opened;
hits.push((document, score));
}
}
// Return the final result
Ok(QueryResponse {

View File

@@ -67,7 +67,6 @@ fn get_linux_desktop_environment() -> Option<LinuxDesktopEnvironment> {
//
// tauri_plugin_shell::open() is deprecated, but we still use it.
#[allow(deprecated)]
#[tauri::command]
pub async fn open<R: Runtime>(app_handle: AppHandle<R>, path: String) -> Result<(), String> {
if cfg!(target_os = "linux") {
let borrowed_path = Path::new(&path);

View File

@@ -42,6 +42,8 @@
"url": "/ui/settings",
"width": 1000,
"height": 700,
"minHeight": 700,
"minWidth": 1000,
"center": true,
"transparent": true,
"maximizable": false,
@@ -105,7 +107,7 @@
}
}
},
"resources": ["assets", "icons"]
"resources": ["assets/**/*", "icons"]
},
"plugins": {
"features": {

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { ChevronDownIcon, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isNil } from "lodash-es";
@@ -16,6 +16,7 @@ import PopoverInput from "@/components/Common/PopoverInput";
import { AssistantFetcher } from "./AssistantFetcher";
import AssistantItem from "./AssistantItem";
import Pagination from "@/components/Common/Pagination";
import { useSearchStore } from "@/stores/searchStore";
interface AssistantListProps {
assistantIDs?: string[];
@@ -37,6 +38,11 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const debounceKeyword = useDebounce(keyword, { wait: 500 });
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId;
});
const assistantList = useConnectStore((state) => state.assistantList);
const { fetchAssistant } = AssistantFetcher({
debounceKeyword,
@@ -62,6 +68,19 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
useEffect(() => {
if (!askAiAssistantId || assistantList.length === 0) return;
const matched = assistantList.find((item) => {
return item._id === askAiAssistantId;
});
if (!matched) return;
setCurrentAssistant(matched);
setAskAiAssistantId(void 0);
}, [assistantList, askAiAssistantId]);
useKeyPress(
["uparrow", "downarrow", "enter"],
(event, key) => {

View File

@@ -1,5 +1,6 @@
import { COPY_BUTTON_ID } from "@/constants";
import { useSearchStore } from "@/stores/searchStore";
import clsx from "clsx";
import {
Check,
Copy,
@@ -14,6 +15,8 @@ interface MessageActionsProps {
id: string;
content: string;
question?: string;
actionClassName?: string;
actionIconSize?: number;
onResend?: () => void;
}
@@ -23,6 +26,8 @@ export const MessageActions = ({
id,
content,
question,
actionClassName,
actionIconSize,
onResend,
}: MessageActionsProps) => {
const [copied, setCopied] = useState(false);
@@ -89,7 +94,7 @@ export const MessageActions = ({
const goAskAi = useSearchStore((state) => state.goAskAi);
return (
<div className="flex items-center gap-1 mt-2">
<div className={clsx("flex items-center gap-1 mt-2", actionClassName)}>
{!isRefreshOnly && (
<button
id={goAskAi ? COPY_BUTTON_ID : ""}
@@ -97,9 +102,21 @@ export const MessageActions = ({
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
>
{copied ? (
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" />
<Check
className="w-4 h-4 text-[#38C200] dark:text-[#38C200]"
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
) : (
<Copy className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]" />
<Copy
className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]"
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
)}
</button>
)}
@@ -116,6 +133,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}
@@ -132,6 +153,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}
@@ -146,6 +171,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}
@@ -162,6 +191,10 @@ export const MessageActions = ({
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
style={{
width: actionIconSize,
height: actionIconSize,
}}
/>
</button>
)}

View File

@@ -30,6 +30,9 @@ interface ChatMessageProps {
onResend?: (value: string) => void;
loadingStep?: Record<string, boolean>;
hide_assistant?: boolean;
rootClassName?: string;
actionClassName?: string;
actionIconSize?: number;
}
export const ChatMessage = memo(function ChatMessage({
@@ -45,6 +48,9 @@ export const ChatMessage = memo(function ChatMessage({
onResend,
loadingStep,
hide_assistant = false,
rootClassName,
actionClassName,
actionIconSize,
}: ChatMessageProps) {
const { t } = useTranslation();
@@ -144,6 +150,8 @@ export const ChatMessage = memo(function ChatMessage({
id={message._id}
content={messageContent || response?.message_chunk || ""}
question={question}
actionClassName={actionClassName}
actionIconSize={actionIconSize}
onResend={() => {
onResend && onResend(question);
}}
@@ -166,7 +174,8 @@ export const ChatMessage = memo(function ChatMessage({
[isAssistant ? "justify-start" : "justify-end"],
{
hidden: visibleStartPage,
}
},
rootClassName
)}
>
<div

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from "react";
import { noop } from "lodash-es";
import { ChatMessage } from "../ChatMessage";
import { ASK_AI_CLIENT_ID, COPY_BUTTON_ID } from "@/constants";
import { COPY_BUTTON_ID } from "@/constants";
import { useSearchStore } from "@/stores/searchStore";
import platformAdapter from "@/utils/platformAdapter";
import useMessageChunkData from "@/hooks/useMessageChunkData";
@@ -75,6 +75,9 @@ const AskAi = () => {
return state.setAskAiServerId;
});
const state = useReactive<State>({});
const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId;
});
useEffect(() => {
if (state.serverId) return;
@@ -97,12 +100,10 @@ const AskAi = () => {
useMount(async () => {
try {
unlisten.current = await platformAdapter.listenEvent(
ASK_AI_CLIENT_ID,
"quick-ai-access-client-id",
({ payload }) => {
console.log("ask_ai", JSON.parse(payload));
setIsTyping(true);
const chunkData = JSON.parse(payload);
if (chunkData?._id) {
@@ -115,6 +116,13 @@ const AskAi = () => {
return;
}
// If the chunk data does not contain a message_chunk, we ignore it
if (!chunkData.message_chunk) {
return;
}
setIsTyping(true);
setLoadingStep(() => ({
query_intent: false,
tools: false,
@@ -164,15 +172,12 @@ const AskAi = () => {
const { serverId, assistantId } = state;
console.log("serverId", serverId);
console.log("assistantId", assistantId);
try {
await platformAdapter.invokeBackend("ask_ai", {
message: askAiMessage,
serverId,
assistantId,
clientId: ASK_AI_CLIENT_ID,
clientId: "quick-ai-access-client-id",
});
} catch (error) {
addError(String(error));
@@ -184,7 +189,7 @@ const AskAi = () => {
if (isTyping) return;
const { serverId } = state;
const { serverId, assistantId } = state;
if ((isMac && metaKey) || (!isMac && ctrlKey)) {
await platformAdapter.commands("open_session_chat", {
@@ -195,7 +200,8 @@ const AskAi = () => {
platformAdapter.emitEvent("toggle-to-chat-mode");
setAskAiServerId(serverId);
return setAskAiSessionId(sessionIdRef.current);
setAskAiSessionId(sessionIdRef.current);
return setAskAiAssistantId(assistantId);
}
const copyButton = document.getElementById(COPY_BUTTON_ID);

View File

@@ -38,16 +38,16 @@ export function useAssistantManager({
const [assistantDetail, setAssistantDetail] = useState<any>({});
const assistant_get = useCallback(async () => {
if (!askAI?.id) return;
if (isTauri) {
if (!askAI?.querySource?.id) return;
const res = await platformAdapter.commands("assistant_get", {
serverId: askAI?.querySource?.id,
assistantId: askAI?.id,
});
setAssistantDetail(res);
} else {
const [error, res]: any = await Get(`/assistant/${askAI?.id}`, {
id: askAI?.id,
});
const [error, res]: any = await Get(`/assistant/${askAI?.id}`);
if (error) {
console.error("assistant", error);
return;
@@ -57,6 +57,8 @@ export function useAssistantManager({
}, [askAI]);
const handleAskAi = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!isTauri) return;
askAIRef.current = cloneDeep(askAI);
if (!askAIRef.current) return;
@@ -67,7 +69,6 @@ export function useAssistantManager({
if (!selectedAssistant && isEmpty(value)) return;
assistant_get();
changeInput("");
setAskAiMessage(!goAskAi && selectedAssistant ? "" : value);
setGoAskAi(true);
@@ -84,7 +85,9 @@ export function useAssistantManager({
return setGoAskAi(false);
}
if (key === "Tab" && !isChatMode) {
if (key === "Tab" && !isChatMode && isTauri) {
assistant_get();
return handleAskAi(e);
}

View File

@@ -1,4 +1,4 @@
import { useBoolean } from "ahooks";
import { useBoolean, useDebounceFn } from "ahooks";
import {
useRef,
useImperativeHandle,
@@ -8,6 +8,10 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
const LINE_HEIGHT = 24; // 1.5rem
const MAX_FIRST_LINE_WIDTH = 470; // Width in pixels for first line
const MAX_HEIGHT = 240; // 15rem
interface AutoResizeTextareaProps {
input: string;
setInput: (value: string) => void;
@@ -37,6 +41,77 @@ const AutoResizeTextarea = forwardRef<
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposition, { setTrue, setFalse }] = useBoolean();
// Memoize resize logic
const { run: debouncedResize } = useDebounceFn(
() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to auto to get the correct scrollHeight
textarea.style.height = "auto";
// Create a hidden span to measure first line width
const span = document.createElement("span");
span.style.visibility = "hidden";
span.style.position = "absolute";
span.style.whiteSpace = "pre";
span.style.font = window.getComputedStyle(textarea).font;
// Get first line content
const content = textarea.value;
const firstLineEnd =
content.indexOf("\n") === -1 ? content.length : content.indexOf("\n");
span.textContent = content.slice(0, firstLineEnd);
document.body.appendChild(span);
// Calculate lines based on first line width
const firstLineWidth = span.offsetWidth;
document.body.removeChild(span);
// Start with 1 line
let lines = 1;
// Add a line if first line exceeds max width
if (firstLineWidth > MAX_FIRST_LINE_WIDTH) {
lines += 1;
}
// Add lines based on scrollHeight for remaining content
const scrollHeight = textarea.scrollHeight;
const remainingLines = Math.floor(
(scrollHeight - LINE_HEIGHT) / LINE_HEIGHT
);
lines += Math.max(0, remainingLines);
// Calculate final height
const newHeight = Math.min(lines * LINE_HEIGHT, MAX_HEIGHT);
// Only update if height actually changed
if (textarea.style.height !== `${newHeight}px`) {
textarea.style.height = `${newHeight}px`;
onLineCountChange?.(lines);
}
},
{ wait: 100 }
);
// Handle input changes and initial setup
useEffect(() => {
if (textareaRef.current) {
debouncedResize();
}
}, [input, debouncedResize]);
useEffect(() => {
if (textareaRef.current) {
requestAnimationFrame(() => {
// Set cursor position to end
const length = textareaRef.current?.value.length || 0;
textareaRef.current?.setSelectionRange(length, length);
});
}
}, [lineCount]);
// Expose methods to the parent via ref
useImperativeHandle(ref, () => ({
reset: () => {
@@ -47,13 +122,6 @@ const AutoResizeTextarea = forwardRef<
},
}));
useEffect(() => {
if (textareaRef.current) {
const length = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(length, length);
}
}, [lineCount]);
const handleKeyPress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (isComposition) {
return event.stopPropagation();
@@ -62,18 +130,6 @@ const AutoResizeTextarea = forwardRef<
handleKeyDown?.(event);
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
const newHeight = Math.min(textareaRef.current.scrollHeight, 15 * 16); // 15rem ≈ 15 * 16px
textareaRef.current.style.height = `${newHeight}px`;
const lineHeight = 24; // 1.5rem = 24px
const lineCount = Math.ceil(newHeight / lineHeight);
onLineCountChange?.(lineCount);
}
}, [input]);
return (
<textarea
ref={textareaRef}
@@ -82,9 +138,7 @@ const AutoResizeTextarea = forwardRef<
autoCapitalize="none"
spellCheck="false"
className="text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar"
placeholder={
chatPlaceholder || t("search.textarea.placeholder")
}
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
aria-label={t("search.textarea.ariaLabel")}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -96,7 +150,8 @@ const AutoResizeTextarea = forwardRef<
rows={1}
style={{
resize: "none", // Prevent manual resize
overflow: "auto", // Enable scrollbars when needed
overflow: "auto",
minHeight: "1.5rem",
maxHeight: "13.5rem", // Limit height to 9 rows (9 * 1.5 line-height)
lineHeight: "1.5rem", // Line height to match row height
}}

View File

@@ -2,17 +2,18 @@ import { useClickAway, useCreation, useReactive } from "ahooks";
import clsx from "clsx";
import { isNil, lowerCase, noop } from "lodash-es";
import { Copy, Link, SquareArrowOutUpRight } from "lucide-react";
import { cloneElement, useEffect, useRef, useState } from "react";
import { cloneElement, FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore";
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
import { copyToClipboard } from "@/utils";
import { isMac } from "@/utils/platform";
import { CONTEXT_MENU_PANEL_ID } from "@/constants";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { Input } from "@headlessui/react";
import VisibleKey from "../Common/VisibleKey";
import platformAdapter from "@/utils/platformAdapter";
interface State {
activeMenuIndex: number;
@@ -22,7 +23,7 @@ interface ContextMenuProps {
hideCoco?: () => void;
}
const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
const ContextMenu: FC<ContextMenuProps> = () => {
const containerRef = useRef<HTMLDivElement>(null);
const { t, i18n } = useTranslation();
const state = useReactive<State>({
@@ -52,9 +53,15 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
const menus = useCreation(() => {
if (isNil(selectedSearchContent)) return [];
const { url, category, payload } = selectedSearchContent;
const { url, category, payload, on_opened } = selectedSearchContent;
const { query, result } = payload ?? {};
if (category === "AI Overview") {
setSearchMenus([]);
return [];
}
const menus = [
{
name: t("search.contextMenu.open"),
@@ -63,9 +70,9 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
shortcut: "enter",
hide: category === "Calculator",
clickEvent: () => {
OpenURLWithBrowser(url);
hideCoco && hideCoco();
if (on_opened) {
platformAdapter.invokeBackend("open", { onOpened: on_opened });
}
},
},
{
@@ -182,6 +189,7 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
};
return (
searchMenus.length > 0 && (
<>
{visibleContextMenu && (
<div
@@ -280,6 +288,7 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
</div>
</div>
</>
)
);
};

View File

@@ -7,7 +7,6 @@ import { SearchHeader } from "./SearchHeader";
import noDataImg from "@/assets/coconut-tree.png";
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
import SearchListItem from "./SearchListItem";
import { OpenURLWithBrowser } from "@/utils/index";
import platformAdapter from "@/utils/platformAdapter";
import { Get } from "@/api/axiosRequest";
import { useAppStore } from "@/stores/appStore";
@@ -170,7 +169,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const handleEnter = () => {
if (selectedItem === null) return;
const item = data.list[selectedItem]?.document;
item?.url && OpenURLWithBrowser(item.url);
if (item?.on_opened) {
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
}
};
switch (e.key) {
@@ -233,9 +234,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
isSelected={selectedItem === index}
currentIndex={index}
onMouseEnter={() => onMouseEnter(index, hit.document)}
onItemClick={() =>
hit.document?.url && OpenURLWithBrowser(hit.document.url)
onItemClick={() => {
if (hit.document?.on_opened) {
platformAdapter.invokeBackend("open", {
onOpened: hit.document.on_opened,
});
}
}}
showListRight={viewMode === "list"}
/>
))}

View File

@@ -10,12 +10,12 @@ import { useDebounceFn, useUnmount } from "ahooks";
import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore";
import { OpenURLWithBrowser } from "@/utils/index";
import ErrorSearch from "@/components/Common/ErrorNotification/ErrorSearch";
import type { QueryHits, SearchDocument, FailedRequest } from "@/types/search";
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
import { SearchSource } from "./SearchSource";
import DropdownListItem from "./DropdownListItem";
import platformAdapter from "@/utils/platformAdapter";
type ISearchData = Record<string, QueryHits[]>;
@@ -33,7 +33,7 @@ function DropdownList({
searchData,
isError,
isChatMode,
globalItemIndexMap
globalItemIndexMap,
}: DropdownListProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
@@ -43,7 +43,6 @@ function DropdownList({
const [selectedName, setSelectedName] = useState<string>("");
const [showIndex, setShowIndex] = useState<boolean>(false);
const {
setSourceData,
setSelectedSearchContent,
@@ -57,7 +56,14 @@ function DropdownList({
);
const handleItemAction = useCallback((item: SearchDocument) => {
if (!item || item.category === "Calculator") return;
if (
!item ||
item.category === "Calculator" ||
item.category === "AI Overview"
) {
return;
}
setSourceData(item);
}, []);
@@ -69,17 +75,19 @@ function DropdownList({
const memoizedCallbacks = useMemo(() => {
return {
onMouseEnter: (index: number, item: SearchDocument) => () => {
console.log("onMouseEnter", index);
onMouseEnter: (index: number, item: SearchDocument) => {
setVisibleContextMenu(false);
setSelectedIndex(index);
setSelectedSearchContent(item);
},
onItemClick: (item: SearchDocument) => () => {
if (item?.url) {
OpenURLWithBrowser(item.url);
onItemClick: (item: SearchDocument) => {
if (item?.on_opened) {
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
}
},
goToTwoPage: (item: SearchDocument) => () => setSourceData(item),
goToTwoPage: (item: SearchDocument) => {
setSourceData(item);
},
};
}, []);
@@ -94,7 +102,7 @@ function DropdownList({
return;
}
const item = globalItemIndexMap[selectedIndex]
const item = globalItemIndexMap[selectedIndex];
setSelectedSearchContent(item);
if (item?.source?.id === "assistant") {
setSelectedAssistant({
@@ -161,7 +169,7 @@ function DropdownList({
{Object.entries(searchData).map(([sourceName, items]) => (
<div key={sourceName}>
{showSource && (
{showSource && items[0].document.category !== "AI Overview" && (
<SearchSource
sourceName={sourceName}
items={items}

View File

@@ -4,6 +4,7 @@ import clsx from "clsx";
import Calculator from "./Calculator";
import SearchListItem from "./SearchListItem";
import type { SearchDocument } from "@/types/search";
import AiOverview from "./AiOverview";
interface DropdownListItemProps {
item: SearchDocument;
@@ -11,7 +12,7 @@ interface DropdownListItemProps {
currentIndex: number;
showIndex: boolean;
memoizedCallbacks: {
onMouseEnter: (index: number, item: SearchDocument) => () => void;
onMouseEnter: (index: number, item: SearchDocument) => void;
onItemClick: (item: SearchDocument) => void;
goToTwoPage: (item: SearchDocument) => void;
};
@@ -30,14 +31,17 @@ const DropdownListItem = memo(
onContextMenu,
}: DropdownListItemProps) => {
const isCalculator = item.category === "Calculator";
const isAiOverview = item.category === "AI Overview";
const isSelected = selectedIndex === currentIndex;
return (
<div onContextMenu={onContextMenu}>
{isCalculator ? (
{isCalculator || isAiOverview ? (
<div
ref={(el) => (itemRefs.current[currentIndex] = el)}
onMouseEnter={memoizedCallbacks.onMouseEnter(currentIndex, item)}
onMouseEnter={() => {
memoizedCallbacks.onMouseEnter(currentIndex, item);
}}
role="option"
aria-selected={isSelected}
id={`search-item-${currentIndex}`}
@@ -45,7 +49,9 @@ const DropdownListItem = memo(
"bg-[#EDEDED] dark:bg-[#202126]": isSelected,
})}
>
<Calculator item={item} isSelected={isSelected} />
{isCalculator && <Calculator item={item} isSelected={isSelected} />}
{isAiOverview && <AiOverview message={item?.payload?.message} />}
</div>
) : (
<SearchListItem
@@ -53,9 +59,15 @@ const DropdownListItem = memo(
isSelected={isSelected}
currentIndex={currentIndex}
showIndex={showIndex}
onMouseEnter={memoizedCallbacks.onMouseEnter(currentIndex, item)}
onItemClick={() => memoizedCallbacks.onItemClick(item)}
goToTwoPage={() => memoizedCallbacks.goToTwoPage(item)}
onMouseEnter={() => {
memoizedCallbacks.onMouseEnter(currentIndex, item);
}}
onItemClick={() => {
memoizedCallbacks.onItemClick(item);
}}
goToTwoPage={() => {
memoizedCallbacks.goToTwoPage(item);
}}
itemRef={(el) => (itemRefs.current[currentIndex] = el)}
/>
)}

View File

@@ -16,6 +16,7 @@ import ChatIcons from "./ChatIcons";
import { useKeyboardHandlers } from "@/hooks/useKeyboardHandlers";
import { useAssistantManager } from "./AssistantManager";
import InputControls from "./InputControls";
import { useExtensionsStore } from "@/stores/extensionsStore";
interface ChatInputProps {
onSend: (message: string) => void;
@@ -79,6 +80,7 @@ export default function ChatInput({
const showTooltip = useAppStore((state) => state.showTooltip);
const setBlurred = useAppStore((state) => state.setBlurred);
const isTauri = useAppStore((state) => state.isTauri);
const { sourceData, goAskAi } = useSearchStore();
@@ -154,12 +156,8 @@ export default function ChatInput({
};
}, [isChatMode]);
const {
askAI,
askAIRef,
assistantDetail,
handleKeyDownAutoResizeTextarea,
} = useAssistantManager({
const { askAI, askAIRef, assistantDetail, handleKeyDownAutoResizeTextarea } =
useAssistantManager({
isChatMode,
handleSubmit,
changeInput,
@@ -181,6 +179,10 @@ export default function ChatInput({
};
}, [currentAssistant]);
const disabledExtensions = useExtensionsStore((state) => {
return state.disabledExtensions;
});
const renderSearchIcon = () => (
<SearchIcons
lineCount={lineCount}
@@ -225,7 +227,11 @@ export default function ChatInput({
</div>
)} */}
{!isChatMode && !goAskAi && askAI && (
{!isChatMode &&
isTauri &&
!goAskAi &&
askAI &&
!disabledExtensions.includes("QuickAIAccess") && (
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
<span>
{t("search.askCocoAi.title", {

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Brain } from "lucide-react";
import { Brain, Sparkles } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@@ -14,6 +14,8 @@ import { useConnectStore } from "@/stores/connectStore";
import VisibleKey from "@/components/Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
// import InputExtra from "./InputExtra";
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
@@ -205,6 +207,22 @@ const InputControls = ({
[assistantConfig]
);
const enabledAiOverview = useSearchStore((state) => {
return state.enabledAiOverview;
});
const setEnabledAiOverview = useSearchStore((state) => {
return state.setEnabledAiOverview;
});
const disabledExtensions = useExtensionsStore((state) => {
return state.disabledExtensions;
});
const aiOverviewServer = useExtensionsStore((state) => {
return state.aiOverviewServer;
});
const aiOverviewAssistant = useExtensionsStore((state) => {
return state.aiOverviewAssistant;
});
return (
<div
data-tauri-drag-region
@@ -286,7 +304,34 @@ const InputControls = ({
</div>
) : (
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
{/* <AiSummaryIcon color={"#881c94"} /> */}
{!disabledExtensions.includes("AIOverview") &&
isTauri &&
aiOverviewServer &&
aiOverviewAssistant && (
<div
className={clsx(
"inline-flex items-center gap-1 px-2 py-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
[
enabledAiOverview
? "text-[#881c94]"
: "text-[#333] dark:text-[#d8d8d8]",
],
{
"bg-[#881C94]/20 dark:bg-[#202126]": enabledAiOverview,
}
)}
onClick={() => {
setEnabledAiOverview(!enabledAiOverview);
}}
>
<Sparkles className="size-4" />
<span
className={clsx("text-xs", { hidden: !enabledAiOverview })}
>
AI Overview
</span>
</div>
)}
</div>
)}

View File

@@ -16,7 +16,14 @@ const SearchResultsPanel = memo<{
const { sourceData, goAskAi } = useSearchStore();
const searchState = useSearch();
const { suggests, searchData, isError, isSearchComplete, globalItemIndexMap, performSearch } = searchState;
const {
suggests,
searchData,
isError,
isSearchComplete,
globalItemIndexMap,
performSearch,
} = searchState;
useEffect(() => {
if (!isChatMode && input) {

View File

@@ -23,8 +23,8 @@ export default function SearchIcons({
const renderContent = () => {
if (goAskAi && assistant) {
return (
<div className="flex h-8 -my-1">
<div className="flex items-center gap-2 pl-2 text-sm bg-white dark:bg-black">
<div className="flex h-8 -my-1 -mx-1">
<div className="flex items-center gap-2 pl-2 text-sm bg-white dark:bg-black rounded-l-sm">
<div className="flex items-center gap-1 text-[#333] dark:text-[#D8D8D8]">
{assistant.icon?.startsWith("font_") ? (
<FontIcon name={assistant.icon} className="size-5" />

View File

@@ -25,7 +25,9 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
onGoToTwoPage,
}) => {
const isDark = useThemeStore((state) => state.isDark);
const hideArrow = items[0]?.document.category === "Calculator";
const hideArrow =
items[0]?.document.category === "Calculator" ||
items[0]?.document.category === "AI Overview";
return (
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
@@ -36,7 +38,7 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
defaultIcon={isDark ? source_default_dark_img : source_default_img}
className="w-4 h-4"
/>
{sourceName} - {items[0]?.source?.name}
{sourceName} {items[0]?.source?.name && `- ${items[0].source.name}`}
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
{!hideArrow && (
<>

View File

@@ -1,84 +1,124 @@
import {
cloneElement,
FC,
Fragment,
MouseEvent,
useContext,
useState,
} from "react";
import { ExtensionsContext, Plugin } from "../..";
import { useMount } from "ahooks";
import { FC, MouseEvent, useContext } from "react";
import { Extension, ExtensionId, ExtensionsContext } from "../..";
import { useReactive } from "ahooks";
import { ChevronRight, LoaderCircle } from "lucide-react";
import clsx from "clsx";
import { isArray, isFunction } from "lodash-es";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { isArray, startCase, sortBy } from "lodash-es";
import platformAdapter from "@/utils/platformAdapter";
import Shortcut from "../Shortcut";
import FontIcon from "@/components/Common/Icons/FontIcon";
import SettingsInput from "@/components/Settings/SettingsInput";
import { useTranslation } from "react-i18next";
import Shortcut from "../Shortcut";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { platform } from "@/utils/platform";
import { useExtensionsStore } from "@/stores/extensionsStore";
const Content = () => {
const { plugins } = useContext(ExtensionsContext);
const { rootState } = useContext(ExtensionsContext);
return plugins.map((item) => {
return <Item key={item.id} {...item} level={1} />;
return rootState.extensions.map((item) => {
const { id } = item;
return <Item key={id} {...item} level={1} extensionId={id} />;
});
};
const Item: FC<Plugin & { level: number }> = (props) => {
const {
id,
icon,
name,
children,
type = "Extension",
manualLoad,
level = 1,
} = props;
const { activeId, setActiveId, setPlugins } = useContext(ExtensionsContext);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const { t } = useTranslation();
const hasChildren = isArray(children);
const handleLoadChildren = async () => {
setLoading(true);
await props.loadChildren?.();
setLoading(false);
};
useMount(async () => {
if (!manualLoad) {
handleLoadChildren();
interface ItemProps extends Extension {
level: number;
extensionId: ExtensionId;
}
interface ItemState {
loading: boolean;
expanded: boolean;
subExtensions?: Extension[];
}
const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
Applications: "get_app_list",
};
const Item: FC<ItemProps> = (props) => {
const { id, icon, title, type, level, extensionId, platforms } = props;
const { rootState } = useContext(ExtensionsContext);
const state = useReactive<ItemState>({
loading: false,
expanded: false,
});
const { t } = useTranslation();
const disabledExtensions = useExtensionsStore((state) => {
return state.disabledExtensions;
});
const setDisabledExtensions = useExtensionsStore((state) => {
return state.setDisabledExtensions;
});
const hasSubExtensions = () => {
const { commands, scripts, quick_links } = props;
if (subExtensionCommand[id]) {
return true;
}
if (isArray(commands) || isArray(scripts) || isArray(quick_links)) {
return true;
}
return false;
};
const getSubExtensions = async () => {
state.loading = true;
const { commands, scripts, quick_links } = props;
let subExtensions: Extension[] = [];
const command = subExtensionCommand[id];
if (command) {
subExtensions = await platformAdapter.invokeBackend<Extension[]>(command);
} else {
subExtensions = [commands, scripts, quick_links].filter(isArray).flat();
}
state.loading = false;
return sortBy(subExtensions, ["title"]);
};
const handleExpand = async (event: MouseEvent) => {
event?.stopPropagation();
if (expanded) {
setExpanded(false);
if (state.expanded) {
state.expanded = false;
} else {
if (manualLoad) {
await handleLoadChildren();
}
state.subExtensions = await getSubExtensions();
setExpanded(true);
state.expanded = true;
}
};
const editable = () => {
return (
type !== "group" &&
type !== "calculator" &&
type !== "extension" &&
type !== "ai_extension"
);
};
const renderAlias = () => {
const { alias, onAliasChange } = props;
const { alias } = props;
const handleChange = (value: string) => {
if (isFunction(onAliasChange)) {
return onAliasChange(value);
}
platformAdapter.invokeBackend("set_extension_alias", {
extensionId,
alias: value,
});
};
if (isFunction(onAliasChange)) {
if (editable()) {
return (
<div
className="-translate-x-2"
@@ -102,15 +142,22 @@ const Item: FC<Plugin & { level: number }> = (props) => {
};
const renderHotkey = () => {
const { hotkey, onHotkeyChange } = props;
const { hotkey } = props;
const handleChange = (value: string) => {
if (isFunction(onHotkeyChange)) {
return onHotkeyChange(value);
if (value) {
platformAdapter.invokeBackend("register_extension_hotkey", {
extensionId,
hotkey: value,
});
} else {
platformAdapter.invokeBackend("unregister_extension_hotkey", {
extensionId,
});
}
};
if (isFunction(onHotkeyChange)) {
if (editable()) {
return (
<div
className="-translate-x-2"
@@ -131,39 +178,36 @@ const Item: FC<Plugin & { level: number }> = (props) => {
};
const renderSwitch = () => {
const { enabled = true, onEnabledChange } = props;
const { enabled } = props;
const handleChange = (value: boolean) => {
if (isFunction(onEnabledChange)) {
return onEnabledChange(value);
if (value) {
setDisabledExtensions(
disabledExtensions.filter((item) => item !== extensionId)
);
platformAdapter.invokeBackend("enable_extension", {
extensionId,
});
} else {
setDisabledExtensions([...disabledExtensions, extensionId]);
platformAdapter.invokeBackend("disable_extension", {
extensionId,
});
}
const command = `${value ? "enable" : "disable"}_local_query_source`;
platformAdapter.invokeBackend(command, {
querySourceId: id,
});
setPlugins((prevPlugins) => {
return prevPlugins.map((item) => {
if (item.id === id) {
return { ...item, enabled: value };
}
return item;
});
});
};
return (
<div
className="flex items-center justify-end"
onClick={(event) => {
event.stopPropagation();
}}
>
<SettingsToggle
label={id}
checked={Boolean(enabled)}
defaultChecked={enabled}
className="scale-75"
onChange={handleChange}
/>
@@ -171,17 +215,35 @@ const Item: FC<Plugin & { level: number }> = (props) => {
);
};
const renderType = () => {
if (type === "ai_extension") {
return "AI Extension";
}
return startCase(type);
};
const renderContent = () => {
if (isArray(platforms)) {
const currentPlatform = platform();
if (currentPlatform && !platforms.includes(currentPlatform)) {
return;
}
}
return (
<Fragment key={id}>
<>
<div
className={clsx("-mx-2 px-2 text-sm rounded-md", {
"bg-[#f0f6fe] dark:bg-gray-700": id === activeId,
"bg-[#f0f6fe] dark:bg-gray-700":
id === rootState.activeExtension?.id,
})}
>
<div
className="flex items-center justify-between gap-2 h-8"
onClick={() => {
setActiveId(id);
rootState.activeExtension = props;
}}
>
<div
@@ -189,15 +251,15 @@ const Item: FC<Plugin & { level: number }> = (props) => {
style={{ paddingLeft: (level - 1) * 20 }}
>
<div className="min-w-4 h-4">
{hasChildren && (
{hasSubExtensions() && (
<>
{loading ? (
{state.loading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<ChevronRight
onClick={handleExpand}
className={clsx("size-4 transition cursor-pointer", {
"rotate-90": expanded,
"rotate-90": state.expanded,
})}
/>
)}
@@ -205,37 +267,46 @@ const Item: FC<Plugin & { level: number }> = (props) => {
)}
</div>
{cloneElement(icon, {
className: clsx("size-4", icon.props.className),
})}
<div className="truncate">{name}</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="w-3/5 flex items-center text-[#999]">
<div className="flex-1">{type}</div>
<div className="truncate">{title}</div>
</div>
<div className="w-4/6 flex items-center text-[#999]">
<div className="flex-1">{renderType()}</div>
<div className="flex-1">{renderAlias()}</div>
<div className="flex-1">{renderHotkey()}</div>
<div className="flex-1 flex items-center justify-end">
{renderSwitch()}
</div>
<div className="w-16">{renderSwitch()}</div>
</div>
</div>
</div>
{hasChildren && (
<div
className={clsx({
hidden: !expanded,
})}
>
{children.map((item) => {
return <Item key={item.id} {...item} level={level + 1} />;
<div className={clsx({ hidden: !state.expanded })}>
{state.subExtensions?.map((item) => {
return (
<Item
key={item.id}
{...item}
level={level + 1}
extensionId={`${id}.${item.id}`}
/>
);
})}
</div>
)}
</Fragment>
</>
);
};
return renderContent();
};
export default Content;

View File

@@ -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;

View File

@@ -4,7 +4,7 @@ import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { ExtensionsContext, Plugin, type ExtensionsContextType } from "../../../index";
import { ExtensionsContext } from "../../../index";
interface Metadata {
name: string;
@@ -17,46 +17,25 @@ interface Metadata {
const App = () => {
const { t } = useTranslation();
const { activeId, plugins } = useContext(ExtensionsContext) as ExtensionsContextType;
const { rootState } = useContext(ExtensionsContext);
const [appMetadata, setAppMetadata] = useState<Metadata>();
const findPlugin = (plugins: Plugin[], id: string) => {
for (const plugin of plugins) {
const { children = [] } = plugin;
if (plugin.id === id) {
return plugin;
}
if (children.length > 0) {
const matched = findPlugin(children, id) as Plugin;
if (!matched) continue;
return matched;
}
}
};
const currentPlugin = useMemo(() => {
if (!activeId) return;
return findPlugin(plugins, activeId);
}, [activeId, plugins]);
useAsyncEffect(async () => {
if (!activeId || !currentPlugin) return;
if (!rootState.activeExtension) return;
const { id, title } = rootState.activeExtension;
const appMetadata = await platformAdapter.invokeBackend<Metadata>(
"get_app_metadata",
{
appName: currentPlugin.name,
appPath: activeId
appPath: id,
appName: title,
}
);
setAppMetadata(appMetadata);
}, [activeId, currentPlugin]);
}, [rootState.activeExtension?.id]);
const metadata = useMemo(() => {
if (!appMetadata) return [];

View File

@@ -99,7 +99,7 @@ const Applications = () => {
<div className="flex items-center gap-1 flex-1 overflow-hidden">
<Folder className="size-4" />
<span className="truncate">{item}</span>
<span className="flex-1 truncate">{item}</span>
</div>
<div className="flex items-center gap-1">

View File

@@ -2,26 +2,24 @@ import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import FontIcon from "@/components/Common/Icons/FontIcon";
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
import { useAppStore } from "@/stores/appStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import platformAdapter from "@/utils/platformAdapter";
import { useAsyncEffect, useMount } from "ahooks";
import { useEffect, useMemo, useState } from "react";
import { FC, useMemo, useState } from "react";
import { ExtensionId } from "../../..";
const QuickAiAccess = () => {
const quickAiAccessServer = useExtensionsStore((state) => {
return state.quickAiAccessServer;
});
const setQuickAiAccessServer = useExtensionsStore((state) => {
return state.setQuickAiAccessServer;
});
const quickAiAccessAssistant = useExtensionsStore((state) => {
return state.quickAiAccessAssistant;
});
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
return state.setQuickAiAccessAssistant;
});
const [serverList, setServerList] = useState<any[]>([]);
const [assistantList, setAssistantList] = useState<any[]>([]);
interface SharedAiProps {
id: ExtensionId;
server?: any;
setServer: (server: any) => void;
assistant?: any;
setAssistant: (assistant: any) => void;
}
const SharedAi: FC<SharedAiProps> = (props) => {
const { id, server, setServer, assistant, setAssistant } = props;
const [serverList, setServerList] = useState<any[]>([server]);
const [assistantList, setAssistantList] = useState<any[]>([assistant]);
const addError = useAppStore((state) => state.addError);
const { fetchAssistant } = AssistantFetcher({});
@@ -33,9 +31,9 @@ const QuickAiAccess = () => {
setServerList(data);
if (quickAiAccessServer) return;
if (server) return;
setQuickAiAccessServer(data[0]);
setServer(data[0]);
} catch (error) {
addError(String(error));
}
@@ -43,77 +41,74 @@ const QuickAiAccess = () => {
useAsyncEffect(async () => {
try {
if (!quickAiAccessServer) return;
if (!server) return;
const data = await fetchAssistant({
current: 1,
pageSize: 1000,
serverId: quickAiAccessServer.id,
serverId: server.id,
});
const list = data.list.map((item: any) => item._source);
setAssistantList(list);
if (quickAiAccessAssistant) {
if (assistant) {
const matched = list.find((item: any) => {
return item.id === quickAiAccessAssistant.id;
return item.id === assistant.id;
});
if (matched) {
return setQuickAiAccessAssistant(matched);
return setAssistant(matched);
}
}
setQuickAiAccessAssistant(list[0]);
setAssistant(list[0]);
} catch (error) {
addError(String(error));
}
}, [quickAiAccessServer]);
useEffect(() => {
const unsubscribe = useExtensionsStore.subscribe((state) => {
platformAdapter.emitEvent("change-extensions-store", state);
});
return () => {
unsubscribe();
};
});
}, [server]);
const selectList = useMemo(() => {
return [
{
label: "Coco Server",
value: quickAiAccessServer?.id,
icon: quickAiAccessServer?.provider?.icon,
value: server?.id,
icon: server?.provider?.icon,
data: serverList,
onChange: (value: string) => {
const matched = serverList.find((item) => item.id === value);
setQuickAiAccessServer(matched);
setServer(matched);
},
},
{
label: "AI Assistant",
value: quickAiAccessAssistant?.id,
icon: quickAiAccessAssistant?.icon,
value: assistant?.id,
icon: assistant?.icon,
data: assistantList,
onChange: (value: string) => {
const matched = assistantList.find((item) => item.id === value);
setQuickAiAccessAssistant(matched);
setAssistant(matched);
},
},
];
}, [serverList, assistantList, quickAiAccessServer, quickAiAccessAssistant]);
}, [serverList, assistantList, server, assistant]);
const renderDescription = () => {
if (id === "QuickAIAccess") {
return "Quick AI access allows you to start a conversation immediately from the search box using the tab key.";
}
if (id === "AIOverview") {
return "AI Summarize generates concise summaries based on your search results, helping you quickly grasp key information without reading every document.";
}
};
return (
<div className="text-sm">
<div className="text-[#999]">
Quick AI access allows you to start a conversation immediately from the
search box using the tab key.
</div>
<div className="text-[#999]">{renderDescription()}</div>
<div className="mt-6 text-[#333] dark:text-white/90">LinkedAssistant</div>
@@ -147,4 +142,4 @@ const QuickAiAccess = () => {
);
};
export default QuickAiAccess;
export default SharedAi;

View File

@@ -1,40 +1,65 @@
import { useContext, useMemo } from "react";
import { ExtensionsContext, Plugin } from "../..";
import { useContext } from "react";
import { ExtensionsContext } from "../..";
import Applications from "./Applications";
import Application from "./Application";
import { useExtensionsStore } from "@/stores/extensionsStore";
import SharedAi from "./SharedAi";
import AiOverview from "./AiOverview";
const Details = () => {
const { plugins, activeId } = useContext(ExtensionsContext);
const { rootState } = useContext(ExtensionsContext);
const quickAiAccessServer = useExtensionsStore((state) => {
return state.quickAiAccessServer;
});
const setQuickAiAccessServer = useExtensionsStore((state) => {
return state.setQuickAiAccessServer;
});
const quickAiAccessAssistant = useExtensionsStore((state) => {
return state.quickAiAccessAssistant;
});
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
return state.setQuickAiAccessAssistant;
});
const findPlugin = (plugins: Plugin[], id: string) => {
for (const plugin of plugins) {
const { children = [] } = plugin;
const renderContent = () => {
if (!rootState.activeExtension) return;
if (plugin.id === id) {
return plugin;
const { id, type } = rootState.activeExtension;
if (id === "Applications") {
return <Applications />;
}
if (children.length > 0) {
const matched = findPlugin(children, id) as Plugin;
if (!matched) continue;
return matched;
if (type === "application") {
return <Application />;
}
if (id === "QuickAIAccess") {
return (
<SharedAi
key="QuickAIAccess"
id="QuickAIAccess"
server={quickAiAccessServer}
setServer={setQuickAiAccessServer}
assistant={quickAiAccessAssistant}
setAssistant={setQuickAiAccessAssistant}
/>
);
}
if (id === "AIOverview") {
return <AiOverview />;
}
};
const currentPlugin = useMemo(() => {
if (!activeId) return;
return findPlugin(plugins, activeId);
}, [activeId, plugins]);
return (
<div className="flex-1 h-full overflow-auto">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{currentPlugin?.name}
{rootState.activeExtension?.title}
</h2>
<div className="pr-4">{currentPlugin?.detail}</div>
<div className="pr-4 pb-4">{renderContent()}</div>
</div>
);
};

View File

@@ -1,210 +1,107 @@
import {
createContext,
Dispatch,
ReactElement,
ReactNode,
SetStateAction,
useEffect,
useMemo,
useState,
} from "react";
import { Bot, Calculator, Folder } from "lucide-react";
import { noop } from "lodash-es";
import { useMount } from "ahooks";
import { createContext, useEffect } from "react";
import { useMount, useReactive } from "ahooks";
import { useTranslation } from "react-i18next";
import type { LiteralUnion } from "type-fest";
import ApplicationsDetail from "./components/Details/Applications";
import Application from "./components/Details/Application";
import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
import QuickAiAccess from "./components/Details/QuickAiAccess";
import { cloneDeep, sortBy } from "lodash-es";
import { useExtensionsStore } from "@/stores/extensionsStore";
export interface IApplication {
path: string;
name: string;
iconPath: string;
alias: string;
hotkey: string;
isDisabled: boolean;
export type ExtensionId = LiteralUnion<
"Applications" | "Calculator" | "QuickAIAccess" | "AIOverview",
string
>;
type ExtensionType =
| "group"
| "extension"
| "application"
| "script"
| "quick_link"
| "setting"
| "calculator"
| "command"
| "ai_extension";
export type ExtensionPlatform = "windows" | "macos" | "linux";
interface ExtensionAction {
exec: string;
args: string[];
}
export interface Plugin {
id: string;
icon: ReactElement;
name: ReactNode;
type?: "Group" | "Extension" | "Application";
interface ExtensionQuickLink {
link: string;
}
export interface Extension {
id: ExtensionId;
type: ExtensionType;
icon: string;
title: string;
description: string;
alias?: string;
hotkey?: string;
enabled?: boolean;
detail?: ReactNode;
children?: Plugin[];
manualLoad?: boolean;
loadChildren?: () => Promise<void>;
onAliasChange?: (alias: string) => void;
onHotkeyChange?: (hotkey: string) => void;
onEnabledChange?: (enabled: boolean) => void;
enabled: boolean;
platforms?: ExtensionPlatform[];
action: ExtensionAction;
quick_link: ExtensionQuickLink;
commands?: Extension[];
scripts?: Extension[];
quick_links?: Extension[];
settings: Record<string, unknown>;
}
export interface ExtensionsContextType {
plugins: Plugin[];
setPlugins: Dispatch<SetStateAction<Plugin[]>>;
activeId?: string;
setActiveId: (id: string) => void;
interface State {
extensions: Extension[];
activeExtension?: Extension;
}
export const ExtensionsContext = createContext<ExtensionsContextType>({
plugins: [],
setPlugins: noop,
setActiveId: noop,
});
const Extensions = () => {
const { t } = useTranslation();
const [apps, setApps] = useState<IApplication[]>([]);
const [disabled, setDisabled] = useState<string[]>([]);
const [activeId, setActiveId] = useState<string>();
useMount(async () => {
const disabled = await platformAdapter.invokeBackend<string[]>(
"get_disabled_local_query_sources"
);
setDisabled(disabled);
});
const loadApps = async () => {
const apps = await platformAdapter.invokeBackend<IApplication[]>(
"get_app_list"
);
const sortedApps = apps.sort((a, b) => {
return a.name.localeCompare(b.name, undefined, {
sensitivity: "base",
});
});
setApps(sortedApps);
const INITIAL_STATE: State = {
extensions: [],
};
const presetPlugins = useMemo<Plugin[]>(() => {
const plugins: Plugin[] = [
{
id: "Applications",
icon: <Folder />,
name: t("settings.extensions.application.title"),
type: "Group",
detail: <ApplicationsDetail />,
children: [],
manualLoad: true,
loadChildren: loadApps,
},
{
id: "Calculator",
icon: <Calculator />,
name: t("settings.extensions.calculator.title"),
},
{
id: "QuickAiAccess",
icon: <Bot />,
name: "Quick AI Access",
detail: <QuickAiAccess />,
},
];
if (apps.length > 0) {
for (const app of apps) {
const { path, iconPath, isDisabled } = app;
plugins[0].children?.push({
...app,
id: path,
type: "Application",
icon: (
<img
src={platformAdapter.convertFileSrc(iconPath)}
className="size-5"
/>
),
enabled: !isDisabled,
detail: <Application />,
onAliasChange(alias) {
platformAdapter.invokeBackend("set_app_alias", {
appPath: path,
alias,
export const ExtensionsContext = createContext<{ rootState: State }>({
rootState: INITIAL_STATE,
});
const nextApps = apps.map((item) => {
if (item.path !== path) return item;
return { ...item, alias };
export const Extensions = () => {
const { t } = useTranslation();
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
const setDisabledExtensions = useExtensionsStore((state) => {
return state.setDisabledExtensions;
});
setApps(nextApps);
},
onHotkeyChange(hotkey) {
const command = `${hotkey ? "register" : "unregister"}_app_hotkey`;
useMount(async () => {
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
"list_extensions"
);
platformAdapter.invokeBackend(command, {
appPath: path,
hotkey,
const extensions = result[1];
const disabledExtensions = extensions.filter((item) => !item.enabled);
setDisabledExtensions(disabledExtensions.map((item) => item.id));
state.extensions = sortBy(extensions, ["title"]);
});
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(() => {
setPlugins(presetPlugins);
}, [presetPlugins]);
useEffect(() => {
setPlugins((prevPlugins) => {
return prevPlugins.map((item) => {
if (disabled.includes(item.id)) {
return { ...item, enabled: false };
}
return item;
const unsubscribe = useExtensionsStore.subscribe((state) => {
platformAdapter.emitEvent("change-extensions-store", state);
});
return () => {
unsubscribe();
};
});
}, [disabled]);
return (
<ExtensionsContext.Provider
value={{
plugins,
setPlugins,
activeId,
setActiveId,
rootState: state,
}}
>
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4">
@@ -217,7 +114,7 @@ const Extensions = () => {
<div className="flex">
<div className="flex-1">{t("settings.extensions.list.name")}</div>
<div className="w-3/5 flex">
<div className="w-4/6 flex">
<div className="flex-1">
{t("settings.extensions.list.type")}
</div>
@@ -227,7 +124,7 @@ const Extensions = () => {
<div className="flex-1">
{t("settings.extensions.list.hotkey")}
</div>
<div className="flex-1 text-right">
<div className="w-16 text-right whitespace-nowrap">
{t("settings.extensions.list.enabled")}
</div>
</div>

View File

@@ -1,6 +1,6 @@
// import { Select, SelectProps } from "@headlessui/react";
import { Select, SelectProps } from "@headlessui/react";
import clsx from "clsx";
import { isArray } from "lodash-es";
import { ChevronDownIcon } from "lucide-react";
import { FC } from "react";
@@ -24,11 +24,11 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
} = props;
const renderOptions = () => {
if (data) {
if (isArray(data)) {
return data.map((item) => {
return (
<option key={item[valueField]} value={item[valueField]}>
{item[labelField]}
<option key={item?.[valueField]} value={item?.[valueField]}>
{item?.[labelField]}
</option>
);
});

View File

@@ -1,35 +1,27 @@
import { Switch } from "@headlessui/react";
import { Switch, SwitchProps } from "@headlessui/react";
import clsx from "clsx";
interface SettingsToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
interface SettingsToggleProps extends SwitchProps {
label: string;
className?: string;
}
export default function SettingsToggle({
checked,
onChange,
label,
className,
}: SettingsToggleProps) {
export default function SettingsToggle(props: SettingsToggleProps) {
const { label, className, ...rest } = props;
return (
<Switch
checked={checked}
onChange={onChange}
{...rest}
className={clsx(
`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`,
[checked ? "bg-blue-600" : "bg-gray-200 dark:bg-gray-700"],
`group relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 bg-gray-200 dark:bg-gray-700 data-[checked]:bg-blue-600`,
className
)}
>
<span className="sr-only">{label}</span>
<span
className={`${checked ? "translate-x-5" : "translate-x-0"}
pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow
ring-0 transition duration-200 ease-in-out`}
className="pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow
ring-0 transition duration-200 ease-in-out translate-x-0 group-data-[checked]:translate-x-5"
/>
</Switch>
);

View File

@@ -4,6 +4,4 @@ export const HISTORY_PANEL_ID = "headlessui-popover-panel:history-panel";
export const CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
export const ASK_AI_CLIENT_ID = "ask-ai-client";
export const COPY_BUTTON_ID = "copy-button";

View File

@@ -2,8 +2,9 @@ import { useCallback, useEffect } from 'react';
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { isMetaOrCtrlKey, metaOrCtrlKey } from '@/utils/keyboardUtils';
import { copyToClipboard, OpenURLWithBrowser } from "@/utils/index";
import { copyToClipboard } from "@/utils/index";
import type { QueryHits, SearchDocument } from "@/types/search";
import platformAdapter from "@/utils/platformAdapter";
interface UseKeyboardNavigationProps {
suggests: QueryHits[];
@@ -67,12 +68,11 @@ export function useKeyboardNavigation({
if (
e.key === "Enter" &&
!e.shiftKey &&
selectedIndex !== null &&
isMetaOrCtrlKey(e)
selectedIndex !== null
) {
const item = globalItemIndexMap[selectedIndex];
if (item?.url) {
OpenURLWithBrowser(item?.url);
if (item?.on_opened) {
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
} else {
copyToClipboard(item?.payload?.result?.value);
}
@@ -85,8 +85,8 @@ export function useKeyboardNavigation({
const item = globalItemIndexMap[index];
if (item?.url) {
OpenURLWithBrowser(item?.url);
if (item?.on_opened) {
platformAdapter.invokeBackend("open", { onOpened: item.on_opened });
}
}
},

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
const useScript = (src: string, onError?: () => void) => {
useEffect(() => {
@@ -6,7 +6,7 @@ const useScript = (src: string, onError?: () => void) => {
return; // Prevent duplicate script loading
}
const script = document.createElement('script');
const script = document.createElement("script");
script.src = src;
script.async = true;
@@ -25,24 +25,27 @@ const useScript = (src: string, onError?: () => void) => {
export default useScript;
export const useIconfontScript = () => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
const [useLocalFallback, setUseLocalFallback] = useState(false);
let baseURL = appStore.state?.endpoint_http
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
baseURL = "";
}
if (useLocalFallback || baseURL === "") {
useScript('/assets/fonts/icons/iconfont.js');
useScript("/assets/fonts/icons/iconfont.js");
return;
}
useScript(`${baseURL}/assets/fonts/icons/iconfont.js`, () => {
console.log("Remote iconfont loading failed, falling back to local resource");
console.log(
"Remote iconfont loading failed, falling back to local resource"
);
setUseLocalFallback(true);
});
useScript("/assets/fonts/icons/extension.js");
};

View File

@@ -1,11 +1,18 @@
import { useState, useCallback, useMemo } from 'react';
import { debounce } from 'lodash-es';
import { useState, useCallback, useMemo, useRef } from "react";
import { debounce } from "lodash-es";
import type { QueryHits, MultiSourceQueryResponse, FailedRequest, SearchDocument } from '@/types/search';
import type {
QueryHits,
MultiSourceQueryResponse,
FailedRequest,
SearchDocument,
} from "@/types/search";
import platformAdapter from "@/utils/platformAdapter";
import { Get } from "@/api/axiosRequest";
import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
interface SearchState {
isError: FailedRequest[];
@@ -21,6 +28,25 @@ interface SearchDataBySource {
export function useSearch() {
const isTauri = useAppStore((state) => state.isTauri);
const enabledAiOverview = useSearchStore((state) => {
return state.enabledAiOverview;
});
const aiOverviewServer = useExtensionsStore((state) => {
return state.aiOverviewServer;
});
const aiOverviewAssistant = useExtensionsStore((state) => {
return state.aiOverviewAssistant;
});
const timerRef = useRef<NodeJS.Timeout | null>(null);
const disabledExtensions = useExtensionsStore((state) => {
return state.disabledExtensions;
});
const aiOverviewCharLen = useExtensionsStore((state) => {
return state.aiOverviewCharLen;
});
const aiOverviewDelay = useExtensionsStore((state) => {
return state.aiOverviewDelay;
});
const { querySourceTimeout } = useConnectStore();
@@ -29,13 +55,17 @@ export function useSearch() {
suggests: [],
searchData: {},
isSearchComplete: false,
globalItemIndexMap: {}
globalItemIndexMap: {},
});
const handleSearchResponse = (response: MultiSourceQueryResponse) => {
const handleSearchResponse = (
response: MultiSourceQueryResponse,
searchInput: string
) => {
const data = response?.hits || [];
const searchData = data.reduce((acc: SearchDataBySource, item: QueryHits) => {
const searchData = data.reduce(
(acc: SearchDataBySource, item: QueryHits) => {
const name = item?.document?.source?.name;
if (name) {
if (!acc[name]) {
@@ -44,7 +74,9 @@ export function useSearch() {
acc[name].push(item);
}
return acc;
}, {});
},
{}
);
// Update indices and map
//console.log("_search response", data, searchData);
@@ -54,10 +86,65 @@ export function useSearch() {
searchData[sourceName].map((item: QueryHits) => {
item.document.querySource = item?.source;
const index = globalIndex++;
item.document.index = index
item.document.index = index;
globalItemIndexMap[index] = item.document;
return item;
})
});
}
const filteredData = data.filter((item: any) => {
return (
item?.document?.type !== "AI Assistant" &&
item?.document?.category !== "Calculator" &&
item?.document?.category !== "Application"
);
});
console.log("aiOverviewCharLen", aiOverviewCharLen);
console.log("aiOverviewDelay", aiOverviewDelay);
if (
searchInput.length >= aiOverviewCharLen &&
isTauri &&
enabledAiOverview &&
aiOverviewServer &&
aiOverviewAssistant &&
filteredData.length > 5 &&
!disabledExtensions.includes("AIOverview")
) {
timerRef.current = setTimeout(() => {
const id = "AI Overview";
const payload = {
source: {
id,
type: id,
},
document: {
index: 1000000,
id,
category: id,
payload: {
message: JSON.stringify({
query: searchInput,
result: filteredData,
}),
},
source: {
icon: "font_a-AIOverview",
},
},
};
setSearchState((prev) => ({
...prev,
suggests: prev.suggests.concat(payload as any),
searchData: {
[id]: [payload as any],
...prev.searchData,
},
}));
}, aiOverviewDelay * 1000);
}
setSearchState({
@@ -69,9 +156,10 @@ export function useSearch() {
});
};
const performSearch = useCallback(async (searchInput: string) => {
const performSearch = useCallback(
async (searchInput: string) => {
if (!searchInput) {
setSearchState(prev => ({ ...prev, suggests: [] }));
setSearchState((prev) => ({ ...prev, suggests: [] }));
return;
}
@@ -84,7 +172,9 @@ export function useSearch() {
queryTimeout: querySourceTimeout,
});
} else {
const [error, res]: any = await Get(`/query/_search?query=${searchInput}`);
const [error, res]: any = await Get(
`/query/_search?query=${searchInput}`
);
if (error) {
console.error("_search", error);
response = { failed: [], hits: [], total_hits: 0 };
@@ -108,8 +198,23 @@ export function useSearch() {
console.log("_suggest", searchInput, response);
handleSearchResponse(response);
}, [querySourceTimeout, isTauri]);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
handleSearchResponse(response, searchInput);
},
[
querySourceTimeout,
isTauri,
enabledAiOverview,
aiOverviewServer,
aiOverviewAssistant,
disabledExtensions,
aiOverviewCharLen,
aiOverviewDelay,
]
);
const debouncedSearch = useMemo(
() => debounce(performSearch, 300),
@@ -118,6 +223,6 @@ export function useSearch() {
return {
...searchState,
performSearch: debouncedSearch
performSearch: debouncedSearch,
};
}

135
src/hooks/useStreamChat.ts Normal file
View 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,
};
};

View File

@@ -93,6 +93,21 @@ export const useSyncStore = () => {
const setQuickAiAccessAssistant = useExtensionsStore((state) => {
return state.setQuickAiAccessAssistant;
});
const setAiOverviewServer = useExtensionsStore((state) => {
return state.setAiOverviewServer;
});
const setAiOverviewAssistant = useExtensionsStore((state) => {
return state.setAiOverviewAssistant;
});
const setDisabledExtensions = useExtensionsStore((state) => {
return state.setDisabledExtensions;
});
const setAiOverviewCharLen = useExtensionsStore((state) => {
return state.setAiOverviewCharLen;
});
const setAiOverviewDelay = useExtensionsStore((state) => {
return state.setAiOverviewDelay;
});
useEffect(() => {
if (!resetFixedWindow) {
@@ -174,10 +189,23 @@ export const useSyncStore = () => {
}),
platformAdapter.listenEvent("change-extensions-store", ({ payload }) => {
const { quickAiAccessServer, quickAiAccessAssistant } = payload;
const {
quickAiAccessServer,
quickAiAccessAssistant,
aiOverviewServer,
aiOverviewAssistant,
disabledExtensions,
aiOverviewCharLen,
aiOverviewDelay,
} = payload;
setQuickAiAccessServer(quickAiAccessServer);
setQuickAiAccessAssistant(quickAiAccessAssistant);
setAiOverviewServer(aiOverviewServer);
setAiOverviewAssistant(aiOverviewAssistant);
setDisabledExtensions(disabledExtensions);
setAiOverviewCharLen(aiOverviewCharLen);
setAiOverviewDelay(aiOverviewDelay);
}),
]);

View File

@@ -250,4 +250,14 @@
-ms-user-select: text;
user-select: text;
}
.hide-scrollbar {
overflow: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
}

View File

@@ -14,6 +14,8 @@ import { AppTheme } from "@/types/index";
import ErrorNotification from "@/components/Common/ErrorNotification";
import { useModifierKeyPress } from "@/hooks/useModifierKeyPress";
import { useIconfontScript } from "@/hooks/useScript";
import { Extension } from "@/components/Settings/Extensions";
import { useExtensionsStore } from "@/stores/extensionsStore";
export default function Layout() {
const location = useLocation();
@@ -119,6 +121,20 @@ export default function Layout() {
useIconfontScript();
const setDisabledExtensions = useExtensionsStore((state) => {
return state.setDisabledExtensions;
});
useMount(async () => {
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
"list_extensions"
);
const disabledExtensions = result[1].filter((item) => !item.enabled);
setDisabledExtensions(disabledExtensions.map((item) => item.id));
});
return (
<>
<Outlet />

View File

@@ -1,3 +1,4 @@
import { ExtensionId } from "@/components/Settings/Extensions";
import { create } from "zustand";
import { persist, subscribeWithSelector } from "zustand/middleware";
@@ -6,6 +7,16 @@ export type IExtensionsStore = {
setQuickAiAccessServer: (quickAiAccessServer?: any) => void;
quickAiAccessAssistant?: any;
setQuickAiAccessAssistant: (quickAiAccessAssistant?: any) => void;
aiOverviewServer?: any;
setAiOverviewServer: (aiOverviewServer?: any) => void;
aiOverviewAssistant?: any;
setAiOverviewAssistant: (aiOverviewAssistant?: any) => void;
disabledExtensions: ExtensionId[];
setDisabledExtensions: (disabledExtensions?: string[]) => void;
aiOverviewCharLen: number;
setAiOverviewCharLen: (aiOverviewCharLen: number) => void;
aiOverviewDelay: number;
setAiOverviewDelay: (aiOverviewDelay: number) => void;
};
export const useExtensionsStore = create<IExtensionsStore>()(
@@ -18,12 +29,34 @@ export const useExtensionsStore = create<IExtensionsStore>()(
setQuickAiAccessAssistant(quickAiAccessAssistant) {
return set({ quickAiAccessAssistant });
},
setAiOverviewServer(aiOverviewServer) {
return set({ aiOverviewServer });
},
setAiOverviewAssistant(aiOverviewAssistant) {
return set({ aiOverviewAssistant });
},
disabledExtensions: [],
setDisabledExtensions(disabledExtensions) {
return set({ disabledExtensions });
},
aiOverviewCharLen: 10,
setAiOverviewCharLen(aiOverviewCharLen) {
return set({ aiOverviewCharLen });
},
aiOverviewDelay: 2,
setAiOverviewDelay(aiOverviewDelay) {
return set({ aiOverviewDelay });
},
}),
{
name: "extensions-store",
partialize: (state) => ({
quickAiAccessServer: state.quickAiAccessServer,
quickAiAccessAssistant: state.quickAiAccessAssistant,
aiOverviewServer: state.aiOverviewServer,
aiOverviewAssistant: state.aiOverviewAssistant,
aiOverviewCharLen: state.aiOverviewCharLen,
aiOverviewDelay: state.aiOverviewDelay,
}),
}
)

View File

@@ -24,6 +24,10 @@ export type ISearchStore = {
setSelectedAssistant: (selectedAssistant?: any) => void;
askAiServerId?: string;
setAskAiServerId: (askAiServerId?: string) => void;
enabledAiOverview: boolean;
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
askAiAssistantId?: string;
setAskAiAssistantId: (askAiAssistantId?: string) => void;
};
export const useSearchStore = create<ISearchStore>()(
@@ -59,6 +63,13 @@ export const useSearchStore = create<ISearchStore>()(
setAskAiServerId: (askAiServerId) => {
return set({ askAiServerId });
},
enabledAiOverview: false,
setEnabledAiOverview: (enabledAiOverview) => {
return set({ enabledAiOverview });
},
setAskAiAssistantId: (askAiAssistantId) => {
return set({ askAiAssistantId });
},
}),
{
name: "search-store",

View File

@@ -1,4 +1,3 @@
import { ASK_AI_CLIENT_ID } from "@/constants";
import { IAppearanceStore } from "@/stores/appearanceStore";
import { IConnectStore } from "@/stores/connectStore";
import { IExtensionsStore } from "@/stores/extensionsStore";
@@ -38,9 +37,10 @@ export interface EventPayloads {
"change-shortcuts-store": IShortcutsStore;
"change-connect-store": IConnectStore;
"change-appearance-store": IAppearanceStore;
[ASK_AI_CLIENT_ID]: any;
"toggle-to-chat-mode": void;
"change-extensions-store": IExtensionsStore;
"quick-ai-access-client-id": any;
"ai-overview-client-id": any;
}
// Window operation interface

View File

@@ -37,6 +37,7 @@ export interface SearchDocument {
querySource?: QuerySource;
index?: number; // Index in the current search result
globalIndex?: number;
on_opened?: any;
}
export interface RichLabel {

View File

@@ -113,3 +113,7 @@ export const closeHistoryPanel = () => {
button.click();
}
};
// export const sortByFirstLetter = <T>(list: T[], key: keyof T) => {
// return list.sort((a, b) => {});
// };

View File

@@ -13,6 +13,18 @@ console.log("isMac", isMac);
console.log("isWin", isWin);
console.log("isLinux", isLinux);
export function platform() {
if (isWin) {
return "windows";
} else if (isMac) {
return "macos";
} else if (isLinux) {
return "linux";
}
return void 0;
}
export function family() {
if (isWeb) {
const ua = navigator.userAgent.toLowerCase();

View File

@@ -207,8 +207,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
},
async openExternal(url) {
const { invoke } = await import("@tauri-apps/api/core");
return invoke("open", { path: url });
const { open } = await import("@tauri-apps/plugin-shell");
open(url);
},
isWindows10,

View File

@@ -56,7 +56,7 @@ export default {
2000: "2000",
},
screens: {
'mobile': {'max': '679px'},
mobile: { max: "679px" },
},
},
},

View File

@@ -67,7 +67,7 @@ export default defineConfig({
const packageJson = {
name: "@infinilabs/search-chat",
version: "1.2.5",
version: "1.2.8",
main: "index.js",
module: "index.js",
type: "module",