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
paths:
- 'src/**'
- 'tsup.config.ts'
- 'package.json'
jobs:
check:
@@ -17,6 +19,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -30,5 +35,36 @@ jobs:
- name: Install dependencies
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
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" "" %}}
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

View File

@@ -8,11 +8,43 @@ title: "Release Notes"
Information about release notes of Coco App is provided here.
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
feat: support switching groups via keyboard shortcuts #911
feat: support opening logs from about page #915
feat: support moving cursor with home and end keys #918
feat: support pageup/pagedown to navigate search results #920
feat: standardize multi-level menu label structure #925
feat(View Extension): page field now accepts HTTP(s) links #925
feat: return sub-exts when extension type exts themselves are matched #928
feat: open quick ai with modifier key + enter #939
feat: allow navigate back when cursor is at the beginning #940
### 🐛 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
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)

65
src-tauri/Cargo.lock generated
View File

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

View File

@@ -122,7 +122,7 @@ path-clean = "1.0.1"
tempfile = "3.23.0"
[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 = "0.6.2"
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }

View File

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

View File

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

View File

@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
// will only be evaluated against non-whitespace characters.
let query_string = query_string.trim();
if query_string.is_empty() || query_string.len() == 1 {
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
@@ -150,6 +150,26 @@ impl SearchSource for CalculatorSource {
let query_source = self.get_type();
let base_score = self.base_score;
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);
match res_num {

View File

@@ -8,6 +8,7 @@ use std::ffi::c_ushort;
use std::ffi::c_void;
use std::ops::Deref;
use std::ptr::NonNull;
use std::time::Duration;
use objc2::MainThreadMarker;
use objc2_app_kit::NSEvent;
@@ -34,6 +35,7 @@ use objc2_core_graphics::CGEventType;
use objc2_core_graphics::CGMouseButton;
use objc2_core_graphics::CGRectGetMidX;
use objc2_core_graphics::CGRectGetMinY;
use objc2_core_graphics::CGRectIntersectsRect;
use objc2_core_graphics::CGWindowID;
use super::error::Error;
@@ -46,12 +48,7 @@ use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
fn intersects(r1: CGRect, r2: CGRect) -> bool {
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|| 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
unsafe { CGRectIntersectsRect(r1, r2) }
}
/// 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)
}
/// 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.
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
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)
}
/// 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).
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
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 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(
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());
}
// 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]
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 {
// Let go of the window.
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(())
@@ -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
/// 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
/// 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
/// 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> {
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 mut point = frame.origin;
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
let pos_attr = CFString::from_static_str("AXPosition");
/*
* Set window origin
*/
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()) };
if error != AXError::Success {
return Err(Error::AXError(error));
let current = get_ui_element_origin(&frontmost_window)?;
if current == frame.origin {
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();
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
let size_attr = CFString::from_static_str("AXSize");
/*
* Set window size
*/
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()) };
if error != AXError::Success {
return Err(Error::AXError(error));
let current = get_ui_element_size(&frontmost_window)?;
// For size, we do not check if `current` has the exact same value as
// `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(())
@@ -624,6 +720,15 @@ pub fn toggle_fullscreen() -> Result<(), Error> {
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>>> =
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();
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;
pub(crate) const EXTENSION_ID: &str = "Window Management";
pub(crate) const EXTENSION_NAME_LOWERCASE: &str = "window management";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");

View File

@@ -1,4 +1,5 @@
use super::EXTENSION_ID;
use super::EXTENSION_NAME_LOWERCASE;
use crate::common::document::{DataSourceReference, Document};
use crate::common::{
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
};

View File

@@ -112,6 +112,8 @@ pub struct Extension {
/// and render. Otherwise, `None`.
page: Option<String>,
ui: Option<ViewExtensionUISettings>,
/// Permission that this extension requires.
permission: Option<ExtensionPermission>,
@@ -126,6 +128,17 @@ pub struct Extension {
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.
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
pub(crate) struct ExtensionBundleId {
@@ -265,8 +278,9 @@ impl Extension {
let page = self.page.as_ref().unwrap_or_else(|| {
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
}).clone();
let ui = self.ui.clone();
let extension_on_opened_type = ExtensionOnOpenedType::View { page };
let extension_on_opened_type = ExtensionOnOpenedType::View { page, ui };
let extension_on_opened = ExtensionOnOpened {
ty: extension_on_opened_type,
settings,
@@ -416,7 +430,7 @@ impl QuicklinkLink {
/// if any.
pub(crate) fn concatenate_url(
&self,
user_supplied_args: &Option<HashMap<String, String>>,
user_supplied_args: &Option<HashMap<String, Json>>,
) -> String {
let mut out = String::new();
for component in self.components.iter() {
@@ -428,20 +442,23 @@ impl QuicklinkLink {
argument_name,
default,
} => {
let opt_argument_value = {
let opt_argument_value: Option<&str> = {
let user_supplied_arg = user_supplied_args
.as_ref()
.and_then(|map| map.get(argument_name.as_str()));
if user_supplied_arg.is_some() {
user_supplied_arg
user_supplied_arg.map(|json| {
json.as_str()
.expect("quicklink should provide string arguments")
})
} else {
default.as_ref()
default.as_deref()
}
};
let argument_value_str = match opt_argument_value {
Some(str) => str.as_str(),
Some(str) => str,
// None => an empty string
None => "",
};
@@ -963,6 +980,14 @@ pub(crate) fn canonicalize_relative_page_path(
.page
.as_ref()
.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);
if page_path.is_relative() {
@@ -1741,7 +1766,7 @@ mod tests {
],
};
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));
assert_eq!(result, "https://www.google.com/search?q=");
}
@@ -1778,7 +1803,7 @@ mod tests {
],
};
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));
assert_eq!(result, "https://www.google.com/search?q=rust");
}
@@ -1801,7 +1826,7 @@ mod tests {
],
};
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));
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(())
}
@@ -267,6 +275,7 @@ mod tests {
hotkey: None,
enabled: true,
page,
ui: None,
permission: None,
settings: 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::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::{
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
@@ -8,7 +9,6 @@ use crate::extension::third_party::{
use crate::extension::{
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
};
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
use crate::util::platform::Platform;
use serde_json::Value as Json;
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
* writing the extension files
* writing the extension files because we will edit them.
*
* HTTP links will be skipped.
*/
let absolute_page_paths: Vec<PathBuf> = {
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?;
}
view_extension_convert_pages(&extension, &dest_dir).await?;
// Canonicalize relative icon and page paths
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;

View File

@@ -42,8 +42,10 @@ pub(crate) mod local_extension;
pub(crate) mod store;
use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::util::platform::Platform;
use std::path::Path;
use std::path::PathBuf;
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)
}
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)]
mod tests {
use super::*;
@@ -259,6 +318,7 @@ mod tests {
enabled: true,
settings: None,
page: None,
ui: None,
permission: None,
screenshots: None,
url: None,

View File

@@ -10,15 +10,14 @@ use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path;
use crate::extension::canonicalize_relative_page_path;
use crate::extension::third_party::check::general_check;
use crate::extension::third_party::get_third_party_extension_directory;
use crate::extension::third_party::install::convert_page;
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
use crate::extension::third_party::install::view_extension_convert_pages;
use crate::server::http_client::HttpClient;
use crate::util::platform::Platform;
use async_trait::async_trait;
@@ -26,8 +25,6 @@ use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use tauri::AppHandle;
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
* writing the extension files
* writing the extension files because we will edit them.
*
* HTTP links will be skipped.
*/
let absolute_page_paths: Vec<PathBuf> = {
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?;
}
view_extension_convert_pages(&extension, &extension_directory).await?;
// Canonicalize relative icon and page paths
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::traits::SearchSource;
use crate::extension::ExtensionBundleIdBorrowed;
use crate::extension::ExtensionType;
use crate::extension::calculate_text_similarity;
use crate::extension::canonicalize_relative_page_path;
use crate::util::platform::Platform;
@@ -22,6 +23,7 @@ use async_trait::async_trait;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
@@ -523,6 +525,24 @@ impl ThirdPartyExtensionsSearchSource {
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
"setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type,
));
let url = on_opened.url();
let extension_type_string = extension.r#type.to_string();
let document = Document {
id: extension.id.clone(),
title: Some(extension.name.clone()),
icon: Some(extension.icon.clone()),
on_opened: Some(on_opened.clone()),
url: Some(url),
category: Some(extension_type_string.clone()),
source: Some(DataSourceReference {
id: Some(extension_type_string.clone()),
name: Some(extension_type_string.clone()),
icon: None,
r#type: Some(extension_type_string),
}),
..Default::default()
};
let bundle_id_owned = bundle_id.to_owned();
tauri_app_handle
@@ -532,9 +552,16 @@ impl ThirdPartyExtensionsSearchSource {
let bundle_id_clone = bundle_id_owned.clone();
let app_handle_clone = tauri_app_handle.clone();
let document_clone = document.clone();
if event.state() == ShortcutState::Pressed {
async_runtime::spawn(async move {
let 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 {
log::warn!(
"failed to open extension [{:?}], error [{}]",
@@ -757,11 +784,22 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
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 {
for command in commands.iter().filter(|cmd| cmd.enabled) {
if let Some(hit) =
extension_to_hit(command, &query_lower, opt_data_source.as_deref())
{
if let Some(hit) = extension_to_hit(
command,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
}
@@ -769,9 +807,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
if let Some(ref scripts) = extension.scripts {
for script in scripts.iter().filter(|script| script.enabled) {
if let Some(hit) =
extension_to_hit(script, &query_lower, opt_data_source.as_deref())
{
if let Some(hit) = extension_to_hit(
script,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
}
@@ -783,6 +824,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
quicklink,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
@@ -791,16 +833,19 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
if let Some(ref views) = extension.views {
for view in views.iter().filter(|link| link.enabled) {
if let Some(hit) =
extension_to_hit(view, &query_lower, opt_data_source.as_deref())
{
if let Some(hit) = extension_to_hit(
view,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
}
}
} else {
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);
}
@@ -839,10 +884,18 @@ pub(crate) async fn uninstall_extension(
.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(
extension: &Extension,
query_lower: &str,
opt_data_source: Option<&str>,
opt_main_extension_lowercase_name: Option<&str>,
) -> Option<(Document, f64)> {
if !extension.searchable() {
return None;
@@ -865,14 +918,26 @@ pub(crate) fn extension_to_hit(
if let Some(title_score) =
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
// Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
total_score += alias_score * 0.7; // Weight for alias
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 setup;
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::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
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 autostart::change_autostart;
use lazy_static::lazy_static;
use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::plugin::TauriPlugin;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
@@ -179,6 +180,7 @@ pub fn run() {
setup::backend_setup,
util::app_lang::update_app_lang,
util::path::path_absolute,
util::logging::app_log_dir
])
.setup(|app| {
#[cfg(target_os = "macos")]
@@ -265,35 +267,57 @@ async fn show_coco(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
let _ = window.show();
let _ = window.unminimize();
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use tauri_nspanel::ManagerExt;
// The Window Management (WM) extension (macOS-only) controls the
// frontmost window. Setting focus on macOS makes Coco the frontmost
// window, which means the WM extension would control Coco instead of other
// windows, which is not what we want.
//
// On Linux/Windows, however, setting focus is a necessity to ensure that
// users open Coco's window, then they can start typing, without needing
// to click on the window.
#[cfg(not(target_os = "macos"))]
let _ = window.set_focus();
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.unminimize();
// The Window Management (WM) extension (macOS-only) controls the
// frontmost window. Setting focus on macOS makes Coco the frontmost
// window, which means the WM extension would control Coco instead of other
// windows, which is not what we want.
//
// On Linux/Windows, however, setting focus is a necessity to ensure that
// users open Coco's window, then they can start typing, without needing
// to click on the window.
let _ = window.set_focus();
}
};
let _ = app_handle.emit("show-coco", ());
}
}
#[tauri::command]
async fn hide_coco(app: AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
if let Err(err) = window.hide() {
log::error!("Failed to hide the window: {}", err);
async fn hide_coco(app_handle: AppHandle) {
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 {
log::debug!("Window successfully hidden.");
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).expect("cannot find the main window");
if let Err(err) = window.hide() {
log::error!("Failed to hide the window: {}", err);
} else {
log::debug!("Window successfully hidden.");
}
}
} else {
log::error!("Main window not found.");
}
};
}
fn move_window_to_active_monitor(window: &WebviewWindow) {
@@ -430,135 +454,3 @@ async fn hide_check(app_handle: AppHandle) {
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!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use coco_lib::util::logging::app_log_dir;
use std::fs::OpenOptions;
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
fn setup_panic_hook() {

View File

@@ -10,13 +10,10 @@ use function_name::named;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use reqwest::StatusCode;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::time::{Duration, timeout};
#[named]
#[tauri::command]
pub async fn query_coco_fusion(
@@ -187,7 +184,6 @@ async fn query_coco_fusion_multi_query_sources(
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 {
let query_source = query_source_trait_object.get_type().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 need_rerank = true; //TODO set default to false when boost supported in Pizza
let mut failed_requests = Vec::new();
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::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
}
let mut all_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
while let Some((query_source, timeout_result)) = futures.next().await {
match timeout_result {
@@ -246,12 +236,10 @@ async fn query_coco_fusion_multi_query_sources(
document,
};
all_hits.push((source_id.clone(), query_hit.clone(), score));
hits_per_source
all_hits_grouped_by_source_id
.entry(source_id.clone())
.or_insert_with(Vec::new)
.push((query_hit, score));
.push(query_hit);
}
}
Err(search_error) => {
@@ -267,109 +255,117 @@ async fn query_coco_fusion_multi_query_sources(
}
}
// Sort hits within each source by score (descending)
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));
let n_sources = all_hits_grouped_by_source_id.len();
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 {
size as usize / total_sources
} else {
size as usize
};
/*
* Sort hits within each source by score (descending) in case data sources
* do not sort them
*/
for hits in all_hits_grouped_by_source_id.values_mut() {
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 {
hits.clone()
};
final_hits_grouped_by_source_id.insert(source_id.clone(), hits_taken);
}
let final_hits_len = final_hits_grouped_by_source_id
.iter()
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
let pruned_len = pruned
.iter()
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
/*
* 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 {
*sorted_hits = &sorted_hits[1..];
}
}
}
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();
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 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()
.enumerate()
.filter_map(|(idx, hit)| {
let source = hit.source.as_ref()?;
let title = hit.document.title.as_deref()?;
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
Some((idx, title))
} else {
None
}
})
.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);
for (_source_id, hits) in final_hits_grouped_by_source_id {
final_hits.extend(hits);
}
// **Sort final hits by score descending**
@@ -379,6 +375,11 @@ async fn query_coco_fusion_multi_query_sources(
.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 {
//TODO: Add a recommendation system to suggest more sources
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 strsim::levenshtein;
use std::collections::HashSet;
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();
titles
.into_iter()
.map(|(idx, title)| {
let mut score = 0.0;
for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() {
// Skip special sources like calculator
if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID {
continue;
}
if title.contains(query) {
score += 0.4;
} else if title.to_lowercase().contains(&query_lower) {
score += 0.2;
}
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 dist = levenshtein(&query_lower, &title.to_lowercase());
let max_len = query_lower.len().max(title.len());
if max_len > 0 {
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
}
let new_score = {
let mut score = 0.0;
(idx, score.min(1.0) as f64)
})
// --- Exact or substring boost ---
if document_title.contains(query) {
score += 0.4;
} else if document_title_lowercase.contains(&query_lower) {
score += 0.2;
}
// --- Levenshtein distance (character similarity) ---
let dist = levenshtein(&query_lower, &document_title_lowercase);
let max_len = query_lower.len().max(document_title.len());
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;
}
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()
}

View File

@@ -1,14 +1,26 @@
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use crate::common::MAIN_WINDOW_LABEL;
use objc2_app_kit::NSNonactivatingPanelMask;
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow};
use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel};
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
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(
_tauri_app_handle: &AppHandle,
@@ -17,68 +29,39 @@ pub fn platform(
_check_window: WebviewWindow,
) {
// 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
//
// 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);
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());
// Open the window in the active workspace and full screen
panel.set_collection_behaviour(
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
panel.set_collection_behavior(
CollectionBehavior::new()
.stationary()
.move_to_active_space()
.full_screen_auxiliary()
.into(),
);
// Define the panel's delegate to listen to panel window events
let delegate = panel_delegate!(EcoPanelDelegate {
window_did_become_key,
window_did_resign_key,
window_did_resize,
window_did_move
});
let handler = NsPanelEventHandler::new();
// Set event listeners for the delegate
delegate.set_listener(Box::new(move |delegate_name: String| {
let window = main_window.clone();
handler.window_did_become_key(move |_| {
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
let window_move_event = || {
if let Ok(position) = main_window.outer_position() {
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
}
};
let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
});
match delegate_name.as_str() {
// Called when the window gets keyboard focus
"window_did_become_key" => {
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();
let window = main_window.clone();
handler.window_did_resign_key(move |_| {
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
if let Ok(size) = main_window.inner_size() {
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size);
}
}
// Called when the window position changes
"window_did_move" => window_move_event(),
_ => (),
}
}));
let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true);
});
// 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 file;
// We need this in main.rs, so it has to be pub
pub mod logging;
pub(crate) mod path;
pub(crate) mod platform;
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";
axios.defaults.withCredentials = true;
import { useAppStore } from "@/stores/appStore";
import {
@@ -78,7 +80,7 @@ export const Get = <T>(
}
axios
.get(baseURL + url, { params })
.get(baseURL + url, { params, withCredentials: true })
.then((result) => {
let res: FcResponse<T>;
if (clearFn !== undefined) {
@@ -110,10 +112,15 @@ export const Post = <T>(
}
axios
.post(baseURL + url, data, {
params,
headers,
} as any)
.post(
baseURL + url,
data,
{
params,
headers,
withCredentials: true,
} as any
)
.then((result) => {
resolve([null, result.data as FcResponse<T>]);
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
{icon?.startsWith("font_") ? (
<FontIcon name={icon} className="size-6" />
) : (
<img src={getTypeIcon()} alt={name} className="size-6" />
<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">

View File

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

View File

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

View File

@@ -24,13 +24,13 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
<img
src={userInfo?.avatar}
alt=""
className="w-6 h-6"
className="w-6 h-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={() => {
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 className="flex-1">

View File

@@ -8,7 +8,7 @@ interface FontIconProps {
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
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}`} />
</svg>
);

View File

@@ -24,7 +24,12 @@ function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
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;

View File

@@ -49,7 +49,13 @@ function UniversalIcon({
// Render image type icon
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 ? (
<IconWrapper className={className} onClick={onClick}>
{img}
@@ -63,7 +69,7 @@ function UniversalIcon({
const renderAppIcon = (src: string) => {
const img = (
<img
className={className}
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
src={platformAdapter.convertFileSrc(src)}
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 Copyright from "@/components/Common/Copyright";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
import logoImg from "@/assets/icon.svg";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useUpdateStore } from "@/stores/updateStore";
import VisibleKey from "../VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { formatKey } from "@/utils/keyboardUtils";
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 platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon";
import TogglePin from "../TogglePin";
interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void;
@@ -37,28 +35,11 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
const isDark = useThemeStore((state) => state.isDark);
const { isTauri, isPinned, setIsPinned } = useAppStore();
const { isTauri } = useAppStore();
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
const { fixedWindow, 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 { modifierKey } = useShortcutsStore();
const openSetting = useCallback(() => {
return platformAdapter.emitEvent("open_settings", "");
@@ -88,7 +69,10 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
if (visibleExtensionDetail && selectedExtension) {
return (
<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>
</div>
);
@@ -139,17 +123,12 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
<div className="flex items-center space-x-2">
{renderLeft()}
<button
onClick={togglePin}
<TogglePin
className={clsx({
"text-blue-500": isPinned,
"pl-2": hasUpdate,
})}
>
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
{isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button>
setIsPinnedWeb={setIsPinnedWeb}
/>
</div>
</div>
) : (

View File

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

View File

@@ -107,7 +107,7 @@ const AskAi: FC<AskAiProps> = (props) => {
unlisten.current = await platformAdapter.listenEvent(
"quick-ai-access-client-id",
({ payload }) => {
console.log("ask_ai", JSON.parse(payload));
// console.log("ask_ai", 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 type { Assistant } from "@/types/chat";
import { useAppStore } from "@/stores/appStore";
import { canNavigateBack, navigateBack } from "@/utils";
import { useKeyPress } from "ahooks";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AssistantManagerProps {
isChatMode: boolean;
@@ -33,9 +36,9 @@ export function useAssistantManager({
setVisibleExtensionStore,
setSearchValue,
visibleExtensionDetail,
setVisibleExtensionDetail,
sourceData,
setSourceData,
setVisibleExtensionDetail,
} = useSearchStore();
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
@@ -47,6 +50,7 @@ export function useAssistantManager({
}, [quickAiAccessAssistant, selectedAssistant]);
const [assistantDetail, setAssistantDetail] = useState<any>({});
const { modifierKey } = useShortcutsStore();
useEffect(() => {
if (goAskAi) return;
@@ -76,7 +80,7 @@ export function useAssistantManager({
}, [askAI?.id, askAI?.querySource?.id, disabledExtensions]);
const handleAskAi = useCallback(() => {
if (!isTauri) return;
if (!isTauri || canNavigateBack()) return;
if (disabledExtensions.includes("QuickAIAccess")) return;
@@ -99,39 +103,14 @@ export function useAssistantManager({
const handleKeyDownAutoResizeTextarea = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { key, shiftKey, currentTarget } = e;
const { value } = currentTarget;
const { value, selectionStart, selectionEnd } = currentTarget;
if (key === "Backspace" && value === "") {
if (goAskAi) {
return setGoAskAi(false);
}
const cursorStart = selectionStart === 0 && selectionEnd === 0;
if (visibleExtensionDetail) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
}
if (sourceData) {
return setSourceData(void 0);
}
}
if (key === "Tab" && !isChatMode && isTauri) {
if (key === "Backspace" && (value === "" || cursorStart)) {
e.preventDefault();
if (visibleExtensionStore) return;
if (selectedSearchContent?.id === "Extension Store") {
changeInput("");
setSearchValue("");
return setVisibleExtensionStore(true);
}
assistant_get();
return handleAskAi();
return navigateBack();
}
if (key === "Enter" && !shiftKey) {
@@ -147,6 +126,17 @@ export function useAssistantManager({
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,
@@ -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 {
askAI,
askAIRef,

View File

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

View File

@@ -16,6 +16,7 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
import { SearchSource } from "./SearchSource";
import DropdownListItem from "./DropdownListItem";
import platformAdapter from "@/utils/platformAdapter";
import Scrollbar from "@/components/Common/Scrollbar";
type ISearchData = Record<string, QueryHits[]>;
@@ -145,13 +146,14 @@ function DropdownList({
handleItemAction,
isChatMode,
formatUrl,
searchData,
});
return (
<div
<Scrollbar
ref={containerRef}
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}
role="listbox"
aria-label={t("search.header.results")}
@@ -188,7 +190,7 @@ function DropdownList({
})}
</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 ?? []);

View File

@@ -18,10 +18,17 @@ import { useAssistantManager } from "./AssistantManager";
import InputControls from "./InputControls";
import { useExtensionsStore } from "@/stores/extensionsStore";
import AudioRecording from "../AudioRecording";
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
import {
canNavigateBack,
getUploadedAttachmentsId,
isDefaultServer,
visibleFilterBar,
visibleSearchBar,
} from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { SendMessageParams } from "../Assistant/Chat";
import { isEmpty } from "lodash-es";
import { formatKey } from "@/utils/keyboardUtils";
interface ChatInputProps {
onSend: (params: SendMessageParams) => void;
@@ -88,7 +95,7 @@ export default function ChatInput({
const { currentAssistant } = useConnectStore();
const { sourceData, goAskAi } = useSearchStore();
const { goAskAi } = useSearchStore();
const { modifierKey, returnToInput, setModifierKeyPressed } =
useShortcutsStore();
@@ -103,8 +110,7 @@ export default function ChatInput({
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
const { curChatEnd } = useChatStore();
const { setSearchValue, visibleExtensionStore, selectedExtension } =
useSearchStore();
const { setSearchValue } = useSearchStore();
const { uploadAttachments } = useChatStore();
useTauriFocus({
@@ -121,7 +127,7 @@ export default function ChatInput({
const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim();
console.log("handleSubmit", trimmedValue, disabled);
// console.log("handleSubmit", trimmedValue, disabled);
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
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 &&
isTauri &&
!goAskAi &&
askAI &&
!disabledExtensions.includes("QuickAIAccess") &&
!visibleExtensionStore && (
!canNavigateBack() && (
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
<span>
{t("search.askCocoAi.title", {
replace: [akiAiTooltipPrefix, askAI.name],
})}
</span>
<div className="flex items-center justify-center w-8 h-[20px] text-xs rounded-md border border-black/10 dark:border-[#545454]">
Tab
<div className="flex items-center justify-center px-1 h-[20px] text-xs rounded-md border border-black/10 dark:border-[#545454]">
{formatKey(modifierKey)} + {formatKey("Enter")}
</div>
</div>
)}
@@ -330,53 +309,60 @@ export default function ChatInput({
return (
<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`}
ref={containerRef}
className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`}
>
<div
ref={containerRef}
className={clsx("relative w-full", {
"flex items-center gap-2": lineCount === 1,
})}
>
{lineCount === 1 && renderSearchIcon()}
{lineCount === 1 && renderSearchIcon()}
{renderTextarea()}
{visibleSearchBar() && (
<div
className={clsx(
"relative w-full p-2 bg-[#ededed] dark:bg-[#202126]",
{
"flex items-center gap-2": lineCount === 1,
}
)}
>
{renderTextarea()}
{lineCount === 1 && renderExtraIcon()}
{lineCount === 1 && renderExtraIcon()}
{lineCount > 1 && (
<div className="flex items-center mt-2">
<div className="flex-1">{renderSearchIcon()}</div>
{lineCount > 1 && (
<div className="flex items-center mt-2">
<div className="flex-1">{renderSearchIcon()}</div>
<div className="self-end">{renderExtraIcon()}</div>
</div>
)}
</div>
<div className="self-end">{renderExtraIcon()}</div>
</div>
)}
</div>
)}
</div>
<InputControls
isChatMode={isChatMode}
isChatPage={isChatPage}
hasModules={hasModules}
searchPlaceholder={searchPlaceholder}
chatPlaceholder={chatPlaceholder}
isSearchActive={isSearchActive}
setIsSearchActive={setIsSearchActive}
isDeepThinkActive={isDeepThinkActive}
setIsDeepThinkActive={setIsDeepThinkActive}
isMCPActive={isMCPActive}
setIsMCPActive={setIsMCPActive}
changeMode={changeMode}
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
{visibleFilterBar() && (
<InputControls
isChatMode={isChatMode}
isChatPage={isChatPage}
hasModules={hasModules}
searchPlaceholder={searchPlaceholder}
chatPlaceholder={chatPlaceholder}
isSearchActive={isSearchActive}
setIsSearchActive={setIsSearchActive}
isDeepThinkActive={isDeepThinkActive}
setIsDeepThinkActive={setIsDeepThinkActive}
isMCPActive={isMCPActive}
setIsMCPActive={setIsMCPActive}
changeMode={changeMode}
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
)}
</div>
);
}

View File

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

View File

@@ -1,7 +1,61 @@
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 { 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 {
lineCount: number;
@@ -16,13 +70,11 @@ export default function SearchIcons({
}: SearchIconsProps) {
const {
sourceData,
setSourceData,
goAskAi,
setGoAskAi,
visibleExtensionStore,
setVisibleExtensionStore,
visibleExtensionDetail,
setVisibleExtensionDetail,
selectedExtension,
viewExtensionOpened,
} = useSearchStore();
if (isChatMode) {
@@ -30,54 +82,42 @@ export default function SearchIcons({
}
const renderContent = () => {
if (visibleExtensionStore) {
if (visibleExtensionDetail && selectedExtension) {
const { name, icon } = selectedExtension;
return <MultilevelWrapper title={name} icon={icon} />;
}
return <MultilevelWrapper title="Extensions Store" icon="font_Store" />;
}
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>
const { name, icon } = assistant;
<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>
);
return <MultilevelWrapper title={name} icon={icon} />;
}
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 (sourceData) {
const { source } = sourceData;
const { name, icon } = source;
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
}
setSourceData(void 0);
}}
/>
);
return <MultilevelWrapper title={name} icon={icon} />;
}
return <Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />;
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>
);
};
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 { useState, useEffect, useMemo } from "react";
import { ArrowLeft } from "lucide-react";
import { useSearchStore } from "@/stores/searchStore";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { ExtensionFileSystemPermission, FileSystemAccess } from "../Settings/Extensions";
import {
ExtensionFileSystemPermission,
FileSystemAccess,
} from "../Settings/Extensions";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
const ViewExtension: React.FC = () => {
const { setViewExtensionOpened, viewExtensionOpened } = useSearchStore();
const [pagePath, setPagePath] = useState<string>("");
const { viewExtensionOpened } = useSearchStore();
const [page, setPage] = useState<string>("");
// Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
const { setModifierKeyPressed } = useShortcutsStore();
if (viewExtensionOpened == null) {
// When this view gets loaded, this state should not be NULL.
@@ -30,9 +28,13 @@ const ViewExtension: React.FC = () => {
useEffect(() => {
const setupFileUrl = async () => {
// The check above ensures viewExtensionOpened is not null here.
const filePath = viewExtensionOpened[0];
if (filePath) {
setPagePath(convertFileSrc(filePath));
const page = viewExtensionOpened[0];
// Only convert to file source if it's a local file path, not a URL
if (page.startsWith("http://") || page.startsWith("https://")) {
setPage(page);
} else {
setPage(platformAdapter.convertFileSrc(page));
}
};
@@ -41,9 +43,13 @@ const ViewExtension: React.FC = () => {
// invoke `apis()` and set the state
useEffect(() => {
setModifierKeyPressed(false);
const fetchApis = async () => {
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)));
} catch (error) {
console.error("Failed to fetch APIs:", error);
@@ -53,10 +59,6 @@ const ViewExtension: React.FC = () => {
fetchApis();
}, []);
const handleBack = () => {
setViewExtensionOpened(null);
};
// White list of the permission entries
const permission = viewExtensionOpened[1];
@@ -108,10 +110,10 @@ const ViewExtension: React.FC = () => {
const category = reversedApis.get(command)!;
var api = null;
if (permission == null) {
api = null
api = null;
} else {
api = permission.api
};
api = permission.api;
}
if (!apiPermissionCheck(category, command, api)) {
source.postMessage(
{
@@ -126,10 +128,10 @@ const ViewExtension: React.FC = () => {
var fs = null;
if (permission == null) {
fs = null
fs = null;
} else {
fs = permission.fs
};
fs = permission.fs;
}
if (!(await fsPermissionCheck(command, event.data, fs))) {
source.postMessage(
{
@@ -145,7 +147,12 @@ const ViewExtension: React.FC = () => {
if (command === "read_dir") {
const { path } = event.data;
try {
const fileNames: [String] = await invoke("read_dir", { path: path });
const fileNames: [String] = await platformAdapter.invokeBackend(
"read_dir",
{
path: path,
}
);
source.postMessage(
{
id,
@@ -171,74 +178,72 @@ const ViewExtension: React.FC = () => {
console.info("Coco extension API listener is up");
return () => {
window.removeEventListener('message', messageHandler);
window.removeEventListener("message", messageHandler);
};
}, [reversedApis, permission]); // Add apiPermissions as dependency
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
src={pagePath}
className="w-full h-full border-0"
>
</iframe>
</div>
</div>
<iframe
src={page}
className="w-full h-full border-0"
onLoad={(event) => {
event.currentTarget.focus();
}}
/>
);
};
export default ViewExtension;
// 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) {
return false;
}
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) {
case "read_dir": {
const { path } = requestPayload;
const { path } = requestPayload;
return [path, ["read"]];
return [path, ["read"]];
}
default: {
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) {
return false;
}
const [ path, access ] = extractFsAccessPattern(command, requestPayload);
const clean_path = await invoke("path_absolute", { path: path });
const [path, access] = extractFsAccessPattern(command, requestPayload);
const clean_path = await platformAdapter.invokeBackend("path_absolute", {
path: path,
});
// Walk through fsPermission array to find matching paths
for (const permission of fsPermission) {
if (permission.path === clean_path) {
// 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)
);
@@ -249,4 +254,4 @@ const fsPermissionCheck = async (command: string, requestPayload: any, fsPermiss
}
return false;
}
};

View File

@@ -21,14 +21,17 @@ import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import platformAdapter from "@/utils/platformAdapter";
import { useStartupStore } from "@/stores/startupStore";
import { useThemeStore } from "@/stores/themeStore";
import { useConnectStore } from "@/stores/connectStore";
import { useAppearanceStore } from "@/stores/appearanceStore";
import type { StartPage } from "@/types/chat";
import { hasUploadingAttachment } from "@/utils";
import {
hasUploadingAttachment,
visibleFilterBar,
visibleSearchBar,
} from "@/utils";
interface SearchChatProps {
isTauri?: boolean;
@@ -105,7 +108,6 @@ function SearchChat({
const [isWin10, setIsWin10] = useState(false);
const blurred = useAppStore((state) => state.blurred);
const { viewExtensionOpened } = useSearchStore();
useWindowEvents();
@@ -289,45 +291,45 @@ function SearchChat({
</Suspense>
</div>
{/* We don't want this inputbox when rendering View extensions */}
{/* TODO: figure out a better way to disable this inputbox */}
{!viewExtensionOpened && (
<div
data-tauri-drag-region={isTauri}
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
isTransitioned ? "border-t" : "border-b"
} border-[#E6E6E6] dark:border-[#272626]`}
>
<InputBox
isChatMode={isChatMode}
inputValue={input}
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={cancelChat}
changeMode={changeMode}
changeInput={setInput}
isSearchActive={isSearchActive}
setIsSearchActive={toggleSearchActive}
isDeepThinkActive={isDeepThinkActive}
setIsDeepThinkActive={toggleDeepThinkActive}
isMCPActive={isMCPActive}
setIsMCPActive={toggleMCPActive}
setupWindowFocusListener={setupWindowFocusListener}
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
hasModules={hasModules}
searchPlaceholder={searchPlaceholder}
chatPlaceholder={chatPlaceholder}
/>
</div>
)}
<div
data-tauri-drag-region={isTauri}
className={clsx(
"p-2 w-full flex justify-center transition-all duration-500 border-[#E6E6E6] dark:border-[#272626]",
[isTransitioned ? "border-t" : "border-b"],
{
"min-h-[82px]": visibleSearchBar() && visibleFilterBar(),
}
)}
>
<InputBox
isChatMode={isChatMode}
inputValue={input}
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={cancelChat}
changeMode={changeMode}
changeInput={setInput}
isSearchActive={isSearchActive}
setIsSearchActive={toggleSearchActive}
isDeepThinkActive={isDeepThinkActive}
setIsDeepThinkActive={toggleDeepThinkActive}
isMCPActive={isMCPActive}
setIsMCPActive={toggleMCPActive}
setupWindowFocusListener={setupWindowFocusListener}
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
hasModules={hasModules}
searchPlaceholder={searchPlaceholder}
chatPlaceholder={chatPlaceholder}
/>
</div>
<div
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 { OpenURLWithBrowser } from "@/utils";
import logoLight from "@/assets/images/logo-text-light.svg";
import logoDark from "@/assets/images/logo-text-dark.svg";
import lightLogo from "@/assets/images/logo-text-light.svg";
import darkLogo from "@/assets/images/logo-text-dark.svg";
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() {
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 (
<div className="flex justify-center items-center flex-col h-[calc(100vh-170px)]">
<div>
<div className="flex h-[calc(100vh-170px)]">
<div className="flex flex-col items-center justify-center w-[70%] pr-10 text-[#999] text-sm">
<img
src={logo}
className="w-48 dark:text-white"
src={isDark ? darkLogo : lightLogo}
className="h-14"
alt={t("settings.about.logo")}
/>
<div className="mt-4 text-base font-medium text-[#333] dark:text-white/80">
{t("settings.about.slogan")}
</div>
<div className="mt-10">
{t("settings.about.version", {
version: process.env.VERSION || "N/A",
})}
</div>
<div className="mt-3">
{t("settings.about.copyright", { year: new Date().getFullYear() })}
</div>
</div>
<div className="mt-8 font-medium text-gray-900 dark:text-gray-100">
{t("settings.about.slogan")}
</div>
<div className="flex justify-center items-center mt-10">
<button
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", {
version: process.env.VERSION || "N/A",
<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 className="mt-4 text-sm text-gray-500 dark:text-gray-400">
{t("settings.about.copyright", { year: new Date().getFullYear() })}
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -29,7 +29,8 @@ export function useChatActions(
isDeepThinkActive?: boolean,
isMCPActive?: boolean,
changeInput?: (val: string) => void,
showChatHistory?: boolean
showChatHistory?: boolean,
getChatHistoryChatPage?: () => void,
) {
const isCurrentLogin = useAuthStore((state) => state.isCurrentLogin);
@@ -197,17 +198,17 @@ export function useChatActions(
) {
try {
const response = JSON.parse(msg);
console.log("first", response);
// console.log("first", response);
let updatedChat: Chat;
if (Array.isArray(response)) {
curIdRef.current = response[0]?._id;
curSessionIdRef.current = response[0]?._source?.session_id;
console.log(
"curIdRef-curSessionIdRef-Array",
curIdRef.current,
curSessionIdRef.current
);
// console.log(
// "curIdRef-curSessionIdRef-Array",
// curIdRef.current,
// curSessionIdRef.current
// );
updatedChat = {
...updatedChatRef.current,
messages: [
@@ -215,16 +216,16 @@ export function useChatActions(
...(response || []),
],
};
console.log("array", updatedChat, updatedChatRef.current?.messages);
// console.log("array", updatedChat, updatedChatRef.current?.messages);
} else {
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
curSessionIdRef.current = response?.payload?.session_id;
console.log(
"curIdRef-curSessionIdRef",
curIdRef.current,
curSessionIdRef.current
);
// console.log(
// "curIdRef-curSessionIdRef",
// curIdRef.current,
// curSessionIdRef.current
// );
newChat._source = {
...response?.payload,
@@ -252,7 +253,7 @@ export function useChatActions(
async (timestamp: number) => {
cleanupListeners();
console.log("setupListeners", clientId, timestamp);
// console.log("setupListeners", clientId, timestamp);
const unlisten_chat_message = await platformAdapter.listenEvent(
`chat-stream-${clientId}-${timestamp}`,
(event) => {
@@ -300,12 +301,45 @@ export function useChatActions(
[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(
async (params?: SendMessageParams) => {
const { message, attachments } = params || {};
console.log("message", message);
console.log("attachments", attachments);
// console.log("message", message);
// console.log("attachments", attachments);
if (!message && isEmpty(attachments)) return;
@@ -325,7 +359,7 @@ export function useChatActions(
if (isTauri) {
if (!currentService?.id) return;
console.log("chat_create", clientId, timestamp);
// console.log("chat_create", clientId, timestamp);
await platformAdapter.commands("chat_create", {
serverId: currentService?.id,
@@ -334,7 +368,7 @@ export function useChatActions(
queryParams,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("_create end", message);
// console.log("_create end", message);
resetChatState();
} else {
await streamPost({
@@ -342,12 +376,17 @@ export function useChatActions(
body: { message },
queryParams,
onMessage: (line) => {
console.log("⏳", line);
// console.log("⏳", line);
handleChatCreateStreamMessage(line);
// append to chat box
},
});
}
// console.log("showChatHistory", showChatHistory);
if (showChatHistory) {
getChatHistoryChatPage ? getChatHistoryChatPage() : getChatHistory();
}
},
[
isTauri,
@@ -360,6 +399,8 @@ export function useChatActions(
currentAssistant,
chatClose,
clientId,
showChatHistory,
getChatHistory,
]
);
@@ -386,7 +427,7 @@ export function useChatActions(
if (isTauri) {
if (!currentService?.id) return;
console.log("chat_chat", clientId, timestamp);
// console.log("chat_chat", clientId, timestamp);
await platformAdapter.commands("chat_chat", {
serverId: currentService?.id,
sessionId: newChat?._id,
@@ -395,7 +436,7 @@ export function useChatActions(
attachments,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("chat_chat end", message, clientId);
// console.log("chat_chat end", message, clientId);
resetChatState();
} else {
await streamPost({
@@ -403,7 +444,7 @@ export function useChatActions(
body: { message },
queryParams,
onMessage: (line) => {
console.log("line", line);
// console.log("line", line);
handleChatCreateStreamMessage(line);
// append to chat box
},
@@ -468,39 +509,6 @@ export function useChatActions(
[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(() => {
if (showChatHistory) {
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 platformAdapter from "@/utils/platformAdapter";
import { useSearchStore } from "@/stores/searchStore";
import { isNumber } from "lodash-es";
interface UseKeyboardNavigationProps {
suggests: QueryHits[];
@@ -16,6 +17,7 @@ interface UseKeyboardNavigationProps {
handleItemAction: (item: SearchDocument) => void;
isChatMode: boolean;
formatUrl?: (item: any) => string;
searchData: Record<string, QueryHits[]>;
}
export function useKeyboardNavigation({
@@ -29,6 +31,7 @@ export function useKeyboardNavigation({
handleItemAction,
isChatMode,
formatUrl,
searchData,
}: UseKeyboardNavigationProps) {
const openPopover = useShortcutsStore((state) => state.openPopover);
const visibleContextMenu = useSearchStore((state) => {
@@ -47,6 +50,20 @@ export function useKeyboardNavigation({
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(
(e: KeyboardEvent) => {
if (isChatMode || !suggests.length || openPopover || visibleContextMenu) {
@@ -59,13 +76,28 @@ export function useKeyboardNavigation({
if (e.key === "ArrowUp") {
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) => {
if (prev == null) {
return Math.min(...indexes);
}
const nextIndex = prev - 1;
if (isNumber(nextIndex)) {
return nextIndex;
}
nextIndex = prev - 1;
if (indexes.includes(nextIndex)) {
return nextIndex;
@@ -75,13 +107,28 @@ export function useKeyboardNavigation({
});
} else if (e.key === "ArrowDown") {
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) => {
if (prev == null) {
return Math.min(...indexes);
}
const nextIndex = prev + 1;
if (isNumber(nextIndex)) {
return nextIndex;
}
nextIndex = prev + 1;
if (indexes.includes(nextIndex)) {
return nextIndex;

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ export default function useSettingsWindow() {
useEffect(() => {
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 | "";
platformAdapter.emitEvent("tab_index", tab);

View File

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

View File

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

View File

@@ -39,7 +39,14 @@
"website": "Visit Website",
"github": "Visit GitHub",
"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": {
"startup": {

View File

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

View File

@@ -35,12 +35,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
setIsTauri(true);
}, []);
const {
setCurrentAssistant,
assistantList,
setVisibleStartPage,
currentService,
} = useConnectStore();
const currentService = useConnectStore((state) => state.currentService);
const chatAIRef = useRef<ChatAIRef>(null);
@@ -77,7 +72,7 @@ export default function StandaloneChat({}: StandaloneChatProps) {
query: keyword,
});
response = response ? JSON.parse(response) : null;
console.log("_history", response);
// console.log("_history", response);
const hits = response?.hits?.hits || [];
setChats(hits);
} catch (error) {
@@ -112,39 +107,6 @@ export default function StandaloneChat({}: StandaloneChatProps) {
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 () => {
if (!activeChat?._id) return;
try {
@@ -153,26 +115,14 @@ export default function StandaloneChat({}: StandaloneChatProps) {
sessionId: activeChat?._id,
});
response = response ? JSON.parse(response) : null;
console.log("_close", response);
// console.log("_close", response);
} catch (error) {
console.error("close_session_chat:", error);
}
};
const onSelectChat = async (chat: any) => {
chatClose();
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);
}
chatAIRef.current?.onSelectChat(chat);
};
const cancelChat = async () => {
@@ -317,6 +267,8 @@ export default function StandaloneChat({}: StandaloneChatProps) {
isChatPage={isChatPage}
getFileUrl={getFileUrl}
changeInput={setInput}
showChatHistory={true}
getChatHistoryChatPage={getChatHistory}
/>
</div>

View File

@@ -11,7 +11,7 @@ import platformAdapter from "@/utils/platformAdapter";
function MainApp() {
const { setIsTauri } = useAppStore();
const { setViewExtensionOpened } = useSearchStore();
const { setViewExtensionOpened } = useSearchStore();
useEffect(() => {
setIsTauri(true);
@@ -20,10 +20,11 @@ function MainApp() {
//
// 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.
platformAdapter.listenEvent("open_view_extension", async ({ payload: view_extension_page_and_permission } ) => {
await platformAdapter.showWindow();
setViewExtensionOpened(view_extension_page_and_permission);
})
platformAdapter.listenEvent("open_view_extension", async ({ payload }) => {
await platformAdapter.showWindow();
setViewExtensionOpened(payload);
});
}, []);
const { synthesizeItem } = useChatStore();

View File

@@ -15,6 +15,7 @@ type keyArrayObject = {
export type IConnectStore = {
serverList: Server[];
setServerList: (servers: Server[]) => void;
setServerListSilently: (servers: Server[]) => void;
currentService: Server;
setCurrentService: (service: Server) => void;
cloudSelectService: Server;
@@ -44,7 +45,15 @@ export const useConnectStore = create<IConnectStore>()(
persist(
(set) => ({
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(
produce((draft) => {
draft.serverList = serverList;

View File

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

View File

@@ -1,8 +1,19 @@
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 { persist } from "zustand/middleware";
export type ViewExtensionOpened = [
string,
ExtensionPermission | null,
ViewExtensionUISettings | null,
SearchDocument
];
export type ISearchStore = {
sourceData: any;
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.
//
// The first array element is the path to the page that we should load, the
// second element is the permission that this extension requires.
viewExtensionOpened: [string, ExtensionPermission | null] | null;
setViewExtensionOpened: (showViewExtension: [string, ExtensionPermission | null] | null) => void;
// Arguments
//
// The first array element is the path to the page that we should load
// The second element is the permission that this extension requires.
// The third argument is the UI Settings
viewExtensionOpened?: ViewExtensionOpened;
setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void;
};
export const useSearchStore = create<ISearchStore>()(
@@ -114,7 +128,6 @@ export const useSearchStore = create<ISearchStore>()(
setVisibleExtensionDetail: (visibleExtensionDetail) => {
return set({ visibleExtensionDetail });
},
viewExtensionOpened: null,
setViewExtensionOpened: (viewExtensionOpened) => {
return set({ viewExtensionOpened });
},

View File

@@ -6,7 +6,8 @@ import { IStartupStore } from "@/stores/startupStore";
import { AppTheme } from "@/types/index";
import { SearchDocument } from "./search";
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 {
"theme-changed": string;
@@ -48,7 +49,8 @@ export interface EventPayloads {
"check-update": any;
oauth_success: any;
extension_install_success: any;
"open_view_extension": [string, ExtensionPermission];
open_view_extension: ViewExtensionOpened;
"server-list-changed": Server[];
}
// Window operation interface
@@ -127,6 +129,7 @@ export interface SystemOperations {
queryParams: string[]
) => Promise<any[]>;
fetchAssistant: (serverId: string, queryParams: string[]) => Promise<any>;
openLogDir: () => Promise<void>;
}
// 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 { useChatStore } from "@/stores/chatStore";
import { getCurrentWindowService } from "@/commands/windowService";
import { useSearchStore } from "@/stores/searchStore";
// 1
export async function copyToClipboard(text: string) {
@@ -212,3 +213,116 @@ export const getUploadedAttachmentsId = () => {
.map((item) => item.attachmentId)
.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
Space: "Space",
space: "Space",
Enter: "",
Enter: "↩︎",
Backspace: "⌫",
Delete: "Del",
Escape: "Esc",

View File

@@ -1,6 +1,6 @@
import type { OpenDialogOptions } from "@tauri-apps/plugin-dialog";
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 { error } from "@tauri-apps/plugin-log";
@@ -13,9 +13,8 @@ import {
import type { BasePlatformAdapter } from "@/types/platform";
import type { AppTheme } from "@/types/index";
import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, OpenURLWithBrowser } from ".";
import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from ".";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { unrequitable } from "@/utils";
export interface TauriPlatformAdapter extends BasePlatformAdapter {
@@ -24,6 +23,7 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
) => Promise<string | string[] | null>;
metadata: typeof metadata;
error: typeof error;
openLogDir: () => Promise<void>;
}
// Create Tauri adapter functions
@@ -258,35 +258,12 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
console.log("openSearchItem", data);
// Extension store needs to be opened in a different way
if (data?.type === "AI Assistant" || data?.id === "Extension Store") {
const textarea = document.querySelector("#search-textarea");
if (!(textarea instanceof HTMLTextAreaElement)) return;
textarea.focus();
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;
if (
data?.type === "AI Assistant" ||
data?.id === "Extension Store" ||
data?.category === "View"
) {
return dispatchEvent("Tab", 9);
}
const hideCoco = () => {
@@ -352,5 +329,13 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
const window = await windowWrapper.getWebviewWindow();
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>;
metadata: (path: string, options: any) => Promise<Record<string, any>>;
error: (message: string) => void;
openLogDir: () => Promise<void>;
}
// Create Web adapter functions
@@ -261,17 +262,20 @@ export const createWebAdapter = (): WebPlatformAdapter => {
`/assistant/_search?${queryParams?.join("&")}`,
undefined
);
if (error) {
console.error("_search", error);
return {};
}
return res;
},
async getCurrentWindowLabel() {
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 { writeFileSync, readFileSync } from 'fs';
import { writeFileSync, readFileSync, readdirSync, statSync } from 'fs';
import { join, resolve } from 'path';
const projectPackageJson = JSON.parse(
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({
entry: ['src/pages/web/index.tsx'],
format: ['esm'],
@@ -66,13 +85,25 @@ export default defineConfig({
outDir: 'out/search-chat',
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(
readFileSync(join(__dirname, 'package.json'), 'utf-8')
);
const packageJson = {
name: "@infinilabs/search-chat",
version: "1.2.38",
version: "1.2.46",
main: "index.js",
module: "index.js",
type: "module",