34 Commits

Author SHA1 Message Date
ayang
a05ca9646d fix: fix macos microphone permission after signing 2025-10-23 15:10:04 +08:00
ayangweb
b07707e973 fix: allow deletion after selecting all text (#943)
* fix: allow deletion after selecting all text

* docs: update changelog
2025-10-23 14:26:08 +08:00
BiggerRain
6b0111b89f fix: remove error code (#942) 2025-10-23 09:16:18 +08:00
SteveLauC
e029ddf2ba fix(view extension): broken search bar UI when opening extensions via hotkey (#938)
* fix: open view extension via hotkey

* refactor: update

* refactor: update

* chore: check error

* chore: ci update

* release notes

---------

Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: rain9 <15911122312@163.com>
2025-10-22 10:08:00 +08:00
ayangweb
731cfc5bd7 feat: allow navigate back when cursor is at the beginning (#940)
* feat: allow navigate back when cursor is at the beginning

* docs: update changelog
2025-10-21 15:58:53 +08:00
ayangweb
cbd8dc52cd feat: open quick ai with modifier key + enter (#939)
* feat: open quick ai with modifier key + enter

* docs: update changelog
2025-10-21 15:56:01 +08:00
ayangweb
d1ad1af71a refactor: hide quick ai on multi-level pages (#937) 2025-10-21 15:06:53 +08:00
ayangweb
121f9c6118 refactor: hide the search bar and filter bar on the plugin details page (#936) 2025-10-21 14:58:49 +08:00
ayangweb
770f60f30c fix: fix page rapidly flickering issue (#935)
* fix: fix page rapidly flickering issue

* docs: update changelog

* refactor: update
2025-10-21 14:31:23 +08:00
SteveLauC
5c92b5acab refactor: procedure that convert_pages() into a func (#934)
Extract a procedure that calls convert_pages() to process HTML files
into a function. It is used in both install/store.rs and
install/local_extension.rs, doing this avoids code duplication.
2025-10-20 18:23:53 +08:00
SteveLauC
8e49455acf refactor: bump tauri_nspanel & show_coco/hide_coco now use NSPanel's function on macOS (#933)
This commit:

1. Bump dep tauri_nspenel to v2.1
2. On macOS, our main window is not a window but panel. Previously, we
   were using window.show() and window.hide() in show_coco() and
   hide_coco(). In this commit, we switch from window.show/hide to
   panel.show/hide

Co-authored-by: ayang <473033518@qq.com>
2025-10-20 15:53:48 +08:00
ayangweb
859def21bf feat: standardize multi-level menu label structure (#925)
* feat: standardize multi-level menu label structure

* refactor: update

* refactor: improve tab behavior

* refactor: update

* refactor: improve backspace behavior

* refactor: optimizes tab behavior

* refactor: optimizes backspace behavior

* refactor: disable calculator subpage navigation

* refactor: iframe auto focus

* ViewExtension UI settings

* refactor: update

* fix Rust code build

* refactor: update

* refactor: update

* refactor: update

* fix tests

* support http pages directly

* support http pages directly

* docs: update changelog

* field ui can only be set by View extensions

* changelog: View Extension page field now accepts HTTP(s) links

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-10-19 19:13:44 +08:00
SteveLauC
6145306ee8 chore: use a custom log directory (#930)
* chore: use a custom log directory

This commit changes our log dirctory from the one set by Tauri to
a custom one. It is mostly the same as Tauri’s one, except that
the "{bundleIdentifier}" will be "Coco AI" rather than the real
identifier.

We are doing this because our bundle ID ("rs.coco.app") ends with ".app", log
directory "/Users/xxx/Library/Logs/rs.coco.app" is mistakenly thought as an
application by Finder on macOS, making it inconvenient to open. We do not want
to change the bundle identifier. The data directory, which stores all the data, still
references it. So doing that will be a breaking change. Using a custom log
directory make more sense.

* fmt
2025-10-19 19:13:09 +08:00
SteveLauC
d0f7b7b833 chore: allow(deprecated) to silence warnings (#931) 2025-10-19 19:12:45 +08:00
SteveLauC
f221606ae2 ci: fix fontend-ci.yml syntax error (#932)
* ci: fix fontend-ci.yml syntax error

* more to fix

* remove file foo
2025-10-19 16:35:37 +08:00
SteveLauC
cd00ada3ac feat: return sub-exts when extension type exts themselves are matched (#928)
Take the 'Spotify Control' extension as an example:

- Spotify Control
  - Toggle Play/Pause
  - Next Track
  - Previous Track

Previously, these sub-extensions were only returned when the query string
matched them, and thus counterintuitively, searching for 'Spotify Control' would
not hit them.

This commit changes that behavior: when a main extension (of type Extension)
matches the query, all of its sub-extensions are now included in the results.
2025-10-19 09:58:01 +08:00
SteveLauC
be6611133a refactor(calculator): skip evaluation if expr is in form "num => num" (#929)
When the expression to evaluate contains only a number, the result is
guaranteed to be this number. In this case, we no longer evaluate the
expression as telling users that "x = x" is meaningless.
2025-10-17 15:56:06 +08:00
BiggerRain
9e682ceafc ci: add ci detection for web component packaging (#927)
* build: add web build ci

* chore: remove  test code

* Update .github/workflows/frontend-ci.yml

Co-authored-by: SteveLauC <stevelauc@outlook.com>

---------

Co-authored-by: SteveLauC <stevelauc@outlook.com>
2025-10-16 08:58:04 +08:00
SteveLauC
5510bedf7f refactor: retry if AXUIElementSetAttributeValue() does not work (#924)
Found another case where the `NextDisplay` command does not work (I said
another because the bug that commit ca71f07f3a3cc[1] fixed was also found
by playing with the `NextDisplay` command). After debugging, the root cause
of the issue is that the macOS API `AXUIElementSetAttributeValue()` does
not work in the expected way.

> When I execute the `NextDisplay` command to move the focused window from
> a big display (2560x1440) to a small display (1440*900), the window size
> could be set to 1460.

I don't know why it does not work so the only thing we can do is to retry.
Luckily, retrying works, at least in my tests.

[1]: ca71f07f3a
2025-10-15 10:22:58 +08:00
SteveLauC
ea34b7a404 fix(Window Management): Next/Previous Desktop do not work (#926)
This commit fixes the issue that when the current desktop contains more
than 1 windows, moving the focused window via `NextDesktop` and
`PreviousDesktop` won't work.

How? By adding 2 missing `sleep()` functions:

1. b91a18dbb8/Silica/Sources/SIWindow.m (L242)
2. b91a18dbb8/Silica/Sources/SIWindow.m (L249)

Also, this commit improves the implementation by resetting the mouse position.
`NextDesktop` and `PreviousDesktop` are implemented by emulating mouse and
keyboard events, draging the focused window and switching to the corresponding
desktop. To make a window draggable, we have to move the mouse to the window's
traffic light area. It is disturbing to not move the mouse back so this commit
implements it.
2025-10-14 17:39:14 +08:00
BiggerRain
ce94543baa build: remove tauri from web component build (#923) 2025-10-14 15:42:51 +08:00
BiggerRain
89a8304b9e build: build error (#922) 2025-10-13 16:43:29 +08:00
BiggerRain
9652a54f08 chore: add cross-domain configuration for web component (#921)
* chore: add cross-domain configuration for web component

* docs: add release note
2025-10-13 15:45:28 +08:00
SteveLauC
ca71f07f3a fix: WM ext does not work when operating focused win from another display (#919)
This commit fixes a bug that most Window Management extension commands
won't work if you:

1. operate the focused window from another display
2. and they are adjacent

To reproduce this:

say you have 2 displays

1. Put the focused window on a non-main display, maximize the window
2. Move the mourse to the main display, making it the active display
3. Launch Coco, then execute the `TopHalf` command

The focused window will be moved to the main display, while it should
stay in the non-main display.

The root cause of the issue is that the previous implementation of
`intersects()` didn't handle an edge case correctly, adjavent rectangles
should not be considered overlapping. This commit replaces the buggy
implementation with the `CGRectIntersectsRect()` function from macOS
core graphics library.
2025-10-13 11:13:42 +08:00
BiggerRain
00eb6bed2b feat: support pageup/pagedown to navigate search results (#920)
* feat: support pageup/pagedown to navigate search results

* docs: add release note
2025-10-11 17:08:53 +08:00
BiggerRain
95dc7a88d2 feat: support moving cursor with home and end keys (#918)
* feat: support moving cursor with home and end keys

* docs: add release notes
2025-10-11 15:38:15 +08:00
ayangweb
6aec9cbae2 fix: resolve pinned window shortcut not working (#917)
* fix: fix pinned window shortcut not working

* docs: update changelog

* refactor: update

* Update _index.md

---------

Co-authored-by: Medcl <m@medcl.net>
2025-10-11 14:46:24 +08:00
BiggerRain
4e58bc4b2c fix: duplicate chat content (#916)
* style: add dark drop shadow to images

* docs: add release note

* style: add dark

* fix: duplicate chat content

* docs: add release note

* chore: update history list
2025-10-11 10:33:09 +08:00
ayangweb
a9a4b5319c feat: support opening logs from about page (#915)
* feat: support opening logs from about page

* docs: update changelog

* refactor: update

* refactor: update i18n
2025-10-10 15:16:46 +08:00
SteveLauC
6523fef12b chore: correct link to Coco server docs (#914) 2025-10-10 14:48:05 +08:00
ayangweb
b8affcd4a1 refactor: improve sorting logic of search results (#910)
* refactor: improve sorting logic of search results

* refactor: update

* wip

* feat: support switching groups via keyboard shortcuts (#911)

* feat: support switching groups via keyboard shortcuts

* refactor: update

* docs: update changelog

* refactor post-querying logic

* refactor post-querying logic

* refactor post-querying logic

* refactor post-querying logic

* refactor: refactoring rerank function

* refactor: refactoring rerank with intelligent hybrid scorer

* chore: remove debug logging

* chore: fix format

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
Co-authored-by: medcl <m@medcl.net>
2025-10-10 14:32:44 +08:00
BiggerRain
595ae676b7 style: add dark drop shadow to images (#912)
* style: add dark drop shadow to images

* docs: add release note

* style: add dark

* style: remove dark
2025-10-10 14:28:23 +08:00
BiggerRain
5c76c92c95 fix: automatic update of service list (#913)
* fix: automatic update of service list

* docs: add release note
2025-10-10 14:26:59 +08:00
ayangweb
f03ad8a6c8 feat: support switching groups via keyboard shortcuts (#911)
* feat: support switching groups via keyboard shortcuts

* refactor: update

* docs: update changelog
2025-10-09 16:50:13 +08:00
81 changed files with 2202 additions and 1159 deletions

View File

@@ -5,6 +5,8 @@ on:
# Only run it when Frontend code changes # Only run it when Frontend code changes
paths: paths:
- 'src/**' - 'src/**'
- 'tsup.config.ts'
- 'package.json'
jobs: jobs:
check: check:
@@ -17,6 +19,9 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -30,5 +35,36 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Switch platformAdapter to Web adapter
shell: bash
run: >
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
let s=fs.readFileSync(f,'utf8');
s=s.replace(/import\\s*\\{\\s*createTauriAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/tauriAdapter\\\";/,'import { createWebAdapter } from \\\"./webAdapter\\\";');
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createTauriAdapter\\(\\);/,'let platformAdapter = createWebAdapter();');
fs.writeFileSync(f,s);"
- name: Build web (Tauri dependency check)
run: pnpm build:web
- name: Verify no Tauri refs in web output
shell: bash
run: |
if grep -R -n -E '@tauri-apps|tauri-plugin' out/search-chat; then
echo 'Tauri references found in web build output';
exit 1;
else
echo 'No Tauri references found';
fi
- name: Restore platformAdapter to Tauri adapter
shell: bash
run: >
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
let s=fs.readFileSync(f,'utf8');
s=s.replace(/import\\s*\\{\\s*createWebAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/webAdapter\\\";/,'import { createTauriAdapter } from \\\"./tauriAdapter\\\";');
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createWebAdapter\\(\\);/,'let platformAdapter = createTauriAdapter();');
fs.writeFileSync(f,s);"
- name: Build frontend - name: Build frontend
run: pnpm build run: pnpm build

View File

@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
{{% load-img "/img/coco-preview.gif" "" %}} {{% load-img "/img/coco-preview.gif" "" %}}
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/). For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-server/](https://docs.infinilabs.com/coco-server/).
## Community ## Community

View File

@@ -8,11 +8,43 @@ title: "Release Notes"
Information about release notes of Coco App is provided here. Information about release notes of Coco App is provided here.
## Latest (In development) ## Latest (In development)
### ❌ Breaking changes ### ❌ Breaking changes
### 🚀 Features ### 🚀 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 ### 🐛 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
### ✈️ Improvements ### ✈️ 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.8.0 (2025-09-28) ## 0.8.0 (2025-09-28)

65
src-tauri/Cargo.lock generated
View File

@@ -976,7 +976,7 @@ dependencies = [
"block", "block",
"cocoa-foundation", "cocoa-foundation",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics 0.24.0", "core-graphics",
"foreign-types 0.5.0", "foreign-types 0.5.0",
"libc", "libc",
"objc", "objc",
@@ -1126,19 +1126,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "core-graphics"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
]
[[package]] [[package]]
name = "core-graphics-types" name = "core-graphics-types"
version = "0.2.0" version = "0.2.0"
@@ -1561,7 +1548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72" checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
dependencies = [ dependencies = [
"cocoa", "cocoa",
"core-graphics 0.24.0", "core-graphics",
"dunce", "dunce",
"gdk", "gdk",
"gdkx11", "gdkx11",
@@ -1656,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6" checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
dependencies = [ dependencies = [
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics 0.24.0", "core-graphics",
"foreign-types-shared 0.3.1", "foreign-types-shared 0.3.1",
"libc", "libc",
"log", "log",
@@ -4126,17 +4113,6 @@ dependencies = [
"malloc_buf", "malloc_buf",
] ]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]] [[package]]
name = "objc-sys" name = "objc-sys"
version = "0.3.5" version = "0.3.5"
@@ -4479,15 +4455,6 @@ dependencies = [
"objc2-security", "objc2-security",
] ]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"
@@ -4709,6 +4676,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "path-clean" name = "path-clean"
version = "1.0.1" version = "1.0.1"
@@ -6279,7 +6252,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"core-graphics 0.24.0", "core-graphics",
"foreign-types 0.5.0", "foreign-types 0.5.0",
"js-sys", "js-sys",
"log", "log",
@@ -6527,7 +6500,7 @@ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"block2 0.6.1", "block2 0.6.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics 0.24.0", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dispatch", "dispatch",
"dlopen2", "dlopen2",
@@ -6727,17 +6700,13 @@ dependencies = [
[[package]] [[package]]
name = "tauri-nspanel" name = "tauri-nspanel"
version = "2.0.1" version = "2.1.0"
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a" source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2.1#da9c9a8d4eb7f0524a2508988df1a7d9585b4904"
dependencies = [ dependencies = [
"bitflags 2.9.4", "objc2 0.6.2",
"block", "objc2-app-kit 0.3.1",
"cocoa", "objc2-foundation 0.3.1",
"core-foundation 0.10.1", "pastey",
"core-graphics 0.25.0",
"objc",
"objc-foundation",
"objc_id",
"tauri", "tauri",
] ]

View File

@@ -122,7 +122,7 @@ path-clean = "1.0.1"
tempfile = "3.23.0" tempfile = "3.23.0"
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] } objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
objc2 = "0.6.2" objc2 = "0.6.2"
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] } objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }

View File

@@ -1,7 +1,8 @@
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::extension::built_in::window_management::actions::Action; use crate::extension::built_in::window_management::actions::Action;
use crate::extension::{ExtensionPermission, ExtensionSettings}; use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
@@ -89,6 +90,7 @@ pub(crate) enum ExtensionOnOpenedType {
/// ///
/// It should be an absolute path or Tauri cannot open it. /// It should be an absolute path or Tauri cannot open it.
page: String, page: String,
ui: Option<ViewExtensionUISettings>,
}, },
} }
@@ -118,7 +120,7 @@ impl OnOpened {
// The URL of a quicklink is nearly useless without such dynamic user // 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". // inputs, so until we have dynamic URL support, we just use "N/A".
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"), ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
ExtensionOnOpenedType::View { page: _ } => { ExtensionOnOpenedType::View { page: _, ui: _ } => {
// We currently don't have URL for this kind of extension. // We currently don't have URL for this kind of extension.
String::from("N/A") String::from("N/A")
} }
@@ -132,7 +134,7 @@ impl OnOpened {
pub(crate) async fn open( pub(crate) async fn open(
tauri_app_handle: AppHandle, tauri_app_handle: AppHandle,
on_opened: OnOpened, on_opened: OnOpened,
extra_args: Option<HashMap<String, String>>, extra_args: Option<HashMap<String, Json>>,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::util::open as homemade_tauri_shell_open; use crate::util::open as homemade_tauri_shell_open;
use std::process::Command; use std::process::Command;
@@ -231,7 +233,7 @@ pub(crate) async fn open(
} }
} }
} }
ExtensionOnOpenedType::View { page } => { ExtensionOnOpenedType::View { page, ui } => {
/* /*
* Emit an event to let the frontend code open this extension. * Emit an event to let the frontend code open this extension.
* *
@@ -243,8 +245,18 @@ pub(crate) async fn open(
use serde_json::Value as Json; use serde_json::Value as Json;
use serde_json::to_value; use serde_json::to_value;
let page_and_permission: [Json; 2] = let mut extra_args =
[Json::String(page), to_value(permission).unwrap()]; 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 page_and_permission: [Json; 4] = [
Json::String(page),
to_value(permission).unwrap(),
to_value(ui).unwrap(),
document,
];
tauri_app_handle tauri_app_handle
.emit("open_view_extension", page_and_permission) .emit("open_view_extension", page_and_permission)
.unwrap(); .unwrap();

View File

@@ -1242,6 +1242,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
enabled, enabled,
settings: None, settings: None,
page: None, page: None,
ui: None,
permission: None, permission: None,
screenshots: None, screenshots: None,
url: None, url: None,

View File

@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
// will only be evaluated against non-whitespace characters. // will only be evaluated against non-whitespace characters.
let query_string = query_string.trim(); let query_string = query_string.trim();
if query_string.is_empty() || query_string.len() == 1 { if query_string.is_empty() {
return Ok(QueryResponse { return Ok(QueryResponse {
source: self.get_type(), source: self.get_type(),
hits: Vec::new(), hits: Vec::new(),
@@ -150,6 +150,26 @@ impl SearchSource for CalculatorSource {
let query_source = self.get_type(); let query_source = self.get_type();
let base_score = self.base_score; let base_score = self.base_score;
let closure = move || -> QueryResponse { let closure = move || -> QueryResponse {
let Ok(tokens) = meval::tokenizer::tokenize(&query_string_clone) else {
// Invalid expression, return nothing.
return QueryResponse {
source: query_source,
hits: Vec::new(),
total_hits: 0,
};
};
// If it is only a number, no need to evaluate it as the result is
// this number.
// Actually, there is no need to return the result back to the users
// in such case because letting them know "x = x" is meaningless.
if tokens.len() == 1 && matches!(tokens[0], meval::tokenizer::Token::Number(_)) {
return QueryResponse {
source: query_source,
hits: Vec::new(),
total_hits: 0,
};
}
let res_num = meval::eval_str(&query_string_clone); let res_num = meval::eval_str(&query_string_clone);
match res_num { match res_num {

View File

@@ -8,6 +8,7 @@ use std::ffi::c_ushort;
use std::ffi::c_void; use std::ffi::c_void;
use std::ops::Deref; use std::ops::Deref;
use std::ptr::NonNull; use std::ptr::NonNull;
use std::time::Duration;
use objc2::MainThreadMarker; use objc2::MainThreadMarker;
use objc2_app_kit::NSEvent; use objc2_app_kit::NSEvent;
@@ -34,6 +35,7 @@ use objc2_core_graphics::CGEventType;
use objc2_core_graphics::CGMouseButton; use objc2_core_graphics::CGMouseButton;
use objc2_core_graphics::CGRectGetMidX; use objc2_core_graphics::CGRectGetMidX;
use objc2_core_graphics::CGRectGetMinY; use objc2_core_graphics::CGRectGetMinY;
use objc2_core_graphics::CGRectIntersectsRect;
use objc2_core_graphics::CGWindowID; use objc2_core_graphics::CGWindowID;
use super::error::Error; use super::error::Error;
@@ -46,12 +48,7 @@ use std::collections::HashMap;
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex};
fn intersects(r1: CGRect, r2: CGRect) -> bool { fn intersects(r1: CGRect, r2: CGRect) -> bool {
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x unsafe { CGRectIntersectsRect(r1, r2) }
|| r1.origin.y + r1.size.height < r2.origin.y
|| r1.origin.x > r2.origin.x + r2.size.width
|| r1.origin.y > r2.origin.y + r2.size.height);
overlapping
} }
/// Core graphics APIs use flipped coordinate system, while AppKit uses the /// Core graphics APIs use flipped coordinate system, while AppKit uses the
@@ -86,6 +83,23 @@ fn get_ui_element_origin(ui_element: &CFRetained<AXUIElement>) -> Result<CGPoint
Ok(position_cg_point) Ok(position_cg_point)
} }
/// Send a set origin request to the `ui_element`, return once request is sent.
fn set_ui_element_origin_oneshot(
ui_element: &CFRetained<AXUIElement>,
mut origin: CGPoint,
) -> Result<(), Error> {
let ptr_to_origin = NonNull::new((&mut origin as *mut CGPoint).cast::<c_void>()).unwrap();
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_origin) }.unwrap();
let pos_attr = CFString::from_static_str("AXPosition");
let error = unsafe { ui_element.set_attribute_value(&pos_attr, pos_value.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(())
}
/// Helper function to extract an UI element's size. /// Helper function to extract an UI element's size.
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> { fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
let mut size_value: *const CFType = std::ptr::null(); let mut size_value: *const CFType = std::ptr::null();
@@ -110,6 +124,23 @@ fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, E
Ok(size_cg_size) Ok(size_cg_size)
} }
/// Send a set size request to the `ui_element`, return once request is sent.
fn set_ui_element_size_oneshot(
ui_element: &CFRetained<AXUIElement>,
mut size: CGSize,
) -> Result<(), Error> {
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
let size_attr = CFString::from_static_str("AXSize");
let error = unsafe { ui_element.set_attribute_value(&size_attr, size_value.deref()) };
if error != AXError::Success {
return Err(Error::AXError(error));
}
Ok(())
}
/// Get the frontmost/focused window (as an UI element). /// Get the frontmost/focused window (as an UI element).
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> { fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
let workspace = unsafe { NSWorkspace::sharedWorkspace() }; let workspace = unsafe { NSWorkspace::sharedWorkspace() };
@@ -307,6 +338,10 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
let window_frame = get_frontmost_window_frame()?; let window_frame = get_frontmost_window_frame()?;
let close_button_frame = get_frontmost_window_close_button_frame()?; let close_button_frame = get_frontmost_window_close_button_frame()?;
let prev_mouse_position = unsafe {
let event = CGEvent::new(None);
CGEvent::location(event.as_deref())
};
let mouse_cursor_point = CGPoint::new( let mouse_cursor_point = CGPoint::new(
unsafe { CGRectGetMidX(close_button_frame) }, unsafe { CGRectGetMidX(close_button_frame) },
@@ -360,6 +395,9 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref()); CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
} }
// Make a slight delay to make sure the window is grabbed
std::thread::sleep(Duration::from_millis(50));
// cast is safe as space is in range [1, 16] // cast is safe as space is in range [1, 16]
let hot_key: c_ushort = 118 + space as c_ushort - 1; let hot_key: c_ushort = 118 + space as c_ushort - 1;
@@ -402,9 +440,30 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
); );
} }
// Make a slight delay to finish the space transition animation
std::thread::sleep(Duration::from_millis(50));
/*
* Cleanup
*/
unsafe { unsafe {
// Let go of the window. // Let go of the window.
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref()); CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
// Reset mouse position
let mouse_reset_event = {
CGEvent::new_mouse_event(
None,
CGEventType::MouseMoved,
prev_mouse_position,
CGMouseButton::Left,
)
};
CGEvent::set_flags(mouse_reset_event.as_deref(), CGEventFlags(0));
CGEvent::post(
CGEventTapLocation::HIDEventTap,
mouse_reset_event.as_deref(),
);
} }
Ok(()) Ok(())
@@ -461,6 +520,9 @@ fn get_frontmost_window_close_button_frame() -> Result<CGRect, Error> {
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS /// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
/// puts a menu bar on every display. /// puts a menu bar on every display.
/// ///
/// Update: This could be wrong, but looks like Apple fixed these 2 bugs in macOS
/// 26. At least the buggy behaviors disappear in my test.
///
/// ///
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it /// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
/// is currently safe to draw your apps content. /// is currently safe to draw your apps content.
@@ -558,27 +620,61 @@ pub fn move_frontmost_window(x: f64, y: f64) -> Result<(), Error> {
/// Set the frontmost window's frame to the specified frame - adjust size and /// Set the frontmost window's frame to the specified frame - adjust size and
/// location at the same time. /// location at the same time.
///
/// This function **retries** up to `RETRY` times until the set operations
/// successfully get performed.
///
/// # Retry
///
/// Retry is added because I encountered a case where `AXUIElementSetAttributeValue()`
/// does not work in the expected way. When I execute the `NextDisplay` command
/// to move the focused window from a big display (2560x1440) to a small display
/// (1440*900), the window size could be set to 1460 sometimes. No idea if this
/// is a bug of the Accessibility APIs or due to the improper API uses. So we
/// retry for `RETRY` times at most to try our beest make it behave correctly.
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> { pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
const RETRY: usize = 5;
/// Sleep for 50ms as I don't want to send too many requests to the focused
/// app and WindowServer because doing that could make them busy and then
/// they won't process my set requests.
///
/// The above is simply my observation, I don't know how the messaging really
/// works under the hood.
const SLEEP: Duration = Duration::from_millis(50);
let frontmost_window = get_frontmost_window()?; let frontmost_window = get_frontmost_window()?;
let mut point = frame.origin; /*
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap(); * Set window origin
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap(); */
let pos_attr = CFString::from_static_str("AXPosition"); set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
for _ in 0..RETRY {
std::thread::sleep(SLEEP);
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) }; let current = get_ui_element_origin(&frontmost_window)?;
if error != AXError::Success { if current == frame.origin {
return Err(Error::AXError(error)); break;
} else {
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
}
} }
let mut size = frame.size; /*
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap(); * Set window size
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap(); */
let size_attr = CFString::from_static_str("AXSize"); set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
for _ in 0..RETRY {
std::thread::sleep(SLEEP);
let error = unsafe { frontmost_window.set_attribute_value(&size_attr, size_value.deref()) }; let current = get_ui_element_size(&frontmost_window)?;
if error != AXError::Success { // For size, we do not check if `current` has the exact same value as
return Err(Error::AXError(error)); // `frame.size` as I have encountered a case where I ask macOS to set
// the height to 1550, but the height gets set to 1551.
if cgsize_roughly_equal(current, frame.size, 3.0) {
break;
} else {
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
}
} }
Ok(()) Ok(())
@@ -624,6 +720,15 @@ pub fn toggle_fullscreen() -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Check if `lhs` roughly equals to `rhs`. The Roughness can be controlled by
/// argument `tolerance`.
fn cgsize_roughly_equal(lhs: CGSize, rhs: CGSize, tolerance: f64) -> bool {
let width_diff = (lhs.width - rhs.width).abs();
let height_diff = (lhs.height - rhs.height).abs();
width_diff <= tolerance && height_diff <= tolerance
}
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> = static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
LazyLock::new(|| Mutex::new(HashMap::new())); LazyLock::new(|| Mutex::new(HashMap::new()));
@@ -636,3 +741,56 @@ pub(crate) fn get_frontmost_window_last_frame(window_id: CGWindowID) -> Option<C
let map = LAST_FRAME.lock().unwrap(); let map = LAST_FRAME.lock().unwrap();
map.get(&window_id).cloned() map.get(&window_id).cloned()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_intersects_adjacent_rects_x() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(100.0, 0.0), CGSize::new(100.0, 100.0));
assert!(
!intersects(r1, r2),
"Adjacent rects on X should not intersect"
);
}
#[test]
fn test_intersects_adjacent_rects_y() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(0.0, 100.0), CGSize::new(100.0, 100.0));
assert!(
!intersects(r1, r2),
"Adjacent rects on Y should not intersect"
);
}
#[test]
fn test_intersects_overlapping_rects() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(50.0, 50.0), CGSize::new(100.0, 100.0));
assert!(intersects(r1, r2), "Overlapping rects should intersect");
}
#[test]
fn test_intersects_separate_rects() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(101.0, 101.0), CGSize::new(100.0, 100.0));
assert!(!intersects(r1, r2), "Separate rects should not intersect");
}
#[test]
fn test_intersects_contained_rect() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(10.0, 10.0), CGSize::new(50.0, 50.0));
assert!(intersects(r1, r2), "Contained rect should intersect");
}
#[test]
fn test_intersects_identical_rects() {
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
let r2 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
assert!(intersects(r1, r2), "Identical rects should intersect");
}
}

View File

@@ -28,6 +28,7 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState; use tauri_plugin_global_shortcut::ShortcutState;
pub(crate) const EXTENSION_ID: &str = "Window Management"; pub(crate) const EXTENSION_ID: &str = "Window Management";
pub(crate) const EXTENSION_NAME_LOWERCASE: &str = "window management";
/// JSON file for this extension. /// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json"); pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");

View File

@@ -1,4 +1,5 @@
use super::EXTENSION_ID; use super::EXTENSION_ID;
use super::EXTENSION_NAME_LOWERCASE;
use crate::common::document::{DataSourceReference, Document}; use crate::common::document::{DataSourceReference, Document};
use crate::common::{ use crate::common::{
error::SearchError, error::SearchError,
@@ -81,6 +82,16 @@ impl SearchSource for WindowManagementSearchSource {
} }
} }
// An "extension" type extension should return all its
// sub-extensions when the query string matches its name.
// To do this, we score the extension name and take that
// into account.
if let Some(main_extension_score) =
calculate_text_similarity(&query_string_lowercase, &EXTENSION_NAME_LOWERCASE)
{
score += main_extension_score;
}
score score
}; };

View File

@@ -112,6 +112,8 @@ pub struct Extension {
/// and render. Otherwise, `None`. /// and render. Otherwise, `None`.
page: Option<String>, page: Option<String>,
ui: Option<ViewExtensionUISettings>,
/// Permission that this extension requires. /// Permission that this extension requires.
permission: Option<ExtensionPermission>, permission: Option<ExtensionPermission>,
@@ -126,6 +128,17 @@ pub struct Extension {
version: Option<Json>, version: Option<Json>,
} }
/// Settings that control the built-in UI Components
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct ViewExtensionUISettings {
/// Show the search bar
search_bar: bool,
/// Show the filter bar
filter_bar: bool,
/// Show the footer
footer: bool,
}
/// Bundle ID uniquely identifies an extension. /// Bundle ID uniquely identifies an extension.
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
pub(crate) struct ExtensionBundleId { pub(crate) struct ExtensionBundleId {
@@ -265,8 +278,9 @@ impl Extension {
let page = self.page.as_ref().unwrap_or_else(|| { 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); panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
}).clone(); }).clone();
let ui = self.ui.clone();
let extension_on_opened_type = ExtensionOnOpenedType::View { page }; let extension_on_opened_type = ExtensionOnOpenedType::View { page, ui };
let extension_on_opened = ExtensionOnOpened { let extension_on_opened = ExtensionOnOpened {
ty: extension_on_opened_type, ty: extension_on_opened_type,
settings, settings,
@@ -416,7 +430,7 @@ impl QuicklinkLink {
/// if any. /// if any.
pub(crate) fn concatenate_url( pub(crate) fn concatenate_url(
&self, &self,
user_supplied_args: &Option<HashMap<String, String>>, user_supplied_args: &Option<HashMap<String, Json>>,
) -> String { ) -> String {
let mut out = String::new(); let mut out = String::new();
for component in self.components.iter() { for component in self.components.iter() {
@@ -428,20 +442,23 @@ impl QuicklinkLink {
argument_name, argument_name,
default, default,
} => { } => {
let opt_argument_value = { let opt_argument_value: Option<&str> = {
let user_supplied_arg = user_supplied_args let user_supplied_arg = user_supplied_args
.as_ref() .as_ref()
.and_then(|map| map.get(argument_name.as_str())); .and_then(|map| map.get(argument_name.as_str()));
if user_supplied_arg.is_some() { if user_supplied_arg.is_some() {
user_supplied_arg user_supplied_arg.map(|json| {
json.as_str()
.expect("quicklink should provide string arguments")
})
} else { } else {
default.as_ref() default.as_deref()
} }
}; };
let argument_value_str = match opt_argument_value { let argument_value_str = match opt_argument_value {
Some(str) => str.as_str(), Some(str) => str,
// None => an empty string // None => an empty string
None => "", None => "",
}; };
@@ -963,6 +980,14 @@ pub(crate) fn canonicalize_relative_page_path(
.page .page
.as_ref() .as_ref()
.expect("this should be invoked on a View extension"); .expect("this should be invoked on a View extension");
// Skip HTTP links
if let Ok(url) = url::Url::parse(page)
&& ["http", "https"].contains(&url.scheme())
{
return Ok(());
}
let page_path = Path::new(page); let page_path = Path::new(page);
if page_path.is_relative() { if page_path.is_relative() {
@@ -1741,7 +1766,7 @@ mod tests {
], ],
}; };
let mut user_args = HashMap::new(); let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), "value".to_string()); user_args.insert("other_param".to_string(), Json::String("value".to_string()));
let result = link.concatenate_url(&Some(user_args)); let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q="); assert_eq!(result, "https://www.google.com/search?q=");
} }
@@ -1778,7 +1803,7 @@ mod tests {
], ],
}; };
let mut user_args = HashMap::new(); let mut user_args = HashMap::new();
user_args.insert("other_param".to_string(), "value".to_string()); user_args.insert("other_param".to_string(), Json::String("value".to_string()));
let result = link.concatenate_url(&Some(user_args)); let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=rust"); assert_eq!(result, "https://www.google.com/search?q=rust");
} }
@@ -1801,7 +1826,7 @@ mod tests {
], ],
}; };
let mut user_args = HashMap::new(); let mut user_args = HashMap::new();
user_args.insert("query".to_string(), "python".to_string()); user_args.insert("query".to_string(), Json::String("python".to_string()));
let result = link.concatenate_url(&Some(user_args)); let result = link.concatenate_url(&Some(user_args));
assert_eq!(result, "https://www.google.com/search?q=python"); assert_eq!(result, "https://www.google.com/search?q=python");
} }

View File

@@ -231,6 +231,14 @@ fn check_main_extension_or_sub_extension(
)); ));
} }
// If field `ui` is Some, then it should be a View
if extension.ui.is_some() && extension.r#type != ExtensionType::View {
return Err(format!(
"invalid {}, field [ui] is set for a non-View extension",
identifier
));
}
Ok(()) Ok(())
} }
@@ -267,6 +275,7 @@ mod tests {
hotkey: None, hotkey: None,
enabled: true, enabled: true,
page, page,
ui: None,
permission: None, permission: None,
settings: None, settings: None,
screenshots: None, screenshots: None,

View File

@@ -1,6 +1,7 @@
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::third_party::check::general_check; use crate::extension::third_party::check::general_check;
use crate::extension::third_party::install::{ use crate::extension::third_party::install::{
convert_page, filter_out_incompatible_sub_extensions, is_extension_installed, filter_out_incompatible_sub_extensions, is_extension_installed, view_extension_convert_pages,
}; };
use crate::extension::third_party::{ use crate::extension::third_party::{
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory, THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
@@ -8,7 +9,6 @@ use crate::extension::third_party::{
use crate::extension::{ use crate::extension::{
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path, Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
}; };
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
use crate::util::platform::Platform; use crate::util::platform::Platform;
use serde_json::Value as Json; use serde_json::Value as Json;
use std::path::Path; use std::path::Path;
@@ -223,50 +223,11 @@ pub(crate) async fn install_local_extension(
/* /*
* Call convert_page() to update the page files. This has to be done after * Call convert_page() to update the page files. This has to be done after
* writing the extension files * writing the extension files because we will edit them.
*
* HTTP links will be skipped.
*/ */
let absolute_page_paths: Vec<PathBuf> = { view_extension_convert_pages(&extension, &dest_dir).await?;
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()
}
}
if extension.r#type == ExtensionType::View {
let page = extension
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
vec![path]
} else if extension.r#type.contains_sub_items()
&& let Some(ref views) = extension.views
{
let mut paths = Vec::with_capacity(views.len());
for view in views.iter() {
let page = view
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
paths.push(path);
}
paths
} else {
// No pages in this extension
Vec::new()
}
};
for page_path in absolute_page_paths {
convert_page(&page_path).await?;
}
// Canonicalize relative icon and page paths // Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&dest_dir, &mut extension)?; canonicalize_relative_icon_path(&dest_dir, &mut extension)?;

View File

@@ -42,8 +42,10 @@ pub(crate) mod local_extension;
pub(crate) mod store; pub(crate) mod store;
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use std::path::Path; use std::path::Path;
use std::path::PathBuf;
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
@@ -228,6 +230,63 @@ fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String
Ok(modified_html) 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()
}
};
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?;
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -259,6 +318,7 @@ mod tests {
enabled: true, enabled: true,
settings: None, settings: None,
page: None, page: None,
ui: None,
permission: None, permission: None,
screenshots: None, screenshots: None,
url: None, url: None,

View File

@@ -10,15 +10,14 @@ use crate::common::search::QuerySource;
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FILE_NAME; use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path; use crate::extension::canonicalize_relative_icon_path;
use crate::extension::canonicalize_relative_page_path; use crate::extension::canonicalize_relative_page_path;
use crate::extension::third_party::check::general_check; use crate::extension::third_party::check::general_check;
use crate::extension::third_party::get_third_party_extension_directory; use crate::extension::third_party::get_third_party_extension_directory;
use crate::extension::third_party::install::convert_page;
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions; 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::server::http_client::HttpClient;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use async_trait::async_trait; use async_trait::async_trait;
@@ -26,8 +25,6 @@ use reqwest::StatusCode;
use serde_json::Map as JsonObject; use serde_json::Map as JsonObject;
use serde_json::Value as Json; use serde_json::Value as Json;
use std::io::Read; use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use tauri::AppHandle; use tauri::AppHandle;
const DATA_SOURCE_ID: &str = "Extension Store"; const DATA_SOURCE_ID: &str = "Extension Store";
@@ -401,50 +398,11 @@ pub(crate) async fn install_extension_from_store(
/* /*
* Call convert_page() to update the page files. This has to be done after * Call convert_page() to update the page files. This has to be done after
* writing the extension files * writing the extension files because we will edit them.
*
* HTTP links will be skipped.
*/ */
let absolute_page_paths: Vec<PathBuf> = { view_extension_convert_pages(&extension, &extension_directory).await?;
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()
}
}
if extension.r#type == ExtensionType::View {
let page = extension
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
vec![path]
} else if extension.r#type.contains_sub_items()
&& let Some(ref views) = extension.views
{
let mut paths = Vec::with_capacity(views.len());
for view in views.iter() {
let page = view
.page
.as_ref()
.expect("View extension should set its page field");
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
paths.push(path);
}
paths
} else {
// No pages in this extension
Vec::new()
}
};
for page_path in absolute_page_paths {
convert_page(&page_path).await?;
}
// Canonicalize relative icon and page paths // Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&extension_directory, &mut extension)?; canonicalize_relative_icon_path(&extension_directory, &mut extension)?;

