mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-18 20:39:25 +01:00
Compare commits
44 Commits
fix-macos-
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac0065234 | ||
|
|
31806b6057 | ||
|
|
533bfaf45b | ||
|
|
459705af70 | ||
|
|
84e556ddad | ||
|
|
b50a20c7d4 | ||
|
|
d4ccd780b2 | ||
|
|
aef934e9a2 | ||
|
|
1fb927c26b | ||
|
|
8974624b3c | ||
|
|
d99b35bf4c | ||
|
|
594d0ffe3f | ||
|
|
7b08a87766 | ||
|
|
ab5ca24270 | ||
|
|
c593b07187 | ||
|
|
c088dde749 | ||
|
|
1fdf7c499d | ||
|
|
01dfc616d4 | ||
|
|
8d7d655581 | ||
|
|
5292538dd7 | ||
|
|
bab98d4576 | ||
|
|
6067fa7029 | ||
|
|
61860b400f | ||
|
|
50518b6c21 | ||
|
|
b5d3ce9910 | ||
|
|
abac92d8d5 | ||
|
|
9ea7c9b1ff | ||
|
|
fcbc77fb5a | ||
|
|
60b34a118b | ||
|
|
3e0839f3da | ||
|
|
bd61faf660 | ||
|
|
0e48f4f71c | ||
|
|
24fe7144f8 | ||
|
|
e92eee1ecf | ||
|
|
1996298f0c | ||
|
|
c879c63b17 | ||
|
|
892fe78d03 | ||
|
|
e5860f63c7 | ||
|
|
fa9656bfd7 | ||
|
|
03954748b6 | ||
|
|
4a627cb32e | ||
|
|
3029303e95 | ||
|
|
fc7cd165a8 | ||
|
|
f267df3f71 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -110,10 +110,10 @@ jobs:
|
||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||
# And bindgen relies on 'libclang'
|
||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||
- name: Install dependencies (Windows only)
|
||||
if: startsWith(matrix.platform, 'windows-latest')
|
||||
shell: bash
|
||||
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
||||
#
|
||||
# We don't need to install it because it is already included in GitHub
|
||||
# Action runner image:
|
||||
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||
|
||||
|
||||
- name: Add Rust build target
|
||||
|
||||
8
.github/workflows/rust_code_check.yml
vendored
8
.github/workflows/rust_code_check.yml
vendored
@@ -35,10 +35,10 @@ jobs:
|
||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||
# And bindgen relies on 'libclang'
|
||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||
- name: Install dependencies (Windows only)
|
||||
if: startsWith(matrix.platform, 'windows-latest')
|
||||
shell: bash
|
||||
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
||||
#
|
||||
# We don't need to install it because it is already included in GitHub
|
||||
# Action runner image:
|
||||
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||
|
||||
- name: Add pizza engine as a dependency
|
||||
working-directory: src-tauri
|
||||
|
||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/main.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -13,38 +13,63 @@ Information about release notes of Coco App is provided here.
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
feat: support switching groups via keyboard shortcuts #911
|
||||
feat: support opening logs from about page #915
|
||||
feat: support moving cursor with home and end keys #918
|
||||
feat: support pageup/pagedown to navigate search results #920
|
||||
feat: standardize multi-level menu label structure #925
|
||||
feat(View Extension): page field now accepts HTTP(s) links #925
|
||||
feat: return sub-exts when extension type exts themselves are matched #928
|
||||
feat: open quick ai with modifier key + enter #939
|
||||
feat: allow navigate back when cursor is at the beginning #940
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
fix: automatic update of service list #913
|
||||
fix: duplicate chat content #916
|
||||
fix: resolve pinned window shortcut not working #917
|
||||
fix: WM ext does not work when operating focused win from another display #919
|
||||
fix(Window Management): Next/Previous Desktop do not work #926
|
||||
fix: fix page rapidly flickering issue #935
|
||||
fix(view extension): broken search bar UI when opening extensions via hotkey #938
|
||||
fix: allow deletion after selecting all text #943
|
||||
- fix: search_extension should not panic when ext is not found #983
|
||||
- fix: persist configuration settings properly #987
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
refactor: improve sorting logic of search results #910
|
||||
style: add dark drop shadow to images #912
|
||||
chore: add cross-domain configuration for web component #921
|
||||
refactor: retry if AXUIElementSetAttributeValue() does not work #924
|
||||
refactor(calculator): skip evaluation if expr is in form "num => num" #929
|
||||
chore: use a custom log directory #930
|
||||
chore: bump tauri_nspanel to v2.1 #933
|
||||
refactor: show_coco/hide_coco now use NSPanel's function on macOS #933
|
||||
refactor: procedure that convert_pages() into a func #934
|
||||
## 0.9.0 (2025-11-19)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: support switching groups via keyboard shortcuts #911
|
||||
- feat: support opening logs from about page #915
|
||||
- feat: support moving cursor with home and end keys #918
|
||||
- feat: support pageup/pagedown to navigate search results #920
|
||||
- feat: standardize multi-level menu label structure #925
|
||||
- feat(View Extension): page field now accepts HTTP(s) links #925
|
||||
- feat: return sub-exts when extension type exts themselves are matched #928
|
||||
- feat: open quick ai with modifier key + enter #939
|
||||
- feat: allow navigate back when cursor is at the beginning #940
|
||||
- feat(extension compatibility): minimum_coco_version #946
|
||||
- feat: add compact mode for window #947
|
||||
- feat: advanced settings search debounce & local query source weight #950
|
||||
- feat: add window opacity configuration option #963
|
||||
- feat: add auto collapse delay for compact mode #981
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: automatic update of service list #913
|
||||
- fix: duplicate chat content #916
|
||||
- fix: resolve pinned window shortcut not working #917
|
||||
- fix: WM ext does not work when operating focused win from another display #919
|
||||
- fix(Window Management): Next/Previous Desktop do not work #926
|
||||
- fix: fix page rapidly flickering issue #935
|
||||
- fix(view extension): broken search bar UI when opening extensions via hotkey #938
|
||||
- fix: allow deletion after selecting all text #943
|
||||
- fix: prevent shaking when switching between chat and search pages #955
|
||||
- fix: prevent duplicate login success messages #977
|
||||
- fix: fix quick ai not continuing conversation #979
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
- refactor: improve sorting logic of search results #910
|
||||
- style: add dark drop shadow to images #912
|
||||
- chore: add cross-domain configuration for web component #921
|
||||
- refactor: retry if AXUIElementSetAttributeValue() does not work #924
|
||||
- refactor(calculator): skip evaluation if expr is in form "num => num" #929
|
||||
- chore: use a custom log directory #930
|
||||
- chore: bump tauri_nspanel to v2.1 #933
|
||||
- refactor: show_coco/hide_coco now use NSPanel's function on macOS #933
|
||||
- refactor: procedure that convert_pages() into a func #934
|
||||
- refactor(post-search): collect at least 2 documents from each query source #948
|
||||
- refactor: custom_version_comparator() now compares semantic versions #941
|
||||
- chore: center the main window vertically #959
|
||||
- refactor(view extension): load HTML/resources via local HTTP server #973
|
||||
|
||||
## 0.8.0 (2025-09-28)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||
@@ -34,7 +35,8 @@
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@wavesurfer/react": "^1.0.11",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
@@ -59,6 +61,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||
|
||||
1079
pnpm-lock.yaml
generated
1079
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
329
src-tauri/Cargo.lock
generated
329
src-tauri/Cargo.lock
generated
@@ -2,6 +2,212 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "actix-codec"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-files"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c0d87f10d70e2948ad40e8edea79c8e77c6c66e0250a4c1f09b690465199576"
|
||||
dependencies = [
|
||||
"actix-http",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"actix-web",
|
||||
"bitflags 2.9.4",
|
||||
"bytes",
|
||||
"derive_more 2.0.1",
|
||||
"futures-core",
|
||||
"http-range",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"v_htmlescape",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-http"
|
||||
version = "3.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-rt",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.9.4",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"derive_more 2.0.1",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"foldhash",
|
||||
"futures-core",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"language-tags",
|
||||
"local-channel",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-macros"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-router"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
|
||||
dependencies = [
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
"http 0.2.12",
|
||||
"regex",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-rt"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-server"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502"
|
||||
dependencies = [
|
||||
"actix-rt",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"mio 1.0.4",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-service"
|
||||
version = "2.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-utils"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8"
|
||||
dependencies = [
|
||||
"local-waker",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-web"
|
||||
version = "4.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-http",
|
||||
"actix-macros",
|
||||
"actix-router",
|
||||
"actix-rt",
|
||||
"actix-server",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"actix-web-codegen",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
"cookie 0.16.2",
|
||||
"derive_more 2.0.1",
|
||||
"encoding_rs",
|
||||
"foldhash",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"impl-more",
|
||||
"itoa",
|
||||
"language-tags",
|
||||
"log",
|
||||
"mime",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"regex",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"socket2 0.5.10",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-web-codegen"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8"
|
||||
dependencies = [
|
||||
"actix-router",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.24.2"
|
||||
@@ -668,6 +874,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytestring"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.0"
|
||||
@@ -869,8 +1084,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coco"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"actix-files",
|
||||
"actix-web",
|
||||
"anyhow",
|
||||
"applications",
|
||||
"async-recursion",
|
||||
@@ -1058,6 +1275,17 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -1075,7 +1303,7 @@ version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"cookie 0.18.1",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
@@ -1899,6 +2127,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
@@ -2649,6 +2883,25 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap 2.11.4",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.12"
|
||||
@@ -2879,7 +3132,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"h2 0.4.12",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
@@ -2942,7 +3195,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -3137,6 +3390,12 @@ version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
|
||||
|
||||
[[package]]
|
||||
name = "impl-more"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -3438,6 +3697,12 @@ dependencies = [
|
||||
"selectors 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "language-tags"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -3597,6 +3862,23 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "local-channel"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"local-waker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "local-waker"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.13"
|
||||
@@ -5187,7 +5469,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"thiserror 2.0.16",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5224,7 +5506,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -5510,6 +5792,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.6"
|
||||
@@ -5533,12 +5821,12 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie 0.18.1",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"h2 0.4.12",
|
||||
"http 1.3.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
@@ -6234,6 +6522,16 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.0"
|
||||
@@ -6579,7 +6877,7 @@ checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie 0.18.1",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
@@ -7091,7 +7389,7 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"cookie 0.18.1",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http 1.3.1",
|
||||
@@ -7345,7 +7643,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
@@ -7567,6 +7865,7 @@ version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -7855,6 +8154,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "v_htmlescape"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.11.1"
|
||||
@@ -8978,7 +9283,7 @@ checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2 0.6.1",
|
||||
"cookie",
|
||||
"cookie 0.18.1",
|
||||
"crossbeam-channel",
|
||||
"dirs 6.0.0",
|
||||
"dpi",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2024"
|
||||
@@ -117,6 +117,8 @@ urlencoding = "2.1.3"
|
||||
scraper = "0.17"
|
||||
toml = "0.8"
|
||||
path-clean = "1.0.1"
|
||||
actix-files = "0.6.8"
|
||||
actix-web = "4.11.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::extension::built_in::window_management::actions::Action;
|
||||
use crate::extension::view_extension::serve_files_in;
|
||||
use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
@@ -86,6 +88,10 @@ pub(crate) enum ExtensionOnOpenedType {
|
||||
open_with: Option<String>,
|
||||
},
|
||||
View {
|
||||
/// Extension name
|
||||
name: String,
|
||||
// An absolute path to the extension icon or a font code.
|
||||
icon: String,
|
||||
/// Path to the HTML file that coco will load and render.
|
||||
///
|
||||
/// It should be an absolute path or Tauri cannot open it.
|
||||
@@ -120,7 +126,12 @@ impl OnOpened {
|
||||
// The URL of a quicklink is nearly useless without such dynamic user
|
||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||
ExtensionOnOpenedType::View { page: _, ui: _ } => {
|
||||
ExtensionOnOpenedType::View {
|
||||
name: _,
|
||||
icon: _,
|
||||
page: _,
|
||||
ui: _,
|
||||
} => {
|
||||
// We currently don't have URL for this kind of extension.
|
||||
String::from("N/A")
|
||||
}
|
||||
@@ -233,32 +244,49 @@ pub(crate) async fn open(
|
||||
}
|
||||
}
|
||||
}
|
||||
ExtensionOnOpenedType::View { page, ui } => {
|
||||
ExtensionOnOpenedType::View {
|
||||
name,
|
||||
icon,
|
||||
page,
|
||||
ui,
|
||||
} => {
|
||||
let page_path = Utf8Path::new(&page);
|
||||
let directory = page_path.parent().unwrap_or_else(|| {
|
||||
panic!("View extension page path should have a parent, i.e., it should be under a directory, but [{}] does not", page);
|
||||
});
|
||||
let mut url = serve_files_in(directory.as_ref()).await;
|
||||
|
||||
/*
|
||||
* Emit an event to let the frontend code open this extension.
|
||||
*
|
||||
* Payload `page_and_permission` contains the information needed
|
||||
* Payload `view_extension_opened` contains the information needed
|
||||
* to do that.
|
||||
*
|
||||
* See "src/pages/main/index.tsx" for more info.
|
||||
*/
|
||||
use camino::Utf8Path;
|
||||
use serde_json::Value as Json;
|
||||
use serde_json::to_value;
|
||||
|
||||
let mut extra_args =
|
||||
extra_args.expect("extra_args is needed to open() a view extension");
|
||||
let document = extra_args.remove("document").expect(
|
||||
"extra argument [document] should be provided to open a view extension",
|
||||
);
|
||||
let html_filename = page_path
|
||||
.file_name()
|
||||
.unwrap_or_else(|| {
|
||||
panic!("View extension page path should have a file name, but [{}] does not have one", page);
|
||||
}).to_string();
|
||||
url.push('/');
|
||||
url.push_str(&html_filename);
|
||||
|
||||
let page_and_permission: [Json; 4] = [
|
||||
Json::String(page),
|
||||
let html_file_url = url;
|
||||
debug!("View extension listening on: {}", html_file_url);
|
||||
let view_extension_opened: [Json; 5] = [
|
||||
Json::String(name),
|
||||
Json::String(icon),
|
||||
Json::String(html_file_url),
|
||||
to_value(permission).unwrap(),
|
||||
to_value(ui).unwrap(),
|
||||
document,
|
||||
];
|
||||
tauri_app_handle
|
||||
.emit("open_view_extension", page_and_permission)
|
||||
.emit("open_view_extension", view_extension_opened)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ impl SearchQuery {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Hash, PartialEq, Eq)]
|
||||
pub struct QuerySource {
|
||||
pub r#type: String, //coco-server/local/ etc.
|
||||
pub id: String, //coco server's id
|
||||
|
||||
@@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
||||
name,
|
||||
platforms: None,
|
||||
developer: None,
|
||||
minimum_coco_version: None,
|
||||
// Leave it empty as it won't be used
|
||||
description: String::new(),
|
||||
icon: icon_path,
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
pub(crate) mod api;
|
||||
pub(crate) mod built_in;
|
||||
pub(crate) mod third_party;
|
||||
pub(crate) mod view_extension;
|
||||
|
||||
use crate::common::document::ExtensionOnOpened;
|
||||
use crate::common::document::ExtensionOnOpenedType;
|
||||
use crate::common::document::OnOpened;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::util::platform::Platform;
|
||||
use crate::util::version::COCO_VERSION;
|
||||
use crate::util::version::parse_coco_semver;
|
||||
use anyhow::Context;
|
||||
use bitflags::bitflags;
|
||||
use borrowme::{Borrow, ToOwned};
|
||||
use derive_more::Display;
|
||||
use indexmap::IndexMap;
|
||||
use semver::Version as SemVer;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
@@ -24,6 +29,7 @@ use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
||||
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
||||
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
@@ -39,10 +45,9 @@ pub struct Extension {
|
||||
name: String,
|
||||
/// ID of the developer.
|
||||
///
|
||||
/// * For built-in extensions, this will always be None.
|
||||
/// * For third-party first-layer extensions, the on-disk plugin.json file
|
||||
/// won't contain this field, but we will set this field for them after reading them into the memory.
|
||||
/// * For third-party sub extensions, this field will be None.
|
||||
/// * For built-in extensions, this is None.
|
||||
/// * For third-party main extensions, this field contains the extension developer ID.
|
||||
/// * For third-party sub extensions, this field is be None.
|
||||
developer: Option<String>,
|
||||
/// Platforms supported by this extension.
|
||||
///
|
||||
@@ -110,6 +115,9 @@ pub struct Extension {
|
||||
|
||||
/// For View extensions, path to the HTML file/page that coco will load
|
||||
/// and render. Otherwise, `None`.
|
||||
///
|
||||
/// It could be a path relative to the extension root directory, Coco will
|
||||
/// canonicalize it in that case.
|
||||
page: Option<String>,
|
||||
|
||||
ui: Option<ViewExtensionUISettings>,
|
||||
@@ -117,6 +125,16 @@ pub struct Extension {
|
||||
/// Permission that this extension requires.
|
||||
permission: Option<ExtensionPermission>,
|
||||
|
||||
/// The version of Coco app that this extension requires.
|
||||
///
|
||||
/// If not set, then this extension is compatible with all versions of Coco app.
|
||||
///
|
||||
/// 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
|
||||
minimum_coco_version: Option<SemVer>,
|
||||
|
||||
/*
|
||||
* The following fields are currently useless to us but are needed by our
|
||||
* extension store.
|
||||
@@ -275,12 +293,19 @@ impl Extension {
|
||||
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 { page, ui };
|
||||
let extension_on_opened_type = ExtensionOnOpenedType::View {
|
||||
name,
|
||||
icon,
|
||||
page,
|
||||
ui,
|
||||
};
|
||||
let extension_on_opened = ExtensionOnOpened {
|
||||
ty: extension_on_opened_type,
|
||||
settings,
|
||||
@@ -290,6 +315,9 @@ impl Extension {
|
||||
|
||||
Some(on_opened)
|
||||
}
|
||||
ExtensionType::Unknown => {
|
||||
unreachable!("Extensions of type [Unknown] should never be opened")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +392,26 @@ impl Extension {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize Coco SemVer from a string.
|
||||
///
|
||||
/// This function adapts `parse_coco_semver` to work with serde's `deserialize_with`
|
||||
/// attribute.
|
||||
fn deserialize_coco_semver<'de, D>(deserializer: D) -> Result<Option<SemVer>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let version_str: Option<String> = Option::deserialize(deserializer)?;
|
||||
let Some(version_str) = version_str else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(semver) = parse_coco_semver(&version_str) else {
|
||||
return Err(serde::de::Error::custom("version string format is invalid"));
|
||||
};
|
||||
|
||||
Ok(Some(semver))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub(crate) struct CommandAction {
|
||||
pub(crate) exec: String,
|
||||
@@ -567,6 +615,10 @@ pub enum ExtensionType {
|
||||
AiExtension,
|
||||
#[display("View")]
|
||||
View,
|
||||
/// Add this variant for better compatibility: Future versions of Coco may
|
||||
/// add new extension types that older versions of Coco are not aware of.
|
||||
#[display("Unknown")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ExtensionType {
|
||||
@@ -814,6 +866,22 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `extension` compatible with the current running Coco app?
|
||||
///
|
||||
/// It is defined as a tauri command rather than an associated function because
|
||||
/// it will be used in frontend code as well.
|
||||
///
|
||||
/// Async tauri commands are required to return `Result<T, E>`, this function
|
||||
/// only needs to return a boolean, so it is not marked async.
|
||||
#[tauri::command]
|
||||
pub(crate) fn is_extension_compatible(extension: Extension) -> bool {
|
||||
let Some(ref minimum_coco_version) = extension.minimum_coco_version else {
|
||||
return true;
|
||||
};
|
||||
|
||||
COCO_VERSION.deref() >= minimum_coco_version
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn enable_extension(
|
||||
tauri_app_handle: AppHandle,
|
||||
@@ -921,6 +989,13 @@ pub(crate) fn canonicalize_relative_icon_path(
|
||||
let icon_path = Path::new(icon_str);
|
||||
|
||||
if icon_path.is_relative() {
|
||||
// If we enter this if statement, then there are 2 possible cases:
|
||||
//
|
||||
// 1. icon_path is a font class code, e.g., "font_coco"
|
||||
// 2. icon_path is a indeed a relative path
|
||||
//
|
||||
// We distinguish between these 2 cases by checking if `absolute_icon_path` exists
|
||||
|
||||
let absolute_icon_path = {
|
||||
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
|
||||
assets_directory.push(icon_path);
|
||||
|
||||
24
src-tauri/src/extension/third_party/check.rs
vendored
24
src-tauri/src/extension/third_party/check.rs
vendored
@@ -14,6 +14,7 @@
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||
use crate::util::platform::Platform;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -179,6 +180,13 @@ fn check_sub_extension_only(
|
||||
}
|
||||
}
|
||||
|
||||
if sub_extension.minimum_coco_version.is_some() {
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: [{}] cannot be set for sub-extensions",
|
||||
extension_id, sub_extension.id, PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -278,6 +286,7 @@ mod tests {
|
||||
ui: None,
|
||||
permission: None,
|
||||
settings: None,
|
||||
minimum_coco_version: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
@@ -541,6 +550,21 @@ mod tests {
|
||||
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_set_minimum_coco_version() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.minimum_coco_version = Some(semver::Version::new(0, 8, 0));
|
||||
extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains(&format!(
|
||||
"[{}] cannot be set for sub-extensions",
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
)));
|
||||
}
|
||||
/* Test check_sub_extension_only */
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::check_compatibility_via_mcv;
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::install::{
|
||||
filter_out_incompatible_sub_extensions, is_extension_installed, view_extension_convert_pages,
|
||||
filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||
};
|
||||
use crate::extension::third_party::{
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||
@@ -79,6 +80,10 @@ pub(crate) async fn install_local_extension(
|
||||
let mut extension_json: Json =
|
||||
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
||||
|
||||
if !check_compatibility_via_mcv(&extension_json)? {
|
||||
return Err("app_incompatible".into());
|
||||
}
|
||||
|
||||
// Set the main extension ID to the directory name
|
||||
let extension_obj = extension_json
|
||||
.as_object_mut()
|
||||
@@ -158,7 +163,7 @@ pub(crate) async fn install_local_extension(
|
||||
//
|
||||
// This is definitely error-prone, but we have to do this until we have
|
||||
// structured error type
|
||||
return Err("incompatible".into());
|
||||
return Err("platform_incompatible".into());
|
||||
}
|
||||
}
|
||||
/* Check ends here */
|
||||
@@ -221,14 +226,6 @@ pub(crate) async fn install_local_extension(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
/*
|
||||
* Call convert_page() to update the page files. This has to be done after
|
||||
* writing the extension files because we will edit them.
|
||||
*
|
||||
* HTTP links will be skipped.
|
||||
*/
|
||||
view_extension_convert_pages(&extension, &dest_dir).await?;
|
||||
|
||||
// Canonicalize relative icon and page paths
|
||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
|
||||
|
||||
469
src-tauri/src/extension/third_party/install/mod.rs
vendored
469
src-tauri/src/extension/third_party/install/mod.rs
vendored
@@ -4,19 +4,27 @@
|
||||
//! # How
|
||||
//!
|
||||
//! Technically, installing an extension involves the following steps. The order
|
||||
//! may vary between implementations.
|
||||
//! varies between 2 implementations.
|
||||
//!
|
||||
//! 1. Check if it is already installed, if so, return
|
||||
//!
|
||||
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||
//! 2. Check if it is compatible by inspecting the "minimum_coco_version"
|
||||
//! field. If it is incompatible, reject and error out.
|
||||
//!
|
||||
//! This should be done before convert `plugin.json` JSON to `struct Extension`
|
||||
//! as the definition of `struct Extension` could change in the future, in this
|
||||
//! case, we want to tell users that "it is an incompatible extension" rather
|
||||
//! than "this extension is invalid".
|
||||
//!
|
||||
//! 3. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||
//! Extension` definition. This can happen because the JSON written by
|
||||
//! developers is in a simplified form for a better developer experience.
|
||||
//!
|
||||
//! 3. Validate the corrected `plugin.json`
|
||||
//! 4. Validate the corrected `plugin.json`
|
||||
//! 1. misc checks
|
||||
//! 2. Platform compatibility check
|
||||
//!
|
||||
//! 4. Write the extension files to the corresponding location
|
||||
//! 5. Write the extension files to the corresponding location
|
||||
//!
|
||||
//! * developer directory
|
||||
//! * extension directory
|
||||
@@ -25,11 +33,6 @@
|
||||
//! * plugin.json file
|
||||
//! * View pages if exist
|
||||
//!
|
||||
//! 5. If this extension contains any View extensions, call `convert_page()`
|
||||
//! on them to make them loadable by Tauri/webview.
|
||||
//!
|
||||
//! See `convert_page()` for more info.
|
||||
//!
|
||||
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
||||
//! relative paths
|
||||
//!
|
||||
@@ -42,10 +45,11 @@ pub(crate) mod local_extension;
|
||||
pub(crate) mod store;
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||
use crate::util::platform::Platform;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use crate::util::version::{COCO_VERSION, parse_coco_semver};
|
||||
use serde_json::Value as Json;
|
||||
use std::ops::Deref;
|
||||
|
||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
|
||||
@@ -117,174 +121,31 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the page file to make it loadable by the Tauri/Webview.
|
||||
pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
|
||||
assert!(absolute_page_path.is_absolute());
|
||||
|
||||
let page_content = tokio::fs::read_to_string(absolute_page_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_page_content = _convert_page(&page_content, absolute_page_path)?;
|
||||
|
||||
// overwrite it
|
||||
tokio::fs::write(absolute_page_path, new_page_content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// NOTE: There is no Rust implementation of `convertFileSrc()` in Tauri. Our
|
||||
/// impl here is based on [comment](https://github.com/tauri-apps/tauri/issues/12022#issuecomment-2572879115)
|
||||
fn convert_file_src(path: &Path) -> Result<String, String> {
|
||||
#[cfg(any(windows, target_os = "android"))]
|
||||
let base = "http://asset.localhost/";
|
||||
#[cfg(not(any(windows, target_os = "android")))]
|
||||
let base = "asset://localhost/";
|
||||
|
||||
let path =
|
||||
dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
let path_str = path.to_string_lossy();
|
||||
let encoded = urlencoding::encode(&path_str);
|
||||
|
||||
Ok(format!("{base}{encoded}"))
|
||||
}
|
||||
|
||||
/// Tauri cannot directly access the file system, to make a file loadable, we
|
||||
/// have to `canonicalize()` and `convertFileSrc()` its path before passing it
|
||||
/// to Tauri.
|
||||
///
|
||||
/// View extension's page is a HTML file that Coco (Tauri) will load, we need
|
||||
/// to process all `<PATH>` tags:
|
||||
///
|
||||
/// 1. `<script type="xxx" crossorigin src="<PATH>"></script>`
|
||||
/// 2. `<a href="<PATH>">xxx</a>`
|
||||
/// 3. `<link rel="xxx" href="<PATH>"/>`
|
||||
/// 4. `<img class="xxx" src="<PATH>" alt="xxx"/>`
|
||||
fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String, String> {
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
/// Helper function.
|
||||
///
|
||||
/// Search `document` for the tag attributes specified by `tag_with_attribute`
|
||||
/// and `tag_attribute`, call `convert_file_src()`, then update the attribute
|
||||
/// value with the function return value.
|
||||
fn modify_tag_attributes(
|
||||
document: &Html,
|
||||
modified_html: &mut String,
|
||||
base_dir: &Path,
|
||||
tag_with_attribute: &str,
|
||||
tag_attribute: &str,
|
||||
) -> Result<(), String> {
|
||||
let script_selector = Selector::parse(tag_with_attribute).unwrap();
|
||||
for element in document.select(&script_selector) {
|
||||
if let Some(src) = element.value().attr(tag_attribute) {
|
||||
if !src.starts_with("http://")
|
||||
&& !src.starts_with("https://")
|
||||
&& !src.starts_with("asset://")
|
||||
&& !src.starts_with("http://asset.localhost/")
|
||||
{
|
||||
// It could be a path like "/assets/index-41be3ec9.js", but it
|
||||
// is still a relative path. We need to remove the starting /
|
||||
// or path.join() will think it is an absolute path and does nothing
|
||||
let corrected_src = if src.starts_with('/') { &src[1..] } else { src };
|
||||
|
||||
let full_path = base_dir.join(corrected_src);
|
||||
|
||||
let converted_path = convert_file_src(full_path.as_path())?;
|
||||
*modified_html = modified_html.replace(
|
||||
&format!("{}=\"{}\"", tag_attribute, src),
|
||||
&format!("{}=\"{}\"", tag_attribute, converted_path),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let base_dir = absolute_page_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
|
||||
let document: Html = Html::parse_document(page_content);
|
||||
let mut modified_html: String = page_content.to_string();
|
||||
|
||||
modify_tag_attributes(
|
||||
&document,
|
||||
&mut modified_html,
|
||||
base_dir,
|
||||
"script[src]",
|
||||
"src",
|
||||
)?;
|
||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "a[href]", "href")?;
|
||||
modify_tag_attributes(
|
||||
&document,
|
||||
&mut modified_html,
|
||||
base_dir,
|
||||
"link[href]",
|
||||
"href",
|
||||
)?;
|
||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "img[src]", "src")?;
|
||||
|
||||
Ok(modified_html)
|
||||
}
|
||||
|
||||
async fn view_extension_convert_pages(
|
||||
extension: &Extension,
|
||||
extension_directory: &Path,
|
||||
) -> Result<(), String> {
|
||||
let pages: Vec<&str> = {
|
||||
if extension.r#type == ExtensionType::View {
|
||||
let page = extension
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
|
||||
vec![page.as_str()]
|
||||
} else if extension.r#type.contains_sub_items()
|
||||
&& let Some(ref views) = extension.views
|
||||
{
|
||||
let mut pages = Vec::with_capacity(views.len());
|
||||
|
||||
for view in views.iter() {
|
||||
let page = view
|
||||
.page
|
||||
.as_ref()
|
||||
.expect("View extension should set its page field");
|
||||
|
||||
pages.push(page.as_str());
|
||||
}
|
||||
|
||||
pages
|
||||
} else {
|
||||
// No pages in this extension
|
||||
Vec::new()
|
||||
}
|
||||
/// Inspect the "minimum_coco_version" field and see if this extension is
|
||||
/// compatible with the current Coco app.
|
||||
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, String> {
|
||||
let Some(mcv_json) = plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) else {
|
||||
return Ok(true);
|
||||
};
|
||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
||||
if page_path.is_relative() {
|
||||
// It is relative to the extension root directory
|
||||
extension_root.join(page_path)
|
||||
} else {
|
||||
page_path.into()
|
||||
}
|
||||
}
|
||||
for page in pages {
|
||||
/*
|
||||
* Skip HTTP links
|
||||
*/
|
||||
if let Ok(url) = url::Url::parse(page)
|
||||
&& ["http", "https"].contains(&url.scheme())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = canonicalize_page_path(Path::new(page), &extension_directory);
|
||||
convert_page(&path).await?;
|
||||
if mcv_json == &Json::Null {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let Some(mcv_str) = mcv_json.as_str() else {
|
||||
return Err(format!(
|
||||
"invalid extension: field [{}] should be a string",
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
));
|
||||
};
|
||||
|
||||
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||
return Err(format!(
|
||||
"invalid extension: [{}] is not a valid version string",
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
));
|
||||
};
|
||||
|
||||
Ok(COCO_VERSION.deref() >= &mcv)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -319,6 +180,7 @@ mod tests {
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
minimum_coco_version: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
@@ -488,257 +350,4 @@ mod tests {
|
||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
|
||||
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_script_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><script src="main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><script src=\"{}\"></script></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_script_tag_with_a_root_char() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><script src="/main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><script src=\"{}\"></script></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_a_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><a href="main.js">foo</a></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_a_tag_with_a_root_char() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><a href="/main.js">foo</a></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_link_href_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let css_file = temp_dir.path().join("main.css");
|
||||
|
||||
let html_content = r#"<html><body><link rel="stylesheet" href="main.css"/></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&css_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&css_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_link_href_tag_with_a_root_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let css_file = temp_dir.path().join("main.css");
|
||||
|
||||
let html_content = r#"<html><body><link rel="stylesheet" href="/main.css"/></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&css_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&css_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_img_src_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let png_file = temp_dir.path().join("main.png");
|
||||
|
||||
let html_content =
|
||||
r#"<html><body> <img class="fit-picture" src="main.png" alt="xxx" /></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&png_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&png_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_img_src_tag_with_a_root_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let png_file = temp_dir.path().join("main.png");
|
||||
|
||||
let html_content =
|
||||
r#"<html><body> <img class="fit-picture" src="/main.png" alt="xxx" /></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&png_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&png_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
||||
path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_contain_both_script_and_a_tags() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content =
|
||||
r#"<html><body><a href="main.js">foo</a><script src="main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
||||
path, path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_contain_both_script_and_a_tags_with_root_char() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
let js_file = temp_dir.path().join("main.js");
|
||||
|
||||
let html_content = r#"<html><body><a href="/main.js">foo</a><script src="/main.js"></script></body></html>"#;
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
std::fs::write(&js_file, "").unwrap();
|
||||
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
|
||||
let path = convert_file_src(&js_file).unwrap();
|
||||
let expected = format!(
|
||||
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
||||
path, path
|
||||
);
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_empty_html() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
|
||||
let html_content = "";
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_page_only_html_tag() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let html_file = temp_dir.path().join("test.html");
|
||||
|
||||
let html_content = "<html></html>";
|
||||
std::fs::write(&html_file, html_content).unwrap();
|
||||
let result = _convert_page(html_content, &html_file).unwrap();
|
||||
assert_eq!(result, html_content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Extension store related stuff.
|
||||
|
||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::check_compatibility_via_mcv;
|
||||
use super::is_extension_installed;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::common::document::Document;
|
||||
@@ -17,7 +18,6 @@ use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::get_third_party_extension_directory;
|
||||
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||
use crate::extension::third_party::install::view_extension_convert_pages;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
@@ -104,15 +104,23 @@ pub(crate) async fn search_extension(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// The response of a ES style search request
|
||||
let mut response: JsonObject<String, Json> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
|
||||
|
||||
let hits_json = response
|
||||
.remove("hits")
|
||||
.expect("the JSON response should contain field [hits]");
|
||||
let hits_json = response.remove("hits").unwrap_or_else(|| {
|
||||
panic!(
|
||||
"the JSON response should contain field [hits], response [{:?}]",
|
||||
response
|
||||
)
|
||||
});
|
||||
|
||||
let mut hits = match hits_json {
|
||||
Json::Object(obj) => obj,
|
||||
_ => panic!(
|
||||
@@ -259,6 +267,10 @@ pub(crate) async fn install_extension_from_store(
|
||||
let mut extension: Json = serde_json::from_str(&plugin_json_content)
|
||||
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
|
||||
|
||||
if !check_compatibility_via_mcv(&extension)? {
|
||||
return Err("app_incompatible".into());
|
||||
}
|
||||
|
||||
let mut_ref_to_developer_object: &mut Json = extension
|
||||
.as_object_mut()
|
||||
.expect("plugin.json should be an object")
|
||||
@@ -308,7 +320,7 @@ pub(crate) async fn install_extension_from_store(
|
||||
let current_platform = Platform::current();
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
return Err("this extension is not compatible with your OS".into());
|
||||
return Err("platform_incompatible".into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,14 +408,6 @@ pub(crate) async fn install_extension_from_store(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
/*
|
||||
* Call convert_page() to update the page files. This has to be done after
|
||||
* writing the extension files because we will edit them.
|
||||
*
|
||||
* HTTP links will be skipped.
|
||||
*/
|
||||
view_extension_convert_pages(&extension, &extension_directory).await?;
|
||||
|
||||
// Canonicalize relative icon and page paths
|
||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
|
||||
|
||||
192
src-tauri/src/extension/third_party/mod.rs
vendored
192
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -16,15 +16,21 @@ use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::ExtensionBundleIdBorrowed;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||
use crate::extension::calculate_text_similarity;
|
||||
use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::extension::is_extension_compatible;
|
||||
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::ToOwned;
|
||||
use check::general_check;
|
||||
use function_name::named;
|
||||
use std::collections::HashMap;
|
||||
use semver::Version as SemVer;
|
||||
use serde_json::Value as Json;
|
||||
use std::io::ErrorKind;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -124,6 +130,154 @@ pub(crate) async fn load_third_party_extensions_from_directory(
|
||||
let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let plugin_json = match serde_json::from_str::<Json>(&plugin_json_file_content) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: file [{}] is not a JSON, error: '{}'",
|
||||
extension_dir_file_name,
|
||||
plugin_json_file_path.display(),
|
||||
e
|
||||
);
|
||||
continue 'extension;
|
||||
}
|
||||
};
|
||||
let opt_mcv: Option<SemVer> = {
|
||||
match plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) {
|
||||
None => None,
|
||||
// NULL is considered None as well.
|
||||
Some(Json::Null) => None,
|
||||
|
||||
Some(mcv_json) => {
|
||||
let Some(mcv_str) = mcv_json.as_str() else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [{}] is not a string",
|
||||
extension_dir_file_name,
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [{}] has invalid version string",
|
||||
extension_dir_file_name,
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
Some(mcv)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let is_compatible: bool = match opt_mcv {
|
||||
Some(ref mcv) => COCO_VERSION.deref() >= mcv,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if !is_compatible {
|
||||
/*
|
||||
* Extract only these field: [id, name, icon, type] from the JSON,
|
||||
* then return a minimal Extension instance with these fields set:
|
||||
*
|
||||
* - `id` and `developer`: to make it identifiable
|
||||
* - `name`, `icon` and `type`: to display it in the Extensions page
|
||||
* - `minimum_coco_version`: so that we can check compatibility using it
|
||||
*/
|
||||
let Some(id) = plugin_json.get("id").and_then(|v| v.as_str()) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [id] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(name) = plugin_json.get("name").and_then(|v| v.as_str()) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [name] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(icon) = plugin_json.get("icon").and_then(|v| v.as_str()) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [icon] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(extension_type_str) = plugin_json.get("type").and_then(|v| v.as_str())
|
||||
else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [type] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let extension_type: ExtensionType = match serde_plain::from_str(extension_type_str)
|
||||
{
|
||||
Ok(t) => t,
|
||||
// Future Coco may have new Extension types that the we don't know
|
||||
//
|
||||
// This should be the only place where `ExtensionType::Unknown`
|
||||
// could be constructed.
|
||||
Err(_e) => ExtensionType::Unknown,
|
||||
};
|
||||
|
||||
// We don't extract the developer ID from the plugin.json to rely
|
||||
// less on it.
|
||||
let developer = developer_dir
|
||||
.file_name()
|
||||
.into_string()
|
||||
.expect("developer ID should be UTF-8 encoded");
|
||||
|
||||
let mut incompatible_extension = Extension {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
icon: icon.to_string(),
|
||||
r#type: extension_type,
|
||||
developer: Some(developer),
|
||||
description: String::new(),
|
||||
enabled: false,
|
||||
platforms: None,
|
||||
action: None,
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
views: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
permission: None,
|
||||
minimum_coco_version: opt_mcv,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
};
|
||||
|
||||
// Turn icon path into an absolute path if it is a valid relative path
|
||||
canonicalize_relative_icon_path(
|
||||
&extension_dir.path(),
|
||||
&mut incompatible_extension,
|
||||
)?;
|
||||
// No need to canonicalize the path field as it is not set
|
||||
|
||||
extensions.push(incompatible_extension);
|
||||
continue 'extension;
|
||||
}
|
||||
|
||||
/*
|
||||
* This is a compatible extension.
|
||||
*/
|
||||
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
||||
Ok(extension) => extension,
|
||||
Err(e) => {
|
||||
@@ -248,7 +402,6 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
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 extension_id_clone = extension.id.clone();
|
||||
|
||||
tauri_app_handle
|
||||
@@ -525,24 +678,6 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
|
||||
"setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type,
|
||||
));
|
||||
let url = on_opened.url();
|
||||
let extension_type_string = extension.r#type.to_string();
|
||||
let document = Document {
|
||||
id: extension.id.clone(),
|
||||
title: Some(extension.name.clone()),
|
||||
icon: Some(extension.icon.clone()),
|
||||
on_opened: Some(on_opened.clone()),
|
||||
url: Some(url),
|
||||
category: Some(extension_type_string.clone()),
|
||||
source: Some(DataSourceReference {
|
||||
id: Some(extension_type_string.clone()),
|
||||
name: Some(extension_type_string.clone()),
|
||||
icon: None,
|
||||
r#type: Some(extension_type_string),
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bundle_id_owned = bundle_id.to_owned();
|
||||
tauri_app_handle
|
||||
@@ -552,16 +687,9 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
let bundle_id_clone = bundle_id_owned.clone();
|
||||
let app_handle_clone = tauri_app_handle.clone();
|
||||
|
||||
let document_clone = document.clone();
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let mut args = HashMap::new();
|
||||
args.insert(
|
||||
String::from("document"),
|
||||
serde_json::to_value(&document_clone).unwrap(),
|
||||
);
|
||||
|
||||
let result = open(app_handle_clone, on_opened_clone, Some(args)).await;
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{:?}], error [{}]",
|
||||
@@ -782,7 +910,11 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
let extensions_read_lock =
|
||||
futures::executor::block_on(async { inner_clone.extensions.read().await });
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
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)))
|
||||
{
|
||||
if extension.r#type.contains_sub_items() {
|
||||
let opt_main_extension_lowercase_name =
|
||||
if extension.r#type == ExtensionType::Extension {
|
||||
@@ -832,7 +964,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
}
|
||||
|
||||
if let Some(ref views) = extension.views {
|
||||
for view in views.iter().filter(|link| link.enabled) {
|
||||
for view in views.iter().filter(|view| view.enabled) {
|
||||
if let Some(hit) = extension_to_hit(
|
||||
view,
|
||||
&query_lower,
|
||||
|
||||
38
src-tauri/src/extension/view_extension.rs
Normal file
38
src-tauri/src/extension/view_extension.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! View extension-related stuff
|
||||
|
||||
use actix_files::Files;
|
||||
use actix_web::{App, HttpServer, dev::ServerHandle};
|
||||
use std::path::Path;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
static FILE_SERVER_HANDLE: Mutex<Option<ServerHandle>> = Mutex::const_new(None);
|
||||
|
||||
/// Start a static HTTP file server serving the directory specified by `path`.
|
||||
/// Return the URL of the server.
|
||||
pub(crate) async fn serve_files_in(path: &Path) -> String {
|
||||
const ADDR: &str = "127.0.0.1";
|
||||
|
||||
let mut guard = FILE_SERVER_HANDLE.lock().await;
|
||||
if let Some(prev_server_handle) = guard.take() {
|
||||
prev_server_handle.stop(true).await;
|
||||
}
|
||||
|
||||
let path = path.to_path_buf();
|
||||
let http_server =
|
||||
HttpServer::new(move || App::new().service(Files::new("/", &path).show_files_listing()))
|
||||
// Set port to 0 and let OS assign a port to us
|
||||
.bind((ADDR, 0))
|
||||
.unwrap();
|
||||
|
||||
let assigned_port = http_server.addrs()[0].port();
|
||||
|
||||
let server = http_server.disable_signals().workers(1).run();
|
||||
|
||||
let new_handle = server.handle();
|
||||
|
||||
tokio::spawn(server);
|
||||
|
||||
*guard = Some(new_handle);
|
||||
|
||||
format!("http://{}:{}", ADDR, assigned_port)
|
||||
}
|
||||
@@ -19,11 +19,14 @@ use autostart::change_autostart;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
|
||||
use tauri::{
|
||||
AppHandle, Emitter, LogicalPosition, Manager, PhysicalPosition, WebviewWindow, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
|
||||
/// Tauri store name
|
||||
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||
pub(crate) const WINDOW_CENTER_BASELINE_HEIGHT: i32 = 590;
|
||||
|
||||
lazy_static! {
|
||||
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
|
||||
@@ -45,6 +48,26 @@ async fn change_window_height(handle: AppHandle, height: u32) {
|
||||
let mut size = window.outer_size().unwrap();
|
||||
size.height = height;
|
||||
window.set_size(size).unwrap();
|
||||
|
||||
// Center the window horizontally and vertically based on the baseline height of 590
|
||||
let monitor = window.primary_monitor().ok().flatten().or_else(|| {
|
||||
window
|
||||
.available_monitors()
|
||||
.ok()
|
||||
.and_then(|ms| ms.into_iter().next())
|
||||
});
|
||||
if let Some(monitor) = monitor {
|
||||
let monitor_position = monitor.position();
|
||||
let monitor_size = monitor.size();
|
||||
|
||||
let window_width = window.outer_size().unwrap().width as i32;
|
||||
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||
|
||||
let y =
|
||||
monitor_position.y + (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
|
||||
|
||||
let _ = window.set_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -90,7 +113,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
.default_version_comparator(crate::util::updater::custom_version_comparator)
|
||||
.default_version_comparator(crate::util::version::custom_version_comparator)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_windows_version::init())
|
||||
@@ -167,10 +190,13 @@ 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::is_extension_compatible,
|
||||
extension::api::apis,
|
||||
extension::api::fs::read_dir,
|
||||
settings::set_allow_self_signature,
|
||||
settings::get_allow_self_signature,
|
||||
settings::set_local_query_source_weight,
|
||||
settings::get_local_query_source_weight,
|
||||
assistant::ask_ai,
|
||||
crate::common::document::open,
|
||||
extension::built_in::file_search::config::get_file_system_config,
|
||||
@@ -321,95 +347,58 @@ async fn hide_coco(app_handle: AppHandle) {
|
||||
}
|
||||
|
||||
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
||||
//dbg!("Moving window to active monitor");
|
||||
// Try to get the available monitors, handle failure gracefully
|
||||
let available_monitors = match window.available_monitors() {
|
||||
Ok(monitors) => monitors,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get monitors: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let scale_factor = window.scale_factor().unwrap();
|
||||
|
||||
// Attempt to get the cursor position, handle failure gracefully
|
||||
let cursor_position = match window.cursor_position() {
|
||||
Ok(pos) => Some(pos),
|
||||
Err(e) => {
|
||||
log::error!("Failed to get cursor position: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
let point = window.cursor_position().unwrap();
|
||||
|
||||
// Find the monitor that contains the cursor or default to the primary monitor
|
||||
let target_monitor = if let Some(cursor_position) = cursor_position {
|
||||
// Convert cursor position to integers
|
||||
let cursor_x = cursor_position.x.round() as i32;
|
||||
let cursor_y = cursor_position.y.round() as i32;
|
||||
let LogicalPosition { x, y } = point.to_logical(scale_factor);
|
||||
|
||||
match window.monitor_from_point(x, y) {
|
||||
Ok(Some(monitor)) => {
|
||||
if let Some(name) = monitor.name() {
|
||||
let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||
if let Some(ref prev_name) = *previous_monitor_name {
|
||||
if name.to_string() == *prev_name {
|
||||
log::debug!("Currently on the same monitor");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the monitor that contains the cursor
|
||||
available_monitors.into_iter().find(|monitor| {
|
||||
let monitor_position = monitor.position();
|
||||
let monitor_size = monitor.size();
|
||||
|
||||
cursor_x >= monitor_position.x
|
||||
&& cursor_x <= monitor_position.x + monitor_size.width as i32
|
||||
&& cursor_y >= monitor_position.y
|
||||
&& cursor_y <= monitor_position.y + monitor_size.height as i32
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Current window size for horizontal centering
|
||||
let window_size = match window.inner_size() {
|
||||
Ok(size) => size,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get window size: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let window_width = window_size.width as i32;
|
||||
|
||||
// Use the target monitor or default to the primary monitor
|
||||
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
|
||||
Some(monitor) => monitor,
|
||||
None => {
|
||||
log::error!("No monitor found!");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Horizontal center uses actual width, vertical center uses 590 baseline
|
||||
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||
let window_y = monitor_position.y
|
||||
+ (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
|
||||
|
||||
if let Some(name) = monitor.name() {
|
||||
let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
|
||||
log::error!("Failed to move window: {}", e);
|
||||
}
|
||||
|
||||
if let Some(ref prev_name) = *previous_monitor_name {
|
||||
if name.to_string() == *prev_name {
|
||||
log::debug!("Currently on the same monitor");
|
||||
|
||||
return;
|
||||
if let Some(name) = monitor.name() {
|
||||
log::debug!("Window moved to monitor: {}", name);
|
||||
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||
*previous_monitor = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let monitor_position = monitor.position();
|
||||
let monitor_size = monitor.size();
|
||||
|
||||
// Get the current size of the window
|
||||
let window_size = match window.inner_size() {
|
||||
Ok(size) => size,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get window size: {}", e);
|
||||
return;
|
||||
Ok(None) => {
|
||||
log::error!("No monitor found at the specified point");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to get monitor from point: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
let window_width = window_size.width as i32;
|
||||
let window_height = window_size.height as i32;
|
||||
|
||||
// Calculate the new position to center the window on the monitor
|
||||
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||
let window_y = monitor_position.y + (monitor_size.height as i32 - window_height) / 2;
|
||||
|
||||
// Move the window to the new position
|
||||
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
|
||||
log::error!("Failed to move window: {}", e);
|
||||
}
|
||||
|
||||
if let Some(name) = monitor.name() {
|
||||
log::debug!("Window moved to monitor: {}", name);
|
||||
|
||||
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||
*previous_monitor = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::server::servers::logout_coco_server;
|
||||
use crate::server::servers::mark_server_as_offline;
|
||||
use crate::settings::get_local_query_source_weight;
|
||||
use function_name::named;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
@@ -205,7 +207,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_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
|
||||
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new();
|
||||
|
||||
while let Some((query_source, timeout_result)) = futures.next().await {
|
||||
match timeout_result {
|
||||
@@ -219,7 +221,6 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
Ok(query_result) => match query_result {
|
||||
Ok(response) => {
|
||||
total_hits += response.total_hits;
|
||||
let source_id = response.source.id.clone();
|
||||
|
||||
for (document, score) in response.hits {
|
||||
log::debug!(
|
||||
@@ -236,8 +237,8 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
document,
|
||||
};
|
||||
|
||||
all_hits_grouped_by_source_id
|
||||
.entry(source_id.clone())
|
||||
all_hits_grouped_by_query_source
|
||||
.entry(query_source.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(query_hit);
|
||||
}
|
||||
@@ -255,7 +256,7 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
}
|
||||
}
|
||||
|
||||
let n_sources = all_hits_grouped_by_source_id.len();
|
||||
let n_sources = all_hits_grouped_by_query_source.len();
|
||||
|
||||
if n_sources == 0 {
|
||||
return Ok(MultiSourceQueryResponse {
|
||||
@@ -265,11 +266,25 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Apply settings: local query source weight
|
||||
*/
|
||||
let local_query_source_weight: f64 = get_local_query_source_weight(tauri_app_handle);
|
||||
// Scores remain unchanged if it is 1.0
|
||||
if local_query_source_weight != 1.0 {
|
||||
for (query_source, hits) in all_hits_grouped_by_query_source.iter_mut() {
|
||||
if query_source.r#type == LOCAL_QUERY_SOURCE_TYPE {
|
||||
hits.iter_mut()
|
||||
.for_each(|hit| hit.score = hit.score * local_query_source_weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Sort hits within each source by score (descending) in case data sources
|
||||
* do not sort them
|
||||
*/
|
||||
for hits in all_hits_grouped_by_source_id.values_mut() {
|
||||
for hits in all_hits_grouped_by_query_source.values_mut() {
|
||||
hits.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
@@ -286,19 +301,17 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
let mut final_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
|
||||
let mut pruned: HashMap<&str, &[QueryHits]> = HashMap::new();
|
||||
|
||||
// max_hits_per_source could be 0, then `final_hits_grouped_by_source_id`
|
||||
// would be empty. But we don't need to worry about this case as we will
|
||||
// populate hits later.
|
||||
let max_hits_per_source = size as usize / n_sources;
|
||||
for (source_id, hits) in all_hits_grouped_by_source_id.iter() {
|
||||
// Include at least 2 hits from each query source
|
||||
let max_hits_per_source = (size as usize / n_sources).max(2);
|
||||
for (query_source, hits) in all_hits_grouped_by_query_source.iter() {
|
||||
let hits_taken = if hits.len() > max_hits_per_source {
|
||||
pruned.insert(&source_id, &hits[max_hits_per_source..]);
|
||||
pruned.insert(&query_source.id, &hits[max_hits_per_source..]);
|
||||
hits[0..max_hits_per_source].to_vec()
|
||||
} else {
|
||||
hits.clone()
|
||||
};
|
||||
|
||||
final_hits_grouped_by_source_id.insert(source_id.clone(), hits_taken);
|
||||
final_hits_grouped_by_source_id.insert(query_source.id.clone(), hits_taken);
|
||||
}
|
||||
|
||||
let final_hits_len = final_hits_grouped_by_source_id
|
||||
@@ -376,8 +389,6 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
});
|
||||
|
||||
// Truncate `final_hits` in case it contains more than `size` hits
|
||||
//
|
||||
// Technically, we are safe to not do this. But since it is trivial, double-check it.
|
||||
final_hits.truncate(size as usize);
|
||||
|
||||
if final_hits.len() < 5 {
|
||||
|
||||
@@ -4,6 +4,7 @@ use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
||||
const LOCAL_QUERY_SOURCE_WEIGHT: &str = "local_query_source_weight";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
|
||||
@@ -70,3 +71,45 @@ pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||
_get_allow_self_signature(tauri_app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_local_query_source_weight(tauri_app_handle: AppHandle, value: f64) {
|
||||
let store = tauri_app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"store [{}] not found/loaded, error [{}]",
|
||||
COCO_TAURI_STORE, e
|
||||
)
|
||||
});
|
||||
|
||||
store.set(LOCAL_QUERY_SOURCE_WEIGHT, value);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_local_query_source_weight(tauri_app_handle: AppHandle) -> f64 {
|
||||
// default to 1.0
|
||||
const DEFAULT: f64 = 1.0;
|
||||
|
||||
let store = tauri_app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"store [{}] not found/loaded, error [{}]",
|
||||
COCO_TAURI_STORE, e
|
||||
)
|
||||
});
|
||||
if !store.has(LOCAL_QUERY_SOURCE_WEIGHT) {
|
||||
store.set(LOCAL_QUERY_SOURCE_WEIGHT, DEFAULT);
|
||||
}
|
||||
|
||||
match store
|
||||
.get(LOCAL_QUERY_SOURCE_WEIGHT)
|
||||
.expect("should be Some")
|
||||
{
|
||||
Json::Number(n) => n
|
||||
.as_f64()
|
||||
.unwrap_or_else(|| panic!("setting [{}] should be a f64", LOCAL_QUERY_SOURCE_WEIGHT)),
|
||||
_ => unreachable!("{} should be stored as a number", LOCAL_QUERY_SOURCE_WEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ pub(crate) mod path;
|
||||
pub(crate) mod platform;
|
||||
pub(crate) mod prevent_default;
|
||||
pub(crate) mod system_lang;
|
||||
pub(crate) mod updater;
|
||||
pub(crate) mod version;
|
||||
|
||||
use std::{path::Path, process::Command};
|
||||
use tauri::AppHandle;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
use semver::Version;
|
||||
use tauri_plugin_updater::RemoteRelease;
|
||||
|
||||
/// Helper function to extract the build number out of `version`.
|
||||
///
|
||||
/// If the version string is in the `x.y.z` format and does not include a build
|
||||
/// number, we assume a build number of 0.
|
||||
fn extract_build_number(version: &Version) -> u32 {
|
||||
let pre = &version.pre;
|
||||
|
||||
if pre.is_empty() {
|
||||
// A special value for the versions that do not have array
|
||||
0
|
||||
} else {
|
||||
let pre_str = pre.as_str();
|
||||
let build_number_str = {
|
||||
match pre_str.strip_prefix("SNAPSHOT-") {
|
||||
Some(str) => str,
|
||||
None => pre_str,
|
||||
}
|
||||
};
|
||||
let build_number : u32 = build_number_str.parse().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"invalid build number, cannot parse [{}] to a valid build number, error [{}], version [{}]",
|
||||
build_number_str, e, version
|
||||
)
|
||||
});
|
||||
|
||||
build_number
|
||||
}
|
||||
}
|
||||
|
||||
/// # Local version format
|
||||
///
|
||||
/// Packages built in our CI use the following format:
|
||||
///
|
||||
/// * `x.y.z-SNAPSHOT-<build number>`
|
||||
/// * `x.y.z-<build number>`
|
||||
///
|
||||
/// If you build Coco from src, the version will be in format `x.y.z`
|
||||
///
|
||||
/// # Remote version format
|
||||
///
|
||||
/// `x.y.z-<build number>`
|
||||
///
|
||||
/// # How we compare versions
|
||||
///
|
||||
/// We compare versions based solely on the build number.
|
||||
/// If the version string is in the `x.y.z` format and does not include a build number,
|
||||
/// we assume a build number of 0. As a result, such versions are considered older
|
||||
/// than any version with an explicit build number.
|
||||
pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRelease) -> bool {
|
||||
let remote = remote_release.version;
|
||||
|
||||
let local_build_number = extract_build_number(&local);
|
||||
let remote_build_number = extract_build_number(&remote);
|
||||
|
||||
let should_update = remote_build_number > local_build_number;
|
||||
log::debug!(
|
||||
"custom version comparator invoked, local version [{}], remote version [{}], should update [{}]",
|
||||
local,
|
||||
remote,
|
||||
should_update
|
||||
);
|
||||
|
||||
should_update
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_build_number() {
|
||||
// 0.6.0 => 0
|
||||
let version = Version::parse("0.6.0").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 0);
|
||||
|
||||
// 0.6.0-2371 => 2371
|
||||
let version = Version::parse("0.6.0-2371").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 2371);
|
||||
|
||||
// 0.6.0-SNAPSHOT-2371 => 2371
|
||||
let version = Version::parse("0.6.0-SNAPSHOT-2371").unwrap();
|
||||
assert_eq!(extract_build_number(&version), 2371);
|
||||
}
|
||||
}
|
||||
245
src-tauri/src/util/version.rs
Normal file
245
src-tauri/src/util/version.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use semver::{BuildMetadata, Prerelease, Version as SemVer};
|
||||
use std::sync::LazyLock;
|
||||
use tauri_plugin_updater::RemoteRelease;
|
||||
|
||||
const SNAPSHOT_DASH: &str = "SNAPSHOT-";
|
||||
const SNAPSHOT_DASH_LEN: usize = SNAPSHOT_DASH.len();
|
||||
// trim the last dash
|
||||
const SNAPSHOT: &str = SNAPSHOT_DASH.split_at(SNAPSHOT_DASH_LEN - 1).0;
|
||||
|
||||
/// Coco app version, in SemVer format.
|
||||
pub(crate) static COCO_VERSION: LazyLock<SemVer> = LazyLock::new(|| {
|
||||
parse_coco_semver(env!("CARGO_PKG_VERSION")).expect("parsing should never fail, if version format changes, then parse_coco_semver() should be updated as well")
|
||||
});
|
||||
|
||||
/// Coco AI app adopt SemVer but the version string format does not adhere to
|
||||
/// the SemVer specification, this function does the conversion. Returns `None`
|
||||
/// if the input is not in the expected format so that the conversion cannot
|
||||
/// complete.
|
||||
///
|
||||
/// # Example cases
|
||||
///
|
||||
/// * 0.8.0 => 0.8.0
|
||||
///
|
||||
/// You may see this when you develop Coco locally
|
||||
///
|
||||
/// * 0.8.0-<build num> => 0.8.0
|
||||
///
|
||||
/// This is the official release for 0.8.0
|
||||
///
|
||||
/// * 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||
///
|
||||
/// A pre-release of 0.9.0
|
||||
fn to_semver(version: &SemVer) -> Option<SemVer> {
|
||||
let pre = &version.pre;
|
||||
|
||||
if pre.is_empty() {
|
||||
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||
}
|
||||
let is_pre_release = pre.starts_with(SNAPSHOT_DASH);
|
||||
|
||||
let build_number_str = if is_pre_release {
|
||||
&pre[SNAPSHOT_DASH_LEN..]
|
||||
} else {
|
||||
pre.as_str()
|
||||
};
|
||||
// Parse the build number to validate it, we do not need the actual number though.
|
||||
build_number_str.parse::<usize>().ok()?;
|
||||
|
||||
// Return after checking the build number is valid
|
||||
if !is_pre_release {
|
||||
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||
}
|
||||
|
||||
let pre = {
|
||||
let pre_str = format!("{}.{}", SNAPSHOT, build_number_str);
|
||||
Prerelease::new(&pre_str).unwrap_or_else(|e| panic!("invalid Prerelease: {}", e))
|
||||
};
|
||||
|
||||
Some(SemVer {
|
||||
major: version.major,
|
||||
minor: version.minor,
|
||||
patch: version.patch,
|
||||
pre,
|
||||
build: BuildMetadata::EMPTY,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse Coco version string to a `SemVer`. Returns `None` if it is not a valid
|
||||
/// version string.
|
||||
pub(crate) fn parse_coco_semver(version_str: &str) -> Option<SemVer> {
|
||||
let not_semver = SemVer::parse(version_str).ok()?;
|
||||
to_semver(¬_semver)
|
||||
}
|
||||
|
||||
pub(crate) fn custom_version_comparator(local: SemVer, remote_release: RemoteRelease) -> bool {
|
||||
let remote = remote_release.version;
|
||||
let local_semver = to_semver(&local);
|
||||
let remote_semver = to_semver(&remote);
|
||||
|
||||
let should_update = remote_semver > local_semver;
|
||||
|
||||
log::debug!(
|
||||
"custom version comparator invoked, local version [{}], remote version [{}], should update [{}]",
|
||||
local,
|
||||
remote,
|
||||
should_update
|
||||
);
|
||||
|
||||
should_update
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use tauri_plugin_updater::RemoteReleaseInner;
|
||||
|
||||
#[test]
|
||||
fn test_try_into_semver_local_dev() {
|
||||
// Case: 0.8.0 => 0.8.0
|
||||
// Local development version without any pre-release or build metadata
|
||||
let input = SemVer::parse("0.8.0").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 0);
|
||||
assert_eq!(result.minor, 8);
|
||||
assert_eq!(result.patch, 0);
|
||||
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||
assert!(result.build.is_empty());
|
||||
assert_eq!(result.to_string(), "0.8.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_into_semver_official_release() {
|
||||
// Case: 0.8.0-<build num> => 0.8.0
|
||||
// Official release with build number in pre-release field
|
||||
let input = SemVer::parse("0.8.0-123").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 0);
|
||||
assert_eq!(result.minor, 8);
|
||||
assert_eq!(result.patch, 0);
|
||||
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||
assert!(result.build.is_empty());
|
||||
assert_eq!(result.to_string(), "0.8.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_into_semver_pre_release() {
|
||||
// Case: 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||
// Pre-release version with SNAPSHOT prefix
|
||||
let input = SemVer::parse("0.9.0-SNAPSHOT-456").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 0);
|
||||
assert_eq!(result.minor, 9);
|
||||
assert_eq!(result.patch, 0);
|
||||
assert_eq!(result.pre.as_str(), "SNAPSHOT.456");
|
||||
assert!(result.build.is_empty());
|
||||
assert_eq!(result.to_string(), "0.9.0-SNAPSHOT.456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_into_semver_official_release_different_version() {
|
||||
// Test with different version numbers
|
||||
let input = SemVer::parse("1.2.3-9999").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 1);
|
||||
assert_eq!(result.minor, 2);
|
||||
assert_eq!(result.patch, 3);
|
||||
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||
assert!(result.build.is_empty());
|
||||
assert_eq!(result.to_string(), "1.2.3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_into_semver_snapshot_different_version() {
|
||||
// Test SNAPSHOT with different version numbers
|
||||
let input = SemVer::parse("2.0.0-SNAPSHOT-777").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 2);
|
||||
assert_eq!(result.minor, 0);
|
||||
assert_eq!(result.patch, 0);
|
||||
assert_eq!(result.pre.as_str(), "SNAPSHOT.777");
|
||||
assert!(result.build.is_empty());
|
||||
assert_eq!(result.to_string(), "2.0.0-SNAPSHOT.777");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_into_semver_invalid_build_number() {
|
||||
// Should panic when build number is not a valid number
|
||||
let input = SemVer::parse("0.8.0-abc").unwrap();
|
||||
assert!(to_semver(&input).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_into_semver_invalid_snapshot_build_number() {
|
||||
// Should panic when SNAPSHOT build number is not a valid number
|
||||
let input = SemVer::parse("0.9.0-SNAPSHOT-xyz").unwrap();
|
||||
assert!(to_semver(&input).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_version_comparator() {
|
||||
fn new_local(str: &str) -> SemVer {
|
||||
SemVer::parse(str).unwrap()
|
||||
}
|
||||
fn new_remote_release(str: &str) -> RemoteRelease {
|
||||
let version = SemVer::parse(str).unwrap();
|
||||
|
||||
RemoteRelease {
|
||||
version,
|
||||
notes: None,
|
||||
pub_date: None,
|
||||
data: RemoteReleaseInner::Static {
|
||||
platforms: HashMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
custom_version_comparator(new_local("0.8.0"), new_remote_release("0.8.0-2518")),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
custom_version_comparator(new_local("0.8.0-2518"), new_remote_release("0.8.0")),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.9.0")),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.8.1")),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.9.0-2")),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
custom_version_comparator(
|
||||
new_local("0.9.0-SNAPSHOT-1"),
|
||||
new_remote_release("0.9.0-SNAPSHOT-1")
|
||||
),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
custom_version_comparator(
|
||||
new_local("0.9.0-SNAPSHOT-11"),
|
||||
new_remote_release("0.9.0-SNAPSHOT-9")
|
||||
),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
custom_version_comparator(
|
||||
new_local("0.9.0-SNAPSHOT-11"),
|
||||
new_remote_release("0.9.0-SNAPSHOT-19")
|
||||
),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,20 +65,25 @@
|
||||
"height": 260,
|
||||
"minHeight": 260,
|
||||
"center": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"maximizable": false,
|
||||
"skipTaskbar": false,
|
||||
"dragDropEnabled": false,
|
||||
"hiddenTitle": true,
|
||||
"visible": false,
|
||||
"shadow": false,
|
||||
"windowEffects": {
|
||||
"effects": ["sidebar"],
|
||||
"state": "active"
|
||||
"state": "active",
|
||||
"radius": 7
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"csp": {
|
||||
"default-src": "'self' asset: http: https: ipc: blob: data:"
|
||||
},
|
||||
"dangerousDisableAssetCspModification": true,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
@@ -141,4 +146,4 @@
|
||||
},
|
||||
"os": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,19 @@ export const handleApiError = (error: any) => {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
const url =
|
||||
error?.config?.url ||
|
||||
error?.response?.config?.url ||
|
||||
error?.request?.config?.url;
|
||||
|
||||
const suppressProfileError =
|
||||
typeof url === "string" && url.includes("/account/profile");
|
||||
|
||||
console.error(error);
|
||||
addError(message, "error");
|
||||
if (!suppressProfileError) {
|
||||
addError(message, "error");
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
@@ -112,15 +123,11 @@ export const Post = <T>(
|
||||
}
|
||||
|
||||
axios
|
||||
.post(
|
||||
baseURL + url,
|
||||
data,
|
||||
{
|
||||
params,
|
||||
headers,
|
||||
withCredentials: true,
|
||||
} as any
|
||||
)
|
||||
.post(baseURL + url, data, {
|
||||
params,
|
||||
headers,
|
||||
withCredentials: true,
|
||||
} as any)
|
||||
.then((result) => {
|
||||
resolve([null, result.data as FcResponse<T>]);
|
||||
})
|
||||
|
||||
@@ -225,7 +225,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
autoFocus
|
||||
value={keyword}
|
||||
placeholder={t("assistant.popover.search")}
|
||||
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
|
||||
className="w-full h-8 px-2 bg-transparent border rounded-[6px] dark:border-white/10"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
|
||||
@@ -120,6 +120,12 @@ const ChatAI = memo(
|
||||
activeChatProp && setActiveChat(activeChatProp);
|
||||
}, [activeChatProp]);
|
||||
|
||||
useEffect(() => {
|
||||
const { setHasActiveChat } = useChatStore.getState();
|
||||
|
||||
setHasActiveChat(Boolean(activeChat));
|
||||
}, [activeChat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
@@ -198,7 +204,7 @@ const ChatAI = memo(
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
showChatHistory,
|
||||
getChatHistoryChatPage,
|
||||
getChatHistoryChatPage
|
||||
);
|
||||
|
||||
const { dealMsg } = useMessageHandler(
|
||||
@@ -382,7 +388,7 @@ const ChatAI = memo(
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
data-chat-instance={instanceId}
|
||||
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
|
||||
className={`flex flex-col rounded-[6px] h-full overflow-hidden relative`}
|
||||
>
|
||||
<ChatHeader
|
||||
clearChat={clearChat}
|
||||
|
||||
@@ -10,6 +10,9 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
// import SessionFile from "./SessionFile";
|
||||
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { NoResults } from "../Common/UI/NoResults";
|
||||
|
||||
interface ChatContentProps {
|
||||
activeChat?: Chat;
|
||||
@@ -97,87 +100,100 @@ export const ChatContent = ({
|
||||
setIsAtBottom(isAtBottom);
|
||||
};
|
||||
|
||||
const { isTauri } = useAppStore();
|
||||
const { disabled } = useWebConfigStore();
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{(!activeChat || activeChat?.messages?.length === 0) &&
|
||||
!visibleStartPage && <Greetings />}
|
||||
{!isTauri && disabled ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{(!activeChat || activeChat?.messages?.length === 0) &&
|
||||
!visibleStartPage && <Greetings />}
|
||||
|
||||
{activeChat?.messages?.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message._id + index}
|
||||
message={message}
|
||||
isTyping={false}
|
||||
onResend={handleSendMessage}
|
||||
/>
|
||||
))}
|
||||
{activeChat?.messages?.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message._id + index}
|
||||
message={message}
|
||||
isTyping={false}
|
||||
onResend={handleSendMessage}
|
||||
/>
|
||||
))}
|
||||
|
||||
{(!curChatEnd ||
|
||||
query_intent ||
|
||||
tools ||
|
||||
fetch_source ||
|
||||
pick_source ||
|
||||
deep_read ||
|
||||
think ||
|
||||
response) &&
|
||||
activeChat?._source?.id ? (
|
||||
<ChatMessage
|
||||
key={"current"}
|
||||
message={{
|
||||
_id: "current",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
assistant_id:
|
||||
allMessages[allMessages.length - 1]?._source?.assistant_id,
|
||||
message: "",
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={!curChatEnd}
|
||||
query_intent={query_intent}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
formatUrl={formatUrl}
|
||||
/>
|
||||
) : null}
|
||||
{(!curChatEnd ||
|
||||
query_intent ||
|
||||
tools ||
|
||||
fetch_source ||
|
||||
pick_source ||
|
||||
deep_read ||
|
||||
think ||
|
||||
response) &&
|
||||
activeChat?._source?.id ? (
|
||||
<ChatMessage
|
||||
key={"current"}
|
||||
message={{
|
||||
_id: "current",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
assistant_id:
|
||||
allMessages[allMessages.length - 1]?._source
|
||||
?.assistant_id,
|
||||
message: "",
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={!curChatEnd}
|
||||
query_intent={query_intent}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
formatUrl={formatUrl}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{timedoutShow ? (
|
||||
<ChatMessage
|
||||
key={"timedout"}
|
||||
message={{
|
||||
_id: "timedout",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.timedout"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{timedoutShow ? (
|
||||
<ChatMessage
|
||||
key={"timedout"}
|
||||
message={{
|
||||
_id: "timedout",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.timedout"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{uploadAttachments.length > 0 && (
|
||||
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
|
||||
<AttachmentList />
|
||||
</div>
|
||||
{uploadAttachments.length > 0 && (
|
||||
<div
|
||||
key={currentSessionId}
|
||||
className="max-h-[120px] overflow-auto p-2"
|
||||
>
|
||||
<AttachmentList />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
||||
|
||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
||||
|
||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HISTORY_PANEL_ID } from "@/constants";
|
||||
import { AssistantList } from "./AssistantList";
|
||||
import { ServerList } from "./ServerList";
|
||||
import TogglePin from "../Common/TogglePin";
|
||||
import WebLogin from "../WebLogin";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
clearChat: () => void;
|
||||
@@ -63,7 +64,7 @@ export function ChatHeader({
|
||||
|
||||
<AssistantList assistantIDs={assistantIDs} />
|
||||
|
||||
{showChatHistory ? (
|
||||
{showChatHistory && (
|
||||
<button
|
||||
onClick={clearChat}
|
||||
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
@@ -76,7 +77,7 @@ export function ChatHeader({
|
||||
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
@@ -100,7 +101,7 @@ export function ChatHeader({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
<WebLogin panelClassName="top-8 right-0" />
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -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-md text-[#0072ff] transition-colors"
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-[6px] text-[#0072ff] transition-colors"
|
||||
onClick={handleConnect}
|
||||
>
|
||||
<span>{t("assistant.chat.connect")}</span>
|
||||
|
||||
@@ -197,7 +197,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
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]" />
|
||||
@@ -205,7 +205,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
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}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useDebounceFn } from "ahooks";
|
||||
|
||||
import { UserProfile } from "./UserProfile";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
@@ -20,7 +21,6 @@ const ServiceAuth = memo(
|
||||
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const language = useAppStore((state) => state.language);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||
@@ -61,19 +61,21 @@ const ServiceAuth = memo(
|
||||
[logoutServer]
|
||||
);
|
||||
|
||||
const { run: debouncedAuthSuccess } = useDebounceFn((event) => {
|
||||
const { serverId } = event.payload;
|
||||
if (serverId) {
|
||||
refreshClick(serverId, () => {
|
||||
setLoading(false);
|
||||
});
|
||||
addError(t("cloud.connect.hints.loginSuccess"), "info");
|
||||
}
|
||||
});
|
||||
|
||||
// handle oauth success event
|
||||
useEffect(() => {
|
||||
const unlistenOAuth = platformAdapter.listenEvent(
|
||||
"oauth_success",
|
||||
(event) => {
|
||||
const { serverId } = event.payload;
|
||||
if (serverId) {
|
||||
refreshClick(serverId, () => {
|
||||
setLoading(false);
|
||||
});
|
||||
addError(language === "zh" ? "登录成功" : "Login Success", "info");
|
||||
}
|
||||
}
|
||||
debouncedAuthSuccess
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -163,7 +165,7 @@ const LoginButton: FC<LoginButtonProps> = memo((props) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-[6px] hover:bg-blue-600 transition-colors mb-3"
|
||||
onClick={LoginClick}
|
||||
aria-label={t("cloud.login")}
|
||||
>
|
||||
@@ -184,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-md hover:bg-red-600 transition-colors"
|
||||
className="px-6 py-2 text-white bg-red-500 rounded-[6px] hover:bg-red-600 transition-colors"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("cloud.cancel")}
|
||||
|
||||
@@ -62,13 +62,13 @@ const ApiDetails: React.FC = () => {
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 border rounded-md shadow-sm bg-gray-50"
|
||||
className="p-4 border rounded-[6px] 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-md whitespace-pre-wrap">
|
||||
<pre className="bg-gray-100 p-2 rounded-[6px] 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-md text-green-700 whitespace-pre-wrap">
|
||||
<pre className="bg-green-100 p-2 rounded-[6px] 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-md text-red-700 whitespace-pre-wrap">
|
||||
<pre className="bg-red-100 p-2 rounded-[6px] text-red-700 whitespace-pre-wrap">
|
||||
{JSON.stringify(log.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={isChatMode}
|
||||
className={`relative flex items-center justify-between w-10 h-[18px] rounded-full cursor-pointer transition-colors duration-300 ${
|
||||
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)]"
|
||||
}`}
|
||||
onClick={handleToggle}
|
||||
@@ -39,8 +39,8 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
||||
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute top-[1px] h-4 w-4 bg-white rounded-full shadow-md transform transition-transform duration-300 ${
|
||||
isChatMode ? "translate-x-6" : "translate-x-0"
|
||||
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"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ const ErrorNotification = ({
|
||||
}: ErrorNotificationProps) => {
|
||||
const errors = useAppStore((state) => state.errors);
|
||||
const removeError = useAppStore((state) => state.removeError);
|
||||
const suppressErrors = useAppStore((state) => state.suppressErrors);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoClose) return;
|
||||
@@ -32,7 +33,7 @@ const ErrorNotification = ({
|
||||
return () => clearInterval(timer);
|
||||
}, [errors, duration, autoClose]);
|
||||
|
||||
if (errors.length === 0) return null;
|
||||
if (errors.length === 0 || suppressErrors) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -169,7 +169,7 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
|
||||
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"
|
||||
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}>
|
||||
|
||||
@@ -27,7 +27,7 @@ const Tooltip2: FC<Tooltip2Props> = (props) => {
|
||||
static
|
||||
anchor={anchor}
|
||||
className={clsx(
|
||||
"fixed z-1000 p-2 rounded-md text-xs text-white bg-black/75 hidden",
|
||||
"fixed z-1000 p-2 rounded-[6px] text-xs text-white bg-black/75 hidden",
|
||||
{
|
||||
"!block": visible,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import CommonIcon from "@/components/Common/Icons/CommonIcon";
|
||||
import Copyright from "@/components/Common/Copyright";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
@@ -17,6 +16,7 @@ import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import FontIcon from "../Icons/FontIcon";
|
||||
import TogglePin from "../TogglePin";
|
||||
import WebLogin from "@/components/WebLogin";
|
||||
|
||||
interface FooterProps {
|
||||
setIsPinnedWeb?: (value: boolean) => void;
|
||||
@@ -49,7 +49,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
return updateInfo && !skipVersions.includes(updateInfo.version);
|
||||
}, [updateInfo, skipVersions]);
|
||||
|
||||
const renderLeft = () => {
|
||||
const renderTauriLeft = () => {
|
||||
if (sourceData?.source?.name) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -116,12 +116,17 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className="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-md rounded-t-none overflow-hidden"
|
||||
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",
|
||||
{
|
||||
"overflow-hidden": isTauri,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isTauri ? (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
{renderLeft()}
|
||||
{renderTauriLeft()}
|
||||
|
||||
<TogglePin
|
||||
className={clsx({
|
||||
@@ -132,7 +137,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Copyright />
|
||||
<WebLogin panelClassName="bottom-5 left-0" />
|
||||
)}
|
||||
|
||||
<div className={`flex mobile:hidden items-center gap-3`}>
|
||||
|
||||
@@ -5,6 +5,11 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import clsx from "clsx";
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
import SearchEmpty from "../SearchEmpty";
|
||||
import FontIcon from "../Icons/FontIcon";
|
||||
import WebLoginButton from "@/components/WebLogin/LoginButton";
|
||||
import WebRefreshButton from "@/components/WebLogin/RefreshButton";
|
||||
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
export const NoResults = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,33 +17,66 @@ export const NoResults = () => {
|
||||
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
||||
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
||||
|
||||
const { isTauri } = useAppStore();
|
||||
const { disabled } = useWebConfigStore();
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isTauri && disabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 text-sm">
|
||||
<FontIcon
|
||||
name="font_coco-logo-line"
|
||||
className="size-20 text-[#999]"
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<p>{t("webLogin.hints.welcome")}</p>
|
||||
<p>{t("webLogin.hints.pleaseLogin")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<WebLoginButton />
|
||||
|
||||
<WebRefreshButton className="size-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchEmpty />
|
||||
|
||||
<div
|
||||
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
|
||||
>
|
||||
{t("search.main.askCoco")}
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
"ml-3 h-5 min-w-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center",
|
||||
{
|
||||
"px-1": !isMac,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{formatKey(modifierKey)}
|
||||
</span>
|
||||
|
||||
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
{modeSwitch}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col justify-center items-center"
|
||||
>
|
||||
<SearchEmpty />
|
||||
|
||||
<div
|
||||
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
|
||||
>
|
||||
{t("search.main.askCoco")}
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
|
||||
{
|
||||
"px-1": !isMac,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{formatKey(modifierKey)}
|
||||
</span>
|
||||
|
||||
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
{modeSwitch}
|
||||
</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,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-md px-3 py-2 text-sm`}
|
||||
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
<Link to={`/`}>Home</Link>
|
||||
@@ -41,7 +41,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-md px-3 py-2 text-sm`}
|
||||
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||
>
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
Profile
|
||||
@@ -55,7 +55,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-md px-3 py-2 text-sm`}
|
||||
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
<Link to={`settings`}>Settings</Link>
|
||||
@@ -70,7 +70,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-md px-3 py-2 text-sm`}
|
||||
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Sign Out
|
||||
|
||||
@@ -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-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",
|
||||
"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",
|
||||
shortcutClassName
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -40,7 +40,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-md cursor-pointer dark:border-[#282828]"
|
||||
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-[6px] cursor-pointer dark:border-[#282828]"
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useAssistantManager({
|
||||
}, [askAI?.id, askAI?.querySource?.id, disabledExtensions]);
|
||||
|
||||
const handleAskAi = useCallback(() => {
|
||||
if (!isTauri || canNavigateBack()) return;
|
||||
if (!isTauri) return;
|
||||
|
||||
if (disabledExtensions.includes("QuickAIAccess")) return;
|
||||
|
||||
@@ -187,17 +187,11 @@ export function useAssistantManager({
|
||||
const onOpened = selectedSearchContent?.on_opened;
|
||||
|
||||
if (onOpened?.Extension?.ty?.View) {
|
||||
const { setViewExtensionOpened } = useSearchStore.getState();
|
||||
const viewData = onOpened.Extension.ty.View;
|
||||
const extensionPermission = onOpened.Extension.permission;
|
||||
|
||||
clearSearchValue();
|
||||
return setViewExtensionOpened([
|
||||
viewData.page,
|
||||
extensionPermission,
|
||||
viewData.ui,
|
||||
selectedSearchContent as any,
|
||||
]);
|
||||
return platformAdapter.invokeBackend("open", {
|
||||
onOpened: onOpened,
|
||||
extraArgs: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +204,10 @@ export function useAssistantManager({
|
||||
});
|
||||
|
||||
useKeyPress(`${modifierKey}.enter`, () => {
|
||||
if (canNavigateBack()) return;
|
||||
|
||||
assistant_get();
|
||||
|
||||
return handleAskAi();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||
import { useBoolean } from "ahooks";
|
||||
import {
|
||||
useImperativeHandle,
|
||||
@@ -101,6 +103,9 @@ const AutoResizeTextarea = forwardRef<
|
||||
[setInput]
|
||||
);
|
||||
|
||||
const { isTauri } = useAppStore();
|
||||
const { disabled } = useWebConfigStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
@@ -121,6 +126,7 @@ const AutoResizeTextarea = forwardRef<
|
||||
setTimeout(setFalse, 0);
|
||||
}}
|
||||
rows={1}
|
||||
disabled={!isTauri && disabled}
|
||||
/>
|
||||
|
||||
<div ref={calcRef} className="absolute whitespace-nowrap -z-10">
|
||||
|
||||
@@ -334,7 +334,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
||||
<kbd
|
||||
key={key}
|
||||
className={clsx(
|
||||
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
|
||||
"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]",
|
||||
{
|
||||
"px-1": key.length > 1,
|
||||
}
|
||||
|
||||
@@ -130,7 +130,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
// set first select hover
|
||||
if (from === 0 && list.length > 0) {
|
||||
setSelectedItem(0);
|
||||
getDocDetail(list[0]?.document);
|
||||
}
|
||||
|
||||
if (taskId === taskIdRef.current) {
|
||||
@@ -193,12 +192,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
);
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
(index: number, item: any) => {
|
||||
(index: number) => {
|
||||
if (isKeyboardMode) return;
|
||||
getDocDetail(item);
|
||||
setSelectedItem(index);
|
||||
},
|
||||
[isKeyboardMode, getDocDetail]
|
||||
[isKeyboardMode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -236,7 +234,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
? Math.max(0, prev - 1)
|
||||
: Math.min(data.list.length - 1, prev + 1);
|
||||
|
||||
getDocDetail(data.list[nextIndex]?.document);
|
||||
itemRefs.current[nextIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
@@ -284,6 +281,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
};
|
||||
}, [handleKeyDown, handleMouseMove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem === null) return;
|
||||
const doc = data.list[selectedItem]?.document;
|
||||
if (doc) {
|
||||
getDocDetail(doc);
|
||||
}
|
||||
}, [selectedItem, data, getDocDetail]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full overflow-x-hidden ${
|
||||
@@ -298,10 +303,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Scrollbar
|
||||
className="flex-1 overflow-auto pr-0.5"
|
||||
ref={containerRef}
|
||||
>
|
||||
<Scrollbar className="flex-1 overflow-auto pr-0.5" ref={containerRef}>
|
||||
{data?.list && data.list.length > 0 && (
|
||||
<div>
|
||||
{data.list.map((hit, index) => (
|
||||
@@ -311,7 +313,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
item={{ ...hit.document, querySource: hit.source }}
|
||||
isSelected={selectedItem === index}
|
||||
currentIndex={index}
|
||||
onMouseEnter={() => onMouseEnter(index, hit.document)}
|
||||
onMouseEnter={() => onMouseEnter(index)}
|
||||
onItemClick={() => {
|
||||
platformAdapter.openSearchItem(hit.document, formatUrl);
|
||||
}}
|
||||
|
||||
@@ -172,12 +172,13 @@ function DropdownList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.map((hit) => {
|
||||
const currentIndex = hit.document.index || 0;
|
||||
{items.map((hit, idx) => {
|
||||
const currentIndex = hit.document.index ?? 0;
|
||||
const itemKey = `${sourceName}-${hit.document.id ?? currentIndex}-${idx}`;
|
||||
|
||||
return (
|
||||
<DropdownListItem
|
||||
key={hit.document.id}
|
||||
key={itemKey}
|
||||
item={hit.document}
|
||||
selectedIndex={selectedIndex}
|
||||
currentIndex={currentIndex}
|
||||
|
||||
@@ -5,7 +5,7 @@ import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { parseSearchQuery } from "@/utils";
|
||||
import { installExtensionError, parseSearchQuery } from "@/utils";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import SearchEmpty from "../Common/SearchEmpty";
|
||||
import ExtensionDetail from "./ExtensionDetail";
|
||||
@@ -244,7 +244,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
"info"
|
||||
);
|
||||
} catch (error) {
|
||||
addError(String(error), "error");
|
||||
installExtensionError(String(error));
|
||||
} finally {
|
||||
const { installingExtensions } = useSearchStore.getState();
|
||||
|
||||
|
||||
@@ -127,8 +127,6 @@ export default function ChatInput({
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
// console.log("handleSubmit", trimmedValue, disabled);
|
||||
|
||||
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
|
||||
changeInput("");
|
||||
onSend({
|
||||
@@ -254,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-md border border-black/10 dark:border-[#545454]">
|
||||
<div className="flex items-center justify-center px-1 h-[20px] text-xs rounded-[6px] border border-black/10 dark:border-[#545454]">
|
||||
{formatKey(modifierKey)} + {formatKey("Enter")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,7 +308,7 @@ export default function ChatInput({
|
||||
<div className={`w-full relative`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`}
|
||||
className={`flex items-center dark:text-[#D8D8D8] rounded-[6px] transition-all relative overflow-hidden`}
|
||||
>
|
||||
{lineCount === 1 && renderSearchIcon()}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
||||
import SearchPopover from "./SearchPopover";
|
||||
import MCPPopover from "./MCPPopover";
|
||||
import ChatSwitch from "@/components/Common/ChatSwitch";
|
||||
import Copyright from "@/components/Common/Copyright";
|
||||
import type { DataSource } from "@/types/commands";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
@@ -17,6 +16,7 @@ import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { parseSearchQuery, SearchQuery } from "@/utils";
|
||||
import InputUpload from "./InputUpload";
|
||||
import Copyright from "../Common/Copyright";
|
||||
|
||||
interface InputControlsProps {
|
||||
isChatMode: boolean;
|
||||
@@ -187,7 +187,7 @@ const InputControls = ({
|
||||
{source?.type === "deep_think" && source?.config?.visible && (
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
||||
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
|
||||
}
|
||||
@@ -250,7 +250,7 @@ const InputControls = ({
|
||||
!visibleExtensionStore && (
|
||||
<div
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
|
||||
"inline-flex items-center gap-1 h-[20px] px-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
|
||||
[
|
||||
enabledAiOverview
|
||||
? "text-[#881c94]"
|
||||
@@ -270,7 +270,7 @@ const InputControls = ({
|
||||
setEnabledAiOverview(!enabledAiOverview);
|
||||
}}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
<Sparkles className="size-3" />
|
||||
</VisibleKey>
|
||||
|
||||
<span
|
||||
|
||||
@@ -199,7 +199,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
||||
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
||||
<Tooltip
|
||||
content={t("search.input.uploadFileHints.tooltip", {
|
||||
replace: [
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function MCPPopover({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
|
||||
}
|
||||
|
||||
@@ -106,11 +106,12 @@ export default function SearchIcons({
|
||||
}
|
||||
|
||||
if (viewExtensionOpened) {
|
||||
const { title, icon } = viewExtensionOpened[3];
|
||||
const name = viewExtensionOpened[0];
|
||||
const icon = viewExtensionOpened[1];
|
||||
|
||||
const iconPath = icon ? platformAdapter.convertFileSrc(icon) : void 0;
|
||||
|
||||
return <MultilevelWrapper title={title} icon={iconPath} />;
|
||||
return <MultilevelWrapper title={name} icon={iconPath} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function SearchPopover({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
|
||||
defaultIcon={isDark ? source_default_dark_img : source_default_img}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{sourceName} {isTauri && items[0]?.source?.name && `- ${items[0].source.name}`}
|
||||
{sourceName}{" "}
|
||||
{isTauri && items[0]?.source?.name && `- ${items[0].source.name}`}
|
||||
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||
{!hideArrow && (
|
||||
<>
|
||||
@@ -56,7 +57,7 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
|
||||
</IconWrapper>
|
||||
{showIndex && sourceName === selectedName && (
|
||||
<div className="absolute top-1 right-4">
|
||||
<VisibleKey shortcut="→" />
|
||||
<VisibleKey shortcut="Tab" shortcutClassName="w-8" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
const ViewExtension: React.FC = () => {
|
||||
const { viewExtensionOpened } = useSearchStore();
|
||||
const [page, setPage] = useState<string>("");
|
||||
// Complete list of the backend APIs, grouped by their category.
|
||||
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
|
||||
const { setModifierKeyPressed } = useShortcutsStore();
|
||||
@@ -23,24 +22,6 @@ const ViewExtension: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Tauri/webview is not allowed to access local files directly,
|
||||
// use convertFileSrc to work around the issue.
|
||||
useEffect(() => {
|
||||
const setupFileUrl = async () => {
|
||||
// The check above ensures viewExtensionOpened is not null here.
|
||||
const page = viewExtensionOpened[0];
|
||||
|
||||
// Only convert to file source if it's a local file path, not a URL
|
||||
if (page.startsWith("http://") || page.startsWith("https://")) {
|
||||
setPage(page);
|
||||
} else {
|
||||
setPage(platformAdapter.convertFileSrc(page));
|
||||
}
|
||||
};
|
||||
|
||||
setupFileUrl();
|
||||
}, [viewExtensionOpened]);
|
||||
|
||||
// invoke `apis()` and set the state
|
||||
useEffect(() => {
|
||||
setModifierKeyPressed(false);
|
||||
@@ -60,7 +41,7 @@ const ViewExtension: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
// White list of the permission entries
|
||||
const permission = viewExtensionOpened[1];
|
||||
const permission = viewExtensionOpened[3];
|
||||
|
||||
// apis is in format {"category": ["api1", "api2"]}, to make the permission check
|
||||
// easier, reverse the map key values: {"api1": "category", "api2": "category"}
|
||||
@@ -182,9 +163,11 @@ const ViewExtension: React.FC = () => {
|
||||
};
|
||||
}, [reversedApis, permission]); // Add apiPermissions as dependency
|
||||
|
||||
const fileUrl = viewExtensionOpened[2];
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={page}
|
||||
src={fileUrl}
|
||||
className="w-full h-full border-0"
|
||||
onLoad={(event) => {
|
||||
event.currentTarget.focus();
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { useMount } from "ahooks";
|
||||
import { useMount, useMutationObserver } from "ahooks";
|
||||
|
||||
import Search from "@/components/Search/Search";
|
||||
import InputBox from "@/components/Search/InputBox";
|
||||
@@ -28,10 +28,15 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||
import type { StartPage } from "@/types/chat";
|
||||
import {
|
||||
canNavigateBack,
|
||||
hasUploadingAttachment,
|
||||
visibleFilterBar,
|
||||
visibleSearchBar,
|
||||
} from "@/utils";
|
||||
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
interface SearchChatProps {
|
||||
isTauri?: boolean;
|
||||
@@ -82,6 +87,7 @@ function SearchChat({
|
||||
);
|
||||
|
||||
const [state, dispatch] = useReducer(appReducer, customInitialState);
|
||||
|
||||
const {
|
||||
isChatMode,
|
||||
input,
|
||||
@@ -91,6 +97,78 @@ function SearchChat({
|
||||
isMCPActive,
|
||||
isTyping,
|
||||
} = state;
|
||||
|
||||
const inputRef = useRef<string>();
|
||||
const isChatModeRef = useRef(false);
|
||||
const [hideMiddleBorder, setHideMiddleBorder] = useState(false);
|
||||
|
||||
const setSuppressErrors = useAppStore((state) => state.setSuppressErrors);
|
||||
let collapseWindowTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const setWindowSize = useCallback(() => {
|
||||
if (collapseWindowTimer.current) {
|
||||
clearTimeout(collapseWindowTimer.current);
|
||||
}
|
||||
|
||||
const width = 680;
|
||||
let height = 590;
|
||||
|
||||
const updateAppDialog = document.querySelector("#update-app-dialog");
|
||||
const popoverPanelEl = document.querySelector(POPOVER_PANEL_SELECTOR);
|
||||
|
||||
const { hasActiveChat } = useChatStore.getState();
|
||||
|
||||
if (
|
||||
updateAppDialog ||
|
||||
canNavigateBack() ||
|
||||
inputRef.current ||
|
||||
popoverPanelEl ||
|
||||
(isChatModeRef.current && hasActiveChat)
|
||||
) {
|
||||
setHideMiddleBorder(false);
|
||||
setSuppressErrors(false);
|
||||
} else {
|
||||
const { windowMode } = useAppearanceStore.getState();
|
||||
|
||||
if (windowMode === "compact") {
|
||||
height = 84;
|
||||
}
|
||||
}
|
||||
|
||||
if (height < 590) {
|
||||
const { compactModeAutoCollapseDelay } = useConnectStore.getState();
|
||||
|
||||
console.log("compactModeAutoCollapseDelay", compactModeAutoCollapseDelay);
|
||||
|
||||
collapseWindowTimer.current = setTimeout(() => {
|
||||
setHideMiddleBorder(true);
|
||||
setSuppressErrors(true);
|
||||
|
||||
platformAdapter.setWindowSize(width, height);
|
||||
}, compactModeAutoCollapseDelay * 1000);
|
||||
} else {
|
||||
platformAdapter.setWindowSize(width, height);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedSetWindowSize = debounce(setWindowSize, 50);
|
||||
|
||||
useMutationObserver(debouncedSetWindowSize, document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current = input;
|
||||
isChatModeRef.current = isChatMode;
|
||||
|
||||
debouncedSetWindowSize();
|
||||
}, [input, isChatMode]);
|
||||
|
||||
useTauriFocus({
|
||||
onFocus: debouncedSetWindowSize,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: "SET_SEARCH_ACTIVE",
|
||||
@@ -114,11 +192,6 @@ function SearchChat({
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const setIsDark = useThemeStore((state) => state.setIsDark);
|
||||
|
||||
const isChatModeRef = useRef(false);
|
||||
useEffect(() => {
|
||||
isChatModeRef.current = isChatMode;
|
||||
}, [isChatMode]);
|
||||
|
||||
useMount(async () => {
|
||||
const isWin10 = await platformAdapter.isWindows10();
|
||||
|
||||
@@ -155,6 +228,8 @@ function SearchChat({
|
||||
|
||||
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
|
||||
if (isChatMode) {
|
||||
const { setHasActiveChat } = useChatStore.getState();
|
||||
setHasActiveChat(true);
|
||||
chatAIRef.current?.init(params);
|
||||
}
|
||||
},
|
||||
@@ -233,7 +308,7 @@ function SearchChat({
|
||||
return state.defaultStartupWindow;
|
||||
});
|
||||
|
||||
const opacity = useAppearanceStore((state) => state.opacity);
|
||||
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauri) {
|
||||
@@ -251,11 +326,11 @@ function SearchChat({
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className={clsx(
|
||||
"m-auto overflow-hidden relative bg-no-repeat bg-cover bg-center bg-white dark:bg-black flex flex-col",
|
||||
"m-auto overflow-hidden relative bg-no-repeat bg-white dark:bg-black flex flex-col",
|
||||
[
|
||||
isTransitioned
|
||||
? "bg-chat_bg_light dark:bg-chat_bg_dark"
|
||||
: "bg-search_bg_light dark:bg-search_bg_dark",
|
||||
? "bg-bottom bg-chat_bg_light dark:bg-chat_bg_dark"
|
||||
: "bg-top bg-search_bg_light dark:bg-search_bg_dark",
|
||||
],
|
||||
{
|
||||
"size-full": !isTauri,
|
||||
@@ -265,7 +340,10 @@ function SearchChat({
|
||||
"border-t border-t-[#999] dark:border-t-[#333]": isTauri && isWin10,
|
||||
}
|
||||
)}
|
||||
style={{ opacity: blurred ? (opacity ?? 30) / 100 : 1 }}
|
||||
style={{
|
||||
backgroundSize: "auto 590px",
|
||||
opacity: blurred ? blurOpacity / 100 : normalOpacity / 100,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
@@ -294,13 +372,21 @@ function SearchChat({
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className={clsx(
|
||||
"p-2 w-full flex justify-center transition-all duration-500 border-[#E6E6E6] dark:border-[#272626]",
|
||||
[isTransitioned ? "border-t" : "border-b"],
|
||||
"relative p-2 w-full flex justify-center transition-all duration-500",
|
||||
{
|
||||
"min-h-[82px]": visibleSearchBar() && visibleFilterBar(),
|
||||
"min-h-[84px]": visibleSearchBar() && visibleFilterBar(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
{!hideMiddleBorder && (
|
||||
<div
|
||||
className={clsx(
|
||||
"pointer-events-none absolute left-0 right-0 h-[1px] bg-[#E6E6E6] dark:bg-[#272626]",
|
||||
isTransitioned ? "top-0" : "bottom-0"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputBox
|
||||
isChatMode={isChatMode}
|
||||
inputValue={input}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { AppWindowMac } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Appearance = () => {
|
||||
const { t } = useTranslation();
|
||||
const opacity = useAppearanceStore((state) => state.opacity);
|
||||
const setOpacity = useAppearanceStore((state) => state.setOpacity);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = useAppearanceStore.subscribe((state) => {
|
||||
platformAdapter.emitEvent("change-appearance-store", state);
|
||||
});
|
||||
|
||||
return unlisten;
|
||||
}, []);
|
||||
const { normalOpacity, setNormalOpacity, blurOpacity, setBlurOpacity } =
|
||||
useAppearanceStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -27,16 +17,34 @@ const Appearance = () => {
|
||||
|
||||
<SettingsItem
|
||||
icon={AppWindowMac}
|
||||
title={t("settings.advanced.appearance.opacity.title")}
|
||||
description={t("settings.advanced.appearance.opacity.description")}
|
||||
title={t("settings.advanced.appearance.normalOpacity.title")}
|
||||
description={t(
|
||||
"settings.advanced.appearance.normalOpacity.description"
|
||||
)}
|
||||
>
|
||||
<SettingsInput
|
||||
type="number"
|
||||
min={10}
|
||||
max={100}
|
||||
value={opacity}
|
||||
value={normalOpacity}
|
||||
onChange={(value) => {
|
||||
return setOpacity(!value ? void 0 : Number(value));
|
||||
return setNormalOpacity(!value ? 100 : Number(value));
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
icon={AppWindowMac}
|
||||
title={t("settings.advanced.appearance.blurOpacity.title")}
|
||||
description={t("settings.advanced.appearance.blurOpacity.description")}
|
||||
>
|
||||
<SettingsInput
|
||||
type="number"
|
||||
min={10}
|
||||
max={100}
|
||||
value={blurOpacity}
|
||||
onChange={(value) => {
|
||||
return setBlurOpacity(!value ? 30 : Number(value));
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
@@ -289,7 +289,7 @@ const Shortcuts = () => {
|
||||
<Button
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"flex items-center justify-center size-8 rounded-md border border-black/5 dark:border-white/10 transition",
|
||||
"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,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AppWindowMac,
|
||||
ArrowUpWideNarrow,
|
||||
MessageSquareMore,
|
||||
PanelTopClose,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Unplug,
|
||||
@@ -18,6 +20,7 @@ import SettingsInput from "@/components//Settings/SettingsInput";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import UpdateSettings from "./components/UpdateSettings";
|
||||
import SettingsToggle from "../SettingsToggle";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
const Advanced = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -57,6 +60,14 @@ const Advanced = () => {
|
||||
const setAllowSelfSignature = useConnectStore((state) => {
|
||||
return state.setAllowSelfSignature;
|
||||
});
|
||||
const {
|
||||
searchDelay,
|
||||
setSearchDelay,
|
||||
compactModeAutoCollapseDelay,
|
||||
setCompactModeAutoCollapseDelay,
|
||||
} = useConnectStore();
|
||||
|
||||
const [localSearchResultWeight, setLocalSearchResultWeight] = useState(1);
|
||||
|
||||
useMount(async () => {
|
||||
const allowSelfSignature = await platformAdapter.invokeBackend<boolean>(
|
||||
@@ -64,6 +75,12 @@ const Advanced = () => {
|
||||
);
|
||||
|
||||
setAllowSelfSignature(allowSelfSignature);
|
||||
|
||||
const weight = await platformAdapter.invokeBackend<number>(
|
||||
"get_local_query_source_weight"
|
||||
);
|
||||
|
||||
setLocalSearchResultWeight(weight);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -174,16 +191,20 @@ const Advanced = () => {
|
||||
|
||||
<Shortcuts />
|
||||
|
||||
<Appearance />
|
||||
|
||||
<UpdateSettings />
|
||||
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t("settings.advanced.connect.title")}
|
||||
{t("settings.advanced.other.title")}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SettingsItem
|
||||
icon={Unplug}
|
||||
title={t("settings.advanced.connect.connectionTimeout.title")}
|
||||
title={t("settings.advanced.other.connectionTimeout.title")}
|
||||
description={t(
|
||||
"settings.advanced.connect.connectionTimeout.description"
|
||||
"settings.advanced.other.connectionTimeout.description"
|
||||
)}
|
||||
>
|
||||
<SettingsInput
|
||||
@@ -198,8 +219,8 @@ const Advanced = () => {
|
||||
|
||||
<SettingsItem
|
||||
icon={Unplug}
|
||||
title={t("settings.advanced.connect.queryTimeout.title")}
|
||||
description={t("settings.advanced.connect.queryTimeout.description")}
|
||||
title={t("settings.advanced.other.queryTimeout.title")}
|
||||
description={t("settings.advanced.other.queryTimeout.description")}
|
||||
>
|
||||
<SettingsInput
|
||||
type="number"
|
||||
@@ -211,15 +232,30 @@ const Advanced = () => {
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
icon={Unplug}
|
||||
title={t("settings.advanced.other.searchDelay.title")}
|
||||
description={t("settings.advanced.other.searchDelay.description")}
|
||||
>
|
||||
<SettingsInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={searchDelay}
|
||||
onChange={(value) => {
|
||||
setSearchDelay(isNil(value) ? 0 : Number(value));
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.advanced.connect.allowSelfSignature.title")}
|
||||
title={t("settings.advanced.other.allowSelfSignature.title")}
|
||||
description={t(
|
||||
"settings.advanced.connect.allowSelfSignature.description"
|
||||
"settings.advanced.other.allowSelfSignature.description"
|
||||
)}
|
||||
>
|
||||
<SettingsToggle
|
||||
label={t("settings.advanced.connect.allowSelfSignature.title")}
|
||||
label={t("settings.advanced.other.allowSelfSignature.title")}
|
||||
checked={allowSelfSignature}
|
||||
onChange={(value) => {
|
||||
setAllowSelfSignature(value);
|
||||
@@ -230,11 +266,62 @@ const Advanced = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
icon={ArrowUpWideNarrow}
|
||||
title={t("settings.advanced.other.localSearchResultWeight.title")}
|
||||
description={t(
|
||||
"settings.advanced.other.localSearchResultWeight.description"
|
||||
)}
|
||||
>
|
||||
<select
|
||||
value={localSearchResultWeight}
|
||||
onChange={(event) => {
|
||||
const weight = Number(event.target.value);
|
||||
|
||||
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>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
icon={PanelTopClose}
|
||||
title={t(
|
||||
"settings.advanced.other.compactModeAutoCollapseDelay.title"
|
||||
)}
|
||||
description={t(
|
||||
"settings.advanced.other.compactModeAutoCollapseDelay.description"
|
||||
)}
|
||||
>
|
||||
<SettingsInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={compactModeAutoCollapseDelay}
|
||||
onChange={(value) => {
|
||||
setCompactModeAutoCollapseDelay(!value ? 0 : Number(value));
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<Appearance />
|
||||
|
||||
<UpdateSettings />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, MouseEvent, useContext, useMemo, useState } from "react";
|
||||
import { useReactive } from "ahooks";
|
||||
import { useMount, useReactive } from "ahooks";
|
||||
import { ChevronRight, LoaderCircle } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { isArray, startCase, sortBy } from "lodash-es";
|
||||
@@ -20,11 +20,12 @@ const Content = () => {
|
||||
return rootState.extensions.map((item) => {
|
||||
const { id } = item;
|
||||
|
||||
return <Item key={id} {...item} level={1} />;
|
||||
return <Item key={id} extension={item} level={1} />;
|
||||
});
|
||||
};
|
||||
|
||||
interface ItemProps extends Extension {
|
||||
interface ItemProps {
|
||||
extension: Extension;
|
||||
level: number;
|
||||
parentId?: ExtensionId;
|
||||
parentDeveloper?: string;
|
||||
@@ -42,19 +43,8 @@ const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
|
||||
};
|
||||
|
||||
const Item: FC<ItemProps> = (props) => {
|
||||
const {
|
||||
id,
|
||||
icon,
|
||||
name,
|
||||
type,
|
||||
level,
|
||||
platforms,
|
||||
developer,
|
||||
enabled,
|
||||
parentId,
|
||||
parentDeveloper,
|
||||
parentDisabled,
|
||||
} = props;
|
||||
const { extension, level, parentId, parentDeveloper, parentDisabled } = props;
|
||||
const { id, icon, name, type, platforms, developer, enabled } = extension;
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
const state = useReactive<ItemState>({
|
||||
loading: false,
|
||||
@@ -63,6 +53,18 @@ const Item: FC<ItemProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { disabledExtensions, setDisabledExtensions } = useExtensionsStore();
|
||||
const [selfDisabled, setSelfDisabled] = useState(!enabled);
|
||||
const [compatible, setCompatible] = useState(true);
|
||||
|
||||
useMount(async () => {
|
||||
const compatible = await platformAdapter.invokeBackend<boolean>(
|
||||
"is_extension_compatible",
|
||||
{
|
||||
extension,
|
||||
}
|
||||
);
|
||||
|
||||
setCompatible(compatible);
|
||||
});
|
||||
|
||||
const bundleId = {
|
||||
developer: developer ?? parentDeveloper,
|
||||
@@ -71,7 +73,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
};
|
||||
|
||||
const hasSubExtensions = () => {
|
||||
const { commands, scripts, quicklinks } = props;
|
||||
const { commands, scripts, quicklinks } = extension;
|
||||
|
||||
if (subExtensionCommand[id]) {
|
||||
return true;
|
||||
@@ -87,7 +89,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
const getSubExtensions = async () => {
|
||||
state.loading = true;
|
||||
|
||||
const { commands, scripts, quicklinks } = props;
|
||||
const { commands, scripts, quicklinks } = extension;
|
||||
|
||||
let subExtensions: Extension[] = [];
|
||||
|
||||
@@ -117,12 +119,16 @@ const Item: FC<ItemProps> = (props) => {
|
||||
};
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
if (!compatible) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (level === 1) {
|
||||
return selfDisabled;
|
||||
}
|
||||
|
||||
return parentDisabled || selfDisabled;
|
||||
}, [parentDisabled, selfDisabled]);
|
||||
}, [parentDisabled, selfDisabled, compatible]);
|
||||
|
||||
const editable = useMemo(() => {
|
||||
return (
|
||||
@@ -134,7 +140,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
}, [type]);
|
||||
|
||||
const renderAlias = () => {
|
||||
const { alias } = props;
|
||||
const { alias } = extension;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
platformAdapter.invokeBackend("set_extension_alias", {
|
||||
@@ -173,7 +179,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
};
|
||||
|
||||
const renderHotkey = () => {
|
||||
const { hotkey } = props;
|
||||
const { hotkey } = extension;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (value) {
|
||||
@@ -246,7 +252,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("flex items-center justify-end", {
|
||||
"opacity-50 pointer-events-none": parentDisabled,
|
||||
"opacity-50 pointer-events-none": !compatible || parentDisabled,
|
||||
})}
|
||||
>
|
||||
<SettingsToggle
|
||||
@@ -286,7 +292,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx("-mx-2 px-2 text-sm rounded-md", {
|
||||
className={clsx("-mx-2 px-2 text-sm rounded-[6px]", {
|
||||
"bg-[#f0f6fe] dark:bg-gray-700":
|
||||
id === rootState.activeExtension?.id,
|
||||
})}
|
||||
@@ -294,7 +300,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 h-8"
|
||||
onClick={() => {
|
||||
rootState.activeExtension = props;
|
||||
rootState.activeExtension = extension;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -356,7 +362,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
return (
|
||||
<Item
|
||||
key={item.id}
|
||||
{...item}
|
||||
extension={item}
|
||||
level={level + 1}
|
||||
parentId={id}
|
||||
parentDeveloper={developer}
|
||||
|
||||
@@ -83,7 +83,7 @@ const Applications = () => {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-md transition"
|
||||
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
|
||||
onClick={handleReindex}
|
||||
>
|
||||
{t("settings.extensions.application.details.reindex")}
|
||||
|
||||
@@ -112,7 +112,7 @@ const DirectoryScope: FC<DirectoryScopeProps> = (props) => {
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full h-8 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-md transition"
|
||||
className="w-full h-8 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{t("settings.extensions.directoryScope.button.addDirectories")}
|
||||
|
||||
@@ -115,7 +115,7 @@ const FileSearch = () => {
|
||||
{t("settings.extensions.fileSearch.label.searchFileTypes")}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 p-2 border rounded-md dark:border-gray-700">
|
||||
<div className="flex flex-wrap gap-2 p-2 border rounded-[6px] dark:border-gray-700">
|
||||
{config.file_types.map((item) => {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
import { ExtensionsContext } from "../..";
|
||||
import Applications from "./Applications";
|
||||
@@ -8,11 +8,12 @@ import SharedAi from "./SharedAi";
|
||||
import AiOverview from "./AiOverview";
|
||||
import Calculator from "./Calculator";
|
||||
import FileSearch from "./FileSearch";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { Ellipsis, Info } from "lucide-react";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
|
||||
const Details = () => {
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
@@ -33,6 +34,23 @@ const Details = () => {
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [compatible, setCompatible] = useState(true);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (rootState.activeExtension?.id) {
|
||||
const compatible = await platformAdapter.invokeBackend<boolean>(
|
||||
"is_extension_compatible",
|
||||
{
|
||||
extension: rootState.activeExtension,
|
||||
}
|
||||
);
|
||||
|
||||
setCompatible(compatible);
|
||||
} else {
|
||||
setCompatible(true);
|
||||
}
|
||||
}, [rootState.activeExtension?.id]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!rootState.activeExtension) return;
|
||||
|
||||
@@ -77,7 +95,7 @@ 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">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<h2 className="m-0 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{rootState.activeExtension?.name}
|
||||
</h2>
|
||||
|
||||
@@ -130,6 +148,16 @@ const Details = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!compatible && (
|
||||
<div className="-mt-1 mb-3 bg-red-50 p-2 rounded">
|
||||
<Info className="inline-flex size-4 mr-1 text-red-600" />
|
||||
|
||||
<span className="text-[#333]">
|
||||
{t("settings.extensions.hints.incompatible")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import Details from "./components/Details";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import SettingsInput from "../SettingsInput";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { installExtensionError } from "@/utils";
|
||||
|
||||
export type ExtensionId = LiteralUnion<
|
||||
| "Applications"
|
||||
@@ -32,7 +33,9 @@ type ExtensionType =
|
||||
| "setting"
|
||||
| "calculator"
|
||||
| "command"
|
||||
| "ai_extension";
|
||||
| "ai_extension"
|
||||
| "view"
|
||||
| "unknown";
|
||||
|
||||
export type ExtensionPlatform = "windows" | "macos" | "linux";
|
||||
|
||||
@@ -63,9 +66,9 @@ export interface ExtensionPermission {
|
||||
}
|
||||
|
||||
export interface ViewExtensionUISettings {
|
||||
search_bar: boolean,
|
||||
filter_bar: boolean,
|
||||
footer: boolean,
|
||||
search_bar: boolean;
|
||||
filter_bar: boolean;
|
||||
footer: boolean;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
@@ -143,6 +146,8 @@ export const Extensions = () => {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("extensions", cloneDeep(extensions));
|
||||
|
||||
state.extensions = sortBy(extensions, ["name"]);
|
||||
|
||||
if (configId) {
|
||||
@@ -187,7 +192,7 @@ export const Extensions = () => {
|
||||
</h2>
|
||||
|
||||
<Menu>
|
||||
<MenuButton className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition">
|
||||
<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>
|
||||
|
||||
@@ -228,21 +233,7 @@ export const Extensions = () => {
|
||||
"info"
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
|
||||
if (errorMessage === "already imported") {
|
||||
addError(
|
||||
t(
|
||||
"settings.extensions.hints.extensionAlreadyImported"
|
||||
)
|
||||
);
|
||||
} else if (errorMessage === "incompatible") {
|
||||
addError(
|
||||
t("settings.extensions.hints.incompatibleExtension")
|
||||
);
|
||||
} else {
|
||||
addError(t("settings.extensions.hints.importFailed"));
|
||||
}
|
||||
installExtensionError(String(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -254,7 +245,7 @@ export const Extensions = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-6 my-4">
|
||||
<div className="flex h-8 border dark:border-gray-700 rounded-md overflow-hidden">
|
||||
<div className="flex h-8 border dark:border-gray-700 rounded-[6px] overflow-hidden">
|
||||
{state.categories.map((item) => {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, cloneElement, ReactElement } from "react";
|
||||
import {
|
||||
Command,
|
||||
Monitor,
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
Tags,
|
||||
// Trash2,
|
||||
Globe,
|
||||
PictureInPicture2,
|
||||
PanelTop,
|
||||
RectangleHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
unregister_shortcut,
|
||||
} from "@/commands";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import clsx from "clsx";
|
||||
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
|
||||
|
||||
export function ThemeOption({
|
||||
icon: Icon,
|
||||
@@ -76,6 +81,7 @@ export default function GeneralSettings() {
|
||||
const [launchAtLogin, setLaunchAtLogin] = useState(true);
|
||||
|
||||
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
|
||||
const { windowMode, setWindowMode } = useAppearanceStore();
|
||||
|
||||
const fetchAutoStartStatus = async () => {
|
||||
if (isTauri()) {
|
||||
@@ -176,6 +182,20 @@ export default function GeneralSettings() {
|
||||
|
||||
const currentLanguage = language || i18n.language;
|
||||
|
||||
const windowModes: Array<{
|
||||
icon: ReactElement;
|
||||
value: WindowMode;
|
||||
}> = [
|
||||
{
|
||||
icon: <PanelTop />,
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
icon: <RectangleHorizontal />,
|
||||
value: "compact",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
@@ -239,6 +259,52 @@ export default function GeneralSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsItem
|
||||
icon={PictureInPicture2}
|
||||
title={t("settings.windowMode.title")}
|
||||
description={t("settings.windowMode.description")}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{windowModes.map((item) => {
|
||||
const { icon, value } = item;
|
||||
|
||||
const label = t(`settings.windowMode.${value}`);
|
||||
|
||||
let isSelected = value === windowMode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setWindowMode(value);
|
||||
}}
|
||||
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":
|
||||
isSelected,
|
||||
}
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{cloneElement(icon, {
|
||||
className: clsx({
|
||||
"text-blue-500": isSelected,
|
||||
}),
|
||||
})}
|
||||
|
||||
<span
|
||||
className={clsx(`text-sm font-medium`, {
|
||||
"text-blue-500": isSelected,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SettingsItem
|
||||
icon={Globe}
|
||||
title={t("settings.language.title")}
|
||||
|
||||
@@ -36,7 +36,7 @@ const SettingsInput: FC<SettingsInputProps> = (props) => {
|
||||
{...rest}
|
||||
autoCorrect="off"
|
||||
className={twMerge(
|
||||
"w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10 hover:!border-[#0072FF] focus:!border-[#0072FF] transition",
|
||||
"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
|
||||
)}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -4,7 +4,7 @@ interface SettingsItemProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SettingsItem({
|
||||
@@ -14,7 +14,7 @@ export default function SettingsItem({
|
||||
children,
|
||||
}: SettingsItemProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon className="h-5 min-w-5 text-gray-400 dark:text-gray-500" />
|
||||
<div>
|
||||
|
||||
@@ -47,7 +47,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
className="flex items-center h-8 px-3 truncate rounded-md border dark:bg-[#1F2937] bg-white dark:border-[#374151]"
|
||||
className="flex items-center h-8 px-3 truncate rounded-[6px] border dark:bg-[#1F2937] bg-white dark:border-[#374151]"
|
||||
onClick={toggle}
|
||||
>
|
||||
{option?.[labelField] ?? (
|
||||
@@ -57,7 +57,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute z-100 top-10 left-0 right-0 rounded-md 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-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)]",
|
||||
{
|
||||
hidden: !open,
|
||||
}
|
||||
@@ -83,7 +83,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
||||
<div
|
||||
key={item?.[valueField] ?? index}
|
||||
className={clsx(
|
||||
"h-8 leading-8 px-2 rounded-md hover:bg-[#EDEDED] hover:dark:bg-[#374151] transition cursor-pointer",
|
||||
"h-8 leading-8 px-2 rounded-[6px] hover:bg-[#EDEDED] hover:dark:bg-[#374151] transition cursor-pointer",
|
||||
{
|
||||
"bg-[#EDEDED] dark:bg-[#374151]":
|
||||
value === item?.[valueField],
|
||||
|
||||
@@ -131,9 +131,8 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
|
||||
const { skipVersions, updateInfo } = useUpdateStore.getState();
|
||||
|
||||
if(updateInfo?.version){
|
||||
setSkipVersions([...skipVersions, updateInfo.version]);
|
||||
|
||||
if (updateInfo?.version) {
|
||||
setSkipVersions([...skipVersions, updateInfo.version]);
|
||||
}
|
||||
|
||||
isCheckPage ? hide_check() : setVisible(false);
|
||||
@@ -143,6 +142,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
<Dialog
|
||||
open={isCheckPage ? true : visible}
|
||||
as="div"
|
||||
id="update-app-dialog"
|
||||
className="relative z-10 focus:outline-none"
|
||||
onClose={noop}
|
||||
>
|
||||
@@ -154,6 +154,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={clsx(
|
||||
"flex min-h-full items-center justify-center",
|
||||
!isCheckPage && "p-4"
|
||||
@@ -161,11 +162,13 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
>
|
||||
<DialogPanel
|
||||
transition
|
||||
className={`relative w-[340px] py-8 flex flex-col items-center ${
|
||||
isCheckPage
|
||||
? ""
|
||||
: "rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md"
|
||||
}`}
|
||||
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
|
||||
@@ -238,7 +241,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!isCheckPage && updateInfo && isOptional && (
|
||||
{updateInfo && isOptional && (
|
||||
<div
|
||||
className={clsx("text-xs text-[#999]", cursorClassName)}
|
||||
onClick={handleSkip}
|
||||
|
||||
26
src/components/WebLogin/LoginButton.tsx
Normal file
26
src/components/WebLogin/LoginButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LoginButton = () => {
|
||||
const { endpoint } = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = () => {
|
||||
window.open(endpoint);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="px-6 h-8 text-white bg-[#0287FF] flex rounded-[8px] items-center justify-center gap-1"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span>{t("webLogin.buttons.login")}</span>
|
||||
|
||||
<SquareArrowOutUpRight className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginButton;
|
||||
48
src/components/WebLogin/RefreshButton.tsx
Normal file
48
src/components/WebLogin/RefreshButton.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { FC, useState } from "react";
|
||||
import { Button, ButtonProps } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
|
||||
const RefreshButton: FC<ButtonProps> = (props) => {
|
||||
const { className, ...rest } = props;
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const { onRefresh } = useWebConfigStore();
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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
|
||||
)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||
<RefreshCw
|
||||
className={clsx(
|
||||
"size-3 text-[#0287FF] transition-transform duration-1000",
|
||||
{
|
||||
"animate-spin": isRefreshing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default RefreshButton;
|
||||
24
src/components/WebLogin/UserAvatar.tsx
Normal file
24
src/components/WebLogin/UserAvatar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import clsx from "clsx";
|
||||
import { LucideProps, User } from "lucide-react";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
interface UserAvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
icon?: LucideProps;
|
||||
}
|
||||
|
||||
const UserAvatar: FC<UserAvatarProps> = (props) => {
|
||||
const { className, icon } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center justify-center size-5 rounded-full border dark:border-white/10 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<User {...icon} className={clsx("size-4", icon?.className)}></User>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAvatar;
|
||||
105
src/components/WebLogin/index.tsx
Normal file
105
src/components/WebLogin/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||
import { LogOut } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { Post } from "@/api/axiosRequest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
import FontIcon from "../Common/Icons/FontIcon";
|
||||
import RefreshButton from "./RefreshButton";
|
||||
import LoginButton from "./LoginButton";
|
||||
import { FC } from "react";
|
||||
import Copyright from "../Common/Copyright";
|
||||
|
||||
interface WebLoginProps {
|
||||
panelClassName: string;
|
||||
}
|
||||
|
||||
const WebLogin: FC<WebLoginProps> = (props) => {
|
||||
const { panelClassName } = props;
|
||||
const { integration, loginInfo, setIntegration, setLoginInfo } =
|
||||
useWebConfigStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Popover>
|
||||
<PopoverButton>
|
||||
{loginInfo ? (
|
||||
<UserAvatar />
|
||||
) : (
|
||||
<FontIcon
|
||||
name="font_coco-logo-line"
|
||||
className="size-5 text-[#999]"
|
||||
/>
|
||||
)}
|
||||
</PopoverButton>
|
||||
|
||||
<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
|
||||
)}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span>{t("webLogin.title")}</span>
|
||||
|
||||
<RefreshButton />
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
{loginInfo ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
className="!size-12"
|
||||
icon={{ className: "!size-6" }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<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"
|
||||
onClick={async () => {
|
||||
await Post("/account/logout", void 0);
|
||||
|
||||
setIntegration(void 0);
|
||||
setLoginInfo(void 0);
|
||||
}}
|
||||
>
|
||||
<LogOut
|
||||
className={clsx(
|
||||
"size-3 text-[#0287FF] transition-transform duration-1000"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-[#999]">
|
||||
{integration?.guest?.enabled
|
||||
? t("webLogin.hints.tourist")
|
||||
: t("webLogin.hints.login")}
|
||||
</span>
|
||||
|
||||
<LoginButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t dark:border-t-white/10">
|
||||
<Copyright />
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebLogin;
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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",
|
||||
{
|
||||
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",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-[6px] px-3 text-xs",
|
||||
lg: "h-10 rounded-[6px] px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MAIN_WINDOW_LABEL } from "@/constants";
|
||||
import { MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL } from "@/constants";
|
||||
import { useAsyncEffect, useEventListener } from "ahooks";
|
||||
|
||||
export interface DeepLinkHandler {
|
||||
@@ -52,7 +52,7 @@ export function useDeepLinkManager() {
|
||||
|
||||
// trigger oauth success event
|
||||
platformAdapter.emitEvent("oauth_success", { serverId });
|
||||
getCurrentWindow().setFocus();
|
||||
getCurrentWebviewWindow().setFocus();
|
||||
} catch (err) {
|
||||
console.error("Failed to parse OAuth callback URL:", err);
|
||||
addError("Invalid OAuth callback URL format: " + err);
|
||||
@@ -86,7 +86,13 @@ export function useDeepLinkManager() {
|
||||
const handlers: DeepLinkHandler[] = [
|
||||
{
|
||||
pattern: "oauth_callback",
|
||||
handler: handleOAuthCallback,
|
||||
handler: async (url) => {
|
||||
const windowLabel = await platformAdapter.getCurrentWindowLabel();
|
||||
|
||||
if (windowLabel !== SETTINGS_WINDOW_LABEL) return;
|
||||
|
||||
handleOAuthCallback(url);
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: "install_extension_from_store",
|
||||
|
||||
@@ -1,53 +1,9 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
export function useKeyboardHandlers() {
|
||||
const {
|
||||
setSourceData,
|
||||
visibleExtensionStore,
|
||||
setVisibleExtensionStore,
|
||||
visibleExtensionDetail,
|
||||
setVisibleExtensionDetail,
|
||||
} = useSearchStore();
|
||||
const { modifierKey } = useShortcutsStore();
|
||||
|
||||
const getModifierKeyPressed = (event: KeyboardEvent) => {
|
||||
const metaKeyPressed = event.metaKey && modifierKey === "meta";
|
||||
const ctrlKeyPressed = event.ctrlKey && modifierKey === "ctrl";
|
||||
const altKeyPressed = event.altKey && modifierKey === "alt";
|
||||
|
||||
return metaKeyPressed || ctrlKeyPressed || altKeyPressed;
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Handle ArrowLeft with meta key
|
||||
if (e.code === "ArrowLeft" && getModifierKeyPressed(e)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (visibleExtensionDetail) {
|
||||
return setVisibleExtensionDetail(false);
|
||||
}
|
||||
|
||||
if (visibleExtensionStore) {
|
||||
return setVisibleExtensionStore(false);
|
||||
}
|
||||
|
||||
return setSourceData(void 0);
|
||||
}
|
||||
},
|
||||
[setSourceData, modifierKey, visibleExtensionDetail]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
const { setSourceData, setVisibleExtensionStore } = useSearchStore();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ export function useKeyboardNavigation({
|
||||
setShowIndex,
|
||||
setSelectedName,
|
||||
globalItemIndexMap,
|
||||
handleItemAction,
|
||||
isChatMode,
|
||||
formatUrl,
|
||||
searchData,
|
||||
@@ -144,18 +143,6 @@ export function useKeyboardNavigation({
|
||||
setShowIndex(true);
|
||||
}
|
||||
|
||||
if (
|
||||
modifierKeyPressed &&
|
||||
e.key === "ArrowRight" &&
|
||||
selectedIndex !== null
|
||||
) {
|
||||
e.preventDefault();
|
||||
|
||||
const item = globalItemIndexMap[selectedIndex];
|
||||
|
||||
handleItemAction(item);
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey && selectedIndex !== null) {
|
||||
const item = globalItemIndexMap[selectedIndex];
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function useSearch() {
|
||||
return state.aiOverviewMinQuantity;
|
||||
});
|
||||
|
||||
const { querySourceTimeout } = useConnectStore();
|
||||
const { querySourceTimeout, searchDelay } = useConnectStore();
|
||||
|
||||
const [searchState, setSearchState] = useState<SearchState>({
|
||||
isError: [],
|
||||
@@ -219,10 +219,11 @@ export function useSearch() {
|
||||
]
|
||||
);
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() => debounce(performSearch, 300),
|
||||
[performSearch]
|
||||
);
|
||||
const debouncedSearch = useMemo(() => {
|
||||
console.log("searchDelay", searchDelay);
|
||||
|
||||
return debounce(performSearch, searchDelay);
|
||||
}, [performSearch, searchDelay]);
|
||||
|
||||
return {
|
||||
...searchState,
|
||||
|
||||
@@ -82,7 +82,7 @@ export const useSyncStore = () => {
|
||||
const setQueryTimeout = useConnectStore((state) => {
|
||||
return state.setQuerySourceTimeout;
|
||||
});
|
||||
const setOpacity = useAppearanceStore((state) => state.setOpacity);
|
||||
const { setNormalOpacity, setBlurOpacity } = useAppearanceStore();
|
||||
const setSnapshotUpdate = useAppearanceStore((state) => {
|
||||
return state.setSnapshotUpdate;
|
||||
});
|
||||
@@ -117,8 +117,12 @@ export const useSyncStore = () => {
|
||||
const setShowTooltip = useAppStore((state) => state.setShowTooltip);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setLanguage = useAppStore((state) => state.setLanguage);
|
||||
|
||||
const setServerListSilently = useConnectStore((state) => state.setServerListSilently);
|
||||
const { setWindowMode } = useAppearanceStore();
|
||||
const { setSearchDelay, setCompactModeAutoCollapseDelay } = useConnectStore();
|
||||
|
||||
const setServerListSilently = useConnectStore(
|
||||
(state) => state.setServerListSilently
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resetFixedWindow) {
|
||||
@@ -185,7 +189,9 @@ export const useSyncStore = () => {
|
||||
const {
|
||||
connectionTimeout,
|
||||
querySourceTimeout,
|
||||
searchDelay,
|
||||
allowSelfSignature,
|
||||
compactModeAutoCollapseDelay,
|
||||
} = payload;
|
||||
if (isNumber(connectionTimeout)) {
|
||||
setConnectionTimeout(connectionTimeout);
|
||||
@@ -193,16 +199,19 @@ export const useSyncStore = () => {
|
||||
if (isNumber(querySourceTimeout)) {
|
||||
setQueryTimeout(querySourceTimeout);
|
||||
}
|
||||
setSearchDelay(searchDelay);
|
||||
setAllowSelfSignature(allowSelfSignature);
|
||||
setCompactModeAutoCollapseDelay(compactModeAutoCollapseDelay);
|
||||
}),
|
||||
|
||||
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {
|
||||
const { opacity, snapshotUpdate } = payload;
|
||||
const { normalOpacity, blurOpacity, snapshotUpdate, windowMode } =
|
||||
payload;
|
||||
|
||||
if (isNumber(opacity)) {
|
||||
setOpacity(opacity);
|
||||
}
|
||||
setNormalOpacity(normalOpacity);
|
||||
setBlurOpacity(blurOpacity);
|
||||
setSnapshotUpdate(snapshotUpdate);
|
||||
setWindowMode(windowMode);
|
||||
}),
|
||||
|
||||
platformAdapter.listenEvent("change-extensions-store", ({ payload }) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useTauriFocus = (props: Props) => {
|
||||
useMount(async () => {
|
||||
if (!isTauri) return;
|
||||
|
||||
const appWindow = await platformAdapter.getWebviewWindow();
|
||||
const appWindow = await platformAdapter.getCurrentWebviewWindow();
|
||||
|
||||
const wait = isMac ? 0 : 100;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const useWindows = () => {
|
||||
useEffect(() => {
|
||||
const fetchWindow = async () => {
|
||||
try {
|
||||
const window = await platformAdapter.getCurrentWindow();
|
||||
const window = await platformAdapter.getCurrentWebviewWindow();
|
||||
setAppWindow(window);
|
||||
} catch (error) {
|
||||
console.error("Failed to get current window:", error);
|
||||
@@ -51,7 +51,7 @@ export const useWindows = () => {
|
||||
|
||||
const win = await platformAdapter.createWebviewWindow(args.label, args);
|
||||
|
||||
if(win) {
|
||||
if (win) {
|
||||
win.once("tauri://created", async () => {
|
||||
console.log("tauri://created");
|
||||
// if (args.label.includes("main")) {
|
||||
@@ -68,7 +68,6 @@ export const useWindows = () => {
|
||||
console.error("error:", error);
|
||||
});
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const closeWin = useCallback(async (label: string) => {
|
||||
@@ -96,32 +95,44 @@ export const useWindows = () => {
|
||||
}, []);
|
||||
|
||||
const listenEvents = useCallback(() => {
|
||||
let unlistenHandlers: { (): void; (): void; (): void; (): void; }[] = [];
|
||||
let unlistenHandlers: { (): void; (): void; (): void; (): void }[] = [];
|
||||
|
||||
const setupListeners = async () => {
|
||||
const winCreateHandler = await platformAdapter.listenWindowEvent("win-create", (event) => {
|
||||
console.log(event);
|
||||
createWin(event.payload);
|
||||
});
|
||||
const winCreateHandler = await platformAdapter.listenWindowEvent(
|
||||
"win-create",
|
||||
(event) => {
|
||||
console.log(event);
|
||||
createWin(event.payload);
|
||||
}
|
||||
);
|
||||
unlistenHandlers.push(winCreateHandler);
|
||||
|
||||
const winShowHandler = await platformAdapter.listenWindowEvent("win-show", async () => {
|
||||
if (!appWindow || !appWindow.label.includes("main")) return;
|
||||
await appWindow.show();
|
||||
await appWindow.unminimize();
|
||||
await appWindow.setFocus();
|
||||
});
|
||||
const winShowHandler = await platformAdapter.listenWindowEvent(
|
||||
"win-show",
|
||||
async () => {
|
||||
if (!appWindow || !appWindow.label.includes("main")) return;
|
||||
await appWindow.show();
|
||||
await appWindow.unminimize();
|
||||
await appWindow.setFocus();
|
||||
}
|
||||
);
|
||||
unlistenHandlers.push(winShowHandler);
|
||||
|
||||
const winHideHandler = await platformAdapter.listenWindowEvent("win-hide", async () => {
|
||||
if (!appWindow || !appWindow.label.includes("main")) return;
|
||||
await appWindow.hide();
|
||||
});
|
||||
const winHideHandler = await platformAdapter.listenWindowEvent(
|
||||
"win-hide",
|
||||
async () => {
|
||||
if (!appWindow || !appWindow.label.includes("main")) return;
|
||||
await appWindow.hide();
|
||||
}
|
||||
);
|
||||
unlistenHandlers.push(winHideHandler);
|
||||
|
||||
const winCloseHandler = await platformAdapter.listenWindowEvent("win-close", async () => {
|
||||
await appWindow.close();
|
||||
});
|
||||
const winCloseHandler = await platformAdapter.listenWindowEvent(
|
||||
"win-close",
|
||||
async () => {
|
||||
await appWindow.close();
|
||||
}
|
||||
);
|
||||
unlistenHandlers.push(winCloseHandler);
|
||||
};
|
||||
|
||||
@@ -144,4 +155,4 @@ export const useWindows = () => {
|
||||
getWin,
|
||||
getAllWin,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
"dark": "Dark",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"windowMode": {
|
||||
"title": "Window Mode",
|
||||
"description": "Set how the window appears when opened.",
|
||||
"default": "Default",
|
||||
"compact": "Compact"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "Choose your preferred language",
|
||||
@@ -158,24 +164,13 @@
|
||||
"description": "Shortcut button to enable AI Overview in chat mode."
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"title": "Connection Settings",
|
||||
"connectionTimeout": {
|
||||
"title": "Connection Timeout",
|
||||
"description": "Retries the connection if no response is received within this time. Default: 120s."
|
||||
},
|
||||
"queryTimeout": {
|
||||
"title": "Query Timeout",
|
||||
"description": "Terminates the query if no search results are returned within this time. Default: 500ms."
|
||||
},
|
||||
"allowSelfSignature": {
|
||||
"title": "Allow Self-Signed Certificates",
|
||||
"description": "Allow connections to servers using self-signed certificates. Enable only if you trust the source."
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance Settings",
|
||||
"opacity": {
|
||||
"normalOpacity": {
|
||||
"title": "Active Window Opacity",
|
||||
"description": "Adjust the opacity of the Coco AI window while in use. The range is from 10% to 100%, where 100% means fully opaque, and lower values increase transparency, allowing the underlying content to show through."
|
||||
},
|
||||
"blurOpacity": {
|
||||
"title": "Pinned Window Dimness Setting",
|
||||
"description": "Adjusts the opacity level of the Coco AI window when it’s pinned and not in focus. Set a value between 10% and 100%, where 100% means fully opaque (no dimming), and lower values increase transparency, allowing underlying content to show through."
|
||||
}
|
||||
@@ -186,6 +181,38 @@
|
||||
"title": "Snapshot Updates",
|
||||
"description": "Get early access to new features. May be unstable."
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"title": "Other Settings",
|
||||
"connectionTimeout": {
|
||||
"title": "Connection Timeout",
|
||||
"description": "Retries the connection if no response is received within this time. Default: 120s."
|
||||
},
|
||||
"queryTimeout": {
|
||||
"title": "Query Timeout",
|
||||
"description": "Terminates the query if no search results are returned within this time. Default: 500ms."
|
||||
},
|
||||
"searchDelay": {
|
||||
"title": "Search Delay",
|
||||
"description": "Delay before search is triggered after user stops typing. Default: 300 ms."
|
||||
},
|
||||
"allowSelfSignature": {
|
||||
"title": "Allow Self-Signed Certificates",
|
||||
"description": "Allow connections to servers using self-signed certificates. Enable only if you trust the source."
|
||||
},
|
||||
"localSearchResultWeight": {
|
||||
"title": "Local Search Result Weight",
|
||||
"description": "Adjusts how local results (files, extensions, commands, etc.) are ranked. Higher values place them closer to the top.",
|
||||
"options": {
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"compactModeAutoCollapseDelay": {
|
||||
"title": "Compact Mode Auto-Collapse Delay",
|
||||
"description": "Adds a delay before collapsing in Compact Mode to avoid sudden size changes when typing or clearing input. Default: 10s."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -216,9 +243,11 @@
|
||||
"importSuccess": "Extension imported successfully.",
|
||||
"importFailed": "No valid extension found in the selected folder. Please check the folder structure.",
|
||||
"extensionAlreadyImported": "Extension already imported. Please remove it first.",
|
||||
"incompatibleExtension": "This extension is incompatible with your OS.",
|
||||
"platformIncompatibleExtension": "This extension is incompatible with your OS.",
|
||||
"appIncompatibleExtension": "Installation failed! Incompatible with your Coco App version. Please update and retry.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallSuccess": "Uninstalled successfully"
|
||||
"uninstallSuccess": "Uninstalled successfully",
|
||||
"incompatible": "Extension cannot run on the current version. Please upgrade Coco App."
|
||||
},
|
||||
"application": {
|
||||
"title": "Applications",
|
||||
@@ -482,7 +511,10 @@
|
||||
"serverPlaceholder": "For example: https://coco.infini.cloud/",
|
||||
"connecting": "Connecting...",
|
||||
"connect": "Connect",
|
||||
"closeError": "Close error message"
|
||||
"closeError": "Close error message",
|
||||
"hints": {
|
||||
"loginSuccess": "Login Successful"
|
||||
}
|
||||
},
|
||||
"dataSource": {
|
||||
"title": "Data Source",
|
||||
@@ -584,5 +616,17 @@
|
||||
},
|
||||
"deepLink": {
|
||||
"extensionInstallSuccessfully": "Extension installed successfully."
|
||||
},
|
||||
"webLogin": {
|
||||
"title": "Account Information",
|
||||
"hints": {
|
||||
"tourist": "Tourist mode, login to unlock the full experience.",
|
||||
"login": "Please log in to your account to start.",
|
||||
"welcome": "Welcome to Coco AI.",
|
||||
"pleaseLogin": "Please log in to your account to start."
|
||||
},
|
||||
"buttons": {
|
||||
"login": "Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
"dark": "深色",
|
||||
"auto": "自动"
|
||||
},
|
||||
"windowMode": {
|
||||
"title": "窗口模式",
|
||||
"description": "设置窗口打开时的显示方式。",
|
||||
"default": "默认",
|
||||
"compact": "紧凑"
|
||||
},
|
||||
"language": {
|
||||
"title": "语言",
|
||||
"description": "选择您的首选语言",
|
||||
@@ -158,24 +164,13 @@
|
||||
"description": "在搜索模式下启用 AI 总结的快捷按键。"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"title": "连接设置",
|
||||
"connectionTimeout": {
|
||||
"title": "连接超时",
|
||||
"description": "如果在此时间内未收到响应,则重试连接。默认值:120 秒。"
|
||||
},
|
||||
"queryTimeout": {
|
||||
"title": "查询超时",
|
||||
"description": "在此时间内未返回搜索结果,则终止查询。默认值:500 毫秒。"
|
||||
},
|
||||
"allowSelfSignature": {
|
||||
"title": "允许自签名证书",
|
||||
"description": "允许连接使用自签名证书的服务器。仅在信任来源的情况下启用。"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外观设置",
|
||||
"opacity": {
|
||||
"normalOpacity": {
|
||||
"title": "窗口透明度",
|
||||
"description": "调整 Coco AI 窗口在使用时的透明度。范围为10%到100%,其中100%表示完全不透明,较低的值会增加透明度,使底层内容能够透过窗口显示出来。"
|
||||
},
|
||||
"blurOpacity": {
|
||||
"title": "置顶时失焦透明度",
|
||||
"description": "设置 Coco AI 窗口在置顶且失去焦点时的不透明度(10%–100%,100% 表示完全不透明)。"
|
||||
}
|
||||
@@ -186,6 +181,38 @@
|
||||
"title": "快照版更新",
|
||||
"description": "抢先体验新功能,可能不稳定。"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"title": "其它设置",
|
||||
"connectionTimeout": {
|
||||
"title": "连接超时",
|
||||
"description": "如果在此时间内未收到响应,则重试连接。默认值:120 秒。"
|
||||
},
|
||||
"queryTimeout": {
|
||||
"title": "查询超时",
|
||||
"description": "在此时间内未返回搜索结果,则终止查询。默认值:500 毫秒。"
|
||||
},
|
||||
"searchDelay": {
|
||||
"title": "搜索延迟",
|
||||
"description": "停止输入后触发搜索的延迟时间。默认值:300 毫秒。 "
|
||||
},
|
||||
"allowSelfSignature": {
|
||||
"title": "允许自签名证书",
|
||||
"description": "允许连接使用自签名证书的服务器。仅在信任来源的情况下启用。"
|
||||
},
|
||||
"localSearchResultWeight": {
|
||||
"title": "本地搜索结果展示权重",
|
||||
"description": "调整本地结果(文件、扩展名、命令等)的排名。值越高,它们越接近顶部。",
|
||||
"options": {
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低"
|
||||
}
|
||||
},
|
||||
"compactModeAutoCollapseDelay": {
|
||||
"title": "紧凑模式自动收起延迟",
|
||||
"description": "为紧凑模式的自动收起添加延迟,避免输入或清空内容时窗口突然缩小。默认: 10s。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -216,9 +243,11 @@
|
||||
"importSuccess": "插件导入成功。",
|
||||
"importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。",
|
||||
"extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。",
|
||||
"incompatibleExtension": "此插件与当前操作系统不兼容。",
|
||||
"platformIncompatibleExtension": "此插件与当前操作系统不兼容。",
|
||||
"appIncompatibleExtension": "安装失败!该插件与当前 Coco App 版本不兼容,请升级后重试。",
|
||||
"uninstall": "卸载",
|
||||
"uninstallSuccess": "卸载成功"
|
||||
"uninstallSuccess": "卸载成功",
|
||||
"incompatible": "扩展无法在当前版本中运行,请升级 Coco App。"
|
||||
},
|
||||
"application": {
|
||||
"title": "应用程序",
|
||||
@@ -482,7 +511,10 @@
|
||||
"serverPlaceholder": "例如:https://coco.infini.cloud/",
|
||||
"connecting": "连接中...",
|
||||
"connect": "连接",
|
||||
"closeError": "关闭错误提示"
|
||||
"closeError": "关闭错误提示",
|
||||
"hints": {
|
||||
"loginSuccess": "登录成功"
|
||||
}
|
||||
},
|
||||
"dataSource": {
|
||||
"title": "数据源",
|
||||
@@ -583,5 +615,17 @@
|
||||
},
|
||||
"deepLink": {
|
||||
"extensionInstallSuccessfully": "扩展安装成功。"
|
||||
},
|
||||
"webLogin": {
|
||||
"title": "账户信息",
|
||||
"hints": {
|
||||
"tourist": "游客模式,登录解锁完整体验。",
|
||||
"login": "请登录您的账户以开始。",
|
||||
"welcome": "欢迎访问 Coco AI。",
|
||||
"pleaseLogin": "请登录您的帐户开始使用。"
|
||||
},
|
||||
"buttons": {
|
||||
"login": "登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
src/main.css
68
src/main.css
@@ -96,7 +96,7 @@
|
||||
}
|
||||
|
||||
.input-body {
|
||||
@apply rounded-md overflow-hidden;
|
||||
@apply rounded-[6px] overflow-hidden;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -106,12 +106,65 @@
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Component styles */
|
||||
@layer components {
|
||||
.settings-input {
|
||||
@apply block w-full rounded-md border-gray-300 dark:border-gray-600
|
||||
@apply block w-full rounded-[6px] border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700
|
||||
text-gray-900 dark:text-gray-100
|
||||
shadow-sm focus:border-blue-500 focus:ring-blue-500
|
||||
@@ -119,7 +172,7 @@
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
@apply text-sm rounded-md border-gray-300 dark:border-gray-600
|
||||
@apply text-sm rounded-[6px] border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700
|
||||
text-gray-900 dark:text-gray-100
|
||||
shadow-sm focus:border-blue-500 focus:ring-blue-500
|
||||
@@ -261,3 +314,12 @@
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function MainApp() {
|
||||
setViewExtensionOpened(payload);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { synthesizeItem } = useChatStore();
|
||||
|
||||
useSyncStore();
|
||||
@@ -34,7 +35,6 @@ function MainApp() {
|
||||
<>
|
||||
<SearchChat isTauri={true} hasModules={["search", "chat"]} />
|
||||
<UpdateApp />
|
||||
|
||||
{synthesizeItem && <Synthesize />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||
|
||||
const tabIndexMap: { [key: string]: number } = {
|
||||
general: 0,
|
||||
@@ -58,6 +59,10 @@ function SettingsPage() {
|
||||
platformAdapter.emitEvent("change-app-store", state);
|
||||
});
|
||||
|
||||
const unsubscribeAppearanceStore = useAppearanceStore.subscribe((state) => {
|
||||
platformAdapter.emitEvent("change-appearance-store", state);
|
||||
});
|
||||
|
||||
const unlisten2 = platformAdapter.listenEvent(
|
||||
"config-extension",
|
||||
({ payload }) => {
|
||||
@@ -70,6 +75,7 @@ function SettingsPage() {
|
||||
return () => {
|
||||
unsubscribeConnect();
|
||||
unsubscribeAppStore();
|
||||
unsubscribeAppearanceStore();
|
||||
unlisten.then((fn) => fn());
|
||||
unlisten2.then((fn) => fn());
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isPlainObject } from "lodash-es";
|
||||
|
||||
import SearchChat from "@/components/SearchChat";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
@@ -10,6 +11,8 @@ import useEscape from "@/hooks/useEscape";
|
||||
import { useViewportHeight } from "@/hooks/useViewportHeight";
|
||||
import type { StartPage } from "@/types/chat";
|
||||
import ErrorNotification from "@/components/Common/ErrorNotification";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||
|
||||
import "@/i18n";
|
||||
import "@/web.css";
|
||||
@@ -32,6 +35,8 @@ interface WebAppProps {
|
||||
formatUrl?: (item: any) => string;
|
||||
isOpen?: boolean;
|
||||
language?: string;
|
||||
settings?: any;
|
||||
refreshSettings?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function WebApp({
|
||||
@@ -45,7 +50,7 @@ function WebApp({
|
||||
hasModules = ["search", "chat"],
|
||||
defaultModule = "search",
|
||||
assistantIDs = [],
|
||||
theme = "dark",
|
||||
theme = "auto",
|
||||
searchPlaceholder = "",
|
||||
chatPlaceholder = "",
|
||||
showChatHistory = false,
|
||||
@@ -53,9 +58,11 @@ function WebApp({
|
||||
setIsPinned,
|
||||
onCancel,
|
||||
formatUrl,
|
||||
language = 'en',
|
||||
language = "en",
|
||||
settings,
|
||||
refreshSettings,
|
||||
}: WebAppProps) {
|
||||
const {setIsTauri, setEndpoint} = useAppStore();
|
||||
const { setIsTauri, setEndpoint } = useAppStore();
|
||||
const setModeSwitch = useShortcutsStore((state) => state.setModeSwitch);
|
||||
const setInternetSearch = useShortcutsStore((state) => {
|
||||
return state.setInternetSearch;
|
||||
@@ -66,11 +73,38 @@ function WebApp({
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
|
||||
const {
|
||||
integration,
|
||||
loginInfo,
|
||||
setIntegration,
|
||||
setLoginInfo,
|
||||
setOnRefresh,
|
||||
setDisabled,
|
||||
} = useWebConfigStore();
|
||||
|
||||
const getUserProfile = async () => {
|
||||
const [err, result] = await Get("/account/profile");
|
||||
|
||||
if (err || !isPlainObject(result)) {
|
||||
setLoginInfo(void 0);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoginInfo(result as any);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUserProfile();
|
||||
|
||||
setIsTauri(false);
|
||||
setEndpoint(serverUrl);
|
||||
setModeSwitch("S");
|
||||
setInternetSearch("E");
|
||||
setIntegration(settings);
|
||||
setOnRefresh(async () => {
|
||||
await getUserProfile();
|
||||
return refreshSettings?.();
|
||||
});
|
||||
|
||||
localStorage.setItem("headers", JSON.stringify(headers || {}));
|
||||
}, []);
|
||||
@@ -83,6 +117,10 @@ function WebApp({
|
||||
useModifierKeyPress();
|
||||
useViewportHeight();
|
||||
|
||||
useEffect(() => {
|
||||
setDisabled(!loginInfo && !integration?.guest?.enabled);
|
||||
}, [integration, loginInfo]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="searchChat-container"
|
||||
@@ -126,7 +164,7 @@ function WebApp({
|
||||
startPage={startPage}
|
||||
formatUrl={formatUrl}
|
||||
/>
|
||||
<ErrorNotification isTauri={false}/>
|
||||
<ErrorNotification isTauri={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useMount } from "ahooks";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import LayoutOutlet from "./outlet";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
const Layout = () => {
|
||||
const { language } = useAppStore();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useMount(async () => {
|
||||
await invoke("backend_setup", {
|
||||
await platformAdapter.invokeBackend("backend_setup", {
|
||||
appLang: language,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ export type IAppStore = {
|
||||
|
||||
blurred: boolean;
|
||||
setBlurred: (blurred: boolean) => void;
|
||||
|
||||
suppressErrors: boolean;
|
||||
setSuppressErrors: (suppressErrors: boolean) => void;
|
||||
};
|
||||
|
||||
export const useAppStore = create<IAppStore>()(
|
||||
@@ -110,6 +113,9 @@ export const useAppStore = create<IAppStore>()(
|
||||
|
||||
blurred: false,
|
||||
setBlurred: (blurred: boolean) => set({ blurred }),
|
||||
|
||||
suppressErrors: false,
|
||||
setSuppressErrors: (suppressErrors: boolean) => set({ suppressErrors }),
|
||||
}),
|
||||
{
|
||||
name: "app-store",
|
||||
|
||||
@@ -1,31 +1,47 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, subscribeWithSelector } from "zustand/middleware";
|
||||
|
||||
export type WindowMode = "default" | "compact";
|
||||
|
||||
export type IAppearanceStore = {
|
||||
opacity: number;
|
||||
setOpacity: (opacity?: number) => void;
|
||||
normalOpacity: number;
|
||||
setNormalOpacity: (normalOpacity: number) => void;
|
||||
blurOpacity: number;
|
||||
setBlurOpacity: (blurOpacity: number) => void;
|
||||
snapshotUpdate: boolean;
|
||||
setSnapshotUpdate: (snapshotUpdate: boolean) => void;
|
||||
windowMode: WindowMode;
|
||||
setWindowMode: (windowMode: WindowMode) => void;
|
||||
};
|
||||
|
||||
export const useAppearanceStore = create<IAppearanceStore>()(
|
||||
subscribeWithSelector(
|
||||
persist(
|
||||
(set) => ({
|
||||
opacity: 30,
|
||||
setOpacity: (opacity) => {
|
||||
return set({ opacity: opacity });
|
||||
normalOpacity: 100,
|
||||
setNormalOpacity(normalOpacity) {
|
||||
return set({ normalOpacity });
|
||||
},
|
||||
blurOpacity: 30,
|
||||
setBlurOpacity(blurOpacity) {
|
||||
return set({ blurOpacity });
|
||||
},
|
||||
snapshotUpdate: false,
|
||||
setSnapshotUpdate: (snapshotUpdate) => {
|
||||
return set({ snapshotUpdate });
|
||||
},
|
||||
windowMode: "default",
|
||||
setWindowMode(windowMode) {
|
||||
return set({ windowMode });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "startup-store",
|
||||
partialize: (state) => ({
|
||||
opacity: state.opacity,
|
||||
normalOpacity: state.normalOpacity,
|
||||
blurOpacity: state.blurOpacity,
|
||||
snapshotUpdate: state.snapshotUpdate,
|
||||
windowMode: state.windowMode,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -33,6 +33,8 @@ export type IChatStore = {
|
||||
setUploadAttachments: (value: UploadAttachments[]) => void;
|
||||
synthesizeItem?: SynthesizeItem;
|
||||
setSynthesizeItem: (synthesizeItem?: SynthesizeItem) => void;
|
||||
hasActiveChat?: boolean;
|
||||
setHasActiveChat: (hasActiveChat?: boolean) => void;
|
||||
};
|
||||
|
||||
export const useChatStore = create<IChatStore>()(
|
||||
@@ -53,9 +55,12 @@ export const useChatStore = create<IChatStore>()(
|
||||
setUploadAttachments: (uploadAttachments: UploadAttachments[]) => {
|
||||
return set(() => ({ uploadAttachments }));
|
||||
},
|
||||
setSynthesizeItem(synthesizeItem?: SynthesizeItem) {
|
||||
setSynthesizeItem: (synthesizeItem?: SynthesizeItem) => {
|
||||
return set(() => ({ synthesizeItem }));
|
||||
},
|
||||
setHasActiveChat(hasActiveChat) {
|
||||
return set(() => ({ hasActiveChat }));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "chat-state",
|
||||
|
||||
@@ -38,6 +38,12 @@ export type IConnectStore = {
|
||||
setVisibleStartPage: (visibleStartPage: boolean) => void;
|
||||
allowSelfSignature: boolean;
|
||||
setAllowSelfSignature: (allowSelfSignature: boolean) => void;
|
||||
searchDelay: number;
|
||||
setSearchDelay: (searchDelay: number) => void;
|
||||
compactModeAutoCollapseDelay: number;
|
||||
setCompactModeAutoCollapseDelay: (
|
||||
compactModeAutoCollapseDelay: number
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const useConnectStore = create<IConnectStore>()(
|
||||
@@ -45,7 +51,7 @@ export const useConnectStore = create<IConnectStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
serverList: [],
|
||||
setServerList: async(serverList: Server[]) => {
|
||||
setServerList: async (serverList: Server[]) => {
|
||||
set(
|
||||
produce((draft) => {
|
||||
draft.serverList = serverList;
|
||||
@@ -143,6 +149,14 @@ export const useConnectStore = create<IConnectStore>()(
|
||||
setAllowSelfSignature: (allowSelfSignature: boolean) => {
|
||||
return set(() => ({ allowSelfSignature }));
|
||||
},
|
||||
searchDelay: 300,
|
||||
setSearchDelay(searchDelay) {
|
||||
return set(() => ({ searchDelay }));
|
||||
},
|
||||
compactModeAutoCollapseDelay: 10,
|
||||
setCompactModeAutoCollapseDelay(compactModeAutoCollapseDelay) {
|
||||
return set(() => ({ compactModeAutoCollapseDelay }));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "connect-store",
|
||||
@@ -156,6 +170,8 @@ export const useConnectStore = create<IConnectStore>()(
|
||||
currentAssistant: state.currentAssistant,
|
||||
querySourceTimeout: state.querySourceTimeout,
|
||||
allowSelfSignature: state.allowSelfSignature,
|
||||
searchDelay: state.searchDelay,
|
||||
compactModeAutoCollapseDelay: state.compactModeAutoCollapseDelay,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,15 +3,18 @@ import {
|
||||
ExtensionPermission,
|
||||
ViewExtensionUISettings,
|
||||
} from "@/components/Settings/Extensions";
|
||||
import { SearchDocument } from "@/types/search";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type ViewExtensionOpened = [
|
||||
// Extension name
|
||||
string,
|
||||
// An absolute path to the extension icon or a font code.
|
||||
string,
|
||||
// HTML file URL
|
||||
string,
|
||||
ExtensionPermission | null,
|
||||
ViewExtensionUISettings | null,
|
||||
SearchDocument
|
||||
];
|
||||
|
||||
export type ISearchStore = {
|
||||
@@ -55,12 +58,6 @@ export type ISearchStore = {
|
||||
setVisibleExtensionDetail: (visibleExtensionDetail: boolean) => void;
|
||||
|
||||
// When we open a View extension, we set this to a non-null value.
|
||||
//
|
||||
// Arguments
|
||||
//
|
||||
// The first array element is the path to the page that we should load
|
||||
// The second element is the permission that this extension requires.
|
||||
// The third argument is the UI Settings
|
||||
viewExtensionOpened?: ViewExtensionOpened;
|
||||
setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void;
|
||||
};
|
||||
|
||||
51
src/stores/webConfigStore.ts
Normal file
51
src/stores/webConfigStore.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface Integration {
|
||||
guest?: {
|
||||
enabled?: boolean;
|
||||
run_as?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type IWebAccessControlStore = {
|
||||
integration?: Integration;
|
||||
setIntegration: (integration?: Integration) => void;
|
||||
loginInfo?: LoginInfo;
|
||||
setLoginInfo: (loginInfo?: LoginInfo) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
setOnRefresh: (onRefresh: () => Promise<void>) => void;
|
||||
disabled: boolean;
|
||||
setDisabled: (disabled: boolean) => void;
|
||||
};
|
||||
|
||||
export const useWebConfigStore = create<IWebAccessControlStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
setIntegration: (integration) => {
|
||||
return set({ integration });
|
||||
},
|
||||
setLoginInfo: (loginInfo) => {
|
||||
return set({ loginInfo });
|
||||
},
|
||||
onRefresh: async () => {},
|
||||
setOnRefresh: (onRefresh) => {
|
||||
return set({ onRefresh });
|
||||
},
|
||||
disabled: true,
|
||||
setDisabled: (disabled) => {
|
||||
return set({ disabled });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "web-config-store",
|
||||
partialize: () => ({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -60,7 +60,6 @@ export interface WindowOperations {
|
||||
showWindow: () => Promise<void>;
|
||||
setAlwaysOnTop: (isPinned: boolean) => Promise<void>;
|
||||
setShadow(enable: boolean): Promise<void>;
|
||||
getWebviewWindow: () => Promise<any>;
|
||||
getWindowByLabel: (label: string) => Promise<{
|
||||
show: () => Promise<void>;
|
||||
setFocus: () => Promise<void>;
|
||||
@@ -68,8 +67,6 @@ export interface WindowOperations {
|
||||
close: () => Promise<void>;
|
||||
} | null>;
|
||||
createWindow: (label: string, options: any) => Promise<void>;
|
||||
getAllWindows: () => Promise<any[]>;
|
||||
getCurrentWindow: () => Promise<any>;
|
||||
createWebviewWindow: (label: string, options: any) => Promise<any>;
|
||||
listenWindowEvent: (
|
||||
event: string,
|
||||
@@ -88,8 +85,6 @@ export interface ThemeAndEvents {
|
||||
event: K,
|
||||
callback: (event: { payload: EventPayloads[K] }) => void
|
||||
) => Promise<() => void>;
|
||||
setWindowTheme: (theme: string | null) => Promise<void>;
|
||||
getWindowTheme: () => Promise<string>;
|
||||
onThemeChanged: (
|
||||
callback: (payload: { payload: string }) => void
|
||||
) => Promise<void>;
|
||||
|
||||
@@ -8,8 +8,8 @@ import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { getCurrentWindowService } from "@/commands/windowService";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import i18next from "i18next";
|
||||
|
||||
// 1
|
||||
export async function copyToClipboard(text: string) {
|
||||
const addError = useAppStore.getState().addError;
|
||||
const language = useAppStore.getState().language;
|
||||
@@ -144,7 +144,7 @@ export const parseSearchQuery = (searchQuery: SearchQuery) => {
|
||||
|
||||
const result = Object.entries(rest)
|
||||
.filter(([_, value]) => !isTrulyEmpty(value))
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`);
|
||||
.map(([key, value]) => `${key}=${value}`);
|
||||
|
||||
if (isObject(filters)) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
@@ -299,7 +299,7 @@ export const visibleSearchBar = () => {
|
||||
|
||||
if (isNil(viewExtensionOpened)) return true;
|
||||
|
||||
const [, , ui] = viewExtensionOpened;
|
||||
const ui = viewExtensionOpened[4];
|
||||
|
||||
return ui?.search_bar ?? true;
|
||||
};
|
||||
@@ -312,7 +312,7 @@ export const visibleFilterBar = () => {
|
||||
|
||||
if (isNil(viewExtensionOpened)) return true;
|
||||
|
||||
const [, , ui] = viewExtensionOpened;
|
||||
const ui = viewExtensionOpened[4];
|
||||
|
||||
return ui?.filter_bar ?? true;
|
||||
};
|
||||
@@ -322,7 +322,27 @@ export const visibleFooterBar = () => {
|
||||
|
||||
if (isNil(viewExtensionOpened)) return true;
|
||||
|
||||
const [, , ui] = viewExtensionOpened;
|
||||
const ui = viewExtensionOpened[4];
|
||||
|
||||
return ui?.footer ?? true;
|
||||
};
|
||||
|
||||
export const installExtensionError = (error: string) => {
|
||||
const { addError } = useAppStore.getState();
|
||||
|
||||
let message = "settings.extensions.hints.importFailed";
|
||||
|
||||
if (error === "already imported") {
|
||||
message = "settings.extensions.hints.extensionAlreadyImported";
|
||||
}
|
||||
|
||||
if (error === "platform_incompatible") {
|
||||
message = "settings.extensions.hints.platformIncompatibleExtension";
|
||||
}
|
||||
|
||||
if (error === "app_incompatible") {
|
||||
message = "settings.extensions.hints.appIncompatibleExtension";
|
||||
}
|
||||
|
||||
addError(i18next.t(message));
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user