From 4df541f7ce99c4dfdd873fa08484dd5aaec8156c Mon Sep 17 00:00:00 2001 From: ayangweb <75017711+ayangweb@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:11:51 +0800 Subject: [PATCH] fix: resolve fst-no-std version conflict causing pizza-engine CI check failure (#1074) * feat: add deep research report workflow and viewer * docs: update changelog * ci: update pizza repository reference from infinilabs to pizza-rs org * fix deep research completion state * fix: prevent deep research stream timeout * fix: update deep research cancel copy * fix: apply rustfmt formatting to attachment.rs * fix: resolve fst-no-std version conflict in pizza-engine CI check The 'check (macos-latest)' CI job fails because pizza-engine (added as a CI dependency) uses infinilabs/fst-no-std while its transitive dependency fst-regex uses pizza-rs/fst-no-std. Cargo treats these as two separate crates, causing: 1. Automaton trait incompatibility between the two fst_no_std instances 2. MapBuilder, Error::Io, and Set::from_iter not found because the infinilabs fork gates them behind 'std' feature while pizza-engine only enabled 'alloc' Fix: - Add [patch."https://github.com/infinilabs/fst-no-std"] to src-tauri/Cargo.toml to redirect the infinilabs fork to pizza-rs/fst-no-std, unifying both into a single crate instance. This patch is a no-op for normal builds without pizza-engine. - Update the CI workflow to also add fst-no-std with std feature so that Error::Io (gated by std) and MapBuilder are available after the redirect. * Update Cargo.toml --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Medcl --- .github/workflows/release.yml | 2 +- .github/workflows/rust_code_check.yml | 10 +- docs/content.en/docs/release-notes/_index.md | 1 + package-lock.json | 357 +++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 188 ++++++++ scripts/buildWeb.ts | 155 +++++++ src-tauri/Cargo.toml | 13 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/assistant/mod.rs | 83 +++- src-tauri/src/lib.rs | 4 + src-tauri/src/server/attachment.rs | 79 ++++ src-tauri/src/server/http_client.rs | 119 ++++- src-tauri/src/settings.rs | 5 +- src/commands/servers.ts | 21 + src/commands/system.ts | 16 +- src/components/Assistant/Chat.tsx | 172 +++++++- src/components/Assistant/ChatContent.tsx | 191 +++++--- .../DeepResearch/DeepResearchCancelDialog.tsx | 88 ++++ .../DeepResearch/DeepResearchLoadingState.tsx | 27 ++ .../DeepResearch/DeepResearchPanel.tsx | 414 ++++++++++++++++++ .../DeepResearch/PdfReportViewer.tsx | 75 ++++ .../DeepResearch/ResearchReportContent.tsx | 234 ++++++++++ .../ResearchSearchResultsContent.tsx | 77 ++++ .../DeepResearch/ResearchStepsContent.tsx | 380 ++++++++++++++++ .../ChatMessage/DeepResearch/deriveState.ts | 292 ++++++++++++ .../ChatMessage/DeepResearch/index.tsx | 363 +++++++++++++++ .../ChatMessage/DeepResearch/load-failed.svg | 18 + .../ChatMessage/DeepResearch/loading.svg | 83 ++++ .../ChatMessage/DeepResearch/payload.ts | 127 ++++++ .../ChatMessage/DeepResearch/reportContent.ts | 291 ++++++++++++ .../DeepResearch/resolveReportUrl.ts | 48 ++ .../ChatMessage/DeepResearch/types.ts | 85 ++++ src/components/ChatMessage/Markdown.tsx | 71 ++- src/components/ChatMessage/index.tsx | 98 ++++- src/components/ChatMessage/markdown.scss | 11 +- src/components/Search/AskAi.tsx | 22 + src/components/SearchChat/index.tsx | 6 +- src/components/Settings/GeneralSettings.tsx | 11 +- src/components/ui/dialog.tsx | 7 +- src/hooks/useChatActions.ts | 4 - src/hooks/useMessageChunkData.ts | 12 + src/hooks/useMessageHandler.ts | 70 ++- src/hooks/useStreamChat.ts | 18 + src/hooks/useWindows.ts | 4 +- src/locales/en/translation.json | 76 +++- src/locales/zh/translation.json | 76 +++- src/pages/chat/index.tsx | 6 +- src/types/platform.ts | 3 + tsup.config.ts | 23 +- 50 files changed, 4342 insertions(+), 200 deletions(-) create mode 100644 scripts/buildWeb.ts create mode 100644 src/components/ChatMessage/DeepResearch/DeepResearchCancelDialog.tsx create mode 100644 src/components/ChatMessage/DeepResearch/DeepResearchLoadingState.tsx create mode 100644 src/components/ChatMessage/DeepResearch/DeepResearchPanel.tsx create mode 100644 src/components/ChatMessage/DeepResearch/PdfReportViewer.tsx create mode 100644 src/components/ChatMessage/DeepResearch/ResearchReportContent.tsx create mode 100644 src/components/ChatMessage/DeepResearch/ResearchSearchResultsContent.tsx create mode 100644 src/components/ChatMessage/DeepResearch/ResearchStepsContent.tsx create mode 100644 src/components/ChatMessage/DeepResearch/deriveState.ts create mode 100644 src/components/ChatMessage/DeepResearch/index.tsx create mode 100644 src/components/ChatMessage/DeepResearch/load-failed.svg create mode 100644 src/components/ChatMessage/DeepResearch/loading.svg create mode 100644 src/components/ChatMessage/DeepResearch/payload.ts create mode 100644 src/components/ChatMessage/DeepResearch/reportContent.ts create mode 100644 src/components/ChatMessage/DeepResearch/resolveReportUrl.ts create mode 100644 src/components/ChatMessage/DeepResearch/types.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd43255e..5f5065ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,7 @@ jobs: - name: Checkout dependency repository uses: actions/checkout@v4 with: - repository: "infinilabs/pizza" + repository: "pizza-rs/pizza" ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} submodules: recursive ref: main diff --git a/.github/workflows/rust_code_check.yml b/.github/workflows/rust_code_check.yml index 2567ed93..19c5a3f6 100644 --- a/.github/workflows/rust_code_check.yml +++ b/.github/workflows/rust_code_check.yml @@ -20,7 +20,7 @@ jobs: - name: Checkout dependency (pizza-engine) repository uses: actions/checkout@v4 with: - repository: 'infinilabs/pizza' + repository: 'pizza-rs/pizza' ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} submodules: recursive ref: main @@ -43,7 +43,13 @@ jobs: - name: Add pizza engine as a dependency working-directory: src-tauri shell: bash - run: cargo add --path ../pizza/lib/engine --features query_string_parser,persistence + run: | + cargo add --path ../pizza/lib/engine --features query_string_parser,persistence + # Add fst-no-std with the std feature to enable MapBuilder and Error::Io. + # The [patch] in Cargo.toml redirects infinilabs/fst-no-std (used by pizza-engine) + # to pizza-rs/fst-no-std (used by fst-regex) so both resolve to the same crate. + # The std feature activates the Error::Io variant and MapBuilder that pizza-engine requires. + cargo add fst-no-std --git https://github.com/pizza-rs/fst-no-std --branch main --features std - name: Format check working-directory: src-tauri diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index 99a04ca0..92652b39 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -15,6 +15,7 @@ Information about release notes of Coco App is provided here. - feat: support app search even if Spotlight is disabled #1028 - feat: read localized names from root InfoPlist.strings #1029 +- feat: add deep research report workflow and viewer #1074 ### 🐛 Bug fix diff --git a/package-lock.json b/package-lock.json index d7ee2c58..b4f53d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "react-hotkeys-hook": "^4.6.2", "react-i18next": "^15.5.1", "react-markdown": "^9.1.0", + "react-pdf": "^10.4.1", "react-router-dom": "^6.30.0", "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", @@ -8491,6 +8492,24 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -8831,6 +8850,23 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -10444,6 +10480,312 @@ "react": ">=18" } }, + "node_modules/react-pdf": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz", + "integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/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", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/react-pdf/node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -11515,6 +11857,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", @@ -12659,6 +13007,15 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/wavesurfer.js": { "version": "7.12.6", "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.6.tgz", diff --git a/package.json b/package.json index 7b00079a..5568c539 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm", + "build:web": "tsx scripts/buildWeb.ts", "publish:web": "cd out/search-chat && npm publish", "publish:web:beta": "cd out/search-chat && npm publish --tag beta", "publish:web:alpha": "cd out/search-chat && npm publish --tag alpha", @@ -68,6 +68,7 @@ "react-hotkeys-hook": "^4.6.2", "react-i18next": "^15.5.1", "react-markdown": "^9.1.0", + "react-pdf": "^10.4.1", "react-router-dom": "^6.30.0", "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2f661e4..ccdd3414 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: react-markdown: specifier: ^9.1.0 version: 9.1.0(@types/react@18.3.26)(react@18.3.1) + react-pdf: + specifier: ^10.4.1 + version: 10.4.1(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^6.30.0 version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -897,6 +900,76 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3273,6 +3346,12 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-cancellable-promise@2.0.0: + resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==} + + make-event-props@2.0.0: + resolution: {integrity: sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3339,6 +3418,14 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-refs@2.0.0: + resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3625,6 +3712,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3757,6 +3848,16 @@ packages: '@types/react': '>=18' react: '>=18' + react-pdf@10.4.1: + resolution: {integrity: sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==} + peerDependencies: + '@types/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 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4097,6 +4198,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -4343,6 +4447,9 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + wavesurfer.js@7.11.1: resolution: {integrity: sha512-8Q+wwItpjJAlhQ7crQLtKwgfbqqczm5/wx+76K4PptP+MBAjB0OA78+A9OuLnULz/8GpAQ+fKM6s81DonEO0Sg==} @@ -4950,6 +5057,54 @@ snapshots: dependencies: langium: 3.3.1 + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7298,6 +7453,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-cancellable-promise@2.0.0: {} + + make-event-props@2.0.0: {} + markdown-table@3.0.4: {} marked@16.4.1: {} @@ -7476,6 +7635,10 @@ snapshots: memoize-one@5.2.1: {} + merge-refs@2.0.0(@types/react@18.3.26): + optionalDependencies: + '@types/react': 18.3.26 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -7902,6 +8065,10 @@ snapshots: pathe@2.0.3: {} + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -8036,6 +8203,21 @@ snapshots: transitivePeerDependencies: - supports-color + react-pdf@10.4.1(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + dequal: 2.0.3 + make-cancellable-promise: 2.0.0 + make-event-props: 2.0.0 + merge-refs: 2.0.0(@types/react@18.3.26) + pdfjs-dist: 5.4.296 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tiny-invariant: 1.3.3 + warning: 4.0.3 + optionalDependencies: + '@types/react': 18.3.26 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@18.3.26)(react@18.3.1): @@ -8448,6 +8630,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-invariant@1.3.3: {} + tinyexec@0.3.2: {} tinyexec@1.0.1: {} @@ -8678,6 +8862,10 @@ snapshots: vscode-uri@3.0.8: {} + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + wavesurfer.js@7.11.1: {} web-namespaces@2.0.1: {} diff --git a/scripts/buildWeb.ts b/scripts/buildWeb.ts new file mode 100644 index 00000000..9335235a --- /dev/null +++ b/scripts/buildWeb.ts @@ -0,0 +1,155 @@ +/// + +import { spawn } from "child_process"; +import { readFileSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const projectRoot = join(__dirname, ".."); +const adapterPath = join(projectRoot, "src/utils/platformAdapter.ts"); + +type AdapterMode = "tauri" | "web"; + +function stripLineComments(content: string) { + return content + .split(/\r?\n/) + .filter((line) => !line.trimStart().startsWith("//")) + .join("\n"); +} + +function detectAdapterMode(content: string): AdapterMode { + const activeContent = stripLineComments(content); + const usesTauriAdapter = + /import\s*\{\s*createTauriAdapter\s*\}\s*from\s*["']\.\/tauriAdapter["']/.test( + activeContent + ) && /platformAdapter\s*=\s*createTauriAdapter\s*\(/.test(activeContent); + const usesWebAdapter = + /import\s*\{\s*createWebAdapter\s*\}\s*from\s*["']\.\/webAdapter["']/.test( + activeContent + ) && /platformAdapter\s*=\s*createWebAdapter\s*\(/.test(activeContent); + + if (usesTauriAdapter && !usesWebAdapter) { + return "tauri"; + } + + if (usesWebAdapter && !usesTauriAdapter) { + return "web"; + } + + throw new Error( + `Unable to detect active platform adapter in ${adapterPath}. ` + + `Expected either createTauriAdapter() or createWebAdapter() to be active.` + ); +} + +function setLineComment(line: string, commented: boolean) { + const indent = line.match(/^\s*/)?.[0] ?? ""; + const content = line.slice(indent.length); + const uncommented = content.replace(/^\/\/\s?/, ""); + + return commented ? `${indent}// ${uncommented}` : `${indent}${uncommented}`; +} + +function switchToWebAdapter(content: string) { + let hasTauriImport = false; + let hasTauriInit = false; + let hasWebImport = false; + let hasWebInit = false; + + const nextContent = content + .split(/\r?\n/) + .map((line) => { + const normalized = line.trim().replace(/^\/\/\s?/, ""); + + if ( + normalized === 'import { createTauriAdapter } from "./tauriAdapter";' + ) { + hasTauriImport = true; + return setLineComment(line, true); + } + + if (normalized === "let platformAdapter = createTauriAdapter();") { + hasTauriInit = true; + return setLineComment(line, true); + } + + if (normalized === 'import { createWebAdapter } from "./webAdapter";') { + hasWebImport = true; + return setLineComment(line, false); + } + + if (normalized === "let platformAdapter = createWebAdapter();") { + hasWebInit = true; + return setLineComment(line, false); + } + + return line; + }) + .join("\n"); + + if (!hasTauriImport || !hasTauriInit || !hasWebImport || !hasWebInit) { + throw new Error( + `Unable to switch ${adapterPath} to Web mode. ` + + `Expected both Tauri and Web adapter import/init lines to exist.` + ); + } + + detectAdapterMode(nextContent); + return nextContent; +} + +function run(command: string, args: string[]) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: projectRoot, + env: { + ...process.env, + BUILD_TARGET: "web", + }, + shell: process.platform === "win32", + stdio: "inherit", + }); + + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`)); + }); + }); +} + +async function main() { + const originalContent = readFileSync(adapterPath, "utf-8"); + const originalMode = detectAdapterMode(originalContent); + const shouldRestore = originalMode !== "web"; + + if (shouldRestore) { + console.log("[build:web] Switching platform adapter to Web for packaging."); + writeFileSync(adapterPath, switchToWebAdapter(originalContent), "utf-8"); + } else { + console.log("[build:web] Platform adapter is already Web; leaving it as-is."); + } + + try { + await run("pnpm", ["exec", "tsc"]); + await run("pnpm", ["exec", "tsup", "--format", "esm"]); + } finally { + if (shouldRestore) { + writeFileSync(adapterPath, originalContent, "utf-8"); + console.log("[build:web] Restored original platform adapter."); + } else { + console.log("[build:web] Kept platform adapter in original Web mode."); + } + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4827f0f6..9cc7e2f9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,7 +28,7 @@ cargo-clippy = [] # # ```toml # [dependencies] -# pizza-engine = { git = "ssh://git@github.com/infinilabs/pizza.git", features = ["query_string_parser", "persistence"] } +# pizza-engine = { git = "ssh://git@github.com/pizza-rs/pizza.git", features = ["query_string_parser", "persistence"] } # ``` # # 2. It is a private repo, you have access to it. @@ -43,7 +43,7 @@ cargo-clippy = [] use_pizza_engine = [] [dependencies] -pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" } +pizza-common = { git = "https://github.com/pizza-rs/common", branch = "main" } tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png"] } tauri-plugin-shell = "2" @@ -173,3 +173,12 @@ windows-sys = { version = "0.61", features = ["Win32", "Win32_System", "Win32_Sy [target."cfg(target_os = \"windows\")".build-dependencies] bindgen = "0.72.1" + +# When pizza-engine is added as a CI dependency, it uses infinilabs/fst-no-std +# while its transitive dependency fst-regex uses pizza-rs/fst-no-std. These two +# forks are treated as separate crates by Cargo, causing Automaton trait +# incompatibility errors. This patch redirects infinilabs/fst-no-std to +# pizza-rs/fst-no-std to unify the dependency. It has no effect on normal +# builds that do not include pizza-engine. +[patch."https://github.com/infinilabs/fst-no-std"] +fst-no-std = { git = "https://github.com/pizza-rs/fst-no-std", branch = "main" } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7a61d628..61617255 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "chat", "settings", "check", "selection"], + "windows": ["*"], "permissions": [ "core:default", "core:event:allow-emit", @@ -32,6 +32,7 @@ "core:window:allow-set-shadow", "core:window:allow-set-position", "core:window:allow-set-theme", + "core:window:allow-set-title", "core:window:allow-unminimize", "core:window:allow-set-fullscreen", "core:window:allow-set-resizable", diff --git a/src-tauri/src/assistant/mod.rs b/src-tauri/src/assistant/mod.rs index ac5f67f7..06f61553 100644 --- a/src-tauri/src/assistant/mod.rs +++ b/src-tauri/src/assistant/mod.rs @@ -133,8 +133,9 @@ pub async fn chat_create( ) }; - let response = HttpClient::advanced_post( + let response = HttpClient::send_streaming_request( &server_id, + Method::POST, "/chat/_create", None, convert_query_params_to_strings(query_params), @@ -160,13 +161,26 @@ pub async fn chat_create( log::info!("client_id_create: {}", &client_id); - while let Ok(Some(line)) = lines.next_line().await { - log::info!("Received chat stream line: {}", &line); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + log::info!("Received chat stream line: {}", &line); - if let Err(err) = app_handle.emit(&client_id, line) { - log::error!("Emit failed: {:?}", err); + if let Err(err) = app_handle.emit(&client_id, line) { + log::error!("Emit failed: {:?}", err); - let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err)); + let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err)); + } + } + Ok(None) => break, + Err(err) => { + log::error!("Failed to read chat create stream: {:?}", err); + let _ = app_handle.emit( + "chat-create-error", + format!("Failed to read chat create stream: {:?}", err), + ); + break; + } } } @@ -210,8 +224,9 @@ pub async fn chat_chat( let path = format!("/chat/{}/_chat", session_id); - let response = HttpClient::advanced_post( + let response = HttpClient::send_streaming_request( &server_id, + Method::POST, path.as_str(), None, convert_query_params_to_strings(query_params), @@ -238,19 +253,32 @@ pub async fn chat_chat( log::info!("client_id: {}", &client_id); - while let Ok(Some(line)) = lines.next_line().await { - log::info!("Received chat stream line: {}", &line); - if first_log { - log::info!("first stream line: {}", &line); - first_log = false; - } + loop { + match lines.next_line().await { + Ok(Some(line)) => { + log::info!("Received chat stream line: {}", &line); + if first_log { + log::info!("first stream line: {}", &line); + first_log = false; + } - if let Err(err) = app_handle.emit(&client_id, line) { - log::error!("Emit failed: {:?}", err); + if let Err(err) = app_handle.emit(&client_id, line) { + log::error!("Emit failed: {:?}", err); - print!("Error sending message: {:?}", err); + print!("Error sending message: {:?}", err); - let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err)); + let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err)); + } + } + Ok(None) => break, + Err(err) => { + log::error!("Failed to read chat stream: {:?}", err); + let _ = app_handle.emit( + "chat-create-error", + format!("Failed to read chat stream: {:?}", err), + ); + break; + } } } @@ -444,7 +472,7 @@ pub async fn ask_ai( println!("Sending request to {}", &path); - let response = HttpClient::send_request( + let response = HttpClient::send_streaming_request( server_id.as_str(), Method::POST, path.as_str(), @@ -475,12 +503,21 @@ pub async fn ask_ai( ); let mut lines = tokio::io::BufReader::new(reader).lines(); - while let Ok(Some(line)) = lines.next_line().await { - dbg!("Received line: {}", &line); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + dbg!("Received line: {}", &line); - let _ = app_handle.emit(&client_id, line).map_err(|err| { - log::error!("Failed to emit: {:?}", err); - }); + let _ = app_handle.emit(&client_id, line).map_err(|err| { + log::error!("Failed to emit: {:?}", err); + }); + } + Ok(None) => break, + Err(err) => { + log::error!("Failed to read assistant stream: {:?}", err); + break; + } + } } Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dfb695b0..6333d513 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -206,6 +206,10 @@ pub fn run() { get_app_search_source, server::attachment::upload_attachment, server::attachment::get_attachment_by_ids, + server::attachment::fetch_attachment_text, + server::attachment::fetch_attachment_binary, + server::attachment::write_text_file, + server::attachment::write_binary_file, server::attachment::delete_attachment, server::transcription::transcription, server::system_settings::get_system_settings, diff --git a/src-tauri/src/server/attachment.rs b/src-tauri/src/server/attachment.rs index 2b24f9fc..76996e16 100644 --- a/src-tauri/src/server/attachment.rs +++ b/src-tauri/src/server/attachment.rs @@ -2,6 +2,7 @@ use super::servers::{get_server_by_id, get_server_token}; use crate::common::error::serialize_error; use crate::common::http::get_response_body_text; use crate::server::http_client::{HttpClient, HttpRequestError, SendSnafu}; +use base64::{DecodeError, decode}; use reqwest::multipart::{Form, Part}; use serde::Deserialize; use serde::Serialize; @@ -26,6 +27,21 @@ pub struct DeleteAttachmentResponse { pub result: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct FetchAttachmentTextResponse { + pub content: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FetchAttachmentBinaryResponse { + pub content_base64: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WriteTextFileResponse { + pub saved: bool, +} + #[derive(Debug, Snafu, Serialize)] #[snafu(visibility(pub(crate)))] pub(crate) enum AttachmentError { @@ -49,6 +65,11 @@ pub(crate) enum AttachmentError { #[serde(serialize_with = "serialize_error")] source: serde_json::Error, }, + #[snafu(display("decoding base64 failed"))] + Base64DecodingError { + #[serde(serialize_with = "serialize_error")] + source: DecodeError, + }, } #[command] @@ -148,6 +169,64 @@ pub async fn get_attachment_by_ids( serde_json::from_str::(&body).context(JsonDecodingSnafu) } +#[command] +pub async fn fetch_attachment_text( + server_id: String, + path: String, +) -> Result { + let response = HttpClient::get(&server_id, &path, None) + .await + .context(HttpRequestSnafu)?; + + let content = get_response_body_text(response) + .await + .context(HttpRequestSnafu)?; + + Ok(FetchAttachmentTextResponse { content }) +} + +#[command] +pub async fn fetch_attachment_binary( + server_id: String, + path: String, +) -> Result { + let response = HttpClient::get(&server_id, &path, None) + .await + .context(HttpRequestSnafu)?; + + let bytes = response + .bytes() + .await + .context(SendSnafu) + .context(HttpRequestSnafu)?; + + Ok(FetchAttachmentBinaryResponse { + content_base64: base64::encode(bytes), + }) +} + +#[command] +pub async fn write_text_file( + path: PathBuf, + content: String, +) -> Result { + tokio::fs::write(path, content).await.context(IoSnafu)?; + + Ok(WriteTextFileResponse { saved: true }) +} + +#[command] +pub async fn write_binary_file( + path: PathBuf, + content_base64: String, +) -> Result { + let content = decode(content_base64).context(Base64DecodingSnafu)?; + + tokio::fs::write(path, content).await.context(IoSnafu)?; + + Ok(WriteTextFileResponse { saved: true }) +} + #[command] pub async fn delete_attachment(server_id: String, id: String) -> Result { let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None) diff --git a/src-tauri/src/server/http_client.rs b/src-tauri/src/server/http_client.rs index 1dd563bc..0e5230e6 100644 --- a/src-tauri/src/server/http_client.rs +++ b/src-tauri/src/server/http_client.rs @@ -23,6 +23,14 @@ pub(crate) fn new_reqwest_http_client(accept_invalid_certs: bool) -> Client { .expect("Failed to build client") } +pub(crate) fn new_reqwest_streaming_http_client(accept_invalid_certs: bool) -> Client { + Client::builder() + .connect_timeout(Duration::from_secs(30)) + .danger_accept_invalid_certs(accept_invalid_certs) + .build() + .expect("Failed to build streaming client") +} + pub static HTTP_CLIENT: Lazy> = Lazy::new(|| { let allow_self_signature = crate::settings::_get_allow_self_signature( crate::GLOBAL_TAURI_APP_HANDLE @@ -33,6 +41,16 @@ pub static HTTP_CLIENT: Lazy> = Lazy::new(|| { Mutex::new(new_reqwest_http_client(allow_self_signature)) }); +pub static STREAMING_HTTP_CLIENT: Lazy> = Lazy::new(|| { + let allow_self_signature = crate::settings::_get_allow_self_signature( + crate::GLOBAL_TAURI_APP_HANDLE + .get() + .expect("global tauri app store not set") + .clone(), + ); + Mutex::new(new_reqwest_streaming_http_client(allow_self_signature)) +}); + /// Errors that could happen when handling a HTTP request. /// /// `reqwest` uses the same error type `reqwest::Error` for all kinds of @@ -148,15 +166,14 @@ impl HttpClient { Ok(response) } - pub async fn get_request_builder( + async fn get_request_builder_with_client( + client: &Client, method: Method, url: &str, headers: Option>, query_params: Option>, // Add query parameters body: Option, ) -> RequestBuilder { - let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT - // Build the request let mut request_builder = client.request(method.clone(), url); @@ -222,6 +239,30 @@ impl HttpClient { request_builder } + pub async fn get_request_builder( + method: Method, + url: &str, + headers: Option>, + query_params: Option>, // Add query parameters + body: Option, + ) -> RequestBuilder { + let client = HTTP_CLIENT.lock().await; + Self::get_request_builder_with_client(&client, method, url, headers, query_params, body) + .await + } + + async fn get_streaming_request_builder( + method: Method, + url: &str, + headers: Option>, + query_params: Option>, + body: Option, + ) -> RequestBuilder { + let client = STREAMING_HTTP_CLIENT.lock().await; + Self::get_request_builder_with_client(&client, method, url, headers, query_params, body) + .await + } + pub async fn send_request( server_id: &str, method: Method, @@ -267,6 +308,60 @@ impl HttpClient { } } + pub async fn send_streaming_request( + server_id: &str, + method: Method, + path: &str, + custom_headers: Option>, + query_params: Option>, + body: Option, + ) -> Result { + let server = get_server_by_id(server_id).await; + if let Some(s) = server { + let url = HttpClient::join_url(&s.endpoint, path); + let token = get_server_token(server_id) + .await + .map(|t| t.access_token.clone()); + + let mut headers = custom_headers.unwrap_or_default(); + if let Some(t) = token { + headers.insert("X-API-TOKEN".to_string(), t); + } + + let request_builder = Self::get_streaming_request_builder( + method, + &url, + Some(headers), + query_params, + body, + ) + .await; + + let response = match request_builder.send().await { + Ok(response) => response, + Err(e) => { + if e.is_timeout() { + return Err(HttpRequestError::ConnectionTimeout); + } + return Err(HttpRequestError::SendError { source: e }); + } + }; + + log::debug!( + "Streaming request: {}, response status: {:?}, header: {:?}", + &url, + &response.status(), + &response.headers() + ); + + Ok(response) + } else { + Err(HttpRequestError::ServerNotFound { + id: server_id.to_string(), + }) + } + } + // Convenience method for GET requests (as it's the most common) pub async fn get( server_id: &str, @@ -286,24 +381,6 @@ impl HttpClient { HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await } - pub async fn advanced_post( - server_id: &str, - path: &str, - custom_headers: Option>, - query_params: Option>, - body: Option, - ) -> Result { - HttpClient::send_request( - server_id, - Method::POST, - path, - custom_headers, - query_params, - body, - ) - .await - } - // Convenience method for PUT requests #[allow(dead_code)] pub async fn put( diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 34206147..ae948025 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -37,7 +37,10 @@ pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) store.set(SETTINGS_ALLOW_SELF_SIGNATURE, value); let mut guard = http_client::HTTP_CLIENT.lock().await; - *guard = http_client::new_reqwest_http_client(value) + *guard = http_client::new_reqwest_http_client(value); + + let mut streaming_guard = http_client::STREAMING_HTTP_CLIENT.lock().await; + *streaming_guard = http_client::new_reqwest_streaming_http_client(value); } /// Synchronous version of `async get_allow_self_signature()`. diff --git a/src/commands/servers.ts b/src/commands/servers.ts index 0890f4c7..725d1396 100644 --- a/src/commands/servers.ts +++ b/src/commands/servers.ts @@ -357,6 +357,27 @@ export const get_attachment_by_ids = (payload: GetAttachmentByIdsPayload) => { ); }; +export const fetch_attachment_text = (payload: { + serverId: string; + path: string; +}) => { + return invokeWithErrorHandler<{ content: string }>("fetch_attachment_text", { + ...payload, + }); +}; + +export const fetch_attachment_binary = (payload: { + serverId: string; + path: string; +}) => { + return invokeWithErrorHandler<{ content_base64: string }>( + "fetch_attachment_binary", + { + ...payload, + } + ); +}; + export const delete_attachment = (payload: DeleteAttachmentPayload) => { return invokeWithErrorHandler("delete_attachment", { ...payload }); }; diff --git a/src/commands/system.ts b/src/commands/system.ts index f1893359..24b4bdba 100644 --- a/src/commands/system.ts +++ b/src/commands/system.ts @@ -34,4 +34,18 @@ export function show_check(): Promise { export function hide_check(): Promise { return invoke('hide_check'); -} \ No newline at end of file +} + +export function write_text_file(payload: { + path: string; + content: string; +}): Promise<{ saved: boolean }> { + return invoke('write_text_file', payload); +} + +export function write_binary_file(payload: { + path: string; + contentBase64: string; +}): Promise<{ saved: boolean }> { + return invoke('write_binary_file', payload); +} diff --git a/src/components/Assistant/Chat.tsx b/src/components/Assistant/Chat.tsx index f6ed1682..c4e6f156 100644 --- a/src/components/Assistant/Chat.tsx +++ b/src/components/Assistant/Chat.tsx @@ -24,6 +24,11 @@ import { useAppStore } from "@/stores/appStore"; import { useSearchStore } from "@/stores/searchStore"; import { useAuthStore } from "@/stores/authStore"; import Splash from "./Splash"; +import { DeepResearchCancelDialog } from "@/components/ChatMessage/DeepResearch/DeepResearchCancelDialog"; +import { + normalizeResearchReportData, + parseReplyEndPayload, +} from "@/components/ChatMessage/DeepResearch/payload"; interface ChatAIProps { isSearchActive?: boolean; @@ -52,6 +57,7 @@ export interface SendMessageParams { export interface ChatAIRef { init: (params: SendMessageParams) => void; cancelChat: () => void; + requestCancelChat: () => void; clearChat: () => void; onSelectChat: (chat: Chat) => void; } @@ -82,6 +88,7 @@ const ChatAI = memo( useImperativeHandle(ref, () => ({ init: init, cancelChat: () => cancelChat(activeChat), + requestCancelChat, clearChat: clearChat, onSelectChat: onSelectChat, })); @@ -102,6 +109,8 @@ const ChatAI = memo( const [activeChat, setActiveChat] = useState(); const [timedoutShow, setTimedoutShow] = useState(false); + const [deepResearchCancelDialogOpen, setDeepResearchCancelDialogOpen] = + useState(false); const curIdRef = useRef(""); const curSessionIdRef = useRef(""); @@ -160,6 +169,8 @@ const ChatAI = memo( deep_read, think, response, + deepResearch, + replyEnd, }, handlers, clearAllChunkData, @@ -209,6 +220,155 @@ const ChatAI = memo( getChatHistoryChatPage ); + const hasRunningDeepResearch = + deepResearch.length > 0 && replyEnd.length === 0 && !curChatEnd; + + const hasDeepResearchRef = useRef(false); + + useEffect(() => { + if (deepResearch.length > 0) { + hasDeepResearchRef.current = true; + } + }, [deepResearch.length]); + + const buildDeepResearchPatchedChat = useCallback( + (current?: Chat): Chat | undefined => { + if (!deepResearch.length && !replyEnd.length) return current; + if (!current) return current; + + const latestDeepResearch = [...deepResearch]; + const latestReplyEnd = replyEnd[replyEnd.length - 1]; + const messageId = + latestDeepResearch[0]?.message_id || + latestReplyEnd?.message_id || + curIdRef.current; + const replyPayload = parseReplyEndPayload( + latestReplyEnd?.message_chunk + ); + const reportData = normalizeResearchReportData( + latestDeepResearch + .slice() + .reverse() + .find((chunk) => chunk.chunk_type === "research_reporter_end") + ?.message_chunk + ); + const detailsPatch: any[] = []; + + if (latestDeepResearch.length) { + detailsPatch.push({ + type: "deep_research", + payload: latestDeepResearch, + }); + } + + if (replyPayload) { + detailsPatch.push({ type: "reply_end", payload: replyPayload }); + } + + if (!detailsPatch.length || !messageId) return current; + + const messages = current.messages || []; + let targetIndex = messages.findIndex( + (item) => + item._id === messageId && item._source?.type === "assistant" + ); + + if (targetIndex === -1) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?._source?.type === "assistant") { + targetIndex = index; + break; + } + } + } + + if (targetIndex === -1) { + return { + ...current, + messages: [ + ...messages, + { + _id: messageId, + _source: { + type: "assistant", + message: "", + question: Question, + session_id: current._id || curSessionIdRef.current, + details: detailsPatch, + payload: reportData, + }, + }, + ], + }; + } + + const target = messages[targetIndex]; + const details: any[] = Array.isArray(target._source?.details) + ? target._source.details + : []; + const nextDetails = details.filter( + (item) => item?.type !== "deep_research" && item?.type !== "reply_end" + ); + nextDetails.push(...detailsPatch); + + const nextMessage = { + ...target, + _source: { + ...target._source, + details: nextDetails, + payload: reportData || target._source?.payload, + }, + }; + const nextMessages = [...messages]; + nextMessages[targetIndex] = nextMessage; + + return { + ...current, + messages: nextMessages, + }; + }, + [Question, deepResearch, replyEnd] + ); + + const patchActiveDeepResearchMessage = useCallback(() => { + setActiveChat((current) => buildDeepResearchPatchedChat(current)); + }, [buildDeepResearchPatchedChat]); + + const refreshChatAfterReplyEnd = useCallback(async () => { + if (!hasDeepResearchRef.current) return; + + patchActiveDeepResearchMessage(); + + const sessionId = curSessionIdRef.current || activeChat?._id; + if (!sessionId) return; + + window.setTimeout(async () => { + const response = await openSessionChat({ _id: sessionId }); + if (response) { + chatHistory(response, async () => { + setActiveChat((current) => buildDeepResearchPatchedChat(current)); + await clearAllChunkData(); + hasDeepResearchRef.current = false; + }); + } + }, 300); + }, [ + activeChat?._id, + chatHistory, + clearAllChunkData, + openSessionChat, + patchActiveDeepResearchMessage, + ]); + + const requestCancelChat = useCallback(() => { + if (hasRunningDeepResearch) { + setDeepResearchCancelDialogOpen(true); + return; + } + + cancelChat(activeChat); + }, [activeChat, cancelChat, hasRunningDeepResearch]); + const { dealMsg } = useMessageHandler( curIdRef, curSessionIdRef, @@ -216,7 +376,8 @@ const ChatAI = memo( setTimedoutShow, (chat) => cancelChat(chat || activeChat), setLoadingStep, - handlers + handlers, + refreshChatAfterReplyEnd ); const updateDealMsg = useCallback( @@ -414,12 +575,16 @@ const ChatAI = memo( deep_read={deep_read} think={think} response={response} + deepResearch={deepResearch} + replyEnd={replyEnd} loadingStep={loadingStep} timedoutShow={timedoutShow} Question={Question} handleSendMessage={(message) => handleSendMessage(activeChat, { message }) } + onCancel={() => cancelChat(activeChat)} + onRequestDeepResearchCancel={requestCancelChat} getFileUrl={getFileUrl} formatUrl={formatUrl} curIdRef={curIdRef} @@ -437,6 +602,11 @@ const ChatAI = memo( }} /> )} + cancelChat(activeChat)} + /> ); diff --git a/src/components/Assistant/ChatContent.tsx b/src/components/Assistant/ChatContent.tsx index 844722d9..a17e24d4 100644 --- a/src/components/Assistant/ChatContent.tsx +++ b/src/components/Assistant/ChatContent.tsx @@ -1,6 +1,5 @@ import { useRef, useEffect, UIEvent, useState } from "react"; import { useTranslation } from "react-i18next"; - import { ChatMessage } from "@/components/ChatMessage"; import { Greetings } from "./Greetings"; import AttachmentList from "@/components/Assistant/AttachmentList"; @@ -13,6 +12,10 @@ import { useChatStore } from "@/stores/chatStore"; import { useWebConfigStore } from "@/stores/webConfigStore"; import { useAppStore } from "@/stores/appStore"; import { NoResults } from "../Common/UI/NoResults"; +import { + DeepResearchPanel, + type DeepResearchPanelPayload, +} from "@/components/ChatMessage/DeepResearch/DeepResearchPanel"; interface ChatContentProps { activeChat?: Chat; @@ -23,10 +26,14 @@ interface ChatContentProps { deep_read?: IChunkData; think?: IChunkData; response?: IChunkData; + deepResearch?: IChunkData[]; + replyEnd?: IChunkData[]; loadingStep?: Record; timedoutShow: boolean; Question: string; handleSendMessage: (content: string, newChat?: Chat) => void; + onCancel?: () => void; + onRequestDeepResearchCancel?: () => void; getFileUrl: (path: string) => string; formatUrl?: (data: any) => string; curIdRef: React.MutableRefObject; @@ -41,10 +48,14 @@ export const ChatContent = ({ deep_read, think, response, + deepResearch, + replyEnd, loadingStep, timedoutShow, Question, handleSendMessage, + onCancel, + onRequestDeepResearchCancel, formatUrl, }: ChatContentProps) => { const { t } = useTranslation(); @@ -57,6 +68,8 @@ export const ChatContent = ({ const uploadAttachments = useChatStore((state) => state.uploadAttachments); const curChatEnd = useChatStore((state) => state.curChatEnd); + const [deepResearchPanel, setDeepResearchPanel] = + useState(null); const messagesEndRef = useRef(null); @@ -70,6 +83,10 @@ export const ChatContent = ({ setCurrentSessionId(activeChat?._id); }, [activeChat?._id]); + useEffect(() => { + setDeepResearchPanel(null); + }, [activeChat?._id]); + useEffect(() => { scrollToBottom(); }, [ @@ -80,6 +97,7 @@ export const ChatContent = ({ deep_read?.message_chunk, think?.message_chunk, response?.message_chunk, + deepResearch?.length, curChatEnd, ]); @@ -103,81 +121,120 @@ export const ChatContent = ({ const { isTauri } = useAppStore(); const { disabled } = useWebConfigStore(); + const closeDeepResearchPanel = () => setDeepResearchPanel(null); + return (
{!isTauri && disabled ? ( ) : ( <> -
- {(!activeChat || activeChat?.messages?.length === 0) && - !visibleStartPage && } +
+
+ {(!activeChat || activeChat?.messages?.length === 0) && + !visibleStartPage && } - {activeChat?.messages?.map((message, index) => ( - - ))} + {activeChat?.messages?.map((message, index) => ( + { + setDeepResearchPanel((current) => { + return current?.viewKey === payload.viewKey + ? payload + : current; + }); + }} + /> + ))} - {(!curChatEnd || - query_intent || - tools || - fetch_source || - pick_source || - deep_read || - think || - response) && - activeChat?._source?.id ? ( - - ) : null} + {(!curChatEnd || + query_intent || + tools || + fetch_source || + pick_source || + deep_read || + think || + response || + (deepResearch && deepResearch.length > 0)) && + activeChat?._source?.id ? ( + { + setDeepResearchPanel((current) => { + return current?.viewKey === payload.viewKey + ? payload + : current; + }); + }} + /> + ) : null} - {timedoutShow ? ( - - ) : null} -
+ {timedoutShow ? ( + + ) : null} +
+
+ + {deepResearchPanel && ( +
+ +
+ )}
{uploadAttachments.length > 0 && ( diff --git a/src/components/ChatMessage/DeepResearch/DeepResearchCancelDialog.tsx b/src/components/ChatMessage/DeepResearch/DeepResearchCancelDialog.tsx new file mode 100644 index 00000000..6721eb00 --- /dev/null +++ b/src/components/ChatMessage/DeepResearch/DeepResearchCancelDialog.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type TFunction } from "i18next"; +import { X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface DeepResearchCancelDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm?: () => void; + t?: TFunction; +} + +export function DeepResearchCancelDialog({ + open, + onOpenChange, + onConfirm, + t: tProp, +}: DeepResearchCancelDialogProps) { + const [portalContainer, setPortalContainer] = useState( + null + ); + const { t: tOriginal } = useTranslation(); + const t = tProp || tOriginal; + + const confirmCancelResearch = () => { + onOpenChange(false); + onConfirm?.(); + }; + + return ( +
+ {portalContainer && ( + + + +
+ + + {t("deepResearch.cancelDialog.title")} + + + {t("deepResearch.cancelDialog.description")} + + +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/ChatMessage/DeepResearch/DeepResearchLoadingState.tsx b/src/components/ChatMessage/DeepResearch/DeepResearchLoadingState.tsx new file mode 100644 index 00000000..fa2fb71e --- /dev/null +++ b/src/components/ChatMessage/DeepResearch/DeepResearchLoadingState.tsx @@ -0,0 +1,27 @@ +import loadingSvg from "./loading.svg"; +import loadFailedSvg from "./load-failed.svg"; + +interface DeepResearchLoadingStateProps { + label: string; + variant?: "loading" | "failed"; +} + +export const DeepResearchLoadingState = ({ + label, + variant = "loading", +}: DeepResearchLoadingStateProps) => { + const imageSrc = variant === "failed" ? loadFailedSvg : loadingSvg; + + return ( +
+ +
+ {label} +
+
+ ); +}; diff --git a/src/components/ChatMessage/DeepResearch/DeepResearchPanel.tsx b/src/components/ChatMessage/DeepResearch/DeepResearchPanel.tsx new file mode 100644 index 00000000..99b8e43d --- /dev/null +++ b/src/components/ChatMessage/DeepResearch/DeepResearchPanel.tsx @@ -0,0 +1,414 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Download, X } from "lucide-react"; +import clsx from "clsx"; + +import { Post } from "@/api/axiosRequest"; +import type { IChunkData } from "@/types/chat"; +import { useAppStore } from "@/stores/appStore"; +import platformAdapter from "@/utils/platformAdapter"; +import { ResearchReportContent } from "./ResearchReportContent"; +import { ResearchSearchResultsContent } from "./ResearchSearchResultsContent"; +import { ResearchStepsContent } from "./ResearchStepsContent"; +import { fetchReportBlob, fetchReportText, toServerPath } from "./reportContent"; +import { + buildSearchHits, + buildStatuses, + buildSteps, + deriveDeepResearchState, +} from "./deriveState"; +import { normalizeResearchReportData } from "./payload"; +import { resolveReportUrl } from "./resolveReportUrl"; +import type { + DeepResearchEndChunk, + ResearchReportData, + StepItem, + StepSearchHit, +} from "./types"; + +export type DeepResearchTabKey = "report" | "steps" | "searchResults"; + +export interface DeepResearchPanelPayload { + viewKey: string; + chunks: IChunkData[]; + defaultTab: DeepResearchTabKey; + reportData?: ResearchReportData; + serverId?: string; + endChunk?: DeepResearchEndChunk; + question?: string; + theme?: "light" | "dark"; + language?: string; +} + +export interface DeepResearchViewData { + steps: StepItem[]; + searchHits: StepSearchHit[]; + reportData?: ResearchReportData; + serverId?: string; + endChunk?: DeepResearchEndChunk; + question?: string; + isEnd: boolean; + plannerStatus: ReturnType["plannerStatus"]; + executionStatus: ReturnType["executionStatus"]; + reportStatus: ReturnType["reportStatus"]; +} + +const sanitizeFilename = (value: string) => { + return value.replace(/[\\/:*?"<>|]+/g, "_").trim(); +}; + +const ensureExtension = (filename: string, extension: string) => { + return filename.toLowerCase().endsWith(extension) + ? filename + : `${filename}${extension}`; +}; + +const getReportExtension = (format?: string) => { + if (format === "html") return ".html"; + if (format === "pdf") return ".pdf"; + return ".md"; +}; + +const getReportMimeType = (format?: string) => { + if (format === "html") return "text/html;charset=utf-8"; + if (format === "pdf") return "application/pdf"; + return "text/markdown;charset=utf-8"; +}; + +const arrayBufferToBase64 = (buffer: ArrayBuffer) => { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]); + } + return window.btoa(binary); +}; + +export const buildDeepResearchViewFromChunks = ( + chunks: IChunkData[], + reportDataOverride?: ResearchReportData, + endChunk?: DeepResearchEndChunk, + question?: string, + serverId?: string +): DeepResearchViewData => { + const state = deriveDeepResearchState(chunks); + const steps = buildSteps(state); + const searchHits = buildSearchHits(state); + const statuses = buildStatuses(state, steps); + const reportData = + normalizeResearchReportData(reportDataOverride) || + state.deepResearchReportData; + const isEnd = + !!endChunk?.payload?.reason || + state.deepResearchReporterFinished || + !!reportData?.url || + !!reportData?.attachment || + !!reportData?.content; + + return { + steps, + searchHits, + reportData, + serverId, + endChunk, + question, + isEnd, + ...statuses, + }; +}; + +export interface DeepResearchPanelHistoryPayload + extends DeepResearchPanelPayload {} + +export const buildDeepResearchPanelFromHistory = async ( + sessionId: string, + messageId: string, + defaultTab: DeepResearchTabKey, + language?: string +): Promise => { + const result = await Post(`/chat/${sessionId}/_open`, {}); + const res: any = result?.[1]; + const messages: any[] = res?.messages || res?.hits?.hits || []; + const msg = messages.find((m) => m?._id === messageId); + const details: any[] = msg?._source?.details || []; + const detail = details.find((d) => d?.type === "deep_research"); + const chunks: IChunkData[] = Array.isArray(detail?.payload) + ? detail.payload + : []; + const endDetail = details.find((d) => d?.type === "reply_end"); + + return { + viewKey: `${sessionId}:${messageId}`, + chunks, + defaultTab, + reportData: normalizeResearchReportData(msg?._source?.payload), + serverId: msg?._source?.server_id, + endChunk: endDetail, + question: msg?._source?.question, + language, + }; +}; + +interface DeepResearchPanelProps { + payload: DeepResearchPanelPayload; + onClose?: () => void; + className?: string; +} + +export function DeepResearchPanel({ + payload, + onClose, + className, +}: DeepResearchPanelProps) { + const { t, i18n } = useTranslation(); + const addError = useAppStore((state) => state.addError); + const withVisibility = useAppStore((state) => state.withVisibility); + const [activeTab, setActiveTab] = useState( + payload.defaultTab + ); + const [mountedTabs, setMountedTabs] = useState< + Record + >({ + report: payload.defaultTab === "report", + steps: payload.defaultTab === "steps", + searchResults: payload.defaultTab === "searchResults", + }); + + useEffect(() => { + setActiveTab(payload.defaultTab); + setMountedTabs({ + report: payload.defaultTab === "report", + steps: payload.defaultTab === "steps", + searchResults: payload.defaultTab === "searchResults", + }); + }, [payload.viewKey, payload.defaultTab]); + + useEffect(() => { + setMountedTabs((current) => + current[activeTab] ? current : { ...current, [activeTab]: true } + ); + }, [activeTab]); + + useEffect(() => { + if (payload.language) { + i18n.changeLanguage(payload.language); + } + }, [i18n, payload.language]); + + const view = useMemo( + () => + buildDeepResearchViewFromChunks( + payload.chunks, + payload.reportData, + payload.endChunk, + payload.question, + payload.serverId + ), + [ + payload.chunks, + payload.endChunk, + payload.question, + payload.reportData, + payload.serverId, + ] + ); + + const reportUrl = resolveReportUrl( + view.reportData?.url || view.reportData?.attachment + ); + + const handleDownloadReport = useCallback(async () => { + if (!reportUrl || !view.reportData) return; + + try { + const reportData = view.reportData; + const extension = getReportExtension(reportData.format); + const rawTitle = reportData.title || t("deepResearch.report.defaultTitle"); + const filename = ensureExtension( + sanitizeFilename(rawTitle) || t("deepResearch.report.defaultTitle"), + extension + ); + const isPdf = reportData.format === "pdf"; + + if (platformAdapter.isTauri()) { + const path = await withVisibility(() => + platformAdapter.saveFileDialog({ + defaultPath: filename, + filters: [ + { + name: + reportData.format === "html" + ? "HTML" + : reportData.format === "pdf" + ? "PDF" + : "Markdown", + extensions: [extension.slice(1)], + }, + ], + }) + ); + + if (!path) return; + + if (isPdf) { + const contentBase64 = view.serverId + ? ( + await platformAdapter.commands<{ + content_base64: string; + }>("fetch_attachment_binary", { + serverId: view.serverId, + path: toServerPath(reportUrl), + }) + ).content_base64 + : arrayBufferToBase64( + await (await fetchReportBlob(reportUrl, view.serverId)).arrayBuffer() + ); + await platformAdapter.commands("write_binary_file", { + path, + contentBase64, + }); + } else { + const content = await fetchReportText(reportUrl, view.serverId); + await platformAdapter.commands("write_text_file", { + path, + content, + }); + } + addError(t("deepResearch.report.downloadSuccess"), "info"); + return; + } + + const blob = isPdf + ? await fetchReportBlob(reportUrl, view.serverId) + : new Blob([await fetchReportText(reportUrl, view.serverId)], { + type: getReportMimeType(reportData.format), + }); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(objectUrl); + addError(t("deepResearch.report.downloadSuccess"), "info"); + } catch (e) { + console.error("download research report failed", e); + addError(t("deepResearch.report.downloadFailed"), "error"); + } + }, [addError, reportUrl, t, view.reportData, view.serverId, withVisibility]); + + const tabs: { key: DeepResearchTabKey; label: string }[] = useMemo( + () => [ + { key: "report", label: t("deepResearch.tab.report") }, + { key: "steps", label: t("deepResearch.tab.steps") }, + { key: "searchResults", label: t("deepResearch.tab.searchResults") }, + ], + [t] + ); + + return ( +
+
+
+
+ {tabs.map((tab) => ( + + ))} +
+ +
+ {activeTab === "report" && reportUrl && ( + + )} + {onClose && ( + + )} +
+
+ +
+ {mountedTabs.report && ( + + )} + {mountedTabs.steps && ( + + )} + {mountedTabs.searchResults && ( + + )} +
+
+
+ ); +} diff --git a/src/components/ChatMessage/DeepResearch/PdfReportViewer.tsx b/src/components/ChatMessage/DeepResearch/PdfReportViewer.tsx new file mode 100644 index 00000000..b42d66f3 --- /dev/null +++ b/src/components/ChatMessage/DeepResearch/PdfReportViewer.tsx @@ -0,0 +1,75 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Document, Page, pdfjs } from "react-pdf"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; + +pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + +interface PdfReportViewerProps { + blob: Blob; +} + +export function PdfReportViewer({ blob }: PdfReportViewerProps) { + const containerRef = useRef(null); + const [numPages, setNumPages] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); + const file = useMemo(() => blob, [blob]); + + useEffect(() => { + const node = containerRef.current; + if (!node) return; + + const updateWidth = () => { + setContainerWidth(node.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver(() => { + updateWidth(); + }); + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }, []); + + const pageWidth = useMemo(() => { + if (!containerWidth) return undefined; + return Math.max(containerWidth - 32, 280); + }, [containerWidth]); + + return ( +
+
+ { + setNumPages(pdf.numPages); + }} + onLoadError={(error) => { + console.error("load pdf document failed", error); + }} + className="flex w-full flex-col items-center gap-6" + > + {Array.from({ length: numPages }, (_, index) => ( + + ))} + +
+
+ ); +} diff --git a/src/components/ChatMessage/DeepResearch/ResearchReportContent.tsx b/src/components/ChatMessage/DeepResearch/ResearchReportContent.tsx new file mode 100644 index 00000000..e0b9dbb2 --- /dev/null +++ b/src/components/ChatMessage/DeepResearch/ResearchReportContent.tsx @@ -0,0 +1,234 @@ +import { + memo, + useEffect, + useMemo, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { type TFunction } from "i18next"; + +import Markdown from "@/components/ChatMessage/Markdown"; +import { useAppStore } from "@/stores/appStore"; +import { useConnectStore } from "@/stores/connectStore"; +import { DeepResearchLoadingState } from "./DeepResearchLoadingState"; +import type { DeepResearchEndChunk, ResearchReportData } from "./types"; +import { PdfReportViewer } from "./PdfReportViewer"; +import { resolveReportUrl } from "./resolveReportUrl"; +import { + fetchReportBlob, + fetchReportText, + getCachedReportBlob, + getCachedReportText, + injectBaseTag, +} from "./reportContent"; + +export interface ResearchReportContentProps { + /** 已抓取的报告正文(markdown 或 html 文本)。优先于 data.url 渲染。 */ + content?: string; + data?: ResearchReportData; + endChunk?: DeepResearchEndChunk; + serverId?: string; + formatUrl?: (data: any) => string; + t?: TFunction; +} + +/** + * 「报告」tab:渲染最终研究报告。 + * - html 格式 → iframe + * - markdown 格式 → App Markdown(content 由窗口页抓取后传入) + */ +const ResearchReportContentComponent = ({ + content, + data, + endChunk, + serverId: serverIdProp, + formatUrl, + t: tProp, +}: ResearchReportContentProps) => { + const { t: tOriginal } = useTranslation(); + const t = tProp || tOriginal; + const currentServiceId = useConnectStore((state) => state.currentService?.id); + const cloudServiceId = useConnectStore((state) => state.cloudSelectService?.id); + const serverId = serverIdProp || currentServiceId || cloudServiceId; + const endpointHttp = useAppStore((state) => state.endpoint_http); + const resolvedUrl = resolveReportUrl(data?.url || data?.attachment, formatUrl); + const embeddedContent = data?.content; + const [remoteContent, setRemoteContent] = useState(() => + resolvedUrl ? getCachedReportText(resolvedUrl, serverId) : undefined + ); + const [isFetching, setIsFetching] = useState(false); + const [fetchError, setFetchError] = useState(); + const [pdfBlob, setPdfBlob] = useState(); + const endReason = endChunk?.payload?.reason; + + useEffect(() => { + if (content || embeddedContent) { + setRemoteContent(undefined); + return; + } + + if (!resolvedUrl || data?.format === "pdf") { + setRemoteContent(undefined); + return; + } + + setRemoteContent(getCachedReportText(resolvedUrl, serverId)); + }, [content, data?.format, embeddedContent, resolvedUrl, serverId]); + + useEffect(() => { + if (data?.format === "pdf") { + setRemoteContent(undefined); + setFetchError(undefined); + setIsFetching(false); + return; + } + + if (content || embeddedContent || !resolvedUrl) { + setRemoteContent(undefined); + setFetchError(undefined); + setIsFetching(false); + return; + } + + const cachedContent = getCachedReportText(resolvedUrl, serverId); + if (cachedContent) { + setRemoteContent(cachedContent); + setFetchError(undefined); + setIsFetching(false); + return; + } + + let cancelled = false; + setIsFetching(true); + setFetchError(undefined); + + (async () => { + try { + const text = await fetchReportText(resolvedUrl, serverId); + if (cancelled) return; + + setRemoteContent(text); + } catch (e) { + if (cancelled) return; + console.error("fetch research report failed", e); + setFetchError(e instanceof Error ? e.message : String(e)); + } finally { + if (!cancelled) setIsFetching(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [content, data?.format, embeddedContent, endpointHttp, resolvedUrl, serverId]); + + useEffect(() => { + if (data?.format !== "pdf" || !resolvedUrl) { + setPdfBlob(undefined); + return; + } + + const cachedBlob = getCachedReportBlob(resolvedUrl, serverId); + if (cachedBlob) { + setPdfBlob(cachedBlob); + setFetchError(undefined); + setIsFetching(false); + return; + } + + let cancelled = false; + setIsFetching(true); + setFetchError(undefined); + + (async () => { + try { + const blob = await fetchReportBlob(resolvedUrl, serverId); + if (cancelled) return; + + setPdfBlob(blob); + } catch (e) { + if (cancelled) return; + console.error("fetch research report pdf failed", e); + setFetchError(e instanceof Error ? e.message : String(e)); + } finally { + if (!cancelled) setIsFetching(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [data?.format, endpointHttp, resolvedUrl, serverId]); + + const reportContent = content || embeddedContent || remoteContent; + const htmlContent = useMemo( + () => + data?.format === "html" && reportContent + ? injectBaseTag(reportContent, resolvedUrl) + : reportContent, + [data?.format, reportContent, resolvedUrl] + ); + + if (!content && !data && endReason === "user_cancelled") { + return ( + + ); + } + + if (!content && !data && (endReason === "error" || endReason === "timeout")) { + return ( + + ); + } + + if (!content && !data) { + return ( + + ); + } + + return ( +
+ {data?.format === "pdf" && pdfBlob && } + + {reportContent && + (data?.format === "html" ? ( +