View File

@@ -15,6 +15,7 @@ use crate::common::search::QuerySource;
use crate::common::search::SearchQuery; use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::extension::ExtensionBundleIdBorrowed; use crate::extension::ExtensionBundleIdBorrowed;
use crate::extension::ExtensionType;
use crate::extension::calculate_text_similarity; use crate::extension::calculate_text_similarity;
use crate::extension::canonicalize_relative_page_path; use crate::extension::canonicalize_relative_page_path;
use crate::util::platform::Platform; use crate::util::platform::Platform;
@@ -22,6 +23,7 @@ use async_trait::async_trait;
use borrowme::ToOwned; use borrowme::ToOwned;
use check::general_check; use check::general_check;
use function_name::named; use function_name::named;
use std::collections::HashMap;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
@@ -523,6 +525,24 @@ impl ThirdPartyExtensionsSearchSource {
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( 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, "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(); let bundle_id_owned = bundle_id.to_owned();
tauri_app_handle tauri_app_handle
@@ -532,9 +552,16 @@ impl ThirdPartyExtensionsSearchSource {
let bundle_id_clone = bundle_id_owned.clone(); let bundle_id_clone = bundle_id_owned.clone();
let app_handle_clone = tauri_app_handle.clone(); let app_handle_clone = tauri_app_handle.clone();
let document_clone = document.clone();
if event.state() == ShortcutState::Pressed { if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move { async_runtime::spawn(async move {
let result = open(app_handle_clone, on_opened_clone, None).await; 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;
if let Err(msg) = result { if let Err(msg) = result {
log::warn!( log::warn!(
"failed to open extension [{:?}], error [{}]", "failed to open extension [{:?}], error [{}]",
@@ -757,11 +784,22 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) { for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
if extension.r#type.contains_sub_items() { if extension.r#type.contains_sub_items() {
let opt_main_extension_lowercase_name =
if extension.r#type == ExtensionType::Extension {
Some(extension.name.to_lowercase())
} else {
// None if it is of type `ExtensionType::Group`
None
};
if let Some(ref commands) = extension.commands { if let Some(ref commands) = extension.commands {
for command in commands.iter().filter(|cmd| cmd.enabled) { for command in commands.iter().filter(|cmd| cmd.enabled) {
if let Some(hit) = if let Some(hit) = extension_to_hit(
extension_to_hit(command, &query_lower, opt_data_source.as_deref()) command,
{ &query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit); hits.push(hit);
} }
} }
@@ -769,9 +807,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
if let Some(ref scripts) = extension.scripts { if let Some(ref scripts) = extension.scripts {
for script in scripts.iter().filter(|script| script.enabled) { for script in scripts.iter().filter(|script| script.enabled) {
if let Some(hit) = if let Some(hit) = extension_to_hit(
extension_to_hit(script, &query_lower, opt_data_source.as_deref()) script,
{ &query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit); hits.push(hit);
} }
} }
@@ -783,6 +824,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
quicklink, quicklink,
&query_lower, &query_lower,
opt_data_source.as_deref(), opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) { ) {
hits.push(hit); hits.push(hit);
} }
@@ -791,16 +833,19 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
if let Some(ref views) = extension.views { if let Some(ref views) = extension.views {
for view in views.iter().filter(|link| link.enabled) { for view in views.iter().filter(|link| link.enabled) {
if let Some(hit) = if let Some(hit) = extension_to_hit(
extension_to_hit(view, &query_lower, opt_data_source.as_deref()) view,
{ &query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit); hits.push(hit);
} }
} }
} }
} else { } else {
if let Some(hit) = if let Some(hit) =
extension_to_hit(extension, &query_lower, opt_data_source.as_deref()) extension_to_hit(extension, &query_lower, opt_data_source.as_deref(), None)
{ {
hits.push(hit); hits.push(hit);
} }
@@ -839,10 +884,18 @@ pub(crate) async fn uninstall_extension(
.await .await
} }
/// Argument `opt_main_extension_lowercase_name`: If `extension` is a sub-extension
/// of an `extension` type extension, then this argument contains the lowercase
/// name of that extension. Otherwise, None.
///
/// This argument is needed as an "extension" type extension should return all its
/// sub-extensions when the query string matches its name. To do this, we pass the
/// extension name, score it and take that into account.
pub(crate) fn extension_to_hit( pub(crate) fn extension_to_hit(
extension: &Extension, extension: &Extension,
query_lower: &str, query_lower: &str,
opt_data_source: Option<&str>, opt_data_source: Option<&str>,
opt_main_extension_lowercase_name: Option<&str>,
) -> Option<(Document, f64)> { ) -> Option<(Document, f64)> {
if !extension.searchable() { if !extension.searchable() {
return None; return None;
@@ -865,14 +918,26 @@ pub(crate) fn extension_to_hit(
if let Some(title_score) = if let Some(title_score) =
calculate_text_similarity(&query_lower, &extension.name.to_lowercase()) calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
{ {
total_score += title_score * 1.0; // Weight for title total_score += title_score;
} }
// Score based on alias match if available // Score based on alias match if available
// Alias is considered less important than title, so it gets a lower weight. // Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias { if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) { if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
total_score += alias_score * 0.7; // Weight for alias total_score += alias_score;
}
}
// An "extension" type extension should return all its
// sub-extensions when the query string matches its ID.
// To do this, we score the extension ID and take that
// into account.
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
if let Some(main_extension_score) =
calculate_text_similarity(&query_lower, main_extension_lowercase_id)
{
total_score += main_extension_score;
} }
} }

View File

@@ -7,17 +7,18 @@ mod server;
mod settings; mod settings;
mod setup; mod setup;
mod shortcut; mod shortcut;
mod util; // We need this in main.rs, so it has to be pub
pub mod util;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL}; use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
use crate::server::servers::{load_or_insert_default_server, load_servers_token}; use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use crate::util::logging::set_up_tauri_logger;
use crate::util::prevent_default; use crate::util::prevent_default;
use autostart::change_autostart; use autostart::change_autostart;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::plugin::TauriPlugin;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent}; use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
@@ -179,6 +180,7 @@ pub fn run() {
setup::backend_setup, setup::backend_setup,
util::app_lang::update_app_lang, util::app_lang::update_app_lang,
util::path::path_absolute, util::path::path_absolute,
util::logging::app_log_dir
]) ])
.setup(|app| { .setup(|app| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -265,9 +267,20 @@ async fn show_coco(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) { if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window); move_window_to_active_monitor(&window);
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use tauri_nspanel::ManagerExt;
let app_handle_clone = app_handle.clone();
app_handle.run_on_main_thread(move || {
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).unwrap();
panel.show_and_make_key();
}).unwrap();
} else {
let _ = window.show(); let _ = window.show();
let _ = window.unminimize(); let _ = window.unminimize();
// The Window Management (WM) extension (macOS-only) controls the // The Window Management (WM) extension (macOS-only) controls the
// frontmost window. Setting focus on macOS makes Coco the frontmost // frontmost window. Setting focus on macOS makes Coco the frontmost
// window, which means the WM extension would control Coco instead of other // window, which means the WM extension would control Coco instead of other
@@ -276,24 +289,35 @@ async fn show_coco(app_handle: AppHandle) {
// On Linux/Windows, however, setting focus is a necessity to ensure that // On Linux/Windows, however, setting focus is a necessity to ensure that
// users open Coco's window, then they can start typing, without needing // users open Coco's window, then they can start typing, without needing
// to click on the window. // to click on the window.
#[cfg(not(target_os = "macos"))]
let _ = window.set_focus(); let _ = window.set_focus();
}
};
let _ = app_handle.emit("show-coco", ()); let _ = app_handle.emit("show-coco", ());
} }
} }
#[tauri::command] #[tauri::command]
async fn hide_coco(app: AppHandle) { async fn hide_coco(app_handle: AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use tauri_nspanel::ManagerExt;
let app_handle_clone = app_handle.clone();
app_handle.run_on_main_thread(move || {
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).expect("cannot find the main window/panel");
panel.hide();
}).unwrap();
} else {
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).expect("cannot find the main window");
if let Err(err) = window.hide() { if let Err(err) = window.hide() {
log::error!("Failed to hide the window: {}", err); log::error!("Failed to hide the window: {}", err);
} else { } else {
log::debug!("Window successfully hidden."); log::debug!("Window successfully hidden.");
} }
} else {
log::error!("Main window not found.");
} }
};
} }
fn move_window_to_active_monitor(window: &WebviewWindow) { fn move_window_to_active_monitor(window: &WebviewWindow) {
@@ -430,135 +454,3 @@ async fn hide_check(app_handle: AppHandle) {
window.hide().unwrap(); window.hide().unwrap();
} }
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
use log::LevelFilter;
use tauri_plugin_log::Builder;
/// Coco-AI app's default log level.
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
fn format_log_level(level: Level) -> &'static str {
match level {
Level::Trace => "TRC",
Level::Debug => "DBG",
Level::Info => "INF",
Level::Warn => "WAR",
Level::Error => "ERR",
}
}
fn format_target_and_line(record: &log::Record) -> String {
let mut str = record.target().to_string();
if let Some(line) = record.line() {
str.push(':');
str.push_str(&line.to_string());
}
str
}
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
///
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
///
/// * If this environment variable is not set, use the default log level.
/// * If it is set, respect it:
///
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
/// equivalent to `COCO_LOG=coco_lib=trace`
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
/// * `COCO_LOG=off` turns off all logging for the application
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
fn dynamic_log_level(mut builder: Builder) -> Builder {
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
return builder.level(DEFAULT_LOG_LEVEL);
};
builder = builder.level(LevelFilter::Off);
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
panic!(
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
e.to_string_lossy(),
LOG_LEVEL_ENV_VAR
)
});
// COCO_LOG=[target][=][level][,...]
let target_log_levels = log_levels.split(',');
for target_log_level in target_log_levels {
#[allow(clippy::collapsible_else_if)]
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
// Remove the equal sign, we know it takes 1 byte
let level = &equal_sign_and_level[1..];
if let Ok(level) = level.parse::<LevelFilter>() {
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target.to_string(), level);
} else {
panic!(
"log level '{}' set in '{}={}' is invalid",
level, target, level
);
}
} else {
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
// This is a level
builder = builder.level(level);
} else {
// This is a target, enable all the logging
//
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
}
}
}
builder
}
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
// that come from Coco in the log file, which helps with debugging.
if !tauri::is_dev() {
// We have absolutely no guarantee that we (We have control over the Rust
// code, but definitely no idea about the libc C code, all the shared objects
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
unsafe {
std::env::set_var("COCO_LOG", "coco_lib=trace");
}
}
let mut builder = tauri_plugin_log::Builder::new();
builder = builder.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record);
out.finish(format_args!(
"[{}] [{}] [{}] {}",
now, level, target_and_line, message
));
});
builder = dynamic_log_level(builder);
builder.build()
}

