feat: add in-app document preview webview

This commit is contained in:
ayangweb
2026-06-23 09:55:34 +08:00
parent e6c48a2269
commit 38ebac5bb5
22 changed files with 1457 additions and 63 deletions

View File

@@ -53,6 +53,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"docx-preview": "^0.3.7",
"dotenv": "^16.5.0",
"filesize": "^10.1.6",
"i18next": "^23.16.8",
@@ -62,6 +63,7 @@
"mdast-util-gfm-autolink-literal": "2.0.0",
"mermaid": "^11.6.0",
"nanoid": "^5.1.5",
"pptx-preview": "^1.0.7",
"react": "^18.3.1",
"react-day-picker": "^9.13.0",
"react-dom": "^18.3.1",

128
pnpm-lock.yaml generated
View File

@@ -110,6 +110,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.18
docx-preview:
specifier: ^0.3.7
version: 0.3.7
dotenv:
specifier: ^16.5.0
version: 16.6.1
@@ -137,6 +140,9 @@ importers:
nanoid:
specifier: ^5.1.5
version: 5.1.6
pptx-preview:
specifier: ^1.0.7
version: 1.0.7
react:
specifier: ^18.3.1
version: 18.3.1
@@ -2364,6 +2370,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cose-base@1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@@ -2627,6 +2636,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
docx-preview@0.3.7:
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
dompurify@3.2.5:
resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==}
@@ -2645,6 +2657,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
electron-to-chromium@1.5.151:
resolution: {integrity: sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA==}
@@ -2983,6 +2998,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
@@ -3128,6 +3146,9 @@ packages:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -3173,6 +3194,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
katex@0.16.22:
resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==}
hasBin: true
@@ -3201,6 +3225,9 @@ packages:
layout-base@2.0.1:
resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@@ -3658,6 +3685,9 @@ packages:
package-manager-detector@1.5.0:
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -3768,10 +3798,16 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
pptx-preview@1.0.7:
resolution: {integrity: sha512-YByocJuyxAR4YB4Q3+VAxdLfEvA5LojG1gAJsx2Mw0QU5FJPps/2fkJOupJ6oBbA+KdWRpuAk6G6T34rKCHVxw==}
pretty-ms@9.2.0:
resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==}
engines: {node: '>=18'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -3916,6 +3952,9 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -4020,6 +4059,9 @@ packages:
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -4044,6 +4086,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -4115,6 +4160,9 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -4239,6 +4287,9 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -4379,6 +4430,14 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
@@ -4524,6 +4583,9 @@ packages:
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
engines: {node: '>=18'}
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
@@ -6440,6 +6502,8 @@ snapshots:
convert-source-map@2.0.0: {}
core-util-is@1.0.3: {}
cose-base@1.0.3:
dependencies:
layout-base: 1.0.2
@@ -6709,6 +6773,10 @@ snapshots:
dependencies:
dequal: 2.0.3
docx-preview@0.3.7:
dependencies:
jszip: 3.10.1
dompurify@3.2.5:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -6727,6 +6795,11 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts@5.6.0:
dependencies:
tslib: 2.3.0
zrender: 5.6.1
electron-to-chromium@1.5.151: {}
electron-to-chromium@1.5.241: {}
@@ -7169,6 +7242,8 @@ snapshots:
ignore@5.3.2: {}
immediate@3.0.6: {}
immer@10.2.0: {}
immutable@5.1.2: {}
@@ -7279,6 +7354,8 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isarray@1.0.0: {}
isexe@2.0.0: {}
issue-parser@7.0.1:
@@ -7315,6 +7392,13 @@ snapshots:
json5@2.2.3: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
katex@0.16.22:
dependencies:
commander: 8.3.0
@@ -7341,6 +7425,10 @@ snapshots:
layout-base@2.0.1: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.30.2:
optional: true
@@ -8010,6 +8098,8 @@ snapshots:
package-manager-detector@1.5.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -8113,10 +8203,20 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
pptx-preview@1.0.7:
dependencies:
echarts: 5.6.0
jszip: 3.10.1
lodash: 4.17.21
tslib: 2.8.1
uuid: 10.0.0
pretty-ms@9.2.0:
dependencies:
parse-ms: 4.0.0
process-nextick-args@2.0.1: {}
property-information@7.1.0: {}
proto-list@1.2.4: {}
@@ -8270,6 +8370,16 @@ snapshots:
dependencies:
loose-envify: 1.4.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdirp@4.1.2: {}
rechoir@0.6.2:
@@ -8455,6 +8565,8 @@ snapshots:
dependencies:
tslib: 2.8.1
safe-buffer@5.1.2: {}
safer-buffer@2.1.2: {}
sass@1.93.2:
@@ -8475,6 +8587,8 @@ snapshots:
semver@7.6.3: {}
setimmediate@1.0.5: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -8545,6 +8659,10 @@ snapshots:
get-east-asian-width: 1.3.0
strip-ansi: 7.1.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -8663,6 +8781,8 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.7.1):
@@ -8814,6 +8934,10 @@ snapshots:
react: 18.3.1
optional: true
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@11.1.0: {}
vfile-location@5.0.3:
@@ -8933,6 +9057,10 @@ snapshots:
yoctocolors@2.1.1: {}
zrender@5.6.1:
dependencies:
tslib: 2.3.0
zustand@5.0.8(@types/react@18.3.26)(immer@10.2.0)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)):
optionalDependencies:
'@types/react': 18.3.26

View File

