diff --git a/package.json b/package.json index 5568c539..252f69ee 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccdd3414..6d774764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src-tauri/src/common/document.rs b/src-tauri/src/common/document.rs index c8916910..8b170836 100644 --- a/src-tauri/src/common/document.rs +++ b/src-tauri/src/common/document.rs @@ -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, @@ -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 } => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6333d513..7fdcec8e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/server/document.rs b/src-tauri/src/server/document.rs new file mode 100644 index 00000000..8d0ac6e2 --- /dev/null +++ b/src-tauri/src/server/document.rs @@ -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 { + 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::(&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 { + 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), + })) +} diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index 6048bff3..0bf6478c 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -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; diff --git a/src/components/ChatMessage/FetchSource.tsx b/src/components/ChatMessage/FetchSource.tsx index bc453b4e..3d217637 100644 --- a/src/components/ChatMessage/FetchSource.tsx +++ b/src/components/ChatMessage/FetchSource.tsx @@ -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); } }; diff --git a/src/components/ChatMessage/Markdown.tsx b/src/components/ChatMessage/Markdown.tsx index b8f57560..fbcfc09b 100644 --- a/src/components/ChatMessage/Markdown.tsx +++ b/src/components/ChatMessage/Markdown.tsx @@ -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(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 ( +
+
+ {t("markdown.mermaid.renderFailed")} +
+
+ {t("markdown.mermaid.renderFailedDescription")} +
+
+ ); } return ( @@ -59,37 +162,31 @@ function Mermaid(props: { code: string }) { }} ref={ref} onClick={() => viewSvgInNewWindow()} - > - {props.code} - + 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(null); // const previewRef = useRef(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(" 0; return ( <> -
-         {
-            if (ref.current) {
-              copyToClipboard(
-                ref.current.querySelector("code")?.innerText ?? ""
-              );
-            }
-          }}
-        >
-        {props.children}
-      
+ {!hideSource && ( +
+           {
+              if (ref.current) {
+                copyToClipboard(
+                  ref.current.querySelector("code")?.innerText ?? ""
+                );
+              }
+            }}
+          >
+          {props.children}
+        
+ )} {mermaidCode.length > 0 && ( )} @@ -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) => ( - + ), code: CustomCode, p: (pProps) =>

, @@ -324,6 +434,7 @@ export default function Markdown( enableSyntaxHighlight?: boolean; enableMermaid?: boolean; enableMath?: boolean; + hideMermaidSource?: boolean; } & React.DOMAttributes ) { const mdRef = useRef(null); @@ -346,6 +457,7 @@ export default function Markdown( enableSyntaxHighlight={props.enableSyntaxHighlight} enableMermaid={props.enableMermaid} enableMath={props.enableMath} + hideMermaidSource={props.hideMermaidSource} /> diff --git a/src/components/ChatMessage/markdown.scss b/src/components/ChatMessage/markdown.scss index 404cae9e..6c51c448 100644 --- a/src/components/ChatMessage/markdown.scss +++ b/src/components/ChatMessage/markdown.scss @@ -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 { diff --git a/src/components/Search/ContextMenu.tsx b/src/components/Search/ContextMenu.tsx index faac97b2..4d6af67e 100644 --- a/src/components/Search/ContextMenu.tsx +++ b/src/components/Search/ContextMenu.tsx @@ -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: , + 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: , @@ -141,9 +159,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => { type === "AI Assistant" || id === "Extension Store", clickEvent() { - copyToClipboard( - (formatUrl && formatUrl(selectedSearchContent)) || url - ); + copyToClipboard(formattedUrl); }, }, { diff --git a/src/icons/AIInsightIcon.tsx b/src/icons/AIInsightIcon.tsx new file mode 100644 index 00000000..e43689ce --- /dev/null +++ b/src/icons/AIInsightIcon.tsx @@ -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; diff --git a/src/icons/PreviewIcon.tsx b/src/icons/PreviewIcon.tsx new file mode 100644 index 00000000..7083fd7b --- /dev/null +++ b/src/icons/PreviewIcon.tsx @@ -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; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 1e88c25a..aeda0a61 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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": { diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 3f46c27a..4bfd19a1 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -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": { diff --git a/src/pages/document-preview/index.tsx b/src/pages/document-preview/index.tsx new file mode 100644 index 00000000..de389acc --- /dev/null +++ b/src/pages/document-preview/index.tsx @@ -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 ? `` : ""; + const styles = ` + + `; + + if (/]*>/i.test(html)) { + return html.replace(/]*)>/i, `${baseTag}${styles}`); + } + + return `${baseTag}${styles}${html}`; +}; + +const HtmlPreview = ({ html, baseUrl }: { html: string; baseUrl?: string }) => { + return ( +