View File

@@ -1,42 +1,9 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use coco_lib::util::logging::app_log_dir;
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::Write; use std::io::Write;
use std::path::PathBuf;
/// Helper function to return the log directory.
///
/// This should return the same value as `tauri_app_handle.path().app_log_dir().unwrap()`.
fn app_log_dir() -> PathBuf {
// This function `app_log_dir()` is for the panic hook, which should be set
// before Tauri performs any initialization. At that point, we do not have
// access to the identifier provided by Tauri, so we need to define our own
// one here.
//
// NOTE: If you update identifier in the following files, update this one
// as well!
//
// src-tauri/tauri.linux.conf.json
// src-tauri/Entitlements.plist
// src-tauri/tauri.conf.json
// src-tauri/Info.plist
const IDENTIFIER: &str = "rs.coco.app";
#[cfg(target_os = "macos")]
let path = dirs::home_dir()
.expect("cannot find the home directory, Coco should never run in such a environment")
.join("Library/Logs")
.join(IDENTIFIER);
#[cfg(not(target_os = "macos"))]
let path = dirs::data_local_dir()
.expect("app local dir is None, we should not encounter this")
.join(IDENTIFIER)
.join("logs");
path
}
/// Set up panic hook to log panic information to a file /// Set up panic hook to log panic information to a file
fn setup_panic_hook() { fn setup_panic_hook() {

View File

@@ -10,13 +10,10 @@ use function_name::named;
use futures::StreamExt; use futures::StreamExt;
use futures::stream::FuturesUnordered; use futures::stream::FuturesUnordered;
use reqwest::StatusCode; use reqwest::StatusCode;
use std::cmp::Reverse;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tokio::time::{Duration, timeout}; use tokio::time::{Duration, timeout};
#[named] #[named]
#[tauri::command] #[tauri::command]
pub async fn query_coco_fusion( pub async fn query_coco_fusion(
@@ -187,7 +184,6 @@ async fn query_coco_fusion_multi_query_sources(
let mut futures = FuturesUnordered::new(); let mut futures = FuturesUnordered::new();
let query_source_list_len = query_source_trait_object_list.len();
for query_source_trait_object in query_source_trait_object_list { for query_source_trait_object in query_source_trait_object_list {
let query_source = query_source_trait_object.get_type().clone(); let query_source = query_source_trait_object.get_type().clone();
let tauri_app_handle_clone = tauri_app_handle.clone(); let tauri_app_handle_clone = tauri_app_handle.clone();
@@ -208,14 +204,8 @@ async fn query_coco_fusion_multi_query_sources(
} }
let mut total_hits = 0; let mut total_hits = 0;
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
let mut failed_requests = Vec::new(); let mut failed_requests = Vec::new();
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new(); let mut all_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
if query_source_list_len > 1 {
need_rerank = true; // If we have more than one source, we need to rerank the hits
}
while let Some((query_source, timeout_result)) = futures.next().await { while let Some((query_source, timeout_result)) = futures.next().await {
match timeout_result { match timeout_result {
@@ -246,12 +236,10 @@ async fn query_coco_fusion_multi_query_sources(
document, document,
}; };
all_hits.push((source_id.clone(), query_hit.clone(), score)); all_hits_grouped_by_source_id
hits_per_source
.entry(source_id.clone()) .entry(source_id.clone())
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push((query_hit, score)); .push(query_hit);
} }
} }
Err(search_error) => { Err(search_error) => {
@@ -267,109 +255,117 @@ async fn query_coco_fusion_multi_query_sources(
} }
} }
// Sort hits within each source by score (descending) let n_sources = all_hits_grouped_by_source_id.len();
for hits in hits_per_source.values_mut() {
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater)); if n_sources == 0 {
return Ok(MultiSourceQueryResponse {
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
});
} }
let total_sources = hits_per_source.len(); /*
let max_hits_per_source = if total_sources > 0 { * Sort hits within each source by score (descending) in case data sources
size as usize / total_sources * do not sort them
*/
for hits in all_hits_grouped_by_source_id.values_mut() {
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Greater)
});
}
/*
* Collect hits evenly across sources, to ensure:
*
* 1. All sources have hits returned
* 2. Query sources with many hits won't dominate
*/
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() {
let hits_taken = if hits.len() > max_hits_per_source {
pruned.insert(&source_id, &hits[max_hits_per_source..]);
hits[0..max_hits_per_source].to_vec()
} else { } else {
size as usize hits.clone()
}; };
let mut final_hits = Vec::new(); final_hits_grouped_by_source_id.insert(source_id.clone(), hits_taken);
let mut seen_docs = HashSet::new(); // To track documents we've already added
// Distribute hits fairly across sources
for (_source_id, hits) in &mut hits_per_source {
let take_count = hits.len().min(max_hits_per_source);
for (doc, score) in hits.drain(0..take_count) {
if !seen_docs.contains(&doc.document.id) {
seen_docs.insert(doc.document.id.clone());
log::debug!(
"collect doc: {}, {:?}, {}",
doc.document.id,
doc.document.title,
score
);
final_hits.push(doc);
}
}
} }
log::debug!("final hits: {:?}", final_hits.len()); let final_hits_len = final_hits_grouped_by_source_id
let mut unique_sources = HashSet::new();
for hit in &final_hits {
if let Some(source) = &hit.source {
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
unique_sources.insert(&source.id);
}
}
}
log::debug!(
"Multiple sources found: {:?}, no rerank needed",
unique_sources
);
if unique_sources.len() < 1 {
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
}
if need_rerank && final_hits.len() > 1 {
// Precollect (index, title)
let titles_to_score: Vec<(usize, &str)> = final_hits
.iter() .iter()
.enumerate() .fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
.filter_map(|(idx, hit)| { let pruned_len = pruned
let source = hit.source.as_ref()?; .iter()
let title = hit.document.title.as_deref()?; .fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID { /*
Some((idx, title)) * If we still need more hits, take the highest-scoring from `pruned`
*
* `pruned` contains sorted arrays, we scan it in a way similar to
* how n-way-merge-sort extracts the element with the greatest value.
*/
if final_hits_len < size as usize {
let n_need = size as usize - final_hits_len;
let n_have = pruned_len;
let n_take = n_have.min(n_need);
for _ in 0..n_take {
let mut highest_score_hit: Option<(&str, &QueryHits)> = None;
for (source_id, sorted_hits) in pruned.iter_mut() {
if sorted_hits.is_empty() {
continue;
}
let hit = &sorted_hits[0];
let have_higher_score_hit = match highest_score_hit {
Some((_, current_highest_score_hit)) => {
hit.score > current_highest_score_hit.score
}
None => true,
};
if have_higher_score_hit {
highest_score_hit = Some((*source_id, hit));
// Advance sorted_hits by 1 element, if have
if sorted_hits.len() == 1 {
*sorted_hits = &[];
} else { } else {
None *sorted_hits = &sorted_hits[1..];
} }
})
.collect();
// Score them
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
// Sort descending by score
let mut scored_hits = scored_hits;
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
// Apply new scores to final_hits
for (idx, score) in scored_hits.into_iter().take(size as usize) {
final_hits[idx].score = score;
} }
} else if final_hits.len() < size as usize {
// If we still need more hits, take the highest-scoring remaining ones
let remaining_needed = size as usize - final_hits.len();
// Sort all hits by score descending, removing duplicates by document ID
all_hits.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let extra_hits = all_hits
.into_iter()
.filter(|(source_id, _, _)| hits_per_source.contains_key(source_id)) // Only take from known sources
.filter_map(|(_, doc, _)| {
if !seen_docs.contains(&doc.document.id) {
seen_docs.insert(doc.document.id.clone());
Some(doc)
} else {
None
} }
})
.take(remaining_needed)
.collect::<Vec<_>>();
final_hits.extend(extra_hits); let (source_id, hit) = highest_score_hit.expect("`pruned` should contain at least `n_take` elements so `highest_score_hit` should be set");
final_hits_grouped_by_source_id
.get_mut(source_id)
.expect("all the source_ids stored in `pruned` come from `final_hits_grouped_by_source_id`, so it should exist")
.push(hit.clone());
}
}
/*
* Re-rank the final hits
*/
if n_sources > 1 {
boosted_levenshtein_rerank(&query_keyword, &mut final_hits_grouped_by_source_id);
}
let mut final_hits = Vec::new();
for (_source_id, hits) in final_hits_grouped_by_source_id {
final_hits.extend(hits);
} }
// **Sort final hits by score descending** // **Sort final hits by score descending**
@@ -379,6 +375,11 @@ async fn query_coco_fusion_multi_query_sources(
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
}); });
// 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 { if final_hits.len() < 5 {
//TODO: Add a recommendation system to suggest more sources //TODO: Add a recommendation system to suggest more sources
log::info!( log::info!(
@@ -395,30 +396,85 @@ async fn query_coco_fusion_multi_query_sources(
}) })
} }
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> { use std::collections::HashSet;
use strsim::levenshtein; use strsim::levenshtein;
fn boosted_levenshtein_rerank(
query: &str,
all_hits_grouped_by_source_id: &mut HashMap<String, Vec<QueryHits>>,
) {
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
titles for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() {
.into_iter() // Skip special sources like calculator
.map(|(idx, title)| { if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID {
continue;
}
for hit in hits.iter_mut() {
let document_title = hit.document.title.as_deref().unwrap_or("");
let document_title_lowercase = document_title.to_lowercase();
let new_score = {
let mut score = 0.0; let mut score = 0.0;
if title.contains(query) { // --- Exact or substring boost ---
if document_title.contains(query) {
score += 0.4; score += 0.4;
} else if title.to_lowercase().contains(&query_lower) { } else if document_title_lowercase.contains(&query_lower) {
score += 0.2; score += 0.2;
} }
let dist = levenshtein(&query_lower, &title.to_lowercase()); // --- Levenshtein distance (character similarity) ---
let max_len = query_lower.len().max(title.len()); let dist = levenshtein(&query_lower, &document_title_lowercase);
if max_len > 0 { let max_len = query_lower.len().max(document_title.len());
score += (1.0 - (dist as f64 / max_len as f64)) as f32; let levenshtein_score = if max_len > 0 {
(1.0 - (dist as f64 / max_len as f64)) as f32
} else {
0.0
};
// --- Jaccard similarity (token overlap) ---
let jaccard_score = jaccard_similarity(&query_lower, &document_title_lowercase);
// --- Combine scores (weights adjustable) ---
// Levenshtein emphasizes surface similarity
// Jaccard emphasizes term overlap (semantic hint)
let hybrid_score = 0.7 * levenshtein_score + 0.3 * jaccard_score;
// --- Apply hybrid score ---
score += hybrid_score;
// --- Limit score range ---
score.min(1.0) as f64
};
hit.score = new_score;
}
}
}
/// Compute token-based Jaccard similarity
fn jaccard_similarity(a: &str, b: &str) -> f32 {
let a_tokens: HashSet<_> = tokenize(a).into_iter().collect();
let b_tokens: HashSet<_> = tokenize(b).into_iter().collect();
if a_tokens.is_empty() || b_tokens.is_empty() {
return 0.0;
} }
(idx, score.min(1.0) as f64) let intersection = a_tokens.intersection(&b_tokens).count() as f32;
}) let union = a_tokens.union(&b_tokens).count() as f32;
intersection / union
}
/// Basic tokenizer (case-insensitive, alphanumeric words only)
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect() .collect()
} }