@@ -8,6 +8,10 @@ use serde_json::Value as Json;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
fn is_web_url(url: &str) -> bool {
url.starts_with("http://") || url.starts_with("https://")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichLabel {
pub label: Option<String>,
@@ -165,7 +169,13 @@ pub(crate) async fn open(
OnOpened::Document { url } => {
log::debug!("open document [{}]", url);
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
if is_web_url(&url) {
tauri_app_handle
.emit("open_url_window", url)
.map_err(|e| e.to_string())?;
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
}
#[cfg(target_os = "macos")]
OnOpened::WindowManagementAction { action } => {

View File

@@ -211,6 +211,8 @@ pub fn run() {
server::attachment::write_text_file,
server::attachment::write_binary_file,
server::attachment::delete_attachment,
server::document::fetch_document_preview_resource,
server::document::get_document_preview,
server::transcription::transcription,
server::system_settings::get_system_settings,
extension::built_in::application::get_app_list,

View File

@@ -0,0 +1,97 @@
use crate::common::http::get_response_body_text;
use crate::server::http_client::HttpClient;
use crate::server::servers::{get_server_by_id, get_server_token};
use base64::encode;
use reqwest::Method;
use serde_json::{Value, json};
use std::collections::HashMap;
use tauri::AppHandle;
#[tauri::command]
pub async fn get_document_preview(
_app_handle: AppHandle,
server_id: String,
document_id: String,
) -> Result<Value, String> {
let response = HttpClient::get(&server_id, &format!("/document/{}", document_id), None)
.await
.map_err(|e| format!("Error fetching document: {}", e))?;
let response_body = get_response_body_text(response)
.await
.map_err(|e| format!("Failed to read document response: {}", e))?;
let document: Value = serde_json::from_str(&response_body)
.map_err(|e| format!("Failed to parse document response: {}", e))?;
let source = document.get("_source").cloned().unwrap_or(Value::Null);
let owner_id = source
.pointer("/_system/owner_id")
.and_then(Value::as_str)
.map(str::to_string);
let owner = if let Some(owner_id) = owner_id {
let owner_response = HttpClient::post(
&server_id,
&format!("/entity/card/user/{}", owner_id),
None,
None,
)
.await
.map_err(|e| format!("Error fetching document owner: {}", e))?;
let owner_body = get_response_body_text(owner_response)
.await
.map_err(|e| format!("Failed to read owner response: {}", e))?;
serde_json::from_str::<Value>(&owner_body).unwrap_or(Value::Null)
} else {
Value::Null
};
Ok(json!({
"document": document,
"owner": owner,
}))
}
#[tauri::command]
pub async fn fetch_document_preview_resource(
_app_handle: AppHandle,
server_id: String,
url: String,
) -> Result<Value, String> {
get_server_by_id(&server_id)
.await
.ok_or_else(|| format!("Server not found: {}", server_id))?;
let response = if url.starts_with("http://") || url.starts_with("https://") {
let mut headers = HashMap::new();
if let Some(token) = get_server_token(&server_id).await {
headers.insert("X-API-TOKEN".to_string(), token.access_token);
}
HttpClient::send_raw_request(Method::GET, &url, None, Some(headers), None).await
} else {
let path = HttpClient::join_url("/", &url);
HttpClient::send_request(&server_id, Method::GET, &path, None, None, None).await
}
.map_err(|e| format!("Error fetching document resource: {}", e))?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read document resource: {}", e))?;
Ok(json!({
"contentType": content_type,
"base64": encode(bytes),
}))
}

View File

@@ -4,6 +4,7 @@ pub mod attachment;
pub mod auth;
pub mod connector;
pub mod datasource;
pub mod document;
pub mod http_client;
pub mod profile;
pub mod search;

View File

@@ -7,7 +7,7 @@ import {
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { OpenURLWithBrowser } from "@/utils/index";
import platformAdapter from "@/utils/platformAdapter";
import type { IChunkData } from "@/types/chat";
import RetrieveIcon from "@/icons/Retrieve";
@@ -83,7 +83,7 @@ export const FetchSource = ({
const sourceClick = (item: ISourceData) => () => {
const url = (formatUrl && formatUrl(item)) || item.url;
if (url) {
OpenURLWithBrowser(url);
platformAdapter.openUrl(url);
}
};

View File

@@ -7,7 +7,7 @@ import RehypeKatex from "rehype-katex";
import RemarkGfm from "remark-gfm";
import RehypeHighlight from "rehype-highlight";
import mermaid from "mermaid";
import { useDebouncedCallback } from "use-debounce";
import { useTranslation } from "react-i18next";
import {
copyToClipboard,
@@ -17,26 +17,117 @@ import {
import "./markdown.scss";
import "./highlight.css";
let mermaidDiagramId = 0;
function removeMermaidTempElements(id: string) {
if (typeof document === "undefined") return;
[id, `d${id}`, `i${id}`].forEach((elementId) => {
document.getElementById(elementId)?.remove();
});
}
function getNodeText(node: React.ReactNode): string {
return React.Children.toArray(node)
.map((child) => {
if (typeof child === "string" || typeof child === "number") {
return String(child);
}
if (React.isValidElement(child)) {
return getNodeText(
(child.props as { children?: React.ReactNode }).children
);
}
return "";
})
.join("");
}
function getCodeClassName(node: React.ReactNode): string {
let className = "";
React.Children.forEach(node, (child) => {
if (className || !React.isValidElement(child)) return;
const childProps = child.props as {
className?: string;
children?: React.ReactNode;
};
if (typeof childProps.className === "string") {
className = childProps.className;
return;
}
className = getCodeClassName(childProps.children);
});
return className;
}
// 8
function Mermaid(props: { code: string }) {
function Mermaid(props: { code: string; onError?: () => void }) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [svg, setSvg] = useState("");
const [diagramId] = useState(() => `mermaid-${++mermaidDiagramId}`);
const bindFunctionsRef = useRef<((element: Element) => void) | undefined>();
useEffect(() => {
if (props.code && ref.current) {
mermaid
.run({
nodes: [ref.current],
if (props.code) {
let cancelled = false;
setErrorMessage("");
setSvg("");
bindFunctionsRef.current = undefined;
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
suppressErrorRendering: true,
});
const renderDiagram = async () => {
const canParse = await mermaid.parse(props.code, {
suppressErrors: true,
});
if (!canParse) {
throw new Error("Invalid Mermaid syntax");
}
return mermaid.render(diagramId, props.code, ref.current || undefined);
};
renderDiagram()
.then(({ svg, bindFunctions }) => {
if (cancelled) return;
bindFunctionsRef.current = bindFunctions;
setSvg(svg);
})
.catch((e) => {
setHasError(true);
console.error("[Mermaid] ", e.message);
if (cancelled) return;
const message = e instanceof Error ? e.message : String(e);
removeMermaidTempElements(diagramId);
setErrorMessage(message);
props.onError?.();
console.error("[Mermaid] ", message);
});
return () => {
cancelled = true;
removeMermaidTempElements(diagramId);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.code]);
useEffect(() => {
if (svg && ref.current) {
bindFunctionsRef.current?.(ref.current);
}
}, [svg]);
function viewSvgInNewWindow() {
const svg = ref.current?.querySelector("svg");
if (!svg) return;
@@ -46,8 +137,20 @@ function Mermaid(props: { code: string }) {
// URL.createObjectURL(blob);
}
if (hasError) {
return null;
if (errorMessage) {
return (
<div
className={clsx("no-dark", "mermaid", "mermaid-error")}
title={errorMessage}
>
<div className="mermaid-error-title">
{t("markdown.mermaid.renderFailed")}
</div>
<div className="mermaid-error-message">
{t("markdown.mermaid.renderFailedDescription")}
</div>
</div>
);
}
return (
@@ -59,37 +162,31 @@ function Mermaid(props: { code: string }) {
}}
ref={ref}
onClick={() => viewSvgInNewWindow()}
>
{props.code}
</div>
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
/>
);
}
// 7
function PreCode(props: { children?: any; enableMermaid?: boolean }) {
function PreCode(props: {
children?: any;
enableMermaid?: boolean;
hideMermaidSource?: boolean;
}) {
const ref = useRef<HTMLPreElement>(null);
// const previewRef = useRef<HTMLPreviewHander>(null);
const [mermaidCode, setMermaidCode] = useState("");
// const [htmlCode, setHtmlCode] = useState("");
// const { height } = useWindowSize();
// console.log(htmlCode, height);
const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return;
if (!props.enableMermaid) return;
const mermaidCode = useMemo(() => {
if (!props.enableMermaid) return "";
const mermaidDom = ref.current.querySelector("code.language-mermaid");
if (mermaidDom) {
setMermaidCode((mermaidDom as HTMLElement).innerText);
}
// const htmlDom = ref.current.querySelector("code.language-html");
// const refText = ref.current.querySelector("code")?.innerText;
// if (htmlDom) {
// setHtmlCode((htmlDom as HTMLElement).innerText);
// } else if (refText?.startsWith("<!DOCTYPE")) {
// setHtmlCode(refText);
// }
}, 600);
const className = getCodeClassName(props.children);
if (!/\blanguage-mermaid\b/.test(className)) return "";
return getNodeText(props.children).trim();
}, [props.children, props.enableMermaid]);
// const enableArtifacts = true;
// console.log(enableArtifacts);
@@ -117,25 +214,33 @@ function PreCode(props: { children?: any; enableMermaid?: boolean }) {
codeElement.style.whiteSpace = "pre-wrap";
}
});
setTimeout(renderArtifacts, 1);
}
}, []);
}, [props.children]);
const hideSource = props.hideMermaidSource && mermaidCode.length > 0;
return (
<>
<pre ref={ref}>
<span
className="copy-code-button"
onClick={() => {
if (ref.current) {
copyToClipboard(
ref.current.querySelector("code")?.innerText ?? ""
);
}
}}
></span>
{props.children}
</pre>
{!hideSource && (
<pre
ref={ref}
className={clsx({
"hidden-mermaid-source": hideSource,
})}
>
<span
className="copy-code-button"
onClick={() => {
if (ref.current) {
copyToClipboard(
ref.current.querySelector("code")?.innerText ?? ""
);
}
}}
></span>
{props.children}
</pre>
)}
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
@@ -238,6 +343,7 @@ function _MarkDownContent(props: {
enableSyntaxHighlight?: boolean;
enableMermaid?: boolean;
enableMath?: boolean;
hideMermaidSource?: boolean;
}) {
const enableMath = props.enableMath !== false;
@@ -278,7 +384,11 @@ function _MarkDownContent(props: {
rehypePlugins={rehypePlugins as any}
components={{
pre: (preProps) => (
<PreCode {...preProps} enableMermaid={props.enableMermaid !== false} />
<PreCode
{...preProps}
enableMermaid={props.enableMermaid !== false}
hideMermaidSource={props.hideMermaidSource}
/>
),
code: CustomCode,
p: (pProps) => <p {...pProps} dir="auto" />,
@@ -324,6 +434,7 @@ export default function Markdown(
enableSyntaxHighlight?: boolean;
enableMermaid?: boolean;
enableMath?: boolean;
hideMermaidSource?: boolean;
} & React.DOMAttributes<HTMLDivElement>
) {
const mdRef = useRef<HTMLDivElement>(null);
@@ -346,6 +457,7 @@ export default function Markdown(
enableSyntaxHighlight={props.enableSyntaxHighlight}
enableMermaid={props.enableMermaid}
enableMath={props.enableMath}
hideMermaidSource={props.hideMermaidSource}
/>
</div>
</div>

View File

@@ -42,6 +42,10 @@
--color-accent-emphasis: #0969da;
--color-attention-subtle: #fff8c5;
--color-danger-fg: #cf222e;
--color-mermaid-error-bg: #fffafa;
--color-mermaid-error-border: #f2d0d0;
--color-mermaid-error-title: #7f2f2f;
--color-mermaid-error-message: #999;
}
@mixin dark {
@@ -88,6 +92,10 @@
--color-accent-emphasis: #1f6feb;
--color-attention-subtle: rgba(187, 128, 9, 0.15);
--color-danger-fg: #f85149;
--color-mermaid-error-bg: rgba(127, 47, 47, 0.12);
--color-mermaid-error-border: rgba(248, 81, 73, 0.25);
--color-mermaid-error-title: #ffb4ad;
--color-mermaid-error-message: #a6a6a6;
}
.light {
@@ -1114,6 +1122,48 @@
border-radius: 4px;
padding: 10px;
background-color: var(--white);
max-width: 100%;
max-height: 70vh;
}
.markdown-body .mermaid:empty {
min-height: 180px;
}
.markdown-body .mermaid.mermaid-error {
display: flex;
min-height: 140px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border-color: var(--color-mermaid-error-border);
background: var(--color-mermaid-error-bg);
color: var(--color-mermaid-error-title);
text-align: center;
}
.markdown-body .mermaid-error-title {
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.markdown-body .mermaid-error-message {
max-width: 100%;
color: var(--color-mermaid-error-message);
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.markdown-body pre.hidden-mermaid-source {
display: none;
}
.markdown-body .mermaid svg {
max-width: none;
}
#dmermaid {

View File

@@ -2,6 +2,7 @@ import { useClickAway, useCreation, useReactive } from "ahooks";
import clsx from "clsx";
import { isNil, lowerCase, noop } from "lodash-es";
import {
Compass,
Copy,
Download,
Info,
@@ -22,6 +23,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import VisibleKey from "../Common/VisibleKey";
import platformAdapter from "@/utils/platformAdapter";
import SearchEmpty from "../Common/SearchEmpty";
import { OpenURLWithBrowser } from "@/utils";
interface State {
activeMenuIndex: number;
@@ -112,6 +114,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
const { id, url, category, type, payload, source } = selectedSearchContent;
const { query, result } = payload ?? {};
const formattedUrl = (formatUrl && formatUrl(selectedSearchContent)) || url;
if (category === "AI Overview") {
return [];
@@ -131,6 +134,21 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
);
},
},
{
name: t("search.contextMenu.openInBrowser"),
icon: <Compass />,
keys: isMac ? ["⌘", "B"] : ["Ctrl", "B"],
shortcut: isMac ? "meta.b" : "ctrl.b",
hide:
!platformAdapter.isTauri() ||
category === "Calculator" ||
type === "AI Assistant" ||
id === "Extension Store" ||
!formattedUrl,
clickEvent() {
OpenURLWithBrowser(formattedUrl);
},
},
{
name: t("search.contextMenu.copyLink"),
icon: <Link />,
@@ -141,9 +159,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
type === "AI Assistant" ||
id === "Extension Store",
clickEvent() {
copyToClipboard(
(formatUrl && formatUrl(selectedSearchContent)) || url
);
copyToClipboard(formattedUrl);
},
},
{

View File

@@ -0,0 +1,16 @@
import { createLucideIcon } from "lucide-react";
const AIInsightIcon = createLucideIcon("AIInsightIcon", [
[
"path",
{
d: "M789.759132 0a169.59947 169.59947 0 0 1 169.59947 169.023472l0.895997 342.846928a41.59987 41.59987 0 0 1-83.19974 0.256l-0.831997-342.846929A86.39973 86.39973 0 0 0 789.759132 83.19974l-556.094262 1.279996a86.39973 86.39973 0 0 0-86.39973 86.39973v684.79786c0 47.743851 38.655879 86.39973 86.39973 86.39973l214.20733-1.279996A41.59987 41.59987 0 1 1 448.0002 1023.9968H233.66487a168.511473 168.511473 0 0 1-169.59947-168.319474l0.064-684.79786A169.59947 169.59947 0 0 1 233.72887 1.279996L789.823132 0z m-28.79991 623.614051l36.799885 105.151672a47.423852 47.423852 0 0 0 31.551901 31.615901l105.215672 36.799885c10.495967 5.247984 10.495967 15.807951 0 21.055934l-105.215672 36.799885a47.423852 47.423852 0 0 0-31.551901 31.551901l-36.863885 105.215672c-5.247984 10.495967-15.743951 10.495967-20.991934 0l-36.863885-105.215672a47.423852 47.423852 0 0 0-31.551901-31.551901l-105.215672-36.799885c-10.495967-5.247984-10.495967-15.807951 0-21.055934l105.215672-36.799885a47.423852 47.423852 0 0 0 31.551901-31.615901l36.863885-105.151672c5.247984-10.559967 15.743951-10.559967 20.991934 0zM534.39993 598.39813a41.59987 41.59987 0 1 1 0 83.19974h-172.79946a41.59987 41.59987 0 1 1 0-83.19974h172.79946z m127.9996-191.9994a41.59987 41.59987 0 1 1 0 83.19974h-300.79906a41.59987 41.59987 0 1 1 0-83.19974h300.79906z m0-191.9994a41.59987 41.59987 0 1 1 0 83.19974h-300.79906a41.59987 41.59987 0 1 1 0-83.19974h300.79906z",
transform: "scale(0.0234375)",
fill: "currentColor",
stroke: "none",
key: "main",
},
],
]);
export default AIInsightIcon;

36
src/icons/PreviewIcon.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { createLucideIcon } from "lucide-react";
const PreviewIcon = createLucideIcon("PreviewIcon", [
[
"path",
{
d: "M938.666667 938.666667c-12.8 0-21.333333-4.266667-29.866667-12.8l-136.533333-136.533334c-17.066667-17.066667-17.066667-42.666667 0-59.733333s42.666667-17.066667 59.733333 0l136.533333 136.533333c17.066667 17.066667 17.066667 42.666667 0 59.733334-8.533333 8.533333-17.066667 12.8-29.866666 12.8z",
transform: "scale(0.0234375)",
fill: "currentColor",
stroke: "none",
key: "search-handle",
},
],
[
"path",
{
d: "M644.266667 866.133333c-68.266667 0-132.266667-25.6-183.466667-76.8-102.4-102.4-102.4-268.8 0-371.2 102.4-102.4 268.8-102.4 371.2 0 102.4 102.4 102.4 268.8 0 371.2-51.2 51.2-119.466667 76.8-187.733333 76.8z m0-439.466666c-46.933333 0-89.6 17.066667-123.733334 51.2-68.266667 68.266667-68.266667 179.2 0 251.733333 68.266667 68.266667 179.2 68.266667 251.733334 0 68.266667-68.266667 68.266667-179.2 0-251.733333-34.133333-34.133333-81.066667-51.2-128-51.2z",
transform: "scale(0.0234375)",
fill: "currentColor",
stroke: "none",
key: "search-circle",
},
],
[
"path",
{
d: "M499.2 981.333333H149.333333C89.6 981.333333 42.666667 934.4 42.666667 874.666667v-725.333334C42.666667 89.6 89.6 42.666667 149.333333 42.666667h640C849.066667 42.666667 896 89.6 896 149.333333v145.066667c0 25.6-17.066667 42.666667-42.666667 42.666667s-42.666667-17.066667-42.666666-42.666667V149.333333c0-12.8-8.533333-21.333333-21.333334-21.333333h-640c-12.8 0-21.333333 8.533333-21.333333 21.333333v725.333334c0 12.8 8.533333 21.333333 21.333333 21.333333h349.866667c25.6 0 42.666667 17.066667 42.666667 42.666667s-21.333333 42.666667-42.666667 42.666666z",
transform: "scale(0.0234375)",
fill: "currentColor",
stroke: "none",
key: "document",
},
],
]);
export default PreviewIcon;

View File

@@ -56,6 +56,12 @@
"cancel": "No"
}
},
"markdown": {
"mermaid": {
"renderFailed": "Mind map can't be displayed",
"renderFailedDescription": "The content format looks invalid, so the preview can't be generated yet"
}
},
"labels": {
"openSource": "Open Source",
"cancelled": "Generation Cancelled",
@@ -497,6 +503,7 @@
},
"contextMenu": {
"open": "Open",
"openInBrowser": "Open in Browser",
"copyLink": "Copy Link",
"copyAnswer": "Copy Answer",
"copyUppercaseAnswer": "Copy Answer (in Word)",
@@ -750,6 +757,29 @@
"loading": "Loading...",
"notFound": "Not found"
},
"documentPreview": {
"labels": {
"preview": "Preview",
"aiInterpretation": "AI Interpretation"
},
"buttons": {
"openSource": "Open Source",
"reload": "Reload"
},
"hints": {
"previewUnavailable": "Preview is not available for this document type.",
"previewUnavailableTitle": "This file can't be previewed",
"previewUnavailableDescription": "The file format may be unsupported or the content is temporarily unavailable"
},
"loading": {
"document": "Loading document...",
"preview": "Loading preview..."
},
"errors": {
"missingParams": "Missing document preview parameters.",
"loadFailed": "Failed to load document"
}
},
"selection": {
"title": "Selection Toolbar Settings",
"actions": {

View File

@@ -56,6 +56,12 @@
"cancel": "否"
}
},
"markdown": {
"mermaid": {
"renderFailed": "思维导图无法显示",
"renderFailedDescription": "内容格式异常,暂时无法生成预览"
}
},
"labels": {
"openSource": "打开来源",
"cancelled": "生成已取消",
@@ -497,6 +503,7 @@
},
"contextMenu": {
"open": "打开",
"openInBrowser": "打开至浏览器",
"copyLink": "复制链接",
"copyAnswer": "复制答案",
"copyUppercaseAnswer": "复制答案(大写)",
@@ -749,6 +756,29 @@
"loading": "加载中...",
"notFound": "未找到"
},
"documentPreview": {
"labels": {
"preview": "预览",
"aiInterpretation": "AI 解读"
},
"buttons": {
"openSource": "打开来源",
"reload": "重新加载"
},
"hints": {
"previewUnavailable": "当前文档类型暂不支持预览。",
"previewUnavailableTitle": "此文件无法预览",
"previewUnavailableDescription": "文件格式可能不受支持,或内容暂时不可用"
},
"loading": {
"document": "文档加载中...",
"preview": "预览加载中..."
},
"errors": {
"missingParams": "缺少文档预览参数。",
"loadFailed": "文档加载失败"
}
},
"selection": {
"title": "划词工具栏设置",
"actions": {

View File

@@ -0,0 +1,678 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronDown,
Compass,
FileText,
RefreshCw,
} from "lucide-react";
import clsx from "clsx";
import { renderAsync } from "docx-preview";
import { init as initPptxPreview } from "pptx-preview";
import loadFailedSvg from "@/components/ChatMessage/DeepResearch/load-failed.svg";
import Markdown from "@/components/ChatMessage/Markdown";
import CommonIcon from "@/components/Common/Icons/CommonIcon";
import Scrollbar from "@/components/Common/Scrollbar";
import { Button } from "@/components/ui/button";
import AIInsightIcon from "@/icons/AIInsightIcon";
import PreviewIcon from "@/icons/PreviewIcon";
import platformAdapter from "@/utils/platformAdapter";
import { formatter, OpenURLWithBrowser } from "@/utils";
import { formatDateToLocal } from "@/utils/date";
type PreviewResponse = {
document?: {
_id?: string;
_source?: any;
};
owner?: any;
};
type PreviewResourceResponse = {
contentType?: string;
base64?: string;
};
type PreviewContentType =
| "image"
| "video"
| "markdown"
| "pdf"
| "docx"
| "pptx"
| "html";
const previewContentTypes: PreviewContentType[] = [
"image",
"video",
"markdown",
"pdf",
"docx",
"pptx",
"html",
];
const normalizePreviewContentType = (
contentType?: string,
mimeType?: string
): PreviewContentType | "" => {
const value = String(contentType || mimeType || "")
.toLowerCase()
.split(";")[0]
.trim();
if (!value) return "";
if (value === "image" || value.startsWith("image/")) return "image";
if (value === "video" || value.startsWith("video/")) return "video";
if (["markdown", "md", "text/markdown"].includes(value)) return "markdown";
if (["pdf", "application/pdf"].includes(value)) return "pdf";
if (
[
"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
].includes(value)
) {
return "docx";
}
if (
[
"pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
].includes(value)
) {
return "pptx";
}
if (["html", "htm", "html_doc", "text/html"].includes(value)) return "html";
return "";
};
const getParams = () => {
const params = new URLSearchParams(window.location.search);
return {
serverId: params.get("serverId") || "",
documentId: params.get("documentId") || "",
sourceUrl: params.get("sourceUrl") || "",
};
};
const resolveRawContentUrl = (rawContent?: string) => {
if (!rawContent) return "";
if (/^https?:\/\//i.test(rawContent)) return rawContent;
const endpoint = JSON.parse(localStorage.getItem("app-store") || "{}")?.state
?.endpoint_http;
if (!endpoint) return rawContent;
return `${String(endpoint).replace(/\/$/, "")}/${rawContent.replace(/^\//, "")}`;
};
const base64ToBlob = (base64: string, contentType: string) => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: contentType });
};
const injectHtmlPreviewStyles = (html: string, baseUrl?: string) => {
const baseTag = baseUrl ? `<base href="${baseUrl}">` : "";
const styles = `
<style>
* {
scrollbar-width: thin;
scrollbar-color: rgba(144, 147, 153, 0.3) transparent !important;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.3) !important;
border-radius: 7px !important;
}
*::-webkit-scrollbar {
width: 7px !important;
height: 7px !important;
}
*::-webkit-scrollbar-track-piece {
background-color: transparent !important;
}
body {
background: #fff !important;
margin: 0 !important;
padding: 0 24px 24px !important;
}
</style>
`;
if (/<head[^>]*>/i.test(html)) {
return html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}${styles}`);
}
return `${baseTag}${styles}${html}`;
};
const HtmlPreview = ({ html, baseUrl }: { html: string; baseUrl?: string }) => {
return (
<iframe
title="HTML preview"
srcDoc={injectHtmlPreviewStyles(html, baseUrl)}
className="h-[68vh] w-full border-0"
sandbox="allow-same-origin allow-scripts"
/>
);
};
const OfficePreview = ({
type,
blob,
}: {
type: "docx" | "pptx";
blob?: Blob;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container || !blob) return;
let cancelled = false;
const renderOffice = async () => {
container.innerHTML = "";
const buffer = await blob.arrayBuffer();
if (cancelled || buffer.byteLength === 0) return;
if (type === "docx") {
await renderAsync(buffer, container, undefined, {
inWrapper: false,
ignoreWidth: true,
ignoreHeight: true,
});
return;
}
const width = container.clientWidth || 840;
const height = Math.round(width * (9 / 16));
const pptx = initPptxPreview(container, { width, height });
await pptx.preview(buffer);
};
renderOffice().catch((error) => {
container.innerHTML = "";
const message = document.createElement("div");
message.style.color = "#999";
message.style.textAlign = "center";
message.style.padding = "48px";
message.textContent = String(error);
container.appendChild(message);
});
return () => {
cancelled = true;
};
}, [blob, type]);
return (
<div
ref={containerRef}
className="min-h-[360px] w-full overflow-hidden [&_.docx]:p-0!"
/>
);
};
function DocumentPreviewPage() {
const { t } = useTranslation();
const { serverId, documentId, sourceUrl } = useMemo(getParams, []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [data, setData] = useState<any>();
const [owner, setOwner] = useState<any>();
const [resourceLoading, setResourceLoading] = useState(false);
const [resourceError, setResourceError] = useState("");
const [resourceUrl, setResourceUrl] = useState("");
const [resourceText, setResourceText] = useState("");
const [resourceBlob, setResourceBlob] = useState<Blob>();
const [expandedMore, setExpandedMore] = useState(false);
const loadData = useCallback(async () => {
if (!serverId || !documentId) {
setError(t("documentPreview.errors.missingParams"));
setLoading(false);
return;
}
setLoading(true);
setError("");
try {
const result = await platformAdapter.invokeBackend<PreviewResponse>(
"get_document_preview",
{
serverId,
documentId,
}
);
setData(result?.document?._source);
setOwner(result?.owner);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
}, [documentId, serverId, t]);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
document.title = data?.title || "Document Preview";
}, [data?.title]);
const rawContent = data?.metadata?.raw_content || sourceUrl;
const rawContentUrl = resolveRawContentUrl(rawContent);
const contentType = data?.metadata?.content_type;
const previewType = normalizePreviewContentType(
contentType,
data?.metadata?.mime_type
);
const hasPreview =
Boolean(rawContentUrl) &&
Boolean(previewType) &&
previewContentTypes.includes(previewType as PreviewContentType);
const ownerName =
owner?.title || owner?.name || owner?.username || data?.owner?.username;
const createdAt = data?.created ? formatDateToLocal(data.created) : "";
const updatedAt = data?.updated ? formatDateToLocal(data.updated) : "";
const hasInlinePreview = previewType === "image" || previewType === "video";
const hasCollapsiblePreview =
hasPreview && ["markdown", "pdf", "docx", "pptx", "html"].includes(previewType);
useEffect(() => {
let objectUrl = "";
let cancelled = false;
const loadResource = async () => {
setResourceUrl("");
setResourceText("");
setResourceBlob(undefined);
setResourceError("");
if (!hasPreview || !rawContent) return;
setResourceLoading(true);
try {
const result =
await platformAdapter.invokeBackend<PreviewResourceResponse>(
"fetch_document_preview_resource",
{
serverId,
url: rawContent,
}
);
if (cancelled) return;
const base64 = result?.base64;
if (!base64) {
throw new Error("Document resource is empty.");
}
const blob = base64ToBlob(
base64,
result?.contentType || "application/octet-stream"
);
if (previewType === "markdown" || previewType === "html") {
setResourceText(await blob.text());
} else if (previewType === "docx" || previewType === "pptx") {
setResourceBlob(blob);
} else {
objectUrl = URL.createObjectURL(blob);
setResourceUrl(objectUrl);
}
} catch (error) {
if (!cancelled) {
setResourceError(error instanceof Error ? error.message : String(error));
}
} finally {
if (!cancelled) {
setResourceLoading(false);
}
}
};
loadResource();
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [hasPreview, previewType, rawContent, serverId]);
const renderPreviewUnavailable = () => {
return (
<div className="flex min-h-[360px] flex-col items-center justify-center px-6 py-16 text-center">
<img
src={loadFailedSvg}
alt=""
className="h-[80px] w-[80px] select-none"
/>
<div className="mt-3 text-sm font-medium leading-5 text-[#999] dark:text-[#A3A3A3]">
{t("documentPreview.hints.previewUnavailableTitle")}
</div>
<div className="mt-1 text-sm leading-5 text-[#999] dark:text-[#A3A3A3]">
{t("documentPreview.hints.previewUnavailableDescription")}
</div>
{rawContentUrl && (
<button
className="mt-5 inline-flex h-9 items-center gap-1.5 rounded-full bg-[#0A84FF] px-4 text-sm font-medium text-white transition hover:bg-[#0072E5]"
onClick={() => OpenURLWithBrowser(rawContentUrl)}
>
<Compass className="size-4" />
{t("documentPreview.buttons.openSource")}
</button>
)}
</div>
);
};
const renderPreview = () => {
if (!hasPreview) {
return renderPreviewUnavailable();
}
if (resourceLoading) {
return (
<div className="flex min-h-[260px] items-center justify-center rounded-md border border-[#E6E6E6] text-sm text-[#777] dark:border-[#2A2A2A] dark:text-[#AAA]">
<RefreshCw className="mr-2 size-4 animate-spin" />
{t("documentPreview.loading.preview")}
</div>
);
}
if (resourceError) {
return renderPreviewUnavailable();
}
const resourceReady =
previewType === "markdown" || previewType === "html"
? Boolean(resourceText)
: previewType === "docx" || previewType === "pptx"
? Boolean(resourceBlob)
: Boolean(resourceUrl);
if (!resourceReady) {
return (
<div className="flex min-h-[260px] items-center justify-center rounded-md border border-[#E6E6E6] text-sm text-[#777] dark:border-[#2A2A2A] dark:text-[#AAA]">
<RefreshCw className="mr-2 size-4 animate-spin" />
{t("documentPreview.loading.preview")}
</div>
);
}
if (previewType === "image") {
return (
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg bg-black/3 p-4 dark:bg-white/5">
<img
src={resourceUrl}
alt={data?.title || "Document image"}
className="max-h-[65vh] max-w-full object-contain"
/>
</div>
);
}
if (previewType === "video") {
return (
<video
controls
src={resourceUrl}
className="max-h-[65vh] w-full rounded-lg bg-black"
/>
);
}
if (previewType === "pdf") {
return (
<iframe
title={data?.title || "PDF preview"}
src={resourceUrl}
className="h-[68vh] w-full border-0"
/>
);
}
if (previewType === "markdown") {
return (
<Markdown
content={resourceText || String(data?.content || data?.summary || "")}
enableMermaid
hideMermaidSource
enableMath={false}
/>
);
}
if (previewType === "html") {
return <HtmlPreview html={resourceText} baseUrl={rawContentUrl} />;
}
if (previewType === "docx" || previewType === "pptx") {
return <OfficePreview type={previewType} blob={resourceBlob} />;
}
return null;
};
const renderPanel = (
key: string,
title: string,
icon: React.ReactNode,
children: React.ReactNode,
defaultOpen = true
) => {
return (
<details
key={key}
open={defaultOpen}
className="group rounded-lg border border-[#E8E8E8] bg-white dark:border-[#303030] dark:bg-[#111]"
>
<summary className="flex cursor-pointer list-none items-center justify-between px-4 py-2.5">
<div className="flex items-center gap-2 text-[14px] leading-5 text-[#333] dark:text-[#DDD]">
<span className="text-[#0A84FF]">{icon}</span>
{title}
</div>
<ChevronDown className="size-4 text-[#999] transition group-open:rotate-180" />
</summary>
<div className="border-t border-[#F0F0F0] px-6 py-8 dark:border-[#303030]">
{children}
</div>
</details>
);
};
if (loading) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-white text-[#777] dark:bg-[#111] dark:text-[#AAA]">
<RefreshCw className="mr-2 size-4 animate-spin" />
{t("documentPreview.loading.document")}
</div>
);
}
if (error) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-white p-8 dark:bg-[#111]">
<div className="flex max-w-lg flex-col items-center gap-4 text-center">
<AlertCircle className="size-10 text-[#EF4444]" />
<div className="text-lg font-medium text-[#222] dark:text-[#EEE]">
{t("documentPreview.errors.loadFailed")}
</div>
<div className="text-sm text-[#777] dark:text-[#AAA]">{error}</div>
<button
className="rounded-md bg-[#0A84FF] px-4 py-2 text-sm text-white hover:bg-[#0072E5]"
onClick={loadData}
>
{t("documentPreview.buttons.reload")}
</button>
</div>
</div>
);
}
return (
<div className="h-screen w-screen bg-white text-[#222] dark:bg-[#0F0F0F] dark:text-[#EEE]">
<Scrollbar className="h-full overflow-y-auto">
<main className="mx-auto flex min-h-full w-full max-w-[960px] flex-col px-4 py-8">
<section>
<div className="flex items-start justify-between gap-6">
<div className="min-w-0 flex-1">
<h1 className="flex items-center gap-2 break-words text-[18px] leading-[26px] text-[#1A0CAB] dark:text-[#8AB4F8]">
<CommonIcon
renderOrder={["item_icon", "connector_icon", "default_icon"]}
item={data}
itemIcon={data?.icon}
defaultIcon={FileText}
className="size-5 shrink-0"
/>
{data?.title || documentId}
</h1>
<div className="mt-4 flex flex-wrap items-center gap-1 text-sm text-[#666] dark:text-[#AAA]">
<span>{data?.source?.name || "-"}</span>
{data?.category && (
<>
<span className="text-[#AAA]"></span>
<span>{data.category}</span>
</>
)}
{ownerName && (
<>
<span className="mx-1 text-[#AAA]">|</span>
<span>{ownerName}</span>
</>
)}
{updatedAt && (
<>
<span className="mx-1 text-[#AAA]"></span>
<span>{updatedAt}</span>
</>
)}
<button
className={clsx(
"ml-1 inline-flex size-5 items-center justify-center rounded text-[#888] transition hover:text-[#0A84FF]",
{ "rotate-180": expandedMore }
)}
onClick={() => setExpandedMore((value) => !value)}
aria-label="Toggle details"
>
<ChevronDown className="size-4" />
</button>
</div>
</div>
{rawContentUrl && (
<Button
variant="outline"
size="sm"
className="mt-8 shrink-0"
onClick={() => OpenURLWithBrowser(rawContentUrl)}
>
{t("documentPreview.buttons.openSource")}
</Button>
)}
</div>
{expandedMore && (
<div className="mt-4 grid grid-cols-2 gap-x-8 gap-y-3 rounded-lg bg-black/3 p-4 text-sm dark:bg-white/5 max-sm:grid-cols-1">
<div>
<span className="mr-3 text-[#999]">
{t("search.document.type")}
</span>
{data?.type || contentType || "-"}
</div>
<div>
<span className="mr-3 text-[#999]">
{t("search.document.size")}
</span>
{formatter.bytes(data?.size || 0)}
</div>
<div>
<span className="mr-3 text-[#999]">
{t("search.document.createdBy")}
</span>
{ownerName || "-"}
</div>
<div>
<span className="mr-3 text-[#999]">
{t("search.document.createdAt")}
</span>
{createdAt || "-"}
</div>
<div>
<span className="mr-3 text-[#999]">
{t("search.document.updatedAt")}
</span>
{updatedAt || "-"}
</div>
</div>
)}
</section>
<section className="mt-5 flex flex-col gap-4">
{hasInlinePreview &&
renderPanel(
"preview",
t("documentPreview.labels.preview"),
<PreviewIcon className="size-4" />,
renderPreview()
)}
{hasCollapsiblePreview &&
renderPanel(
"preview",
t("documentPreview.labels.preview"),
<PreviewIcon className="size-4" />,
renderPreview(),
!data?.ai_insights?.text
)}
{data?.ai_insights?.text &&
renderPanel(
"ai",
t("documentPreview.labels.aiInterpretation"),
<AIInsightIcon className="size-4" />,
<div className="px-2">
<Markdown
content={data.ai_insights.text}
enableMermaid
hideMermaidSource
enableMath={false}
/>
</div>
)}
{!hasInlinePreview &&
!hasCollapsiblePreview &&
!data?.ai_insights?.text &&
renderPanel(
"empty",
t("documentPreview.labels.preview"),
<PreviewIcon className="size-4" />,
renderPreview()
)}
</section>
</main>
</Scrollbar>
</div>
);
}
export default DocumentPreviewPage;

View File

@@ -25,6 +25,12 @@ function MainApp() {
setViewExtensionOpened(payload);
});
platformAdapter.listenEvent("open_url_window", async ({ payload }) => {
if (typeof payload === "string" && payload) {
await platformAdapter.openUrl(payload);
}
});
}, []);
const { synthesizeItem } = useChatStore();

View File

@@ -0,0 +1,65 @@
import { useEffect, useMemo, useState } from "react";
import { ExternalLink } from "lucide-react";
import platformAdapter from "@/utils/platformAdapter";
const getInitialUrl = () => {
const params = new URLSearchParams(window.location.search);
return params.get("url") || "";
};
const getUrlTitle = (url: string) => {
try {
return new URL(url).hostname || "Coco";
} catch {
return "Coco";
}
};
function WebviewPage() {
const url = useMemo(() => getInitialUrl(), []);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
document.title = getUrlTitle(url);
}, [url]);
if (!url) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-white text-sm text-[#666] dark:bg-[#111] dark:text-[#bbb]">
URL unavailable
</div>
);
}
return (
<div className="relative h-screen w-screen overflow-hidden bg-white dark:bg-[#111]">
{!loaded && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-[#777] dark:text-[#aaa]">
Loading...
</div>
)}
<button
aria-label="Open in browser"
className="absolute right-3 top-3 z-10 flex size-9 items-center justify-center rounded-md bg-black/45 text-white shadow-sm transition hover:bg-black/65"
onClick={() => {
platformAdapter.openUrlWithBrowser(url);
}}
>
<ExternalLink className="size-4" />
</button>
<iframe
title={getUrlTitle(url)}
src={url}
className="h-full w-full border-0"
allow="fullscreen; clipboard-read; clipboard-write; geolocation; microphone; camera"
allowFullScreen
onLoad={() => setLoaded(true)}
/>
</div>
);
}
export default WebviewPage;

View File

@@ -9,6 +9,8 @@ const SettingsPage = lazy(() => import("@/pages/settings/index"));
const StandaloneChat = lazy(() => import("@/pages/chat/index"));
const CheckPage = lazy(() => import("@/pages/check/index"));
const SelectionWindow = lazy(() => import("@/pages/selection/index"));
const WebviewPage = lazy(() => import("@/pages/webview/index"));
const DocumentPreviewPage = lazy(() => import("@/pages/document-preview/index"));
const routerOptions = {
basename: "/",
@@ -30,6 +32,8 @@ export const router = createBrowserRouter(
{ path: "/ui/chat", element: (<Suspense fallback={<></>}><StandaloneChat /></Suspense>) },
{ path: "/ui/check", element: (<Suspense fallback={<></>}><CheckPage /></Suspense>) },
{ path: "/ui/selection", element: (<Suspense fallback={<></>}><SelectionWindow /></Suspense>) },
{ path: "/ui/webview", element: (<Suspense fallback={<></>}><WebviewPage /></Suspense>) },
{ path: "/ui/document-preview", element: (<Suspense fallback={<></>}><DocumentPreviewPage /></Suspense>) },
],
},
],

View File

@@ -54,6 +54,7 @@ export interface EventPayloads {
oauth_success: any;
extension_install_success: any;
open_view_extension: ViewExtensionOpened;
open_url_window: string;
"server-list-changed": Server[];
"selection-text": string;
"selection-ask-ai": any;
@@ -124,6 +125,7 @@ export interface SystemOperations {
relaunchApp: () => Promise<void>;
isTauri: () => boolean;
openUrl: (url: string) => Promise<unknown>;
openUrlWithBrowser: (url: string) => Promise<unknown>;
commands: <T>(commandName: string, ...args: any[]) => Promise<T>;
isWindows10: () => Promise<boolean>;
revealItemInDir: (path: string) => Promise<unknown>;

View File

@@ -87,7 +87,7 @@ export const OpenURLWithBrowser = async (url?: string) => {
if (!url) return;
if (IsTauri()) {
try {
await platformAdapter.openUrl(url);
await platformAdapter.openUrlWithBrowser(url);
await platformAdapter.commands("hide_coco");
console.log("URL opened in default browser");
} catch (error) {

View File

@@ -13,7 +13,7 @@ import {
import type { BasePlatformAdapter } from "@/types/platform";
import type { AppTheme } from "@/types/index";
import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from ".";
import { copyToClipboard, dispatchEvent } from ".";
import { useAppStore } from "@/stores/appStore";
import { unrequitable } from "@/utils";
import {
@@ -58,6 +58,102 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
// Create Tauri adapter functions
export const createTauriAdapter = (): TauriPlatformAdapter => {
const getWebviewWindowTitle = (url: string) => {
try {
return new URL(url).hostname || "Coco";
} catch {
return "Coco";
}
};
const openUrlInWebviewWindow = async (url: string) => {
const documentPreview = getDocumentPreviewRoute(url);
const label = `webview-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const route = documentPreview || `/ui/webview?url=${encodeURIComponent(url)}`;
return new WebviewWindow(label, {
title: getWebviewWindowTitle(url),
url: route,
width: 1100,
height: 760,
minWidth: 640,
minHeight: 420,
center: true,
resizable: true,
maximizable: true,
minimizable: true,
closable: true,
decorations: true,
transparent: false,
shadow: true,
dragDropEnabled: false,
});
};
const getDocumentPreviewRoute = (
url: string,
options?: { serverId?: string; sourceUrl?: string }
) => {
try {
const parsed = new URL(url);
const hashPath = parsed.hash.replace(/^#/, "");
const routePath = `${parsed.pathname}${hashPath}`;
const match = routePath.match(/\/preview\/document\/([^/?#]+)/);
const documentId = match?.[1];
const serverId = options?.serverId;
if (!documentId || !serverId) return "";
const params = new URLSearchParams({
serverId,
documentId,
});
if (options?.sourceUrl) {
params.set("sourceUrl", options.sourceUrl);
}
return `/ui/document-preview?${params.toString()}`;
} catch {
return "";
}
};
const openSearchUrl = async (url: string, data?: any) => {
const documentPreviewRoute = getDocumentPreviewRoute(url, {
serverId: data?.querySource?.id,
sourceUrl: data?.metadata?.raw_content || data?.url,
});
if (documentPreviewRoute) {
const label = `document-preview-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
return new WebviewWindow(label, {
title: data?.title || getWebviewWindowTitle(url),
url: documentPreviewRoute,
width: 1100,
height: 760,
minWidth: 720,
minHeight: 480,
center: true,
resizable: true,
maximizable: true,
minimizable: true,
closable: true,
decorations: true,
transparent: false,
shadow: true,
dragDropEnabled: false,
});
}
return openUrlInWebviewWindow(url);
};
return {
async setWindowSize(width, height) {
return windowWrapper.setLogicalSize(width, height);
@@ -310,9 +406,13 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
},
async openUrl(url) {
return openUrlInWebviewWindow(url);
},
async openUrlWithBrowser(url) {
const { openUrl } = await import("@tauri-apps/plugin-opener");
openUrl(url);
return openUrl(url);
},
isWindows10,
@@ -332,7 +432,7 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
revealItemInDir(path);
},
async openSearchItem(data) {
async openSearchItem(data, formatUrl) {
const { invoke } = await import("@tauri-apps/api/core");
console.log("openSearchItem", data);
@@ -354,6 +454,14 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
return invoke("hide_coco");
};
const url = (formatUrl && formatUrl(data)) || data?.url;
if (url?.startsWith("http://") || url?.startsWith("https://")) {
await openSearchUrl(url, data);
return hideCoco();
}
if (data?.on_opened) {
await invoke("open", {
onOpened: data.on_opened,
@@ -363,12 +471,6 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
return hideCoco();
}
if (data?.url) {
OpenURLWithBrowser(data.url);
return hideCoco();
}
if (data?.payload?.result?.value) {
return copyToClipboard(data.payload.result.value);
}

View File

@@ -251,6 +251,13 @@ export const createWebAdapter = (): WebPlatformAdapter => {
window.open(urlWithIntegrationId, "_blank");
},
async openUrlWithBrowser(url) {
const urlWithIntegrationId = appendAppIntegrationIdToUrl(url);
console.log(`Web mode opening URL in browser: ${urlWithIntegrationId}`);
window.open(urlWithIntegrationId, "_blank");
},
isWindows10: async () => false,
async setShadow(enable) {