53 Commits

Author SHA1 Message Date
ayang
6ab93407ed refactor: update 2025-12-19 18:21:58 +08:00
ayang
a488b0b465 refactor: update 2025-12-19 18:21:35 +08:00
ayang
5fdbc7cd31 refactor: update 2025-12-19 18:14:22 +08:00
ayang
6d00efc7e8 refactor: update 2025-12-19 17:49:18 +08:00
ayang
798bd923c6 refactor: update 2025-12-19 17:48:52 +08:00
ayang
56234526a8 refactor: update 2025-12-19 17:45:11 +08:00
Steve Lau
5fe4032209 fix: when field buckets does not exist 2025-12-19 17:39:43 +08:00
ayang
8d0d719964 refactor: update 2025-12-19 17:13:08 +08:00
Steve Lau
d056271848 fix: source ID filter 2025-12-19 17:10:47 +08:00
ayang
b11fec29dc refactor: update 2025-12-19 17:10:04 +08:00
ayang
97369963a6 refactor: update 2025-12-19 17:09:03 +08:00
Steve Lau
f3fa91a03c fix: source ID filter 2025-12-19 17:07:55 +08:00
ayang
0e6d7fa52f refactor: update 2025-12-19 17:02:14 +08:00
Steve Lau
209438a638 filter 2025-12-19 16:53:23 +08:00
ayang
4ada34ad75 refactor: update 2025-12-19 16:52:12 +08:00
ayang
52f6f73d53 refactor: update 2025-12-19 16:47:56 +08:00
ayang
aa779ec156 refactor: update 2025-12-19 16:46:00 +08:00
ayang
93da46662c refactor: update 2025-12-19 16:30:43 +08:00
Steve Lau
fbbc5f1d6a Bucket.label 2025-12-19 16:26:57 +08:00
ayang
e89cca1c2f refactor: update 2025-12-19 15:43:03 +08:00
ayang
5e103bfc3d refactor: update 2025-12-19 15:37:32 +08:00
ayang
78ec0836a1 refactor: update 2025-12-19 15:31:32 +08:00
ayang
c698b4094b refactor: update 2025-12-19 14:44:00 +08:00
ayang
2235bd1da1 refactor: update 2025-12-19 12:10:41 +08:00
ayang
8a2898b0b9 refactor: update 2025-12-19 12:08:50 +08:00
ayang
53f6b33279 refactor: update 2025-12-19 12:06:35 +08:00
rain9
b20cc771ea Merge branch 'add-search-filter' of github.com:infinilabs/coco-app into add-search-filter 2025-12-19 12:00:14 +08:00
rain9
a339dbab9c chore: up 2025-12-19 11:59:58 +08:00
Steve Lau
1bb2d8b3b3 pass request body 2025-12-19 11:50:36 +08:00
Steve Lau
1ceb385505 print response body string 2025-12-19 11:40:42 +08:00
Steve Lau
371f8d0daa pass v2=true to coco server 2025-12-19 11:38:10 +08:00
Steve Lau
28cf5ca326 update 2025-12-19 11:35:04 +08:00
Steve Lau
ed10be5c6f update 2025-12-19 11:33:29 +08:00
ayang
0bcb974837 style: remove unless file 2025-12-19 11:30:42 +08:00
Steve Lau
4c4c08a598 update 2025-12-19 11:27:30 +08:00
Steve Lau
724db0f66d update 2025-12-19 11:24:51 +08:00
Steve Lau
a8a14cae18 update 2025-12-19 11:24:51 +08:00
ayang
eea6a7a5ae refactor: update 2025-12-19 11:23:56 +08:00
ayang
fdc8967b76 refactor: update 2025-12-19 11:22:22 +08:00
ayang
5df1f9668d refactor: update 2025-12-18 18:18:11 +08:00
ayang
23607e6b4c refactor: update 2025-12-18 16:55:47 +08:00
ayang
494be3db62 refactor: update 2025-12-18 16:51:49 +08:00
Steve Lau
530502ecff debugging: print query strings 2025-12-18 16:00:31 +08:00
ayang
8d6204a9d8 refactor: update 2025-12-18 15:59:37 +08:00
ayang
ca350dfeed feat: add search filter 2025-12-18 15:54:59 +08:00
BiggerRain
3a9c9ec9eb style: img styles (#1015) 2025-12-18 15:51:00 +08:00
BiggerRain
f7c0600480 feat: add open button to launch installed extension (#1013)
* chore: up

* support query string main_extension_id

* chore: up

* fix tests

* open non-group/extension extensions

* dbg

* chore: upadate

* extension SearchSource now accepts empty querystring

* update

* chore: open

* chore: input

* remove DBG statements

* chore: icon

* style: adjust styles

* docs: update release notes

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-12-18 15:50:26 +08:00
BiggerRain
ed8a1cb477 refactor: replace legacy components with shadcn/ui components (#1002)
* chore: shadcn config

* feat: add shadcn ui config

* style: adjust styles

* style: adjust styles

* refactor: update style

* style: adjust styles

* style: adjust styles

* style: adjust styles

* style: adjust styles

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* style: adjust styles

* style: adjust styles

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* style: web styles

* refactor: update

* style: web styles

* style: web styles

* refactor: update

* refactor: update

* refactor: update

* chhore: add

* chore: add

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* chore: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* chore: rename

* refactor: update

* refactor: update

* chore: add

* refactor: update

* chore: update

* chroe: up

* refactor: update

* refactor: update

* chore: up

* refactor: update

* chore: up

* feat: support for extracting css variables

* chore: update

* fix: fixed dark mode

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* docs: update release notes

* style: adjust styles

* style: adjust styles

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

---------

Co-authored-by: ayang <473033518@qq.com>
2025-12-18 10:26:13 +08:00
SteveLauC
abf20f81ff refactor: treat Applications and File Search as normal extensions (#1012)
I was digging into an issue that the "File Search" extension does
not appear in the extension list under some cases, then I realized
that, in list_extensions(), "File Search" is another extension that
needs to be processed separately, just like the "Applications"
extension.

Both extensions are of type group/extension, which semantically should
behave like a folder, i.e., they contain sub-extensions, but they
do not. In our implementation, they are more like command extensions.

So we treat them as normal extensions in list_extensioins().
2025-12-17 15:08:01 +08:00
SteveLauC
65a48efdde fix: implement custom serialization for Extension.minimum_coco_version (#1010)
Coco's version string does not adhere to semver's spec, so we had to
write a custom deserialization impl to do the conversion:

```
0.9.1-SNPASHOT-2560 => 0.9.1-SNAPSHOT.2560
```

But we forget to serialize versions to Coco's format when writing
extensions to disk. When Coco reads extension from disk, it sees
valid semantic versions, which are not expected:

```
[WAR] [third_party:167] invalid extension: [base64-converter]:
  field [minimum_coco_version] has invalid version:
    failed to parse build number 'SNAPSHOT.2560'', caused by: ['invalid digit found in string']
```

This commit provides a custom serialization impl to fix the issue.
2025-12-16 15:29:36 +08:00
ayangweb
0613238876 refactor: hide the filter bar in quick ai access (#1008) 2025-12-15 09:25:13 +08:00
SteveLauC
501f6df473 chore: show error msg (not err code) when installing exts via deeplink/store fails (#1007)
* chore: show error msg (not err code) when installing exts via deeplink fails

When installing extensions via deeplink fails, previous implementation
showed the raw error code returned from the backend interfaces, which
is not user-friendly. We now call installExtensionError() to interrupt
the error code to get a human-readable error message, then show it to
the users.

* fix: correct install extension error when installing via store
2025-12-14 09:24:13 +08:00
ayangweb
67c8c4bdfa fix: fix the abnormal input height issue (#1006)
* fix: fix the abnormal input height issue

* docs: update changelog
2025-12-09 15:07:41 +08:00
131 changed files with 5596 additions and 2860 deletions

4
.gitignore vendored
View File

@@ -13,6 +13,8 @@ dist-ssr
*.local
out
src/components/web
SearchChatDemo/
web.md
# Editor directories and files
# .vscode/*
@@ -26,3 +28,5 @@ src/components/web
*.sln
*.sw?
.env
.trae

View File

@@ -37,15 +37,18 @@
"meval",
"Minimizable",
"msvc",
"njsproj",
"nord",
"nowrap",
"nspanel",
"nsstring",
"ntvs",
"objc",
"overscan",
"partialize",
"patchelf",
"Quicklink",
"Quicklinks",
"Raycast",
"rehype",
"reqwest",
@@ -54,6 +57,7 @@
"rustup",
"screenshotable",
"serde",
"Shadcn",
"swatinem",
"tailwindcss",
"tauri",
@@ -61,6 +65,7 @@
"timedout",
"titlebar",
"tpddns",
"trae",
"traptitech",
"unlisten",
"unlistener",

View File

@@ -7,11 +7,24 @@ title: "Release Notes"
Information about release notes of Coco App is provided here.
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
### 🐛 Bug fix
### ✈️ Improvements
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
- feat: add open button to launch installed extension #1013
### 🐛 Bug fix
- fix: fix the abnormal input height issue #1006
- fix: implement custom serialization for Extension.minimum_coco_version #1010
### ✈️ Improvements
- refactor: replace legacy components with shadcn/ui components #1002
- chore: show error msg (not err code) when installing exts via deeplink fails #1007
- refactor: treat Applications and File Search as normal extensions #1012
## 0.9.1 (2025-12-05)

View File

@@ -8,9 +8,10 @@
"build": "tsc && vite build",
"build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm",
"publish:web": "cd out/search-chat && npm publish",
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
"publish:web:beta": "cd out/search-chat && npm publish --tag beta",
"publish:web:alpha": "cd out/search-chat && npm publish --tag alpha",
"publish:web:rc": "cd out/search-chat && npm publish --tag rc",
"publish:web:otp": "cd out/search-chat && npm publish --access public --otp $NPM_OTP",
"preview": "vite preview",
"tauri": "tauri",
"release": "release-it",
@@ -18,10 +19,18 @@
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
},
"dependencies": {
"@headlessui/react": "^2.2.2",
"@infinilabs/custom-icons": "0.0.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
@@ -42,17 +51,19 @@
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"filesize": "^10.1.6",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.461.0",
"lucide-react": "^0.561.0",
"mdast-util-gfm-autolink-literal": "2.0.0",
"mermaid": "^11.6.0",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-day-picker": "^9.13.0",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.5.1",
@@ -77,6 +88,8 @@
"zustand": "^5.0.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.5.0",
"@types/dom-speech-recognition": "^0.0.4",
"@types/lodash-es": "^4.17.12",
@@ -93,11 +106,11 @@
"postcss": "^8.5.3",
"release-it": "^18.1.2",
"sass": "^1.87.0",
"tailwindcss": "^3.4.17",
"tailwindcss": "^4.0.0",
"tsup": "^8.4.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vite": "^5.4.19"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}
}

1602
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
export default {
plugins: {
tailwindcss: {},
// Tailwind v4 PostCSS plugin has moved to @tailwindcss/postcss
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

39
scripts/buildWebAfter.ts Normal file
View File

@@ -0,0 +1,39 @@
import { readFileSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const extractCssVars = () => {
const filePath = join(__dirname, "../out/search-chat/index.css");
const cssContent = readFileSync(filePath, "utf-8");
const vars: Record<string, string> = {};
const propertyBlockRegex = /@property\s+(--[\w-]+)\s*\{([\s\S]*?)\}/g;
let match: RegExpExecArray | null;
while ((match = propertyBlockRegex.exec(cssContent))) {
const [, varName, body] = match;
const initialValueMatch = /initial-value\s*:\s*([^;]+);/.exec(body);
if (initialValueMatch) {
vars[varName] = initialValueMatch[1].trim();
}
}
const cssVarsBlock =
`.coco-container {\n` +
Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join("\n") +
`\n}\n`;
writeFileSync(filePath, `${cssContent}\n${cssVarsBlock}`, "utf-8");
};
extractCssVars();

View File

@@ -1,6 +1,7 @@
use crate::common::document::Document;
use crate::common::http::get_response_body_text;
use reqwest::Response;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::error::Error;
@@ -11,6 +12,7 @@ pub struct SearchResponse<T> {
pub timed_out: Option<bool>,
pub _shards: Option<Shards>,
pub hits: Hits<T>,
pub aggregations: Option<Aggregations>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -122,11 +124,162 @@ pub struct FailedRequest {
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Aggregation {
// Frontend code needs to this to not be NULL, so we `clean_aggregations()`
// in query_coco_fusion() to ensure this.
pub buckets: Option<Vec<AggBucket>>,
}
/// A bucket's fields contain more than just "doc_count" and "key", but we only
/// need them. Serde can deserialize this as we don't `deny_unknown_fields`.
#[derive(Debug, Serialize, Clone)]
pub struct AggBucket {
doc_count: usize,
key: String,
/// Optional human label extracted from `top.hits.hits[0]._source.source.name`.
label: Option<String>,
}
impl<'de> Deserialize<'de> for AggBucket {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Wrapper {
doc_count: usize,
key: String,
#[serde(default)]
top: Option<Top>,
}
#[derive(Deserialize)]
struct Top {
hits: TopHits,
}
#[derive(Deserialize)]
struct TopHits {
hits: Vec<TopHit>,
}
#[derive(Deserialize)]
struct TopHit {
#[serde(default)]
_source: Option<TopSource>,
}
#[derive(Deserialize)]
struct TopSource {
#[serde(default)]
source: Option<SourceLabel>,
}
#[derive(Deserialize)]
struct SourceLabel {
#[serde(default)]
name: Option<String>,
}
let wrapper = Wrapper::deserialize(deserializer)?;
let label = wrapper
.top
.and_then(|top| top.hits.hits.into_iter().next())
.and_then(|hit| hit._source)
.and_then(|src| src.source)
.and_then(|lbl| lbl.name);
Ok(AggBucket {
doc_count: wrapper.doc_count,
key: wrapper.key,
label,
})
}
}
/// Coco server aggregation result.
///
/// ```json
/// {
/// "type": {
/// "buckets": [
/// {
/// "doc_count": 26,
/// "key": "web_page"
/// },
/// {
/// "doc_count": 1,
/// "key": "pdf"
/// }
/// ]
/// },
/// "lang": {
/// "buckets": [
/// {
/// "doc_count": 30,
/// "key": "en"
/// }
/// ]
/// }
/// }
/// ```
pub type Aggregations = HashMap<String, Aggregation>;
/// Merge the buckets in `from` to `to`.
pub(crate) fn merge_aggregations(to: &mut Option<Aggregations>, from: Aggregations) {
use std::collections::hash_map::Entry;
if from.is_empty() {
return;
}
match to {
None => {
*to = Some(from);
}
Some(to_map) => {
for (agg_name, agg) in from {
match to_map.entry(agg_name) {
Entry::Occupied(mut occ) => {
let to_agg = occ.get_mut();
if let Some(from_buckets) = agg.buckets {
match &mut to_agg.buckets {
Some(to_buckets) => {
for bucket in from_buckets {
if let Some(existing) = to_buckets
.iter_mut()
.find(|existing| existing.key == bucket.key)
{
existing.doc_count += bucket.doc_count;
} else {
to_buckets.push(bucket);
}
}
}
None => {
to_agg.buckets = Some(from_buckets);
}
}
}
}
Entry::Vacant(vacant) => {
vacant.insert(agg);
}
};
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct QueryResponse {
pub source: QuerySource,
pub hits: Vec<(Document, f64)>,
pub total_hits: usize,
pub aggregations: Option<Aggregations>,
}
#[derive(Debug, Clone, Serialize)]
@@ -134,4 +287,121 @@ pub struct MultiSourceQueryResponse {
pub failed: Vec<FailedRequest>,
pub hits: Vec<QueryHits>,
pub total_hits: usize,
pub aggregations: Option<Aggregations>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
/// Helper function to create an `AggBucket`, used in tests.
fn bucket(key: &str, doc_count: usize) -> AggBucket {
AggBucket {
key: key.to_string(),
doc_count,
label: None,
}
}
/// Helper function to create an `Aggregation`, used in tests.
fn agg_with_buckets(buckets: Vec<AggBucket>) -> Aggregation {
Aggregation {
buckets: Some(buckets),
}
}
/// Helper to get `doc_count` from the bucket specified by `key`.
/// Returns `None` when buckets are absent or the key is missing.
fn get_doc_count(agg: &Aggregation, key: &str) -> Option<usize> {
agg.buckets
.as_ref()
.and_then(|buckets| buckets.iter().find(|b| b.key == key))
.map(|b| b.doc_count)
}
#[test]
fn merge_into_none_initializes() {
let mut to: Option<Aggregations> = None;
let mut from = Aggregations::new();
from.insert("terms".to_string(), agg_with_buckets(vec![bucket("a", 2)]));
merge_aggregations(&mut to, from);
let terms = to.unwrap().get("terms").cloned().unwrap();
assert_eq!(get_doc_count(&terms, "a"), Some(2));
}
#[test]
fn merge_sums_and_appends_buckets() {
let mut to_inner = Aggregations::new();
to_inner.insert(
"terms".to_string(),
agg_with_buckets(vec![bucket("a", 1), bucket("b", 2)]),
);
let mut to = Some(to_inner);
let mut from = Aggregations::new();
from.insert(
"terms".to_string(),
agg_with_buckets(vec![bucket("a", 3), bucket("c", 5)]),
);
from.insert(
"lang".to_string(),
agg_with_buckets(vec![bucket("zh", 3), bucket("en", 5)]),
);
merge_aggregations(&mut to, from);
let terms = to.as_ref().unwrap().get("terms").unwrap();
assert_eq!(get_doc_count(terms, "a"), Some(4));
assert_eq!(get_doc_count(terms, "b"), Some(2));
assert_eq!(get_doc_count(terms, "c"), Some(5));
let lang = to.as_ref().unwrap().get("lang").unwrap();
assert_eq!(get_doc_count(lang, "zh"), Some(3));
assert_eq!(get_doc_count(lang, "en"), Some(5));
}
#[test]
fn deserialize_bucket_with_label() {
let json = r#"
{
"doc_count": 251,
"key": "d23ek9gqlqbcd9e3uiig",
"top": {
"hits": {
"hits": [
{
"_source": {
"source": {
"name": "INFINI Easysearch"
}
}
}
]
}
}
}
"#;
let bucket: AggBucket = serde_json::from_str(json).unwrap();
assert_eq!(bucket.doc_count, 251);
assert_eq!(bucket.key, "d23ek9gqlqbcd9e3uiig");
assert_eq!(bucket.label.as_deref(), Some("INFINI Easysearch"));
}
#[test]
fn deserialize_bucket_without_top_sets_label_none() {
let json = r#"
{
"doc_count": 10,
"key": "no-top"
}
"#;
let bucket: AggBucket = serde_json::from_str(json).unwrap();
assert_eq!(bucket.doc_count, 10);
assert_eq!(bucket.key, "no-top");
assert_eq!(bucket.label, None);
}
}

View File

@@ -654,6 +654,8 @@ impl SearchSource for ApplicationSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -689,6 +691,8 @@ impl SearchSource for ApplicationSearchSource {
source,
hits,
total_hits,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -39,6 +39,8 @@ impl SearchSource for ApplicationSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -131,6 +131,8 @@ impl SearchSource for CalculatorSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
};
@@ -143,6 +145,8 @@ impl SearchSource for CalculatorSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -156,6 +160,8 @@ impl SearchSource for CalculatorSource {
source: query_source,
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
};
};
// If it is only a number, no need to evaluate it as the result is
@@ -167,6 +173,8 @@ impl SearchSource for CalculatorSource {
source: query_source,
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
};
}
@@ -199,12 +207,16 @@ impl SearchSource for CalculatorSource {
source: query_source,
hits: vec![(doc, base_score)],
total_hits: 1,
// Local search source does not support aggregations
aggregations: None,
}
}
Err(_) => QueryResponse {
source: query_source,
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
},
}
};

View File

@@ -51,6 +51,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
};
let from = usize::try_from(query.from).expect("from too big");
@@ -62,6 +64,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -77,6 +81,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -92,6 +98,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: query_source,
hits,
total_hits,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -38,6 +38,8 @@ impl SearchSource for WindowManagementSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregation
aggregations: None,
});
};
let from = usize::try_from(query.from).expect("from too big");
@@ -49,6 +51,8 @@ impl SearchSource for WindowManagementSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregation
aggregations: None,
});
}
let query_string_lowercase = query_string.to_lowercase();
@@ -133,6 +137,8 @@ impl SearchSource for WindowManagementSearchSource {
source: self.get_type(),
hits: from_size_applied,
total_hits,
// Local search source does not support aggregation
aggregations: None,
})
}
}