View File

@@ -1,14 +1,26 @@
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs //! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use crate::common::MAIN_WINDOW_LABEL; use crate::common::MAIN_WINDOW_LABEL;
use objc2_app_kit::NSNonactivatingPanelMask; use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow};
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow}; use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
const WINDOW_FOCUS_EVENT: &str = "tauri://focus"; const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
const WINDOW_BLUR_EVENT: &str = "tauri://blur"; const WINDOW_BLUR_EVENT: &str = "tauri://blur";
const WINDOW_MOVED_EVENT: &str = "tauri://move";
const WINDOW_RESIZED_EVENT: &str = "tauri://resize"; tauri_panel! {
panel!(NsPanel {
config: {
is_floating_panel: true,
can_become_key_window: true,
can_become_main_window: false
}
})
panel_event!(NsPanelEventHandler {
window_did_become_key(notification: &NSNotification) -> (),
window_did_resign_key(notification: &NSNotification) -> (),
})
}
pub fn platform( pub fn platform(
_tauri_app_handle: &AppHandle, _tauri_app_handle: &AppHandle,
@@ -17,68 +29,39 @@ pub fn platform(
_check_window: WebviewWindow, _check_window: WebviewWindow,
) { ) {
// Convert ns_window to ns_panel // Convert ns_window to ns_panel
let panel = main_window.to_panel().unwrap(); let panel = main_window.to_panel::<NsPanel>().unwrap();
// set level
panel.set_level(PanelLevel::Utility.value());
// Do not steal focus from other windows // Do not steal focus from other windows
// panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());
// Cast is safe
panel.set_style_mask(NSNonactivatingPanelMask.0 as i32);
// Set its level to NSFloatingWindowLevel to ensure it appears in front of
// all normal-level windows
//
// NOTE: some Chinese input methods use a level between NSDockWindowLevel (20)
// and NSMainMenuWindowLevel (24), setting our level above NSDockWindowLevel
// would block their window
panel.set_floating_panel(true);
// Open the window in the active workspace and full screen // Open the window in the active workspace and full screen
panel.set_collection_behaviour( panel.set_collection_behavior(
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace CollectionBehavior::new()
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary .stationary()
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary, .move_to_active_space()
.full_screen_auxiliary()
.into(),
); );
// Define the panel's delegate to listen to panel window events let handler = NsPanelEventHandler::new();
let delegate = panel_delegate!(EcoPanelDelegate {
window_did_become_key,
window_did_resign_key,
window_did_resize,
window_did_move
});
// Set event listeners for the delegate let window = main_window.clone();
delegate.set_listener(Box::new(move |delegate_name: String| { handler.window_did_become_key(move |_| {
let target = EventTarget::labeled(MAIN_WINDOW_LABEL); let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
let window_move_event = || { let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
if let Ok(position) = main_window.outer_position() { });
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
}
};
match delegate_name.as_str() { let window = main_window.clone();
// Called when the window gets keyboard focus handler.window_did_resign_key(move |_| {
"window_did_become_key" => { let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
let _ = main_window.emit_to(target, WINDOW_FOCUS_EVENT, true);
}
// Called when the window loses keyboard focus
"window_did_resign_key" => {
let _ = main_window.emit_to(target, WINDOW_BLUR_EVENT, true);
}
// Called when the window size changes
"window_did_resize" => {
window_move_event();
if let Ok(size) = main_window.inner_size() { let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true);
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size); });
}
}
// Called when the window position changes
"window_did_move" => window_move_event(),
_ => (),
}
}));
// Set the delegate object for the window to handle window events // Set the delegate object for the window to handle window events
panel.set_delegate(delegate); panel.set_event_handler(Some(handler.as_ref()));
} }

View File

@@ -0,0 +1,189 @@
use std::path::PathBuf;
use tauri::plugin::TauriPlugin;
/// Return the log directory.
///
/// We use a custom log directory, which is similar to the one used by
/// Tauri, except that the "{bundleIdentifier}" will be "Coco AI" rather
/// than the real identifier.
///
/// We do this because our bundle ID ("rs.coco.app") ends with ".app", log directory
/// "/Users/xxx/Library/Logs/rs.coco.app" is mistakenly thought as an application
/// by Finder on macOS, making it inconvenient to open. We do not want to change the
/// bundle identifier. The data directory, which stores all the data, still
/// references it. So doing that will be a breaking change. Using a custom log
/// directory make more sense.
///
/// ### Platform-specific
///
/// |Platform | Value | Example |
/// | --------- | -------------------------------------------------------------------| --------------------------------------------|
/// | Linux | `$XDG_DATA_HOME/Coco AI/logs` or `$HOME/.local/share/Coco AI/logs` | `/home/alice/.local/share/Coco AI/logs` |
/// | macOS/iOS | `{homeDir}/Library/Logs/Coco AI` | `/Users/Alice/Library/Logs/Coco AI` |
/// | Windows | `{FOLDERID_LocalAppData}/Coco AI/logs` | `C:\Users\Alice\AppData\Local\Coco AI\logs` |
/// | Android | `{ConfigDir}/logs` | `/data/data/com.tauri.dev/files/logs` |
#[tauri::command]
pub fn app_log_dir() -> PathBuf {
const IDENTIFIER: &str = "Coco AI";
#[cfg(target_os = "macos")]
let path = dirs::home_dir()
.expect("cannot find the home directory, Coco should never run in such a environment")
.join("Library/Logs")
.join(IDENTIFIER);
#[cfg(not(target_os = "macos"))]
let path = dirs::data_local_dir()
.expect("app local dir is None, we should not encounter this")
.join(IDENTIFIER)
.join("logs");
path
}
/// Log format:
///
/// ```text
/// [time] [log level] [file module:line] message
/// ```
///
/// Example:
///
///
/// ```text
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
/// ```
pub(crate) fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
use log::Level;
use log::LevelFilter;
use tauri_plugin_log::Builder;
use tauri_plugin_log::Target;
use tauri_plugin_log::TargetKind;
/// Coco-AI app's default log level.
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
fn format_log_level(level: Level) -> &'static str {
match level {
Level::Trace => "TRC",
Level::Debug => "DBG",
Level::Info => "INF",
Level::Warn => "WAR",
Level::Error => "ERR",
}
}
fn format_target_and_line(record: &log::Record) -> String {
let mut str = record.target().to_string();
if let Some(line) = record.line() {
str.push(':');
str.push_str(&line.to_string());
}
str
}
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
///
/// Generally, it mirrors the behavior of `env_logger`. Syntax: `COCO_LOG=[module][=][level][,...]`
///
/// * If this environment variable is not set, use the default log level.
/// * If it is set, respect it:
///
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
/// equivalent to `COCO_LOG=coco_lib=trace`
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
/// * `COCO_LOG=off` turns off all logging for the application
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
fn dynamic_log_level(mut builder: Builder) -> Builder {
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
return builder.level(DEFAULT_LOG_LEVEL);
};
builder = builder.level(LevelFilter::Off);
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
panic!(
"The value '{}' set in environment variable '{}' is not UTF-8 encoded",
// Cannot use `.display()` here because that requires MSRV 1.87.0
e.to_string_lossy(),
LOG_LEVEL_ENV_VAR
)
});
// COCO_LOG=[module][=][level][,...]
let module_log_levels = log_levels.split(',');
for module_log_level in module_log_levels {
#[allow(clippy::collapsible_else_if)]
if let Some(char_index) = module_log_level.chars().position(|c| c == '=') {
let (module, equal_sign_and_level) = module_log_level.split_at(char_index);
// Remove the equal sign, we know it takes 1 byte
let level = &equal_sign_and_level[1..];
if let Ok(level) = level.parse::<LevelFilter>() {
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(module.to_string(), level);
} else {
panic!(
"log level '{}' set in '{}={}' is invalid",
level, module, level
);
}
} else {
if let Ok(level) = module_log_level.parse::<LevelFilter>() {
// This is a level
builder = builder.level(level);
} else {
// This is a module, enable all the logging
let module = module_log_level;
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
builder = builder.level_for(module.to_string(), LevelFilter::Trace);
}
}
}
builder
}
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
// that come from Coco in the log file, which helps with debugging.
if !tauri::is_dev() {
// We have absolutely no guarantee that we (We have control over the Rust
// code, but definitely no idea about the libc C code, all the shared objects
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
unsafe {
std::env::set_var("COCO_LOG", "coco_lib=trace");
}
}
let mut builder = tauri_plugin_log::Builder::new();
builder = builder.format(|out, message, record| {
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
let level = format_log_level(record.level());
let target_and_line = format_target_and_line(record);
out.finish(format_args!(
"[{}] [{}] [{}] {}",
now, level, target_and_line, message
));
});
builder = dynamic_log_level(builder);
/*
* Use our custom log directory
*/
// We have no public APIs to update targets in-place, so we need to remove
// them all, then bring back the correct ones.
builder = builder.clear_targets();
builder = builder.target(Target::new(TargetKind::Stdout));
builder = builder.target(Target::new(TargetKind::Folder {
path: app_log_dir(),
// Use the default value, which is "Coco-AI.log"
file_name: None,
}));
builder.build()
}

View File

@@ -1,5 +1,7 @@
pub(crate) mod app_lang; pub(crate) mod app_lang;
pub(crate) mod file; pub(crate) mod file;
// We need this in main.rs, so it has to be pub
pub mod logging;
pub(crate) mod path; pub(crate) mod path;
pub(crate) mod platform; pub(crate) mod platform;
pub(crate) mod prevent_default; pub(crate) mod prevent_default;

View File

@@ -0,0 +1,8 @@
{
"identifier": "rs.coco.app",
"bundle": {
"macOS": {
"entitlements": "./Entitlements.plist"
}
}
}

View File

