feat: add in-app document preview webview (#1077)

* feat: add in-app document preview webview

* feat: improve mermaid viewer
This commit is contained in:
ayangweb
2026-08-06 15:18:32 +08:00
committed by GitHub
parent ca12f20df7
commit df7fb5323a
23 changed files with 2153 additions and 89 deletions

View File

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

128
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -211,6 +211,8 @@ pub fn run() {
server::attachment::write_text_file, server::attachment::write_text_file,
server::attachment::write_binary_file, server::attachment::write_binary_file,
server::attachment::delete_attachment, server::attachment::delete_attachment,
server::document::fetch_document_preview_resource,
server::document::get_document_preview,
server::transcription::transcription, server::transcription::transcription,
server::system_settings::get_system_settings, server::system_settings::get_system_settings,
extension::built_in::application::get_app_list, 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 auth;
pub mod connector; pub mod connector;
pub mod datasource; pub mod datasource;
pub mod document;
pub mod http_client; pub mod http_client;
pub mod profile; pub mod profile;
pub mod search; pub mod search;

View File

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

View File

@@ -7,8 +7,10 @@ import RehypeKatex from "rehype-katex";
import RemarkGfm from "remark-gfm"; import RemarkGfm from "remark-gfm";
import RehypeHighlight from "rehype-highlight"; import RehypeHighlight from "rehype-highlight";
import mermaid from "mermaid"; import mermaid from "mermaid";
import { useDebouncedCallback } from "use-debounce"; import { useTranslation } from "react-i18next";
import { Copy, Download, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
import Tooltip from "@/components/Common/Tooltip";
import { import {
copyToClipboard, copyToClipboard,
// useWindowSize // useWindowSize
@@ -17,79 +19,642 @@ import {
import "./markdown.scss"; import "./markdown.scss";
import "./highlight.css"; import "./highlight.css";
let mermaidDiagramId = 0;
const MERMAID_MIN_ZOOM = 0.5;
const MERMAID_MAX_ZOOM = 10;
const MERMAID_MAX_FIT_ZOOM = 8;
const MERMAID_ZOOM_STEP = 0.2;
const MERMAID_FIT_PADDING_RATIO = 0.92;
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;
}
function getNextMermaidZoom(current: number, delta: number) {
const next = Math.min(
MERMAID_MAX_ZOOM,
Math.max(MERMAID_MIN_ZOOM, current + delta)
);
return Number(next.toFixed(2));
}
function getElementBackgroundColor(element?: HTMLElement | null) {
if (!element) return "#fff";
const color = window.getComputedStyle(element).backgroundColor;
if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)") {
return getElementBackgroundColor(element.parentElement);
}
return color;
}
function getMermaidContentBox(svgElement: SVGSVGElement) {
try {
const box = svgElement.getBBox();
if (box.width > 0 && box.height > 0) {
return box;
}
} catch (error) {
console.error("[Mermaid] Failed to measure SVG content", error);
}
const viewBox = svgElement.viewBox?.baseVal;
if (viewBox?.width && viewBox?.height) return viewBox;
const width = Number.parseFloat(svgElement.getAttribute("width") || "");
const height = Number.parseFloat(svgElement.getAttribute("height") || "");
return {
x: 0,
y: 0,
width: Number.isFinite(width) && width > 0 ? width : 1,
height: Number.isFinite(height) && height > 0 ? height : 1,
};
}
function normalizeMermaidSvgViewport(svgElement: SVGSVGElement) {
const box = getMermaidContentBox(svgElement);
const padding = Math.max(Math.min(box.width, box.height) * 0.04, 12);
const width = box.width + padding * 2;
const height = box.height + padding * 2;
if (!width || !height) return { width: 1, height: 1 };
svgElement.setAttribute(
"viewBox",
`${box.x - padding} ${box.y - padding} ${width} ${height}`
);
svgElement.setAttribute("width", String(width));
svgElement.setAttribute("height", String(height));
svgElement.style.width = `${width}px`;
svgElement.style.height = `${height}px`;
svgElement.style.maxWidth = "none";
return { width, height };
}
function centerMermaidStage(stage: HTMLElement) {
const maxLeft = stage.scrollWidth - stage.clientWidth;
const maxTop = stage.scrollHeight - stage.clientHeight;
if (maxLeft > 0 || maxTop > 0) {
stage.scrollTo({
left: Math.max(maxLeft / 2, 0),
top: Math.max(maxTop / 2, 0),
});
}
}
function getSvgExportSize(svgElement: SVGSVGElement) {
const width = Number.parseFloat(svgElement.getAttribute("width") || "");
const height = Number.parseFloat(svgElement.getAttribute("height") || "");
if (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0) {
return { width, height };
}
const viewBox = svgElement.viewBox?.baseVal;
if (viewBox?.width && viewBox?.height) {
return {
width: viewBox.width,
height: viewBox.height,
};
}
const box = getMermaidContentBox(svgElement);
return {
width: box.width,
height: box.height,
};
}
function replaceForeignObjectForExport(
originalForeignObject: SVGForeignObjectElement,
clonedForeignObject: SVGForeignObjectElement
) {
const text = originalForeignObject.textContent?.trim() || "";
if (!text) {
clonedForeignObject.remove();
return;
}
const labelElement =
originalForeignObject.querySelector<HTMLElement>(
".nodeLabel, .edgeLabel, p, span, div"
) || originalForeignObject;
const labelStyle = window.getComputedStyle(labelElement);
const textElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"text"
);
const x = Number.parseFloat(originalForeignObject.getAttribute("x") || "0");
const y = Number.parseFloat(originalForeignObject.getAttribute("y") || "0");
const width = Number.parseFloat(
originalForeignObject.getAttribute("width") || "0"
);
const height = Number.parseFloat(
originalForeignObject.getAttribute("height") || "0"
);
textElement.setAttribute("x", String(x + width / 2));
textElement.setAttribute("y", String(y + height / 2));
textElement.setAttribute("dominant-baseline", "middle");
textElement.setAttribute("text-anchor", "middle");
textElement.setAttribute("fill", labelStyle.color || "#333");
textElement.style.fontFamily =
labelStyle.fontFamily || "trebuchet ms, verdana, arial, sans-serif";
textElement.style.fontSize = labelStyle.fontSize || "16px";
textElement.style.fontWeight = labelStyle.fontWeight || "400";
textElement.textContent = text;
clonedForeignObject.parentNode?.replaceChild(textElement, clonedForeignObject);
}
function serializeMermaidSvg(svgElement: SVGSVGElement) {
const clone = svgElement.cloneNode(true) as SVGSVGElement;
const originalForeignObjects = Array.from(
svgElement.querySelectorAll<SVGForeignObjectElement>("foreignObject")
);
const clonedForeignObjects = Array.from(
clone.querySelectorAll<SVGForeignObjectElement>("foreignObject")
);
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
clonedForeignObjects.forEach((clonedForeignObject, index) => {
const originalForeignObject = originalForeignObjects[index];
if (!originalForeignObject) {
clonedForeignObject.remove();
return;
}
replaceForeignObjectForExport(originalForeignObject, clonedForeignObject);
});
return new XMLSerializer().serializeToString(clone);
}
function loadImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load Mermaid image"));
image.src = src;
});
}
async function svgToPngBytes(
svgText: string,
size: { width: number; height: number },
backgroundColor: string
) {
const svgBlob = new Blob([svgText], {
type: "image/svg+xml;charset=utf-8",
});
const url = URL.createObjectURL(svgBlob);
try {
const image = await loadImage(url);
const scale = Math.max(window.devicePixelRatio || 1, 2);
const canvas = document.createElement("canvas");
const width = Math.ceil(size.width * scale);
const height = Math.ceil(size.height * scale);
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas is not available");
}
context.fillStyle = backgroundColor;
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pngBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Failed to export Mermaid PNG"));
}, "image/png");
});
return new Uint8Array(await pngBlob.arrayBuffer());
} finally {
URL.revokeObjectURL(url);
}
}
async function savePngBytes(
bytes: Uint8Array,
fileName: string,
filterName: string
) {
if (window.__TAURI__?.dialog?.save && window.__TAURI__?.fs?.writeBinaryFile) {
let filePath = await window.__TAURI__.dialog.save({
defaultPath: fileName,
filters: [
{
name: filterName,
extensions: ["png"],
},
],
});
if (!filePath) return;
if (!filePath.toLowerCase().endsWith(".png")) {
filePath = `${filePath}.png`;
}
await window.__TAURI__.fs.writeBinaryFile(filePath, bytes);
return;
}
const pngBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
const blob = new Blob([pngBuffer], { type: "image/png" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function getMermaidFitZoom(stage: HTMLElement, svgElement: SVGSVGElement) {
const styles = window.getComputedStyle(stage);
const verticalPadding =
Number.parseFloat(styles.paddingTop || "0") +
Number.parseFloat(styles.paddingBottom || "0");
const availableHeight = Math.max(stage.clientHeight - verticalPadding, 1);
const { height } = normalizeMermaidSvgViewport(svgElement);
if (!height) return 1;
const fitZoom = (availableHeight / height) * MERMAID_FIT_PADDING_RATIO;
return Number(
Math.min(
MERMAID_MAX_FIT_ZOOM,
Math.max(MERMAID_MIN_ZOOM, fitZoom)
).toFixed(2)
);
}
// 8 // 8
function Mermaid(props: { code: string }) { function Mermaid(props: { code: string; onError?: () => void }) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [hasError, setHasError] = useState(false); const stageRef = useRef<HTMLDivElement>(null);
const [errorMessage, setErrorMessage] = useState("");
const [svg, setSvg] = useState("");
const [activeView, setActiveView] = useState<"image" | "code">("image");
const [zoom, setZoom] = useState(1);
const [fitZoom, setFitZoom] = useState(1);
const [diagramId] = useState(() => `mermaid-${++mermaidDiagramId}`);
const bindFunctionsRef = useRef<((element: Element) => void) | undefined>();
const userAdjustedZoomRef = useRef(false);
useEffect(() => { useEffect(() => {
if (props.code && ref.current) { if (props.code) {
mermaid let cancelled = false;
.run({
nodes: [ref.current], setActiveView("image");
setErrorMessage("");
setSvg("");
setZoom(1);
setFitZoom(1);
userAdjustedZoomRef.current = false;
bindFunctionsRef.current = undefined;
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose",
suppressErrorRendering: true,
});
const renderDiagram = async () => {
const canParse = await mermaid.parse(props.code, {
suppressErrors: true, 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) => { .catch((e) => {
setHasError(true); if (cancelled) return;
console.error("[Mermaid] ", e.message); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.code]); }, [props.code]);
function viewSvgInNewWindow() { useEffect(() => {
const svg = ref.current?.querySelector("svg"); if (svg && ref.current) {
if (!svg) return; bindFunctionsRef.current?.(ref.current);
// const text = new XMLSerializer().serializeToString(svg); }
// const blob = new Blob([text], { type: "image/svg+xml" }); }, [svg]);
// view img
// URL.createObjectURL(blob); useEffect(() => {
const stage = stageRef.current;
const svgElement = ref.current?.querySelector("svg");
if (!svg || errorMessage || !stage || !svgElement) return;
let frame = 0;
const updateFitZoom = () => {
const nextFitZoom = getMermaidFitZoom(stage, svgElement);
setFitZoom(nextFitZoom);
if (!userAdjustedZoomRef.current) {
setZoom(nextFitZoom);
window.requestAnimationFrame(() => centerMermaidStage(stage));
}
};
frame = window.requestAnimationFrame(updateFitZoom);
if (typeof ResizeObserver === "undefined") {
return () => {
window.cancelAnimationFrame(frame);
};
}
const resizeObserver = new ResizeObserver(updateFitZoom);
resizeObserver.observe(stage);
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
};
}, [errorMessage, svg]);
function changeZoom(delta: number) {
userAdjustedZoomRef.current = true;
setZoom((current) => getNextMermaidZoom(current, delta));
} }
if (hasError) { function resetZoom() {
return null; userAdjustedZoomRef.current = false;
setZoom(fitZoom);
window.requestAnimationFrame(() => {
if (stageRef.current) {
centerMermaidStage(stageRef.current);
}
});
}
async function downloadPng() {
const svgElement = ref.current?.querySelector("svg");
if (!svgElement) return;
try {
normalizeMermaidSvgViewport(svgElement);
const svgText = serializeMermaidSvg(svgElement);
const pngBytes = await svgToPngBytes(
svgText,
getSvgExportSize(svgElement),
getElementBackgroundColor(stageRef.current)
);
await savePngBytes(
pngBytes,
`${diagramId}.png`,
t("markdown.mermaid.pngImage")
);
} catch (error) {
console.error("[Mermaid] Failed to download PNG", error);
}
} }
return ( return (
<div <div className="mermaid-viewer">
className={clsx("no-dark", "mermaid")} <div className="mermaid-viewer-toolbar">
style={{ <div className="mermaid-viewer-tabs">
cursor: "pointer", <Tooltip content={t("markdown.mermaid.showImage")}>
overflow: "auto", <button
}} type="button"
ref={ref} className={clsx("mermaid-viewer-tab", {
onClick={() => viewSvgInNewWindow()} active: activeView === "image",
> })}
{props.code} aria-label={t("markdown.mermaid.showImage")}
onClick={() => setActiveView("image")}
>
{t("markdown.mermaid.image")}
</button>
</Tooltip>
<Tooltip content={t("markdown.mermaid.showCode")}>
<button
type="button"
className={clsx("mermaid-viewer-tab", {
active: activeView === "code",
})}
aria-label={t("markdown.mermaid.showCode")}
onClick={() => setActiveView("code")}
>
{t("markdown.mermaid.code")}
</button>
</Tooltip>
</div>
<div className="mermaid-viewer-actions">
{activeView === "image" ? (
<>
<Tooltip content={t("markdown.mermaid.zoomIn")}>
<button
type="button"
className="mermaid-viewer-action"
aria-label={t("markdown.mermaid.zoomIn")}
onClick={() => changeZoom(MERMAID_ZOOM_STEP)}
>
<ZoomIn size={18} strokeWidth={2} />
</button>
</Tooltip>
<Tooltip content={t("markdown.mermaid.zoomOut")}>
<button
type="button"
className="mermaid-viewer-action"
aria-label={t("markdown.mermaid.zoomOut")}
disabled={zoom <= MERMAID_MIN_ZOOM}
onClick={() => changeZoom(-MERMAID_ZOOM_STEP)}
>
<ZoomOut size={18} strokeWidth={2} />
</button>
</Tooltip>
<Tooltip content={t("markdown.mermaid.resetView")}>
<button
type="button"
className="mermaid-viewer-action"
aria-label={t("markdown.mermaid.resetView")}
onClick={resetZoom}
>
<RotateCcw size={18} strokeWidth={2} />
</button>
</Tooltip>
<Tooltip content={t("markdown.mermaid.downloadPng")}>
<button
type="button"
className="mermaid-viewer-action"
aria-label={t("markdown.mermaid.downloadPng")}
disabled={!svg || Boolean(errorMessage)}
onClick={downloadPng}
>
<Download size={18} strokeWidth={2} />
</button>
</Tooltip>
</>
) : (
<Tooltip content={t("markdown.mermaid.copyCode")}>
<button
type="button"
className="mermaid-viewer-action"
aria-label={t("markdown.mermaid.copyCode")}
onClick={() => copyToClipboard(props.code)}
>
<Copy size={18} strokeWidth={2} />
</button>
</Tooltip>
)}
</div>
</div>
<div className="mermaid-viewer-content">
<div
className="mermaid-diagram-stage"
ref={stageRef}
hidden={activeView !== "image"}
>
{errorMessage ? (
<div className="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>
) : (
<div
className="mermaid-diagram-canvas"
style={{ zoom } as React.CSSProperties & { zoom: number }}
>
<div
className="mermaid-render-target"
ref={ref}
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
/>
</div>
)}
</div>
<pre className="mermaid-source-code" hidden={activeView !== "code"}>
<code>{props.code}</code>
</pre>
</div>
</div> </div>
); );
} }
// 7 // 7
function PreCode(props: { children?: any; enableMermaid?: boolean }) { function PreCode(props: {
children?: any;
enableMermaid?: boolean;
hideMermaidSource?: boolean;
}) {
const ref = useRef<HTMLPreElement>(null); const ref = useRef<HTMLPreElement>(null);
// const previewRef = useRef<HTMLPreviewHander>(null); // const previewRef = useRef<HTMLPreviewHander>(null);
const [mermaidCode, setMermaidCode] = useState("");
// const [htmlCode, setHtmlCode] = useState(""); // const [htmlCode, setHtmlCode] = useState("");
// const { height } = useWindowSize(); // const { height } = useWindowSize();
// console.log(htmlCode, height); // console.log(htmlCode, height);
const renderArtifacts = useDebouncedCallback(() => { const mermaidCode = useMemo(() => {
if (!ref.current) return; if (!props.enableMermaid) return "";
if (!props.enableMermaid) return;
const mermaidDom = ref.current.querySelector("code.language-mermaid"); const className = getCodeClassName(props.children);
if (mermaidDom) { if (!/\blanguage-mermaid\b/.test(className)) return "";
setMermaidCode((mermaidDom as HTMLElement).innerText);
} return getNodeText(props.children).trim();
// const htmlDom = ref.current.querySelector("code.language-html"); }, [props.children, props.enableMermaid]);
// const refText = ref.current.querySelector("code")?.innerText;
// if (htmlDom) {
// setHtmlCode((htmlDom as HTMLElement).innerText);
// } else if (refText?.startsWith("<!DOCTYPE")) {
// setHtmlCode(refText);
// }
}, 600);
// const enableArtifacts = true; // const enableArtifacts = true;
// console.log(enableArtifacts); // console.log(enableArtifacts);
@@ -117,25 +682,33 @@ function PreCode(props: { children?: any; enableMermaid?: boolean }) {
codeElement.style.whiteSpace = "pre-wrap"; codeElement.style.whiteSpace = "pre-wrap";
} }
}); });
setTimeout(renderArtifacts, 1);
} }
}, []); }, [props.children]);
const hideSource = props.hideMermaidSource && mermaidCode.length > 0;
return ( return (
<> <>
<pre ref={ref}> {!hideSource && mermaidCode.length === 0 && (
<span <pre
className="copy-code-button" ref={ref}
onClick={() => { className={clsx({
if (ref.current) { "hidden-mermaid-source": hideSource,
copyToClipboard( })}
ref.current.querySelector("code")?.innerText ?? "" >
); <span
} className="copy-code-button"
}} onClick={() => {
></span> if (ref.current) {
{props.children} copyToClipboard(
</pre> ref.current.querySelector("code")?.innerText ?? ""
);
}
}}
></span>
{props.children}
</pre>
)}
{mermaidCode.length > 0 && ( {mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} /> <Mermaid code={mermaidCode} key={mermaidCode} />
)} )}
@@ -238,6 +811,7 @@ function _MarkDownContent(props: {
enableSyntaxHighlight?: boolean; enableSyntaxHighlight?: boolean;
enableMermaid?: boolean; enableMermaid?: boolean;
enableMath?: boolean; enableMath?: boolean;
hideMermaidSource?: boolean;
}) { }) {
const enableMath = props.enableMath !== false; const enableMath = props.enableMath !== false;
@@ -278,7 +852,11 @@ function _MarkDownContent(props: {
rehypePlugins={rehypePlugins as any} rehypePlugins={rehypePlugins as any}
components={{ components={{
pre: (preProps) => ( pre: (preProps) => (
<PreCode {...preProps} enableMermaid={props.enableMermaid !== false} /> <PreCode
{...preProps}
enableMermaid={props.enableMermaid !== false}
hideMermaidSource={props.hideMermaidSource}
/>
), ),
code: CustomCode, code: CustomCode,
p: (pProps) => <p {...pProps} dir="auto" />, p: (pProps) => <p {...pProps} dir="auto" />,
@@ -324,6 +902,7 @@ export default function Markdown(
enableSyntaxHighlight?: boolean; enableSyntaxHighlight?: boolean;
enableMermaid?: boolean; enableMermaid?: boolean;
enableMath?: boolean; enableMath?: boolean;
hideMermaidSource?: boolean;
} & React.DOMAttributes<HTMLDivElement> } & React.DOMAttributes<HTMLDivElement>
) { ) {
const mdRef = useRef<HTMLDivElement>(null); const mdRef = useRef<HTMLDivElement>(null);
@@ -346,6 +925,7 @@ export default function Markdown(
enableSyntaxHighlight={props.enableSyntaxHighlight} enableSyntaxHighlight={props.enableSyntaxHighlight}
enableMermaid={props.enableMermaid} enableMermaid={props.enableMermaid}
enableMath={props.enableMath} enableMath={props.enableMath}
hideMermaidSource={props.hideMermaidSource}
/> />
</div> </div>
</div> </div>

View File

@@ -42,6 +42,10 @@
--color-accent-emphasis: #0969da; --color-accent-emphasis: #0969da;
--color-attention-subtle: #fff8c5; --color-attention-subtle: #fff8c5;
--color-danger-fg: #cf222e; --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 { @mixin dark {
@@ -88,6 +92,10 @@
--color-accent-emphasis: #1f6feb; --color-accent-emphasis: #1f6feb;
--color-attention-subtle: rgba(187, 128, 9, 0.15); --color-attention-subtle: rgba(187, 128, 9, 0.15);
--color-danger-fg: #f85149; --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 { .light {
@@ -1108,15 +1116,237 @@
filter: invert(50%); filter: invert(50%);
} }
.markdown-body .mermaid { .markdown-body .mermaid-viewer {
border: var(--border-in-light); position: relative;
margin-bottom: 10px; width: 100%;
border-radius: 4px; max-width: 100%;
padding: 10px; margin: 12px 0 16px;
background-color: var(--white); overflow: visible;
border: 1px solid var(--color-border-muted);
border-radius: 8px;
background: #fff;
}
.markdown-body .mermaid-viewer-toolbar {
position: relative;
z-index: 3;
display: flex;
min-height: 54px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px 10px 16px;
border-radius: 8px 8px 0 0;
background: #f1f1f1;
}
.markdown-body .mermaid-viewer-tabs {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 8px;
background: #f5f5f5;
}
.markdown-body .mermaid-viewer-tab,
.markdown-body .mermaid-viewer-action {
appearance: none;
border: 0;
font: inherit;
cursor: pointer;
}
.markdown-body .mermaid-viewer-tab {
min-width: 74px;
padding: 8px 14px;
border-radius: 7px;
background: transparent;
color: #4d4d4d;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.markdown-body .mermaid-viewer-tab.active {
background: #fff;
color: #222;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
}
.markdown-body .mermaid-viewer-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.markdown-body .mermaid-viewer-action {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 6px;
background: transparent;
color: #222;
}
.markdown-body .mermaid-viewer-action:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
}
.markdown-body .mermaid-viewer-action:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.markdown-body .mermaid-viewer-content {
overflow: hidden;
border-radius: 0 0 8px 8px;
background: #fff;
}
.markdown-body .mermaid-diagram-stage {
display: flex;
height: clamp(320px, 52vh, 560px);
min-height: 320px;
align-items: center;
justify-content: center;
overflow: auto;
padding: 28px;
background: #fff;
}
.markdown-body .mermaid-diagram-stage[hidden],
.markdown-body .mermaid-source-code[hidden] {
display: none;
}
.markdown-body .mermaid-diagram-canvas {
display: inline-flex;
min-width: max-content;
min-height: max-content;
align-items: center;
justify-content: center;
}
.markdown-body .mermaid-render-target:empty {
min-width: 240px;
min-height: 160px;
}
.markdown-body .mermaid-render-target svg {
display: block;
max-width: none;
}
.markdown-body .mermaid-source-code {
min-height: 320px;
max-height: 560px;
margin: 0;
overflow: auto;
border: 0;
border-radius: 0;
background: #fff;
color: #444;
font-size: 14px;
line-height: 1.75;
white-space: pre;
}
.markdown-body .mermaid-source-code code {
display: block;
padding: 24px;
background: transparent;
color: inherit;
font: inherit;
white-space: inherit;
}
.markdown-body .mermaid-error {
display: flex;
min-height: 140px;
width: min(420px, 100%);
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px;
border: 1px solid var(--color-mermaid-error-border);
border-radius: 8px;
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;
} }
#dmermaid { #dmermaid {
display: none; display: none;
} }
:where(.dark, [data-theme="dark"]) & {
.markdown-body .mermaid-viewer {
border-color: var(--color-border-muted);
background: #0f1115;
}
.markdown-body .mermaid-viewer-toolbar {
background: #202328;
}
.markdown-body .mermaid-viewer-tabs {
background: #262a30;
}
.markdown-body .mermaid-viewer-tab {
color: #c2c7cf;
}
.markdown-body .mermaid-viewer-tab.active {
background: #111418;
color: #f2f4f8;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.markdown-body .mermaid-viewer-action {
color: #eef1f5;
}
.markdown-body .mermaid-viewer-action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.markdown-body .mermaid-viewer-content,
.markdown-body .mermaid-diagram-stage {
background: #0f1115;
}
.markdown-body .mermaid-render-target svg {
background: transparent !important;
}
.markdown-body .mermaid-source-code {
background: #0f1115;
color: #d7dbe2;
}
}
} }

View File

@@ -4,6 +4,7 @@
} }
.tooltip-box { .tooltip-box {
--tooltip-offset: translateX(-50%);
position: absolute; position: absolute;
background-color: rgba(0, 0, 0, 0.75); background-color: rgba(0, 0, 0, 0.75);
color: #fff; color: #fff;
@@ -13,42 +14,43 @@
white-space: nowrap; white-space: nowrap;
z-index: 1000; z-index: 1000;
opacity: 0; opacity: 0;
transform: scale(0.9); transform: var(--tooltip-offset) scale(0.9);
transition: opacity 0.2s, transform 0.2s; transition: opacity 0.2s, transform 0.2s;
pointer-events: none; pointer-events: none;
transform-origin: center;
} }
.tooltip-box.tooltip-top { .tooltip-box.tooltip-top {
--tooltip-offset: translateX(-50%);
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%);
margin-bottom: 8px; margin-bottom: 8px;
} }
.tooltip-box.tooltip-bottom { .tooltip-box.tooltip-bottom {
--tooltip-offset: translateX(-50%);
top: 100%; top: 100%;
left: 50%; left: 50%;
transform: translateX(-50%);
margin-top: 8px; margin-top: 8px;
} }
.tooltip-box.tooltip-left { .tooltip-box.tooltip-left {
--tooltip-offset: translateY(-50%);
right: 100%; right: 100%;
top: 50%; top: 50%;
transform: translateY(-50%);
margin-right: 8px; margin-right: 8px;
} }
.tooltip-box.tooltip-right { .tooltip-box.tooltip-right {
--tooltip-offset: translateY(-50%);
left: 100%; left: 100%;
top: 50%; top: 50%;
transform: translateY(-50%);
margin-left: 8px; margin-left: 8px;
} }
.tooltip-container:hover .tooltip-box, .tooltip-container:hover .tooltip-box,
.tooltip-container:focus-within .tooltip-box { .tooltip-container:focus-within .tooltip-box {
opacity: 1; opacity: 1;
transform: scale(1); transform: var(--tooltip-offset) scale(1);
pointer-events: auto; pointer-events: auto;
} }