View File

@@ -135,7 +135,9 @@ pub struct Extension {
/// It is only for third-party extensions. Built-in extensions should always
/// set this field to `None`.
#[serde(deserialize_with = "deserialize_coco_semver")]
#[serde(default)] // None if this field is missing
#[serde(serialize_with = "serialize_coco_semver")]
// None if this field is missing, required as we use custom deserilize method.
#[serde(default)]
minimum_coco_version: Option<SemVer>,
/*
@@ -216,112 +218,117 @@ impl<'ext> PartialEq<ExtensionBundleId> for ExtensionBundleIdBorrowed<'ext> {
}
}
#[tauri::command]
pub(crate) fn extension_on_opened(extension: Extension) -> Option<OnOpened> {
_extension_on_opened(&extension)
}
/// Return what will happen when we open this extension.
///
/// `None` if it cannot be opened.
pub(crate) fn _extension_on_opened(extension: &Extension) -> Option<OnOpened> {
let settings = extension.settings.clone();
let permission = extension.permission.clone();
match extension.r#type {
// This function, at the time of writing this comment, is primarily
// used by third-party extensions.
//
// Built-in extensions don't use this as they are technically not
// "struct Extension"s. Typically, they directly construct a
// "struct Document" from their own type.
ExtensionType::Calculator => unreachable!("this is handled by frontend"),
ExtensionType::AiExtension => unreachable!(
"currently, all AI extensions we have are non-searchable, so we won't open them"
),
ExtensionType::Application => {
// We can have a impl like:
//
// Some(OnOpened::Application { app_path: self.id.clone() })
//
// but it won't be used.
unreachable!(
"Applications are not \"struct Extension\" under the hood, they won't call this method"
)
}
// These 2 types of extensions cannot be opened
ExtensionType::Group => return None,
ExtensionType::Extension => return None,
ExtensionType::Command => {
let ty = ExtensionOnOpenedType::Command {
action: extension.action.clone().unwrap_or_else(|| {
panic!(
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", extension.id
)
}),
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Quicklink => {
let quicklink = extension.quicklink.clone().unwrap_or_else(|| {
panic!(
"Quicklink extension [{}]'s [quicklink] field is not set, something wrong with your extension validity check", extension.id
)
});
let ty = ExtensionOnOpenedType::Quicklink {
link: quicklink.link,
open_with: quicklink.open_with,
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::View => {
let name = extension.name.clone();
let icon = extension.icon.clone();
let page = extension.page.as_ref().unwrap_or_else(|| {
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", extension.id);
}).clone();
let ui = extension.ui.clone();
let extension_on_opened_type = ExtensionOnOpenedType::View {
name,
icon,
page,
ui,
};
let extension_on_opened = ExtensionOnOpened {
ty: extension_on_opened_type,
settings,
permission,
};
let on_opened = OnOpened::Extension(extension_on_opened);
Some(on_opened)
}
ExtensionType::Unknown => {
unreachable!("Extensions of type [Unknown] should never be opened")
}
}
}
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> {
let settings = self.settings.clone();
let permission = self.permission.clone();
match self.r#type {
// This function, at the time of writing this comment, is primarily
// used by third-party extensions.
//
// Built-in extensions don't use this as they are technically not
// "struct Extension"s. Typically, they directly construct a
// "struct Document" from their own type.
ExtensionType::Calculator => unreachable!("this is handled by frontend"),
ExtensionType::AiExtension => unreachable!(
"currently, all AI extensions we have are non-searchable, so we won't open them"
),
ExtensionType::Application => {
// We can have a impl like:
//
// Some(OnOpened::Application { app_path: self.id.clone() })
//
// but it won't be used.
unreachable!(
"Applications are not \"struct Extension\" under the hood, they won't call this method"
)
}
// These 2 types of extensions cannot be opened
ExtensionType::Group => return None,
ExtensionType::Extension => return None,
ExtensionType::Command => {
let ty = ExtensionOnOpenedType::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
)
}),
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Quicklink => {
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
panic!(
"Quicklink extension [{}]'s [quicklink] field is not set, something wrong with your extension validity check", self.id
)
});
let ty = ExtensionOnOpenedType::Quicklink {
link: quicklink.link,
open_with: quicklink.open_with,
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::View => {
let name = self.name.clone();
let icon = self.icon.clone();
let page = self.page.as_ref().unwrap_or_else(|| {
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
}).clone();
let ui = self.ui.clone();
let extension_on_opened_type = ExtensionOnOpenedType::View {
name,
icon,
page,
ui,
};
let extension_on_opened = ExtensionOnOpened {
ty: extension_on_opened_type,
settings,
permission,
};
let on_opened = OnOpened::Extension(extension_on_opened);
Some(on_opened)
}
ExtensionType::Unknown => {
unreachable!("Extensions of type [Unknown] should never be opened")
}
}
_extension_on_opened(self).is_some()
}
pub(crate) fn get_sub_extension(&self, sub_extension_id: &str) -> Option<&Self> {
@@ -419,6 +426,37 @@ where
Ok(Some(semver))
}
/// Serialize Coco SemVer to a string.
///
/// For a `SemVer`, there are 2 possible input cases, guarded by `to_semver()`:
///
/// 1. "x.y.z" => "x.y.z"
/// 2. "x.y.z-SNAPSHOT.2560" => "x.y.z-SNAPSHOT-2560"
fn serialize_coco_semver<S>(version: &Option<SemVer>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match version {
Some(v) => {
assert!(v.build.is_empty());
let s = if v.pre.is_empty() {
format!("{}.{}.{}", v.major, v.minor, v.patch)
} else {
format!(
"{}.{}.{}-{}",
v.major,
v.minor,
v.patch,
v.pre.as_str().replace('.', "-")
)
};
serializer.serialize_str(&s)
}
None => serializer.serialize_none(),
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct CommandAction {
pub(crate) exec: String,
@@ -673,7 +711,30 @@ fn filter_out_extensions(
extensions.retain(|ext| {
let ty = ext.r#type;
ty == ExtensionType::Group || ty == ExtensionType::Extension || ty == extension_type
if ty.contains_sub_items() {
/*
* We should not filter out group/extension extensions, with 2
* exceptions: "Applications" and "File Search". They contains
* no sub-extensions, so we treat them as normal extensions.
*
* When `extenison_type` is "Application", we return the "Applications"
* extension as well because it is the entry to access the application
* list.
*/
if ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
{
ty == extension_type || extension_type == ExtensionType::Application
} else if ext.developer.is_none() && ext.id == built_in::file_search::EXTENSION_ID {
ty == extension_type
} else {
// We should not filter out group/extension extensions
true
}
} else {
ty == extension_type
}
});
// Filter sub-extensions to only include the requested type
@@ -693,19 +754,6 @@ fn filter_out_extensions(
}
}
}
// Application is special, technically, it should never be filtered out by
// this condition. But if our users will be surprising if they choose a
// non-Application type and see it in the results. So we do this to remedy the
// issue
if let Some(idx) = extensions.iter().position(|ext| {
ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
}) {
if extension_type != ExtensionType::Application {
extensions.remove(idx);
}
}
}
// apply query filter
@@ -721,8 +769,23 @@ fn filter_out_extensions(
extensions.retain(|ext| {
if ext.r#type.contains_sub_items() {
// Keep all group/extension types
true
/*
* We should keep all the group/extension extensions. But we
* have 2 exceptions: "Applications" and "File Search". Even
* though they are of type group/extension, they do not contain
* sub-extensions, so they are more like commands, apply the
* `match_closure` here
*/
if ext.developer.is_none()
&& (ext.id
== built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|| ext.id == built_in::file_search::EXTENSION_ID)
{
match_closure(ext)
} else {
// Keep all group/extension types
true
}
} else {
// Apply filter to non-group/extension types
match_closure(ext)
@@ -779,7 +842,8 @@ pub(crate) async fn list_extensions(
// Cleanup after filtering extensions, don't do it if filter is not performed.
//
// Remove parent extensions (Group/Extension types) that have no sub-items after filtering
// Remove parent extensions (Group/Extension types) that have no sub-items
// after filtering
let filter_performed = query.is_some() || extension_type.is_some() || list_enabled;
if filter_performed {
extensions.retain(|ext| {
@@ -787,11 +851,20 @@ pub(crate) async fn list_extensions(
return true;
}
// We don't do this filter to applications since it is always empty, load at runtime.
if ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
{
return true;
/*
* Two exceptions: "Applications" and "File Search"
*
* They are of type group/extension, but they contain no sub
* extensions, which means technically, we should filter them
* out. However, we sould not do this because they are not real
* group/extension extensions.
*/
if ext.developer.is_none() {
if ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|| ext.id == built_in::file_search::EXTENSION_ID
{
return true;
}
}
let has_commands = ext
@@ -2150,4 +2223,31 @@ mod tests {
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_serialize_coco_semver_none() {
let version: Option<SemVer> = None;
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_coco_semver(&version, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, "null");
}
#[test]
fn test_serialize_coco_semver_simple() {
let version: Option<SemVer> = Some(SemVer::parse("1.2.3").unwrap());
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_coco_semver(&version, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, "\"1.2.3\"");
}
#[test]
fn test_serialize_coco_semver_with_pre() {
let version: Option<SemVer> = Some(SemVer::parse("1.2.3-SNAPSHOT.1234").unwrap());
let mut serializer = serde_json::Serializer::new(Vec::new());
serialize_coco_semver(&version, &mut serializer).unwrap();
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
assert_eq!(serialized, "\"1.2.3-SNAPSHOT-1234\"");
}
}

View File

@@ -69,6 +69,8 @@ impl SearchSource for ExtensionStore {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
};
@@ -94,12 +96,16 @@ impl SearchSource for ExtensionStore {
source: self.get_type(),
hits: vec![(doc, SCORE)],
total_hits: 1,
// Local search source does not support aggregations
aggregations: None,
})
} else {
Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -16,6 +16,8 @@ use crate::common::search::QueryResponse;
use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
use crate::extension::_extension_on_opened;
use crate::extension::ExtensionBundleId;
use crate::extension::ExtensionBundleIdBorrowed;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
@@ -26,11 +28,13 @@ use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use async_trait::async_trait;
use borrowme::Borrow;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use semver::Version as SemVer;
use serde_json::Value as Json;
use snafu::prelude::*;
use std::io::ErrorKind;
use std::ops::Deref;
use std::path::Path;
@@ -44,6 +48,7 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
use tokio::fs::read_dir;
use tokio::sync::RwLock;
use tokio::sync::RwLockReadGuard;
use tokio::sync::RwLockWriteGuard;
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
@@ -393,6 +398,26 @@ impl ThirdPartyExtensionsSearchSource {
extension.get_sub_extension_mut(sub_extension_id)
}
/// Return an immutable reference to the extension specified by `bundle_id` if it exists.
fn get_extension<'lock, 'extensions>(
extensions_read_lock: &'lock RwLockReadGuard<'extensions, Vec<Extension>>,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Option<&'lock Extension> {
let index = extensions_read_lock.iter().position(|ext| {
ext.id == bundle_id.extension_id && ext.developer.as_deref() == bundle_id.developer
})?;
let extension = extensions_read_lock
.get(index)
.expect("just checked this extension exists");
let Some(sub_extension_id) = bundle_id.sub_extension_id else {
return Some(extension);
};
extension.get_sub_extension(sub_extension_id)
}
/// Difference between this function and `enable_extension()`
///
/// This function does the actual job, i.e., to enable/activate the extension.
@@ -407,7 +432,7 @@ impl ThirdPartyExtensionsSearchSource {
) -> Result<(), String> {
if extension.supports_alias_hotkey() {
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 on_opened = _extension_on_opened(extension).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
@@ -681,7 +706,7 @@ impl ThirdPartyExtensionsSearchSource {
)?;
// Set hotkey
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
let on_opened = _extension_on_opened(extension).unwrap_or_else(|| panic!(
"setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type,
));
@@ -867,6 +892,59 @@ impl ThirdPartyExtensionsSearchSource {
pub(crate) async fn extensions_snapshot(&self) -> Vec<Extension> {
self.inner.extensions.read().await.clone()
}
/// Open the specified third-party extension.
async fn open(
&self,
tauri_app_handle: AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), OpenThirdPartyExtensionError> {
let extensions_read_lock = self.inner.extensions.read().await;
let Some(ext) = Self::get_extension(&extensions_read_lock, bundle_id) else {
log::warn!(
"trying to open() a third-party extension [{:?}] that does not exist",
bundle_id
);
return Err(OpenThirdPartyExtensionError::ExtensionNotFound {
bundle_id: bundle_id.to_owned(),
});
};
let Some(on_opened) = _extension_on_opened(ext) else {
log::warn!("third-party extension [{:?}] cannot be opened", bundle_id);
return Err(OpenThirdPartyExtensionError::ExtensionCannotBeOpened {
bundle_id: bundle_id.to_owned(),
});
};
crate::common::document::open(tauri_app_handle, on_opened, None)
.await
.map_err(|err_msg| OpenThirdPartyExtensionError::OnOpenedOpenError { msg: err_msg })?;
Ok(())
}
}
#[derive(Debug, Snafu, serde::Serialize)]
pub(crate) enum OpenThirdPartyExtensionError {
#[snafu(display("extension '{:?}' does not exist", bundle_id))]
ExtensionNotFound { bundle_id: ExtensionBundleId },
#[snafu(display("extension '{:?}' cannot be opened", bundle_id))]
ExtensionCannotBeOpened { bundle_id: ExtensionBundleId },
#[snafu(display("executing open(OnOpened) failed: '{}'", msg))]
OnOpenedOpenError { msg: String },
}
#[tauri::command]
pub(crate) async fn open_third_party_extension(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId,
) -> Result<(), OpenThirdPartyExtensionError> {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap_or_else(|| panic!("THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE is not set"))
.open(tauri_app_handle, &bundle_id.borrow())
.await
}
pub(crate) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
@@ -890,25 +968,37 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
}
}
// query main_extension_id querysource
// main_extension_id querysource
// query querysource datasource
async fn search(
&self,
_tauri_app_handle: AppHandle,
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 opt_lowercase_query_string: Option<String> = {
match query.query_strings.get("query") {
Some(query_string) => {
if query_string.is_empty() {
None
} else {
Some(query_string.to_lowercase())
}
}
None => None,
}
};
let opt_data_source = query
.query_strings
.get("datasource")
.map(|owned_str| owned_str.to_string());
.map(|str| str.to_string());
let opt_main_extension_id = query
.query_strings
.get("main_extension_id")
.map(|str| str.to_string());
let query_lower = query_string.to_lowercase();
let inner_clone = Arc::clone(&self.inner);
let closure = move || {
@@ -916,10 +1006,22 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
let extensions_read_lock =
futures::executor::block_on(async { inner_clone.extensions.read().await });
let main_extension_filter_closure = |ext: &&Extension| -> bool {
// field minimum_coco_extension is only set for main
// extensions, so we only check main extensions.
let condition1 = ext.enabled && is_extension_compatible(Extension::clone(ext));
let condition2 = if let Some(ref main_extension_id) = opt_main_extension_id {
&ext.id == main_extension_id
} else {
true
};
condition1 && condition2
};
for extension in extensions_read_lock
.iter()
// field minimum_coco_extension is only set for main extensions.
.filter(|ext| ext.enabled && is_extension_compatible(Extension::clone(ext)))
.filter(main_extension_filter_closure)
{
if extension.r#type.contains_sub_items() {
let opt_main_extension_lowercase_name =
@@ -934,7 +1036,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for command in commands.iter().filter(|cmd| cmd.enabled) {
if let Some(hit) = extension_to_hit(
command,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -947,7 +1049,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for script in scripts.iter().filter(|script| script.enabled) {
if let Some(hit) = extension_to_hit(
script,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -960,7 +1062,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for quicklink in quicklinks.iter().filter(|link| link.enabled) {
if let Some(hit) = extension_to_hit(
quicklink,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -973,7 +1075,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for view in views.iter().filter(|view| view.enabled) {
if let Some(hit) = extension_to_hit(
view,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -982,9 +1084,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
}
}
} else {
if let Some(hit) =
extension_to_hit(extension, &query_lower, opt_data_source.as_deref(), None)
{
if let Some(hit) = extension_to_hit(
extension,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
None,
) {
hits.push(hit);
}
}
@@ -1005,6 +1110,8 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
source: self.get_type(),
hits,
total_hits,
// Local search source does not support aggregations
aggregations: None,
})
}
}
@@ -1029,9 +1136,9 @@ pub(crate) async fn uninstall_extension(
/// This argument is needed as an "extension" type extension should return all its
/// sub-extensions when the query string matches its name. To do this, we pass the
/// extension name, score it and take that into account.
pub(crate) fn extension_to_hit(
fn extension_to_hit(
extension: &Extension,
query_lower: &str,
opt_lowercase_query_string: Option<&str>,
opt_data_source: Option<&str>,
opt_main_extension_lowercase_name: Option<&str>,
) -> Option<(Document, f64)> {
@@ -1050,64 +1157,66 @@ pub(crate) fn extension_to_hit(
}
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.name.to_lowercase())
{
total_score += title_score;
}
// Score based on alias match if available
// Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
total_score += alias_score;
}
}
// An "extension" type extension should return all its
// sub-extensions when the query string matches its ID.
// To do this, we score the extension ID and take that
// into account.
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
if let Some(main_extension_score) =
calculate_text_similarity(&query_lower, main_extension_lowercase_id)
if let Some(query_lower) = opt_lowercase_query_string {
// 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.name.to_lowercase())
{
total_score += main_extension_score;
total_score += title_score;
}
// Score based on alias match if available
// Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(query_lower, &alias.to_lowercase())
{
total_score += alias_score;
}
}
// An "extension" type extension should return all its
// sub-extensions when the query string matches its ID.
// To do this, we score the extension ID and take that
// into account.
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
if let Some(main_extension_score) =
calculate_text_similarity(query_lower, main_extension_lowercase_id)
{
total_score += main_extension_score;
}
}
// Only filter by score if query string is set
if total_score == 0.0 {
return None;
}
}
// 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 on_opened = _extension_on_opened(extension).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.name.clone()),
icon: Some(extension.icon.clone()),
on_opened: Some(on_opened),
url: Some(url),
category: Some(extension_type_string.clone()),
source: Some(DataSourceReference {
id: Some(extension_type_string.clone()),
name: Some(extension_type_string.clone()),
icon: None,
r#type: Some(extension_type_string),
}),
let document = Document {
id: extension.id.clone(),
title: Some(extension.name.clone()),
icon: Some(extension.icon.clone()),
on_opened: Some(on_opened),
url: Some(url),
category: Some(extension_type_string.clone()),
source: Some(DataSourceReference {
id: Some(extension_type_string.clone()),
name: Some(extension_type_string.clone()),
icon: None,
r#type: Some(extension_type_string),
}),
..Default::default()
};
..Default::default()
};
Some((document, total_score))
} else {
None
}
Some((document, total_score))
}

View File

@@ -177,6 +177,7 @@ pub fn run() {
extension::enable_extension,
extension::disable_extension,
extension::set_extension_alias,
extension::extension_on_opened,
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
@@ -185,6 +186,7 @@ pub fn run() {
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
extension::third_party::open_third_party_extension,
extension::is_extension_compatible,
extension::api::apis,
extension::api::fs::read_dir,

View File

@@ -1,7 +1,8 @@
use crate::common::error::{ReportErrorStyle, SearchError, report_error};
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
Aggregations, FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
merge_aggregations,
};
use crate::common::traits::SearchSource;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
@@ -17,6 +18,34 @@ use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::time::{Duration, timeout};
/// Helper function to drop empty aggregations and normalize `Option` state.
fn clean_aggregations(aggs: &mut Option<Aggregations>) {
if let Some(map) = aggs {
map.retain(|_, agg| match &agg.buckets {
Some(buckets) => !buckets.is_empty(),
None => false,
});
if map.is_empty() {
*aggs = None;
}
}
}
/// Available `query_strings`:
///
/// * "querysource": the query/search source to search
/// * "datasource": the data source to search. If this is provided, then
/// "querysource" has to be specified as well.
/// * "main_extension_id": Currently, only the "extensions" query source
/// supports this. If you set
///
/// ```text
/// {"querysource": "extension", "main_extension_id"}
/// ```
///
/// then only the extension with this ID will be returned, if exists.
#[named]
#[tauri::command]
pub async fn query_coco_fusion(
@@ -26,6 +55,12 @@ pub async fn query_coco_fusion(
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
println!("DBG: querystrings {:?}", query_strings);
if query_strings.contains_key("datasource") && !query_strings.contains_key("querysource") {
panic!("[querysource] has to be provided if [datasource] is set")
}
let opt_query_source_id = query_strings.get("querysource");
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
let query_source_list = search_sources.get_sources().await;
@@ -42,7 +77,7 @@ pub async fn query_coco_fusion(
);
// Dispatch to different `query_coco_fusion_xxx()` functions.
if let Some(query_source_id) = opt_query_source_id {
let mut res_response = if let Some(query_source_id) = opt_query_source_id {
query_coco_fusion_single_query_source(
tauri_app_handle,
query_source_list,
@@ -59,7 +94,13 @@ pub async fn query_coco_fusion(
search_query,
)
.await
};
if let Ok(ref mut response) = res_response {
clean_aggregations(&mut response.aggregations);
}
res_response
}
/// Query only 1 query source.
@@ -103,6 +144,7 @@ async fn query_coco_fusion_single_query_source(
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
aggregations: None,
});
};
@@ -114,6 +156,7 @@ async fn query_coco_fusion_single_query_source(
let mut failed_requests: Vec<FailedRequest> = Vec::new();
let mut hits = Vec::new();
let mut total_hits = 0;
let mut aggregations = None;
match timeout_result {
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
@@ -126,6 +169,7 @@ async fn query_coco_fusion_single_query_source(
Ok(query_result) => match query_result {
Ok(response) => {
total_hits = response.total_hits;
aggregations = response.aggregations;
for (document, score) in response.hits {
log::debug!(
@@ -161,6 +205,7 @@ async fn query_coco_fusion_single_query_source(
failed: failed_requests,
hits,
total_hits,
aggregations,
})
}
@@ -209,6 +254,7 @@ async fn query_coco_fusion_multi_query_sources(
let mut total_hits = 0;
let mut failed_requests = Vec::new();
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new();
let mut aggregations = None;
while let Some((query_source, timeout_result)) = futures.next().await {
match timeout_result {
@@ -222,6 +268,9 @@ async fn query_coco_fusion_multi_query_sources(
Ok(query_result) => match query_result {
Ok(response) => {
total_hits += response.total_hits;
if let Some(from) = response.aggregations {
merge_aggregations(&mut aggregations, from);
}
for (document, score) in response.hits {
log::debug!(
@@ -264,6 +313,7 @@ async fn query_coco_fusion_multi_query_sources(
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
aggregations: None,
});
}
@@ -405,6 +455,7 @@ async fn query_coco_fusion_multi_query_sources(
failed: failed_requests,
hits: final_hits,
total_hits,
aggregations,
})
}

View File

@@ -75,6 +75,31 @@ pub struct CocoSearchSource {
server: Server,
}
/// Convert frontend query string key/value into coco server query param.
/// Returns `None` when the key is not recognized.
fn convert_query_string(key: &str, value: &str) -> Option<String> {
match key {
// existing single-value params
"querysource" | "datasource" | "query" | "fuzziness" => Some(format!("{}={}", key, value)),
// time range filters (single value)
"update_time_start" => Some(format!("filter=updated>={}", value)),
"update_time_end" => Some(format!("filter=updated<={}", value)),
"create_time_start" => Some(format!("filter=created>={}", value)),
"create_time_end" => Some(format!("filter=created<={}", value)),
// multi-value filters (value string may already contain any(...))
"type" => Some(format!("filter=type:{}", value)),
"source.id" => Some(format!("filter=source.id:{}", value)),
"category" => Some(format!("filter=category:{}", value)),
"subcategory" => Some(format!("filter=subcategory:{}", value)),
"lang" => Some(format!("filter=lang:{}", value)),
"tag" => Some(format!("filter=tag:{}", value)),
_ => None,
}
}
impl CocoSearchSource {
pub fn new(server: Server) -> Self {
CocoSearchSource { server }
@@ -99,6 +124,7 @@ impl SearchSource for CocoSearchSource {
let url = "/query/_search";
let mut total_hits = 0;
let mut hits: Vec<(Document, f64)> = Vec::new();
let mut aggregations = None;
let mut query_params = Vec::new();
@@ -108,12 +134,55 @@ impl SearchSource for CocoSearchSource {
// Add query strings
for (key, value) in query.query_strings {
query_params.push(format!("{}={}", key, value));
if let Some(param) = convert_query_string(&key, &value) {
query_params.push(param);
}
}
println!("DBG: query params\n{:?}", query_params);
let response = HttpClient::get(&self.server.id, &url, Some(query_params))
.await
.context(HttpSnafu)?;
let request_body = r#"
{
"aggs": {
"category": {
"terms": {
"field": "category"
}
},
"lang": {
"terms": {
"field": "lang"
}
},
"source.id": {
"terms": {
"field": "source.id"
},
"aggs": {
"top": {
"top_hits": {
"size": 1,
"_source": [
"source.name"
]
}
}
}
},
"type": {
"terms": {
"field": "type"
}
}
}
}"#;
let response = HttpClient::post(
&self.server.id,
url,
Some(query_params),
Some(request_body.into()),
)
.await
.context(HttpSnafu)?;
let status_code = response.status();
if ![StatusCode::OK, StatusCode::CREATED].contains(&status_code) {
@@ -156,6 +225,8 @@ impl SearchSource for CocoSearchSource {
hits.push((document, score));
}
}
aggregations = parsed.aggregations;
}
// Return the final result
@@ -163,6 +234,7 @@ impl SearchSource for CocoSearchSource {
source: self.get_type(),
hits,
total_hits,
aggregations,
})
}
}

View File

@@ -12,7 +12,7 @@ import {
handleNetworkError,
} from "./tools";
type Fn = (data: FcResponse<any>) => unknown;
type Fn = (data: FcResponse<unknown>) => unknown;
interface IAnyObj {
[index: string]: unknown;
@@ -85,8 +85,26 @@ export const Get = <T>(
new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
// In Vite dev, prefer using the proxy by keeping requests relative
const isDev = (import.meta as any).env?.DEV === true;
const PROXY_PREFIXES: readonly string[] = [
"account",
"chat",
"query",
"connector",
"integration",
"assistant",
"datasource",
"settings",
"mcp_server",
];
const shouldProxy =
isDev &&
url.startsWith("/") &&
PROXY_PREFIXES.some((p) => url.startsWith(`/${p}`));
let baseURL: string = appStore.state?.endpoint_http as string;
if (!baseURL || baseURL === "undefined" || shouldProxy) {
baseURL = "";
}
@@ -117,8 +135,25 @@ export const Post = <T>(
return new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
const isDev = (import.meta as any).env?.DEV === true;
const PROXY_PREFIXES: readonly string[] = [
"account",
"chat",
"query",
"connector",
"integration",
"assistant",
"datasource",
"settings",
"mcp_server",
];
const shouldProxy =
isDev &&
url.startsWith("/") &&
PROXY_PREFIXES.some((p) => url.startsWith(`/${p}`));
let baseURL: string = appStore.state?.endpoint_http as string;
if (!baseURL || baseURL === "undefined" || shouldProxy) {
baseURL = "";
}

View File

@@ -81,14 +81,24 @@ async function invokeWithErrorHandler<T>(
return result;
} catch (error: any) {
const errorMessage = error || "Command execution failed";
console.log(error);
const errorMessage =
error instanceof Error
? error.message
: typeof error === "object"
? JSON.stringify(error)
: String(error || "Command execution failed");
// 401 Unauthorized
if (errorMessage.includes("Unauthorized")) {
handleLogout();
} else {
addError(command + ":" + errorMessage, "error");
}
throw error;
if (error instanceof Error) {
throw error;
}
throw new Error(errorMessage);
}
}

View File

@@ -41,15 +41,20 @@ const AssistantItem = memo(
)}
onClick={onClick}
>
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">
{_source?.icon?.startsWith("font_") ? (
<FontIcon name={_source?.icon} className="size-4" />
{_source?.icon?.startsWith("font_") ? (
<FontIcon
name={_source?.icon}
className="w-4 h-4 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
/>
) : (
<img src={logoImg} className="size-4" alt={name} />
<img
src={logoImg}
className="w-4 h-4 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
alt={name}
/>
)}
</div>
<div className="text-left flex-1 min-w-0">
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{_source?.name || "-"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
@@ -67,4 +72,4 @@ const AssistantItem = memo(
)
);
export default AssistantItem;
export default AssistantItem;

View File

@@ -1,8 +1,11 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { ChevronDownIcon, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isNil } from "lodash-es";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useDebounce, useKeyPress, usePagination } from "ahooks";
import clsx from "clsx";
@@ -17,6 +20,7 @@ import { AssistantFetcher } from "./AssistantFetcher";
import AssistantItem from "./AssistantItem";
import Pagination from "@/components/Common/Pagination";
import { useSearchStore } from "@/stores/searchStore";
import { Button } from "../ui/button";
interface AssistantListProps {
assistantIDs?: string[];
@@ -83,6 +87,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const targetId = askAiAssistantId ?? targetAssistantId;
@@ -105,7 +110,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
useKeyPress(
["uparrow", "downarrow", "enter"],
(event, key) => {
const isClose = isNil(popoverButtonRef.current?.dataset["open"]);
const isClose = !open;
if (isClose) return;
@@ -161,26 +166,29 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
}, []);
return (
<div className="relative">
<Popover ref={popoverRef}>
<PopoverButton
<div ref={popoverRef} className="relative">
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v);
}}
>
<PopoverTrigger
ref={popoverButtonRef}
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] text-sm/6 font-semibold text-gray-800 dark:text-[#d8d8d8] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full border border-input bg-background text-sm/6 font-semibold text-foreground hover:bg-accent hover:text-accent-foreground focus:outline-none"
>
<div className="w-4 h-4 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
{currentAssistant?._source?.icon?.startsWith("font_") ? (
<FontIcon
name={currentAssistant._source.icon}
className="w-3 h-3"
/>
) : (
<img
src={logoImg}
className="w-3 h-3"
alt={t("assistant.message.logo")}
/>
)}
</div>
{currentAssistant?._source?.icon?.startsWith("font_") ? (
<FontIcon
name={currentAssistant._source.icon}
className="w-4 h-4"
/>
) : (
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
)}
<div className="max-w-[100px] truncate">
{currentAssistant?._source?.name || "Coco AI"}
</div>
@@ -190,12 +198,14 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
popoverButtonRef.current?.click();
}}
>
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400 transition-transform" />
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform" />
</VisibleKey>
</PopoverButton>
</PopoverTrigger>
<PopoverPanel
className="absolute z-50 top-full mt-1 left-0 w-60 rounded-xl bg-white dark:bg-[#202126] p-3 text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 focus:outline-none max-h-[calc(100vh-150px)] overflow-y-auto"
<PopoverContent
side="bottom"
align="start"
className="z-50 w-60 rounded-xl p-3 shadow-lg focus:outline-none max-h-[calc(100vh-150px)] overflow-y-auto"
onMouseMove={handleMouseMove}
>
<div className="flex items-center justify-between text-sm font-bold">
@@ -203,9 +213,11 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
{t("assistant.popover.title")}{pagination.total}
</div>
<button
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-lg border dark:border-white/10"
className="size-6"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
@@ -218,7 +230,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
)}
/>
</VisibleKey>
</button>
</Button>
</div>
<VisibleKey
@@ -234,8 +246,8 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
autoFocus
value={keyword}
placeholder={t("assistant.popover.search")}
className="w-full h-8 px-2 bg-transparent border rounded-[6px] dark:border-white/10"
onChange={(event) => {
className="w-full h-8"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(event.target.value);
}}
/>
@@ -272,7 +284,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
<NoDataImage />
</div>
)}
</PopoverPanel>
</PopoverContent>
</Popover>
</div>
);

View File

@@ -388,7 +388,7 @@ const ChatAI = memo(
<div
data-tauri-drag-region
data-chat-instance={instanceId}
className={`flex flex-col rounded-[6px] h-full overflow-hidden relative`}
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
>
<ChatHeader
clearChat={clearChat}

View File

@@ -95,13 +95,13 @@ export function ChatHeader({
{isChatPage ? null : (
<button className="inline-flex" onClick={onOpenChatAI}>
<VisibleKey shortcut={external} onKeyPress={onOpenChatAI}>
<WindowsFullIcon className="rotate-30 scale-x-[-1]" />
<WindowsFullIcon className="scale-x-[-1]" />
</VisibleKey>
</button>
)}
</div>
) : (
<WebLogin panelClassName="top-8 right-0" />
<WebLogin side="bottom" align="end" />
)}
</header>
);

View File

@@ -28,7 +28,7 @@ const ConnectPrompt = () => {
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
<button
className="flex items-center gap-2 px-6 py-2 rounded-[6px] text-[#0072ff] transition-colors"
className="flex items-center gap-2 px-6 py-2 rounded-md text-[#0072ff] transition-colors"
onClick={handleConnect}
>
<span>{t("assistant.chat.connect")}</span>

View File

@@ -1,9 +1,13 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Settings, RefreshCw, Check, Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { useKeyPress } from "ahooks";
import { isNil } from "lodash-es";
import logoImg from "@/assets/icon.svg";
import ServerIcon from "@/icons/Server";
@@ -61,6 +65,7 @@ export function ServerList({ clearChat }: ServerListProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const serverListButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const { refreshServerList } = useServers();
@@ -143,7 +148,7 @@ export function ServerList({ clearChat }: ServerListProps) {
["uparrow", "downarrow", "enter"],
async (event, key) => {
const service = await getCurrentWindowService();
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
const isClose = !open;
const length = serverList.length;
if (isClose || length <= 1) return;
@@ -182,122 +187,130 @@ export function ServerList({ clearChat }: ServerListProps) {
}, []);
return (
<Popover ref={popoverRef} className="relative">
<PopoverButton ref={serverListButtonRef} className="flex items-center">
<VisibleKey
shortcut={serviceListShortcut}
onKeyPress={() => {
serverListButtonRef.current?.click();
}}
>
<ServerIcon />
</VisibleKey>
</PopoverButton>
<div ref={popoverRef} className="relative">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger ref={serverListButtonRef} className="flex items-center">
<VisibleKey
shortcut={serviceListShortcut}
onKeyPress={() => {
serverListButtonRef.current?.click();
}}
>
<ServerIcon />
</VisibleKey>
</PopoverTrigger>
<PopoverPanel
onMouseMove={handleMouseMove}
className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
>
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("assistant.chat.servers")}
</h3>
<div className="flex items-center gap-2">
<button
onClick={openSettings}
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
>
<VisibleKey shortcut=",">
<Settings className="h-4 w-4 text-[#0287FF]" />
</VisibleKey>
</button>
<button
onClick={handleRefresh}
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/>
</VisibleKey>
</button>
<PopoverContent
side="bottom"
align="end"
onMouseMove={handleMouseMove}
className="z-10 min-w-60 rounded-lg shadow-lg"
>
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-foreground">
{t("assistant.chat.servers")}
</h3>
<div className="flex items-center gap-2">
<Button
onClick={openSettings}
variant="ghost"
size="icon"
className="rounded-md focus-visible:ring-0 focus-visible:ring-offset-0"
>
<VisibleKey shortcut=",">
<Settings className="h-4 w-4 text-primary" />
</VisibleKey>
</Button>
<Button
onClick={handleRefresh}
variant="ghost"
size="icon"
className="rounded-md focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
className={`h-4 w-4 text-primary transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/>
</VisibleKey>
</Button>
</div>
</div>
</div>
<div className="space-y-1">
{list.length > 0 ? (
list.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap
<div className="space-y-1">
{list.length > 0 ? (
list.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap
${
currentService?.id === server.id ||
highlightId === server.id
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
? "bg-muted"
: "hover:bg-muted"
}`}
>
<div className="flex items-center gap-2 min-w-0">
<img
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = logoImg;
}}
/>
<div className="text-left flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{server.name}
>
<div className="flex items-center gap-2 min-w-0">
<img
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = logoImg;
}}
/>
<div className="text-left flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate max-w-[200px]">
{server.name}
</div>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{t("assistant.chat.aiAssistant")}:{" "}
{server.stats?.assistant_count || 1}
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
{t("assistant.chat.aiAssistant")}:{" "}
{server.stats?.assistant_count || 1}
</div>
<div className="flex flex-col items-center gap-2">
<StatusIndicator
enabled={server.enabled}
public={server.public}
hasProfile={!!server?.profile}
status={server.health?.status}
/>
<div className="size-4 flex justify-end">
{currentService?.id === server.id && (
<VisibleKey
shortcut="↓↑"
shortcutClassName="w-6 -translate-x-4"
>
<Check className="w-full h-full text-muted-foreground" />
</VisibleKey>
)}
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<StatusIndicator
enabled={server.enabled}
public={server.public}
hasProfile={!!server?.profile}
status={server.health?.status}
/>
<div className="size-4 flex justify-end">
{currentService?.id === server.id && (
<VisibleKey
shortcut="↓↑"
shortcutClassName="w-6 -translate-x-4"
>
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
</VisibleKey>
)}
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{t("assistant.chat.noServers")}
</p>
<button
onClick={openSettings}
className="mt-2 text-xs text-[#0287FF] hover:underline"
>
{t("assistant.chat.addServer")}
</button>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("assistant.chat.noServers")}
</p>
<button
onClick={openSettings}
className="mt-2 text-xs text-[#0287FF] hover:underline"
>
{t("assistant.chat.addServer")}
</button>
</div>
)}
)}
</div>
</div>
</div>
</PopoverPanel>
</Popover>
</PopoverContent>
</Popover>
</div>
);
}
}

View File

@@ -122,7 +122,7 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
return (
<li key={id} className="mobile:w-full w-1/2 p-1">
<div
className="group h-[74px] px-3 py-2 text-sm rounded-xl border dark:border-[#262626] bg-white dark:bg-black cursor-pointer transition hover:!border-[#0087FF]"
className="group h-[74px] px-3 py-2 text-sm rounded-xl border border-input bg-white dark:bg-black cursor-pointer transition hover:border-[#0087FF]!"
onClick={() => {
setCurrentAssistant(item);

View File

@@ -34,7 +34,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
const state = useReactive({ ...INITIAL_STATE });
const containerRef = useRef<HTMLDivElement>(null);
const recordRef = useRef<RecordPlugin>();
const { withVisibility, addError } = useAppStore();
const { addError } = useAppStore();
const { currentService } = useConnectStore();
const { wavesurfer } = useWavesurfer({
@@ -146,7 +146,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
};
const startRecording = async () => {
await withVisibility(checkPermission);
await checkPermission();
state.isRecording = true;
recordRef.current?.startRecording();
};
@@ -173,9 +173,9 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
<div
className={clsx(
"absolute -inset-2 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
"absolute inset-0 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
{
"!translate-x-0": state.isRecording || state.converting,
"translate-x-0!": state.isRecording || state.converting,
}
)}
>
@@ -184,7 +184,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
className={clsx(
"flex items-center justify-center size-6 bg-white dark:bg-black rounded-full transition cursor-pointer",
{
"!cursor-not-allowed opacity-50": state.converting,
"cursor-not-allowed! opacity-50": state.converting,
}
)}
onClick={() => resetState()}

View File

@@ -107,7 +107,7 @@ export const MessageActions = ({
<button
id={copyButtonId}
onClick={handleCopy}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
className="p-1 rounded-lg hover:bg-muted transition-colors"
>
{copied ? (
<Check
@@ -131,7 +131,7 @@ export const MessageActions = ({
{!isRefreshOnly && (
<button
onClick={handleLike}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
className={`p-1 rounded-lg hover:bg-muted transition-colors ${
liked ? "animate-shake" : ""
}`}
>
@@ -151,7 +151,7 @@ export const MessageActions = ({
{!isRefreshOnly && (
<button
onClick={handleDislike}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
className={`p-1 rounded-lg hover:bg-muted transition-colors ${
disliked ? "animate-shake" : ""
}`}
>
@@ -172,7 +172,7 @@ export const MessageActions = ({
<>
<button
onClick={handleSpeak}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
className="p-1 rounded-lg hover:bg-muted transition-colors"
>
<Volume2
className={`w-4 h-4 ${
@@ -191,7 +191,7 @@ export const MessageActions = ({
{question && (
<button
onClick={handleResend}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
className={`p-1 rounded-lg hover:bg-muted transition-colors ${
isResending ? "animate-spin" : ""
}`}
>

View File

@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
{icon?.startsWith("font_") ? (
<FontIcon name={icon} className="size-6" />
) : (
<img src={getTypeIcon()} alt={name} className="size-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
<img src={getTypeIcon()} alt={name} className="size-6 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
)}
<span className="font-medium text-gray-900 dark:text-white">

View File

@@ -44,7 +44,7 @@ export function DataSourcesList({ server }: { server: string }) {
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
{t("cloud.dataSource.title")}
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => initServerAppData()}
>
<RefreshCcw

View File

@@ -165,7 +165,7 @@ const LoginButton: FC<LoginButtonProps> = memo((props) => {
return (
<button
className="px-6 py-2 bg-blue-500 text-white rounded-[6px] hover:bg-blue-600 transition-colors mb-3"
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
aria-label={t("cloud.login")}
>
@@ -186,7 +186,7 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
return (
<div className="flex items-center space-x-2 mb-3">
<button
className="px-6 py-2 text-white bg-red-500 rounded-[6px] hover:bg-red-600 transition-colors"
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors"
onClick={onCancel}
>
{t("cloud.cancel")}

View File

@@ -18,7 +18,9 @@ const ServiceHeader = memo(
({ refreshLoading, refreshClick }: ServiceHeaderProps) => {
const { t } = useTranslation();
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
const cloudSelectService = useConnectStore(
(state) => state.cloudSelectService
);
const { enableServer, removeServer } = useServers();
@@ -46,7 +48,7 @@ const ServiceHeader = memo(
/>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() =>
OpenURLWithBrowser(cloudSelectService?.provider?.website)
}
@@ -54,7 +56,7 @@ const ServiceHeader = memo(
<Globe className="w-3.5 h-3.5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => refreshClick(cloudSelectService?.id)}
>
<RefreshCcw
@@ -63,7 +65,7 @@ const ServiceHeader = memo(
</button>
{!cloudSelectService?.builtin && (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => removeServer(cloudSelectService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />

View File

@@ -53,7 +53,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
<img
src={item?.provider?.icon || cocoLogoImg}
alt={`${item.name} logo`}
className="w-5 h-5 flex-shrink-0 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
className="w-5 h-5 shrink-0 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = cocoLogoImg;

View File

@@ -2,6 +2,7 @@ import { User, LogOut } from "lucide-react";
import { UserProfile as UserInfo } from "@/types/server";
import { useState } from "react";
import { Button } from "../ui/button";
interface UserProfileProps {
server: string; //server's id
@@ -38,12 +39,14 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
<span className="font-medium text-gray-900 dark:text-white">
{userInfo?.name || "-"}
</span>
<button
<Button
variant="outline"
size="icon"
onClick={handleLogout}
className="flex items-center p-1 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="size-7 text-red-500!"
>
<LogOut className="w-4 h-4" />
</button>
<LogOut className="size-4" />
</Button>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{userInfo?.email || "-"}

View File

@@ -62,13 +62,13 @@ const ApiDetails: React.FC = () => {
{logs.map((log, index) => (
<div
key={index}
className="p-4 border rounded-[6px] shadow-sm bg-gray-50"
className="p-4 border rounded-md shadow-sm bg-gray-50"
>
<h4 className="font-semibold text-gray-800">
Latest Request {index + 1}:
</h4>
<div className="text-sm text-gray-700 mt-1">
<pre className="bg-gray-100 p-2 rounded-[6px] whitespace-pre-wrap">
<pre className="bg-gray-100 p-2 rounded-md whitespace-pre-wrap">
{JSON.stringify(log.request, null, 2)}
</pre>
</div>
@@ -87,7 +87,7 @@ const ApiDetails: React.FC = () => {
</h4>
{showIndex === index ? (
<div className="text-sm text-gray-700 mt-1">
<pre className="bg-green-100 p-2 rounded-[6px] text-green-700 whitespace-pre-wrap">
<pre className="bg-green-100 p-2 rounded-md text-green-700 whitespace-pre-wrap">
{JSON.stringify(log.response, null, 2)}
</pre>
</div>
@@ -98,7 +98,7 @@ const ApiDetails: React.FC = () => {
<>
<h4 className="font-semibold text-red-800 mt-4">Error:</h4>
<div className="text-sm text-gray-700 mt-1">
<pre className="bg-red-100 p-2 rounded-[6px] text-red-700 whitespace-pre-wrap">
<pre className="bg-red-100 p-2 rounded-md text-red-700 whitespace-pre-wrap">
{JSON.stringify(log.error, null, 2)}
</pre>
</div>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from "react";
import { Bot, Search } from "lucide-react";
import platformAdapter from "@/utils/platformAdapter";
import clsx from "clsx";
interface ChatSwitchProps {
isChatMode: boolean;
@@ -29,19 +30,31 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
<div
role="switch"
aria-checked={isChatMode}
className={`relative flex items-center justify-between w-10 h-[20px] rounded-full cursor-pointer transition-colors duration-300 ${
isChatMode ? "bg-[#0072ff]" : "bg-[var(--coco-primary-color)]"
className={`relative flex items-center justify-between w-10 h-5 rounded-full cursor-pointer transition-colors duration-300 ${
isChatMode ? "bg-[#0072ff]" : "bg-(--coco-primary-color)"
}`}
onClick={handleToggle}
>
<div className="absolute top-0 left-0 w-full h-full pointer-events-none flex items-center justify-between px-1">
{isChatMode ? <Bot className="w-4 h-4 text-white" /> : <div></div>}
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
</div>
<div
className={`absolute top-[1px] left-[1px] h-[18px] w-[18px] bg-white rounded-full shadow-md transform transition-transform duration-300 ${
isChatMode ? "translate-x-5" : "translate-x-0"
}`}
className={clsx(
"absolute inset-0 pointer-events-none flex items-center px-1 text-white",
{
"justify-end": !isChatMode,
}
)}
>
{isChatMode ? (
<Bot className="size-4" />
) : (
<Search className="size-4" />
)}
</div>
<div
className={clsx(
"absolute top-px h-4.5 w-4.5 bg-white rounded-full shadow-md",
[isChatMode ? "right-px" : "left-px"]
)}
></div>
</div>
);

View File

@@ -1,33 +1,35 @@
import {
CheckboxProps as HeadlessCheckboxProps,
Checkbox as HeadlessCheckbox,
} from "@headlessui/react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import type { ComponentProps } from "react";
import clsx from "clsx";
import { CheckIcon } from "lucide-react";
interface CheckboxProps extends HeadlessCheckboxProps {
interface CheckboxProps
extends Omit<ComponentProps<typeof CheckboxPrimitive.Root>, "onCheckedChange" | "onChange"> {
indeterminate?: boolean;
onChange?: (checked: boolean) => void;
}
const Checkbox = (props: CheckboxProps) => {
const { indeterminate, className, ...rest } = props;
const { indeterminate, className, onChange, checked, ...rest } = props;
return (
<HeadlessCheckbox
<CheckboxPrimitive.Root
{...rest}
checked={checked}
onCheckedChange={(v) => onChange?.(v === true)}
className={clsx(
"group size-4 rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition cursor-pointer",
"group h-4 w-4 rounded-sm border border-black/30 dark:border-white/30 data-[state=checked]:bg-[#2F54EB] data-[state=checked]:border-[#2F54EB] transition cursor-pointer inline-flex items-center justify-center",
className
)}
>
{indeterminate && (
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
<div className="size-2 bg-[#2F54EB]"></div>
<div className="h-full w-full flex items-center justify-center group-data-[state=checked]:hidden">
<div className="h-2 w-2 bg-[#2F54EB]"></div>
</div>
)}
<CheckIcon className="hidden size-[14px] text-white group-data-[checked]:block" />
</HeadlessCheckbox>
<CheckIcon className="hidden h-[14px] w-[14px] text-white group-data-[state=checked]:block" />
</CheckboxPrimitive.Root>
);
};

View File

@@ -8,7 +8,7 @@ const Copyright = () => {
const renderLogo = () => {
return (
<a href="https://coco.rs/" target="_blank">
<img src={isDark ? logoDark : logoLight} alt="Logo" className="h-4" />
<img src={isDark ? logoDark : logoLight} alt="Logo" className="h-4!" />
</a>
);
};

View File

@@ -1,24 +1,20 @@
import {
Button,
ButtonProps,
Description,
Dialog,
DialogPanel,
DialogTitle,
} from "@headlessui/react";
import { FC, KeyboardEvent } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FC, KeyboardEvent, ComponentProps } from "react";
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
import VisibleKey from "./VisibleKey";
import { useTranslation } from "react-i18next";
type ShadButtonProps = ComponentProps<typeof Button>;
interface DeleteDialogProps {
isOpen: boolean;
title: string;
description: string;
deleteButtonProps?: ButtonProps;
cancelButtonProps?: ButtonProps;
deleteButtonProps?: ShadButtonProps;
cancelButtonProps?: ShadButtonProps;
reverseButtonPosition?: boolean;
setIsOpen: (isOpen: boolean) => void;
onCancel: () => void;
@@ -49,69 +45,60 @@ const DeleteDialog: FC<DeleteDialogProps> = (props) => {
};
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-1000"
>
<div
id="headlessui-popover-panel:delete-history"
className="fixed inset-0 flex items-center justify-center w-screen"
>
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-[0_4px_10px_rgba(0,0,0,0.2)] rounded-lg dark:shadow-[0_8px_20px_rgba(0,0,0,0.4)]">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold">{title}</DialogTitle>
<Description className="text-sm">{description}</Description>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-[0_4px_10px_rgba(0,0,0,0.2)] rounded-lg dark:shadow-[0_8px_20px_rgba(0,0,0,0.4)]">
<DialogHeader className="mb-2">
<DialogTitle className="text-base font-bold">{title}</DialogTitle>
<DialogDescription className="text-sm">{description}</DialogDescription>
</DialogHeader>
<div
className={clsx("flex gap-4 self-end", {
"flex-row-reverse": reverseButtonPosition,
})}
<div
className={clsx("flex gap-4 self-end", {
"flex-row-reverse": reverseButtonPosition,
})}
>
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={onCancel}
>
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={onCancel}
<Button
{...cancelButtonProps}
autoFocus
className={twMerge(
"h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg focus:border-black/30 dark:focus:border-white/50 transition",
cancelButtonProps?.className as string
)}
onClick={onCancel}
onKeyDown={(event) => {
handleEnter(event, onCancel);
}}
>
<Button
{...cancelButtonProps}
autoFocus
className={twMerge(
"h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg focus:border-black/30 dark:focus:border-white/50 transition",
cancelButtonProps?.className as string
)}
onClick={onCancel}
onKeyDown={(event) => {
handleEnter(event, onCancel);
}}
>
{t("deleteDialog.button.cancel")}
</Button>
</VisibleKey>
{t("deleteDialog.button.cancel")}
</Button>
</VisibleKey>
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={onDelete}
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={onDelete}
>
<Button
{...deleteButtonProps}
className={twMerge(
"h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg border border-[#EF4444] focus:border-black/30 dark:focus:border-white/50 transition",
deleteButtonProps?.className as string
)}
onClick={onDelete}
onKeyDown={(event) => {
handleEnter(event, onDelete);
}}
>
<Button
{...deleteButtonProps}
className={twMerge(
"h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg border border-[#EF4444] focus:border-black/30 dark:focus:border-white/50 transition",
deleteButtonProps?.className as string
)}
onClick={onDelete}
onKeyDown={(event) => {
handleEnter(event, onDelete);
}}
>
{t("deleteDialog.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogPanel>
</div>
{t("deleteDialog.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,10 +1,11 @@
import {
Button,
Description,
Dialog,
DialogPanel,
DialogContent,
DialogHeader,
DialogTitle,
} from "@headlessui/react";
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import VisibleKey from "@/components/Common/VisibleKey";
@@ -36,69 +37,63 @@ const DeleteDialog = ({
};
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-1000"
>
<div
id="headlessui-popover-panel:delete-history"
className="fixed inset-0 flex items-center justify-center w-screen"
>
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold">
{t("history_list.delete_modal.title")}
</DialogTitle>
<Description className="text-sm">
{t("history_list.delete_modal.description", {
replace: [
active?._source?.title ||
active?._source?.message ||
active?._id,
],
})}
</Description>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="flex flex-col justify-between w-[360px] h-40 p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
<DialogHeader className="mb-2">
<DialogTitle className="text-base font-bold">
{t("history_list.delete_modal.title")}
</DialogTitle>
<DialogDescription className="text-sm">
{t("history_list.delete_modal.description", {
replace: [
active?._source?.title ||
active?._source?.message ||
active?._id,
],
})}
</DialogDescription>
</DialogHeader>
<div className="flex gap-4 self-end">
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={() => setIsOpen(false)}
<div className="flex gap-4 self-end">
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={() => {
setIsOpen(false);
}}
>
<Button
variant="outline"
autoFocus
onClick={() => setIsOpen(false)}
onKeyDown={(event) => {
handleEnter(event, () => {
setIsOpen(false);
});
}}
>
<Button
autoFocus
className="h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg focus:border-black/30 dark:focus:border-white/50 transition"
onClick={() => setIsOpen(false)}
onKeyDown={(event) => {
handleEnter(event, () => {
setIsOpen(false);
});
}}
>
{t("history_list.delete_modal.button.cancel")}
</Button>
</VisibleKey>
{t("history_list.delete_modal.button.cancel")}
</Button>
</VisibleKey>
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={handleRemove}
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={handleRemove}
>
<Button
variant="destructive"
className="text-white"
onClick={handleRemove}
onKeyDown={(event) => {
handleEnter(event, handleRemove);
}}
>
<Button
className="h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg border border-[#EF4444] focus:border-black/30 dark:focus:border-white/50 transition"
onClick={handleRemove}
onKeyDown={(event) => {
handleEnter(event, handleRemove);
}}
>
{t("history_list.delete_modal.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogPanel>
</div>
{t("history_list.delete_modal.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -113,7 +113,8 @@ const HistoryListContent: FC<HistoryListContentProps> = ({
const scrollToElement = useCallback(
(elementId: string, isKeyboardNav: boolean) => {
if (!listRef.current) return;
if (typeof window === 'undefined' || typeof document === 'undefined') return;
if (typeof window === "undefined" || typeof document === "undefined")
return;
const element = listRef.current.querySelector(`#${elementId}`);
if (!element) return;
@@ -123,7 +124,7 @@ const HistoryListContent: FC<HistoryListContentProps> = ({
const isVisible =
rect.top >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight);
(window.innerHeight || document.documentElement.clientHeight);
// Only scroll if element is not visible
if (!isVisible) {

View File

@@ -1,10 +1,16 @@
import { FC, useRef, useCallback, useState } from "react";
import { Input, Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { FC, useRef, useCallback, useState, useEffect } from "react";
import { Ellipsis } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Pencil, Trash2 } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverPortal,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import type { Chat } from "@/types/chat";
import VisibleKey from "../VisibleKey";
@@ -31,9 +37,11 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
const moreButtonRef = useRef<HTMLButtonElement>(null);
const { _id, _source } = item;
const title = _source?.title ?? _id;
const isActive = item._id === active?._id || item._id === highlightId;
const isSelected = item._id === active?._id;
const isHovered = item._id === highlightId;
const [isEdit, setIsEdit] = useState(false);
const [open, setOpen] = useState(false);
const onContextMenu = useCallback(
(e: React.MouseEvent) => {
@@ -72,24 +80,34 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
},
];
useEffect(() => {
if (!(isSelected || isHovered) || isEdit) {
setOpen(false);
}
}, [isSelected, isHovered, isEdit]);
return (
<li
key={_id}
id={_id}
className={clsx(
"flex items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#353F4D] transition",
"group flex w-full items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#353F4D] transition-colors",
{
"!bg-[#E5E7EB] dark:!bg-[#2B3444]": isActive,
"bg-[#E5E7EB] dark:bg-[#2B3444]": isSelected,
"bg-[#EDEDED] dark:bg-[#353F4D]": isHovered && !isSelected,
}
)}
onClick={() => {
if (!isActive) {
if (!isSelected) {
setIsEdit(false);
}
onSelect(item);
}}
onMouseEnter={onMouseEnter}
onMouseLeave={() => {
setOpen(false);
}}
onContextMenu={onContextMenu}
>
<div
@@ -99,11 +117,11 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
/>
<div className="flex-1 flex items-center justify-between gap-2 px-2 overflow-hidden">
{isEdit && isActive ? (
{isEdit && isSelected ? (
<Input
autoFocus
defaultValue={title}
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-[4px]"
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-sm"
onKeyDown={(event) => {
if (event.key !== "Enter") return;
@@ -128,7 +146,7 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
)}
<div className="flex items-center gap-2">
{isActive && !isEdit && (
{!isEdit && isSelected && (
<VisibleKey
shortcut="↑↓"
rootClassName="w-6"
@@ -136,56 +154,73 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
/>
)}
<Popover>
{isActive && !isEdit && (
<PopoverButton ref={moreButtonRef} className="flex gap-2">
<VisibleKey
shortcut="O"
onKeyPress={() => {
moreButtonRef.current?.click();
}}
>
<Ellipsis className="size-4 text-[#979797]" />
</VisibleKey>
</PopoverButton>
)}
<PopoverPanel
anchor="bottom"
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10"
onClick={(event) => {
event.stopPropagation();
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
ref={moreButtonRef}
className={clsx("flex gap-2", {
"opacity-100 pointer-events-auto":
!isEdit && (isSelected || isHovered),
"opacity-0 pointer-events-none": !(
!isEdit &&
(isSelected || isHovered)
),
})}
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
>
{menuItems.map((menuItem) => {
const {
label,
icon: Icon,
shortcut,
iconColor,
onClick,
} = menuItem;
<VisibleKey
shortcut="O"
onKeyPress={() => {
moreButtonRef.current?.click();
}}
>
<Ellipsis className="size-4 text-[#979797]" />
</VisibleKey>
</PopoverTrigger>
return (
<button
key={label}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-[6px] hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
onClick={onClick}
>
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
<Icon
className="size-4"
style={{
color: iconColor,
}}
/>
</VisibleKey>
<PopoverPortal>
<PopoverContent
side="bottom"
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10"
onClick={(event) => {
event.stopPropagation();
}}
onMouseLeave={() => {
setOpen(false);
}}
>
{menuItems.map((menuItem) => {
const {
label,
icon: Icon,
shortcut,
iconColor,
onClick,
} = menuItem;
<span>{t(label)}</span>
</button>
);
})}
</PopoverPanel>
return (
<button
key={label}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
onClick={onClick}
>
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
<Icon
className="size-4"
style={{
color: iconColor,
}}
/>
</VisibleKey>
<span>{t(label)}</span>
</button>
);
})}
</PopoverContent>
</PopoverPortal>
</Popover>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Input } from "@headlessui/react";
import { Input } from "@/components/ui/input";
import { debounce } from "lodash-es";
import { FC, useMemo, useRef, useState } from "react";
import clsx from "clsx";
@@ -9,6 +9,7 @@ import VisibleKey from "../VisibleKey";
import { Chat } from "@/types/chat";
import { closeHistoryPanel } from "@/utils";
import HistoryListContent from "./HistoryListContent";
import { Button } from "@/components/ui/button";
interface HistoryListProps {
historyPanelId?: string;
@@ -57,21 +58,21 @@ const HistoryList: FC<HistoryListProps> = (props) => {
"flex flex-col h-screen text-sm bg-[#F3F4F6] dark:bg-[#1F2937]"
)}
>
<div className="flex gap-1 p-2 border-b dark:border-[#343D4D]">
<div className="flex-1 flex items-center gap-2 px-2 rounded-lg border transition border-[#E6E6E6] bg-[#F8F9FA] dark:bg-[#2B3444] dark:border-[#343D4D] focus-within:border-[#0061FF]">
<div className="flex gap-1 p-2 border-b border-input">
<div className="flex-1 h-8 flex items-center px-2 rounded-lg border border-input bg-background transition focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
<VisibleKey
shortcut="F"
onKeyPress={() => {
searchInputRef.current?.focus();
}}
>
<Search className="size-4 text-[#6B7280]" />
<Search className="size-4 text-muted-foreground" />
</VisibleKey>
<Input
autoFocus
ref={searchInputRef}
className="w-full bg-transparent outline-none"
className="w-full h-8 bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t("history_list.search.placeholder")}
onChange={(event) => {
debouncedSearch(event.target.value);
@@ -79,18 +80,20 @@ const HistoryList: FC<HistoryListProps> = (props) => {
/>
</div>
<div
className="size-8 flex items-center justify-center rounded-lg border text-[#0072FF] border-[#E6E6E6] bg-[#F3F4F6] dark:border-[#343D4D] dark:bg-[#1F2937] hover:bg-[#F8F9FA] dark:hover:bg-[#353F4D] cursor-pointer transition"
<Button
variant="outline"
size="icon"
className="size-8"
onClick={handleRefresh}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCcw
className={clsx("size-4", {
className={clsx("size-4 text-[#0287FF]", {
"animate-spin": isRefresh,
})}
/>
</VisibleKey>
</div>
</Button>
</div>
<div className="flex-1 px-2 overflow-auto custom-scrollbar">
@@ -104,10 +107,10 @@ const HistoryList: FC<HistoryListProps> = (props) => {
</div>
{historyPanelId && (
<div className="flex justify-end p-2 border-t dark:border-[#343D4D]">
<div className="flex justify-end p-2 border-t border-input">
<VisibleKey shortcut="Esc" shortcutClassName="w-7">
<PanelLeftClose
className="size-4 text-black/80 dark:text-white/80 cursor-pointer"
className="size-4 text-muted-foreground cursor-pointer"
onClick={closeHistoryPanel}
/>
</VisibleKey>

View File

@@ -41,7 +41,7 @@ function UniversalIcon({
icon,
defaultIcon = File,
appIcon = false,
className = "w-5 h-5 flex-shrink-0",
className = "w-5 h-5 shrink-0",
onClick = () => {},
wrapWithIconWrapper = true,
}: UniversalIconProps) {

View File

@@ -1,6 +1,7 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import VisibleKey from "./VisibleKey";
import { cn } from "@/lib/utils";
interface PaginationProps {
current: number;
@@ -19,10 +20,15 @@ function Pagination({
}: PaginationProps) {
return (
<div
className={`flex items-center justify-between h-8 px-3 text-[#999] border-t dark:border-t-white/10 ${className}`}
className={`flex items-center justify-between h-8 px-2 text-muted-foreground border-t border-input ${className}`}
>
<VisibleKey shortcut="leftarrow" onKeyPress={onPrev}>
<ChevronLeft className="size-4 cursor-pointer" onClick={onPrev} />
<ChevronLeft
className={cn("size-4 cursor-pointer", {
"cursor-not-allowed opacity-50": current === 1,
})}
onClick={onPrev}
/>
</VisibleKey>
<div className="text-xs">
@@ -30,7 +36,12 @@ function Pagination({
</div>
<VisibleKey shortcut="rightarrow" onKeyPress={onNext}>
<ChevronRight className="size-4 cursor-pointer" onClick={onNext} />
<ChevronRight
className={cn("size-4 cursor-pointer", {
"cursor-not-allowed opacity-50": current === totalPage,
})}
onClick={onNext}
/>
</VisibleKey>
</div>
);

View File

@@ -1,4 +1,5 @@
import { Input, InputProps } from "@headlessui/react";
import type { InputProps } from "@/components/ui/input";
import { Input } from "@/components/ui/input";
import { useKeyPress } from "ahooks";
import { forwardRef, useImperativeHandle, useRef } from "react";
@@ -29,7 +30,7 @@ const PopoverInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
}
);
return <Input autoCorrect="off" ref={inputRef} {...props} />;
return <Input autoCorrect="off" ref={inputRef} {...(props as any)} />;
});
export default PopoverInput;

View File

@@ -1,20 +1,20 @@
import { RefObject } from "react";
import clsx from "clsx";
import { ArrowDown } from "lucide-react";
import { Button } from "../ui/button";
interface ScrollToBottomProps {
scrollRef: RefObject<HTMLDivElement>;
isAtBottom: boolean;
}
const ScrollToBottom = ({
scrollRef,
isAtBottom,
}: ScrollToBottomProps) => {
const ScrollToBottom = ({ scrollRef, isAtBottom }: ScrollToBottomProps) => {
return (
<button
<Button
size="icon"
variant="outline"
className={clsx(
"absolute right-4 bottom-4 flex items-center justify-center size-8 border bg-white rounded-full shadow dark:border-[#272828] dark:bg-black dark:shadow-white/15",
"absolute right-4 bottom-4 border border-border rounded-full shadow dark:shadow-white/15",
{
hidden: isAtBottom,
}
@@ -27,7 +27,7 @@ const ScrollToBottom = ({
}}
>
<ArrowDown className="size-5" />
</button>
</Button>
);
};

View File

@@ -1,40 +1,38 @@
import {
Popover,
PopoverButton,
PopoverPanel,
PopoverPanelProps,
} from "@headlessui/react";
import { FC, ReactNode } from "react";
import { useBoolean } from "ahooks";
import clsx from "clsx";
import { FC, ReactNode } from "react";
interface Tooltip2Props extends PopoverPanelProps {
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
interface Tooltip2Props {
content: string;
children: ReactNode;
className?: string;
}
const Tooltip2: FC<Tooltip2Props> = (props) => {
const { content, children, anchor = "top", ...rest } = props;
const { content, children, className } = props;
const [visible, { setTrue, setFalse }] = useBoolean(false);
return (
<Popover>
<PopoverButton onMouseOver={setTrue} onMouseOut={setFalse}>
<PopoverTrigger onMouseOver={setTrue} onMouseOut={setFalse}>
{children}
</PopoverButton>
<PopoverPanel
{...rest}
static
anchor={anchor}
</PopoverTrigger>
<PopoverContent
side="top"
className={clsx(
"fixed z-1000 p-2 rounded-[6px] text-xs text-white bg-black/75 hidden",
"z-1000 p-2 rounded-md text-xs text-white bg-black/75 hidden",
{
"!block": visible,
}
block: visible,
},
className
)}
>
{content}
</PopoverPanel>
</PopoverContent>
</Popover>
);
};

View File

@@ -71,7 +71,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
<div className="flex items-center gap-2">
<img
src={selectedExtension.icon}
className="size-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
className="h-5 w-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
/>
<span className="text-sm">{selectedExtension.name}</span>
</div>
@@ -81,7 +81,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
if (visibleExtensionStore) {
return (
<div className="flex items-center gap-2">
<FontIcon name="font_Store" className="size-5" />
<FontIcon name="font_Store" className="h-5 w-5" />
<span className="text-sm">Extension Store</span>
</div>
);
@@ -100,7 +100,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
{hasUpdate ? (
<div className="cursor-pointer" onClick={() => setVisible(true)}>
<span>{t("search.footer.updateAvailable")}</span>
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
<span className="absolute top-0 -right-2 h-1.5 w-1.5 bg-[#FF3434] rounded-full"></span>
</div>
) : (
sourceData?.source?.name ||
@@ -117,7 +117,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
<div
data-tauri-drag-region={isTauri}
className={clsx(
"px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-[6px] rounded-t-none",
"px-4 z-999 mx-px h-8 absolute bottom-0 left-0 right-0 border-t! border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-md rounded-t-none",
{
"overflow-hidden": isTauri,
}
@@ -137,7 +137,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
</div>
</div>
) : (
<WebLogin panelClassName="bottom-5 left-0" />
<WebLogin side="top" align="start" />
)}
<div className={`flex mobile:hidden items-center gap-3`}>

View File

@@ -37,7 +37,7 @@ export const NoResults = () => {
<div className="flex gap-2">
<WebLoginButton />
<WebRefreshButton className="size-8" />
<WebRefreshButton />
</div>
</div>
);
@@ -54,7 +54,7 @@ export const NoResults = () => {
<span
className={clsx(
"ml-3 h-5 min-w-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center",
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
{
"px-1": !isMac,
}
@@ -63,7 +63,7 @@ export const NoResults = () => {
{formatKey(modifierKey)}
</span>
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<span className="ml-1 w-5 h-5 rounded-md border border-[#D8D8D8] flex justify-center items-center">
{modeSwitch}
</span>
</div>

View File

@@ -1,13 +1,16 @@
import { Menu, MenuButton } from "@headlessui/react";
import {
DropdownMenu,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import logoImg from "@/assets/icon.svg";
const Footer = () => {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-white/10">
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
<Menu as="div" className="relative">
<MenuButton className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<DropdownMenu>
<DropdownMenuTrigger className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<img
src={logoImg}
className="w-5 h-5 text-gray-600 dark:text-gray-400"
@@ -16,7 +19,7 @@ const Footer = () => {
Coco
</span>
{/* <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" /> */}
</MenuButton>
</DropdownMenuTrigger>
{/* <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="p-1">
@@ -27,7 +30,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<Home className="w-4 h-4 mr-2" />
<Link to={`/`}>Home</Link>
@@ -41,7 +44,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<User className="w-4 h-4 mr-2" />
Profile
@@ -55,7 +58,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<Settings className="w-4 h-4 mr-2" />
<Link to={`settings`}>Settings</Link>
@@ -70,7 +73,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
@@ -79,7 +82,7 @@ const Footer = () => {
</MenuItem>
</div>
</MenuItems> */}
</Menu>
</DropdownMenu>
<div className="flex items-center space-x-4">
<span className="text-xs text-gray-500 dark:text-gray-400">

View File

@@ -111,7 +111,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
{showTooltip && visibleShortcut ? (
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-[6px] shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
shortcutClassName
)}
>

View File

@@ -5,6 +5,7 @@ import clsx from "clsx";
import { useStreamChat } from "@/hooks/useStreamChat";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { ChatMessage } from "../ChatMessage";
import { Button } from "../ui/button";
interface AiSummaryProps {
message: string;
@@ -33,20 +34,22 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
<div className={clsx({ "p-2": visible })}>
<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)]",
"flex flex-col gap-2 relative max-h-[210px] px-4 py-3 rounded 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: !visible,
}
)}
>
<div
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-[6px] cursor-pointer dark:border-[#282828]"
<Button
size="icon"
variant="outline"
className="absolute top-2 right-2 size-5"
onClick={() => {
setVisible(false);
}}
>
<X className="size-4" />
</div>
<X className="size-3" />
</Button>
<div className="flex item-center gap-1">
<Sparkles className="size-4 text-[#881c94]" />
@@ -77,7 +80,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
</div>
<div
className={clsx("min-h-[20px]", {
className={clsx("min-h-5", {
hidden: isTyping,
})}
/>

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { useAppStore } from "@/stores/appStore";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { useBoolean } from "ahooks";
@@ -37,6 +38,7 @@ const AutoResizeTextarea = forwardRef<
setInput,
handleKeyDown,
chatPlaceholder,
lineCount,
onLineCountChange,
firstLineMaxWidth,
},
@@ -79,8 +81,10 @@ const AutoResizeTextarea = forwardRef<
let height = lineHeight;
let minHeight = lineHeight;
const hasNewline = /[\r\n]/.test(input);
const hasContent = input.length > 0;
const firstLineExceeds =
calcRef.current?.offsetWidth >= firstLineMaxWidth - 32;
hasContent &&
(calcRef.current?.offsetWidth ?? 0) >= Math.max(firstLineMaxWidth - 32, 0);
if (hasNewline || firstLineExceeds) {
minHeight = lineHeight * 2;
@@ -115,7 +119,12 @@ const AutoResizeTextarea = forwardRef<
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="auto-resize-textarea 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 resize-none overflow-y-auto"
className={cn(
"auto-resize-textarea 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 resize-none overflow-y-auto",
{
"overflow-y-hidden": lineCount === 1,
}
)}
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
aria-label={t("search.textarea.ariaLabel")}
value={input}

View File

@@ -12,7 +12,6 @@ import {
} from "lucide-react";
import { cloneElement, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@headlessui/react";
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore";
@@ -292,9 +291,9 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
ref={containerRef}
id={visibleContextMenu ? CONTEXT_MENU_PANEL_ID : ""}
className={clsx(
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg shadow-xs border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
{
"!scale-100": visibleContextMenu,
"scale-100": visibleContextMenu,
}
)}
>
@@ -329,12 +328,12 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
<span style={{ color }}>{name}</span>
</div>
<div className="flex gap-[4px] text-black/60 dark:text-white/60">
<div className="flex gap-1 text-black/60 dark:text-white/60">
{keys.map((key) => (
<kbd
key={key}
className={clsx(
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-[6px] border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
"flex justify-center items-center font-sans h-5 min-w-5 text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
{
"px-1": key.length > 1,
}
@@ -363,7 +362,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
searchInputRef.current?.focus();
}}
>
<Input
<input
ref={searchInputRef}
autoFocus
autoCorrect="off"

View File

@@ -77,7 +77,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
{/* Document Summary */}
{document?.summary && (
<div className="mb-4 text-xs text-[rgba(153,153,153,1)] dark:text-[#D8D8D8] whitespace-pre-wrap break-words">
<div className="mb-4 text-xs text-[rgba(153,153,153,1)] dark:text-[#D8D8D8] whitespace-pre-wrap wrap-break-word">
{document.summary}
</div>
)}

View File

@@ -88,6 +88,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
rich_category: sourceData?.rich_categories[0]?.key,
};
}
if (sourceData?.main_extension_id) {
queryStrings.main_extension_id = sourceData?.main_extension_id
}
let response: any;
if (isTauri) {
@@ -178,7 +181,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
{
target: containerRef,
isNoMore: (d) => !d?.hasMore,
reloadDeps: [input?.trim(), JSON.stringify(sourceData)],
reloadDeps: [input, JSON.stringify(sourceData)],
onFinally: (data) => {
if (data?.page === 1) return;
if (selectedItem === null) return;
@@ -211,7 +214,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
list: [],
}));
loadingFromRef.current = -1;
}, [input]);
}, [input, JSON.stringify(sourceData)]);
const { visibleContextMenu } = useSearchStore();
@@ -292,10 +295,10 @@ export const DocumentList: React.FC<DocumentListProps> = ({
return (
<div
className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full overflow-x-hidden ${
viewMode === "list" ? "w-[100%]" : "w-[50%]"
viewMode === "list" ? "w-full" : "w-[50%]"
}`}
>
<div className="px-2 flex-shrink-0">
<div className="px-2 shrink-0">
<SearchHeader
total={total}
viewMode={viewMode}
@@ -306,6 +309,10 @@ export const DocumentList: React.FC<DocumentListProps> = ({
<Scrollbar className="flex-1 overflow-auto pr-0.5" ref={containerRef}>
{data?.list && data.list.length > 0 && (
<div>
{(() => {
console.log("Rendering list with items:", data.list.length);
return null;
})()}
{data.list.map((hit, index) => (
<SearchListItem
key={hit.document.id + index}

View File

@@ -46,8 +46,8 @@ const DropdownListItem = memo(
aria-selected={isSelected}
id={`search-item-${currentIndex}`}
className={clsx("p-2 transition rounded-lg", {
"bg-[#EDEDED] dark:bg-[#202126]": isSelected,
"!p-0": isAiOverview,
"bg-muted": isSelected,
"p-0!": isAiOverview,
})}
>
{isCalculator && <Calculator item={item} isSelected={isSelected} />}

View File

@@ -1,4 +1,4 @@
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import dayjs from "dayjs";
import {
CircleCheck,
@@ -8,6 +8,7 @@ import {
Loader,
Trash2,
User,
SquareArrowOutUpRight,
} from "lucide-react";
import { FC, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -15,15 +16,24 @@ import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore";
import DeleteDialog from "../Common/DeleteDialog";
import PreviewImage from "../Common/PreviewImage";
import platformAdapter from "@/utils/platformAdapter";
interface ExtensionDetailProps {
onInstall: () => void;
onUninstall: () => void;
changeInput: (value: string) => void;
}
const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
const { onInstall, onUninstall } = props;
const { selectedExtension, installingExtensions } = useSearchStore();
const { onInstall, onUninstall, changeInput } = props;
const {
selectedExtension,
installingExtensions,
setVisibleExtensionStore,
setVisibleExtensionDetail,
setSourceData,
setSearchValue,
} = useSearchStore();
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
@@ -37,6 +47,53 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
setIsOpen(false);
};
const extensionOpen = (item: any) => {
setSourceData({
source: {
name: item.name,
icon: item.icon,
},
querySource: {
id: "extensions",
},
main_extension_id: item.id,
});
setSearchValue(item.name || "");
};
const otherExtensionOpen = (item: any) => {
changeInput("");
//
const extension = { ...item };
let developerId = extension.developer.id;
let extensionId = extension.id;
const bundleId = {
developer: developerId,
extension_id: extensionId,
sub_extension_id: null,
};
platformAdapter.invokeBackend("open_third_party_extension", {
bundleId,
});
};
const handleOpen = async (item: any) => {
if (!item) return;
// close extension store
setVisibleExtensionStore(false);
setVisibleExtensionDetail(false);
//
if (item.type === "group" || item.type === "extension") {
extensionOpen(item);
} else {
otherExtensionOpen(item);
}
};
const renderDivider = () => {
return <div className="my-4 h-px bg-[#E6E6E6] dark:bg-[#262626]"></div>;
};
@@ -69,13 +126,19 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
<div className="pt-2">
{selectedExtension.installed ? (
<div className="flex items-center gap-2">
<Trash2
className="size-4 text-red-500 cursor-pointer"
onClick={() => {
setIsOpen(true);
}}
/>
<div className="flex items-center gap-1 h-6 px-2 rounded-full text-[#22C461] bg-[#22C461]/20">
<Button
className="flex justify-center items-center h-6 px-3 rounded-full bg-[#007BFF] hover:bg-[#007BFF] text-white ring-0 ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 outline-none"
onClick={() => handleOpen(selectedExtension)}
>
<SquareArrowOutUpRight className="size-4" />
</Button>
<Button
className="flex justify-center items-center h-6 px-3 rounded-full bg-[#FFE2E2] hover:bg-[#FFE2E2] text-red-500 ring-0 ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 outline-none"
onClick={() => setIsOpen(true)}
>
<Trash2 className="size-4" />
</Button>
<div className="flex items-center gap-1 h-6 px-2 rounded-full text-[#999999] bg-[#E6E6E6]">
<CircleCheck className="size-4" />
<span>{t("extensionDetail.hints.installed")}</span>
</div>
@@ -170,8 +233,9 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
<DeleteDialog
reverseButtonPosition
isOpen={isOpen}
title={`${t("extensionDetail.deleteDialog.title")} ${selectedExtension.name
}`}
title={`${t("extensionDetail.deleteDialog.title")} ${
selectedExtension.name
}`}
description={t("extensionDetail.deleteDialog.description")}
cancelButtonProps={{
className:

View File

@@ -73,7 +73,13 @@ export interface SearchExtensionItem {
}>;
}
const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
const ExtensionStore = ({
extensionId,
changeInput,
}: {
extensionId?: string;
changeInput: (value: string) => void;
}) => {
const {
searchValue,
selectedExtension,
@@ -244,7 +250,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
"info"
);
} catch (error) {
installExtensionError(String(error));
installExtensionError(error);
} finally {
const { installingExtensions } = useSearchStore.getState();
@@ -295,6 +301,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
<ExtensionDetail
onInstall={handleInstall}
onUninstall={handleUnInstall}
changeInput={changeInput}
/>
) : (
<>
@@ -306,7 +313,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
<div
key={id}
className={clsx(
"flex justify-between gap-4 h-[40px] px-2 rounded-lg cursor-pointer text-[#333] dark:text-[#d8d8d8] transition",
"flex justify-between gap-4 h-10 px-2 rounded-lg cursor-pointer text-[#333] dark:text-[#d8d8d8] transition",
{
"bg-black/10 dark:bg-white/15":
selectedExtension?.id === id,
@@ -325,7 +332,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
}}
>
<div className="flex items-center gap-2 overflow-hidden">
<img src={icon} className="size-[20px]" />
<img src={icon} className="size-5" />
<span className="whitespace-nowrap">{name}</span>
<span className="truncate text-[#999]">{description}</span>
</div>

View File

@@ -252,7 +252,7 @@ export default function ChatInput({
replace: [akiAiTooltipPrefix, askAI.name],
})}
</span>
<div className="flex items-center justify-center px-1 h-[20px] text-xs rounded-[6px] border border-black/10 dark:border-[#545454]">
<div className="flex items-center justify-center px-1 h-5 text-xs rounded-md border border-black/10 dark:border-[#545454]">
{formatKey(modifierKey)} + {formatKey("Enter")}
</div>
</div>
@@ -276,8 +276,8 @@ export default function ChatInput({
return (
<VisibleKey
shortcut={returnToInput}
rootClassName="flex-1 flex items-center justify-center"
shortcutClassName="!left-0 !translate-x-0"
rootClassName="flex-1 flex items-center justify-center w-full"
shortcutClassName="!left-auto !right-2 !translate-x-0"
>
<AutoResizeTextarea
ref={textareaRef}
@@ -308,14 +308,13 @@ export default function ChatInput({
<div className={`w-full relative`}>
<div
ref={containerRef}
className={`flex items-center dark:text-[#D8D8D8] rounded-[6px] transition-all relative overflow-hidden`}
className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`}
>
{lineCount === 1 && renderSearchIcon()}
{visibleSearchBar() && (
<div
className={clsx(
"relative w-full p-2 bg-[#ededed] dark:bg-[#202126]",
"min-h-10 w-full p-[7px] bg-[#ededed] dark:bg-[#202126]",
{
"flex items-center gap-2": lineCount === 1,
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Brain, Sparkles } from "lucide-react";
import { Brain, RotateCcw, ScanSearch, Sparkles } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@@ -12,11 +12,13 @@ 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 { DEFAULT_FUZZINESS, useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
import InputUpload from "./InputUpload";
import Copyright from "../Common/Copyright";
import TimeFilter from "./TimeFilter";
import { Slider } from "../ui/slider";
interface InputControlsProps {
isChatMode: boolean;
@@ -161,7 +163,13 @@ const InputControls = ({
return state.aiOverviewAssistant;
});
const aiOverviewShortcut = useShortcutsStore((state) => state.aiOverview);
const { visibleExtensionStore } = useSearchStore();
const {
visibleExtensionStore,
enabledFuzzyMatch,
setEnabledFuzzyMatch,
fuzziness,
setFuzziness,
} = useSearchStore();
return (
<div
@@ -187,9 +195,9 @@ const InputControls = ({
{source?.type === "deep_think" && source?.config?.visible && (
<button
className={clsx(
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
"flex items-center justify-center gap-1 h-5 px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
"bg-[rgba(0,114,255,0.3)]!": isDeepThinkActive,
}
)}
onClick={setIsDeepThinkActive}
@@ -242,7 +250,10 @@ const InputControls = ({
)}
</div>
) : (
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
<div
data-tauri-drag-region
className="w-28 flex gap-2 items-center relative"
>
{!disabledExtensions.includes("AIOverview") &&
isTauri &&
aiOverviewServer &&
@@ -250,7 +261,7 @@ const InputControls = ({
!visibleExtensionStore && (
<div
className={clsx(
"inline-flex items-center gap-1 h-[20px] px-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
"inline-flex items-center gap-1 h-5 px-1 rounded-full hover:text-[#881c94]! cursor-pointer transition",
[
enabledAiOverview
? "text-[#881c94]"
@@ -274,12 +285,74 @@ const InputControls = ({
</VisibleKey>
<span
className={clsx("text-xs", { hidden: !enabledAiOverview })}
className={clsx("text-xs truncate", {
hidden: !enabledAiOverview,
})}
>
AI Overview
</span>
</div>
)}
{/* app search filter */}
{isTauri && (
<>
<div
className={clsx(
"inline-flex items-center gap-1 h-5 px-1 rounded-full hover:text-[#881c94]! cursor-pointer transition",
[
enabledFuzzyMatch
? "text-[#881c94]"
: "text-[#333] dark:text-[#d8d8d8]",
],
{
"bg-[#881C94]/20 dark:bg-[#202126]": enabledFuzzyMatch,
}
)}
onClick={() => {
setEnabledFuzzyMatch(!enabledFuzzyMatch);
}}
>
<ScanSearch className="size-3" />
{enabledFuzzyMatch && (
<>
<span className={clsx("text-xs truncate")}>
{t("search.fuzziness.fuzzyMatch")}
</span>
<Slider
value={[fuzziness]}
max={5}
className="w-20"
classNames={{
range: "bg-[#881C94]",
thumb:
"border-[#881C94] focus-visible:ring-0 focus-visible:ring-offset-0",
}}
onValueChange={(value) => {
setFuzziness(value[0]);
}}
onClick={(event) => {
event.stopPropagation();
}}
/>
<RotateCcw
className="size-3"
onClick={(event) => {
event.stopPropagation();
setFuzziness(DEFAULT_FUZZINESS);
}}
/>
</>
)}
</div>
<TimeFilter />
</>
)}
</div>
)}

View File

@@ -2,14 +2,16 @@ import { FC, Fragment, MouseEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Plus } from "lucide-react";
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { castArray, find, isNil } from "lodash-es";
import { nanoid } from "nanoid";
import { useCreation, useMount, useReactive } from "ahooks";
@@ -198,8 +200,8 @@ const InputUpload: FC<InputUploadProps> = (props) => {
]);
return (
<Menu>
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip
content={t("search.input.uploadFileHints.tooltip", {
replace: [
@@ -212,32 +214,41 @@ const InputUpload: FC<InputUploadProps> = (props) => {
<Plus className="size-3 scale-[1.3]" />
</VisibleKey>
</Tooltip>
</MenuButton>
</DropdownMenuTrigger>
<MenuItems
anchor="bottom start"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<DropdownMenuContent
side="bottom"
align="start"
className="p-1 text-sm rounded-lg"
>
{menuItems.map((item) => {
const { label, children, clickEvent } = item;
return (
<MenuItem key={label}>
<DropdownMenuItem
key={label}
onSelect={(e: Event) => {
if (children) e.preventDefault();
}}
className="px-0 py-0"
>
{children ? (
<Popover>
<PopoverButton
className="flex items-center justify-between gap-2 px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={clickEvent}
>
<span>{label}</span>
<PopoverTrigger asChild>
<div
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted cursor-pointer"
onClick={clickEvent}
>
<span>{label}</span>
<ChevronRight className="size-4" />
</PopoverButton>
<ChevronRight className="size-4" />
</div>
</PopoverTrigger>
<PopoverPanel
transition
anchor="right"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<PopoverContent
side="right"
align="start"
className="p-1 text-sm rounded-lg"
>
{children.map((childItem) => {
const { groupName, groupItems } = childItem;
@@ -259,7 +270,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
return (
<div
key={id}
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
className="px-3 py-2 rounded-lg hover:bg-muted cursor-pointer"
onClick={clickEvent}
>
{label}
@@ -269,21 +280,21 @@ const InputUpload: FC<InputUploadProps> = (props) => {
</Fragment>
);
})}
</PopoverPanel>
</PopoverContent>
</Popover>
) : (
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
className="px-3 py-2 rounded-lg hover:bg-muted cursor-pointer"
onClick={clickEvent}
>
{label}
</div>
)}
</MenuItem>
</DropdownMenuItem>
);
})}
</MenuItems>
</Menu>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,10 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { ChevronDownIcon, RefreshCw, Layers, Hammer } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useDebounce } from "ahooks";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import CommonIcon from "@/components/Common/Icons/CommonIcon";
import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore";
@@ -16,6 +20,7 @@ import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { SearchQuery } from "@/utils";
import { Button } from "../ui/button";
interface MCPPopoverProps {
mcp_servers: any;
@@ -79,6 +84,7 @@ export default function MCPPopover({
}, [currentService?.id, debouncedKeyword, getMCPByServer]);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const mcpSearch = useShortcutsStore((state) => state.mcpSearch);
const mcpSearchScope = useShortcutsStore((state) => {
return state.mcpSearchScope;
@@ -166,9 +172,9 @@ export default function MCPPopover({
return (
<div
className={clsx(
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-5 px-1 rounded-md transition cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
"bg-[rgba(0,114,255,0.3)]!": isMCPActive,
}
)}
onClick={setIsMCPActive}
@@ -191,8 +197,14 @@ export default function MCPPopover({
{t("search.input.MCP")}
</span>
<Popover className="relative">
<PopoverButton ref={popoverButtonRef} className="flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
ref={popoverButtonRef}
className="flex items-center"
onClick={(e) => {
e.stopPropagation();
}}
>
<VisibleKey
shortcut={mcpSearchScope}
onKeyPress={() => {
@@ -200,29 +212,35 @@ export default function MCPPopover({
}}
>
<ChevronDownIcon
className={clsx("size-3", [
className={clsx("size-3 cursor-pointer", [
isMCPActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white",
])}
/>
</VisibleKey>
</PopoverButton>
</PopoverTrigger>
<PopoverPanel className="absolute z-50 left-0 bottom-6 w-[240px] overflow-y-auto bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<PopoverContent
side="top"
align="start"
className="z-50 w-60 overflow-y-auto rounded-lg shadow-lg p-0"
>
<div
className="text-sm"
onClick={(e) => {
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
}}
>
<div className="p-3">
<div className="p-2">
<div className="flex justify-between">
<span>{t("search.input.searchPopover.title")}</span>
<div
<Button
variant="outline"
size="icon"
className="size-6"
onClick={handleRefresh}
className="size-[24px] flex justify-center items-center rounded-lg border border-black/10 dark:border-white/10 cursor-pointer"
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
@@ -231,7 +249,7 @@ export default function MCPPopover({
}`}
/>
</VisibleKey>
</div>
</Button>
</div>
<div className="relative h-8 my-2">
@@ -250,7 +268,7 @@ export default function MCPPopover({
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"
onChange={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value);
}}
/>
@@ -280,7 +298,7 @@ export default function MCPPopover({
>
<div className="flex items-center gap-2 overflow-hidden">
{isAll ? (
<Layers className="size-[16px] text-[#0287FF]" />
<Layers className="min-w-4 min-h-4 size-4 text-[#0287FF]" />
) : (
<CommonIcon
item={item}
@@ -290,7 +308,7 @@ export default function MCPPopover({
"default_icon",
]}
itemIcon={item.icon}
className="size-4"
className="min-w-4 min-h-4 size-4"
/>
)}
@@ -308,7 +326,7 @@ export default function MCPPopover({
}}
/>
<div className="flex justify-center items-center size-[24px]">
<div className="flex justify-center items-center size-6">
<Checkbox
checked={isChecked()}
indeterminate={isAll}
@@ -339,7 +357,7 @@ export default function MCPPopover({
/>
)}
</div>
</PopoverPanel>
</PopoverContent>
</Popover>
</>
)}

View File

@@ -1,4 +1,5 @@
import { useEffect, memo, useRef, useCallback, useState } from "react";
import clsx from "clsx";
import DropdownList from "./DropdownList";
import { SearchResults } from "@/components/Search/SearchResults";
@@ -12,7 +13,6 @@ import ExtensionStore from "./ExtensionStore";
import platformAdapter from "@/utils/platformAdapter";
import ViewExtension from "./ViewExtension";
import { visibleFooterBar } from "@/utils";
import clsx from "clsx";
const SearchResultsPanel = memo<{
input: string;
@@ -124,7 +124,9 @@ const SearchResultsPanel = memo<{
// If state gets updated, render the UI
if (visibleExtensionStore) {
return <ExtensionStore extensionId={extensionId} />;
return (
<ExtensionStore extensionId={extensionId} changeInput={changeInput} />
);
}
// Render the view extension
@@ -133,11 +135,14 @@ const SearchResultsPanel = memo<{
}
if (goAskAi) return <AskAi isChatMode={isChatMode} />;
if (sourceData) {
return <SearchResults input={input} isChatMode={isChatMode} />;
}
if (suggests.length === 0) return <NoResults />;
return sourceData ? (
<SearchResults input={input} isChatMode={isChatMode} />
) : (
return (
<DropdownList
suggests={suggests}
searchData={searchData}

View File

@@ -1,15 +1,16 @@
import { useSearchStore } from "@/stores/searchStore";
import { ChevronLeft, Search } from "lucide-react";
import { FC } from "react";
import clsx from "clsx";
import FontIcon from "@/components/Common/Icons/FontIcon";
import { FC } from "react";
import lightDefaultIcon from "@/assets/images/source_default.png";
import darkDefaultIcon from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import { navigateBack, visibleSearchBar } from "@/utils";
import VisibleKey from "../Common/VisibleKey";
import clsx from "clsx";
import { cn } from "@/lib/utils";
interface MultilevelWrapperProps {
title?: string;
@@ -36,7 +37,7 @@ const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
<div
data-tauri-drag-region
className={clsx(
"flex items-center h-[40px] gap-1 px-2 border border-[#EDEDED] dark:border-[#202126] rounded-l-lg",
"flex items-center h-10 gap-1 px-2 border border-(--border) rounded-l-lg",
{
"justify-center": visibleSearchBar(),
"w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(),
@@ -50,7 +51,7 @@ const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
/>
</VisibleKey>
<div className="size-5 [&>*]:size-full">{renderIcon()}</div>
<div className="size-5 *:size-full">{renderIcon()}</div>
<span className="text-sm whitespace-nowrap">{title}</span>
</div>
@@ -115,7 +116,14 @@ export default function SearchIcons({
}
return (
<div className="flex items-center justify-center pl-2 h-[40px] bg-[#ededed] dark:bg-[#202126]">
<div
className={cn(
"flex items-center justify-center bg-[#ededed] dark:bg-[#202126]",
{
"pl-2 h-10": lineCount === 1,
}
)}
>
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />
</div>
);

View File

@@ -1,10 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { ChevronDownIcon, RefreshCw, Layers, Globe } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useDebounce } from "ahooks";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import CommonIcon from "@/components/Common/Icons/CommonIcon";
import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore";
@@ -15,6 +19,7 @@ import VisibleKey from "@/components/Common/VisibleKey";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { Button } from "../ui/button";
interface SearchPopoverProps {
datasource: any;
@@ -85,6 +90,7 @@ export default function SearchPopover({
}, [currentService?.id, debouncedKeyword, getDataSourcesByServer]);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const internetSearch = useShortcutsStore((state) => state.internetSearch);
const internetSearchScope = useShortcutsStore((state) => {
return state.internetSearchScope;
@@ -172,9 +178,9 @@ export default function SearchPopover({
return (
<div
className={clsx(
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-5 px-1 rounded-md transition cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
"bg-[rgba(0,114,255,0.3)]!": isSearchActive,
}
)}
onClick={setIsSearchActive}
@@ -199,8 +205,14 @@ export default function SearchPopover({
{t("search.input.search")}
</span>
<Popover className="relative">
<PopoverButton ref={popoverButtonRef} className="flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
ref={popoverButtonRef}
className="flex items-center"
onClick={(e) => {
e.stopPropagation();
}}
>
<VisibleKey
shortcut={internetSearchScope}
onKeyPress={() => {
@@ -208,29 +220,35 @@ export default function SearchPopover({
}}
>
<ChevronDownIcon
className={clsx("size-3", [
className={clsx("size-3 cursor-pointer", [
isSearchActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white",
])}
/>
</VisibleKey>
</PopoverButton>
</PopoverTrigger>
<PopoverPanel className="absolute z-50 left-0 bottom-6 w-[240px] overflow-y-auto bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<PopoverContent
side="top"
align="start"
className="z-50 w-60 overflow-y-auto rounded-lg shadow-lg p-0"
>
<div
className="text-sm"
onClick={(e) => {
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
}}
>
<div className="p-3">
<div className="p-2">
<div className="flex justify-between">
<span>{t("search.input.searchPopover.title")}</span>
<div
<Button
variant="outline"
size="icon"
className="size-6"
onClick={handleRefresh}
className="size-[24px] flex justify-center items-center rounded-lg border border-black/10 dark:border-white/10 cursor-pointer"
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
@@ -239,7 +257,7 @@ export default function SearchPopover({
}`}
/>
</VisibleKey>
</div>
</Button>
</div>
<div className="relative h-8 my-2">
@@ -258,7 +276,7 @@ export default function SearchPopover({
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"
onChange={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value);
}}
/>
@@ -288,7 +306,7 @@ export default function SearchPopover({
>
<div className="flex items-center gap-2 overflow-hidden">
{isAll ? (
<Layers className="size-[16px] text-[#0287FF]" />
<Layers className="size-4 text-[#0287FF]" />
) : (
<CommonIcon
item={item}
@@ -316,7 +334,7 @@ export default function SearchPopover({
}}
/>
<div className="flex justify-center items-center size-[24px]">
<div className="flex justify-center items-center size-6">
<Checkbox
checked={isChecked()}
indeterminate={isAll}
@@ -347,7 +365,7 @@ export default function SearchPopover({
/>
)}
</div>
</PopoverPanel>
</PopoverContent>
</Popover>
</>
)}

View File

@@ -0,0 +1,207 @@
import { useState, Fragment, useMemo } from "react";
import { ListFilter, ChevronRight, BrushCleaning } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { useSearchStore } from "@/stores/searchStore";
import MultiSelect from "../ui/multi-select";
import DatePickerRange from "../ui/date-picker-range";
import { camelCase, upperFirst } from "lodash-es";
import dayjs from "dayjs";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { useTranslation } from "react-i18next";
const TimeFilter = () => {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const {
filterDateRange,
setFilterDateRange,
aggregateFilter,
setAggregateFilter,
aggregations,
} = useSearchStore();
const { t } = useTranslation();
const dropdownMenuItems = [
{
key: "all-time",
label: t("search.filers.allTime"),
value: void 0,
},
{
key: "7-day",
label: t("search.filers.past7Days"),
value: {
from: dayjs().subtract(7, "day").toDate(),
to: dayjs().toDate(),
},
},
{
key: "90-day",
label: t("search.filers.past90Days"),
value: {
from: dayjs().subtract(90, "day").toDate(),
to: dayjs().toDate(),
},
},
{
key: "1-year",
label: t("search.filers.past1year"),
value: {
from: dayjs().subtract(1, "year").toDate(),
to: dayjs().toDate(),
},
},
{
key: "more",
label: t("search.filers.more"),
onClick: () => {
setPopoverOpen(true);
},
},
];
const filterCount = useMemo(() => {
let count = 0;
if (filterDateRange) {
count += 1;
}
if (aggregateFilter) {
for (const item of Object.values(aggregateFilter)) {
if (item.length === 0) continue;
count += 1;
}
}
return count;
}, [filterDateRange, aggregateFilter]);
return (
<div>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 h-5 px-1 text-xs rounded-full hover:text-[#881c94]! cursor-pointer transition",
{
"bg-[#881C94]/20 dark:bg-[#202126] text-[#881c94]":
filterCount > 0,
}
)}
>
<ListFilter className="size-3" />
{filterCount > 0 && (
<>
<div className="whitespace-nowrap">
{t("search.filers.filters")}
</div>
<div className="inline-flex items-center justify-center size-4 rounded-full text-white bg-[#881c94]">
{filterCount}
</div>
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{dropdownMenuItems.map((item) => {
const { key, label, value, onClick } = item;
return (
<DropdownMenuItem
key={key}
className="flex justify-between"
onClick={() => {
if (onClick) {
onClick();
} else {
setFilterDateRange(value);
}
}}
>
<span>{label}</span>
{key === "more" && (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<div />
</PopoverTrigger>
<PopoverContent className="w-100 p-4 text-sm">
<div className="flex items-center justify-between text-sm">
<span className="font-bold">{t("search.filers.filters")}</span>
<Button
size="icon"
variant="outline"
className="size-6"
onClick={() => {
setFilterDateRange(void 0);
setAggregateFilter(void 0);
}}
>
<BrushCleaning className="size-3 text-[#6000FF]" />
</Button>
</div>
<div className="pt-4 pb-2 text-[#999]">
{t("search.filers.dateRange")}
</div>
<DatePickerRange
selected={filterDateRange}
onSelect={setFilterDateRange}
/>
{aggregations &&
Object.entries(aggregations).map(([key, value]) => {
return (
<Fragment key={key}>
<div className="pt-4 pb-2 text-[#999]">
{upperFirst(camelCase(key))}
</div>
<MultiSelect
value={aggregateFilter?.[key] ?? []}
placeholder={`Please select ${key}`}
options={value.buckets.map((bucket) => ({
label: bucket.label ?? bucket.key,
value: bucket.key,
}))}
onChange={(value) => {
setAggregateFilter({
...aggregateFilter,
[key]: value,
});
}}
/>
</Fragment>
);
})}
</PopoverContent>
</Popover>
</div>
);
};
export default TimeFilter;

View File

@@ -35,7 +35,10 @@ import {
visibleSearchBar,
} from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { POPOVER_PANEL_SELECTOR, WINDOW_CENTER_BASELINE_HEIGHT } from "@/constants";
import {
POPOVER_PANEL_SELECTOR,
WINDOW_CENTER_BASELINE_HEIGHT,
} from "@/constants";
import { useChatStore } from "@/stores/chatStore";
import { useSearchStore } from "@/stores/searchStore";
@@ -383,11 +386,11 @@ function SearchChat({
<div
data-tauri-drag-region={isTauri}
className={clsx(
"m-auto overflow-hidden relative bg-no-repeat bg-white dark:bg-black flex flex-col",
"m-auto overflow-hidden relative bg-no-repeat flex flex-col",
[
isTransitioned
? "bg-bottom bg-chat_bg_light dark:bg-chat_bg_dark"
: "bg-top bg-search_bg_light dark:bg-search_bg_dark",
? "bg-bottom bg-[url('/assets/chat_bg_light.png')] dark:bg-[url('/assets/chat_bg_dark.png')]"
: "bg-top bg-[url('/assets/search_bg_light.png')] dark:bg-[url('/assets/search_bg_dark.png')]",
],
{
"size-full": !isTauri,
@@ -438,7 +441,7 @@ function SearchChat({
{!hideMiddleBorder && (
<div
className={clsx(
"pointer-events-none absolute left-0 right-0 h-[1px] bg-[#E6E6E6] dark:bg-[#272626]",
"pointer-events-none absolute left-0 right-0 h-px bg-[#E6E6E6] dark:bg-[#272626]",
isTransitioned ? "top-0" : "bottom-0"
)}
/>

View File

@@ -6,6 +6,13 @@ import { nanoid } from "nanoid";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { Button } from "@/components/ui/button";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { ButtonConfig } from "./config";
import { useThemeStore } from "@/stores/themeStore";
import { useAppStore } from "@/stores/appStore";
@@ -169,43 +176,58 @@ export default function AddChatDialog({
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.service")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
<Select
value={serverId}
onChange={(e) => setServerId(e.target.value)}
onValueChange={(v) => setServerId(v === "__default__" ? "" : v)}
>
<option value="" disabled>
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-full">
<SelectValue className="truncate" placeholder={t("selection.bind.defaultService") as string} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__" disabled>
{t("selection.bind.defaultService")}
</SelectItem>
{serverList.map((s: any) => (
<SelectItem key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.assistant")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
<Select
value={assistantId}
onChange={(e) => setAssistantId(e.target.value)}
onValueChange={(v) => setAssistantId(v === "__default__" ? "" : v)}
disabled={loading || !serverId}
>
<option value="" disabled>
{loading
? t("common.loading")
: t("selection.bind.defaultAssistant")}
</option>
{!loading &&
assistantList.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-full">
<SelectValue
className="truncate"
placeholder={
(loading
? t("common.loading")
: t("selection.bind.defaultAssistant")) as string
}
/>
</SelectTrigger>
<SelectContent>
{!loading && (
<SelectItem value="__default__">
{t("selection.bind.defaultAssistant")}
</SelectItem>
)}
{!loading &&
assistantList.map((a: any) => (
<SelectItem key={a._id} value={a._id}>
{a._source?.name || a._id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>

View File

@@ -7,6 +7,13 @@ import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { setCurrentWindowService } from "@/commands/windowService";
import { AddChatButton } from "./AddChatButton";
import { ButtonConfig, resolveLucideIcon } from "./config";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
@@ -250,50 +257,58 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
<div className="ml-auto flex items-center gap-2">
{isChat && (
<>
<select
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
<Select
value={btn.action.assistantServerId || ""}
onChange={(e) => handleServerSelect(btn, e.target.value)}
title={t("selection.bind.service")}
onValueChange={(v) =>
handleServerSelect(btn, v === "__default__" ? "" : v)
}
>
<option value="">
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-60">
<SelectValue className="truncate" placeholder={t("selection.bind.defaultService") as string} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t("selection.bind.defaultService")}
</SelectItem>
{serverList.map((s: any) => (
<SelectItem key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</SelectItem>
))}
</SelectContent>
</Select>
{(() => {
const sid = btn.action.assistantServerId;
const list = (sid && assistantByServer[sid]) || [];
const loading = !!(sid && assistantLoadingByServer[sid]);
return (
<select
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
<Select
value={btn.action.assistantId || ""}
onChange={(e) =>
handleAssistantSelect(btn, e.target.value)
onValueChange={(v) =>
handleAssistantSelect(
btn,
v === "__default__" ? "" : v
)
}
title={t("selection.bind.assistant")}
disabled={loading}
>
<option value="">
{t("selection.bind.defaultAssistant")}
</option>
{loading && (
<option value="" disabled>
{t("common.loading")}
</option>
)}
{list.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-60">
<SelectValue className="truncate" placeholder={t("selection.bind.defaultAssistant") as string} />
</SelectTrigger>
<SelectContent>
{!loading && (
<SelectItem value="__default__">
{t("selection.bind.defaultAssistant")}
</SelectItem>
)}
{list.map((a: any) => (
<SelectItem key={a._id} value={a._id}>
{a._source?.name || a._id}
</SelectItem>
))}
</SelectContent>
</Select>
);
})()}
</>

View File

@@ -117,7 +117,7 @@ const SelectionSettings = () => {
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
</div>
<div className="relative rounded-xl p-4 bg-gradient-to-r from-[#E6F0FA] to-[#FFF1F1]">
<div className="relative rounded-xl p-4 bg-linear-to-r from-[#E6F0FA] to-[#FFF1F1] dark:from-[#0B1220] dark:to-[#1A2234] dark:border dark:border-gray-800 dark:shadow-sm transition-colors">
<div className="flex items-center flex-col" aria-hidden="true">
<div className="rounded-xl border border-gray-200 bg-white/70 shadow-sm dark:border-gray-700 dark:bg-gray-900/40">
<HeaderToolbar
@@ -148,7 +148,7 @@ const SelectionSettings = () => {
</SettingsItem>
{selectionEnabled && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4">
<SettingsItem
icon={Sparkles}
title={t("selection.display.title")}

View File

@@ -1,7 +1,14 @@
import { useTranslation } from "react-i18next";
import { Command, RotateCcw } from "lucide-react";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { useEffect } from "react";
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
import { formatKey } from "@/utils/keyboardUtils";
@@ -246,21 +253,21 @@ const Shortcuts = () => {
title={t("settings.advanced.shortcuts.modifierKey.title")}
description={t("settings.advanced.shortcuts.modifierKey.description")}
>
<select
<Select
value={modifierKey}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(event) => {
setModifierKey(event.target.value as ModifierKey);
}}
onValueChange={(v) => setModifierKey(v as ModifierKey)}
>
{modifierKeys.map((item) => {
return (
<option key={item} value={item}>
<SelectTrigger className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modifierKeys.map((item) => (
<SelectItem key={item} value={item}>
{formatKey(item)}
</option>
);
})}
</select>
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsItem>
{list.map((item) => {
@@ -279,6 +286,7 @@ const Shortcuts = () => {
<span>{formatKey(modifierKey)}</span>
<span>+</span>
<SettingsInput
className="w-20"
value={value}
max={1}
onChange={(value) => {
@@ -287,23 +295,14 @@ const Shortcuts = () => {
/>
<Button
variant="outline"
disabled={disabled}
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition",
{
"hover:border-[#0072FF]": !disabled,
"opacity-70 cursor-not-allowed": disabled,
}
)}
size="icon"
onClick={() => {
handleChange(initialValue, setValue);
}}
>
<RotateCcw
className={clsx("size-4 text-[#999]", {
"!text-[#0072FF]": !disabled,
})}
/>
<RotateCcw className={clsx("size-4 opacity-80")} />
</Button>
</div>
</SettingsItem>

View File

@@ -21,8 +21,15 @@ import SettingsInput from "@/components//Settings/SettingsInput";
import platformAdapter from "@/utils/platformAdapter";
import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
// import SelectionSettings from "./components/Selection";
// import { isMac } from "@/utils/platform";
import SelectionSettings from "./components/Selection";
import { isMac } from "@/utils/platform";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
const Advanced = () => {
const { t } = useTranslation();
@@ -169,29 +176,27 @@ const Advanced = () => {
title={t(title)}
description={t(description)}
>
<select
value={value}
onChange={(event) => {
onChange(event.target.value as never);
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{items.map((item) => {
const { label, value } = item;
return (
<option key={value} value={value}>
{t(label)}
</option>
);
})}
</select>
<Select value={value as string} onValueChange={(v) => onChange(v as never)}>
<SelectTrigger className="h-8 w-44">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectContent>
{items.map((item) => {
const { label, value } = item;
return (
<SelectItem key={value} value={value as string}>
{t(label)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</SettingsItem>
);
})}
</div>
{/* {isMac && <SelectionSettings />} */}
{isMac && <SelectionSettings />}
<Shortcuts />
@@ -278,33 +283,35 @@ const Advanced = () => {
"settings.advanced.other.localSearchResultWeight.description"
)}
>
<select
value={localSearchResultWeight}
onChange={(event) => {
const weight = Number(event.target.value);
<Select
value={String(localSearchResultWeight)}
onValueChange={(v) => {
const weight = Number(v);
setLocalSearchResultWeight(weight);
platformAdapter.invokeBackend("set_local_query_source_weight", {
value: weight,
});
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="0.5">
{t("settings.advanced.other.localSearchResultWeight.options.low")}
</option>
<option value="1">
{t(
"settings.advanced.other.localSearchResultWeight.options.medium"
)}
</option>
<option value="2">
{t(
"settings.advanced.other.localSearchResultWeight.options.high"
)}
</option>
</select>
<SelectTrigger className="h-8 w-44">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">
{t("settings.advanced.other.localSearchResultWeight.options.low")}
</SelectItem>
<SelectItem value="1">
{t(
"settings.advanced.other.localSearchResultWeight.options.medium"
)}
</SelectItem>
<SelectItem value="2">
{t(
"settings.advanced.other.localSearchResultWeight.options.high"
)}
</SelectItem>
</SelectContent>
</Select>
</SettingsItem>
<SettingsItem

View File

@@ -13,6 +13,7 @@ import Shortcut from "../Shortcut";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { platform } from "@/utils/platform";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { cn } from "@/lib/utils";
const Content = () => {
const { rootState } = useContext(ExtensionsContext);
@@ -165,7 +166,9 @@ const Item: FC<ItemProps> = (props) => {
<SettingsInput
defaultValue={alias}
placeholder={t("settings.extensions.hints.addAlias")}
className="!w-[90%] !h-6 !border-transparent rounded-[4px]"
className={cn(
"w-[90%] h-6 px-1 py-0 border-none rounded-sm shadow-none bg-transparent placeholder:text-[#999]"
)}
onChange={(value) => {
handleChange(String(value));
}}
@@ -292,7 +295,7 @@ const Item: FC<ItemProps> = (props) => {
return (
<>
<div
className={clsx("-mx-2 px-2 text-sm rounded-[6px]", {
className={clsx("-mx-2 px-2 text-sm rounded-md", {
"bg-[#f0f6fe] dark:bg-gray-700":
id === rootState.activeExtension?.id,
})}

View File

@@ -72,7 +72,7 @@ const AiOverview = () => {
/>
<>
<div className="mt-6 text-[#333] dark:text-white/90">
<div className="mt-6">
{t("settings.extensions.aiOverview.details.aiOverviewTrigger.title")}
</div>
@@ -88,9 +88,7 @@ const AiOverview = () => {
return (
<div>
<div className="mb-2 text-[#666] dark:text-white/70">
{label}
</div>
<div className="mb-2">{label}</div>
<SettingsInput
type="number"

View File

@@ -1,4 +1,4 @@
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import { useMount } from "ahooks";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -35,15 +35,13 @@ const Applications = () => {
return (
<>
<div className="text-[#999]">
<p className="font-bold mb-2">
{t("settings.extensions.application.details.searchScope")}
</p>
<p className="mb-2">
{t("settings.extensions.application.details.searchScope")}
</p>
<p>
{t("settings.extensions.application.details.searchScopeDescription")}
</p>
</div>
<p className="text-[#999]">
{t("settings.extensions.application.details.searchScopeDescription")}
</p>
<DirectoryScope
paths={paths}
@@ -72,18 +70,18 @@ const Applications = () => {
}}
/>
<div className="text-[#999] mt-4">
<p className="font-bold mb-2">
{t("settings.extensions.application.details.rebuildIndex")}
</p>
<p className="mt-4 mb-2">
{t("settings.extensions.application.details.rebuildIndex")}
</p>
<p>
{t("settings.extensions.application.details.rebuildIndexDescription")}
</p>
</div>
<p className="text-[#999]">
{t("settings.extensions.application.details.rebuildIndexDescription")}
</p>
<Button
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
variant="outline"
className="w-full my-4"
// className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:border-[#0087FF] dark:border-gray-700 rounded-md transition"
onClick={handleReindex}
>
{t("settings.extensions.application.details.reindex")}

View File

@@ -1,6 +1,6 @@
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
import { castArray } from "lodash-es";
import { Folder, SquareArrowOutUpRight, X } from "lucide-react";
@@ -82,7 +82,7 @@ const DirectoryScope: FC<DirectoryScopeProps> = (props) => {
return (
<div
key={item}
className="flex items-center justify-between gap-2"
className="flex items-center justify-between gap-2 text-[#666] dark:text-white/70"
>
<div className="flex items-center gap-1 flex-1 overflow-hidden">
<Folder className="size-4" />
@@ -112,7 +112,9 @@ const DirectoryScope: FC<DirectoryScopeProps> = (props) => {
)}
<Button
className="w-full h-8 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
variant="outline"
className="w-full"
size="sm"
onClick={handleAdd}
>
{t("settings.extensions.directoryScope.button.addDirectories")}

View File

@@ -82,7 +82,7 @@ const FileSearch = () => {
{t("settings.extensions.fileSearch.description")}
</div>
<div className="mt-4 mb-2 text-[#666] dark:text-white/70">
<div className="mt-4 mb-2">
{t("settings.extensions.fileSearch.label.searchBy")}
</div>
@@ -99,10 +99,7 @@ const FileSearch = () => {
return (
<>
<div
key={label}
className="mt-4 mb-2 text-[#666] dark:text-white/70"
>
<div key={label} className="mt-4 mb-2">
{label}
</div>
@@ -111,16 +108,16 @@ const FileSearch = () => {
);
})}
<div className="mt-4 mb-2 text-[#666] dark:text-white/70">
<div className="mt-4 mb-2">
{t("settings.extensions.fileSearch.label.searchFileTypes")}
</div>
<div className="flex flex-wrap gap-2 p-2 border rounded-[6px] dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2 p-2 rounded-lg border border-input bg-background hover:border-[#0072FF] focus-within:border-[#0072FF] transition">
{config.file_types.map((item) => {
return (
<div
key={item}
className="flex items-center gap-1 h-6 px-1 rounded bg-[#f0f0f0] dark:bg-[#444]"
className="flex items-center gap-1 h-6 px-2 rounded-full text-xs border border-black/5 dark:border-white/10 bg-black/5 dark:bg-white/10"
>
<span>{item}</span>
@@ -140,7 +137,7 @@ const FileSearch = () => {
<SettingsInput
placeholder=".*"
className="h-6 border-0 -ml-2"
className="h-6 w-24 px-2 border-0 outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
onKeyDown={(event) => {
if (event.code !== "Enter") return;

View File

@@ -4,7 +4,13 @@ import { isArray } from "lodash-es";
import { useAsyncEffect, useMount } from "ahooks";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { useAppStore } from "@/stores/appStore";
import { ExtensionId } from "@/components/Settings/Extensions/index";
import { useConnectStore } from "@/stores/connectStore";
@@ -175,27 +181,40 @@ const SharedAi: FC<SharedAiProps> = (props) => {
<>
<div className="text-[#999]">{renderDescription()}</div>
<div className="mt-6 text-[#333] dark:text-white/90">
<div className="mt-6">
{t("settings.extensions.shardAi.details.linkedAssistant.title")}
</div>
{selectList.map((item) => {
const { label, value, data, searchable, onChange, onSearch } = item;
const { label, value, data, searchable, onChange } = item;
return (
<div key={label} className="mt-4">
<div className="mb-2 text-[#666] dark:text-white/70">{label}</div>
<SettingsSelectPro
<Select
value={value}
options={data}
searchable={searchable}
onChange={onChange}
onSearch={onSearch}
placeholder={
isLoadingAssistants && searchable ? "Loading..." : undefined
}
/>
onValueChange={(v) => onChange?.(v)}
disabled={searchable && isLoadingAssistants}
>
<SelectTrigger className="ml-1 h-9 w-full max-w-[480px]">
<SelectValue
className="truncate"
placeholder={
(searchable && isLoadingAssistants
? (t("common.loading") as string)
: undefined) as string | undefined
}
/>
</SelectTrigger>
<SelectContent>
{data?.map((opt: any) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.name || opt.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}

View File

@@ -9,7 +9,13 @@ import AiOverview from "./AiOverview";
import Calculator from "./Calculator";
import FileSearch from "./FileSearch";
import { Ellipsis, Info } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore";
import { useTranslation } from "react-i18next";
@@ -93,58 +99,60 @@ const Details = () => {
};
return (
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex-1 h-full p-4 overflow-auto">
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="m-0 text-lg font-semibold text-gray-900 dark:text-white">
{rootState.activeExtension?.name}
</h2>
{rootState.activeExtension?.developer && (
<Menu>
<MenuButton className="h-7">
<Ellipsis className="size-5 text-[#999]" />
</MenuButton>
<MenuItems
anchor="bottom end"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<Ellipsis className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="p-1 text-sm rounded-lg"
>
<MenuItem>
<div
className="px-3 py-2 text-nowrap text-red-500 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const { id, developer } = rootState.activeExtension!;
<DropdownMenuItem
className="px-3 py-2 text-nowrap text-red-500 rounded-lg hover:bg-muted"
onSelect={async (e: Event) => {
e.preventDefault();
try {
const { id, developer } = rootState.activeExtension!;
await platformAdapter.invokeBackend(
"uninstall_extension",
{
extensionId: id,
developer: developer,
}
);
await platformAdapter.invokeBackend("uninstall_extension", {
extensionId: id,
developer: developer,
});
Object.assign(rootState, {
activeExtension: void 0,
extensions: rootState.extensions.filter((item) => {
return item.id !== id;
}),
});
Object.assign(rootState, {
activeExtension: void 0,
extensions: rootState.extensions.filter((item) => {
return item.id !== id;
}),
});
addError(
t("settings.extensions.hints.uninstallSuccess"),
"info"
);
} catch (error) {
addError(String(error));
}
}}
>
{t("settings.extensions.hints.uninstall")}
</div>
</MenuItem>
</MenuItems>
</Menu>
addError(
t("settings.extensions.hints.uninstallSuccess"),
"info"
);
} catch (error) {
addError(String(error));
}
}}
>
{t("settings.extensions.hints.uninstall")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>

View File

@@ -3,15 +3,21 @@ import { useReactive } from "ahooks";
import { useTranslation } from "react-i18next";
import type { LiteralUnion } from "type-fest";
import { cloneDeep, sortBy } from "lodash-es";
import clsx from "clsx";
import { Plus } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore";
import SettingsInput from "../SettingsInput";
import { useAppStore } from "@/stores/appStore";
import { installExtensionError } from "@/utils";
@@ -184,95 +190,88 @@ export const Extensions = () => {
rootState: state,
}}
>
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4 text-sm">
<div className="w-2/3 h-full px-4 border-r dark:border-gray-700 overflow-auto">
<div className="flex h-[calc(100vh-128px)] -mx-6 text-sm">
<div className="w-2/3 h-full px-4 border-r border-border overflow-auto">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t("settings.extensions.title")}
</h2>
<Menu>
<MenuButton className="flex items-center justify-center size-6 border rounded-[6px] dark:border-gray-700 hover:!border-[#0096FB] transition">
<Plus className="size-4 text-[#0096FB]" />
</MenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="size-6">
<Plus className="h-4 w-4 text-primary" />
</Button>
</DropdownMenuTrigger>
<MenuItems
anchor={{ gap: 4 }}
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<DropdownMenuContent
sideOffset={4}
className="p-1 text-sm rounded-lg"
>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
{t("settings.extensions.menuItem.extensionStore")}
</div>
</MenuItem>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const path = await platformAdapter.openFileDialog({
directory: true,
});
<DropdownMenuItem
className="px-3 py-2 rounded-lg hover:bg-muted"
onSelect={(e: Event) => {
e.preventDefault();
platformAdapter.emitEvent("open-extension-store");
}}
>
{t("settings.extensions.menuItem.extensionStore")}
</DropdownMenuItem>
<DropdownMenuItem
className="px-3 py-2 rounded-lg hover:bg-muted"
onSelect={async (e: Event) => {
e.preventDefault();
try {
const path = await platformAdapter.openFileDialog({
directory: true,
});
if (!path) return;
if (!path) return;
await platformAdapter.invokeBackend(
"install_local_extension",
{ path }
);
await platformAdapter.invokeBackend(
"install_local_extension",
{ path }
);
await getExtensions();
await getExtensions();
addError(
t("settings.extensions.hints.importSuccess"),
"info"
);
} catch (error) {
installExtensionError(error);
}
}}
>
{t("settings.extensions.menuItem.localExtensionImport")}
</div>
</MenuItem>
</MenuItems>
</Menu>
addError(
t("settings.extensions.hints.importSuccess"),
"info"
);
} catch (error) {
installExtensionError(error);
}
}}
>
{t("settings.extensions.menuItem.localExtensionImport")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex justify-between gap-6 my-4">
<div className="flex h-8 border dark:border-gray-700 rounded-[6px] overflow-hidden">
{state.categories.map((item) => {
return (
<div
key={item}
className={clsx(
"flex items-center h-full px-4 cursor-pointer",
{
"bg-[#F0F6FE] dark:bg-gray-700":
item === state.currentCategory,
}
)}
onClick={() => {
state.currentCategory = item;
}}
>
<div className="flex items-center justify-between gap-6 my-4">
<Tabs
value={state.currentCategory}
onValueChange={(v) => {
state.currentCategory = v as Category;
}}
>
<TabsList>
{state.categories.map((item) => (
<TabsTrigger key={item} value={item}>
{item}
</div>
);
})}
</div>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<SettingsInput
className="flex-1"
<Input
className="flex-1 h-8"
placeholder="Search"
value={state.searchValue}
onChange={(value) => {
state.searchValue = String(value);
value={state.searchValue ?? ""}
onChange={(e) => {
state.searchValue = e.target.value;
}}
/>
</div>

View File

@@ -36,6 +36,13 @@ import {
} from "@/commands";
import platformAdapter from "@/utils/platformAdapter";
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
export function ThemeOption({
icon: Icon,
@@ -83,8 +90,6 @@ export default function GeneralSettings() {
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
const { windowMode, setWindowMode } = useAppearanceStore();
const fetchAutoStartStatus = async () => {
if (isTauri()) {
try {
@@ -283,7 +288,7 @@ export default function GeneralSettings() {
className={clsx(
"p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 flex flex-col items-center justify-center space-y-2 transition-all",
{
"!border-blue-500 bg-blue-50 dark:bg-blue-900/20":
"border-blue-500! bg-blue-50! dark:bg-blue-900/20!":
isSelected,
}
)}
@@ -307,28 +312,31 @@ export default function GeneralSettings() {
})}
</div>
<SettingsItem
icon={Globe}
title={t("settings.language.title")}
description={t("settings.language.description")}
>
<div className="flex items-center gap-2">
<select
<Select
value={currentLanguage}
onChange={(event) => {
const lang = event.currentTarget.value;
onValueChange={(lang) => {
setLanguage(lang);
platformAdapter.invokeBackend("update_app_lang", { lang });
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="en">{t("settings.language.english")}</option>
<option value="zh">{t("settings.language.chinese")}</option>
</select>
<SelectTrigger className="h-8 w-44">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">
{t("settings.language.english")}
</SelectItem>
<SelectItem value="zh">
{t("settings.language.chinese")}
</SelectItem>
</SelectContent>
</Select>
</div>
</SettingsItem>

View File

@@ -1,10 +1,13 @@
import { Input, InputProps } from "@headlessui/react";
import { Input } from "@/components/ui/input";
import { isNumber } from "lodash-es";
import { FC, FocusEvent } from "react";
import { FC, FocusEvent, InputHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
interface SettingsInputProps
extends Omit<InputProps, "onChange" | "className"> {
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"onChange" | "className"
> {
className?: string;
onChange?: (value?: string | number) => void;
}
@@ -35,10 +38,7 @@ const SettingsInput: FC<SettingsInputProps> = (props) => {
<Input
{...rest}
autoCorrect="off"
className={twMerge(
"w-20 h-8 px-2 rounded-[6px] border bg-transparent border-black/5 dark:border-white/10 hover:!border-[#0072FF] focus:!border-[#0072FF] transition",
className
)}
className={twMerge("w-44 h-8", className)}
onBlur={handleBlur}
onChange={(event) => {
onChange?.(event.target.value);

View File

@@ -7,7 +7,7 @@ interface SettingsPanelProps {
const SettingsPanel: React.FC<SettingsPanelProps> = ({ children }) => {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-6">
<div className="bg-background text-foreground rounded-xl p-6">
{/* <h2 className="text-xl font-semibold mb-6">{title}</h2> */}
{children}
</div>

View File

@@ -1,7 +1,8 @@
import { useBoolean, useClickAway, useDebounce } from "ahooks";
import clsx from "clsx";
import { FC, useEffect, useMemo, useRef, useState } from "react";
import SettingsInput from "./SettingsInput";
import { Input } from "@/components/ui/input";
import NoDataImage from "../Common/NoDataImage";
interface SettingsSelectProProps {
@@ -47,7 +48,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
return (
<div ref={containerRef} className="relative">
<div
className="flex items-center h-8 px-3 truncate rounded-[6px] border dark:bg-[#1F2937] bg-white dark:border-[#374151]"
className="flex items-center h-9 px-3 truncate rounded-md border border-input bg-background text-foreground shadow-sm"
onClick={toggle}
>
{option?.[labelField] ?? (
@@ -57,7 +58,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
<div
className={clsx(
"absolute z-100 top-10 left-0 right-0 rounded-[6px] py-2 border dark:border-[#374151] bg-white dark:bg-[#1F2937] shadow-[0_5px_15px_rgba(0,0,0,0.2)] dark:shadow-[0_5px_10px_rgba(0,0,0,0.3)]",
"absolute z-50 top-11 left-0 right-0 rounded-md p-2 border border-input bg-popover text-popover-foreground shadow-md",
{
hidden: !open,
}
@@ -65,12 +66,12 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
>
{searchable && (
<div className="px-2 mb-2">
<SettingsInput
<Input
autoFocus
value={searchValue}
className="w-full"
onChange={(value) => {
setSearchValue(String(value));
className="w-full h-8 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => {
setSearchValue(String(e.target.value));
}}
/>
</div>
@@ -83,9 +84,9 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
<div
key={item?.[valueField] ?? index}
className={clsx(
"h-8 leading-8 px-2 rounded-[6px] hover:bg-[#EDEDED] hover:dark:bg-[#374151] transition cursor-pointer",
"h-8 leading-8 px-2 rounded-md hover:bg-accent hover:text-accent-foreground transition cursor-pointer",
{
"bg-[#EDEDED] dark:bg-[#374151]":
"bg-accent text-accent-foreground":
value === item?.[valueField],
}
)}

View File

@@ -1,28 +1,26 @@
import { Switch, SwitchProps } from "@headlessui/react";
import { Switch } from "@/components/ui/switch";
import clsx from "clsx";
interface SettingsToggleProps extends SwitchProps {
type BaseSwitchProps = React.ComponentProps<typeof Switch>;
interface SettingsToggleProps
extends Omit<BaseSwitchProps, "onChange" | "onCheckedChange"> {
label: string;
className?: string;
onChange?: (checked: boolean) => void;
}
export default function SettingsToggle(props: SettingsToggleProps) {
const { label, className, ...rest } = props;
const { label, className, onChange, ...rest } = props;
return (
<Switch
{...rest}
aria-label={label}
onCheckedChange={(v) => onChange?.(v)}
className={clsx(
`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 data-[checked]:bg-blue-600`,
"h-5 w-9",
className
)}
>
<span className="sr-only">{label}</span>
<span
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

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useEffect } from "react";
import { Button, Dialog, DialogPanel } from "@headlessui/react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { noop } from "lodash-es";
import { LoaderCircle, X } from "lucide-react";
import { useInterval, useReactive } from "ahooks";
import clsx from "clsx";
@@ -141,117 +141,107 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
return (
<Dialog
open={isCheckPage ? true : visible}
as="div"
id="update-app-dialog"
className="relative z-10 focus:outline-none"
onClose={noop}
onOpenChange={(v) => {
if (!isCheckPage) setVisible(v);
}}
>
<div
className={`fixed inset-0 z-10 w-screen overflow-y-auto ${
<DialogContent
id="update-app-dialog"
overlayClassName={clsx("bg-transparent backdrop-blur-0 rounded-xl")}
className={clsx(
isCheckPage
? "rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md"
: ""
}`}
? "inset-0 left-0 top-0 translate-x-0 translate-y-0 w-full h-screen max-w-none rounded-lg border-none bg-background text-foreground p-0"
: "w-[340px] py-8 flex flex-col items-center rounded-lg border border-input bg-background text-foreground shadow-md"
)}
>
<div
data-tauri-drag-region
className={clsx(
"flex min-h-full items-center justify-center",
!isCheckPage && "p-4"
"w-full flex flex-col items-center justify-center px-6",
isCheckPage && "h-full"
)}
>
<DialogPanel
transition
className={clsx(
"relative w-[340px] py-8 flex flex-col items-center",
{
"rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md":
!isCheckPage,
}
)}
>
{!isCheckPage && isOptional && (
<X
className={clsx(
"absolute size-5 top-3 right-3 text-[#999] dark:text-[#D8D8D8]",
cursorClassName
)}
onClick={handleCancel}
role="button"
aria-label="Close dialog"
tabIndex={0}
/>
)}
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
<div className="text-[#333] text-sm leading-5 py-2 dark:text-[#D8D8D8] text-center">
{updateInfo ? (
isOptional ? (
t("update.optional_description")
) : (
<>
<p>{t("update.force_description1")}</p>
<p>{t("update.force_description2")}</p>
</>
)
) : (
t("update.date")
)}
</div>
{updateInfo ? (
<div
className="text-xs text-[#0072FF] cursor-pointer"
onClick={() =>
OpenURLWithBrowser(
"https://docs.infinilabs.com/coco-app/main/docs/release-notes"
)
}
>
v{updateInfo.version} {t("update.releaseNotes")}
</div>
) : (
<div className={clsx("text-xs text-[#999]", cursorClassName)}>
{t("update.latest", {
replace: [
updateInfo?.version || process.env.VERSION || "N/A",
],
})}
</div>
)}
<Button
{!isCheckPage && isOptional && (
<X
className={clsx(
"mb-3 mt-6 bg-[#0072FF] text-white text-sm px-[14px] py-[8px] rounded-lg",
cursorClassName,
state.loading && "opacity-50"
"absolute h-5 w-5 top-3 right-3 text-muted-foreground",
cursorClassName
)}
onClick={updateInfo ? handleDownload : handleSkip}
>
{state.loading ? (
<div className="flex justify-center items-center gap-2">
<LoaderCircle className="animate-spin size-5" />
{percent}%
</div>
) : updateInfo ? (
t("update.button.install")
) : (
t("update.button.ok")
)}
</Button>
onClick={handleCancel}
role="button"
aria-label="Close dialog"
tabIndex={0}
/>
)}
{updateInfo && isOptional && (
<div
className={clsx("text-xs text-[#999]", cursorClassName)}
onClick={handleSkip}
>
{t("update.skip_version")}
</div>
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
<div className="text-sm leading-5 py-2 text-foreground text-center">
{updateInfo ? (
isOptional ? (
t("update.optional_description")
) : (
<>
<p>{t("update.force_description1")}</p>
<p>{t("update.force_description2")}</p>
</>
)
) : (
t("update.date")
)}
</DialogPanel>
</div>
{updateInfo ? (
<div
className="text-xs text-primary cursor-pointer"
onClick={() =>
OpenURLWithBrowser(
"https://docs.infinilabs.com/coco-app/main/docs/release-notes"
)
}
>
v{updateInfo.version} {t("update.releaseNotes")}
</div>
) : (
<div
className={clsx("text-xs text-muted-foreground", cursorClassName)}
>
{t("update.latest", {
replace: [updateInfo?.version || process.env.VERSION || "N/A"],
})}
</div>
)}
<Button
className={clsx(
"mb-3 mt-6 bg-primary text-primary-foreground text-sm px-[14px] py-[8px] rounded-lg",
cursorClassName,
state.loading && "opacity-50"
)}
onClick={updateInfo ? handleDownload : handleSkip}
>
{state.loading ? (
<div className="flex justify-center items-center gap-2">
<LoaderCircle className="animate-spin h-5 w-5" />
{percent}%
</div>
) : updateInfo ? (
t("update.button.install")
) : (
t("update.button.ok")
)}
</Button>
{updateInfo && isOptional && (
<div
className={clsx("text-xs text-muted-foreground", cursorClassName)}
onClick={handleSkip}
>
{t("update.skip_version")}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,5 +1,5 @@
import { useAppStore } from "@/stores/appStore";
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import { SquareArrowOutUpRight } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -12,10 +12,7 @@ const LoginButton = () => {
};
return (
<Button
className="px-6 h-8 text-white bg-[#0287FF] flex rounded-[8px] items-center justify-center gap-1"
onClick={handleClick}
>
<Button className="h-8" onClick={handleClick}>
<span>{t("webLogin.buttons.login")}</span>
<SquareArrowOutUpRight className="size-4" />

View File

@@ -1,6 +1,6 @@
import { RefreshCw } from "lucide-react";
import { FC, useState } from "react";
import { Button, ButtonProps } from "@headlessui/react";
import { Button, ButtonProps } from "@/components/ui/button";
import clsx from "clsx";
import { useWebConfigStore } from "@/stores/webConfigStore";
@@ -25,10 +25,9 @@ const RefreshButton: FC<ButtonProps> = (props) => {
<Button
{...rest}
onClick={handleRefresh}
className={clsx(
"flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10",
className
)}
variant="outline"
size="icon"
className={clsx("size-8", className)}
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>

View File

@@ -12,7 +12,7 @@ const UserAvatar: FC<UserAvatarProps> = (props) => {
return (
<div
className={clsx(
"flex items-center justify-center size-5 rounded-full border dark:border-white/10 overflow-hidden",
"flex items-center justify-center size-5 rounded-full border border-border overflow-hidden",
className
)}
>

View File

@@ -1,4 +1,8 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { LogOut } from "lucide-react";
import clsx from "clsx";
@@ -10,21 +14,18 @@ import RefreshButton from "./RefreshButton";
import LoginButton from "./LoginButton";
import { FC } from "react";
import Copyright from "../Common/Copyright";
import { PopoverContentProps } from "@radix-ui/react-popover";
import { Button } from "../ui/button";
interface WebLoginProps {
panelClassName: string;
}
const WebLogin: FC<WebLoginProps> = (props) => {
const { panelClassName } = props;
const WebLogin: FC<PopoverContentProps> = (props) => {
const { integration, loginInfo, setIntegration, setLoginInfo } =
useWebConfigStore();
const { t } = useTranslation();
return (
<div className="relative">
<div className="flex items-center relative text-sm">
<Popover>
<PopoverButton>
<PopoverTrigger className="cursor-pointer">
{loginInfo ? (
<UserAvatar />
) : (
@@ -33,38 +34,35 @@ const WebLogin: FC<WebLoginProps> = (props) => {
className="size-5 text-[#999]"
/>
)}
</PopoverButton>
</PopoverTrigger>
<PopoverPanel
className={clsx(
"absolute z-50 w-[300px] rounded-xl bg-white dark:bg-[#202126] text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 -translate-y-2",
panelClassName
)}
>
<PopoverContent {...props} className="p-0">
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span>{t("webLogin.title")}</span>
<RefreshButton />
<RefreshButton className="size-6" />
</div>
<div className="py-2">
{loginInfo ? (
<div className="flex justify-between items-center">
<div className="flex justify-between items-center gap-3">
<div className="flex items-center gap-3">
<UserAvatar
className="!size-12"
icon={{ className: "!size-6" }}
className="h-12 w-12"
icon={{ className: "h-6 w-6" }}
/>
<div className="flex flex-col">
<span>{loginInfo.name}</span>
<span className="text-[#999]">{loginInfo.email}</span>
<span>{loginInfo?.name}</span>
<span className="text-[#999]">{loginInfo?.email}</span>
</div>
</div>
<button
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10"
<Button
variant="outline"
size="icon"
className="size-6"
onClick={async () => {
await Post("/account/logout", void 0);
@@ -77,7 +75,7 @@ const WebLogin: FC<WebLoginProps> = (props) => {
"size-3 text-[#0287FF] transition-transform duration-1000"
)}
/>
</button>
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-3">
@@ -93,10 +91,10 @@ const WebLogin: FC<WebLoginProps> = (props) => {
</div>
</div>
<div className="p-3 border-t dark:border-t-white/10">
<div className="p-3 border-t border-border">
<Copyright />
</div>
</PopoverPanel>
</PopoverContent>
</Popover>
</div>
);

View File

@@ -1,52 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[6px] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-[6px] px-3 text-xs",
lg: "h-10 rounded-[6px] px-8",
sm: "h-8 px-3",
md: "h-9 px-4",
lg: "h-10 px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
size: "md",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
({ className, variant, size, ...props }, ref) => {
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);

View File

@@ -0,0 +1,212 @@
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
style={{
// @ts-ignore
"--cell-size": "2rem",
}}
className={cn(
"bg-background group/calendar p-3 [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex gap-4", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[var(--cell-size)] w-[var(--cell-size)] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[var(--cell-size)] w-[var(--cell-size)] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[var(--cell-size)] w-full items-center justify-center px-[var(--cell-size)]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[var(--cell-size)] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[var(--cell-size)] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[var(--cell-size)] items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[var(--cell-size)] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-input bg-background shadow ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-primary-foreground">
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,43 @@
import { FC, memo } from "react";
import { PropsRange } from "react-day-picker";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { Calendar } from "./calendar";
import { CalendarIcon } from "lucide-react";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
const DatePickerRange: FC<Partial<PropsRange>> = (props) => {
const { selected } = props;
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<div className="h-8 flex items-center justify-between px-2 border border-border rounded-lg">
{selected ? (
<div className="flex items-center gap-2">
<span>{dayjs(selected.from).format("YYYY-MM-DD")}</span>
<span className="text-muted-foreground">-</span>
<span>{dayjs(selected.to).format("YYYY-MM-DD")}</span>
</div>
) : (
<div className="text-muted-foreground">
{t("search.filers.selectDateRange")}
</div>
)}
<CalendarIcon className="size-4 text-muted-foreground" />
</div>
</PopoverTrigger>
<PopoverContent>
<div>
<Calendar mode="range" numberOfMonths={2} {...props} />
</div>
</PopoverContent>
</Popover>
);
};
export default memo(DatePickerRange);

View File

@@ -0,0 +1,108 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-3000 bg-black/60 backdrop-blur-sm",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, overlayClassName, ...props }, ref) => (
<DialogPortal>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-3001 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-input bg-background p-6 text-foreground shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background",
className
)}
{...props}
/>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
};

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, side = "bottom", align = "start", sideOffset = 8, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
side={side}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-40 rounded-lg border border-input bg-background p-1 text-foreground shadow-lg",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm outline-none focus:bg-muted",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-3 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
};

Some files were not shown because too many files have changed in this diff Show More