44 Commits

Author SHA1 Message Date
medcl
8ac0065234 v0.9.0 2025-11-24 11:24:37 +08:00
ayangweb
31806b6057 fix: persist configuration settings properly (#987)
* fix: persist configuration settings properly

* docs: update changelog
2025-11-19 16:09:27 +08:00
SteveLauC
533bfaf45b fix: search_extension should not panic when ext is not found (#983)
This commit fixes a bug that the search_extension() function panics
when the "GET /store/_search" interface returns a 404 response.

```
GET /store/_search?query=<query string>
{"_id":"_search","result":"not_found"}
```

It also improves the panic message by including varaible "response" in it,
so that we can inspect the actual response.

```
let hits_json = response.remove("hits").unwrap_or_else(|| {
    panic!(
        "the JSON response should contain field [hits], response [{:?}]",
        response
    )
});
```
2025-11-19 10:10:59 +08:00
ayangweb
459705af70 refactor: simplify fetching the screen under the mouse cursor (#985) 2025-11-19 09:39:36 +08:00
Hardy
84e556ddad chore: update release notes for publish 0.9.0 (#986)
Co-authored-by: github-actions <github-actions@github.com>
2025-11-19 09:38:37 +08:00
Medcl
b50a20c7d4 fix release notes for version 0.9.0 (#982) 2025-11-18 13:50:57 +08:00
ayangweb
d4ccd780b2 feat: add auto collapse delay for compact mode (#981)
* feat: add auto collapse delay for compact mode

* refactor: change i18n

* docs: update changelog
2025-11-17 20:56:44 +08:00
SteveLauC
aef934e9a2 feat: advanced settings search debounce & local query source weight (#950)
* wip

* wip

* wip

* feat: add search delay

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* docs: update changelog

---------

Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-11-17 18:35:30 +08:00
ayangweb
1fb927c26b fix: fix quick ai not continuing conversation (#979)
* fix: fix quick ai not continuing conversation

* docs: update changelog
2025-11-16 20:59:20 +08:00
ayangweb
8974624b3c refactor: remove url encoding from query parameters (#978) 2025-11-16 14:05:43 +08:00
ayangweb
d99b35bf4c fix: prevent duplicate login success messages (#977)
* fix: prevent duplicate login success messages

* refactor: i18n

* refactor: update

* docs: update changelog
2025-11-14 15:42:56 +08:00
SteveLauC
594d0ffe3f fix: allow any http/https requests (#976) 2025-11-14 11:53:29 +08:00
ayangweb
7b08a87766 refactor: replace all rounded-md with rounded-[6px] (#975) 2025-11-14 11:53:14 +08:00
SteveLauC
ab5ca24270 fix: correct csp setting (#974) 2025-11-14 10:34:29 +08:00
ayangweb
c593b07187 refactor: only call oauth in settings window (#972) 2025-11-13 19:50:59 +08:00
SteveLauC
c088dde749 refactor(view extension): load HTML/resources via local HTTP server (#973)
Previously, View extensions loaded their HTML files directly from disk.
Now, Coco starts a lightweight local HTTP server to serve the static files,
and the extension loads them via HTTP instead.

This refactoring is needed because Tauri is not allowed to load local
files directly, we have to call convertFileSrc() to do the URL
conversion to make it work. In previous implementations, we did such
conversions to all the paths specified in the HTML file, but we realized
that there are paths in JS/CSS files as well, and it is impossible to
convert them all. So we have to change the way how view extensions load
their files.
2025-11-13 19:50:32 +08:00
ayangweb
1fdf7c499d refactor: change web login position (#971)
* refactor: change web login position

* refactor: update
2025-11-08 21:02:04 +08:00
ayangweb
01dfc616d4 refactor: render footer content conditionally (#970) 2025-11-08 10:51:09 +08:00
BiggerRain
8d7d655581 fix: web page login state (#969) 2025-11-07 21:44:28 +08:00
BiggerRain
5292538dd7 fix: chat mode has been minimized (#968) 2025-11-07 21:05:50 +08:00
ayangweb
bab98d4576 feat: add web login (#967)
* feat: add web login

* refactor: update

* refactor: update
2025-11-07 17:12:00 +08:00
BiggerRain
6067fa7029 chore: remove check window decorations (#966)
* chore: remove check window decorations

* chore: add skip button

* chore: key add index
2025-11-05 15:13:26 +08:00
BiggerRain
61860b400f chore: set divider line (#965) 2025-11-05 09:48:50 +08:00
ayangweb
50518b6c21 refactor: optimize chat window size in compact mode (#964) 2025-11-04 20:03:53 +08:00
ayangweb
b5d3ce9910 feat: add window opacity configuration option (#963)
* feat: add window opacity configuration option

* docs: update changelog
2025-11-04 14:52:01 +08:00
ayangweb
abac92d8d5 refactor: keep the same height in compact mode (#962) 2025-11-04 11:45:47 +08:00
BiggerRain
9ea7c9b1ff chore: center the main window vertically (#959)
* chore: center the main window vertically

* docs: add release note
2025-11-03 10:52:29 +08:00
BiggerRain
fcbc77fb5a chore: fixed window background image (#960) 2025-11-03 10:52:09 +08:00
BiggerRain
60b34a118b chore: hide error messages in small window (#961) 2025-11-03 10:51:51 +08:00
SteveLauC
3e0839f3da feat(extension compatibility): minimum_coco_version (#946)
This commit introduces a new field, `minimum_coco_version`, to the
`plugin.json` JSON. It specifies the lowest Coco version required
for an extension to run.

This ensures better compatibility by preventing new extensions from
being loaded on older Coco apps that may lack necessary APIs or features.

Co-authored-by: ayang <473033518@qq.com>
2025-11-02 10:59:29 +08:00
BiggerRain
bd61faf660 fix: console code error (#956) 2025-11-02 10:58:51 +08:00
BiggerRain
0e48f4f71c fix: react code render bug (#957) 2025-11-02 10:58:23 +08:00
BiggerRain
24fe7144f8 fix: keep the window height when the popover is open. (#958) 2025-11-02 09:17:04 +08:00
BiggerRain
e92eee1ecf fix: prevent shaking when switching between chat and search pages (#955)
* fix: prevent shaking when switching between chat and search pages in compact mode

* docs: add release note
2025-10-30 10:19:05 +08:00
BiggerRain
1996298f0c feat: add shadcn ui config (#954)
* feat: add shadcn ui config

* chore: tailwind config
2025-10-29 10:38:52 +08:00
SteveLauC
c879c63b17 ci(windows): skip installing LLVM since it is pre-installed (#953)
This commit removes the CI step that installs LLVM on Windows because:

1. It was constantly failing when I worked on [1]

   ```text
   Failed in attempting to update the source: winget
   The `msstore` source requires that you view the following agreements before using.
   Terms of Transaction: https://aka.ms/microsoft-store-terms-of-transaction
   The source requires the current machine's 2-letter geographic region to be sent to
   the backend service to function properly (ex. "US").

   Failed when searching source: winget
   An unexpected error occurred while executing the command:
   0x8a15000f : Data required by the source is missing

   No packages were found among the working sources.
   ```

2. Actually, we don't need to install it since the Windows Github
   action image already includes it. See [2]

[1]: https://github.com/infinilabs/coco-app/pull/946
[2]: https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
2025-10-28 14:10:03 +08:00
dependabot[bot]
892fe78d03 build(deps): bump axios from 1.9.0 to 1.12.0 (#952)
Bumps [axios](https://github.com/axios/axios) from 1.9.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.9.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-28 11:08:21 +08:00
ayangweb
e5860f63c7 refactor: restore default cursor movement with meta + arrow keys (#951) 2025-10-27 10:10:14 +08:00
SteveLauC
fa9656bfd7 fix: panic when opening view extension via hotkey (#949)
Another change that should be made to fix the View extension hotkey. I
should include it in this commit[1] but I forgot to.

[1]: e029ddf2ba
2025-10-27 10:08:53 +08:00
SteveLauC
03954748b6 refactor(post-search): collect at least 2 documents from each query source (#948)
This commit refactors the code that evenly collects documents from query
sources to let it collect at least 2 documents in every source, which
could correct the case when `max_hits_per_source` is 0. This was possible
with the previous impl, no longer allowed after this commit
2025-10-27 10:07:57 +08:00
ayangweb
4a627cb32e feat: add compact mode for window (#947)
* feat: add compact mode for window

* docs: update changelog

* feat: add i18n

* refactor: update

* refactor: update
2025-10-27 10:06:19 +08:00
SteveLauC
3029303e95 refactor: custom_version_comparator() now compares semantic versions (#941)
* refactor: custom_version_comparator() now compares semantic versions

Previously, when comparing 2 versions, custom_version_comparator() only
compared their build numbers, which was incorrect. See this case:

```text
0.8.0-2500 -> 0.9.0-SNAPSHOT-2501 -> 0.8.1-2502
```

Coco adopts SemVer[1], and according to the specification, "0.8.1-2502"
is older than "0.9.0-SNAPSHOT-2501" even though it has a larger build
number.

This commit refactors it to compare the semantic versions.

[1]: Even though Coco uses SemVer, our version string does not follow the
spec. In this implementation, we use `to_semver()` to do the conversion, see
the code comments for more details.

* correct comments
2025-10-27 10:04:15 +08:00
ayangweb
fc7cd165a8 refactor: replace getCurrentWindow with getCurrentWebviewWindow (#945)
* refactor: replace `getCurrentWindow` with `getCurrentWebviewWindow`

* refactor: update

* refactor: update
2025-10-23 16:48:13 +08:00
SteveLauC
f267df3f71 docs: update some comments (#944) 2025-10-23 15:23:18 +08:00
105 changed files with 3349 additions and 1776 deletions

View File

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

View File

@@ -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
View 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": {}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

329
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&current_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)?;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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": {}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ const ConnectPrompt = () => {
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
<button
className="flex items-center gap-2 px-6 py-2 rounded-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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}

View File

@@ -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`}>

View File

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

View File

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

View File

@@ -111,7 +111,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
{showTooltip && visibleShortcut ? (
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-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
)}
>

View File

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

View File

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

View File

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

View File

@@ -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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [

View File

@@ -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,
}

View File

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

View File

@@ -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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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],

View File

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

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

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

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

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

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

View File

@@ -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",

View File

@@ -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 () => {

View File

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

View File

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

View File

@@ -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 }) => {

View File

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

View File

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

View File

@@ -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 its 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"
}
}
}

View File

@@ -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": "登录"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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

View File

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

View 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: () => ({}),
}
)
);

View File

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

View File

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