@@ -1,5 +1,7 @@
import axios from "axios"; import axios from "axios";
axios.defaults.withCredentials = true;
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { import {
@@ -78,7 +80,7 @@ export const Get = <T>(
} }
axios axios
.get(baseURL + url, { params }) .get(baseURL + url, { params, withCredentials: true })
.then((result) => { .then((result) => {
let res: FcResponse<T>; let res: FcResponse<T>;
if (clearFn !== undefined) { if (clearFn !== undefined) {
@@ -110,10 +112,15 @@ export const Post = <T>(
} }
axios axios
.post(baseURL + url, data, { .post(
baseURL + url,
data,
{
params, params,
headers, headers,
} as any) withCredentials: true,
} as any
)
.then((result) => { .then((result) => {
resolve([null, result.data as FcResponse<T>]); resolve([null, result.data as FcResponse<T>]);
}) })

View File

@@ -34,6 +34,7 @@ export async function streamPost({
...(headersStorage), ...(headersStorage),
...(headers || {}), ...(headers || {}),
}, },
credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}); });

View File

@@ -30,19 +30,23 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const setCurrentAssistant = useConnectStore((state) => { const setCurrentAssistant = useConnectStore((state) => {
return state.setCurrentAssistant; return state.setCurrentAssistant;
}); });
const assistantList = useConnectStore((state) => state.assistantList);
const aiAssistant = useShortcutsStore((state) => state.aiAssistant); const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
const [assistants, setAssistants] = useState<any[]>([]); const [assistants, setAssistants] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const popoverButtonRef = useRef<HTMLButtonElement>(null); const popoverButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const debounceKeyword = useDebounce(keyword, { wait: 500 }); const debounceKeyword = useDebounce(keyword, { wait: 500 });
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId); const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
const setAskAiAssistantId = useSearchStore((state) => { const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId; return state.setAskAiAssistantId;
}); });
const assistantList = useConnectStore((state) => state.assistantList);
const { fetchAssistant } = AssistantFetcher({ const { fetchAssistant } = AssistantFetcher({
debounceKeyword, debounceKeyword,

View File

@@ -41,6 +41,7 @@ interface ChatAIProps {
startPage?: StartPage; startPage?: StartPage;
formatUrl?: (data: any) => string; formatUrl?: (data: any) => string;
instanceId?: string; instanceId?: string;
getChatHistoryChatPage?: () => void;
} }
export interface SendMessageParams { export interface SendMessageParams {
@@ -52,6 +53,7 @@ export interface ChatAIRef {
init: (params: SendMessageParams) => void; init: (params: SendMessageParams) => void;
cancelChat: () => void; cancelChat: () => void;
clearChat: () => void; clearChat: () => void;
onSelectChat: (chat: Chat) => void;
} }
const ChatAI = memo( const ChatAI = memo(
@@ -73,6 +75,7 @@ const ChatAI = memo(
startPage, startPage,
formatUrl, formatUrl,
instanceId, instanceId,
getChatHistoryChatPage,
}, },
ref ref
) => { ) => {
@@ -80,6 +83,7 @@ const ChatAI = memo(
init: init, init: init,
cancelChat: () => cancelChat(activeChat), cancelChat: () => cancelChat(activeChat),
clearChat: clearChat, clearChat: clearChat,
onSelectChat: onSelectChat,
})); }));
const curChatEnd = useChatStore((state) => state.curChatEnd); const curChatEnd = useChatStore((state) => state.curChatEnd);
@@ -193,7 +197,8 @@ const ChatAI = memo(
isDeepThinkActive, isDeepThinkActive,
isMCPActive, isMCPActive,
changeInput, changeInput,
showChatHistory showChatHistory,
getChatHistoryChatPage,
); );
const { dealMsg } = useMessageHandler( const { dealMsg } = useMessageHandler(

View File

@@ -5,7 +5,6 @@ import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings"; import { Greetings } from "./Greetings";
import AttachmentList from "@/components/Assistant/AttachmentList"; import AttachmentList from "@/components/Assistant/AttachmentList";
import { useChatScroll } from "@/hooks/useChatScroll"; import { useChatScroll } from "@/hooks/useChatScroll";
import type { Chat, IChunkData } from "@/types/chat"; import type { Chat, IChunkData } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
// import SessionFile from "./SessionFile"; // import SessionFile from "./SessionFile";
@@ -45,20 +44,23 @@ export const ChatContent = ({
handleSendMessage, handleSendMessage,
formatUrl, formatUrl,
}: ChatContentProps) => { }: ChatContentProps) => {
const { currentSessionId, setCurrentSessionId } = useConnectStore();
const { t } = useTranslation(); const { t } = useTranslation();
const { uploadAttachments } = useChatStore(); const currentSessionId = useConnectStore((state) => state.currentSessionId);
const setCurrentSessionId = useConnectStore(
(state) => state.setCurrentSessionId
);
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const uploadAttachments = useChatStore((state) => state.uploadAttachments);
const curChatEnd = useChatStore((state) => state.curChatEnd);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef); const { scrollToBottom } = useChatScroll(messagesEndRef);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const curChatEnd = useChatStore((state) => state.curChatEnd);
useEffect(() => { useEffect(() => {
setIsAtBottom(true); setIsAtBottom(true);

View File

@@ -1,9 +1,6 @@
import { MessageSquarePlus } from "lucide-react"; import { MessageSquarePlus } from "lucide-react";
import clsx from "clsx";
import HistoryIcon from "@/icons/History"; import HistoryIcon from "@/icons/History";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
import WindowsFullIcon from "@/icons/WindowsFull"; import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import type { Chat } from "@/types/chat"; import type { Chat } from "@/types/chat";
@@ -12,6 +9,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import { HISTORY_PANEL_ID } from "@/constants"; import { HISTORY_PANEL_ID } from "@/constants";
import { AssistantList } from "./AssistantList"; import { AssistantList } from "./AssistantList";
import { ServerList } from "./ServerList"; import { ServerList } from "./ServerList";
import TogglePin from "../Common/TogglePin";
interface ChatHeaderProps { interface ChatHeaderProps {
clearChat: () => void; clearChat: () => void;
@@ -34,21 +32,9 @@ export function ChatHeader({
showChatHistory = true, showChatHistory = true,
assistantIDs, assistantIDs,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const { isPinned, setIsPinned, isTauri } = useAppStore(); const { isTauri } = useAppStore();
const { historicalRecords, newSession, fixedWindow, external } = const { historicalRecords, newSession, external } = useShortcutsStore();
useShortcutsStore();
const togglePin = async () => {
try {
const { isPinned } = useAppStore.getState();
setIsPinned(!isPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
return ( return (
<header <header
@@ -101,16 +87,7 @@ export function ChatHeader({
{isTauri ? ( {isTauri ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <TogglePin className="inline-flex" />
onClick={togglePin}
className={clsx("inline-flex", {
"text-blue-500": isPinned,
})}
>
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
{isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button>
<ServerList clearChat={clearChat} /> <ServerList clearChat={clearChat} />

View File

@@ -18,7 +18,10 @@ import StatusIndicator from "@/components/Cloud/StatusIndicator";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { useServers } from "@/hooks/useServers"; import { useServers } from "@/hooks/useServers";
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService"; import {
getCurrentWindowService,
setCurrentWindowService,
} from "@/commands/windowService";
interface ServerListProps { interface ServerListProps {
clearChat: () => void; clearChat: () => void;
@@ -33,10 +36,9 @@ export function ServerList({ clearChat }: ServerListProps) {
); );
const setEndpoint = useAppStore((state) => state.setEndpoint); const setEndpoint = useAppStore((state) => state.setEndpoint);
const isTauri = useAppStore((state) => state.isTauri); const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService); const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => { const serverList = useConnectStore((state) => state.serverList);
return state.cloudSelectService;
});
const { setMessages } = useChatStore(); const { setMessages } = useChatStore();
@@ -55,7 +57,6 @@ export function ServerList({ clearChat }: ServerListProps) {
const serverListButtonRef = useRef<HTMLButtonElement>(null); const serverListButtonRef = useRef<HTMLButtonElement>(null);
const { refreshServerList } = useServers(); const { refreshServerList } = useServers();
const serverList = useConnectStore((state) => state.serverList);
const switchServer = async (server: IServer) => { const switchServer = async (server: IServer) => {
if (!server) return; if (!server) return;
@@ -95,8 +96,10 @@ export function ServerList({ clearChat }: ServerListProps) {
} else { } else {
switchServer(enabledServers[enabledServers.length - 1]); switchServer(enabledServers[enabledServers.length - 1]);
} }
} else {
setCurrentWindowService({});
} }
}, [currentService?.id, cloudSelectService?.id, serverList]); }, [serverList]);
useEffect(() => { useEffect(() => {
if (!askAiServerId || serverList.length === 0) return; if (!askAiServerId || serverList.length === 0) return;
@@ -229,11 +232,11 @@ export function ServerList({ clearChat }: ServerListProps) {
: "hover:bg-gray-50 dark:hover:bg-gray-800/50" : "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`} }`}
> >
<div className="flex items-center gap-2 overflow-hidden min-w-0"> <div className="flex items-center gap-2 min-w-0">
<img <img
src={server?.provider?.icon || logoImg} src={server?.provider?.icon || logoImg}
alt={server.name} alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800" className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.src = logoImg; target.src = logoImg;

View File

@@ -37,8 +37,6 @@ const SessionFile = (props: SessionFileProps) => {
const getUploadedFiles = async () => { const getUploadedFiles = async () => {
if (isTauri) { if (isTauri) {
console.log("sessionId", sessionId);
const response: any = await platformAdapter.commands( const response: any = await platformAdapter.commands(
"get_attachment_by_ids", "get_attachment_by_ids",
{ {

View File

@@ -72,8 +72,6 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
setSettings(response); setSettings(response);
}; };
console.log("currentService", currentService);
useEffect(() => { useEffect(() => {
getSettings(); getSettings();
fetchData(); fetchData();

View File

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

View File

@@ -182,9 +182,9 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2 mb-3">
<button <button
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3" className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors"
onClick={onCancel} onClick={onCancel}
> >
{t("cloud.cancel")} {t("cloud.cancel")}

View File

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

View File

@@ -24,13 +24,13 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
<img <img
src={userInfo?.avatar} src={userInfo?.avatar}
alt="" alt=""
className="w-6 h-6" className="w-6 h-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={() => { onError={() => {
setImageLoadError(true); setImageLoadError(true);
}} }}
/> />
) : ( ) : (
<User className="w-6 h-6 text-gray-500 dark:text-gray-400" /> <User className="w-6 h-6 text-gray-500 dark:text-gray-400 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">

View File

@@ -8,7 +8,7 @@ interface FontIconProps {
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => { const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
return ( return (
<svg className={`icon ${className || ""}`} style={style} {...rest}> <svg className={`icon dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className || ""}`} style={style} {...rest}>
<use xlinkHref={`#${name}`} /> <use xlinkHref={`#${name}`} />
</svg> </svg>
); );

View File

@@ -24,7 +24,12 @@ function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
return <Component className={className} color={color} />; return (
<Component
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
color={color}
/>
);
} }
export default ThemedIcon; export default ThemedIcon;

View File

@@ -49,7 +49,13 @@ function UniversalIcon({
// Render image type icon // Render image type icon
const renderImageIcon = (src: string) => { const renderImageIcon = (src: string) => {
const img = <img className={className} src={src} alt="icon" />; const img = (
<img
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
src={src}
alt="icon"
/>
);
return wrapWithIconWrapper ? ( return wrapWithIconWrapper ? (
<IconWrapper className={className} onClick={onClick}> <IconWrapper className={className} onClick={onClick}>
{img} {img}
@@ -63,7 +69,7 @@ function UniversalIcon({
const renderAppIcon = (src: string) => { const renderAppIcon = (src: string) => {
const img = ( const img = (
<img <img
className={className} className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
src={platformAdapter.convertFileSrc(src)} src={platformAdapter.convertFileSrc(src)}
alt="icon" alt="icon"
/> />

View File

@@ -0,0 +1,47 @@
import { useEventListener } from "ahooks";
import clsx from "clsx";
import {
forwardRef,
HTMLAttributes,
useImperativeHandle,
useRef,
} from "react";
const Scrollbar = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const { children, className, ...rest } = props;
const containerRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
useEventListener("keydown", (event) => {
const { key } = event;
if (key !== "PageDown" && key !== "PageUp") return;
if (!containerRef.current) return;
event.preventDefault();
const delta = key === "PageDown" ? 1 : -1;
const el = containerRef.current;
el.scrollBy({
top: delta * el.clientHeight * 0.9,
behavior: "smooth",
});
});
return (
<div
{...rest}
ref={containerRef}
className={clsx("custom-scrollbar", className)}
>
{children}
</div>
);
}
);
export default Scrollbar;

View File

@@ -0,0 +1,50 @@
import { useAppStore } from "@/stores/appStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import clsx from "clsx";
import VisibleKey from "./VisibleKey";
import { FC, HTMLAttributes } from "react";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
interface TogglePinProps extends HTMLAttributes<HTMLButtonElement> {
setIsPinnedWeb?: (value: boolean) => void;
}
const TogglePin: FC<TogglePinProps> = (props) => {
const { className, setIsPinnedWeb } = props;
const { isPinned, setIsPinned } = useAppStore();
const { fixedWindow } = useShortcutsStore();
const togglePin = async () => {
const { isTauri, isPinned } = useAppStore.getState();
try {
const nextPinned = !isPinned;
if (!isTauri) {
setIsPinnedWeb?.(nextPinned);
}
setIsPinned(nextPinned);
} catch (err) {
setIsPinned(isPinned);
console.error("Failed to toggle window pin state:", err);
}
};
return (
<button
onClick={togglePin}
className={clsx(className, {
"text-blue-500": isPinned,
})}
>
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
{isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button>
);
};
export default TogglePin;

View File

@@ -5,13 +5,10 @@ import clsx from "clsx";
import CommonIcon from "@/components/Common/Icons/CommonIcon"; import CommonIcon from "@/components/Common/Icons/CommonIcon";
import Copyright from "@/components/Common/Copyright"; import Copyright from "@/components/Common/Copyright";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
import logoImg from "@/assets/icon.svg"; import logoImg from "@/assets/icon.svg";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { useUpdateStore } from "@/stores/updateStore"; import { useUpdateStore } from "@/stores/updateStore";
import VisibleKey from "../VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
import { formatKey } from "@/utils/keyboardUtils"; import { formatKey } from "@/utils/keyboardUtils";
import source_default_img from "@/assets/images/source_default.png"; import source_default_img from "@/assets/images/source_default.png";
@@ -19,6 +16,7 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon"; import FontIcon from "../Icons/FontIcon";
import TogglePin from "../TogglePin";
interface FooterProps { interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void; setIsPinnedWeb?: (value: boolean) => void;
@@ -37,28 +35,11 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
const isDark = useThemeStore((state) => state.isDark); const isDark = useThemeStore((state) => state.isDark);
const { isTauri, isPinned, setIsPinned } = useAppStore(); const { isTauri } = useAppStore();
const { setVisible, updateInfo, skipVersions } = useUpdateStore(); const { setVisible, updateInfo, skipVersions } = useUpdateStore();
const { fixedWindow, modifierKey } = useShortcutsStore(); const { modifierKey } = useShortcutsStore();
const togglePin = async () => {
try {
const { isTauri, isPinned } = useAppStore.getState();
const nextPinned = !isPinned;
if (!isTauri) {
setIsPinnedWeb?.(nextPinned);
}
setIsPinned(nextPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
const openSetting = useCallback(() => { const openSetting = useCallback(() => {
return platformAdapter.emitEvent("open_settings", ""); return platformAdapter.emitEvent("open_settings", "");
@@ -88,7 +69,10 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
if (visibleExtensionDetail && selectedExtension) { if (visibleExtensionDetail && selectedExtension) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src={selectedExtension.icon} className="size-5" /> <img
src={selectedExtension.icon}
className="size-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
/>
<span className="text-sm">{selectedExtension.name}</span> <span className="text-sm">{selectedExtension.name}</span>
</div> </div>
); );
@@ -139,17 +123,12 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{renderLeft()} {renderLeft()}
<button <TogglePin
onClick={togglePin}
className={clsx({ className={clsx({
"text-blue-500": isPinned,
"pl-2": hasUpdate, "pl-2": hasUpdate,
})} })}
> setIsPinnedWeb={setIsPinnedWeb}
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}> />
{isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button>
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -6,6 +6,9 @@ import { last } from "lodash-es";
import { POPOVER_PANEL_SELECTOR } from "@/constants"; import { POPOVER_PANEL_SELECTOR } from "@/constants";
import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { KeyType } from "ahooks/lib/useKeyPress";
const keyTriggerMap = new Map<KeyType, number>();
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> { interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
shortcut: string; shortcut: string;
@@ -60,8 +63,16 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
setVisibleShortcut(isChildInPopover && modifierKeyPressed); setVisibleShortcut(isChildInPopover && modifierKeyPressed);
}, [openPopover, modifierKeyPressed]); }, [openPopover, modifierKeyPressed]);
useKeyPress(`${modifierKey}.${shortcut}`, (event) => { useKeyPress(`${modifierKey}.${shortcut}`, (event, key) => {
if (!visibleShortcut) return; if (!visibleShortcut || event.repeat) return;
const now = Date.now();
const last = keyTriggerMap.get(key) ?? 0;
const wait = 100;
if (now - last < wait) return;
keyTriggerMap.set(key, now);
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
@@ -82,6 +93,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
return "↩︎"; return "↩︎";
} }
if (shortcut === "backspace") {
return "⌫";
}
return shortcut; return shortcut;
}; };

View File

@@ -107,7 +107,7 @@ const AskAi: FC<AskAiProps> = (props) => {
unlisten.current = await platformAdapter.listenEvent( unlisten.current = await platformAdapter.listenEvent(
"quick-ai-access-client-id", "quick-ai-access-client-id",
({ payload }) => { ({ payload }) => {
console.log("ask_ai", JSON.parse(payload)); // console.log("ask_ai", JSON.parse(payload));
const chunkData = JSON.parse(payload); const chunkData = JSON.parse(payload);

View File

@@ -7,6 +7,9 @@ import platformAdapter from "@/utils/platformAdapter";
import { Get } from "@/api/axiosRequest"; import { Get } from "@/api/axiosRequest";
import type { Assistant } from "@/types/chat"; import type { Assistant } from "@/types/chat";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { canNavigateBack, navigateBack } from "@/utils";
import { useKeyPress } from "ahooks";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AssistantManagerProps { interface AssistantManagerProps {
isChatMode: boolean; isChatMode: boolean;
@@ -33,9 +36,9 @@ export function useAssistantManager({
setVisibleExtensionStore, setVisibleExtensionStore,
setSearchValue, setSearchValue,
visibleExtensionDetail, visibleExtensionDetail,
setVisibleExtensionDetail,
sourceData, sourceData,
setSourceData, setSourceData,
setVisibleExtensionDetail,
} = useSearchStore(); } = useSearchStore();
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore(); const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
@@ -47,6 +50,7 @@ export function useAssistantManager({
}, [quickAiAccessAssistant, selectedAssistant]); }, [quickAiAccessAssistant, selectedAssistant]);
const [assistantDetail, setAssistantDetail] = useState<any>({}); const [assistantDetail, setAssistantDetail] = useState<any>({});
const { modifierKey } = useShortcutsStore();
useEffect(() => { useEffect(() => {
if (goAskAi) return; if (goAskAi) return;
@@ -76,7 +80,7 @@ export function useAssistantManager({
}, [askAI?.id, askAI?.querySource?.id, disabledExtensions]); }, [askAI?.id, askAI?.querySource?.id, disabledExtensions]);
const handleAskAi = useCallback(() => { const handleAskAi = useCallback(() => {
if (!isTauri) return; if (!isTauri || canNavigateBack()) return;
if (disabledExtensions.includes("QuickAIAccess")) return; if (disabledExtensions.includes("QuickAIAccess")) return;
@@ -99,39 +103,14 @@ export function useAssistantManager({
const handleKeyDownAutoResizeTextarea = useCallback( const handleKeyDownAutoResizeTextarea = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { key, shiftKey, currentTarget } = e; const { key, shiftKey, currentTarget } = e;
const { value } = currentTarget; const { value, selectionStart, selectionEnd } = currentTarget;
if (key === "Backspace" && value === "") { const cursorStart = selectionStart === 0 && selectionEnd === 0;
if (goAskAi) {
return setGoAskAi(false);
}
if (visibleExtensionDetail) { if (key === "Backspace" && (value === "" || cursorStart)) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
}
if (sourceData) {
return setSourceData(void 0);
}
}
if (key === "Tab" && !isChatMode && isTauri) {
e.preventDefault(); e.preventDefault();
if (visibleExtensionStore) return; return navigateBack();
if (selectedSearchContent?.id === "Extension Store") {
changeInput("");
setSearchValue("");
return setVisibleExtensionStore(true);
}
assistant_get();
return handleAskAi();
} }
if (key === "Enter" && !shiftKey) { if (key === "Enter" && !shiftKey) {
@@ -147,6 +126,17 @@ export function useAssistantManager({
handleSubmit(); handleSubmit();
} }
if (key === "Home") {
e.preventDefault();
return currentTarget.setSelectionRange(0, 0);
}
if (key === "End") {
e.preventDefault();
const length = currentTarget.value.length;
return currentTarget.setSelectionRange(length, length);
}
}, },
[ [
isChatMode, isChatMode,
@@ -161,6 +151,69 @@ export function useAssistantManager({
] ]
); );
const clearSearchValue = () => {
changeInput("");
setSearchValue("");
};
// useKeyPress("backspace", () => {
// console.log("backspace");
// dispatchEvent("Backspace", 8, "#search-textarea");
// });
useKeyPress("tab", (event) => {
event.preventDefault();
const { selectedSearchContent, visibleExtensionStore } =
useSearchStore.getState();
console.log("selectedSearchContent", selectedSearchContent);
const { id, type, category } = selectedSearchContent ?? {};
if (isChatMode || !isTauri || id === "Calculator") return;
if (visibleExtensionStore) {
clearSearchValue();
return setVisibleExtensionDetail(true);
}
if (id === "Extension Store") {
clearSearchValue();
return setVisibleExtensionStore(true);
}
if (category === "View") {
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,
]);
}
}
if (type === "AI Assistant") {
assistant_get();
return handleAskAi();
}
setSourceData(selectedSearchContent);
});
useKeyPress(`${modifierKey}.enter`, () => {
assistant_get();
return handleAskAi();
});
return { return {
askAI, askAI,
askAIRef, askAIRef,

View File

@@ -1,6 +1,9 @@
import React, { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteScroll } from "ahooks"; import { useInfiniteScroll } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Data } from "ahooks/lib/useInfiniteScroll/types";
import { nanoid } from "nanoid";
import { isNil } from "lodash-es";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { SearchHeader } from "./SearchHeader"; import { SearchHeader } from "./SearchHeader";
@@ -11,9 +14,7 @@ import { Get } from "@/api/axiosRequest";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import SearchEmpty from "../Common/SearchEmpty"; import SearchEmpty from "../Common/SearchEmpty";
import { Data } from "ahooks/lib/useInfiniteScroll/types"; import Scrollbar from "@/components/Common/Scrollbar";
import { nanoid } from "nanoid";
import { isNil } from "lodash-es";
interface DocumentListProps { interface DocumentListProps {
onSelectDocument: (id: string) => void; onSelectDocument: (id: string) => void;
@@ -297,8 +298,8 @@ export const DocumentList: React.FC<DocumentListProps> = ({
/> />
</div> </div>
<div <Scrollbar
className="flex-1 overflow-auto custom-scrollbar pr-0.5" className="flex-1 overflow-auto pr-0.5"
ref={containerRef} ref={containerRef}
> >
{data?.list && data.list.length > 0 && ( {data?.list && data.list.length > 0 && (
@@ -334,7 +335,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
<SearchEmpty /> <SearchEmpty />
</div> </div>
)} )}
</div> </Scrollbar>
</div> </div>
); );
}; };

View File

@@ -16,6 +16,7 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
import { SearchSource } from "./SearchSource"; import { SearchSource } from "./SearchSource";
import DropdownListItem from "./DropdownListItem"; import DropdownListItem from "./DropdownListItem";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import Scrollbar from "@/components/Common/Scrollbar";
type ISearchData = Record<string, QueryHits[]>; type ISearchData = Record<string, QueryHits[]>;
@@ -145,13 +146,14 @@ function DropdownList({
handleItemAction, handleItemAction,
isChatMode, isChatMode,
formatUrl, formatUrl,
searchData,
}); });
return ( return (
<div <Scrollbar
ref={containerRef} ref={containerRef}
data-tauri-drag-region data-tauri-drag-region
className="h-full w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none" className="h-full w-full p-2 flex flex-col overflow-y-auto focus:outline-none"
tabIndex={0} tabIndex={0}
role="listbox" role="listbox"
aria-label={t("search.header.results")} aria-label={t("search.header.results")}
@@ -188,7 +190,7 @@ function DropdownList({
})} })}
</div> </div>
))} ))}
</div> </Scrollbar>
); );
} }

View File

@@ -139,7 +139,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
} }
); );
console.log("search_extension", result); // console.log("search_extension", result);
setList(result ?? []); setList(result ?? []);

View File

@@ -18,10 +18,17 @@ import { useAssistantManager } from "./AssistantManager";
import InputControls from "./InputControls"; import InputControls from "./InputControls";
import { useExtensionsStore } from "@/stores/extensionsStore"; import { useExtensionsStore } from "@/stores/extensionsStore";
import AudioRecording from "../AudioRecording"; import AudioRecording from "../AudioRecording";
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils"; import {
canNavigateBack,
getUploadedAttachmentsId,
isDefaultServer,
visibleFilterBar,
visibleSearchBar,
} from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus"; import { useTauriFocus } from "@/hooks/useTauriFocus";
import { SendMessageParams } from "../Assistant/Chat"; import { SendMessageParams } from "../Assistant/Chat";
import { isEmpty } from "lodash-es"; import { isEmpty } from "lodash-es";
import { formatKey } from "@/utils/keyboardUtils";
interface ChatInputProps { interface ChatInputProps {
onSend: (params: SendMessageParams) => void; onSend: (params: SendMessageParams) => void;
@@ -88,7 +95,7 @@ export default function ChatInput({
const { currentAssistant } = useConnectStore(); const { currentAssistant } = useConnectStore();
const { sourceData, goAskAi } = useSearchStore(); const { goAskAi } = useSearchStore();
const { modifierKey, returnToInput, setModifierKeyPressed } = const { modifierKey, returnToInput, setModifierKeyPressed } =
useShortcutsStore(); useShortcutsStore();
@@ -103,8 +110,7 @@ export default function ChatInput({
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null); const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
const { curChatEnd } = useChatStore(); const { curChatEnd } = useChatStore();
const { setSearchValue, visibleExtensionStore, selectedExtension } = const { setSearchValue } = useSearchStore();
useSearchStore();
const { uploadAttachments } = useChatStore(); const { uploadAttachments } = useChatStore();
useTauriFocus({ useTauriFocus({
@@ -121,7 +127,7 @@ export default function ChatInput({
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim(); const trimmedValue = inputValue.trim();
console.log("handleSubmit", trimmedValue, disabled); // console.log("handleSubmit", trimmedValue, disabled);
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) { if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
changeInput(""); changeInput("");
@@ -237,46 +243,19 @@ export default function ChatInput({
/> />
)} )}
{!isChatMode &&
(sourceData || visibleExtensionStore || selectedExtension) && (
<div
className={`absolute ${
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
} left-2`}
>
<VisibleKey shortcut="←" />
</div>
)}
{/*
<div
className={clsx(
`absolute ${
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
} left-2`,
{
"left-8": !isChatMode && sourceData,
}
)}
>
<VisibleKey shortcut={returnToInput} />
</div>
*/}
{!isChatMode && {!isChatMode &&
isTauri && isTauri &&
!goAskAi &&
askAI && askAI &&
!disabledExtensions.includes("QuickAIAccess") && !disabledExtensions.includes("QuickAIAccess") &&
!visibleExtensionStore && ( !canNavigateBack() && (
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap"> <div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
<span> <span>
{t("search.askCocoAi.title", { {t("search.askCocoAi.title", {
replace: [akiAiTooltipPrefix, askAI.name], replace: [akiAiTooltipPrefix, askAI.name],
})} })}
</span> </span>
<div className="flex items-center justify-center w-8 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-md border border-black/10 dark:border-[#545454]">
Tab {formatKey(modifierKey)} + {formatKey("Enter")}
</div> </div>
</div> </div>
)} )}
@@ -329,17 +308,21 @@ export default function ChatInput({
return ( return (
<div className={`w-full relative`}> <div className={`w-full relative`}>
<div
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`}
>
<div <div
ref={containerRef} ref={containerRef}
className={clsx("relative w-full", { className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`}
"flex items-center gap-2": lineCount === 1,
})}
> >
{lineCount === 1 && renderSearchIcon()} {lineCount === 1 && renderSearchIcon()}
{visibleSearchBar() && (
<div
className={clsx(
"relative w-full p-2 bg-[#ededed] dark:bg-[#202126]",
{
"flex items-center gap-2": lineCount === 1,
}
)}
>
{renderTextarea()} {renderTextarea()}
{lineCount === 1 && renderExtraIcon()} {lineCount === 1 && renderExtraIcon()}
@@ -352,8 +335,10 @@ export default function ChatInput({
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
{visibleFilterBar() && (
<InputControls <InputControls
isChatMode={isChatMode} isChatMode={isChatMode}
isChatPage={isChatPage} isChatPage={isChatPage}
@@ -377,6 +362,7 @@ export default function ChatInput({
getFileMetadata={getFileMetadata} getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon} getFileIcon={getFileIcon}
/> />
)}
</div> </div>
); );
} }

View File

@@ -10,7 +10,9 @@ import AskAi from "./AskAi";
import { useSearch } from "@/hooks/useSearch"; import { useSearch } from "@/hooks/useSearch";
import ExtensionStore from "./ExtensionStore"; import ExtensionStore from "./ExtensionStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import ViewExtension from "./ViewExtension" import ViewExtension from "./ViewExtension";
import { visibleFooterBar } from "@/utils";
import clsx from "clsx";
const SearchResultsPanel = memo<{ const SearchResultsPanel = memo<{
input: string; input: string;
@@ -47,8 +49,12 @@ const SearchResultsPanel = memo<{
} }
}, [input, isChatMode, performSearch, sourceData]); }, [input, isChatMode, performSearch, sourceData]);
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore, viewExtensionOpened } = const {
useSearchStore(); setSelectedAssistant,
selectedSearchContent,
visibleExtensionStore,
viewExtensionOpened,
} = useSearchStore();
useEffect(() => { useEffect(() => {
if (selectedSearchContent?.type === "AI Assistant") { if (selectedSearchContent?.type === "AI Assistant") {
@@ -164,7 +170,12 @@ function Search({
const mainWindowRef = useRef<HTMLDivElement>(null); const mainWindowRef = useRef<HTMLDivElement>(null);
return ( return (
<div ref={mainWindowRef} className={`h-full pb-8 w-full relative`}> <div
ref={mainWindowRef}
className={clsx("h-full w-full relative", {
"pb-8": visibleFooterBar(),
})}
>
<SearchResultsPanel <SearchResultsPanel
input={input} input={input}
isChatMode={isChatMode} isChatMode={isChatMode}
@@ -173,7 +184,7 @@ function Search({
formatUrl={formatUrl} formatUrl={formatUrl}
/> />
<Footer setIsPinnedWeb={setIsPinned} /> {visibleFooterBar() && <Footer setIsPinnedWeb={setIsPinned} />}
<ContextMenu formatUrl={formatUrl} /> <ContextMenu formatUrl={formatUrl} />
</div> </div>

View File

@@ -1,7 +1,61 @@
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { ArrowBigLeft, Search, X } from "lucide-react"; import { ChevronLeft, Search } from "lucide-react";
import FontIcon from "@/components/Common/Icons/FontIcon"; import FontIcon from "@/components/Common/Icons/FontIcon";
import { FC } from "react";
import lightDefaultIcon from "@/assets/images/source_default.png";
import darkDefaultIcon from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import { navigateBack, visibleSearchBar } from "@/utils";
import VisibleKey from "../Common/VisibleKey";
import clsx from "clsx";
interface MultilevelWrapperProps {
title?: string;
icon?: string;
}
const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
const { icon, title = "" } = props;
const { isDark } = useThemeStore();
const renderIcon = () => {
if (!icon) {
return <img src={isDark ? darkDefaultIcon : lightDefaultIcon} />;
}
if (icon.startsWith("font_")) {
return <FontIcon name={icon} />;
}
return <img src={icon} />;
};
return (
<div
data-tauri-drag-region
className={clsx(
"flex items-center h-[40px] gap-1 px-2 border border-[#EDEDED] dark:border-[#202126] rounded-l-lg",
{
"justify-center": visibleSearchBar(),
"w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(),
}
)}
>
<VisibleKey shortcut="backspace" onKeyPress={navigateBack}>
<ChevronLeft
className="size-5 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
onClick={navigateBack}
/>
</VisibleKey>
<div className="size-5 [&>*]:size-full">{renderIcon()}</div>
<span className="text-sm whitespace-nowrap">{title}</span>
</div>
);
};
interface SearchIconsProps { interface SearchIconsProps {
lineCount: number; lineCount: number;
@@ -16,13 +70,11 @@ export default function SearchIcons({
}: SearchIconsProps) { }: SearchIconsProps) {
const { const {
sourceData, sourceData,
setSourceData,
goAskAi, goAskAi,
setGoAskAi,
visibleExtensionStore, visibleExtensionStore,
setVisibleExtensionStore,
visibleExtensionDetail, visibleExtensionDetail,
setVisibleExtensionDetail, selectedExtension,
viewExtensionOpened,
} = useSearchStore(); } = useSearchStore();
if (isChatMode) { if (isChatMode) {
@@ -30,54 +82,42 @@ export default function SearchIcons({
} }
const renderContent = () => { const renderContent = () => {
if (goAskAi && assistant) {
return (
<div className="flex h-8 -my-1 -mx-1">
<div className="flex items-center gap-2 pl-2 text-sm bg-white dark:bg-black rounded-l-sm">
<div className="flex items-center gap-1 text-[#333] dark:text-[#D8D8D8]">
{assistant.icon?.startsWith("font_") ? (
<FontIcon name={assistant.icon} className="size-5" />
) : (
<img src={assistant.icon} className="size-5" />
)}
<span>{assistant.name}</span>
</div>
<X
className="size-4 text-[#999] cursor-pointer"
onClick={() => {
setGoAskAi(false);
}}
/>
</div>
<div className="relative w-4 overflow-hidden">
<div className="absolute size-0 border-[16px] border-transparent border-l-white dark:border-l-black"></div>
</div>
</div>
);
}
if (sourceData || visibleExtensionStore || visibleExtensionDetail) {
return (
<ArrowBigLeft
className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
onClick={() => {
if (visibleExtensionDetail) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) { if (visibleExtensionStore) {
return setVisibleExtensionStore(false); if (visibleExtensionDetail && selectedExtension) {
const { name, icon } = selectedExtension;
return <MultilevelWrapper title={name} icon={icon} />;
} }
setSourceData(void 0); return <MultilevelWrapper title="Extensions Store" icon="font_Store" />;
}} }
/>
if (goAskAi && assistant) {
const { name, icon } = assistant;
return <MultilevelWrapper title={name} icon={icon} />;
}
if (sourceData) {
const { source } = sourceData;
const { name, icon } = source;
return <MultilevelWrapper title={name} icon={icon} />;
}
if (viewExtensionOpened) {
const { title, icon } = viewExtensionOpened[3];
const iconPath = icon ? platformAdapter.convertFileSrc(icon) : void 0;
return <MultilevelWrapper title={title} icon={iconPath} />;
}
return (
<div className="flex items-center justify-center pl-2 h-[40px] bg-[#ededed] dark:bg-[#202126]">
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />
</div>
); );
}
return <Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />;
}; };
if (lineCount === 1) { if (lineCount === 1) {

View File

@@ -1,22 +1,20 @@
/*
* ViewExtension.tsx
*
* View that will be rendered when opening a View extension.
*
*/
import React from "react"; import React from "react";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { ArrowLeft } from "lucide-react";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import {
import { ExtensionFileSystemPermission, FileSystemAccess } from "../Settings/Extensions"; ExtensionFileSystemPermission,
FileSystemAccess,
} from "../Settings/Extensions";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
const ViewExtension: React.FC = () => { const ViewExtension: React.FC = () => {
const { setViewExtensionOpened, viewExtensionOpened } = useSearchStore(); const { viewExtensionOpened } = useSearchStore();
const [pagePath, setPagePath] = useState<string>(""); const [page, setPage] = useState<string>("");
// Complete list of the backend APIs, grouped by their category. // Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null); const [apis, setApis] = useState<Map<string, string[]> | null>(null);
const { setModifierKeyPressed } = useShortcutsStore();
if (viewExtensionOpened == null) { if (viewExtensionOpened == null) {
// When this view gets loaded, this state should not be NULL. // When this view gets loaded, this state should not be NULL.
@@ -30,9 +28,13 @@ const ViewExtension: React.FC = () => {
useEffect(() => { useEffect(() => {
const setupFileUrl = async () => { const setupFileUrl = async () => {
// The check above ensures viewExtensionOpened is not null here. // The check above ensures viewExtensionOpened is not null here.
const filePath = viewExtensionOpened[0]; const page = viewExtensionOpened[0];
if (filePath) {
setPagePath(convertFileSrc(filePath)); // 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));
} }
}; };
@@ -41,9 +43,13 @@ const ViewExtension: React.FC = () => {
// invoke `apis()` and set the state // invoke `apis()` and set the state
useEffect(() => { useEffect(() => {
setModifierKeyPressed(false);
const fetchApis = async () => { const fetchApis = async () => {
try { try {
const availableApis = await invoke("apis") as Record<string, string[]>; const availableApis = (await platformAdapter.invokeBackend(
"apis"
)) as Record<string, string[]>;
setApis(new Map(Object.entries(availableApis))); setApis(new Map(Object.entries(availableApis)));
} catch (error) { } catch (error) {
console.error("Failed to fetch APIs:", error); console.error("Failed to fetch APIs:", error);
@@ -53,10 +59,6 @@ const ViewExtension: React.FC = () => {
fetchApis(); fetchApis();
}, []); }, []);
const handleBack = () => {
setViewExtensionOpened(null);
};
// White list of the permission entries // White list of the permission entries
const permission = viewExtensionOpened[1]; const permission = viewExtensionOpened[1];
@@ -108,10 +110,10 @@ const ViewExtension: React.FC = () => {
const category = reversedApis.get(command)!; const category = reversedApis.get(command)!;
var api = null; var api = null;
if (permission == null) { if (permission == null) {
api = null api = null;
} else { } else {
api = permission.api api = permission.api;
}; }
if (!apiPermissionCheck(category, command, api)) { if (!apiPermissionCheck(category, command, api)) {
source.postMessage( source.postMessage(
{ {
@@ -126,10 +128,10 @@ const ViewExtension: React.FC = () => {
var fs = null; var fs = null;
if (permission == null) { if (permission == null) {
fs = null fs = null;
} else { } else {
fs = permission.fs fs = permission.fs;
}; }
if (!(await fsPermissionCheck(command, event.data, fs))) { if (!(await fsPermissionCheck(command, event.data, fs))) {
source.postMessage( source.postMessage(
{ {
@@ -145,7 +147,12 @@ const ViewExtension: React.FC = () => {
if (command === "read_dir") { if (command === "read_dir") {
const { path } = event.data; const { path } = event.data;
try { try {
const fileNames: [String] = await invoke("read_dir", { path: path }); const fileNames: [String] = await platformAdapter.invokeBackend(
"read_dir",
{
path: path,
}
);
source.postMessage( source.postMessage(
{ {
id, id,
@@ -171,49 +178,41 @@ const ViewExtension: React.FC = () => {
console.info("Coco extension API listener is up"); console.info("Coco extension API listener is up");
return () => { return () => {
window.removeEventListener('message', messageHandler); window.removeEventListener("message", messageHandler);
}; };
}, [reversedApis, permission]); // Add apiPermissions as dependency }, [reversedApis, permission]); // Add apiPermissions as dependency
return ( return (
<div className="h-full w-full flex flex-col">
{/* Header with back button */}
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
<button
onClick={handleBack}
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-600 dark:text-gray-300"
>
<ArrowLeft size={20} />
<span>Back to Search</span>
</button>
</div>
{/* Main content */}
<div className="flex-1">
<iframe <iframe
src={pagePath} src={page}
className="w-full h-full border-0" className="w-full h-full border-0"
> onLoad={(event) => {
</iframe> event.currentTarget.focus();
</div> }}
</div> />
); );
}; };
export default ViewExtension; export default ViewExtension;
// Permission check function - TypeScript translation of Rust function // Permission check function - TypeScript translation of Rust function
const apiPermissionCheck = (category: string, api: string, allowedApis: string[] | null): boolean => { const apiPermissionCheck = (
category: string,
api: string,
allowedApis: string[] | null
): boolean => {
if (!allowedApis) { if (!allowedApis) {
return false; return false;
} }
const qualifiedApi = `${category}:${api}`; const qualifiedApi = `${category}:${api}`;
return allowedApis.some(a => a === qualifiedApi); return allowedApis.some((a) => a === qualifiedApi);
}; };
const extractFsAccessPattern = (command: string, requestPayload: any): [string, FileSystemAccess] => { const extractFsAccessPattern = (
command: string,
requestPayload: any
): [string, FileSystemAccess] => {
switch (command) { switch (command) {
case "read_dir": { case "read_dir": {
const { path } = requestPayload; const { path } = requestPayload;
@@ -224,21 +223,27 @@ const extractFsAccessPattern = (command: string, requestPayload: any): [string,
throw new Error(`unknown command ${command}`); throw new Error(`unknown command ${command}`);
} }
} }
} };
const fsPermissionCheck = async (command: string, requestPayload: any, fsPermission: ExtensionFileSystemPermission[] | null): Promise<boolean> => { const fsPermissionCheck = async (
command: string,
requestPayload: any,
fsPermission: ExtensionFileSystemPermission[] | null
): Promise<boolean> => {
if (!fsPermission) { if (!fsPermission) {
return false; return false;
} }
const [ path, access ] = extractFsAccessPattern(command, requestPayload); const [path, access] = extractFsAccessPattern(command, requestPayload);
const clean_path = await invoke("path_absolute", { path: path }); const clean_path = await platformAdapter.invokeBackend("path_absolute", {
path: path,
});
// Walk through fsPermission array to find matching paths // Walk through fsPermission array to find matching paths
for (const permission of fsPermission) { for (const permission of fsPermission) {
if (permission.path === clean_path) { if (permission.path === clean_path) {
// Check if all required access permissions are included in the permission's access array // Check if all required access permissions are included in the permission's access array
const hasAllRequiredAccess = access.every(requiredAccess => const hasAllRequiredAccess = access.every((requiredAccess) =>
permission.access.includes(requiredAccess) permission.access.includes(requiredAccess)
); );
@@ -249,4 +254,4 @@ const fsPermissionCheck = async (command: string, requestPayload: any, fsPermiss
} }
return false; return false;
} };

View File

@@ -21,14 +21,17 @@ import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer"; import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents"; import { useWindowEvents } from "@/hooks/useWindowEvents";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { useStartupStore } from "@/stores/startupStore"; import { useStartupStore } from "@/stores/startupStore";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useAppearanceStore } from "@/stores/appearanceStore"; import { useAppearanceStore } from "@/stores/appearanceStore";
import type { StartPage } from "@/types/chat"; import type { StartPage } from "@/types/chat";
import { hasUploadingAttachment } from "@/utils"; import {
hasUploadingAttachment,
visibleFilterBar,
visibleSearchBar,
} from "@/utils";
interface SearchChatProps { interface SearchChatProps {
isTauri?: boolean; isTauri?: boolean;
@@ -105,7 +108,6 @@ function SearchChat({
const [isWin10, setIsWin10] = useState(false); const [isWin10, setIsWin10] = useState(false);
const blurred = useAppStore((state) => state.blurred); const blurred = useAppStore((state) => state.blurred);
const { viewExtensionOpened } = useSearchStore();
useWindowEvents(); useWindowEvents();
@@ -289,14 +291,15 @@ function SearchChat({
</Suspense> </Suspense>
</div> </div>
{/* We don't want this inputbox when rendering View extensions */}
{/* TODO: figure out a better way to disable this inputbox */}
{!viewExtensionOpened && (
<div <div
data-tauri-drag-region={isTauri} data-tauri-drag-region={isTauri}
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${ className={clsx(
isTransitioned ? "border-t" : "border-b" "p-2 w-full flex justify-center transition-all duration-500 border-[#E6E6E6] dark:border-[#272626]",
} border-[#E6E6E6] dark:border-[#272626]`} [isTransitioned ? "border-t" : "border-b"],
{
"min-h-[82px]": visibleSearchBar() && visibleFilterBar(),
}
)}
> >
<InputBox <InputBox
isChatMode={isChatMode} isChatMode={isChatMode}
@@ -327,7 +330,6 @@ function SearchChat({
chatPlaceholder={chatPlaceholder} chatPlaceholder={chatPlaceholder}
/> />
</div> </div>
)}
<div <div
data-tauri-drag-region={isTauri} data-tauri-drag-region={isTauri}

View File

@@ -1,55 +1,135 @@
import { Globe, Github } from "lucide-react"; import {
Globe,
Github,
Rocket,
BookOpen,
MessageCircleReply,
ScrollText,
SquareArrowOutUpRight,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { OpenURLWithBrowser } from "@/utils"; import { OpenURLWithBrowser } from "@/utils";
import logoLight from "@/assets/images/logo-text-light.svg"; import lightLogo from "@/assets/images/logo-text-light.svg";
import logoDark from "@/assets/images/logo-text-dark.svg"; import darkLogo from "@/assets/images/logo-text-dark.svg";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import { cloneElement, ReactElement, useMemo } from "react";
import platformAdapter from "@/utils/platformAdapter";
interface Link {
icon: ReactElement;
label: string;
url?: string;
onPress?: () => void;
}
export default function AboutView() { export default function AboutView() {
const { t } = useTranslation(); const { t } = useTranslation();
const isDark = useThemeStore((state) => state.isDark); const { isDark } = useThemeStore();
const logo = isDark ? logoDark : logoLight; const links = useMemo<Link[]>(() => {
return [
{
icon: <Rocket />,
label: t("settings.about.labels.changelog"),
url: "https://coco.rs/en/roadmap",
},
{
icon: <BookOpen />,
label: t("settings.about.labels.docs"),
url: "https://docs.infinilabs.com/coco-app/main",
},
{
icon: <Github />,
label: "GitHub",
url: "https://github.com/infinilabs/coco-app",
},
{
icon: <Globe />,
label: t("settings.about.labels.officialWebsite"),
url: "https://coco.rs",
},
{
icon: <MessageCircleReply />,
label: t("settings.about.labels.submitFeedback"),
url: "https://github.com/infinilabs/coco-app/issues",
},
{
icon: <ScrollText />,
label: t("settings.about.labels.runningLog"),
onPress: platformAdapter.openLogDir,
},
];
}, [t]);
const handleClick = (link: Link) => {
const { url, onPress } = link;
if (url) {
return OpenURLWithBrowser(url);
}
onPress?.();
};
return ( return (
<div className="flex justify-center items-center flex-col h-[calc(100vh-170px)]"> <div className="flex h-[calc(100vh-170px)]">
<div> <div className="flex flex-col items-center justify-center w-[70%] pr-10 text-[#999] text-sm">
<img <img
src={logo} src={isDark ? darkLogo : lightLogo}
className="w-48 dark:text-white" className="h-14"
alt={t("settings.about.logo")} alt={t("settings.about.logo")}
/> />
</div>
<div className="mt-8 font-medium text-gray-900 dark:text-gray-100"> <div className="mt-4 text-base font-medium text-[#333] dark:text-white/80">
{t("settings.about.slogan")} {t("settings.about.slogan")}
</div> </div>
<div className="flex justify-center items-center mt-10">
<button <div className="mt-10">
onClick={() => OpenURLWithBrowser("https://coco.rs")}
className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
aria-label={t("settings.about.website")}
>
<Globe className="w-3 text-blue-500" />
</button>
<button
onClick={() =>
OpenURLWithBrowser("https://github.com/infinilabs/coco-app")
}
className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
aria-label={t("settings.about.github")}
>
<Github className="w-3 text-blue-500" />
</button>
</div>
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
{t("settings.about.version", { {t("settings.about.version", {
version: process.env.VERSION || "N/A", version: process.env.VERSION || "N/A",
})} })}
</div> </div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
<div className="mt-3">
{t("settings.about.copyright", { year: new Date().getFullYear() })} {t("settings.about.copyright", { year: new Date().getFullYear() })}
</div> </div>
</div> </div>
<div className="flex-1 flex flex-col items-center justify-center gap-4 pl-10 border-l border-[#e5e5e5] dark:border-[#4e4e56]">
{links.map((item) => {
const { icon, label } = item;
return (
<div
key={label}
className="flex items-center justify-between w-full"
>
<div className="flex items-center gap-2">
{cloneElement(icon, {
className: "size-4 text-[#999]",
})}
<span
className="text-[#333] dark:text-white/80 cursor-pointer hover:text-[#027FFE] transition"
onClick={() => {
handleClick(item);
}}
>
{label}
</span>
</div>
<SquareArrowOutUpRight
className="text-[#027FFE] size-4 cursor-pointer"
onClick={() => {
handleClick(item);
}}
/>
</div>
);
})}
</div>
</div>
); );
} }

View File

@@ -328,7 +328,7 @@ const Item: FC<ItemProps> = (props) => {
) : ( ) : (
<img <img
src={platformAdapter.convertFileSrc(icon)} src={platformAdapter.convertFileSrc(icon)}
className="size-full" className="size-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
/> />
)} )}
</div> </div>

View File

@@ -62,6 +62,12 @@ export interface ExtensionPermission {
api: string[] | null; api: string[] | null;
} }
export interface ViewExtensionUISettings {
search_bar: boolean,
filter_bar: boolean,
footer: boolean,
}
export interface Extension { export interface Extension {
id: ExtensionId; id: ExtensionId;
type: ExtensionType; type: ExtensionType;

View File

@@ -29,7 +29,8 @@ export function useChatActions(
isDeepThinkActive?: boolean, isDeepThinkActive?: boolean,
isMCPActive?: boolean, isMCPActive?: boolean,
changeInput?: (val: string) => void, changeInput?: (val: string) => void,
showChatHistory?: boolean showChatHistory?: boolean,
getChatHistoryChatPage?: () => void,
) { ) {
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin); const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
@@ -197,17 +198,17 @@ export function useChatActions(
) { ) {
try { try {
const response = JSON.parse(msg); const response = JSON.parse(msg);
console.log("first", response); // console.log("first", response);
let updatedChat: Chat; let updatedChat: Chat;
if (Array.isArray(response)) { if (Array.isArray(response)) {
curIdRef.current = response[0]?._id; curIdRef.current = response[0]?._id;
curSessionIdRef.current = response[0]?._source?.session_id; curSessionIdRef.current = response[0]?._source?.session_id;
console.log( // console.log(
"curIdRef-curSessionIdRef-Array", // "curIdRef-curSessionIdRef-Array",
curIdRef.current, // curIdRef.current,
curSessionIdRef.current // curSessionIdRef.current
); // );
updatedChat = { updatedChat = {
...updatedChatRef.current, ...updatedChatRef.current,
messages: [ messages: [
@@ -215,16 +216,16 @@ export function useChatActions(
...(response || []), ...(response || []),
], ],
}; };
console.log("array", updatedChat, updatedChatRef.current?.messages); // console.log("array", updatedChat, updatedChatRef.current?.messages);
} else { } else {
const newChat: Chat = response; const newChat: Chat = response;
curIdRef.current = response?.payload?.id; curIdRef.current = response?.payload?.id;
curSessionIdRef.current = response?.payload?.session_id; curSessionIdRef.current = response?.payload?.session_id;
console.log( // console.log(
"curIdRef-curSessionIdRef", // "curIdRef-curSessionIdRef",
curIdRef.current, // curIdRef.current,
curSessionIdRef.current // curSessionIdRef.current
); // );
newChat._source = { newChat._source = {
...response?.payload, ...response?.payload,
@@ -252,7 +253,7 @@ export function useChatActions(
async (timestamp: number) => { async (timestamp: number) => {
cleanupListeners(); cleanupListeners();
console.log("setupListeners", clientId, timestamp); // console.log("setupListeners", clientId, timestamp);
const unlisten_chat_message = await platformAdapter.listenEvent( const unlisten_chat_message = await platformAdapter.listenEvent(
`chat-stream-${clientId}-${timestamp}`, `chat-stream-${clientId}-${timestamp}`,
(event) => { (event) => {
@@ -300,12 +301,45 @@ export function useChatActions(
[setupListeners] [setupListeners]
); );
const getChatHistory = useCallback(async () => {
let response: any;
if (isTauri) {
if (await unrequitable()) {
return setChats([]);
}
response = await platformAdapter.commands("chat_history", {
serverId: currentService?.id,
from: 0,
size: 100,
query: keyword,
});
response = response ? JSON.parse(response) : null;
} else {
const [_error, res] = await Get(`/chat/_history`, {
from: 0,
size: 100,
});
response = res;
}
const hits = response?.hits?.hits || [];
setChats(hits);
}, [
currentService?.id,
keyword,
isTauri,
currentService?.enabled,
isCurrentLogin,
]);
const createNewChat = useCallback( const createNewChat = useCallback(
async (params?: SendMessageParams) => { async (params?: SendMessageParams) => {
const { message, attachments } = params || {}; const { message, attachments } = params || {};
console.log("message", message); // console.log("message", message);
console.log("attachments", attachments); // console.log("attachments", attachments);
if (!message && isEmpty(attachments)) return; if (!message && isEmpty(attachments)) return;
@@ -325,7 +359,7 @@ export function useChatActions(
if (isTauri) { if (isTauri) {
if (!currentService?.id) return; if (!currentService?.id) return;
console.log("chat_create", clientId, timestamp); // console.log("chat_create", clientId, timestamp);
await platformAdapter.commands("chat_create", { await platformAdapter.commands("chat_create", {
serverId: currentService?.id, serverId: currentService?.id,
@@ -334,7 +368,7 @@ export function useChatActions(
queryParams, queryParams,
clientId: `chat-stream-${clientId}-${timestamp}`, clientId: `chat-stream-${clientId}-${timestamp}`,
}); });
console.log("_create end", message); // console.log("_create end", message);
resetChatState(); resetChatState();
} else { } else {
await streamPost({ await streamPost({
@@ -342,12 +376,17 @@ export function useChatActions(
body: { message }, body: { message },
queryParams, queryParams,
onMessage: (line) => { onMessage: (line) => {
console.log("⏳", line); // console.log("⏳", line);
handleChatCreateStreamMessage(line); handleChatCreateStreamMessage(line);
// append to chat box // append to chat box
}, },
}); });
} }
// console.log("showChatHistory", showChatHistory);
if (showChatHistory) {
getChatHistoryChatPage ? getChatHistoryChatPage() : getChatHistory();
}
}, },
[ [
isTauri, isTauri,
@@ -360,6 +399,8 @@ export function useChatActions(
currentAssistant, currentAssistant,
chatClose, chatClose,
clientId, clientId,
showChatHistory,
getChatHistory,
] ]
); );
@@ -386,7 +427,7 @@ export function useChatActions(
if (isTauri) { if (isTauri) {
if (!currentService?.id) return; if (!currentService?.id) return;
console.log("chat_chat", clientId, timestamp); // console.log("chat_chat", clientId, timestamp);
await platformAdapter.commands("chat_chat", { await platformAdapter.commands("chat_chat", {
serverId: currentService?.id, serverId: currentService?.id,
sessionId: newChat?._id, sessionId: newChat?._id,
@@ -395,7 +436,7 @@ export function useChatActions(
attachments, attachments,
clientId: `chat-stream-${clientId}-${timestamp}`, clientId: `chat-stream-${clientId}-${timestamp}`,
}); });
console.log("chat_chat end", message, clientId); // console.log("chat_chat end", message, clientId);
resetChatState(); resetChatState();
} else { } else {
await streamPost({ await streamPost({
@@ -403,7 +444,7 @@ export function useChatActions(
body: { message }, body: { message },
queryParams, queryParams,
onMessage: (line) => { onMessage: (line) => {
console.log("line", line); // console.log("line", line);
handleChatCreateStreamMessage(line); handleChatCreateStreamMessage(line);
// append to chat box // append to chat box
}, },
@@ -468,39 +509,6 @@ export function useChatActions(
[currentService?.id, isTauri] [currentService?.id, isTauri]
); );
const getChatHistory = useCallback(async () => {
let response: any;
if (isTauri) {
if (await unrequitable()) {
return setChats([]);
}
response = await platformAdapter.commands("chat_history", {
serverId: currentService?.id,
from: 0,
size: 100,
query: keyword,
});
response = response ? JSON.parse(response) : null;
} else {
const [_error, res] = await Get(`/chat/_history`, {
from: 0,
size: 100,
});
response = res;
}
const hits = response?.hits?.hits || [];
setChats(hits);
}, [
currentService?.id,
keyword,
isTauri,
currentService?.enabled,
isCurrentLogin,
]);
useEffect(() => { useEffect(() => {
if (showChatHistory) { if (showChatHistory) {
getChatHistory(); getChatHistory();

160
src/hooks/useChatPanel.ts Normal file
View File

@@ -0,0 +1,160 @@
import { useCallback, useState } from "react";
import type { Chat } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { unrequitable } from "@/utils";
export function useChatPanel() {
const {
assistantList,
setCurrentAssistant,
setVisibleStartPage,
currentService,
} = useConnectStore();
const [chats, setChats] = useState<Chat[]>([]);
const [activeChat, setActiveChat] = useState<Chat | undefined>();
const [keyword, setKeyword] = useState("");
const getChatHistory = useCallback(async () => {
try {
if (await unrequitable()) {
return setChats([]);
}
let response: any = await platformAdapter.commands("chat_history", {
serverId: currentService?.id,
from: 0,
size: 100,
query: keyword,
});
response = response ? JSON.parse(response) : null;
const hits = response?.hits?.hits || [];
setChats(hits);
} catch (error) {
console.error("chat_history:", error);
}
}, [keyword, currentService?.id]);
const chatHistory = useCallback(
async (chat: Chat) => {
try {
let response: any = await platformAdapter.commands(
"session_chat_history",
{
serverId: currentService?.id,
sessionId: chat?._id || "",
from: 0,
size: 500,
}
);
response = response ? JSON.parse(response) : null;
const hits = response?.hits?.hits || [];
// set current assistant based on last message
const lastAssistantId = hits[hits.length - 1]?._source?.assistant_id;
const matchedAssistant = assistantList?.find(
(assistant) => assistant._id === lastAssistantId
);
if (matchedAssistant) {
setCurrentAssistant(matchedAssistant);
}
const updatedChat: Chat = {
...chat,
messages: hits,
};
setActiveChat(updatedChat);
} catch (error) {
console.error("session_chat_history:", error);
}
},
[assistantList, currentService?.id, setCurrentAssistant]
);
const onSelectChat = useCallback(
async (chat: any) => {
try {
let response: any = await platformAdapter.commands(
"open_session_chat",
{
serverId: currentService?.id,
sessionId: chat?._id,
}
);
response = response ? JSON.parse(response) : null;
chatHistory(response);
setVisibleStartPage(false);
} catch (error) {
console.error("open_session_chat:", error);
}
},
[currentService?.id, chatHistory, setVisibleStartPage]
);
const deleteChat = useCallback(
async (chatId: string) => {
if (!currentService?.id) return;
await platformAdapter.commands(
"delete_session_chat",
currentService.id,
chatId
);
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId);
setActiveChat(remainingChats[0]);
}
},
[currentService?.id, activeChat?._id, chats]
);
const handleSearch = useCallback((kw: string) => {
setKeyword(kw);
}, []);
const handleRename = useCallback(
(chatId: string, title: string) => {
if (!currentService?.id) return;
setChats((prev) => {
const updatedChats = prev.map((item) => {
if (item._id !== chatId) return item;
return { ...item, _source: { ...item._source, title } };
});
return updatedChats;
});
if (activeChat?._id === chatId) {
setActiveChat((prev) => {
if (!prev) return prev;
return { ...prev, _source: { ...prev._source, title } };
});
}
platformAdapter.commands("update_session_chat", {
serverId: currentService.id,
sessionId: chatId,
title,
});
},
[currentService?.id, activeChat?._id]
);
return {
chats,
setChats,
activeChat,
setActiveChat,
keyword,
setKeyword,
getChatHistory,
chatHistory,
onSelectChat,
deleteChat,
handleSearch,
handleRename,
};
}

View File

@@ -4,6 +4,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import type { QueryHits, SearchDocument } from "@/types/search"; import type { QueryHits, SearchDocument } from "@/types/search";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { isNumber } from "lodash-es";
interface UseKeyboardNavigationProps { interface UseKeyboardNavigationProps {
suggests: QueryHits[]; suggests: QueryHits[];
@@ -16,6 +17,7 @@ interface UseKeyboardNavigationProps {
handleItemAction: (item: SearchDocument) => void; handleItemAction: (item: SearchDocument) => void;
isChatMode: boolean; isChatMode: boolean;
formatUrl?: (item: any) => string; formatUrl?: (item: any) => string;
searchData: Record<string, QueryHits[]>;
} }
export function useKeyboardNavigation({ export function useKeyboardNavigation({
@@ -29,6 +31,7 @@ export function useKeyboardNavigation({
handleItemAction, handleItemAction,
isChatMode, isChatMode,
formatUrl, formatUrl,
searchData,
}: UseKeyboardNavigationProps) { }: UseKeyboardNavigationProps) {
const openPopover = useShortcutsStore((state) => state.openPopover); const openPopover = useShortcutsStore((state) => state.openPopover);
const visibleContextMenu = useSearchStore((state) => { const visibleContextMenu = useSearchStore((state) => {
@@ -47,6 +50,20 @@ export function useKeyboardNavigation({
return metaKeyPressed || ctrlKeyPressed || altKeyPressed; return metaKeyPressed || ctrlKeyPressed || altKeyPressed;
}; };
const getGroupContext = () => {
const groupEntries = Object.entries(searchData);
const groupIndex = groupEntries.findIndex(([_, value]) => {
return value.some((item) => {
return item.document.index === selectedIndex;
});
});
return {
groupEntries,
groupIndex,
};
};
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (isChatMode || !suggests.length || openPopover || visibleContextMenu) { if (isChatMode || !suggests.length || openPopover || visibleContextMenu) {
@@ -59,13 +76,28 @@ export function useKeyboardNavigation({
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
// console.log("ArrowUp pressed", selectedIndex, suggests.length);
let nextIndex: number | undefined = void 0;
if (modifierKeyPressed) {
const { groupEntries, groupIndex } = getGroupContext();
const nextGroupIndex =
groupIndex > 0 ? groupIndex - 1 : groupEntries.length - 1;
nextIndex = groupEntries[nextGroupIndex][1][0].document.index;
}
setSelectedIndex((prev) => { setSelectedIndex((prev) => {
if (prev == null) { if (prev == null) {
return Math.min(...indexes); return Math.min(...indexes);
} }
const nextIndex = prev - 1; if (isNumber(nextIndex)) {
return nextIndex;
}
nextIndex = prev - 1;
if (indexes.includes(nextIndex)) { if (indexes.includes(nextIndex)) {
return nextIndex; return nextIndex;
@@ -75,13 +107,28 @@ export function useKeyboardNavigation({
}); });
} else if (e.key === "ArrowDown") { } else if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
//console.log("ArrowDown pressed", selectedIndex, suggests.length);
let nextIndex: number | undefined = void 0;
if (modifierKeyPressed) {
const { groupEntries, groupIndex } = getGroupContext();
const nextGroupIndex =
groupIndex < groupEntries.length - 1 ? groupIndex + 1 : 0;
nextIndex = groupEntries[nextGroupIndex][1][0].document.index;
}
setSelectedIndex((prev) => { setSelectedIndex((prev) => {
if (prev == null) { if (prev == null) {
return Math.min(...indexes); return Math.min(...indexes);
} }
const nextIndex = prev + 1; if (isNumber(nextIndex)) {
return nextIndex;
}
nextIndex = prev + 1;
if (indexes.includes(nextIndex)) { if (indexes.includes(nextIndex)) {
return nextIndex; return nextIndex;

View File

@@ -35,7 +35,7 @@ export function useMessageHandler(
} }
messageTimeoutRef.current = setTimeout(() => { messageTimeoutRef.current = setTimeout(() => {
console.log("AI response timeout"); // console.log("AI response timeout");
setTimedoutShow(true); setTimedoutShow(true);
onCancel(); onCancel();
}, (connectionTimeout ?? 120) * 1000); }, (connectionTimeout ?? 120) * 1000);
@@ -108,7 +108,7 @@ export function useMessageHandler(
clearTimeout(messageTimeoutRef.current); clearTimeout(messageTimeoutRef.current);
} }
setCurChatEnd(true); setCurChatEnd(true);
console.log("AI finished output"); // console.log("AI finished output");
return; return;
} }
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
import { debounce } from "lodash-es"; import { debounce, orderBy } from "lodash-es";
import type { import type {
QueryHits, QueryHits,
@@ -65,7 +65,9 @@ export function useSearch() {
response: MultiSourceQueryResponse, response: MultiSourceQueryResponse,
searchInput: string searchInput: string
) => { ) => {
const data = response?.hits || []; const hits = response?.hits ?? [];
const data = orderBy(hits, "score", "desc");
const searchData = data.reduce( const searchData = data.reduce(
(acc: SearchDataBySource, item: QueryHits) => { (acc: SearchDataBySource, item: QueryHits) => {
@@ -196,7 +198,7 @@ export function useSearch() {
} }
} }
console.log("_suggest", searchInput, response); //console.log("_suggest", searchInput, response);
if (timerRef.current) { if (timerRef.current) {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);

View File

@@ -11,10 +11,6 @@ import {
export const useServers = () => { export const useServers = () => {
const setServerList = useConnectStore((state) => state.setServerList); const setServerList = useConnectStore((state) => state.setServerList);
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => {
return state.cloudSelectService;
});
const getAllServerList = async () => { const getAllServerList = async () => {
try { try {
@@ -30,6 +26,8 @@ export const useServers = () => {
} catch (error) { } catch (error) {
console.error("Failed to fetch server list:", error); console.error("Failed to fetch server list:", error);
setServerList([]); setServerList([]);
} finally {
} }
}; };
@@ -73,7 +71,7 @@ export const useServers = () => {
await setCurrentWindowService({ ...service, enabled }); await setCurrentWindowService({ ...service, enabled });
await getAllServerList(); await getAllServerList();
}, },
[currentService, cloudSelectService] []
); );
const removeServer = useCallback( const removeServer = useCallback(
@@ -81,7 +79,7 @@ export const useServers = () => {
await platformAdapter.commands("remove_coco_server", id); await platformAdapter.commands("remove_coco_server", id);
await getAllServerList(); await getAllServerList();
}, },
[currentService?.id, cloudSelectService?.id] []
); );
const logoutServer = useCallback(async (id: string) => { const logoutServer = useCallback(async (id: string) => {
@@ -92,7 +90,7 @@ export const useServers = () => {
useEffect(() => { useEffect(() => {
getAllServerList(); getAllServerList();
}, [currentService?.enabled, cloudSelectService?.enabled]); }, []);
return { return {
getAllServerList, getAllServerList,

View File

@@ -68,7 +68,7 @@ export default function useSettingsWindow() {
useEffect(() => { useEffect(() => {
const unlisten = platformAdapter.listenEvent("open_settings", async (event) => { const unlisten = platformAdapter.listenEvent("open_settings", async (event) => {
console.log("open_settings event received:", event); //console.log("open_settings event received:", event);
const tab = event.payload as string | ""; const tab = event.payload as string | "";
platformAdapter.emitEvent("tab_index", tab); platformAdapter.emitEvent("tab_index", tab);

View File

@@ -52,7 +52,7 @@ export const useStreamChat = (options: Options) => {
unlistenRef.current = await platformAdapter.listenEvent( unlistenRef.current = await platformAdapter.listenEvent(
clientId, clientId,
({ payload }) => { ({ payload }) => {
console.log(clientId, JSON.parse(payload)); //console.log(clientId, JSON.parse(payload));
const chunkData = JSON.parse(payload); const chunkData = JSON.parse(payload);

View File

@@ -117,7 +117,8 @@ export const useSyncStore = () => {
const setShowTooltip = useAppStore((state) => state.setShowTooltip); const setShowTooltip = useAppStore((state) => state.setShowTooltip);
const setEndpoint = useAppStore((state) => state.setEndpoint); const setEndpoint = useAppStore((state) => state.setEndpoint);
const setLanguage = useAppStore((state) => state.setLanguage); const setLanguage = useAppStore((state) => state.setLanguage);
const { setCurrentService } = useConnectStore();
const setServerListSilently = useConnectStore((state) => state.setServerListSilently);
useEffect(() => { useEffect(() => {
if (!resetFixedWindow) { if (!resetFixedWindow) {
@@ -185,7 +186,6 @@ export const useSyncStore = () => {
connectionTimeout, connectionTimeout,
querySourceTimeout, querySourceTimeout,
allowSelfSignature, allowSelfSignature,
currentService,
} = payload; } = payload;
if (isNumber(connectionTimeout)) { if (isNumber(connectionTimeout)) {
setConnectionTimeout(connectionTimeout); setConnectionTimeout(connectionTimeout);
@@ -194,7 +194,6 @@ export const useSyncStore = () => {
setQueryTimeout(querySourceTimeout); setQueryTimeout(querySourceTimeout);
} }
setAllowSelfSignature(allowSelfSignature); setAllowSelfSignature(allowSelfSignature);
setCurrentService(currentService);
}), }),
platformAdapter.listenEvent("change-appearance-store", ({ payload }) => { platformAdapter.listenEvent("change-appearance-store", ({ payload }) => {
@@ -235,6 +234,10 @@ export const useSyncStore = () => {
setEndpoint(endpoint); setEndpoint(endpoint);
setLanguage(language); setLanguage(language);
}), }),
platformAdapter.listenEvent("server-list-changed", ({ payload }) => {
setServerListSilently(payload);
}),
]); ]);
return () => { return () => {

View File

@@ -39,7 +39,14 @@
"website": "Visit Website", "website": "Visit Website",
"github": "Visit GitHub", "github": "Visit GitHub",
"version": "Version {{version}}", "version": "Version {{version}}",
"copyright": "©{{year}} INFINI Labs, All Rights Reserved." "copyright": "©{{year}} INFINI Labs, All Rights Reserved.",
"labels": {
"changelog": "Whats New",
"docs": "Docs",
"officialWebsite": "Official Website",
"submitFeedback": "Submit Feedback",
"runningLog": "View App Logs"
}
}, },
"advanced": { "advanced": {
"startup": { "startup": {

View File

@@ -39,7 +39,14 @@
"website": "访问官网", "website": "访问官网",
"github": "访问 GitHub", "github": "访问 GitHub",
"version": "版本 {{version}}", "version": "版本 {{version}}",
"copyright": "©{{year}} INFINI Labs保留所有权利。" "copyright": "©{{year}} INFINI Labs保留所有权利。",
"labels": {
"changelog": "更新日志",
"docs": "帮助文档",
"officialWebsite": "官方网站",
"submitFeedback": "提交反馈",
"runningLog": "运行日志"
}
}, },
"advanced": { "advanced": {
"startup": { "startup": {

View File

@@ -35,12 +35,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
setIsTauri(true); setIsTauri(true);
}, []); }, []);
const { const currentService = useConnectStore((state) => state.currentService);
setCurrentAssistant,
assistantList,
setVisibleStartPage,
currentService,
} = useConnectStore();
const chatAIRef = useRef<ChatAIRef>(null); const chatAIRef = useRef<ChatAIRef>(null);
@@ -77,7 +72,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
query: keyword, query: keyword,
}); });
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
console.log("_history", response); // console.log("_history", response);
const hits = response?.hits?.hits || []; const hits = response?.hits?.hits || [];
setChats(hits); setChats(hits);
} catch (error) { } catch (error) {
@@ -112,39 +107,6 @@ export default function StandaloneChat({}: StandaloneChatProps) {
chatAIRef.current?.init(params); chatAIRef.current?.init(params);
}; };
const chatHistory = async (chat: typeChat) => {
try {
let response: any = await platformAdapter.commands(
"session_chat_history",
{
serverId: currentService?.id,
sessionId: chat?._id || "",
from: 0,
size: 500,
}
);
response = response ? JSON.parse(response) : null;
console.log("id_history", response);
const hits = response?.hits?.hits || [];
// set current assistant
const lastAssistantId = hits[hits.length - 1]?._source?.assistant_id;
const matchedAssistant = assistantList?.find(
(assistant) => assistant._id === lastAssistantId
);
if (matchedAssistant) {
setCurrentAssistant(matchedAssistant);
}
//
const updatedChat: typeChat = {
...chat,
messages: hits,
};
setActiveChat(updatedChat);
} catch (error) {
console.error("session_chat_history:", error);
}
};
const chatClose = async () => { const chatClose = async () => {
if (!activeChat?._id) return; if (!activeChat?._id) return;
try { try {
@@ -153,26 +115,14 @@ export default function StandaloneChat({}: StandaloneChatProps) {
sessionId: activeChat?._id, sessionId: activeChat?._id,
}); });
response = response ? JSON.parse(response) : null; response = response ? JSON.parse(response) : null;
console.log("_close", response); // console.log("_close", response);
} catch (error) { } catch (error) {
console.error("close_session_chat:", error); console.error("close_session_chat:", error);
} }
}; };
const onSelectChat = async (chat: any) => { const onSelectChat = async (chat: any) => {
chatClose(); chatAIRef.current?.onSelectChat(chat);
try {
let response: any = await platformAdapter.commands("open_session_chat", {
serverId: currentService?.id,
sessionId: chat?._id,
});
response = response ? JSON.parse(response) : null;
console.log("_open", response);
chatHistory(response);
setVisibleStartPage(false);
} catch (error) {
console.error("open_session_chat:", error);
}
}; };
const cancelChat = async () => { const cancelChat = async () => {
@@ -317,6 +267,8 @@ export default function StandaloneChat({}: StandaloneChatProps) {
isChatPage={isChatPage} isChatPage={isChatPage}
getFileUrl={getFileUrl} getFileUrl={getFileUrl}
changeInput={setInput} changeInput={setInput}
showChatHistory={true}
getChatHistoryChatPage={getChatHistory}
/> />
</div> </div>

View File

@@ -20,10 +20,11 @@ function MainApp() {
// //
// Events will be sent when users try to open a View extension via hotkey, // Events will be sent when users try to open a View extension via hotkey,
// whose payload contains the needed information to load the View page. // whose payload contains the needed information to load the View page.
platformAdapter.listenEvent("open_view_extension", async ({ payload: view_extension_page_and_permission } ) => { platformAdapter.listenEvent("open_view_extension", async ({ payload }) => {
await platformAdapter.showWindow(); await platformAdapter.showWindow();
setViewExtensionOpened(view_extension_page_and_permission);
}) setViewExtensionOpened(payload);
});
}, []); }, []);
const { synthesizeItem } = useChatStore(); const { synthesizeItem } = useChatStore();

View File

@@ -15,6 +15,7 @@ type keyArrayObject = {
export type IConnectStore = { export type IConnectStore = {
serverList: Server[]; serverList: Server[];
setServerList: (servers: Server[]) => void; setServerList: (servers: Server[]) => void;
setServerListSilently: (servers: Server[]) => void;
currentService: Server; currentService: Server;
setCurrentService: (service: Server) => void; setCurrentService: (service: Server) => void;
cloudSelectService: Server; cloudSelectService: Server;
@@ -44,7 +45,15 @@ export const useConnectStore = create<IConnectStore>()(
persist( persist(
(set) => ({ (set) => ({
serverList: [], serverList: [],
setServerList: (serverList: Server[]) => { setServerList: async(serverList: Server[]) => {
set(
produce((draft) => {
draft.serverList = serverList;
})
);
await platformAdapter.emitEvent("server-list-changed", serverList);
},
setServerListSilently: (serverList: Server[]) => {
set( set(
produce((draft) => { produce((draft) => {
draft.serverList = serverList; draft.serverList = serverList;

View File

@@ -1,7 +1,8 @@
import { ExtensionId } from "@/components/Settings/Extensions";
import { create } from "zustand"; import { create } from "zustand";
import { persist, subscribeWithSelector } from "zustand/middleware"; import { persist, subscribeWithSelector } from "zustand/middleware";
import { ExtensionId } from "@/components/Settings/Extensions";
export type IExtensionsStore = { export type IExtensionsStore = {
quickAiAccessServer?: any; quickAiAccessServer?: any;
setQuickAiAccessServer: (quickAiAccessServer?: any) => void; setQuickAiAccessServer: (quickAiAccessServer?: any) => void;

View File

@@ -1,8 +1,19 @@
import { SearchExtensionItem } from "@/components/Search/ExtensionStore"; import { SearchExtensionItem } from "@/components/Search/ExtensionStore";
import { ExtensionPermission } from "@/components/Settings/Extensions"; import {
ExtensionPermission,
ViewExtensionUISettings,
} from "@/components/Settings/Extensions";
import { SearchDocument } from "@/types/search";
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
export type ViewExtensionOpened = [
string,
ExtensionPermission | null,
ViewExtensionUISettings | null,
SearchDocument
];
export type ISearchStore = { export type ISearchStore = {
sourceData: any; sourceData: any;
setSourceData: (sourceData: any) => void; setSourceData: (sourceData: any) => void;
@@ -45,10 +56,13 @@ export type ISearchStore = {
// When we open a View extension, we set this to a non-null value. // When we open a View extension, we set this to a non-null value.
// //
// The first array element is the path to the page that we should load, the // Arguments
// second element is the permission that this extension requires. //
viewExtensionOpened: [string, ExtensionPermission | null] | null; // The first array element is the path to the page that we should load
setViewExtensionOpened: (showViewExtension: [string, ExtensionPermission | null] | null) => void; // The second element is the permission that this extension requires.
// The third argument is the UI Settings
viewExtensionOpened?: ViewExtensionOpened;
setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void;
}; };
export const useSearchStore = create<ISearchStore>()( export const useSearchStore = create<ISearchStore>()(
@@ -114,7 +128,6 @@ export const useSearchStore = create<ISearchStore>()(
setVisibleExtensionDetail: (visibleExtensionDetail) => { setVisibleExtensionDetail: (visibleExtensionDetail) => {
return set({ visibleExtensionDetail }); return set({ visibleExtensionDetail });
}, },
viewExtensionOpened: null,
setViewExtensionOpened: (viewExtensionOpened) => { setViewExtensionOpened: (viewExtensionOpened) => {
return set({ viewExtensionOpened }); return set({ viewExtensionOpened });
}, },

View File

@@ -6,7 +6,8 @@ import { IStartupStore } from "@/stores/startupStore";
import { AppTheme } from "@/types/index"; import { AppTheme } from "@/types/index";
import { SearchDocument } from "./search"; import { SearchDocument } from "./search";
import { IAppStore } from "@/stores/appStore"; import { IAppStore } from "@/stores/appStore";
import { ExtensionPermission } from "@/components/Settings/Extensions"; import type { Server } from "@/types/server";
import { ViewExtensionOpened } from "@/stores/searchStore";
export interface EventPayloads { export interface EventPayloads {
"theme-changed": string; "theme-changed": string;
@@ -48,7 +49,8 @@ export interface EventPayloads {
"check-update": any; "check-update": any;
oauth_success: any; oauth_success: any;
extension_install_success: any; extension_install_success: any;
"open_view_extension": [string, ExtensionPermission]; open_view_extension: ViewExtensionOpened;
"server-list-changed": Server[];
} }
// Window operation interface // Window operation interface
@@ -127,6 +129,7 @@ export interface SystemOperations {
queryParams: string[] queryParams: string[]
) => Promise<any[]>; ) => Promise<any[]>;
fetchAssistant: (serverId: string, queryParams: string[]) => Promise<any>; fetchAssistant: (serverId: string, queryParams: string[]) => Promise<any>;
openLogDir: () => Promise<void>;
} }
// Base platform adapter interface // Base platform adapter interface

View File

@@ -7,6 +7,7 @@ import { useAppStore } from "@/stores/appStore";
import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants"; import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import { getCurrentWindowService } from "@/commands/windowService"; import { getCurrentWindowService } from "@/commands/windowService";
import { useSearchStore } from "@/stores/searchStore";
// 1 // 1
export async function copyToClipboard(text: string) { export async function copyToClipboard(text: string) {
@@ -212,3 +213,116 @@ export const getUploadedAttachmentsId = () => {
.map((item) => item.attachmentId) .map((item) => item.attachmentId)
.filter((id) => !isNil(id)); .filter((id) => !isNil(id));
}; };
export const canNavigateBack = () => {
const {
goAskAi,
visibleExtensionStore,
visibleExtensionDetail,
viewExtensionOpened,
sourceData,
} = useSearchStore.getState();
return (
goAskAi ||
visibleExtensionStore ||
visibleExtensionDetail ||
viewExtensionOpened ||
sourceData
);
};
export const navigateBack = () => {
const {
goAskAi,
visibleExtensionStore,
visibleExtensionDetail,
viewExtensionOpened,
setGoAskAi,
setVisibleExtensionDetail,
setVisibleExtensionStore,
setSourceData,
setViewExtensionOpened,
} = useSearchStore.getState();
if (goAskAi) {
return setGoAskAi(false);
}
if (visibleExtensionDetail) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
}
if (viewExtensionOpened) {
return setViewExtensionOpened(void 0);
}
setSourceData(void 0);
};
export const dispatchEvent = (
key: string,
keyCode: number,
selector?: string
) => {
let target: HTMLElement | Window = window;
if (isString(selector)) {
target = document.querySelector(selector) as HTMLElement;
if (document.activeElement === target) return;
target.focus();
}
const event = new KeyboardEvent("keydown", {
key,
code: key,
keyCode,
which: keyCode,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
};
export const visibleSearchBar = () => {
const { viewExtensionOpened, visibleExtensionDetail } =
useSearchStore.getState();
if (visibleExtensionDetail) return false;
if (isNil(viewExtensionOpened)) return true;
const [, , ui] = viewExtensionOpened;
return ui?.search_bar ?? true;
};
export const visibleFilterBar = () => {
const { viewExtensionOpened, visibleExtensionDetail } =
useSearchStore.getState();
if (visibleExtensionDetail) return false;
if (isNil(viewExtensionOpened)) return true;
const [, , ui] = viewExtensionOpened;
return ui?.filter_bar ?? true;
};
export const visibleFooterBar = () => {
const { viewExtensionOpened } = useSearchStore.getState();
if (isNil(viewExtensionOpened)) return true;
const [, , ui] = viewExtensionOpened;
return ui?.footer ?? true;
};

View File

@@ -37,7 +37,7 @@ export const KEY_SYMBOLS: Record<string, string> = {
// Special keys // Special keys
Space: "Space", Space: "Space",
space: "Space", space: "Space",
Enter: "", Enter: "↩︎",
Backspace: "⌫", Backspace: "⌫",
Delete: "Del", Delete: "Del",
Escape: "Esc", Escape: "Esc",

View File

@@ -1,6 +1,6 @@
import type { OpenDialogOptions } from "@tauri-apps/plugin-dialog"; import type { OpenDialogOptions } from "@tauri-apps/plugin-dialog";
import { isWindows10 } from "tauri-plugin-windows-version-api"; import { isWindows10 } from "tauri-plugin-windows-version-api";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { metadata } from "tauri-plugin-fs-pro-api"; import { metadata } from "tauri-plugin-fs-pro-api";
import { error } from "@tauri-apps/plugin-log"; import { error } from "@tauri-apps/plugin-log";
@@ -13,9 +13,8 @@ import {
import type { BasePlatformAdapter } from "@/types/platform"; import type { BasePlatformAdapter } from "@/types/platform";
import type { AppTheme } from "@/types/index"; import type { AppTheme } from "@/types/index";
import { useAppearanceStore } from "@/stores/appearanceStore"; import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, OpenURLWithBrowser } from "."; import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from ".";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { unrequitable } from "@/utils"; import { unrequitable } from "@/utils";
export interface TauriPlatformAdapter extends BasePlatformAdapter { export interface TauriPlatformAdapter extends BasePlatformAdapter {
@@ -24,6 +23,7 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
) => Promise<string | string[] | null>; ) => Promise<string | string[] | null>;
metadata: typeof metadata; metadata: typeof metadata;
error: typeof error; error: typeof error;
openLogDir: () => Promise<void>;
} }
// Create Tauri adapter functions // Create Tauri adapter functions
@@ -258,35 +258,12 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
console.log("openSearchItem", data); console.log("openSearchItem", data);
// Extension store needs to be opened in a different way // Extension store needs to be opened in a different way
if (data?.type === "AI Assistant" || data?.id === "Extension Store") { if (
const textarea = document.querySelector("#search-textarea"); data?.type === "AI Assistant" ||
data?.id === "Extension Store" ||
if (!(textarea instanceof HTMLTextAreaElement)) return; data?.category === "View"
) {
textarea.focus(); return dispatchEvent("Tab", 9);
const event = new KeyboardEvent("keydown", {
key: "Tab",
code: "Tab",
keyCode: 9,
which: 9,
bubbles: true,
cancelable: true,
});
return textarea.dispatchEvent(event);
}
// View extension should be handled separately as it needs frontend to open
// a page
const onOpened = data?.on_opened;
if (onOpened?.Extension?.ty?.View) {
const { setViewExtensionOpened } = useSearchStore.getState();
const viewData = onOpened.Extension.ty.View;
const extensionPermission = onOpened.Extension.permission;
setViewExtensionOpened([viewData.page, extensionPermission]);
return;
} }
const hideCoco = () => { const hideCoco = () => {
@@ -352,5 +329,13 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
const window = await windowWrapper.getWebviewWindow(); const window = await windowWrapper.getWebviewWindow();
return window.label; return window.label;
}, },
async openLogDir() {
const { revealItemInDir } = await import("@tauri-apps/plugin-opener");
const logDir: string = await invoke("app_log_dir");
revealItemInDir(logDir);
},
}; };
}; };

View File

@@ -7,6 +7,7 @@ export interface WebPlatformAdapter extends BasePlatformAdapter {
openFileDialog: (options: any) => Promise<string | string[] | null>; openFileDialog: (options: any) => Promise<string | string[] | null>;
metadata: (path: string, options: any) => Promise<Record<string, any>>; metadata: (path: string, options: any) => Promise<Record<string, any>>;
error: (message: string) => void; error: (message: string) => void;
openLogDir: () => Promise<void>;
} }
// Create Web adapter functions // Create Web adapter functions
@@ -261,17 +262,20 @@ export const createWebAdapter = (): WebPlatformAdapter => {
`/assistant/_search?${queryParams?.join("&")}`, `/assistant/_search?${queryParams?.join("&")}`,
undefined undefined
); );
if (error) { if (error) {
console.error("_search", error); console.error("_search", error);
return {}; return {};
} }
return res; return res;
}, },
async getCurrentWindowLabel() { async getCurrentWindowLabel() {
return "web"; return "web";
}, },
async openLogDir() {
console.log("openLogDir is not supported in web environment");
return Promise.resolve();
},
}; };
}; };

View File

@@ -1,11 +1,30 @@
import { defineConfig } from 'tsup'; import { defineConfig } from 'tsup';
import { writeFileSync, readFileSync } from 'fs'; import { writeFileSync, readFileSync, readdirSync, statSync } from 'fs';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
const projectPackageJson = JSON.parse( const projectPackageJson = JSON.parse(
readFileSync(join(__dirname, 'package.json'), 'utf-8') readFileSync(join(__dirname, 'package.json'), 'utf-8')
); );
function walk(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const name of entries) {
const full = join(dir, name);
const stat = statSync(full);
if (stat.isDirectory()) {
files.push(...walk(full));
} else {
files.push(full);
}
}
return files;
}
function hasTauriRefs(content: string): boolean {
return /@tauri-apps|tauri-plugin/i.test(content);
}
export default defineConfig({ export default defineConfig({
entry: ['src/pages/web/index.tsx'], entry: ['src/pages/web/index.tsx'],
format: ['esm'], format: ['esm'],
@@ -66,13 +85,25 @@ export default defineConfig({
outDir: 'out/search-chat', outDir: 'out/search-chat',
async onSuccess() { async onSuccess() {
const outDir = join(__dirname, 'out/search-chat');
const files = walk(outDir).filter(f => /\.(m?js|cjs)$/i.test(f));
const tauriFiles = files.filter(f => {
const content = readFileSync(f, 'utf-8');
return hasTauriRefs(content);
});
if (tauriFiles.length) {
throw new Error(
`Build output contains Tauri references:\n${tauriFiles.map(f => ` - ${f}`).join('\n')}`
);
}
const projectPackageJson = JSON.parse( const projectPackageJson = JSON.parse(
readFileSync(join(__dirname, 'package.json'), 'utf-8') readFileSync(join(__dirname, 'package.json'), 'utf-8')
); );
const packageJson = { const packageJson = {
name: "@infinilabs/search-chat", name: "@infinilabs/search-chat",
version: "1.2.38", version: "1.2.46",
main: "index.js", main: "index.js",
module: "index.js", module: "index.js",
type: "module", type: "module",