refactor: refactoring Coco App (#112)

* feat: impl Coco server related APIs

* chore: remove unused method

* fix: invoke Rust interfaces in tauri::run()

* chore: add invoke

* feat: add add_coco_server

* fix: trim the tailing forward slash

* feat: interface get_user_profiles

* chore: add

* fix: store the servers in add interface

* chore: ass

* fix: skip non-publich servers with no token

* feat: add

* feat: get datasources and connectors

* fix: invoke interfaces in tauri::run()

* chore: add SidebarRef

* refactor: refactoring coco-app

* refactor: refactoring coco app

* refactor: refactoring project layout

* refactor: refactoring server management

* chore: cleanup code

* chore: display error when connect failed

* refactor: refactoring refresh server's info

* refactor: refactoring how to connect the coco serverg

* chore: rename to cloud

* refactor: refactoring remove coco server

* fix: refresh current selected server

* fix: reset server selection

* chore: update login status

* feat: add error message tips

* fix: fix login and logout

* refactor: refactoring http client

* fix: fix the datasources

* chore: minor fix

* refactor: refactoring code

* fix: fix search api

* chore: optimize part of icons

* chore: fix build

* refactor: search list icon

* refactor: search list icon

* chore: lib

* feat: add plugin-os

---------

Co-authored-by: rain <15911122312@163.com>
Co-authored-by: medcl <m@medcl.net>
This commit is contained in:
SteveLauC
2025-02-06 11:45:37 +08:00
committed by GitHub
parent 9c824a0bdc
commit 45ffe4cad8
114 changed files with 3629 additions and 2877 deletions

View File

@@ -12,12 +12,12 @@
"dependencies": {
"@headlessui/react": "^2.1.10",
"@react-oauth/google": "^0.12.1",
"@tauri-apps/api": ">=2.0.0",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
"@tauri-apps/plugin-http": "~2.0.1",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-shell": ">=2.0.0",
"@tauri-apps/plugin-websocket": "~2",
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
@@ -45,7 +45,7 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@tauri-apps/cli": ">=2.0.0",
"@tauri-apps/cli": "^2.2.7",
"@types/lodash": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.8.4",

116
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: ^0.12.1
version: 0.12.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tauri-apps/api':
specifier: '>=2.0.0'
version: 2.0.2
specifier: ^2.2.0
version: 2.2.0
'@tauri-apps/plugin-autostart':
specifier: ~2
version: 2.2.0
@@ -30,7 +30,7 @@ importers:
specifier: ~2.0.1
version: 2.0.1
'@tauri-apps/plugin-os':
specifier: ^2.0.0
specifier: ^2.2.0
version: 2.2.0
'@tauri-apps/plugin-shell':
specifier: '>=2.0.0'
@@ -109,8 +109,8 @@ importers:
version: 5.0.0(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)
devDependencies:
'@tauri-apps/cli':
specifier: '>=2.0.0'
version: 2.0.3
specifier: ^2.2.7
version: 2.2.7
'@types/lodash':
specifier: ^4.17.12
version: 4.17.12
@@ -638,75 +638,75 @@ packages:
resolution: {integrity: sha512-ZMOc3eu9amwvkC6M69h3hWt4/EsFaAXmtkiw4xd2LN59/lTb4ZQiVfq2QKlRcu1rj3n/Tcr7U30ZopvHwXBGIg==}
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
'@tauri-apps/api@2.0.2':
resolution: {integrity: sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==}
'@tauri-apps/api@2.2.0':
resolution: {integrity: sha512-R8epOeZl1eJEl603aUMIGb4RXlhPjpgxbGVEaqY+0G5JG9vzV/clNlzTeqc+NLYXVqXcn8mb4c5b9pJIUDEyAg==}
'@tauri-apps/cli-darwin-arm64@2.0.3':
resolution: {integrity: sha512-jIsbxGWS+As1ZN7umo90nkql/ZAbrDK0GBT6UsgHSz5zSwwArICsZFFwE1pLZip5yoiV5mn3TGG2c1+v+0puzQ==}
'@tauri-apps/cli-darwin-arm64@2.2.7':
resolution: {integrity: sha512-54kcpxZ3X1Rq+pPTzk3iIcjEVY4yv493uRx/80rLoAA95vAC0c//31Whz75UVddDjJfZvXlXZ3uSZ+bnCOnt0A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.0.3':
resolution: {integrity: sha512-ROITHtLTA1muyrwgyuwyasmaLCGtT4as/Kd1kerXaSDtFcYrnxiM984ZD0+FDUEDl5BgXtYa/sKKkKQFjgmM0A==}
'@tauri-apps/cli-darwin-x64@2.2.7':
resolution: {integrity: sha512-Vgu2XtBWemLnarB+6LqQeLanDlRj7CeFN//H8bVVdjbNzxcSxsvbLYMBP8+3boa7eBnjDrqMImRySSgL6IrwTw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.0.3':
resolution: {integrity: sha512-bQ3EZwCFfrLg/ZQ2I8sLuifSxESz4TP56SleTkKsPtTIZgNnKpM88PRDz4neiRroHVOq8NK0X276qi9LjGcXPw==}
'@tauri-apps/cli-linux-arm-gnueabihf@2.2.7':
resolution: {integrity: sha512-+Clha2iQAiK9zoY/KKW0KLHkR0k36O78YLx5Sl98tWkwI3OBZFg5H5WT1plH/4sbZIS2aLFN6dw58/JlY9Bu/g==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.0.3':
resolution: {integrity: sha512-aLfAA8P9OTErVUk3sATxtXqpAtlfDPMPp4fGjDysEELG/MyekGhmh2k/kG/i32OdPeCfO+Nr37wJksARJKubGw==}
'@tauri-apps/cli-linux-arm64-gnu@2.2.7':
resolution: {integrity: sha512-Z/Lp4SQe6BUEOays9BQAEum2pvZF4w9igyXijP+WbkOejZx4cDvarFJ5qXrqSLmBh7vxrdZcLwoLk9U//+yQrg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.0.3':
resolution: {integrity: sha512-I4MVD7nf6lLLRmNQPpe5beEIFM6q7Zkmh77ROA5BNu/+vHNL5kiTMD+bmd10ZL2r753A6pO7AvqkIxcBuIl0tg==}
'@tauri-apps/cli-linux-arm64-musl@2.2.7':
resolution: {integrity: sha512-+8HZ+txff/Y3YjAh80XcLXcX8kpGXVdr1P8AfjLHxHdS6QD4Md+acSxGTTNbplmHuBaSHJvuTvZf9tU1eDCTDg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-x64-gnu@2.0.3':
resolution: {integrity: sha512-C6Jkx2zZGKkoi+sg5FK9GoH/0EvAaOgrZfF5azV5EALGba46g7VpWcZgp9zFUd7K2IzTi+0OOY8TQ2OVfKZgew==}
'@tauri-apps/cli-linux-x64-gnu@2.2.7':
resolution: {integrity: sha512-ahlSnuCnUntblp9dG7/w5ZWZOdzRFi3zl0oScgt7GF4KNAOEa7duADsxPA4/FT2hLRa0SvpqtD4IYFvCxoVv3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.0.3':
resolution: {integrity: sha512-qi4ghmTfSAl+EEUDwmwI9AJUiOLNSmU1RgiGgcPRE+7A/W+Am9UnxYySAiRbB/gJgTl9sj/pqH5Y9duP1/sqHg==}
'@tauri-apps/cli-linux-x64-musl@2.2.7':
resolution: {integrity: sha512-+qKAWnJRSX+pjjRbKAQgTdFY8ecdcu8UdJ69i7wn3ZcRn2nMMzOO2LOMOTQV42B7/Q64D1pIpmZj9yblTMvadA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.0.3':
resolution: {integrity: sha512-UXxHkYmFesC97qVmZre4vY7oDxRDtC2OeKNv0bH+iSnuUp/ROxzJYGyaelnv9Ybvgl4YVqDCnxgB28qMM938TA==}
'@tauri-apps/cli-win32-arm64-msvc@2.2.7':
resolution: {integrity: sha512-aa86nRnrwT04u9D9fhf5JVssuAZlUCCc8AjqQjqODQjMd4BMA2+d4K9qBMpEG/1kVh95vZaNsLogjEaqSTTw4A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.0.3':
resolution: {integrity: sha512-D+xoaa35RGlkXDpnL5uDTpj29untuC5Wp6bN9snfgFDagD0wnFfC8+2ZQGu16bD0IteWqDI0OSoIXhNvy+F+wg==}
'@tauri-apps/cli-win32-ia32-msvc@2.2.7':
resolution: {integrity: sha512-EiJ5/25tLSQOSGvv+t6o3ZBfOTKB5S3vb+hHQuKbfmKdRF0XQu2YPdIi1CQw1DU97ZAE0Dq4frvnyYEKWgMzVQ==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.0.3':
resolution: {integrity: sha512-eWV9XWb4dSYHXl13OtYWLjX1JHphUEkHkkGwJrhr8qFBm7RbxXxQvrsUEprSi51ug/dwJenjJgM4zR8By4htfw==}
'@tauri-apps/cli-win32-x64-msvc@2.2.7':
resolution: {integrity: sha512-ZB8Kw90j8Ld+9tCWyD2fWCYfIrzbQohJ4DJSidNwbnehlZzP7wAz6Z3xjsvUdKtQ3ibtfoeTqVInzCCEpI+pWg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.0.3':
resolution: {integrity: sha512-JwEyhc5BAVpn4E8kxzY/h7+bVOiXQdudR1r3ODMfyyumZBfgIWqpD/WuTcPq6Yjchju1BSS+80jAE/oYwI/RKg==}
'@tauri-apps/cli@2.2.7':
resolution: {integrity: sha512-ZnsS2B4BplwXP37celanNANiIy8TCYhvg5RT09n72uR/o+navFZtGpFSqljV8fy1Y4ixIPds8FrGSXJCN2BerA==}
engines: {node: '>= 10'}
hasBin: true
@@ -2727,78 +2727,78 @@ snapshots:
'@tauri-apps/api@2.0.0-alpha.6': {}
'@tauri-apps/api@2.0.2': {}
'@tauri-apps/api@2.2.0': {}
'@tauri-apps/cli-darwin-arm64@2.0.3':
'@tauri-apps/cli-darwin-arm64@2.2.7':
optional: true
'@tauri-apps/cli-darwin-x64@2.0.3':
'@tauri-apps/cli-darwin-x64@2.2.7':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.0.3':
'@tauri-apps/cli-linux-arm-gnueabihf@2.2.7':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.0.3':
'@tauri-apps/cli-linux-arm64-gnu@2.2.7':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.0.3':
'@tauri-apps/cli-linux-arm64-musl@2.2.7':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.0.3':
'@tauri-apps/cli-linux-x64-gnu@2.2.7':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.0.3':
'@tauri-apps/cli-linux-x64-musl@2.2.7':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.0.3':
'@tauri-apps/cli-win32-arm64-msvc@2.2.7':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.0.3':
'@tauri-apps/cli-win32-ia32-msvc@2.2.7':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.0.3':
'@tauri-apps/cli-win32-x64-msvc@2.2.7':
optional: true
'@tauri-apps/cli@2.0.3':
'@tauri-apps/cli@2.2.7':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.0.3
'@tauri-apps/cli-darwin-x64': 2.0.3
'@tauri-apps/cli-linux-arm-gnueabihf': 2.0.3
'@tauri-apps/cli-linux-arm64-gnu': 2.0.3
'@tauri-apps/cli-linux-arm64-musl': 2.0.3
'@tauri-apps/cli-linux-x64-gnu': 2.0.3
'@tauri-apps/cli-linux-x64-musl': 2.0.3
'@tauri-apps/cli-win32-arm64-msvc': 2.0.3
'@tauri-apps/cli-win32-ia32-msvc': 2.0.3
'@tauri-apps/cli-win32-x64-msvc': 2.0.3
'@tauri-apps/cli-darwin-arm64': 2.2.7
'@tauri-apps/cli-darwin-x64': 2.2.7
'@tauri-apps/cli-linux-arm-gnueabihf': 2.2.7
'@tauri-apps/cli-linux-arm64-gnu': 2.2.7
'@tauri-apps/cli-linux-arm64-musl': 2.2.7
'@tauri-apps/cli-linux-x64-gnu': 2.2.7
'@tauri-apps/cli-linux-x64-musl': 2.2.7
'@tauri-apps/cli-win32-arm64-msvc': 2.2.7
'@tauri-apps/cli-win32-ia32-msvc': 2.2.7
'@tauri-apps/cli-win32-x64-msvc': 2.2.7
'@tauri-apps/plugin-autostart@2.2.0':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.2.0
'@tauri-apps/plugin-deep-link@2.2.0':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.2.0
'@tauri-apps/plugin-global-shortcut@2.0.0':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.2.0
'@tauri-apps/plugin-http@2.0.1':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.2.0
'@tauri-apps/plugin-os@2.2.0':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.2.0
'@tauri-apps/plugin-shell@2.0.0':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.2.0
'@tauri-apps/plugin-websocket@2.0.0':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.2.0
'@tauri-apps/plugin-window@2.0.0-alpha.1':
dependencies:

259
src-tauri/Cargo.lock generated
View File

@@ -17,6 +17,18 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "ahash"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -41,6 +53,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-tzdata"
version = "0.1.1"
@@ -584,6 +602,13 @@ dependencies = [
name = "coco"
version = "0.1.0"
dependencies = [
"futures",
"lazy_static",
"log",
"once_cell",
"ordered-float",
"pizza-common",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -598,6 +623,7 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-theme",
"tauri-plugin-websocket",
"tokio",
]
[[package]]
@@ -611,7 +637,7 @@ dependencies = [
"cocoa-foundation",
"core-foundation 0.10.0",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"libc",
"objc",
]
@@ -738,7 +764,7 @@ dependencies = [
"bitflags 2.6.0",
"core-foundation 0.10.0",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1236,6 +1262,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1243,7 +1278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1257,6 +1292,12 @@ dependencies = [
"syn 2.0.90",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1282,6 +1323,21 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1289,6 +1345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1371,6 +1428,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1518,8 +1576,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1723,6 +1783,10 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
@@ -1853,10 +1917,26 @@ dependencies = [
]
[[package]]
name = "hyper-util"
version = "0.1.9"
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4"
dependencies = [
"bytes",
"futures-channel",
@@ -2321,6 +2401,23 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "native-tls"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2677,12 +2774,65 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-multimap"
version = "0.7.3"
@@ -2936,6 +3086,24 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pizza-common"
version = "0.1.0"
source = "git+https://github.com/infinilabs/pizza-common?branch=main#60ff2901bd302777b8c754cfd0bc436fac377838"
dependencies = [
"bytes",
"camino",
"getrandom 0.2.15",
"hashbrown 0.14.5",
"rand 0.8.5",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
"serde",
"serde_json",
"serde_with",
"uuid",
]
[[package]]
name = "pkg-config"
version = "0.3.31"
@@ -3307,9 +3475,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.8"
version = "0.12.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b"
checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -3324,11 +3492,13 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -3342,8 +3512,10 @@ dependencies = [
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-service",
"url",
"wasm-bindgen",
@@ -3483,6 +3655,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "schemars"
version = "0.8.21"
@@ -3516,6 +3697,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.6.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.22.0"
@@ -3796,7 +4000,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2",
@@ -4606,6 +4810,16 @@ dependencies = [
"syn 2.0.90",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.0"
@@ -4716,6 +4930,27 @@ dependencies = [
"winnow 0.6.22",
]
[[package]]
name = "tower"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
@@ -4943,6 +5178,12 @@ dependencies = [
"serde",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.0"

View File

@@ -6,7 +6,6 @@ authors = ["INFINI Labs"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
@@ -18,6 +17,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main"}
tauri = { version = "2.0.6", features = ["macos-private-api", "tray-icon", "image-png", "unstable"] }
tauri-plugin-shell = "2.0.0"
serde = { version = "1", features = ["derive"] }
@@ -31,8 +32,14 @@ tauri-plugin-oauth = { git = "https://github.com/FabianLars/tauri-plugin-oauth",
tauri-plugin-deep-link = "2.0.0"
tauri-plugin-single-instance = "2.0.0"
tauri-plugin-store = "2.2.0"
reqwest = "0.12.12"
futures = "0.3.31"
ordered-float = { version = "4.6.0", default-features = false }
lazy_static = "1.5.0"
log = "0.4.22"
tokio = "1.40.0"
once_cell = "1.20.2"
# tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
[profile.dev]
incremental = true # Compile your binary in smaller steps.

View File

@@ -0,0 +1,9 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::common::health::Status;
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct RequestAccessTokenResponse {
pub access_token: String,
pub expire_at: u32,
}

View File

@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct Connector {
pub id: String,
pub created: Option<String>,
pub updated: Option<String>,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub icon: Option<String>,
pub tags: Option<Vec<String>>,
pub url: Option<String>,
pub assets: Option<ConnectorAssets>,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct ConnectorAssets {
pub icons: Option<std::collections::HashMap<String, String>>,
}

View File

@@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
use crate::common::connector::Connector;
// The DataSource struct representing a datasource, which includes the Connector
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct DataSource {
pub id: String,
pub created: Option<String>,
pub updated: Option<String>,
pub r#type: Option<String>, // Using 'r#type' to escape the reserved keyword 'type'
pub name: Option<String>,
pub connector: Option<ConnectorConfig>,
pub connector_info: Option<Connector>,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct ConnectorConfig {
pub id: Option<String>,
pub config: Option<serde_json::Value>, // Using serde_json::Value to handle any type of config
}

View File

@@ -0,0 +1,16 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug,Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Green,
Yellow,
Red,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct Health {
pub services: HashMap<String, Status>,
pub status: Status,
}

View File

@@ -0,0 +1,7 @@
pub mod health;
pub mod profile;
pub mod server;
pub mod auth;
pub mod datasource;
pub mod connector;
pub mod search_response;

View File

@@ -0,0 +1,15 @@
use serde::{Serialize, Deserialize};
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct Preferences {
pub theme: String,
pub language: String,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct UserProfile {
pub name: String,
pub email: String,
pub avatar: String,
pub preferences: Preferences,
}

View File

@@ -0,0 +1,80 @@
use std::error::Error;
use reqwest::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResponse<T> {
pub took: u64,
pub timed_out: bool,
pub _shards: Shards,
pub hits: Hits<T>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Shards {
pub total: u64,
pub successful: u64,
pub skipped: u64,
pub failed: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Hits<T> {
pub total: Total,
pub max_score: Option<f32>,
pub hits: Vec<SearchHit<T>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Total {
pub value: u64,
pub relation: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchHit<T> {
pub _index: String,
pub _type: String,
pub _id: String,
pub _score: Option<f32>,
pub _source: T, // This will hold the type we pass in (e.g., DataSource)
}
pub async fn parse_search_results<T>(response: Response) -> Result<Vec<T>, Box<dyn Error>>
where
T: for<'de> Deserialize<'de> + std::fmt::Debug,
{
// Log the response status and headers
// dbg!(&response.status());
// dbg!(&response.headers());
// Parse the response body to a serde::Value
let body = response
.json::<Value>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
// Log the raw body before further processing
// dbg!(&body);
// Deserialize into the generic search response
let search_response: SearchResponse<T> = serde_json::from_value(body)
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
// Log the deserialized search response
// dbg!(&search_response);
// Collect the _source part from all hits
let results: Vec<T> = search_response
.hits
.hits
.into_iter()
.map(|hit| hit._source)
.collect();
// Log the final results before returning
// dbg!(&results);
Ok(results)
}

View File

@@ -0,0 +1,116 @@
use std::hash::{Hash, Hasher};
use serde::{Deserialize, Serialize};
use crate::common::profile::UserProfile;
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct Provider {
pub name: String,
pub icon: String,
pub website: String,
pub eula: String,
pub privacy_policy: String,
pub banner: String,
pub description: String,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct Version {
pub number: String,
}
#[derive(Debug, Clone,Serialize, Deserialize)]
pub struct Sso {
pub url: String,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct AuthProvider {
pub sso: Sso,
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct Server {
#[serde(default = "default_empty_string")] // Custom default function for empty string
pub id: String,
#[serde(default = "default_bool_type")]
pub builtin: bool,
pub name: String,
pub endpoint: String,
pub provider: Provider,
pub version: Version,
pub updated: String,
#[serde(default = "default_bool_type")]
pub public: bool,
#[serde(default = "default_available_type")]
pub available: bool,
#[serde(default = "default_user_profile_type")] // Custom default function for empty string
pub profile: Option<UserProfile>,
pub auth_provider: AuthProvider,
#[serde(default = "default_priority_type")]
pub priority: u32,
}
impl PartialEq for Server {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for Server {}
impl Hash for Server {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
#[derive(Debug,Clone, Serialize, Deserialize)]
pub struct ServerAccessToken {
#[serde(default = "default_empty_string")] // Custom default function for empty string
pub id: String,
pub access_token: String,
pub expired_at: u32, //unix timestamp in seconds
}
impl ServerAccessToken{
pub fn new(id: String, access_token: String, expired_at: u32) -> Self {
Self {
id,
access_token,
expired_at: expired_at,
}
}
}
impl PartialEq for ServerAccessToken {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for ServerAccessToken {}
impl Hash for ServerAccessToken {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
fn default_empty_string() -> String {
"".to_string() // Default to empty string if not provided
}
fn default_bool_type() -> bool {
false // Default to false if not provided
}
fn default_available_type() -> bool {
true
}
fn default_priority_type() -> u32 {
0
}
fn default_user_profile_type() -> Option<UserProfile> {
None
}

View File

@@ -1,12 +1,21 @@
mod autostart;
mod common;
mod server;
mod shortcut;
mod util;
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
use autostart::{change_autostart, enable_autostart};
#[cfg(target_os = "macos")]
use tauri::ActivationPolicy;
use tauri::{AppHandle, Emitter, Listener, Manager, WebviewWindow};
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime, WebviewWindow};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt;
use tokio::runtime::Runtime as RT; // Add this import
// Add this import
/// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
#[tauri::command]
fn change_window_height(handle: AppHandle, height: u32) {
@@ -80,12 +89,39 @@ pub fn run() {
change_autostart,
hide_coco,
switch_tray_icon,
// show_panel,
// hide_panel,
// close_panel
server::servers::add_coco_server,
server::servers::remove_coco_server,
server::servers::list_coco_servers,
server::servers::logout_coco_server,
server::servers::refresh_coco_server_info,
server::auth::handle_sso_callback,
server::profile::get_user_profiles,
server::datasource::get_datasources_by_server,
server::connector::get_connectors_by_server,
server::search::query_coco_servers,
// server::get_coco_server_health_info,
// server::get_coco_servers_health_info,
// server::get_user_profiles,
// server::get_coco_server_datasources,
// server::get_coco_server_connectors
])
.setup(|app| {
init(app.app_handle());
// Get app handle
let app_handle = app.handle().clone();
// Create a single Tokio runtime instance
let rt = RT::new().expect("Failed to create Tokio runtime");
// Use the runtime to spawn the async initialization tasks
rt.spawn(async move {
dbg!("Running async initialization tasks");
init(&app_handle).await; // Pass a reference to `app_handle`
dbg!("Async initialization tasks completed");
});
shortcut::enable_shortcut(app);
enable_tray(app);
@@ -108,13 +144,26 @@ pub fn run() {
dbg!(event.urls());
});
Ok(())
})
.run(ctx)
.expect("error while running tauri application");
}
fn init(_app_handle: &AppHandle) {
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
dbg!("Initializing...");
// Await the async functions to load the servers and tokens
if let Err(err) = load_or_insert_default_server(app_handle).await {
eprintln!("Failed to load servers: {}", err);
}
if let Err(err) = load_servers_token(app_handle).await {
eprintln!("Failed to load server tokens: {}", err);
}
dbg!("Initialization completed");
// let window: WebviewWindow = app_handle.get_webview_window("main").unwrap();
// let panel = window.to_panel().unwrap();
@@ -231,15 +280,15 @@ fn enable_tray(app: &mut tauri::App) {
let quit_i = MenuItem::with_id(app, "quit", "Quit Coco", true, None::<&str>).unwrap();
let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>).unwrap();
let open_i = MenuItem::with_id(app, "open", "Open Coco", true, None::<&str>).unwrap();
let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
let open_i = MenuItem::with_id(app, "open", "Show Coco", true, None::<&str>).unwrap();
// let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
// let hide_i = MenuItem::with_id(app, "hide", "Hide Coco", true, None::<&str>).unwrap();
let menu = MenuBuilder::new(app)
.item(&open_i)
.separator()
// .item(&hide_i)
.item(&about_i)
// .item(&about_i)
.item(&settings_i)
.separator()
.item(&quit_i)

View File

@@ -0,0 +1,92 @@
use std::fmt::format;
use reqwest::StatusCode;
use tauri::{AppHandle, Runtime};
use crate::common::auth::RequestAccessTokenResponse;
use crate::common::server::{Server, ServerAccessToken};
use crate::server::http_client::HttpClient;
use crate::server::profile::get_user_profiles;
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server};
use crate::util;
fn request_access_token_url(request_id: &str) -> String {
// Remove the endpoint part and keep just the path for the request
format!("/auth/request_access_token?request_id={}", request_id)
}
#[tauri::command]
pub async fn handle_sso_callback<R: Runtime>(
app_handle: AppHandle<R>,
server_id: String,
request_id: String,
code: String,
) -> Result<(), String> {
// Retrieve the server details using the server ID
let server = get_server_by_id(&server_id);
if let Some(mut server) = server {
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60*15));
let path = request_access_token_url( &request_id);
// Send the request for the access token using the util::http::HttpClient::get method
let response = HttpClient::get(&server_id, &path)
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
if response.status() == StatusCode::OK {
// Check if the response has a valid content length
if let Some(content_length) = response.content_length() {
if content_length > 0 {
// Deserialize the response body to get the access token
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
match token_result {
Ok(token) => {
// Save the access token for the server
let access_token = ServerAccessToken::new(
server_id.clone(),
token.access_token.clone(),
token.expire_at,
);
dbg!(&server_id, &request_id, &code, &token);
save_access_token(server_id.clone(), access_token);
persist_servers_token(&app_handle)?;
// Update the server's profile using the util::http::HttpClient::get method
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
dbg!(&profile);
match profile {
Ok(p) => {
server.profile = Some(p);
server.available = true;
save_server(&server);
persist_servers(&app_handle)?;
Ok(())
}
Err(e) => Err(format!("Failed to get user profile: {}", e)),
}
}
Err(e) => Err(format!("Failed to deserialize the token response: {}", e)),
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!(
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
response.status(),
path,
code,
response
))
}
} else {
Err(format!(
"Server not found for ID: {}, Request ID: {}, Code: {}",
server_id, request_id, code
))
}
}

View File

@@ -0,0 +1,147 @@
use crate::common::connector::Connector;
use crate::common::search_response::parse_search_results;
use crate::server::http_client::HttpClient;
use crate::server::servers::{get_all_servers, list_coco_servers};
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tauri::{AppHandle, Runtime};
lazy_static! {
static ref CONNECTOR_CACHE: Arc<RwLock<HashMap<String, HashMap<String, Connector>>>> = Arc::new(RwLock::new(HashMap::new()));
}
pub fn save_connectors_to_cache(server_id: &str, connectors: Vec<Connector>) {
let mut cache = CONNECTOR_CACHE.write().unwrap(); // Acquire write lock
let connectors_map: HashMap<String, Connector> = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
cache.insert(server_id.to_string(), connectors_map);
}
pub fn get_connector_by_id(server_id: &str, connector_id: &str) -> Option<Connector> {
let cache = CONNECTOR_CACHE.read().unwrap(); // Async read lock
let server_cache = cache.get(server_id)?;
let connector = server_cache.get(connector_id)?;
Some(connector.clone())
}
pub async fn refresh_all_connectors<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<(), String> {
dbg!("Attempting to refresh all connectors");
let servers = get_all_servers();
// Collect all the tasks for fetching and refreshing connectors
let mut serverMap = HashMap::new();
for server in servers {
dbg!("start fetch connectors for server: {}", &server.id);
let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await {
Ok(connectors) => {
let connectors_map: HashMap<String, Connector> = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
connectors_map
}
Err(e) => {
dbg!("Failed to get connectors for server {}: {}", &server.id, e);
HashMap::new() // Return empty map on failure
}
};
serverMap.insert(server.id.clone(), connectors);
dbg!("end fetch connectors for server: {}", &server.id);
}
// After all tasks have finished, perform a read operation on the cache
let cache_size = {
// Insert connectors into the cache (async write lock)
let mut cache = CONNECTOR_CACHE.write().unwrap(); // Async write lock
cache.clear();
cache.extend(serverMap);
// let cache = CONNECTOR_CACHE.read().await; // Async read lock
cache.len()
};
dbg!("finished refresh connectors: {:?}", cache_size);
Ok(())
}
pub async fn get_connectors_from_cache_or_remote(server_id: &str) -> Result<Vec<Connector>, String> {
// Acquire the read lock and check cache for connectors
let cache = CONNECTOR_CACHE.read().unwrap(); // Acquire read lock
if let Some(connectors) = cache.get(server_id).cloned() {
return Ok(connectors.values().cloned().collect());
}
// Drop the read lock before performing async operations
drop(cache);
// If the cache is empty, fetch connectors from the remote server
let connectors = fetch_connectors_by_server(server_id).await?;
// Convert the Vec<Connector> into HashMap<String, Connector>
let connectors_map: HashMap<String, Connector> = connectors.clone()
.into_iter()
.map(|connector| (connector.id.clone(), connector)) // Assuming Connector has an `id` field
.collect();
// Optionally, update the cache after fetching data from remote
let mut cache = CONNECTOR_CACHE.write().unwrap(); // Acquire write lock
cache.insert(server_id.to_string(), connectors_map.clone());
Ok(connectors)
}
pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, String> {
dbg!("start get_connectors_by_server: id =", &id);
// Use the generic GET method from HttpClient
let resp = HttpClient::get(&id, "/connector/_search")
.await
.map_err(|e| {
dbg!("Error fetching connector for id {}: {}", &id, &e);
format!("Error fetching connector: {}", e)
})?;
// Log the raw response status and headers
dbg!("Response status: {:?}", resp.status());
dbg!("Response headers: {:?}", resp.headers());
// Ensure the response body is not empty or invalid
if resp.status().is_success() {
dbg!("Received successful response for id: {}", &id);
} else {
dbg!("Failed to fetch connectors. Response status: {}", resp.status());
}
// Parse the search results directly from the response body
let datasources: Vec<Connector> = parse_search_results(resp).await.map_err(|e| {
dbg!("Error parsing search results for id {}: {}", &id, &e);
e.to_string()
})?;
// Log the parsed results
dbg!("Parsed connectors: {:?}", &datasources);
// Save the connectors to the cache
save_connectors_to_cache(&id, datasources.clone());
dbg!("end get_connectors_by_server: id =", &id);
return Ok(datasources);
}
#[tauri::command]
pub async fn get_connectors_by_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<Vec<Connector>, String> {
//fetch_connectors_by_server
let connectors = fetch_connectors_by_server(&id).await?;
Ok(connectors)
}

View File

@@ -0,0 +1,138 @@
use crate::common::datasource::DataSource;
use crate::common::search_response::parse_search_results;
use crate::server::connector::{fetch_connectors_by_server, get_connector_by_id, get_connectors_by_server, get_connectors_from_cache_or_remote};
use crate::server::http_client::HttpClient;
use crate::server::servers::{get_all_servers, list_coco_servers};
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tauri::{AppHandle, Runtime};
use crate::common::connector::Connector;
lazy_static! {
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String,HashMap<String,DataSource>>>> = Arc::new(RwLock::new(HashMap::new()));
}
pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
let mut cache = DATASOURCE_CACHE.write().unwrap(); // Acquire write lock
let datasources_map: HashMap<String, DataSource> = datasources
.into_iter()
.map(|datasource| {
(datasource.id.clone(), datasource)
})
.collect();
cache.insert(server_id.to_string(), datasources_map);
}
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
dbg!("cache: {:?}", &cache);
let server_cache = cache.get(server_id)?; // Get the server's cache
Some(server_cache.clone())
}
pub async fn refresh_all_datasources<R: Runtime>(
app_handle: &AppHandle<R>,
) -> Result<(), String> {
dbg!("Attempting to refresh all datasources");
let servers = get_all_servers();
let mut serverMap = HashMap::new();
for server in servers {
dbg!("fetch datasources for server: {}", &server.id);
// Attempt to get datasources by server, and continue even if it fails
let mut connectors = match get_datasources_by_server(app_handle.clone(), server.id.clone()).await {
Ok(connectors) => {
// Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors
.into_iter()
.map(|mut connector| {
(connector.id.clone(), connector)
})
.collect();
dbg!("connectors_map: {:?}", &connectors_map);
connectors_map
}
Err(e) => {
dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
HashMap::new()
}
};
let mut new_map = HashMap::new();
for (id, mut datasource) in connectors.iter() {
dbg!("connector: {:?}", &datasource);
if let Some(existing_connector) = get_connector_by_id(&server.id, &datasource.id) {
// If found in cache, update the connector's info
dbg!("Found connector in cache for {}: {:?}", &datasource.id, &existing_connector);
let mut obj = datasource.clone();
obj.connector_info = Some(existing_connector);
new_map.insert(id.clone(), obj);
}
}
serverMap.insert(server.id.clone(), new_map);
}
// Perform a read operation after all writes are done
let cache_size = {
let mut cache = DATASOURCE_CACHE.write().unwrap();
cache.clear();
cache.extend(serverMap);
cache.len()
};
dbg!("datasource_map size: {:?}", cache_size);
Ok(())
}
#[tauri::command]
pub async fn get_datasources_by_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<Vec<DataSource>, String> {
dbg!("get_datasources_by_server: id = {}", &id);
// Perform the async HTTP request outside the cache lock
let resp = HttpClient::get(&id, "/datasource/_search")
.await
.map_err(|e| {
dbg!("Error fetching datasource: {}", &e);
format!("Error fetching datasource: {}", e)
})?;
// Parse the search results from the response
let mut datasources:Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
dbg!("Error parsing search results: {}", &e);
e.to_string()
})?;
// let connectors=fetch_connectors_by_server(id.as_str()).await?;
//
// // Convert the Vec<Connector> into HashMap<String, Connector>
// let connectors_map: HashMap<String, Connector> = connectors
// .into_iter()
// .map(|connector| (connector.id.clone(), connector)) // Assuming Connector has an `id` field
// .collect();
//
// for datasource in datasources.iter_mut() {
// if let Some(connector) = &datasource.connector {
// if let Some(connector_id) = connector.id.as_ref() {
// if let Some(existing_connector) = connectors_map.get(connector_id) {
// // If found in cache, update the connector's info
// datasource.connector_info = Some(existing_connector.clone());
// }
// }
// }
// }
dbg!("Parsed datasources: {:?}", &datasources);
// Save the updated datasources to cache
save_datasource_to_cache(&id, datasources.clone());
Ok(datasources)
}

View File

@@ -0,0 +1,79 @@
use crate::COCO_TAURI_STORE;
use core::panic;
use futures::stream::FuturesUnordered;
use futures::FutureExt;
use futures::StreamExt;
use futures::TryFutureExt;
use ordered_float::OrderedFloat;
use reqwest::Client;
use serde::Serialize;
use serde_json::Map as JsonMap;
use serde_json::Value as Json;
use std::collections::HashMap;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri::Runtime;
use tauri_plugin_store::StoreExt;
use crate::util::http::HTTP_CLIENT;
fn health_url(endpoint: &str) -> String {
format!("{endpoint}/health")
}
#[tauri::command]
pub async fn get_coco_server_health_info(endpoint: String) -> bool {
let response = match HTTP_CLIENT
.get(health_url(&endpoint))
.send()
.map_err(|_request_err| ())
.await
{
Ok(response) => response,
Err(_) => return false,
};
let json: JsonMap<String, Json> = response.json().await.expect("invalid response");
let status = json
.get("status")
.expect("response does not have a [status] field")
.as_str()
.expect("status field is not a string");
status != "red"
}
#[tauri::command]
pub async fn get_coco_servers_health_info<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<HashMap<String, bool>, ()> {
// let coco_server_endpoints = _list_coco_server_endpoints(&app_handle).await?;
//
// let mut futures = FuturesUnordered::new();
// for coco_server_endpoint in coco_server_endpoints {
// let request_future = HTTP_CLIENT.get(health_url(&coco_server_endpoint)).send();
// futures.push(request_future.map(|request_result| (coco_server_endpoint, request_result)));
// }
//
// let mut health_info = HashMap::new();
//
// while let Some((endpoint, res_response)) = futures.next().await {
// let healthy = match res_response {
// Ok(response) => {
// let json: JsonMap<String, Json> = response.json().await.expect("invalid response");
// let status = json
// .get("status")
// .expect("response does not have a [status] field")
// .as_str()
// .expect("status field is not a string");
// status != "red"
// }
// Err(_) => false,
// };
//
// health_info.insert(endpoint, healthy);
// }
//
// Ok(health_info)
Ok()
}

View File

@@ -0,0 +1,144 @@
use std::future::Future;
use std::time::Duration;
use lazy_static::lazy_static;
use tauri::AppHandle;
use crate::server::servers::{get_server_by_id, get_server_token};
use once_cell::sync::Lazy;
use tokio::sync::Mutex;
use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
let client = Client::builder()
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds
.danger_accept_invalid_certs(true) // example for self-signed certificates
.build()
.expect("Failed to build client");
Mutex::new(client)
});
pub struct HttpClient;
impl HttpClient {
// Utility function for properly joining paths
pub(crate) fn join_url(base: &str, path: &str) -> String {
let base = base.trim_end_matches('/');
let path = path.trim_start_matches('/');
format!("{}/{}", base, path)
}
pub async fn send_raw_request(
method: Method,
url: &str,
headers: Option<reqwest::header::HeaderMap>,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
let request_builder = Self::get_request_builder(method, url, headers, body).await;
// Send the request
let response = match request_builder.send().await {
Ok(resp) => resp,
Err(e) => {
dbg!("Failed to send request: {}", &e);
return Err(format!("Failed to send request: {}", e));
}
};
Ok(response)
}
pub async fn get_request_builder(
method: Method,
url: &str,
headers: Option<reqwest::header::HeaderMap>,
body: Option<reqwest::Body>,
) -> RequestBuilder {
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
// Build the request
let mut request_builder = client.request(method.clone(), url);
// Add headers if present
if let Some(h) = headers {
request_builder = request_builder.headers(h);
}
// Add body if present
if let Some(b) = body {
request_builder = request_builder.body(b);
}
request_builder
}
pub async fn send_request(
server_id: &str,
method: Method,
path: &str,
body: Option<reqwest::Body>,
) -> Result<reqwest::Response, String> {
// Fetch the server using the server_id
let server = get_server_by_id(server_id);
if let Some(s) = server {
// Construct the URL
let url = HttpClient::join_url(&s.endpoint, path);
// dbg!(&url);
// dbg!(&server_id);
// Retrieve the token for the server (token is optional)
let token = get_server_token(server_id).map(|t| t.access_token.clone());
// Create headers map (optional "X-API-TOKEN" header)
let mut headers = reqwest::header::HeaderMap::new();
if let Some(t) = token {
headers.insert("X-API-TOKEN", reqwest::header::HeaderValue::from_str(&t).unwrap());
}
// dbg!(&headers);
Self::send_raw_request(method, &url, Some(headers), body).await
} else {
Err("Server not found".to_string())
}
}
// Convenience method for GET requests (as it's the most common)
pub async fn get(
server_id: &str,
path: &str,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::GET, path, None).await
}
// Convenience method for POST requests
pub async fn post(
server_id: &str,
path: &str,
body: reqwest::Body,
) -> Result<reqwest::Response, String> {
HttpClient::send_request( server_id, Method::POST, path, Some(body)).await
}
// Convenience method for PUT requests
pub async fn put(
server_id: &str,
path: &str,
body: reqwest::Body,
) -> Result<reqwest::Response, String> {
HttpClient::send_request( server_id, Method::PUT, path, Some(body)).await
}
// Convenience method for DELETE requests
pub async fn delete(
server_id: &str,
path: &str,
) -> Result<reqwest::Response, String> {
HttpClient::send_request(server_id, Method::DELETE, path, None).await
}
}

View File

@@ -0,0 +1,20 @@
//! This file contains Rust APIs related to Coco Server management.
use futures::FutureExt;
use futures::StreamExt;
use futures::TryFutureExt;
use reqwest::Client;
use serde::Serialize;
use std::sync::LazyLock;
use tauri::Runtime;
use tauri_plugin_store::StoreExt;
pub mod servers;
pub mod auth;
// pub mod health;
pub mod datasource;
pub mod connector;
pub mod search;
pub mod profile;
pub mod http_client;

View File

@@ -0,0 +1,26 @@
use crate::common::profile::UserProfile;
use crate::server::http_client::HttpClient;
use tauri::{AppHandle, Runtime};
#[tauri::command]
pub async fn get_user_profiles<R: Runtime>(
app_handle: AppHandle<R>,
server_id: String,
) -> Result<UserProfile, String> {
// Use the generic GET method from HttpClient
let response = HttpClient::get( &server_id, "/account/profile")
.await
.map_err(|e| format!("Error fetching profile: {}", e))?;
if let Some(content_length) = response.content_length() {
if content_length > 0 {
let profile: UserProfile = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
return Ok(profile);
}
}
Err("Profile not found or empty response".to_string())
}

View File

@@ -0,0 +1,182 @@
use std::collections::HashMap;
use ordered_float::OrderedFloat;
use reqwest::Method;
use serde::Serialize;
use tauri::{ AppHandle, Runtime};
use serde_json::Map as JsonMap;
use serde_json::Value as Json;
use crate::server::http_client::{HttpClient, HTTP_CLIENT};
use crate::server::servers::{get_all_servers, get_server_token, get_servers_as_hashmap};
use futures::stream::{FuturesUnordered, StreamExt};
struct DocumentsSizedCollector {
size: u64,
/// Documents and socres
///
/// Sorted by score, in descending order.
docs: Vec<(JsonMap<String, Json>, OrderedFloat<f64>)>,
}
impl DocumentsSizedCollector {
fn new(size: u64) -> Self {
// there will be size + 1 documents in docs at max
let docs = Vec::with_capacity((size + 1).try_into().expect("overflow"));
Self { size, docs }
}
fn push(&mut self, item: JsonMap<String, Json>, score: f64) {
let score = OrderedFloat(score);
let insert_idx = match self.docs.binary_search_by(|(_doc, s)| score.cmp(s)) {
Ok(idx) => idx,
Err(idx) => idx,
};
self.docs.insert(insert_idx, (item, score));
// cast usize to u64 is safe
if self.docs.len() as u64 > self.size {
self.docs.truncate(self.size.try_into().expect(
"self.size < a number of type usize, it can be expressed using usize, we are safe",
));
}
}
fn documents(self) -> impl ExactSizeIterator<Item = JsonMap<String, Json>> {
self.docs.into_iter().map(|(doc, _score)| doc)
}
}
#[derive(Debug, Serialize)]
pub struct QueryResponse {
failed_coco_servers: Vec<String>,
documents: Vec<JsonMap<String, Json>>,
total_hits: u64,
}
fn get_name(provider_info: &JsonMap<String, Json>) -> &str {
provider_info
.get("name")
.expect("provider info does not have a [name] field")
.as_str()
.expect("field [name] should be a string")
}
fn get_public(provider_info: &JsonMap<String, Json>) -> bool {
provider_info
.get("public")
.expect("provider info does not have a [public] field")
.as_bool()
.expect("field [public] should be a string")
}
#[tauri::command]
pub async fn query_coco_servers<R: Runtime>(
app_handle: AppHandle<R>,
from: u64,
size: u64,
query_strings: HashMap<String, String>,
) -> Result<QueryResponse, ()> {
println!(
"DBG: query_coco_servers, from: {} size: {} query_strings {:?}",
from, size, query_strings
);
let coco_servers = get_servers_as_hashmap();
let mut futures = FuturesUnordered::new();
let size_for_each_request = (from + size).to_string();
for (_,server) in coco_servers {
let url = HttpClient::join_url(&server.endpoint, "/query/_search");
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
let mut request_builder = client.request(Method::GET, url);
if !server.public {
if let Some(token) = get_server_token(&server.id).map(|t| t.access_token) {
request_builder = request_builder.header("X-API-TOKEN", token);
}
}
let query_strings_cloned = query_strings.clone(); // Clone for each iteration
let size=size_for_each_request.clone();
let future = async move {
let response = request_builder
.query(&[("from", "0"), ("size", size.as_str())])
.query(&query_strings_cloned) // Use cloned instance
.send()
.await;
(server.id, response)
};
futures.push(future);
}
let mut total_hits = 0;
let mut failed_coco_servers = Vec::new();
let mut docs_collector = DocumentsSizedCollector::new(size);
while let Some((name, res_response)) = futures.next().await {
match res_response {
Ok(response) => {
if let Ok(mut body) = response.json::<JsonMap<String, Json>>().await {
if let Some(Json::Object(mut hits)) = body.remove("hits") {
if let Some(Json::Number(hits_total_value)) = hits.get("total").and_then(|t| t.get("value")) {
if let Some(hits_total) = hits_total_value.as_u64() {
total_hits += hits_total;
}
}
if let Some(Json::Array(hits_hits)) = hits.remove("hits") {
for hit in hits_hits.into_iter().filter_map(|h| h.as_object().cloned()) {
if let (Some(Json::Number(score)), Some(Json::Object(source))) = (hit.get("_score"), hit.get("_source")) {
if let Some(score_value) = score.as_f64() {
docs_collector.push(source.clone(), score_value);
}
}
}
}
}
}
}
Err(_) => failed_coco_servers.push(name),
}
}
let docs=docs_collector.documents().collect();
// dbg!(&total_hits);
// dbg!(&failed_coco_servers);
// dbg!(&docs);
Ok(QueryResponse {
failed_coco_servers,
total_hits,
documents:docs ,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_docs_collector() {
let mut collector = DocumentsSizedCollector::new(3);
for i in 0..10 {
collector.push(JsonMap::new(), i as f64);
}
assert_eq!(collector.docs.len(), 3);
assert!(collector
.docs
.into_iter()
.map(|(_doc, score)| score)
.eq(vec![
OrderedFloat(9.0),
OrderedFloat(8.0),
OrderedFloat(7.0)
]));
}
}

View File

@@ -0,0 +1,516 @@
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
use crate::server::http_client::HttpClient;
use crate::COCO_TAURI_STORE;
use lazy_static::lazy_static;
use reqwest::{Method, StatusCode};
use serde_json::from_value;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::RwLock;
use tauri::AppHandle;
use tauri::Runtime;
use tauri_plugin_store::StoreExt;
use crate::server::connector::refresh_all_connectors;
use crate::server::datasource::refresh_all_datasources;
// Assuming you're using serde_json
lazy_static! {
static ref SERVER_CACHE: Arc<RwLock<HashMap<String,Server>>> = Arc::new(RwLock::new(HashMap::new()));
static ref SERVER_TOKEN: Arc<RwLock<HashMap<String,ServerAccessToken>>> = Arc::new(RwLock::new(HashMap::new()));
}
fn check_server_exists(id: &str) -> bool {
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
cache.contains_key(id)
}
pub fn get_server_by_id(id: &str) -> Option<Server> {
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
cache.get(id).cloned()
}
pub fn get_server_token(id: &str) -> Option<ServerAccessToken> {
let cache = SERVER_TOKEN.read().unwrap(); // Acquire read lock
cache.get(id).cloned()
}
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
let mut cache = SERVER_TOKEN.write().unwrap();
cache.insert(server_id, token).is_none()
}
fn check_endpoint_exists(endpoint: &str) -> bool {
let cache = SERVER_CACHE.read().unwrap();
cache.values().any(|server| server.endpoint == endpoint)
}
pub fn save_server(server: &Server) -> bool {
let mut cache = SERVER_CACHE.write().unwrap();
cache.insert(server.id.clone(), server.clone()).is_none() // If the server id did not exist, `insert` will return `None`
}
fn remove_server_by_id(id: String) -> bool {
dbg!("remove server by id:", &id);
let mut cache = SERVER_CACHE.write().unwrap();
let deleted = cache.remove(id.as_str());
deleted.is_some()
}
pub fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let cache = SERVER_CACHE.read().unwrap(); // Acquire a read lock, not a write lock, since you're not modifying the cache
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
let servers: Vec<Server> = cache.values().cloned().collect();
// Serialize the servers into JSON automatically
let json_servers: Vec<JsonValue> = servers
.into_iter()
.map(|server| serde_json::to_value(server).expect("Failed to serialize server")) // Automatically serialize all fields
.collect();
// dbg!(format!("persist servers: {:?}", &json_servers));
// Save the serialized servers to Tauri's store
app_handle
.store(COCO_TAURI_STORE)
.expect("create or load a store should never fail")
.set(COCO_SERVERS, json_servers);
Ok(())
}
pub fn remove_server_token(id: &str) -> bool {
dbg!("remove server token by id:", &id);
let mut cache = SERVER_TOKEN.write().unwrap();
cache.remove(id).is_some()
}
pub fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
let cache = SERVER_TOKEN.read().unwrap(); // Acquire a read lock, not a write lock, since you're not modifying the cache
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
let servers: Vec<ServerAccessToken> = cache.values().cloned().collect();
// Serialize the servers into JSON automatically
let json_servers: Vec<JsonValue> = servers
.into_iter()
.map(|server| serde_json::to_value(server).expect("Failed to serialize access_tokens")) // Automatically serialize all fields
.collect();
dbg!(format!("persist servers token: {:?}", &json_servers));
// Save the serialized servers to Tauri's store
app_handle
.store(COCO_TAURI_STORE)
.expect("create or load a store should never fail")
.set(COCO_SERVER_TOKENS, json_servers);
Ok(())
}
// Function to get the default server if the request or parsing fails
fn get_default_server() -> Server {
Server {
id: "default_coco_server".to_string(),
builtin: true,
name: "Coco Cloud".to_string(),
endpoint: "https://coco.infini.cloud".to_string(),
provider: Provider {
name: "INFINI Labs".to_string(),
icon: "https://coco.infini.cloud/icon.png".to_string(),
website: "http://infinilabs.com".to_string(),
eula: "http://infinilabs.com/eula.txt".to_string(),
privacy_policy: "http://infinilabs.com/privacy_policy.txt".to_string(),
banner: "https://coco.infini.cloud/banner.jpg".to_string(),
description: "Coco AI Server - Search, Connect, Collaborate, AI-powered enterprise search, all in one space.".to_string(),
},
version: Version {
number: "1.0.0_SNAPSHOT".to_string(),
},
updated: "2025-01-24T12:12:17.326286927+08:00".to_string(),
public: false,
available: true,
profile: None,
auth_provider: AuthProvider {
sso: Sso {
url: "https://coco.infini.cloud/sso/login/".to_string(),
},
},
priority: 0,
}
}
pub async fn load_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<ServerAccessToken>, String> {
dbg!("Attempting to load servers token");
let store = app_handle
.store(COCO_TAURI_STORE)
.expect("create or load a store should not fail");
// Check if the servers key exists in the store
if !store.has(COCO_SERVER_TOKENS) {
return Err("Failed to read servers from store: No servers found".to_string());
}
// Load servers from store
let servers: Option<JsonValue> = store.get(COCO_SERVER_TOKENS);
// Handle the None case
let servers = servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?;
// Convert each item in the JsonValue array to a Server
if let JsonValue::Array(servers_array) = servers {
// Deserialize each JsonValue into Server, filtering out any errors
let deserialized_tokens: Vec<ServerAccessToken> = servers_array
.into_iter()
.filter_map(|server_json| from_value(server_json).ok()) // Only keep valid Server instances
.collect();
if deserialized_tokens.is_empty() {
return Err("Failed to deserialize any servers from the store.".to_string());
}
for server in deserialized_tokens.iter() {
save_access_token(server.id.clone(), server.clone());
}
dbg!(format!("loaded {:?} servers's token", &deserialized_tokens.len()));
Ok(deserialized_tokens)
} else {
Err("Failed to read servers from store: Invalid format".to_string())
}
}
pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
let store = app_handle
.store(COCO_TAURI_STORE)
.expect("create or load a store should not fail");
// Check if the servers key exists in the store
if !store.has(COCO_SERVERS) {
return Err("Failed to read servers from store: No servers found".to_string());
}
// Load servers from store
let servers: Option<JsonValue> = store.get(COCO_SERVERS);
// Handle the None case
let servers = servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?;
// Convert each item in the JsonValue array to a Server
if let JsonValue::Array(servers_array) = servers {
// Deserialize each JsonValue into Server, filtering out any errors
let deserialized_servers: Vec<Server> = servers_array
.into_iter()
.filter_map(|server_json| from_value(server_json).ok()) // Only keep valid Server instances
.collect();
if deserialized_servers.is_empty() {
return Err("Failed to deserialize any servers from the store.".to_string());
}
for server in deserialized_servers.iter() {
save_server(&server);
}
// dbg!(format!("load servers: {:?}", &deserialized_servers));
Ok(deserialized_servers)
} else {
Err("Failed to read servers from store: Invalid format".to_string())
}
}
/// Function to load servers or insert a default one if none exist
pub async fn load_or_insert_default_server<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
dbg!("Attempting to load or insert default server");
let exists_servers = load_servers(&app_handle).await;
if exists_servers.is_ok() && !exists_servers.as_ref()?.is_empty() {
dbg!(format!("loaded {} servers", &exists_servers.clone()?.len()));
return exists_servers;
}
let default = get_default_server();
save_server(&default);
dbg!("loaded default servers");
Ok(vec![default])
}
#[tauri::command]
pub async fn list_coco_servers<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<Vec<Server>, String> {
let servers: Vec<Server> =get_all_servers();
Ok(servers)
}
pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
let cache = SERVER_CACHE.read().unwrap();
cache.clone()
}
pub fn get_all_servers() -> Vec<Server> {
let cache = SERVER_CACHE.read().unwrap();
cache.values().cloned().collect()
}
/// We store added Coco servers in the Tauri store using this key.
pub const COCO_SERVERS: &str = "coco_servers";
const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
#[tauri::command]
pub async fn refresh_coco_server_info<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<Server, String> {
// Retrieve the server from the cache
let server = {
let cache = SERVER_CACHE.read().unwrap();
cache.get(&id).cloned()
};
if let Some(server) = server {
let is_builtin = server.builtin;
let profile = server.profile;
// Use the HttpClient to send the request
let response = HttpClient::get(&id, "/provider/_info") // Assuming "/provider-info" is the endpoint
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
if response.status() == StatusCode::OK {
if let Some(content_length) = response.content_length() {
if content_length > 0 {
let new_coco_server: Result<Server, _> = response.json().await;
match new_coco_server {
Ok(mut server) => {
server.id = id;
server.builtin = is_builtin;
server.available = true;
server.profile = profile;
trim_endpoint_last_forward_slash(&mut server);
save_server(&server);
persist_servers(&app_handle).expect("Failed to persist coco servers.");
//refresh connectors and datasources
if let Err(err) = refresh_all_connectors(&app_handle).await {
return Err(format!("Failed to load server connectors: {}", err))
}
if let Err(err) = refresh_all_datasources(&app_handle).await {
return Err(format!("Failed to load server datasources: {}", err))
}
Ok(server)
}
Err(e) => {
Err(format!("Failed to deserialize the response: {}", e))
}
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!("Request failed with status: {}", response.status()))
}
} else {
Err("Server not found.".to_string())
}
}
#[tauri::command]
pub async fn add_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
endpoint: String,
) -> Result<Server, String> {
load_or_insert_default_server(&app_handle).await
.expect("Failed to load default servers");
// Remove the trailing '/' from the endpoint to ensure correct URL construction
let endpoint = endpoint.trim_end_matches('/');
// Check if the server with this endpoint already exists
if check_endpoint_exists(endpoint) {
dbg!(format!("This Coco server has already been registered: {:?}", &endpoint));
return Err("This Coco server has already been registered.".into());
}
let url = provider_info_url(&endpoint);
// Use the HttpClient to fetch provider information
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None)
.await
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
dbg!(format!("Get provider info response: {:?}", &response));
// Check if the response status is OK (200)
if response.status() == StatusCode::OK {
if let Some(content_length) = response.content_length() {
if content_length > 0 {
let new_coco_server: Result<Server, _> = response.json().await;
match new_coco_server {
Ok(mut server) => {
// Perform necessary checks and adjustments on the server data
trim_endpoint_last_forward_slash(&mut server);
if server.id.is_empty() {
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
}
if server.name.is_empty() {
server.name = "Coco Cloud".to_string();
}
// Save the new server to the cache
save_server(&server);
// Persist the servers to the store
persist_servers(&app_handle)
.expect("Failed to persist Coco servers.");
dbg!(format!("Successfully registered server: {:?}", &endpoint));
Ok(server)
}
Err(e) => {
Err(format!("Failed to deserialize the response: {}", e))
}
}
} else {
Err("Received empty response body.".to_string())
}
} else {
Err("Could not determine the content length.".to_string())
}
} else {
Err(format!("Request failed with status: {}", response.status()))
}
}
#[tauri::command]
pub async fn remove_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<(), ()> {
remove_server_token(id.as_str());
remove_server_by_id(id);
persist_servers(&app_handle).expect("failed to save servers");
persist_servers_token(&app_handle).expect("failed to save server tokens");
Ok(())
}
#[tauri::command]
pub async fn logout_coco_server<R: Runtime>(
app_handle: AppHandle<R>,
id: String,
) -> Result<(), String> {
dbg!("Attempting to log out server by id:", &id);
// Check if server token exists
if let Some(token) = get_server_token(id.as_str()) {
dbg!("Found server token for id:", &id);
// Remove the server token from cache
remove_server_token(id.as_str());
// Persist the updated tokens
if let Err(e) = persist_servers_token(&app_handle) {
dbg!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
return Err(format!("Failed to save tokens: {}", &e));
}
} else {
// Log the case where server token is not found
dbg!("No server token found for id: {}", &id);
}
// Check if the server exists
if let Some(mut server) = get_server_by_id(id.as_str()) {
dbg!("Found server for id:", &id);
// Clear server profile
server.profile = None;
// Save the updated server data
save_server(&server);
// Persist the updated server data
if let Err(e) = persist_servers(&app_handle) {
dbg!("Failed to save server for id: {}. Error: {:?}", &id, &e);
return Err(format!("Failed to save server: {}", &e));
}
} else {
// Log the case where server is not found
dbg!("No server found for id: {}", &id);
return Err(format!("No server found for id: {}", id));
}
dbg!("Successfully logged out server with id:", &id);
Ok(())
}
/// Removes the trailing slash from the server's endpoint if present.
fn trim_endpoint_last_forward_slash(server: &mut Server) {
if server.endpoint.ends_with('/') {
server.endpoint.pop(); // Remove the last character
while server.endpoint.ends_with('/') {
server.endpoint.pop();
}
}
}
/// Helper function to construct the provider info URL.
fn provider_info_url(endpoint: &str) -> String {
format!("{endpoint}/provider/_info")
}
#[test]
fn test_trim_endpoint_last_forward_slash() {
let mut server = Server {
id: "test".to_string(),
builtin: false,
name: "".to_string(),
endpoint: "https://example.com///".to_string(),
provider: Provider {
name: "".to_string(),
icon: "".to_string(),
website: "".to_string(),
eula: "".to_string(),
privacy_policy: "".to_string(),
banner: "".to_string(),
description: "".to_string(),
},
version: Version {
number: "".to_string(),
},
updated: "".to_string(),
public: false,
available: false,
profile: None,
auth_provider: AuthProvider {
sso: Sso {
url: "".to_string(),
},
},
priority: 0,
};
trim_endpoint_last_forward_slash(&mut server);
assert_eq!(server.endpoint, "https://example.com");
}

View File

@@ -1,3 +1,4 @@
use crate::COCO_TAURI_STORE;
use tauri::App;
use tauri::AppHandle;
use tauri::Manager;
@@ -8,9 +9,6 @@ use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::JsonValue;
use tauri_plugin_store::StoreExt;
/// Tauri store name
const COCO_TAURI_STORE: &str = "coco_tauri_store";
/// Tauri's store is a key-value database, we use it to store our registered
/// global shortcut.
///

View File

View File

@@ -1,27 +0,0 @@
import { useTranslation } from "react-i18next";
import "./i18n";
import CommandInput from "./components/CommandInput";
function App() {
const { t } = useTranslation();
return (
<div
className={`
bg-background
w-screen h-screen
`}
>
<main className="w-[100%] h-[100%] flex flex-col items-center justify-center">
<div className="text-xl text-primary">{t("welcome")}</div>
<div className="mx-0 mt-5 w-[100%]">
<CommandInput />
</div>
</main>
</div>
);
}
export default App;

View File

@@ -60,7 +60,7 @@ export const tauriFetch = async <T = any>({
headers["Content-Type"] = "application/json";
}
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || (auth && auth[endpoint_http]?.token) || "";
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || (auth && auth[endpoint_http]?.token) || undefined;
// debug API
const requestInfo = {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,435 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
CircleAlert,
Bolt,
X,
SquareArrowRight,
// UserRoundPen,
} from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import source_default_img from "@/assets/images/source_default.png";
import source_default_dark_img from "@/assets/images/source_default_dark.png";
import file_efault_img from "@/assets/images/file_efault.png";
import { useTheme } from "@/contexts/ThemeContext";
import { useConnectStore } from "@/stores/connectStore";
type ISearchData = Record<string, any[]>;
interface DropdownListProps {
selected: (item: any) => void;
suggests: any[];
SearchData: ISearchData;
IsError: boolean;
isSearchComplete: boolean;
isChatMode: boolean;
}
function DropdownList({
selected,
suggests,
SearchData,
IsError,
isChatMode,
}: DropdownListProps) {
let globalIndex = 0;
const globalItemIndexMap: any[] = [];
// const letterFirstIndex: any = {
// a: 0,
// s: 0,
// d: 0,
// f: 0,
// };
const { theme } = useTheme();
const connector_data = useConnectStore((state) => state.connector_data);
const datasourceData = useConnectStore((state) => state.datasourceData);
const endpoint_http = useAppStore((state) => state.endpoint_http);
const setSourceData = useSearchStore((state) => state.setSourceData);
const [showError, setShowError] = useState<boolean>(IsError);
const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [selectedName, setSelectedName] = useState<string>("");
const [showIndex, setShowIndex] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
isChatMode && setSelectedItem(null);
}, [isChatMode]);
const handleOpenURL = async (url: string) => {
if (!url) return;
try {
if (isTauri()) {
await open(url);
// console.log("URL opened in default browser");
}
} catch (error) {
console.error("Failed to open URL:", error);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// console.log(
// "handleKeyDown",
// e.key,
// showIndex,
// e.key >= "0" && e.key <= "9" && showIndex
// );
if (!suggests.length) return;
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedItem((prev) => {
const res =
prev === null || prev === 0 ? suggests.length - 1 : prev - 1;
return res;
});
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedItem((prev) =>
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
);
} else if (e.key === "Meta") {
e.preventDefault();
if (selectedItem !== null) {
const item = globalItemIndexMap[selectedItem];
setSelectedName(item?._source?.source?.name);
}
setShowIndex(true);
}
if (e.key === "ArrowRight" && selectedItem !== null) {
e.preventDefault();
const item = globalItemIndexMap[selectedItem];
goToTwoPage(item);
}
if (e.key === "Enter" && selectedItem !== null) {
// console.log("Enter key pressed", selectedItem);
const item = globalItemIndexMap[selectedItem];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}
if (e.key >= "0" && e.key <= "9" && showIndex) {
// console.log(`number ${e.key}`);
const item = globalItemIndexMap[parseInt(e.key, 10)];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
// console.log("handleKeyUp", e.key);
if (!suggests.length) return;
if (!e.metaKey) {
setShowIndex(false);
}
};
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [showIndex, selectedItem, suggests]);
useEffect(() => {
if (selectedItem !== null && itemRefs.current[selectedItem]) {
itemRefs.current[selectedItem]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedItem]);
function findConnectorIcon(item: any) {
const id = item?._source?.source?.id || "";
const result_source = datasourceData[endpoint_http]?.find(
(data: any) => data._source.id === id
);
const connector_id = result_source?._source?.connector?.id;
const result_connector = connector_data[endpoint_http]?.find(
(data: any) => data._source.id === connector_id
);
return result_connector?._source;
}
function getTypeIcon(item: any) {
const connectorSource = findConnectorIcon(item);
const icons = connectorSource?.icon;
if (!icons) {
return theme === "dark" ? source_default_dark_img : source_default_img;
}
if (icons?.startsWith("http://") || icons?.startsWith("https://")) {
return icons;
} else {
return endpoint_http + icons;
}
}
function getIcon(item: any) {
const connectorSource = findConnectorIcon(item);
const icons = connectorSource?.assets?.icons || {};
const selectedIcon = icons[item?._source?.icon];
if (!selectedIcon) {
return file_efault_img;
}
if (selectedIcon?.startsWith("http://") || selectedIcon?.startsWith("https://")) {
return selectedIcon;
} else {
return endpoint_http + selectedIcon;
}
}
function getRichIcon(item: any) {
const connectorSource = findConnectorIcon(item);
const icons = connectorSource?.assets?.icons || {};
const selectedIcon = icons[item?._source?.rich_categories?.[0]?.icon];
if (!selectedIcon) {
return theme === "dark" ? source_default_dark_img : source_default_img;
}
if (selectedIcon?.startsWith("http://") || selectedIcon?.startsWith("https://")) {
return selectedIcon;
} else {
return endpoint_http + selectedIcon;
}
}
function goToTwoPage(item: any) {
setSourceData(item);
selected && selected(item);
}
// function numberToLetter(num: number): string {
// const mapping = ["A", "S", "D", "F"];
// if (num >= 0 && num < mapping.length) {
// const letter = mapping[num];
// letterFirstIndex[letter.toLocaleLowerCase()] = globalIndex
// return letter
// } else {
// return "";
// }
// }
return (
<div
ref={containerRef}
data-tauri-drag-region
className="h-[458px] w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
tabIndex={0}
>
{showError ? (
<div className="flex items-center gap-2 text-sm text-[#333] p-2">
<CircleAlert className="text-[#FF0000] w-[14px] h-[14px]" />
Coco server is unavailable, only local results and available services
are displayed.
<Bolt className="text-[#000] w-[14px] h-[14px] cursor-pointer" />
<X
className="text-[#666] w-[16px] h-[16px] cursor-pointer"
onClick={() => setShowError(false)}
/>
</div>
) : null}
{Object.entries(SearchData).map(([sourceName, items]) => (
<div key={sourceName}>
{Object.entries(SearchData).length < 5 ? (
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
<img className="w-4 h-4" src={getTypeIcon(items[0])} alt="icon" />
{sourceName}
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
<SquareArrowRight
className="w-4 h-4 cursor-pointer"
onClick={() => goToTwoPage(items[0])}
/>
{showIndex && sourceName === selectedName ? (
<div
className={`absolute right-2
w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
>
</div>
) : null}
</div>
) : null}
{items.map((item: any) => {
const isSelected = selectedItem === globalIndex;
const currentIndex = globalIndex;
globalItemIndexMap.push(item);
globalIndex++;
return (
<div
key={item._id}
ref={(el) => (itemRefs.current[currentIndex] = el)}
onMouseEnter={() => setSelectedItem(currentIndex)}
onClick={() => {
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}}
className={`w-full px-2 py-2.5 text-sm flex gap-7 items-center justify-between rounded-lg transition-colors ${
isSelected
? "text-white bg-[#950599] hover:bg-[#950599]"
: "text-[#333] dark:text-[#d8d8d8]"
}`}
>
<div className="flex gap-2 items-center justify-start max-w-[450px]">
<img className="w-5 h-5" src={getIcon(item)} alt="icon" />
<span
className={`text-sm truncate text-left ${
isSelected ? "font-medium" : ""
}`}
>
{item?._source?.title}
</span>
</div>
<div className="flex-1 text-right min-w-[160px] h-full pl-5 text-[12px] flex gap-2 items-center justify-end relative">
{Object.entries(SearchData).length < 5 ? null : (
<img
className="w-4 h-4 cursor-pointer"
src={getTypeIcon(item)}
alt="icon"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
)}
{item?._source?.rich_categories ? (
<div className="flex items-center justify-end max-w-[calc(100%-20px)] whitespace-nowrap">
{Object.entries(SearchData).length < 5 ? (
<img
className="w-4 h-4 mr-2 cursor-pointer"
src={getRichIcon(item)}
alt="icon"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
) : null}
<span
className={`${
isSelected ? "text-[#C8C8C8]" : "text-[#666]"
} text-right truncate`}
>
{item?._source?.rich_categories?.map(
(rich_item: any, index: number) => {
if (
item?._source?.rich_categories.length > 2 &&
index ===
item?._source?.rich_categories.length - 1
)
return "";
else
return (
(index !== 0 ? "/" : "") + rich_item?.label
);
}
)}
</span>
{item?._source?.rich_categories.length > 2 ? (
<span
className={`${
isSelected ? "text-[#C8C8C8]" : "text-[#666]"
} text-right truncate`}
>
{"/" + item?._source?.rich_categories?.at(-1)?.label}
</span>
) : null}
</div>
) : item?._source?.category || item?._source?.subcategory ? (
<span
className={`text-[12px] truncate ${
isSelected
? "text-[#DCDCDC]"
: "text-[#999] dark:text-[#666]"
}`}
>
{(item?._source?.category || "") +
(item?._source?.subcategory
? `/${item?._source?.subcategory}`
: "")}
</span>
) : (
<span
className={`text-[12px] truncate ${
isSelected
? "text-[#DCDCDC]"
: "text-[#999] dark:text-[#666]"
}`}
>
{item?._source?.type || ""}
</span>
)}
{isSelected ? (
<div
className={`absolute ${
showIndex && currentIndex < 10 ? "right-7" : "right-0"
} w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${
isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
</div>
) : null}
{showIndex && currentIndex < 10 ? (
<div
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${
isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
{currentIndex}
</div>
) : null}
</div>
</div>
);
})}
</div>
))}
</div>
);
}
export default DropdownList;

View File

@@ -234,7 +234,7 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
skipTaskbar: true,
decorations: true,
closable: true,
url: "/ui/chat",
url: "/ui/app/chat",
});
}
}

View File

@@ -1,335 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import {
RefreshCcw,
Globe,
PackageOpen,
GitFork,
CalendarSync,
Trash2,
} from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
onOpenUrl,
getCurrent as getCurrentDeepLinkUrls,
} from "@tauri-apps/plugin-deep-link";
import { UserProfile } from "./UserProfile";
import { DataSourcesList } from "./DataSourcesList";
import { Sidebar } from "./Sidebar";
import { ConnectService } from "./ConnectService";
import { OpenBrowserURL } from "@/utils/index";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useConnectStore } from "@/stores/connectStore";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
export default function CocoCloud() {
const [error, setError] = useState<string | null>(null);
const [isConnect, setIsConnect] = useState(true);
const app_uid = useAppStore((state) => state.app_uid);
const setAppUid = useAppStore((state) => state.setAppUid);
const setEndpoint = useAppStore((state) => state.setEndpoint);
const endpoint = useAppStore((state) => state.endpoint);
const auth = useAuthStore((state) => state.auth);
const setAuth = useAuthStore((state) => state.setAuth);
const userInfo = useAuthStore((state) => state.userInfo);
const setUserInfo = useAuthStore((state) => state.setUserInfo);
const defaultService = useConnectStore((state) => state.defaultService);
const currentService = useConnectStore((state) => state.currentService);
const setDefaultService = useConnectStore((state) => state.setDefaultService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const deleteOtherService = useConnectStore(
(state) => state.deleteOtherService
);
const [loading, setLoading] = useState(false);
const [refreshLoading, setRefreshLoading] = useState(false);
useEffect(() => {
console.log("currentService", currentService);
setLoading(false);
setRefreshLoading(false);
setError(null);
setEndpoint(currentService.endpoint);
setIsConnect(true);
}, [JSON.stringify(currentService)]);
const getProfile = useCallback(async () => {
const response: any = await tauriFetch({
url: `/account/profile`,
method: "GET",
});
console.log("getProfile", response);
setUserInfo(response.data || {}, endpoint);
}, [endpoint]);
const handleOAuthCallback = useCallback(
async (code: string | null, provider: string | null) => {
if (!code) {
setError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, provider });
const response: any = await tauriFetch({
url: `/auth/request_access_token?request_id=${app_uid}`,
method: "GET",
headers: {
"X-API-TOKEN": code,
},
});
console.log(
"response",
`/auth/request_access_token?request_id=${app_uid}`,
code,
response
);
if (response.data?.access_token) {
setAuth(
{
token: response.data?.access_token,
expires: response.data?.expire_at,
plan: { upgraded: false, last_checked: 0 },
},
endpoint
);
getProfile();
} else {
setAuth(undefined, endpoint);
setError("Sign in failed: " + response.data?.error?.reason);
}
getCurrentWindow()
.setFocus()
.catch(() => {});
} catch (e) {
console.error("Sign in failed:", error);
setError("Sign in failed: catch");
setAuth(undefined, endpoint);
throw error;
} finally {
setLoading(false);
}
},
[app_uid, endpoint]
);
const handleUrl = (url: string) => {
try {
// url = "coco://oauth_callback?code=cu8ag982sdb06e0j6k3g&provider=coco-cloud"
const urlObject = new URL(url);
console.log("urlObject:", urlObject);
const code = urlObject.searchParams.get("code");
const provider = urlObject.searchParams.get("provider");
handleOAuthCallback(code, provider);
// switch (urlObject.hostname) {
// case "/oauth_callback":
// break;
// default:
// console.log("Unhandled deep link path:", urlObject.pathname);
// }
} catch (err) {
console.error("Failed to parse URL:", err);
setError("Invalid URL format");
}
};
// Fetch the initial deep link intent
useEffect(() => {
// handleUrl("");
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
handleUrl(urls[0]);
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
setError("Failed to get initial URLs");
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
return () => {
unlisten.then((fn) => fn());
};
}, [app_uid]);
const LoginClick = useCallback(() => {
if (loading) return;
setAuth(undefined, endpoint);
let uid = uuidv4();
setAppUid(uid);
console.log("LoginClick", uid, currentService.auth_provider.sso.url);
OpenBrowserURL(
`${currentService.auth_provider.sso.url}/?provider=coco-cloud&product=coco&request_id=${uid}`
);
setLoading(true);
}, [JSON.stringify(currentService)]);
function goToHref(url: string) {
OpenBrowserURL(url);
}
const refreshClick = useCallback(() => {
setRefreshLoading(true);
tauriFetch({
url: `/provider/_info`,
method: "GET",
})
.then((res) => {
setEndpoint(res.data.endpoint);
setCurrentService(res.data || {});
if (res.data?.endpoint === "https://coco.infini.cloud/") {
setDefaultService(res.data);
}
})
.catch((err) => {
console.error(err);
})
.finally(() => {
setRefreshLoading(false);
});
}, [JSON.stringify(defaultService)]);
function addService() {
setIsConnect(false);
}
const deleteClick = useCallback(() => {
deleteOtherService(currentService);
setAuth(undefined, endpoint);
setUserInfo({}, endpoint);
}, [JSON.stringify(currentService), endpoint]);
return (
<div className="flex bg-gray-50 dark:bg-gray-900">
<Sidebar addService={addService} />
<main className="flex-1 p-4 py-8">
{/* <div>
{error && (
<div className="text-red-500 dark:text-red-400 mb-4">
Error: {error}
</div>
)}
</div> */}
{isConnect ? (
<div className="max-w-4xl mx-auto">
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
<img
width="100%"
src={currentService.provider.banner || bannerImg}
alt="banner"
/>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="flex items-center text-gray-900 dark:text-white font-medium">
{currentService.name}
</div>
</div>
<div className="flex gap-2">
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => goToHref(currentService.provider.website)}
>
<Globe className="w-3.5 h-3.5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => refreshClick()}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${
refreshLoading ? "animate-spin" : ""
}`}
/>
</button>
{currentService.endpoint !== defaultService.endpoint ? (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => deleteClick()}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>
) : null}
</div>
</div>
<div className="mb-8">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
<span className="flex items-center gap-1">
<PackageOpen className="w-4 h-4" />{" "}
{currentService.provider.name}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<GitFork className="w-4 h-4" />{" "}
{currentService.version.number}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<CalendarSync className="w-4 h-4" /> {currentService.updated}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{currentService.provider.description}
</p>
</div>
{currentService.auth_provider.sso.url ? (
<div className="mb-8">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Account Information
</h2>
{auth && auth[endpoint] ? (
<UserProfile userInfo={userInfo[endpoint]} />
) : (
<div>
<button
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
>
{loading ? "Login..." : "Login"}
</button>
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
goToHref(currentService.provider.privacy_policy)
}
>
EULA | Privacy Policy
</button>
</div>
)}
</div>
) : null}
{auth && auth[endpoint] ? <DataSourcesList /> : null}
</div>
) : (
<ConnectService setIsConnect={setIsConnect} />
)}
</main>
</div>
);
}

View File

@@ -1,122 +0,0 @@
import React, { useState, useCallback } from "react";
import { ChevronLeft } from "lucide-react";
import { useConnectStore } from "@/stores/connectStore";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useAppStore } from "@/stores/appStore";
interface ConnectServiceProps {
setIsConnect: (isConnect: boolean) => void;
}
export function ConnectService({ setIsConnect }: ConnectServiceProps) {
const addOtherServices = useConnectStore((state) => state.addOtherServices);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const defaultService = useConnectStore((state) => state.defaultService);
const otherServices = useConnectStore((state) => state.otherServices);
const setEndpoint = useAppStore((state) => state.setEndpoint);
const [endpointLink, setEndpointLink] = useState("");
const [refreshLoading, setRefreshLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log("Connecting Google Drive with name:", endpointLink);
};
const goBack = () => {
setIsConnect(true);
};
const addService = useCallback(() => {
if (!endpointLink) return;
if (!endpointLink.startsWith("http://") && !endpointLink.startsWith("https://")) {
return
}
setRefreshLoading(true);
//
let baseURL = endpointLink;
if (baseURL.endsWith("/")) {
baseURL = baseURL.slice(0, -1);
}
tauriFetch({
url: `${baseURL}/provider/_info`,
method: "GET",
})
.then((res) => {
if (
res.data?.endpoint === defaultService.endpoint ||
otherServices.some(
(item: any) => item.endpoint === res.data?.endpoint
)
) {
console.error(`${res.data?.endpoint} Repeated`);
} else {
addOtherServices(res.data);
setCurrentService(res.data);
setEndpoint(res.data.endpoint);
setIsConnect(true);
}
})
.catch((err) => {
console.error(err);
})
.finally(() => {
setRefreshLoading(false);
});
}, [endpointLink]);
return (
<div className="max-w-4xl">
<div className="flex items-center gap-2 mb-8">
<button
className=" text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border border-[rgba(228,229,239,1)] dark:border-gray-700 p-1"
onClick={goBack}
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="text-xl text-[#101010] dark:text-white">
Connecting to third-party services
</div>
</div>
<div className="mb-8">
<p className="text-gray-600 dark:text-gray-400">
Third-party services are provided by other platforms or providers, and
users can integrate these services into Coco AI to expand the scope of
search data.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="endpoint"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2.5"
>
Server address
</label>
<div className="flex gap-2">
<input
type="text"
id="endpoint"
value={endpointLink}
placeholder="For example: https://coco.infini.cloud/"
onChange={(e) => setEndpointLink(e.target.value)}
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={addService}
>
{refreshLoading ? "Connecting..." : "Connect"}
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -1,57 +0,0 @@
import { useEffect, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { DataSourceItem } from "./DataSourceItem";
import { useConnectStore } from "@/stores/connectStore";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useAppStore } from "@/stores/appStore";
export function DataSourcesList() {
const datasourceData = useConnectStore((state) => state.datasourceData);
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
const endpoint_http = useAppStore((state) => state.endpoint_http);
const [refreshLoading, setRefreshLoading] = useState(false);
async function getDatasourceData() {
setRefreshLoading(true);
try {
const response = await tauriFetch({
url: `/datasource/_search`,
method: "GET",
});
console.log("datasource", response);
const data = response.data?.hits?.hits || [];
setDatasourceData(data, endpoint_http);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
setRefreshLoading(false);
}
useEffect(() => {
getDatasourceData()
}, [])
return (
<div className="space-y-4">
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
Data Source
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => getDatasourceData()}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
/>
</button>
</h2>
<div className="space-y-4">
{datasourceData[endpoint_http]?.map((source) => (
<DataSourceItem key={source._id} {...source._source} />
))}
</div>
</div>
);
}

View File

@@ -1,56 +0,0 @@
import React, { useState } from 'react';
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your email"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Sign In
</button>
</form>
);
}

View File

@@ -1,164 +0,0 @@
import { useState, useEffect } from "react";
import { Plus } from "lucide-react";
import cocoLogoImg from "@/assets/app-icon.png";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
interface SidebarProps {
addService: () => void;
}
type StringBooleanMap = {
[key: string]: boolean;
};
export function Sidebar({ addService }: SidebarProps) {
const defaultService = useConnectStore((state) => state.defaultService);
const currentService = useConnectStore((state) => state.currentService);
const otherServices = useConnectStore((state) => state.otherServices);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const setEndpoint = useAppStore((state) => state.setEndpoint);
const [defaultHealth, setDefaultHealth] = useState(false);
const [otherHealth, setOtherHealth] = useState<StringBooleanMap>({});
const addServiceClick = () => {
addService();
};
useEffect(() => {
getDefaultHealth();
}, []);
useEffect(() => {
getOtherHealth(currentService);
setEndpoint(currentService.endpoint);
}, [currentService.endpoint]);
const getDefaultHealth = () => {
let baseURL = defaultService.endpoint
if (baseURL.endsWith("/")) {
baseURL = baseURL.slice(0, -1);
}
tauriFetch({
url: `${baseURL}/health`,
method: "GET",
})
.then((res) => {
// "services": {
// "system_cluster": "yellow"
// },
// "status": "yellow"
setDefaultHealth(res.data?.status !== "red");
})
.catch((err) => {
console.error(err);
});
};
const getOtherHealth = (item: any) => {
if (!item.endpoint) return;
//
let baseURL = item.endpoint
if (baseURL.endsWith("/")) {
baseURL = baseURL.slice(0, -1);
}
tauriFetch({
url: `${baseURL}/health`,
method: "GET",
})
.then((res) => {
let obj = {
...otherHealth,
[item.endpoint]: res.data?.status !== "red",
};
setOtherHealth(obj);
})
.catch((err) => {
console.error(err);
});
};
return (
<div className="w-64 min-h-[550px] border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="p-4 py-8">
<div
className={`flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-6 ${
currentService.endpoint === defaultService.endpoint
? "border border-[rgba(0,135,255,1)]"
: ""
}`}
onClick={() => {
setCurrentService(defaultService);
setEndpoint(defaultService.endpoint);
getDefaultHealth();
}}
>
<img
src={defaultService.provider.icon || cocoLogoImg}
alt="cocoLogoImg"
className="w-5 h-5"
/>
<span className="font-medium">{defaultService.name}</span>
<div className="flex-1" />
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{defaultHealth ? (
<div className="w-3 h-3 rounded-full bg-[#00DB5E]"></div>
) : (
<div className="w-3 h-3 rounded-full bg-[#FF4747]"></div>
)}
</button>
</div>
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Third-party services
</div>
{otherServices?.map((item, index) => (
<div
key={item.name + index}
className={`flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
currentService.endpoint === item.endpoint
? "border border-[rgba(0,135,255,1)]"
: ""
}`}
onClick={() => {
setEndpoint(item.endpoint);
setCurrentService(item);
getOtherHealth(item);
}}
>
<img
src={item.provider.icon || cocoLogoImg}
alt="LogoImg"
className="w-5 h-5"
/>
<span className="font-medium">{item.name}</span>
<div className="flex-1" />
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{otherHealth[item.endpoint] ? (
<div className="w-3 h-3 rounded-full bg-[#00DB5E]"></div>
) : (
<div className="w-3 h-3 rounded-full bg-[#FF4747]"></div>
)}
</button>
</div>
))}
<div className="space-y-2">
<button
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
onClick={addServiceClick}
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,59 +0,0 @@
export default `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<title>Coco Auth</title>
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-weight: 400;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
text-align: center;
background-color: #f8f9fa;
}
.container {
padding: 30px;
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.logo {
width: 130px;
height: auto;
margin-bottom: 20px;
}
p {
font-size: 21px;
line-height: 26px;
color: #12161F;
margin: 0;
}
.error {
color: #dc2626;
margin-top: 12px;
font-size: 16px;
}
</style>
</head>
<body>
<div class="container">
<h1>Coco<h1>
<p id="message">You are now signed in. Please re-open the Coco desktop app to continue.</p>
<div id="error-container"></div>
</div>
</body>
</html>
`;

View File

@@ -1,55 +0,0 @@
import { useEffect } from 'react';
import { Github, Mail, Apple } from 'lucide-react';
import { useSearchParams } from "react-router-dom";
import { LoginForm } from '@/components/Auth/LoginForm';
import { SocialButton } from '@/components/Auth/SocialButton';
import { Divider } from '@/components/Auth/Divider';
import { authWitheGithub } from '@/utils/index';
export default function LoginPage() {
const [searchParams] = useSearchParams();
const uid = searchParams.get("uid");
const code = searchParams.get("code");
useEffect(()=>{
}, [code])
function GithubClick() {
uid && authWitheGithub(uid)
}
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-gray-800 rounded-xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Welcome Back</h1>
<p className="text-gray-400">Sign in to continue to Coco</p>
</div>
<div className="space-y-4 mb-6">
<SocialButton
icon={<Github className="w-5 h-5" />}
provider="GitHub"
onClick={() => GithubClick()}
/>
<SocialButton
icon={<Mail className="w-5 h-5" />}
provider="Google"
onClick={() => console.log('Google login')}
/>
<SocialButton
icon={<Apple className="w-5 h-5" />}
provider="Apple"
onClick={() => console.log('Apple login')}
/>
</div>
<Divider />
<LoginForm onSubmit={(email, password) => console.log(email, password)} />
</div>
</div>
);
}

View File

@@ -1,105 +0,0 @@
import { useState, useEffect, useRef } from "react";
// import { invoke } from "@tauri-apps/api/core";
import { useHotkeys } from "react-hotkeys-hook";
import debounce from "lodash/debounce";
import { Textarea } from "@headlessui/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { fetch } from "@tauri-apps/plugin-http";
import SendIcon from "../icons/Send";
export default function ChatInput() {
const { t } = useTranslation();
const inputRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState("");
const [info, setInfo] = useState("");
const isMac = true;
useEffect(() => {
const syncMessage = debounce(async () => {
try {
// await invoke("ask_sync", { message: JSON.stringify(message) });
} catch (error) {
console.error("Error syncing message:", error);
}
}, 300); // Debounce by 300ms
syncMessage();
return () => syncMessage.cancel(); // Cleanup debounce on unmount
}, [message]);
useHotkeys(
isMac ? "meta+enter" : "ctrl+enter",
async (event: KeyboardEvent) => {
event.preventDefault();
await handleSend();
},
{
enableOnFormTags: true,
},
[message]
);
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInfo("");
setMessage(e.target.value);
};
const handleSend = async () => {
if (!message) return;
try {
// await invoke("ask_send", { message: JSON.stringify(message) });
// Send a GET request
const response = await fetch("https://test.tauri.app/data.json", {
method: "GET",
});
setInfo(JSON.stringify(response));
// console.log(response.status); // e.g. 200
// console.log(response.statusText); // e.g. "OK"
} catch (error) {
console.error("Error sending message:", error);
setInfo(JSON.stringify(error));
}
setMessage("");
if (inputRef.current) {
inputRef.current.value = "";
inputRef.current.focus();
}
};
return (
<div className="w-[100%] h-[100%]">
<div>{info}</div>
<div className="relative flex dark:bg-app-gray-2/[0.98] dark:text-slate-200 items-center gap-1">
<Textarea
ref={inputRef}
onChange={handleInput}
spellCheck="false"
autoFocus
className={clsx(
"mt-3 block w-full resize-none rounded-xl border border-transparent bg-white/10",
"py-3 px-4 text-sm text-black placeholder-gray-500 shadow-md",
// Transition for smoother appearance changes
"transition-colors duration-300 ease-in-out",
// Dark mode styles
"dark:bg-gray-800 dark:text-white dark:placeholder-gray-400",
"focus:border-blue-400 focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50",
"dark:focus:border-white dark:focus:ring-white/25",
"focus:outline-none"
)}
placeholder={t("InputMessage")}
/>
<SendIcon
size={30}
className="absolute right-2 text-gray-400/80 dark:text-gray-600 cursor-pointer"
onClick={handleSend}
title={`Send message (${isMac ? "⌘⏎" : "⌃⏎"})`}
aria-label="Send message"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,476 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
RefreshCcw,
Globe,
PackageOpen,
GitFork,
CalendarSync,
Trash2,
Copy,
} from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
onOpenUrl,
getCurrent as getCurrentDeepLinkUrls,
} from "@tauri-apps/plugin-deep-link";
import { invoke } from "@tauri-apps/api/core";
import { UserProfile } from "./UserProfile";
import { DataSourcesList } from "./DataSourcesList";
import { Sidebar } from "./Sidebar";
import { Connect } from "./Connect.tsx";
import { OpenURLWithBrowser } from "@/utils";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
export default function Cloud() {
const SidebarRef = useRef<{ refreshData: () => void; }>(null);
// const [error, setError] = useState<string | null>(null);
const error = useAppStore((state) => state.error);
const setError = useAppStore((state) => state.setError);
const [isConnect, setIsConnect] = useState(true);
// const [ssoRequestID, setSSORequestID] = useState("");
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
// const ssoServerID = useAppStore((state) => state.ssoServerID);
// const setSSOServerID = useAppStore((state) => state.setSSOServerID);
const endpoint = useAppStore((state) => state.endpoint);
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const serverList = useConnectStore((state) => state.serverList);
const setServerList = useConnectStore((state) => state.setServerList);
const [loading, setLoading] = useState(false);
const [refreshLoading, setRefreshLoading] = useState(false);
// const [profiles, setProfiles] = useState<any>({});
// const [userInfo, setUserInfo] = useState<any>({});
//fetch the servers
useEffect(() => {
fetchServers(true);
}, []);
useEffect(() => {
console.log("currentService", currentService);
setLoading(false);
setRefreshLoading(false);
setError("");
// setEndpoint(currentService.endpoint);
setIsConnect(true);
// setUserInfo(profiles[endpoint] || {})
}, [JSON.stringify(currentService)]);
// const get_user_profiles = useCallback(() => {
// invoke("get_user_profiles")
// .then((res: any) => {
// console.log("get_user_profiles", res);
// setProfiles(res);
// console.log("setUserInfo", res[endpoint]);
// setUserInfo(res[endpoint] || {})
// })
// .catch((err: any) => {
// console.error(err);
// });
// }, [endpoint]);
useEffect(() => {
// get_user_profiles()
}, [])
const fetchServers = async (resetSelection: boolean) => {
invoke("list_coco_servers")
.then((res: any) => {
console.log("list_coco_servers", res);
setServerList(res);
if (resetSelection && res.length > 0) {
console.log("setCurrentService", res[res.length - 1]);
setCurrentService(res[res.length - 1]);
} else {
console.warn("Service list is empty or last item has no id");
}
})
.catch((err: any) => {
setError(err);
console.error(err);
});
};
const add_coco_server = (endpointLink: string) => {
if (!endpointLink) {
throw new Error('Endpoint is required');
}
if (!endpointLink.startsWith("http://") && !endpointLink.startsWith("https://")) {
throw new Error('Invalid Endpoint');
}
setRefreshLoading(true);
return invoke("add_coco_server", { endpoint: endpointLink })
.then((res: any) => {
console.log("add_coco_server", res);
fetchServers(false)
.then((r) => {
console.log("fetchServers", r);
setCurrentService(res);
})
.catch((err: any) => {
console.error("fetchServers failed:", err);
setError(err);
throw err; // Propagate error back up to outer promise chain
});
})
.catch((err: any) => {
// Handle the invoke error
console.error("add coco server failed:", err);
setError(err);
throw err; // Propagate error back up
})
.finally(() => {
setRefreshLoading(false);
});
};
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code) {
setError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await invoke("handle_sso_callback", {
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow()
.setFocus()
.catch((err) => {
setError(err);
});
} catch (e) {
console.error("Sign in failed:", e);
setError("SSO login failed: " + e);
// setAuth(undefined, endpoint);
throw error;
} finally {
setLoading(false);
}
},
[ssoRequestID, endpoint]
);
const handleUrl = (url: string) => {
try {
// url = "coco://oauth_callback?code=cuhhi8o2sdbbbcoe0g10ktmht6aky3jmd4xkwsgvzf748i4zdgr898bfeu3kze7ffdusdtbgtnpke8ng3fe6&provider=coco-cloud/"
const urlObject = new URL(url);
console.log("handle urlObject:", urlObject);
//TODO, pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
setError("Request ID not matched, skip");
return;
}
const serverId = currentService?.id;
handleOAuthCallback(code, serverId);
// switch (urlObject.hostname) {
// case "/oauth_callback":
// break;
// default:
// console.log("Unhandled deep link path:", urlObject.pathname);
// }
} catch (err) {
console.error("Failed to parse URL:", err);
setError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
useEffect(() => {
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData('text');
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith('coco://oauth_callback');
};
// Adding event listener for paste events
document.addEventListener('paste', handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0])) {
handleUrl(urls[0]);
}
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
setError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
return () => {
unlisten.then((fn) => fn());
document.removeEventListener('paste', handlePaste);
};
}, [ssoRequestID]);
// const generateLogin = () => {
// const requestID = uuidv4();
// setSSORequestID(requestID);
// setSSOServerID(currentService?.id); // Set server ID
//
// // The URL is now updated when ssoRequestID and ssoServerID are both set
// };
const LoginClick = useCallback(() => {
if (loading) return; // Prevent multiple clicks if already loading
// If the appUid doesn't exist, generate one
// if (!ssoRequestID) {
let requestID = uuidv4();
setSSORequestID(requestID);
// setSSOServerID(currentService?.id);
// }
// Generate the login URL with the current appUid
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
console.log("Open SSO link, requestID:", ssoRequestID, url);
// Open the URL in a browser
OpenURLWithBrowser(url);
// Start loading state
setLoading(true);
}, [ssoRequestID, loading, currentService]);
const refreshClick = (id: string) => {
setRefreshLoading(true);
invoke("refresh_coco_server_info", { id })
.then((res: any) => {
console.log("refresh_coco_server_info", id, JSON.stringify(res));
fetchServers(false).then(r => {
console.log("fetchServers", r);
});
//update currentService
setCurrentService(res);
})
.catch((err: any) => {
setError(err);
console.error(err);
})
.finally(() => {
setRefreshLoading(false);
});
};
function onAddServer() {
setIsConnect(false);
}
function onLogout(id: string) {
console.log("onLogout", id);
setRefreshLoading(true);
invoke("logout_coco_server", { id })
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
refreshClick(id);
})
.catch((err: any) => {
setError(err);
console.error(err);
}).finally(() => {
setRefreshLoading(false);
});
}
const remove_coco_server = (id: string) => {
invoke("remove_coco_server", { id })
.then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then(r => {
console.log("fetchServers", r);
})
})
.catch((err: any) => {
//TODO display the error message
setError(err);
console.error(err);
});
};
return (
<div className="flex bg-gray-50 dark:bg-gray-900">
<Sidebar ref={SidebarRef} onAddServer={onAddServer} serverList={serverList} />
<main className="flex-1 p-4 py-8">
{isConnect ? (
<div className="max-w-4xl mx-auto">
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
<img
width="100%"
src={currentService?.provider?.banner || bannerImg}
alt="banner"
/>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="flex items-center text-gray-900 dark:text-white font-medium">
{currentService?.name}
</div>
</div>
<div className="flex gap-2">
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => OpenURLWithBrowser(currentService?.provider?.website)}
>
<Globe className="w-3.5 h-3.5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => refreshClick(currentService?.id)}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""
}`}
/>
</button>
{!currentService?.builtin && (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => remove_coco_server(currentService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>
)}
</div>
</div>
<div className="mb-8">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
<span className="flex items-center gap-1">
<PackageOpen className="w-4 h-4" />{" "}
{currentService?.provider?.name}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<GitFork className="w-4 h-4" />{" "}
{currentService?.version?.number}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{currentService?.provider?.description}
</p>
</div>
{currentService?.auth_provider?.sso?.url ? (
<div className="mb-8">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Account Information
</h2>
{currentService?.profile ? (
<UserProfile server={currentService?.id} userInfo={currentService?.profile} onLogout={onLogout} />
) : (
<div>
{/* Login Button (conditionally rendered when not loading) */}
{!loading && (
<button
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
>
Login
</button>
)}
{/* Cancel Button and Copy URL button while loading */}
{loading && (
<div className="flex items-center space-x-2">
<button
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
onClick={() => setLoading(false)} // Reset loading state
>
Cancel
</button>
<button
onClick={() => {
navigator.clipboard.writeText(
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
);
}}
className="text-xl text-blue-500 hover:text-blue-600"
>
<Copy className="inline mr-2" /> {/* Lucide Copy Icon */}
</button>
</div>
)}
{/* Privacy Policy Link */}
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
}
>
EULA | Privacy Policy
</button>
</div>
)}
</div>
) : null}
{currentService?.profile ? <DataSourcesList server={currentService?.id} /> : null}
</div>
) : (
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} />
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import React, { useState } from "react";
import { ChevronLeft } from "lucide-react";
import {useAppStore} from "@/stores/appStore";
interface ConnectServiceProps {
setIsConnect: (isConnect: boolean) => void;
onAddServer: (endpoint: string) => void;
}
export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
const [endpointLink, setEndpointLink] = useState("");
const [refreshLoading, ] = useState(false);
const [errorMessage, setErrorMessage] = useState(''); // State to store the error message
const setError = useAppStore((state) => state.setError);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
const goBack = () => {
setIsConnect(true);
};
const onAddServerClick = async (endpoint: string) => {
console.log("onAddServer", endpoint);
try {
await onAddServer(endpoint);
setIsConnect(true); // Only set as connected if the server is added successfully
} catch (err: any) {
// Handle the error if something goes wrong
const errorMessage = typeof err === 'string' ? err : err?.message || 'An unknown error occurred.';
setErrorMessage("ERR:"+errorMessage);
setError(errorMessage);
console.error('Error:', errorMessage);
}
};
// Function to close the error message
const closeError = () => {
setErrorMessage('');
};
return (
<div className="max-w-4xl">
<div className="flex items-center gap-2 mb-8">
<button
className=" text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border border-[rgba(228,229,239,1)] dark:border-gray-700 p-1"
onClick={goBack}
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="text-xl text-[#101010] dark:text-white">
Connecting to Your Coco-Server
</div>
</div>
<div className="mb-8">
<p className="text-gray-600 dark:text-gray-400">
Running your own private instance of coco-server ensures complete control over
your data, keeping it secure and accessible only within your environment.
Enjoy enhanced privacy, better performance, and seamless integration with your
internal systems.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="endpoint"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2.5"
>
Server address
</label>
<div className="flex gap-2">
<input
type="text"
id="endpoint"
value={endpointLink}
placeholder="For example: https://coco.infini.cloud/"
onChange={(e) => setEndpointLink(e.target.value)}
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={()=>onAddServerClick(endpointLink)}
>
{refreshLoading ? "Connecting..." : "Connect"}
</button>
</div>
</div>
</form>
{/*//TODO move to outer container, move error state to global*/}
{errorMessage && (
<div
className="mb-8"
>
<div style={{
color: 'red',
marginTop: '10px',
display: 'block', // Makes sure the error message starts on a new line
marginBottom: '10px',
}}>
<span>{errorMessage}</span>
<button
onClick={closeError}
style={{
background: 'none',
border: 'none',
color: 'red',
cursor: 'pointer'
}}
>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -29,10 +29,10 @@ export function DataSourceItem({ name, connector }: DataSourceItemProps) {
const connector_id = connector?.id;
const result_connector = connector_data[endpoint_http]?.find(
(data: any) => data._source.id === connector_id
(data: any) => data.id === connector_id
);
return result_connector?._source;
return result_connector;
}
function getTypeIcon() {

View File

@@ -0,0 +1,80 @@
import {useEffect, useState} from "react";
import {RefreshCcw} from "lucide-react";
import {DataSourceItem} from "./DataSourceItem";
import {useConnectStore} from "@/stores/connectStore";
import {useAppStore} from "@/stores/appStore";
import {invoke} from "@tauri-apps/api/core";
export function DataSourcesList({server}: { server: string }) {
const datasourceData = useConnectStore((state) => state.datasourceData);
const setError = useAppStore((state) => state.setError);
const [refreshLoading, setRefreshLoading] = useState(false);
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
const setConnectorData = useConnectStore((state) => state.setConnectorData);
function initServerAppData({server}: { server: string }) {
//fetch datasource data
invoke("get_connectors_by_server", {id: server})
.then((res: any) => {
console.log("get_connectors_by_server", res);
setConnectorData(res, server);
})
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {
});
//fetch datasource data
invoke("get_datasources_by_server", {id: server})
.then((res: any) => {
console.log("get_datasources_by_server", res);
setDatasourceData(res, server);
})
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {
});
}
async function getDatasourceData() {
setRefreshLoading(true);
try {
initServerAppData({server});
} catch (e) {
setError(e);
} finally {
setRefreshLoading(false);
}
}
useEffect(() => {
getDatasourceData()
}, [])
return (
<div className="space-y-4">
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
Data Source
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => getDatasourceData()}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
/>
</button>
</h2>
<div className="space-y-4">
{datasourceData[server]?.map((source) => (
<DataSourceItem key={source.id} {...source} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { forwardRef } from "react";
import { Plus } from "lucide-react";
import cocoLogoImg from "@/assets/app-icon.png";
import { useConnectStore } from "@/stores/connectStore";
interface SidebarProps {
onAddServer: () => void;
serverList: any[];
}
export const Sidebar = forwardRef<{ refreshData: () => void; }, SidebarProps>(
({ onAddServer, serverList }, _ref) => {
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const onAddServerClick = () => {
onAddServer();
};
// Extracted server item rendering
const renderServerItem = (item: any) => {
return (
<div
key={item?.id}
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
currentService?.id === item?.id ? "dark:bg-blue-900/20 dark:bg-blue-900" // Apply background color when selected
: "bg-gray-50 dark:bg-gray-900" // Default background color when not selected
}`}
onClick={() => setCurrentService(item)}
>
<img
src={item?.provider?.icon || cocoLogoImg}
alt="LogoImg"
className="w-5 h-5"
/>
<span className="font-medium">{item?.name}</span>
<div className="flex-1" />
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{item?.available ? (
<div className="w-3 h-3 rounded-full bg-[#00DB5E]" />
) : (
<div className="w-3 h-3 rounded-full bg-[#FF4747]" />
)}
</button>
</div>
);
};
return (
<div className="w-64 min-h-[550px] border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="p-4 py-8">
{/* Render Built-in Servers */}
<div>
{serverList
.filter((item) => item?.builtin)
.map((item) => renderServerItem(item))}
</div>
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Your Coco-Servers
</div>
{/* Render Non-Built-in Servers */}
<div>
{serverList
.filter((item) => !item?.builtin)
.map((item) => renderServerItem(item))}
</div>
<div className="space-y-2">
<button
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
onClick={onAddServerClick}
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}
);

View File

@@ -1,8 +1,5 @@
import { User, LogOut } from "lucide-react";
import { useAuthStore } from "@/stores/authStore";
import { useAppStore } from "@/stores/appStore";
interface UserPreferences {
theme: "dark" | "light";
language: string;
@@ -16,17 +13,15 @@ interface UserInfo {
}
interface UserProfileProps {
server: string; //server's id
userInfo: UserInfo;
onLogout: (server: string) => void;
}
export function UserProfile({ userInfo }: UserProfileProps) {
const setAuth = useAuthStore((state) => state.setAuth);
const setUserInfo = useAuthStore((state) => state.setUserInfo);
const endpoint = useAppStore((state) => state.endpoint);
export function UserProfile({ server,userInfo,onLogout }: UserProfileProps) {
const handleLogout = () => {
setAuth(undefined, endpoint);
setUserInfo({}, endpoint);
onLogout(server);
console.log("Logout",server);
};
return (

View File

@@ -1,28 +0,0 @@
import { Command } from "lucide-react";
import { CommandPalette } from "./CommandPalette";
function CommandInput() {
return (
<div className="h-[100%] w-[100%]">
<div className="mx-auto px-4 py-16">
<div className="text-center">
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
<Command className="w-5 h-5 text-gray-500" />
<span className="text-gray-600">Press</span>
<kbd className="px-2 py-1 text-sm font-semibold text-gray-700 bg-gray-100 border border-gray-200 rounded-md">
</kbd>
<kbd className="px-2 py-1 text-sm font-semibold text-gray-700 bg-gray-100 border border-gray-200 rounded-md">
K
</kbd>
<span className="text-gray-600">to open command palette</span>
</div>
</div>
</div>
<CommandPalette />
</div>
);
}
export default CommandInput;

View File

@@ -1,189 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Search,
Settings,
Calculator,
Calendar,
Mail,
Music,
User,
} from "lucide-react";
interface CommandItem {
id: string;
icon: React.ReactNode;
title: string;
description: string;
action: () => void;
}
export function CommandPalette() {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const commands: CommandItem[] = [
{
id: "settings",
icon: <Settings className="w-5 h-5" />,
title: "Settings",
description: "Adjust your preferences",
action: () => console.log("Settings clicked"),
},
{
id: "calculator",
icon: <Calculator className="w-5 h-5" />,
title: "Calculator",
description: "Perform quick calculations",
action: () => console.log("Calculator clicked"),
},
{
id: "calendar",
icon: <Calendar className="w-5 h-5" />,
title: "Calendar",
description: "View your schedule",
action: () => console.log("Calendar clicked"),
},
{
id: "mail",
icon: <Mail className="w-5 h-5" />,
title: "Mail",
description: "Check your inbox",
action: () => console.log("Mail clicked"),
},
{
id: "music",
icon: <Music className="w-5 h-5" />,
title: "Music",
description: "Control playback",
action: () => console.log("Music clicked"),
},
{
id: "profile",
icon: <User className="w-5 h-5" />,
title: "Profile",
description: "View your profile",
action: () => console.log("Profile clicked"),
},
];
const filteredCommands = commands.filter(
(command) =>
command.title.toLowerCase().includes(search.toLowerCase()) ||
command.description.toLowerCase().includes(search.toLowerCase())
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsOpen((prev) => !prev);
} else if (e.key === "Escape") {
setIsOpen(false);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) =>
prev < filteredCommands.length - 1 ? prev + 1 : prev
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === "Enter" && filteredCommands[selectedIndex]) {
filteredCommands[selectedIndex].action();
setIsOpen(false);
}
},
[filteredCommands, selectedIndex]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
useEffect(() => {
setSelectedIndex(0);
}, [search]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="min-h-screen px-4 text-center">
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={() => setIsOpen(false)}
/>
<div className="inline-block w-full max-w-2xl my-16 text-left align-middle transition-all transform">
<div className="relative bg-white rounded-xl shadow-2xl overflow-hidden">
<div className="flex items-center px-4 border-b border-gray-200">
<Search className="w-5 h-5 text-gray-400" />
<input
autoFocus
type="text"
className="w-full px-4 py-4 text-gray-700 bg-transparent border-none focus:outline-none focus:ring-0"
placeholder="Search commands..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="w-32 flex items-center gap-1">
<kbd className="px-2 py-1 text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded-md">
Esc
</kbd>
<span className="text-gray-400">to close</span>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{filteredCommands.length === 0 ? (
<div className="px-4 py-14 text-center text-gray-500">
No results found.
</div>
) : (
<div className="py-2">
{filteredCommands.map((command, index) => (
<div
key={command.id}
className={`px-4 py-3 flex items-center gap-3 cursor-pointer transition-colors ${
selectedIndex === index
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-50"
}`}
onClick={() => {
command.action();
setIsOpen(false);
}}
>
<div
className={`${
selectedIndex === index
? "text-blue-600"
: "text-gray-400"
}`}
>
{command.icon}
</div>
<div>
<div className="font-medium">{command.title}</div>
<div
className={`text-sm ${
selectedIndex === index
? "text-blue-500"
: "text-gray-500"
}`}
>
{command.description}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -53,8 +53,8 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
if (e.key === "Enter" && selectedItem !== null) {
// console.log("Enter key pressed", selectedItem);
const item = suggests[selectedItem];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
@@ -63,8 +63,8 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
if (e.key >= "0" && e.key <= "9" && showIndex) {
// console.log(`number ${e.key}`);
const item = suggests[parseInt(e.key, 10)];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
@@ -121,8 +121,8 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
ref={(el) => (itemRefs.current[index] = el)}
onMouseEnter={() => setSelectedItem(index)}
onClick={() => {
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
@@ -134,14 +134,14 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
}`}
>
<div className="flex gap-2 items-center">
<img className="w-5 h-5" src={item?._source?.icon} alt="icon" />
<img className="w-5 h-5" src={item?.icon} alt="icon" />
<span className="text-[#333] dark:text-[#d8d8d8] truncate w-80 text-left">
{item?._source?.title}
{item?.title}
</span>
</div>
<div className="flex gap-2 items-center relative">
<span className="text-sm text-[#666] dark:text-[#666] truncate w-52 text-right">
{item?._source?.source}
{item?.source}
</span>
{showIndex && index < 10 ? (
<div

View File

@@ -0,0 +1,23 @@
import React from 'react';
interface IconWrapperProps {
children: React.ReactNode;
className?: string;
onClick: React.MouseEventHandler<HTMLDivElement>;
}
function IconWrapper({ children, className="", onClick }: IconWrapperProps) {
return (
<div
className={className}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
>
{children}
</div>
);
}
export default IconWrapper;

View File

@@ -0,0 +1,50 @@
import {
File,
} from "lucide-react";
import IconWrapper from './IconWrapper';
import ThemedIcon from './ThemedIcon';
import { useFindConnectorIcon } from "./hooks"
import { useAppStore } from "@/stores/appStore";
interface ItemIconProps {
item: any;
className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
function ItemIcon({
item,
className = "w-5 h-5 flex-shrink-0",
onClick = () => {}
}: ItemIconProps) {
const endpoint_http = useAppStore((state) => state.endpoint_http);
const connectorSource = useFindConnectorIcon(item);
const icons = connectorSource?.assets?.icons || {};
const selectedIcon = icons[item?.icon];
if (!selectedIcon) {
return (
<IconWrapper className={className} onClick={onClick}>
<ThemedIcon component={File} className={className} />
</IconWrapper>
);
}
if (selectedIcon.startsWith("http://") || selectedIcon.startsWith("https://")) {
return (
<IconWrapper className={className} onClick={onClick}>
<img className={className} src={selectedIcon} alt="icon" />
</IconWrapper>
);
} else {
return (
<IconWrapper className={className} onClick={onClick}>
<img className={className} src={`${endpoint_http}${selectedIcon}`} alt="icon" />
</IconWrapper>
);
}
}
export default ItemIcon;

View File

@@ -0,0 +1,47 @@
import {
Folder,
} from "lucide-react";
import IconWrapper from './IconWrapper';
import ThemedIcon from './ThemedIcon';
import { useFindConnectorIcon } from "./hooks"
import { useAppStore } from "@/stores/appStore";
interface RichIconProps {
item: any;
className?: string;
onClick: (e: React.MouseEvent) => void;
}
function RichIcon({ item, className, onClick }: RichIconProps) {
const endpoint_http = useAppStore((state) => state.endpoint_http);
const connectorSource = useFindConnectorIcon(item);
const icons = connectorSource?.assets?.icons || {};
const selectedIcon = icons[item?.rich_categories?.[0]?.icon];
if (!selectedIcon) {
return (
<IconWrapper className={className} onClick={onClick}>
<ThemedIcon component={Folder} className={className} />
</IconWrapper>
);
}
if (selectedIcon.startsWith("http://") || selectedIcon.startsWith("https://")) {
return (
<IconWrapper className={className} onClick={onClick}>
<img className={className} src={selectedIcon} alt="icon" />
</IconWrapper>
);
} else {
return (
<IconWrapper className={className} onClick={onClick}>
<img className={className} src={`${endpoint_http}${selectedIcon}`} alt="icon" />
</IconWrapper>
);
}
}
export default RichIcon;

View File

@@ -0,0 +1,27 @@
import React, { useEffect, useState } from "react";
interface ThemedIconProps {
component: React.ElementType;
className?: string;
}
function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
const [color, setColor] = useState("#000");
useEffect(() => {
const updateTheme = () => {
const isDark = document.body.classList.contains("dark");
setColor(isDark ? "#DCDCDC" : "#999");
};
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(document.body, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return <Component className={className} color={color} />;
}
export default ThemedIcon;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Box } from "lucide-react";
import IconWrapper from './IconWrapper';
import ThemedIcon from './ThemedIcon';
import { useFindConnectorIcon } from "./hooks"
import { useAppStore } from "@/stores/appStore";
interface TypeIconProps {
item: any;
className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
function TypeIcon({
item,
className = "w-5 h-5 flex-shrink-0",
onClick = () => { }
}: TypeIconProps) {
const endpoint_http = useAppStore((state) => state.endpoint_http);
const connectorSource = useFindConnectorIcon(item);
const selectedIcon = connectorSource?.icon;
if (!selectedIcon) {
// console.log("go default folder:");
return (
<IconWrapper className={className} onClick={onClick}>
<ThemedIcon component={Box} className={className} />
</IconWrapper>
);
}
if (selectedIcon.startsWith("http://") || selectedIcon.startsWith("https://")) {
return (
<IconWrapper className={className} onClick={onClick}>
<img className={className} src={selectedIcon} alt="icon" />
</IconWrapper>
);
}
return (
<IconWrapper className={className} onClick={onClick}>
<img className={className} src={`${endpoint_http}${selectedIcon}`} alt="icon" />
</IconWrapper>
);
}
export default TypeIcon;

View File

@@ -0,0 +1,21 @@
import { useConnectStore } from "@/stores/connectStore";
export function useFindConnectorIcon(item: any) {
const connector_data = useConnectStore((state) => state.connector_data);
const datasourceData = useConnectStore((state) => state.datasourceData);
const currentService = useConnectStore((state) => state.currentService);
const id = item?.source?.id || "";
const result_source = datasourceData[currentService?.id]?.find(
(data: any) => data.id === id
);
const connector_id = result_source?.connector?.id;
const result_connector = connector_data[currentService?.id]?.find(
(data: any) => data.id === connector_id
);
return result_connector;
}

View File

@@ -2,7 +2,7 @@ import { Library, Mic, Send, Plus, AudioLines, Image } from "lucide-react";
import { useRef, useState, useEffect, useCallback } from "react";
import { listen } from "@tauri-apps/api/event";
import ChatSwitch from "../SearchChat/ChatSwitch";
import ChatSwitch from "@/components/Common/ChatSwitch";
import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "../../stores/chatStore";
import StopIcon from "../../icons/Stop";

View File

@@ -5,7 +5,7 @@ import { LogicalSize } from "@tauri-apps/api/dpi";
import DropdownList from "./DropdownList";
import { Footer } from "./Footer";
import { SearchResults } from "./SearchResults";
import { SearchResults } from "../Search/SearchResults";
import { tauriFetch } from "../../api/tauriFetchClient";
import { useAppStore } from '@/stores/appStore';
interface SearchProps {

View File

@@ -8,7 +8,7 @@ import { LogicalSize } from "@tauri-apps/api/dpi";
import InputBox from "./InputBox";
import Search from "./Search";
import ChatAI, { ChatAIRef } from "../ChatAI/Chat";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import { useWindows } from "../../hooks/useWindows";
// const appWindow = new Window("main");

View File

@@ -1,30 +1,54 @@
import {
Menu,
MenuButton,
// MenuItems, MenuItem
Menu,
MenuButton,
// MenuItems, MenuItem
} from "@headlessui/react";
// import { Settings, LogOut, User, ChevronUp, Home } from "lucide-react";
// import { Link } from "react-router-dom";
import logoImg from "../assets/32x32.png";
import {useAppStore} from "@/stores/appStore";
import {OctagonAlert} from 'lucide-react';
import { X } from 'lucide-react';
const Footer = () => {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
<Menu as="div" className="relative">
<MenuButton className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<img
src={logoImg}
className="w-5 h-5 text-gray-600 dark:text-gray-400"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
const error = useAppStore((state) => state.error);
const setError = useAppStore((state) => state.setError);
return (
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
{/* Move the warning message outside the border */}
{error && (
<div className="fixed bottom-6 left-0 right-0 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 rounded-lg shadow-lg p-4 m-4">
<div className="flex items-center space-x-4">
<OctagonAlert size={32} color="red" className="mr-2" />
<span className="text-xs text-red-500 dark:text-red-400 flex-1">{error}</span>
<X
className="cursor-pointer ml-2"
onClick={() => setError("")}
size={32}
color="gray"
/>
</div>
</div>
)}
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
<Menu as="div" className="relative">
<MenuButton
className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<img
src={logoImg}
className="w-5 h-5 text-gray-600 dark:text-gray-400"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Coco
</span>
{/* <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" /> */}
</MenuButton>
{/* <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" /> */}
</MenuButton>
{/* <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{/* <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="p-1">
<MenuItem>
{({ active }) => (
@@ -85,20 +109,22 @@ const Footer = () => {
</MenuItem>
</div>
</MenuItems> */}
</Menu>
</Menu>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-4">
<span className="text-xs text-gray-500 dark:text-gray-400">
Version 1.0.0
</span>
{/* <div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
{/* <div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
<button className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
Check for Updates
</button> */}
</div>
</div>
</div>
</div>
</div>
);
);
};
export default Footer;

View File

@@ -1,68 +0,0 @@
import { useState, useEffect } from "react";
interface ChatInterfaceProps {
query: string;
}
interface Message {
id: string;
content: string;
isUser: boolean;
}
export const ChatInterface: React.FC<ChatInterfaceProps> = ({ query }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [isTyping, setIsTyping] = useState(false);
useEffect(() => {
if (query) {
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
content: query,
isUser: true,
},
]);
setIsTyping(true);
setTimeout(() => {
setMessages((prev) => [
...prev,
{
id: (Date.now() + 1).toString(),
content: "hello world...",
isUser: false,
},
]);
setIsTyping(false);
}, 1000);
}
}, [query]);
return (
<div className="space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.isUser ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] p-3 rounded-lg ${
message.isUser
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
{message.content}
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-gray-100 p-3 rounded-lg">...</div>
</div>
)}
</div>
);
};

View File

@@ -1,91 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { SearchIcon, MessageCircleIcon } from "lucide-react";
import { SearchResults } from "./SearchResults";
import { ChatInterface } from "./ChatInterface";
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
setIsOpen: (isOpen: boolean) => void;
}
type Mode = "search" | "chat";
export const CommandPalette: React.FC<CommandPaletteProps> = ({
isOpen,
onClose,
setIsOpen,
}) => {
const [mode, setMode] = useState<Mode>("search");
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
isOpen ? onClose() : setIsOpen(true);
}
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
const toggleMode = () => {
setMode((prev) => (prev === "search" ? "chat" : "search"));
setQuery("");
};
return (
<div>
{isOpen && (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-20"
>
<div className="w-full max-w-2xl bg-white rounded-lg shadow-2xl">
<div className="flex items-center p-4 border-b">
<button
onClick={toggleMode}
className={`p-2 rounded-md transition-colors ${
mode === "search" ? "bg-blue-100 text-blue-600" : ""
}`}
>
<SearchIcon size={20} />
</button>
<button
onClick={toggleMode}
className={`p-2 ml-2 rounded-md transition-colors ${
mode === "chat" ? "bg-blue-100 text-blue-600" : ""
}`}
>
<MessageCircleIcon size={20} />
</button>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={mode === "search" ? "Search for files..." : "Input Message..."}
className="flex-1 ml-4 p-2 outline-none"
autoFocus
/>
</div>
<div className="max-h-[60vh] overflow-y-auto p-4">
{mode === "search" ? (
<SearchResults query={query} />
) : (
<ChatInterface query={query} />
)}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,46 +0,0 @@
import { useEffect, useState } from "react";
import { FileIcon, FolderIcon } from "lucide-react";
interface SearchResultsProps {
query: string;
}
export const SearchResults: React.FC<SearchResultsProps> = ({ query }) => {
const [results, setResults] = useState<
Array<{ name: string; path: string; type: "file" | "folder" }>
>([]);
useEffect(() => {
const searchFiles = async () => {
setResults([
{ name: "Document.pdf", path: "/documents/Document.pdf", type: "file" },
{ name: "Projects", path: "/documents/projects", type: "folder" },
]);
};
if (query) {
searchFiles();
}
}, [query]);
return (
<div className="space-y-2">
{results.map((result, index) => (
<div
key={index}
className="flex items-center p-3 hover:bg-gray-100 rounded-md cursor-pointer"
>
{result.type === "file" ? (
<FileIcon size={16} />
) : (
<FolderIcon size={16} />
)}
<div className="ml-3">
<div className="font-medium">{result.name}</div>
<div className="text-sm text-gray-500">{result.path}</div>
</div>
</div>
))}
</div>
);
};

View File

@@ -1,23 +0,0 @@
import { useState } from "react";
import { CommandPalette } from "./CommandPalette";
export default function MySearch() {
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
return (
<div>
<button
onClick={() => setIsCommandPaletteOpen(true)}
className="fixed bottom-4 right-4 p-4 bg-blue-500 text-white rounded-full"
>
Open the Command Palette
</button>
<CommandPalette
isOpen={isCommandPaletteOpen}
onClose={() => setIsCommandPaletteOpen(false)}
setIsOpen={setIsCommandPaletteOpen}
/>
</div>
);
}

View File

@@ -20,19 +20,19 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
const { theme } = useTheme();
function findConnectorIcon(item: any) {
const id = item?._source?.source?.id || "";
const id = item?.source?.id || "";
const result_source = datasourceData[endpoint_http]?.find(
(data: any) => data._source.id === id
(data: any) => data.id === id
);
const connector_id = result_source?._source?.connector?.id;
const connector_id = result_source?.connector?.id;
const result_connector = connector_data[endpoint_http]?.find(
(data: any) => data._source.id === connector_id
(data: any) => data.id === connector_id
);
return result_connector?._source;
return result_connector;
}
function getTypeIcon(item: any) {
@@ -58,7 +58,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
{/* <div className="mb-4">
<iframe
src={document?._source?.metadata?.web_view_link}
src={document?.metadata?.web_view_link}
style={{ width: "100%", height: "500px" }}
title="Text Preview"
/>
@@ -74,7 +74,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Name</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-60 break-words">
{document?._source?.title || "-"}
{document?.title || "-"}
</div>
</div>
@@ -86,7 +86,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
src={getTypeIcon(document)}
alt="icon"
/>
{document?._source?.source?.name || "-"}
{document?.source?.name || "-"}
</div>
</div>
{/* <div className="flex justify-between font-normal text-xs mb-2.5">
@@ -95,43 +95,43 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
-
</div>
</div> */}
{document?._source?.updated ? (
{document?.updated ? (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Updated at</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?._source?.updated || "-"}
{document?.updated || "-"}
</div>
</div>
) : null}
{document?._source?.last_updated_by?.user?.username ? (
{document?.last_updated_by?.user?.username ? (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Update by</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?._source?.last_updated_by?.user?.username || "-"}
{document?.last_updated_by?.user?.username || "-"}
</div>
</div>
) : null}
{document?._source?.owner?.username ? (
{document?.owner?.username ? (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Created by</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?._source?.owner?.username || "-"}
{document?.owner?.username || "-"}
</div>
</div>
) : null}
{document?._source?.type ? (
{document?.type ? (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Type</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?._source?.type || "-"}
{document?.type || "-"}
</div>
</div>
) : null}
{document?._source?.size ? (
{document?.size ? (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Size</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{formatter.bytes(document?._source?.size || 0)}
{formatter.bytes(document?.size || 0)}
</div>
</div>
) : null}

View File

@@ -1,15 +1,12 @@
import React, { useState, useRef, useEffect } from "react";
import { useInfiniteScroll } from "ahooks";
import { isTauri } from "@tauri-apps/api/core";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { useAppStore } from "@/stores/appStore";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useSearchStore } from "@/stores/searchStore";
import { SearchHeader } from "./SearchHeader";
import file_efault_img from "@/assets/images/file_efault.png";
import noDataImg from "@/assets/coconut-tree.png";
import { useConnectStore } from "@/stores/connectStore";
import ItemIcon from "@/components/Common/Icons/ItemIcon";
interface DocumentListProps {
onSelectDocument: (id: string) => void;
@@ -26,11 +23,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
getDocDetail,
isChatMode,
}) => {
const connector_data = useConnectStore((state) => state.connector_data);
const datasourceData = useConnectStore((state) => state.datasourceData);
const sourceData = useSearchStore((state) => state.sourceData);
const endpoint_http = useAppStore((state) => state.endpoint_http);
const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [total, setTotal] = useState(0);
@@ -39,26 +32,30 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const { data, loading } = useInfiniteScroll(
async (d) => {
const page = d ? Math.ceil(d.list.length / PAGE_SIZE) + 1 : 1;
const from = d?.list.length || 0;
const from = (page - 1) * PAGE_SIZE;
let queryStrings: any = {
query: input,
datasource: sourceData?.source?.id,
};
let url = `/query/_search?query=${input}&datasource=${sourceData?._source?.source?.id}&from=${from}&size=${PAGE_SIZE}`;
if (sourceData?._source?.rich_categories) {
url = `/query/_search?query=${input}&rich_category=${sourceData?._source?.rich_categories[0]?.key}&from=${from}&size=${PAGE_SIZE}`;
if (sourceData?.rich_categories) {
queryStrings = {
query: input,
rich_category: sourceData?.rich_categories[0]?.key,
};
}
try {
const response = await tauriFetch({
url,
method: "GET",
const response: any = await invoke("query_coco_servers", {
from: from,
size: PAGE_SIZE,
queryStrings,
});
const list = response?.documents || [];
const total = response?.total_hits || 0;
const list = response.data?.hits?.hits || [];
const total = response.data?.hits?.total?.value || 0;
console.log("doc", url, response.data?.hits)
console.log("doc", response?.documents);
setTotal(total);
@@ -84,10 +81,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
setTimeout(() => {
const parentRef = containerRef.current;
if (parentRef && parentRef.childElementCount > 10) {
const itemHeight = (parentRef.firstChild as HTMLElement)?.offsetHeight || 80;
const itemHeight =
(parentRef.firstChild as HTMLElement)?.offsetHeight || 80;
parentRef.scrollTo({
top: (parentRef.lastChild as HTMLElement)?.offsetTop - itemHeight,
behavior: 'instant',
behavior: "instant",
});
}
});
@@ -104,43 +102,10 @@ export const DocumentList: React.FC<DocumentListProps> = ({
parentRef.scrollTo({
top:
parentRef.lastChild?.offsetTop - (data?.list?.length + 1) * itemHeight,
behavior: 'instant',
behavior: "instant",
});
};
function findConnectorIcon(item: any) {
const id = item?._source?.source?.id || "";
const result_source = datasourceData[endpoint_http]?.find(
(data: any) => data._source.id === id
);
const connector_id = result_source?._source?.connector?.id;
const result_connector = connector_data[endpoint_http]?.find(
(data: any) => data._source.id === connector_id
);
return result_connector?._source;
}
function getIcon(item: any) {
const connectorSource = findConnectorIcon(item);
const icons = connectorSource?.assets?.icons || {};
const selectedIcon = icons[item?._source?.icon];
if (!selectedIcon) {
return file_efault_img;
}
if (selectedIcon?.startsWith("http://") || selectedIcon?.startsWith("https://")) {
return selectedIcon;
} else {
return endpoint_http + selectedIcon;
}
}
function onMouseEnter(index: number, item: any) {
getDocDetail(item);
setSelectedItem(index);
@@ -179,8 +144,8 @@ export const DocumentList: React.FC<DocumentListProps> = ({
if (e.key === "Enter" && selectedItem !== null) {
const item = data?.list?.[selectedItem];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
if (item?.url) {
handleOpenURL(item?.url);
}
}
};
@@ -216,13 +181,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const isSelected = selectedItem === index;
return (
<div
key={item._id + index}
key={item.id + index}
ref={(el) => (itemRefs.current[index] = el)}
onMouseEnter={() => onMouseEnter(index, item)}
onClick={() => {
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
}
if (item?.url) {
handleOpenURL(item?.url);
}
}}
className={`w-full px-2 py-2.5 text-sm flex items-center gap-3 rounded-lg transition-colors cursor-pointer ${
isSelected
@@ -231,17 +196,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
}`}
>
<div className="flex gap-2 items-center flex-1 min-w-0">
<img
className="w-5 h-5 flex-shrink-0"
src={getIcon(item)}
alt="icon"
/>
<ItemIcon item={item} />
<span
className={`text-sm truncate ${
isSelected ? "font-medium" : ""
}`}
>
{item?._source?.title}
{item?.title}
</span>
</div>
</div>
@@ -256,14 +218,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
{!loading && data?.list.length === 0 && (
<div
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
>
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
No Results
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
>
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
No Results
</div>
</div>
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,245 @@
import { useEffect, useRef, useState, useCallback } from "react";
import {
CircleAlert,
Bolt,
X,
ArrowBigRight,
} from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { useSearchStore } from "@/stores/searchStore";
import ThemedIcon from "@/components/Common/Icons/ThemedIcon";
import IconWrapper from "@/components/Common/Icons/IconWrapper";
import TypeIcon from "@/components/Common/Icons/TypeIcon";
import ItemIcon from "@/components/Common/Icons/ItemIcon";
import ListRight from "./ListRight";
type ISearchData = Record<string, any[]>;
interface DropdownListProps {
selected: (item: any) => void;
suggests: any[];
SearchData: ISearchData;
IsError: boolean;
isSearchComplete: boolean;
isChatMode: boolean;
}
function DropdownList({
selected,
suggests,
SearchData,
IsError,
isChatMode,
}: DropdownListProps) {
const globalIndex = useRef(0);
const globalItemIndexMap = useRef<any[]>([]);
const setSourceData = useSearchStore((state: { setSourceData: any; }) => state.setSourceData);
const [showError, setShowError] = useState<boolean>(IsError);
const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [selectedName, setSelectedName] = useState<string>("");
const [showIndex, setShowIndex] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
if (isChatMode) {
setSelectedItem(null);
}
}, [isChatMode]);
const handleOpenURL = async (url: string) => {
if (!url) return;
try {
if (isTauri()) {
await open(url);
// console.log("URL opened in default browser");
}
} catch (error) {
console.error("Failed to open URL:", error);
}
};
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// console.log(
// "handleKeyDown",
// e.key,
// showIndex,
// e.key >= "0" && e.key <= "9" && showIndex
// );
if (!suggests.length) return;
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedItem((prev) => {
const res =
prev === null || prev === 0 ? suggests.length - 1 : prev - 1;
return res;
});
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedItem((prev) =>
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
);
} else if (e.key === "Meta") {
e.preventDefault();
if (selectedItem !== null) {
const item = globalItemIndexMap.current[selectedItem];
setSelectedName(item?.source?.name);
}
setShowIndex(true);
}
if (e.key === "ArrowRight" && selectedItem !== null) {
e.preventDefault();
const item = globalItemIndexMap.current[selectedItem];
goToTwoPage(item);
}
if (e.key === "Enter" && selectedItem !== null) {
// console.log("Enter key pressed", selectedItem);
const item = globalItemIndexMap.current[selectedItem];
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
}
if (e.key >= "0" && e.key <= "9" && showIndex) {
// console.log(`number ${e.key}`);
const item = globalItemIndexMap.current[parseInt(e.key, 10)];
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
}
}, [suggests, selectedItem, showIndex, selected, handleOpenURL, globalItemIndexMap]);
const handleKeyUp = useCallback((e: KeyboardEvent) => {
// console.log("handleKeyUp", e.key);
if (!suggests.length) return;
if (!e.metaKey) {
setShowIndex(false);
}
}, []);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
useEffect(() => {
if (selectedItem !== null && itemRefs.current[selectedItem]) {
itemRefs.current[selectedItem]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedItem]);
function goToTwoPage(item: any) {
setSourceData(item);
selected && selected(item);
}
return (
<div
ref={containerRef}
data-tauri-drag-region
className="h-[458px] w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
tabIndex={0}
>
{showError ? (
<div className="flex items-center gap-2 text-sm text-[#333] p-2">
<CircleAlert className="text-[#FF0000] w-[14px] h-[14px]" />
Coco server is unavailable, only local results and available services
are displayed.
<Bolt className="text-[#000] w-[14px] h-[14px] cursor-pointer" />
<X
className="text-[#666] w-[16px] h-[16px] cursor-pointer"
onClick={() => setShowError(false)}
/>
</div>
) : null}
{Object.entries(SearchData).map(([sourceName, items]) => (
<div key={sourceName}>
{Object.entries(SearchData).length < 5 ? (
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
<TypeIcon item={items[0]} className="w-4 h-4" />
{sourceName}
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
<IconWrapper
className="w-4 h-4 cursor-pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
goToTwoPage(items[0]);
}}
>
<ThemedIcon component={ArrowBigRight} className="w-4 h-4" />
</IconWrapper>
{showIndex && sourceName === selectedName ? (
<div
className={`absolute right-2
w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
>
</div>
) : null}
</div>
) : null}
{items.map((item: any, index: number) => {
const isSelected = selectedItem === globalIndex.current;
const currentIndex = globalIndex.current;
globalItemIndexMap.current.push(item);
globalIndex.current++;
return (
<div
key={item.id + index}
ref={(el) => (itemRefs.current[currentIndex] = el)}
onMouseEnter={() => setSelectedItem(currentIndex)}
onClick={() => {
if (item?.url) {
handleOpenURL(item?.url);
} else {
selected(item);
}
}}
className={`w-full px-2 py-2.5 text-sm flex gap-7 items-center justify-between rounded-lg transition-colors ${isSelected
? "text-white bg-[#950599] hover:bg-[#950599]"
: "text-[#333] dark:text-[#d8d8d8]"
}`}
>
<div className="flex gap-2 items-center justify-start max-w-[450px]">
<ItemIcon item={item} />
<span
className={`text-sm truncate text-left ${isSelected ? "font-medium" : ""
}`}
>
{item?.title}
</span>
</div>
<ListRight goToTwoPage={goToTwoPage} item={item} isSelected={isSelected} showIndex={showIndex} currentIndex={currentIndex}/>
</div>
);
})}
</div>
))}
</div>
);
}
export default DropdownList;

View File

@@ -1,7 +1,7 @@
import {
Command,
ArrowDown01,
AppWindowMac,
// AppWindowMac,
CornerDownLeft,
} from "lucide-react";
@@ -18,7 +18,7 @@ interface FooterProps {
name?: string;
}
export default function Footer({ name }: FooterProps) {
export default function Footer({ }: FooterProps) {
const sourceData = useSearchStore((state) => state.sourceData);
const connector_data = useConnectStore((state) => state.connector_data);
@@ -29,19 +29,19 @@ export default function Footer({ name }: FooterProps) {
const { theme } = useTheme();
function findConnectorIcon(item: any) {
const id = item?._source?.source?.id || "";
const id = item?.source?.id || "";
const result_source = datasourceData[endpoint_http]?.find(
(data: any) => data._source.id === id
(data: any) => data.id === id
);
const connector_id = result_source?._source?.connector?.id;
const connector_id = result_source?.connector?.id;
const result_connector = connector_data[endpoint_http]?.find(
(data: any) => data._source.id === connector_id
(data: any) => data.id === connector_id
);
return result_connector?._source;
return result_connector;
}
function getTypeIcon(item: any) {
@@ -66,21 +66,21 @@ export default function Footer({ name }: FooterProps) {
>
<div className="flex items-center">
<div className="flex items-center space-x-2">
{sourceData?._source?.source?.name ? (
{sourceData?.source?.name ? (
<img className="w-5 h-5" src={getTypeIcon(sourceData)} alt="icon" />
) : (
<img src={logoImg} className="w-5 h-5" />
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
{sourceData?._source?.source?.name || "Version 1.0.0"}
{sourceData?.source?.name || "Version 1.0.0"}
</span>
</div>
{name ? (
{/* {name ? (
<div className="flex gap-2 items-center text-[#666] text-xs">
<AppWindowMac className="w-5 h-5" /> {name}
</div>
) : null}
) : null} */}
</div>
<div className="flex items-center gap-3">

View File

@@ -5,18 +5,19 @@ import {
Plus,
AudioLines,
Image,
SquareArrowLeft,
ArrowBigLeft,
} from "lucide-react";
import { useRef, useState, useEffect, useCallback } from "react";
import { listen } from "@tauri-apps/api/event";
import { isTauri } from "@tauri-apps/api/core";
import ChatSwitch from "../SearchChat/ChatSwitch";
import ChatSwitch from "@/components/Common/ChatSwitch";
import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "@/stores/chatStore";
import StopIcon from "@/icons/Stop";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { isMac } from "@/utils/platform";
interface ChatInputProps {
onSend: (message: string) => void;
@@ -39,10 +40,10 @@ export default function ChatInput({
disabledChange,
reconnect,
}: ChatInputProps) {
const showTooltip = useAppStore((state) => state.showTooltip);
const showTooltip = useAppStore((state: { showTooltip: boolean }) => state.showTooltip);
const sourceData = useSearchStore((state) => state.sourceData);
const setSourceData = useSearchStore((state) => state.setSourceData);
const sourceData = useSearchStore((state: { sourceData: any; }) => state.sourceData);
const setSourceData = useSearchStore((state: { setSourceData: any; }) => state.setSourceData);
useEffect(() => {
setSourceData(undefined);
@@ -76,11 +77,13 @@ export default function ChatInput({
(e: KeyboardEvent) => {
pressedKeys.add(e.code);
if (e.code === "MetaLeft" || e.code === "MetaRight") {
if ((isMac && (e.code === "MetaLeft" || e.code === "MetaRight")) ||
(!isMac && (e.code === "ControlLeft" || e.code === "ControlRight"))) {
setIsCommandPressed(true);
}
if (pressedKeys.has("MetaLeft") || pressedKeys.has("MetaRight")) {
if ((isMac && (pressedKeys.has("MetaLeft") || pressedKeys.has("MetaRight"))) ||
(!isMac && (pressedKeys.has("ControlLeft") || pressedKeys.has("ControlRight")))) {
// e.preventDefault();
switch (e.code) {
case "Comma":
@@ -128,7 +131,8 @@ export default function ChatInput({
const handleKeyUp = useCallback((e: KeyboardEvent) => {
pressedKeys.delete(e.code);
if (e.code === "MetaLeft" || e.code === "MetaRight") {
if ((isMac && (e.code === "MetaLeft" || e.code === "MetaRight")) ||
(!isMac && (e.code === "ControlLeft" || e.code === "ControlRight"))) {
setIsCommandPressed(false);
}
}, []);
@@ -198,7 +202,7 @@ export default function ChatInput({
<div className="p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative">
<div className="flex flex-wrap gap-2 flex-1 items-center relative">
{!isChatMode && sourceData ? (
<SquareArrowLeft
<ArrowBigLeft
className="w-4 h-4 text-[#000] dark:text-[#d8d8d8] cursor-pointer"
onClick={() => setSourceData(undefined)}
/>
@@ -242,9 +246,8 @@ export default function ChatInput({
) : null}
{showTooltip && isCommandPressed ? (
<div
className={`absolute ${
!isChatMode && sourceData ? "left-7" : ""
} w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`}
className={`absolute ${!isChatMode && sourceData ? "left-7" : ""
} w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`}
>
I
</div>
@@ -262,11 +265,10 @@ export default function ChatInput({
{isChatMode && curChatEnd ? (
<button
className={`ml-1 p-1 ${
inputValue
? "bg-[#0072FF]"
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors`}
className={`ml-1 p-1 ${inputValue
? "bg-[#0072FF]"
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors`}
type="submit"
onClick={() => onSend(inputValue.trim())}
>
@@ -386,7 +388,7 @@ export default function ChatInput({
) : null}
<ChatSwitch
isChatMode={isChatMode}
onChange={(value) => {
onChange={(value: boolean) => {
value && disabledChange();
changeMode(value);
setSourceData(undefined);

View File

@@ -0,0 +1,111 @@
import TypeIcon from "@/components/Common/Icons/TypeIcon";
import RichIcon from "@/components/Common/Icons/RichIcon";
interface ListRightProps {
item: any;
isSelected: boolean;
showIndex: boolean;
currentIndex: number;
goToTwoPage: (item: any) => void;
}
export default function ListRight({
item,
isSelected,
showIndex,
currentIndex,
goToTwoPage
}: ListRightProps) {
return (
<div className="flex-1 text-right min-w-[160px] h-full pl-5 text-[12px] flex gap-2 items-center justify-end relative">
{item?.rich_categories ? null : (
<div
className="w-4 h-4 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
>
<TypeIcon
item={item}
className="w-4 h-4 cursor-pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
</div>
)}
{item?.rich_categories ? (
<div className="flex items-center justify-end max-w-[calc(100%-20px)] whitespace-nowrap">
<RichIcon
item={item}
className="w-4 h-4 mr-2 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
<span
className={`${isSelected ? "text-[#C8C8C8]" : "text-[#666]"} text-right truncate`}
>
{item?.rich_categories?.map(
(rich_item: any, index: number) => {
if (
item?.rich_categories.length > 2 &&
index === item?.rich_categories.length - 1
)
return "";
return (index !== 0 ? "/" : "") + rich_item?.label;
}
)}
</span>
{item?.rich_categories.length > 2 ? (
<span className={`${isSelected ? "text-[#C8C8C8]" : "text-[#666]"} text-right truncate`}>
{"/" + item?.rich_categories?.at(-1)?.label}
</span>
) : null}
</div>
) : item?.category || item?.subcategory ? (
<span
className={`text-[12px] truncate ${isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
}`}
>
{(item?.category || "") + (item?.subcategory ? `/${item?.subcategory}` : "")}
</span>
) : (
<span
className={`text-[12px] truncate ${isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
}`}
>
{item?.last_updated_by?.user?.username || item?.owner?.username || item?.updated || item?.created || item?.type || ""}
</span>
)}
{isSelected ? (
<div
className={`absolute ${showIndex && currentIndex < 10 ? "right-7" : "right-0"
} w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
</div>
) : null}
{showIndex && currentIndex < 10 ? (
<div
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
{currentIndex}
</div>
) : null}
</div>
);
}

View File

@@ -1,15 +1,13 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { Command } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
// import { isTauri } from "@tauri-apps/api/core";
import DropdownList from "./DropdownList";
import Footer from "./Footer";
import { tauriFetch } from "@/api/tauriFetchClient";
import noDataImg from "@/assets/coconut-tree.png";
import { useAppStore } from "@/stores/appStore";
// import { res_search2 } from "@/mock/index";
import { SearchResults } from "../SearchChat/SearchResults";
import { SearchResults } from "@/components/Search/SearchResults";
import { useSearchStore } from "@/stores/searchStore";
interface SearchProps {
@@ -19,8 +17,6 @@ interface SearchProps {
}
function Search({ isChatMode, input }: SearchProps) {
const appStore = useAppStore();
const sourceData = useSearchStore((state) => state.sourceData);
const [IsError, setIsError] = useState<boolean>(false);
@@ -76,17 +72,20 @@ function Search({ isChatMode, input }: SearchProps) {
// return;
//
try {
const response = await tauriFetch({
url: `/query/_search?query=${input}`,
method: "GET",
baseURL: appStore.endpoint_http,
});
// const response = await tauriFetch({
// url: `/query/_search?query=${input}`,
// method: "GET",
// baseURL: appStore.endpoint_http,
// });
const response: any = await invoke("query_coco_servers", { from: 0, size: 10, queryStrings: { query: input } });
// failed_coco_servers documents
console.log("_suggest", input, response);
let data = response.data?.hits?.hits || [];
let data = response?.documents || [];
setSuggests(data);
const search_data = data.reduce((acc: any, item: any) => {
const name = item?._source?.source?.name;
const name = item?.source?.name;
if (!acc[name]) {
acc[name] = [];
}

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { DocumentList } from "./DocumentList";
import { DocumentDetail } from "./DocumentDetail";
@@ -21,16 +22,16 @@ export function SearchResults({ input, isChatMode }: SearchResultsProps) {
<div className="h-full flex">
{/* Left Panel */}
<DocumentList
onSelectDocument={setSelectedDocumentId}
selectedId={selectedDocumentId}
input={input}
getDocDetail={getDocDetail}
isChatMode={isChatMode}
/>
onSelectDocument={setSelectedDocumentId}
selectedId={selectedDocumentId}
input={input}
getDocDetail={getDocDetail}
isChatMode={isChatMode}
/>
{/* Right Panel */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<DocumentDetail document={detailData}/>
<DocumentDetail document={detailData} />
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { Globe, Github } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { OpenBrowserURL } from "@/utils";
import { OpenURLWithBrowser } from "@/utils";
import logoLight from "@/assets/images/logo-text-light.svg";
import logoDark from "@/assets/images/logo-text-dark.svg";
@@ -20,8 +20,8 @@ export default function AboutView(){
Search, Connect, CollaborateAll in one place
</div>
<div className="flex justify-center items-center mt-10">
<button onClick={() => OpenBrowserURL('https://coco.rs')} className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"><Globe className="w-3 text-blue-500"/></button>
<button onClick={() => OpenBrowserURL('https://github.com/infinilabs/coco-app')} className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700" ><Github className="w-3 text-blue-500"/></button>
<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"><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" ><Github className="w-3 text-blue-500"/></button>
</div>
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
Version 1.0.0

View File

@@ -1,197 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { v4 as uuidv4 } from "uuid";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import * as shell from "@tauri-apps/plugin-shell";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import { OpenBrowserURL } from "@/utils/index";
import logoImg from "@/assets/32x32.png";
import callbackTemplate from "@/components/Auth/callback.template";
import { clientEnv } from "@/utils/env";
export default function Account() {
const app_uid = useAppStore((state) => state.app_uid);
const setAppUid = useAppStore((state) => state.setAppUid);
const endpoint_http = useAppStore((state) => state.endpoint_http);
const { auth, setAuth } = useAuthStore();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setupAuthListener = async () => {
try {
if (!(auth && auth[endpoint_http])) {
// Replace the current route with signin
// navigate("/signin", { replace: true });
}
} catch (error) {
console.error("Failed to set up auth listener:", error);
}
};
setupAuthListener();
// Clean up logic on unmount
return () => {
const cleanup = async () => {
try {
await invoke("plugin:oauth|stop");
} catch (e) {
// Ignore errors if no server is running
}
if (unsubscribe) {
unsubscribe();
}
};
cleanup();
};
}, [JSON.stringify(auth)]);
async function signIn() {
let res: (url: URL) => void;
try {
const stopListening = await listen(
"oauth://url",
(data: { payload: string }) => {
if (!data.payload.includes("token")) {
return;
}
const urlObject = new URL(data.payload);
res(urlObject);
}
);
// Stop any existing OAuth server first
try {
await invoke("plugin:oauth|stop");
} catch (e) {
// Ignore errors if no server is running
}
const port: string = await invoke("plugin:oauth|start", {
config: {
response: callbackTemplate,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
},
// Add a cleanup function to stop the server after handling the request
cleanup: true,
},
});
await shell.open(
`${endpoint_http || clientEnv.COCO_SERVER_URL}/api/desktop/session/request?port=${port}`
);
const url = await new Promise<URL>((r) => {
res = r;
});
stopListening();
const token = url.searchParams.get("token");
const user_id = url.searchParams.get("user_id");
const expires = Number(url.searchParams.get("expires"));
if (!token || !expires || !user_id) {
throw new Error("Invalid token or expires");
}
await setAuth({
token,
user_id,
expires,
plan: { upgraded: false, last_checked: 0 },
}, endpoint_http);
getCurrentWindow()
.setFocus()
.catch(() => {});
return navigate("/");
} catch (error) {
console.error("Sign in failed:", error);
await setAuth(undefined, endpoint_http);
throw error;
}
}
async function initializeUser() {
let uid = app_uid;
if (!uid) {
uid = uuidv4();
setAppUid(uid);
}
// const response = await fetch("/api/register", {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ uid }),
// });
// const { token } = await response.json();
// localStorage.setItem("auth_token", token);
OpenBrowserURL(`http://localhost:1420/login?uid=${uid}`);
setLoading(true);
try {
await signIn();
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
function LoginClick(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
event.preventDefault();
initializeUser();
}
return (
<div className="h-[450px] bg-gradient-to-br from-purple-100 via-purple-200 to-gray-200 dark:from-gray-800 dark:via-gray-900 dark:to-black flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md flex flex-col items-center text-center space-y-8">
<div className="animate-pulse">
<img
src={logoImg}
alt="logo"
className="w-12 h-12 text-red-500 dark:text-red-300"
/>
</div>
<div className="space-y-4">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Get Started
</h1>
<p className="text-gray-600 text-sm leading-relaxed max-w-sm dark:text-gray-300">
You need to log in or create an account to view your organizations,
manage your custom extensions.
</p>
</div>
<div className="mt-10 flex items-center justify-center gap-x-6">
<a
href="#"
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
Sign Up
</a>
<a
href="#"
className="text-sm/6 font-semibold text-gray-900 dark:text-gray-100"
onClick={LoginClick}
>
{loading ? "Signing In..." : "Sign In"}
</a>
</div>
</div>
</div>
);
}

View File

@@ -20,11 +20,45 @@ import SettingsToggle from "./SettingsToggle";
import { ShortcutItem } from "./ShortcutItem";
import { Shortcut } from "./shortcut";
import { useShortcutEditor } from "@/hooks/useShortcutEditor";
import { ThemeOption } from "./index2";
import { useAppStore } from "@/stores/appStore";
import {AppTheme} from "@/utils/tauri.ts";
import {useTheme} from "@/contexts/ThemeContext.tsx";
// import { useAuthStore } from "@/stores/authStore";
// import { useConnectStore } from "@/stores/connectStore";
export function ThemeOption({
icon: Icon,
title,
theme,
}: {
icon: any;
title: string;
theme: AppTheme;
}) {
const { theme: currentTheme, changeTheme } = useTheme();
const isSelected = currentTheme === theme;
return (
<button
onClick={() => changeTheme(theme)}
className={`p-4 rounded-lg border-2 ${
isSelected
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
} flex flex-col items-center justify-center space-y-2 transition-all`}
>
<Icon className={`w-6 h-6 ${isSelected ? "text-blue-500" : ""}`} />
<span
className={`text-sm font-medium ${isSelected ? "text-blue-500" : ""}`}
>
{title}
</span>
</button>
);
}
export default function GeneralSettings() {
const [launchAtLogin, setLaunchAtLogin] = useState(true);

View File

@@ -1,145 +0,0 @@
import { useState } from "react";
import {
Settings,
Search,
Command,
Keyboard,
Globe,
Zap,
ChevronRight,
Moon,
Sun,
} from "lucide-react";
function NavItem({ icon: Icon, label, active, onClick }: any) {
return (
<button
onClick={onClick}
className={`w-full flex items-center px-3 py-2 rounded-lg text-sm ${
active
? "bg-indigo-50 text-indigo-600"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<Icon className="w-4 h-4 mr-3" />
<span className="font-medium">{label}</span>
</button>
);
}
function SettingItem({ icon: Icon, title, description, action }: any) {
return (
<div className="flex items-center justify-between py-4 border-b border-gray-100">
<div className="flex items-start space-x-3">
<div className="p-2 bg-gray-50 rounded-lg">
<Icon className="w-5 h-5 text-gray-600" />
</div>
<div>
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
{action}
</div>
);
}
function AppSettings() {
const [activeSection, setActiveSection] = useState("general");
const [darkMode, setDarkMode] = useState(false);
const sections = [
{ id: "general", label: "General", icon: Settings },
{ id: "appearance", label: "Appearance", icon: Search },
{ id: "extensions", label: "Extensions", icon: Command },
{ id: "keyboard", label: "Keyboard", icon: Keyboard },
{ id: "advanced", label: "Advanced", icon: Zap },
];
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white rounded-2xl border border-gray-200 flex min-h-[500px]">
<div className="w-64 p-4 border-r border-gray-100">
<div className="space-y-1">
{sections.map((section) => (
<NavItem
key={section.id}
icon={section.icon}
label={section.label}
active={activeSection === section.id}
onClick={() => setActiveSection(section.id)}
/>
))}
</div>
</div>
{/* Main Content */}
<div className="flex-1 p-6">
<div className="max-w-3xl">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">
Settings
</h1>
<div className="space-y-1">
<SettingItem
icon={Globe}
title="Language"
description="Choose your preferred language for the interface"
action={
<button className="flex items-center text-sm text-gray-600 hover:text-gray-900">
English
<ChevronRight className="w-4 h-4 ml-1" />
</button>
}
/>
<SettingItem
icon={darkMode ? Moon : Sun}
title="Appearance"
description="Switch between light and dark mode"
action={
<button
onClick={() => setDarkMode(!darkMode)}
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span
className={`${
darkMode ? "translate-x-6" : "translate-x-1"
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
/>
</button>
}
/>
<SettingItem
icon={Command}
title="Keyboard Shortcuts"
description="Customize your keyboard shortcuts"
action={
<button className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-md hover:border-gray-300">
Configure
</button>
}
/>
<SettingItem
icon={Zap}
title="Performance Mode"
description="Optimize for better performance"
action={
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-indigo-600 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<span className="translate-x-6 inline-block h-4 w-4 transform rounded-full bg-white transition-transform" />
</button>
}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default AppSettings;

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