View File

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

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,22 @@
"cancel": "No" "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",
"image": "Image",
"code": "Code",
"showImage": "Show image",
"showCode": "Show code",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"resetView": "Reset view",
"downloadPng": "Download PNG",
"copyCode": "Copy code",
"pngImage": "PNG Image"
}
},
"labels": { "labels": {
"openSource": "Open Source", "openSource": "Open Source",
"cancelled": "Generation Cancelled", "cancelled": "Generation Cancelled",
@@ -497,6 +513,7 @@
}, },
"contextMenu": { "contextMenu": {
"open": "Open", "open": "Open",
"openInBrowser": "Open in Browser",
"copyLink": "Copy Link", "copyLink": "Copy Link",
"copyAnswer": "Copy Answer", "copyAnswer": "Copy Answer",
"copyUppercaseAnswer": "Copy Answer (in Word)", "copyUppercaseAnswer": "Copy Answer (in Word)",
@@ -750,6 +767,29 @@
"loading": "Loading...", "loading": "Loading...",
"notFound": "Not found" "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": { "selection": {
"title": "Selection Toolbar Settings", "title": "Selection Toolbar Settings",
"actions": { "actions": {

View File

@@ -56,6 +56,22 @@
"cancel": "否" "cancel": "否"
} }
}, },
"markdown": {
"mermaid": {
"renderFailed": "思维导图无法显示",
"renderFailedDescription": "内容格式异常,暂时无法生成预览",
"image": "图片",
"code": "代码",
"showImage": "显示图片",
"showCode": "显示代码",
"zoomIn": "放大",
"zoomOut": "缩小",
"resetView": "重置视图",
"downloadPng": "下载 PNG",
"copyCode": "复制代码",
"pngImage": "PNG 图片"
}
},
"labels": { "labels": {
"openSource": "打开来源", "openSource": "打开来源",
"cancelled": "生成已取消", "cancelled": "生成已取消",
@@ -497,6 +513,7 @@
}, },
"contextMenu": { "contextMenu": {
"open": "打开", "open": "打开",
"openInBrowser": "打开至浏览器",
"copyLink": "复制链接", "copyLink": "复制链接",
"copyAnswer": "复制答案", "copyAnswer": "复制答案",
"copyUppercaseAnswer": "复制答案(大写)", "copyUppercaseAnswer": "复制答案(大写)",
@@ -749,6 +766,29 @@
"loading": "加载中...", "loading": "加载中...",
"notFound": "未找到" "notFound": "未找到"
}, },
"documentPreview": {
"labels": {
"preview": "预览",
"aiInterpretation": "AI 解读"
},
"buttons": {
"openSource": "打开来源",
"reload": "重新加载"
},
"hints": {
"previewUnavailable": "当前文档类型暂不支持预览。",
"previewUnavailableTitle": "此文件无法预览",
"previewUnavailableDescription": "文件格式可能不受支持,或内容暂时不可用"
},
"loading": {
"document": "文档加载中...",
"preview": "预览加载中..."
},
"errors": {
"missingParams": "缺少文档预览参数。",
"loadFailed": "文档加载失败"
}
},
"selection": { "selection": {
"title": "划词工具栏设置", "title": "划词工具栏设置",
"actions": { "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); setViewExtensionOpened(payload);
}); });
platformAdapter.listenEvent("open_url_window", async ({ payload }) => {
if (typeof payload === "string" && payload) {
await platformAdapter.openUrl(payload);
}
});
}, []); }, []);
const { synthesizeItem } = useChatStore(); 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 StandaloneChat = lazy(() => import("@/pages/chat/index"));
const CheckPage = lazy(() => import("@/pages/check/index")); const CheckPage = lazy(() => import("@/pages/check/index"));
const SelectionWindow = lazy(() => import("@/pages/selection/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 = { const routerOptions = {
basename: "/", basename: "/",
@@ -30,6 +32,8 @@ export const router = createBrowserRouter(
{ path: "/ui/chat", element: (<Suspense fallback={<></>}><StandaloneChat /></Suspense>) }, { path: "/ui/chat", element: (<Suspense fallback={<></>}><StandaloneChat /></Suspense>) },
{ path: "/ui/check", element: (<Suspense fallback={<></>}><CheckPage /></Suspense>) }, { path: "/ui/check", element: (<Suspense fallback={<></>}><CheckPage /></Suspense>) },
{ path: "/ui/selection", element: (<Suspense fallback={<></>}><SelectionWindow /></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; oauth_success: any;
extension_install_success: any; extension_install_success: any;
open_view_extension: ViewExtensionOpened; open_view_extension: ViewExtensionOpened;
open_url_window: string;
"server-list-changed": Server[]; "server-list-changed": Server[];
"selection-text": string; "selection-text": string;
"selection-ask-ai": any; "selection-ask-ai": any;
@@ -124,6 +125,7 @@ export interface SystemOperations {
relaunchApp: () => Promise<void>; relaunchApp: () => Promise<void>;
isTauri: () => boolean; isTauri: () => boolean;
openUrl: (url: string) => Promise<unknown>; openUrl: (url: string) => Promise<unknown>;
openUrlWithBrowser: (url: string) => Promise<unknown>;
commands: <T>(commandName: string, ...args: any[]) => Promise<T>; commands: <T>(commandName: string, ...args: any[]) => Promise<T>;
isWindows10: () => Promise<boolean>; isWindows10: () => Promise<boolean>;
revealItemInDir: (path: string) => Promise<unknown>; revealItemInDir: (path: string) => Promise<unknown>;

View File

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

View File

@@ -13,7 +13,7 @@ import {
import type { BasePlatformAdapter } from "@/types/platform"; import type { BasePlatformAdapter } from "@/types/platform";
import type { AppTheme } from "@/types/index"; import type { AppTheme } from "@/types/index";
import { useAppearanceStore } from "@/stores/appearanceStore"; import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from "."; import { copyToClipboard, dispatchEvent } from ".";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { unrequitable } from "@/utils"; import { unrequitable } from "@/utils";
import { import {
@@ -58,6 +58,102 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
// Create Tauri adapter functions // Create Tauri adapter functions
export const createTauriAdapter = (): TauriPlatformAdapter => { 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 { return {
async setWindowSize(width, height) { async setWindowSize(width, height) {
return windowWrapper.setLogicalSize(width, height); return windowWrapper.setLogicalSize(width, height);
@@ -310,9 +406,13 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
}, },
async openUrl(url) { async openUrl(url) {
return openUrlInWebviewWindow(url);
},
async openUrlWithBrowser(url) {
const { openUrl } = await import("@tauri-apps/plugin-opener"); const { openUrl } = await import("@tauri-apps/plugin-opener");
openUrl(url); return openUrl(url);
}, },
isWindows10, isWindows10,
@@ -332,7 +432,7 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
revealItemInDir(path); revealItemInDir(path);
}, },
async openSearchItem(data) { async openSearchItem(data, formatUrl) {
const { invoke } = await import("@tauri-apps/api/core"); const { invoke } = await import("@tauri-apps/api/core");
console.log("openSearchItem", data); console.log("openSearchItem", data);
@@ -354,6 +454,14 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
return invoke("hide_coco"); 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) { if (data?.on_opened) {
await invoke("open", { await invoke("open", {
onOpened: data.on_opened, onOpened: data.on_opened,
@@ -363,12 +471,6 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
return hideCoco(); return hideCoco();
} }
if (data?.url) {
OpenURLWithBrowser(data.url);
return hideCoco();
}
if (data?.payload?.result?.value) { if (data?.payload?.result?.value) {
return copyToClipboard(data.payload.result.value); return copyToClipboard(data.payload.result.value);
} }

View File

@@ -251,6 +251,13 @@ export const createWebAdapter = (): WebPlatformAdapter => {
window.open(urlWithIntegrationId, "_blank"); 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, isWindows10: async () => false,
async setShadow(enable) { async setShadow(enable) {