diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 60b4b947..3bd6ddf9 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -12,6 +12,10 @@ on: - synchronize - reopened +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'run-pipeline') && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'run-pipeline') }} + env: RETENTION_INTERMEDIATE_ASSETS: 1 RETENTION_RELEASE_ASSETS: 30 @@ -37,6 +41,8 @@ jobs: id: determine env: EVENT_NAME: ${{ github.event_name }} + PR_ACTION: ${{ github.event.action }} + ACTION_LABEL_NAME: ${{ github.event.label.name }} REF: ${{ github.ref }} PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} @@ -55,6 +61,11 @@ jobs: is_internal_pr=true fi + has_run_pipeline_label=false + if [[ " $PR_LABELS " == *" run-pipeline "* ]]; then + has_run_pipeline_label=true + fi + if [[ "$REF" == refs/tags/v* ]]; then is_release=true build_enabled=true @@ -65,13 +76,21 @@ jobs: build_enabled=true artifact_retention_days=7 skip_reason="" - elif [[ "$EVENT_NAME" == "pull_request" && " $PR_LABELS " == *" run-pipeline "* ]]; then + elif [[ "$EVENT_NAME" == "pull_request" && "$PR_ACTION" == "labeled" && "$ACTION_LABEL_NAME" == "run-pipeline" ]]; then is_labeled_pr=true is_pr_build=true build_enabled=true artifact_retention_days=3 skip_reason="" - elif [[ "$EVENT_NAME" == "pull_request" && " $PR_LABELS " != *" run-pipeline "* ]]; then + elif [[ "$EVENT_NAME" == "pull_request" && "$PR_ACTION" != "labeled" && "$has_run_pipeline_label" == "true" ]]; then + is_labeled_pr=true + is_pr_build=true + build_enabled=true + artifact_retention_days=3 + skip_reason="" + elif [[ "$EVENT_NAME" == "pull_request" && "$PR_ACTION" == "labeled" ]]; then + skip_reason="Build disabled: label '${ACTION_LABEL_NAME}' is not 'run-pipeline'." + elif [[ "$EVENT_NAME" == "pull_request" && "$has_run_pipeline_label" != "true" ]]; then skip_reason="Build disabled: PR does not have the required 'run-pipeline' label." fi @@ -220,29 +239,29 @@ jobs: rust_target: 'aarch64-apple-darwin' dotnet_runtime: 'osx-arm64' dotnet_name_postfix: '-aarch64-apple-darwin' - tauri_bundle: 'dmg,updater' + tauri_bundle: 'dmg,app,updater' tauri_bundle_pr: 'dmg' - platform: 'macos-latest' # for Intel-based macOS rust_target: 'x86_64-apple-darwin' dotnet_runtime: 'osx-x64' dotnet_name_postfix: '-x86_64-apple-darwin' - tauri_bundle: 'dmg,updater' + tauri_bundle: 'dmg,app,updater' tauri_bundle_pr: 'dmg' - platform: 'ubuntu-22.04' # for x86-based Linux rust_target: 'x86_64-unknown-linux-gnu' dotnet_runtime: 'linux-x64' dotnet_name_postfix: '-x86_64-unknown-linux-gnu' - tauri_bundle: 'appimage,deb,updater' - tauri_bundle_pr: 'appimage,deb' + tauri_bundle: 'appimage,updater' + tauri_bundle_pr: 'appimage' - platform: 'ubuntu-22.04-arm' # for ARM-based Linux rust_target: 'aarch64-unknown-linux-gnu' dotnet_runtime: 'linux-arm64' dotnet_name_postfix: '-aarch64-unknown-linux-gnu' - tauri_bundle: 'appimage,deb,updater' - tauri_bundle_pr: 'appimage,deb' + tauri_bundle: 'appimage,updater' + tauri_bundle_pr: 'appimage' - platform: 'windows-latest' # for x86-based Windows rust_target: 'x86_64-pc-windows-msvc' @@ -685,11 +704,9 @@ jobs: uses: actions/cache@v4 with: path: | - ~/.cargo/bin ~/.cargo/git/db/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ - ~/.rustup/toolchains runtime/target key: target-${{ matrix.dotnet_runtime }}-rust-${{ env.RUST_VERSION }} @@ -699,42 +716,64 @@ jobs: with: toolchain: ${{ env.RUST_VERSION }} targets: ${{ matrix.rust_target }} + + - name: Cache Tauri CLI + uses: actions/cache@v4 + with: + path: ~/.cargo-tauri-cli + key: tauri-cli-v2-${{ runner.os }}-${{ runner.arch }} - name: Setup dependencies (Ubuntu-specific, x86) if: matrix.platform == 'ubuntu-22.04' && contains(matrix.rust_target, 'x86_64') run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf libfuse2 + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 xdg-utils gstreamer1.0-plugins-base gstreamer1.0-plugins-good - name: Setup dependencies (Ubuntu-specific, ARM) if: matrix.platform == 'ubuntu-22.04-arm' && contains(matrix.rust_target, 'aarch64') run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf libfuse2 + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 xdg-utils gstreamer1.0-plugins-base gstreamer1.0-plugins-good - name: Setup Tauri (Unix) if: matrix.platform != 'windows-latest' run: | - if ! cargo tauri --version > /dev/null 2>&1; then - cargo install --version 1.6.2 tauri-cli + echo "$HOME/.cargo-tauri-cli/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.cargo-tauri-cli/bin:$PATH" + + if ! cargo tauri --version 2>/dev/null | grep -Eq '^tauri-cli 2\.'; then + cargo install tauri-cli --version "^2.11.0" --locked --force --root "$HOME/.cargo-tauri-cli" else - echo "Tauri is already installed" + echo "Tauri CLI v2 is already installed" fi - name: Setup Tauri (Windows) if: matrix.platform == 'windows-latest' run: | - if (-not (cargo tauri --version 2>$null)) { - cargo install --version 1.6.2 tauri-cli + "$env:USERPROFILE\.cargo-tauri-cli\bin" >> $env:GITHUB_PATH + $env:PATH = "$env:USERPROFILE\.cargo-tauri-cli\bin;$env:PATH" + + $tauriVersion = cargo tauri --version 2>$null + if (-not $tauriVersion -or $tauriVersion -notmatch '^tauri-cli 2\.') { + cargo install tauri-cli --version "^2.11.0" --locked --force --root "$env:USERPROFILE\.cargo-tauri-cli" } else { - Write-Output "Tauri is already installed" + Write-Output "Tauri CLI v2 is already installed" } - name: Delete previous artifact, which may exist due to caching (macOS) if: startsWith(matrix.platform, 'macos') run: | - rm -f runtime/target/${{ matrix.rust_target }}/release/bundle/dmg/MindWork AI Studio_*.dmg - rm -f runtime/target/${{ matrix.rust_target }}/release/bundle/macos/MindWork AI Studio.app.tar.gz* + dmg_dir="runtime/target/${{ matrix.rust_target }}/release/bundle/dmg" + macos_dir="runtime/target/${{ matrix.rust_target }}/release/bundle/macos" + + if [ -d "$dmg_dir" ]; then + find "$dmg_dir" -maxdepth 1 -name 'MindWork AI Studio_*.dmg' -delete + fi + + if [ -d "$macos_dir" ]; then + find "$macos_dir" -maxdepth 1 -name '*.app' -exec rm -rf {} + + find "$macos_dir" -maxdepth 1 -name '*.app.tar.gz*' -delete + fi - name: Delete previous artifact, which may exist due to caching (Windows - MSI) if: startsWith(matrix.platform, 'windows') && contains(matrix.tauri_bundle, 'msi') @@ -748,16 +787,11 @@ jobs: rm -Force "runtime/target/${{ matrix.rust_target }}/release/bundle/nsis/MindWork AI Studio_*.exe" -ErrorAction SilentlyContinue rm -Force "runtime/target/${{ matrix.rust_target }}/release/bundle/nsis/MindWork AI Studio*nsis.zip*" -ErrorAction SilentlyContinue - - name: Delete previous artifact, which may exist due to caching (Linux - Debian Package) - if: startsWith(matrix.platform, 'ubuntu') && contains(matrix.tauri_bundle, 'deb') - run: | - rm -f runtime/target/${{ matrix.rust_target }}/release/bundle/deb/mind-work-ai-studio_*.deb - - name: Delete previous artifact, which may exist due to caching (Linux - AppImage) if: startsWith(matrix.platform, 'ubuntu') && contains(matrix.tauri_bundle, 'appimage') run: | - rm -f runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/mind-work-ai-studio_*.AppImage - rm -f runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/mind-work-ai-studio*AppImage.tar.gz* + rm -f runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/*.AppImage + rm -f runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/*.AppImage.tar.gz* - name: Build Tauri project (Unix) if: matrix.platform != 'windows-latest' @@ -766,17 +800,39 @@ jobs: PRIVATE_PUBLISH_KEY_PASSWORD: ${{ secrets.PRIVATE_PUBLISH_KEY_PASSWORD }} run: | bundles="${{ matrix.tauri_bundle }}" + tauri_config_args=() if [ "${{ needs.determine_run_mode.outputs.is_pr_build }}" = "true" ]; then echo "Running PR test build without updater bundle signing" bundles="${{ matrix.tauri_bundle_pr }}" + tauri_config_args=(--config '{"bundle":{"createUpdaterArtifacts":false}}') else - export TAURI_PRIVATE_KEY="$PRIVATE_PUBLISH_KEY" - export TAURI_KEY_PASSWORD="$PRIVATE_PUBLISH_KEY_PASSWORD" + export TAURI_SIGNING_PRIVATE_KEY="$PRIVATE_PUBLISH_KEY" + export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$PRIVATE_PUBLISH_KEY_PASSWORD" fi cd runtime - cargo tauri build --target ${{ matrix.rust_target }} --bundles "$bundles" + cargo tauri build --target ${{ matrix.rust_target }} --bundles "$bundles" "${tauri_config_args[@]}" + + if [ "${{ needs.determine_run_mode.outputs.is_pr_build }}" = "true" ]; then + updater_artifact_count=$(find target/${{ matrix.rust_target }}/release/bundle -type f \( -name '*.app.tar.gz*' -o -name '*.AppImage.tar.gz*' -o -name '*nsis.zip*' \) | wc -l) + + if [ "$updater_artifact_count" -ne 0 ]; then + echo "PR builds must not generate updater artifacts." + find target/${{ matrix.rust_target }}/release/bundle -type f \( -name '*.app.tar.gz*' -o -name '*.AppImage.tar.gz*' -o -name '*nsis.zip*' \) + exit 1 + fi + fi + + if [ "${{ needs.determine_run_mode.outputs.is_pr_build }}" != "true" ] && [[ "${{ matrix.platform }}" == macos* ]]; then + app_update_archive_count=$(find target/${{ matrix.rust_target }}/release/bundle/macos -maxdepth 1 -name '*.app.tar.gz' | wc -l) + app_update_signature_count=$(find target/${{ matrix.rust_target }}/release/bundle/macos -maxdepth 1 -name '*.app.tar.gz.sig' | wc -l) + + if [ "$app_update_archive_count" -eq 0 ] || [ "$app_update_signature_count" -eq 0 ]; then + echo "Expected macOS updater artifacts were not generated." + exit 1 + fi + fi - name: Build Tauri project (Windows) if: matrix.platform == 'windows-latest' @@ -785,17 +841,29 @@ jobs: PRIVATE_PUBLISH_KEY_PASSWORD: ${{ secrets.PRIVATE_PUBLISH_KEY_PASSWORD }} run: | $bundles = "${{ matrix.tauri_bundle }}" + $tauriConfigArgs = @() if ("${{ needs.determine_run_mode.outputs.is_pr_build }}" -eq "true") { Write-Output "Running PR test build without updater bundle signing" $bundles = "${{ matrix.tauri_bundle_pr }}" + $tauriConfigArgs = @("--config", '{"bundle":{"createUpdaterArtifacts":false}}') } else { - $env:TAURI_PRIVATE_KEY="$env:PRIVATE_PUBLISH_KEY" - $env:TAURI_KEY_PASSWORD="$env:PRIVATE_PUBLISH_KEY_PASSWORD" + $env:TAURI_SIGNING_PRIVATE_KEY="$env:PRIVATE_PUBLISH_KEY" + $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$env:PRIVATE_PUBLISH_KEY_PASSWORD" } cd runtime - cargo tauri build --target ${{ matrix.rust_target }} --bundles $bundles + cargo tauri build --target ${{ matrix.rust_target }} --bundles $bundles @tauriConfigArgs + + if ("${{ needs.determine_run_mode.outputs.is_pr_build }}" -eq "true") { + $updaterArtifacts = Get-ChildItem -Path "target/${{ matrix.rust_target }}/release/bundle" -Recurse -File -Include "*.app.tar.gz*", "*.AppImage.tar.gz*", "*nsis.zip*" -ErrorAction SilentlyContinue + + if ($updaterArtifacts.Count -ne 0) { + Write-Error "PR builds must not generate updater artifacts." + $updaterArtifacts | ForEach-Object { Write-Error $_.FullName } + exit 1 + } + } - name: Upload artifact (macOS) if: startsWith(matrix.platform, 'macos') @@ -804,7 +872,7 @@ jobs: name: MindWork AI Studio (macOS ${{ matrix.dotnet_runtime }}) path: | runtime/target/${{ matrix.rust_target }}/release/bundle/dmg/MindWork AI Studio_*.dmg - runtime/target/${{ matrix.rust_target }}/release/bundle/macos/MindWork AI Studio.app.tar.gz* + runtime/target/${{ matrix.rust_target }}/release/bundle/macos/*.app.tar.gz* if-no-files-found: error retention-days: ${{ fromJSON(needs.determine_run_mode.outputs.artifact_retention_days) }} @@ -830,24 +898,14 @@ jobs: if-no-files-found: error retention-days: ${{ fromJSON(needs.determine_run_mode.outputs.artifact_retention_days) }} - - name: Upload artifact (Linux - Debian Package) - if: startsWith(matrix.platform, 'ubuntu') && contains(matrix.tauri_bundle, 'deb') - uses: actions/upload-artifact@v4 - with: - name: MindWork AI Studio (Linux - deb ${{ matrix.dotnet_runtime }}) - path: | - runtime/target/${{ matrix.rust_target }}/release/bundle/deb/mind-work-ai-studio_*.deb - if-no-files-found: error - retention-days: ${{ fromJSON(needs.determine_run_mode.outputs.artifact_retention_days) }} - - name: Upload artifact (Linux - AppImage) if: startsWith(matrix.platform, 'ubuntu') && contains(matrix.tauri_bundle, 'appimage') uses: actions/upload-artifact@v4 with: name: MindWork AI Studio (Linux - AppImage ${{ matrix.dotnet_runtime }}) path: | - runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/mind-work-ai-studio_*.AppImage - runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/mind-work-ai-studio*AppImage.tar.gz* + runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/*.AppImage + runtime/target/${{ matrix.rust_target }}/release/bundle/appimage/*.AppImage.tar.gz* if-no-files-found: error retention-days: ${{ fromJSON(needs.determine_run_mode.outputs.artifact_retention_days) }} @@ -883,14 +941,14 @@ jobs: # Find and process files in the artifacts directory: find "$GITHUB_WORKSPACE/artifacts" -type f | while read -r FILE; do - if [[ "$FILE" == *"osx-x64"* && "$FILE" == *".tar.gz" ]]; then - TARGET_NAME="MindWork AI Studio_x64.app.tar.gz" - elif [[ "$FILE" == *"osx-x64"* && "$FILE" == *".tar.gz.sig" ]]; then + if [[ "$FILE" == *"osx-x64"* && "$FILE" == *".tar.gz.sig" ]]; then TARGET_NAME="MindWork AI Studio_x64.app.tar.gz.sig" - elif [[ "$FILE" == *"osx-arm64"* && "$FILE" == *".tar.gz" ]]; then - TARGET_NAME="MindWork AI Studio_aarch64.app.tar.gz" + elif [[ "$FILE" == *"osx-x64"* && "$FILE" == *".tar.gz" ]]; then + TARGET_NAME="MindWork AI Studio_x64.app.tar.gz" elif [[ "$FILE" == *"osx-arm64"* && "$FILE" == *".tar.gz.sig" ]]; then TARGET_NAME="MindWork AI Studio_aarch64.app.tar.gz.sig" + elif [[ "$FILE" == *"osx-arm64"* && "$FILE" == *".tar.gz" ]]; then + TARGET_NAME="MindWork AI Studio_aarch64.app.tar.gz" else TARGET_NAME="$(basename "$FILE")" TARGET_NAME=$(echo "$TARGET_NAME" | sed "s/_${VERSION}//") @@ -941,9 +999,9 @@ jobs: platform="linux-x86_64" elif [[ "$sig_file" == *"aarch64.AppImage"* ]]; then platform="linux-aarch64" - elif [[ "$sig_file" == *"x64-setup.nsis"* ]]; then + elif [[ "$sig_file" == *"x64-setup"* ]]; then platform="windows-x86_64" - elif [[ "$sig_file" == *"arm64-setup.nsis"* ]]; then + elif [[ "$sig_file" == *"arm64-setup"* ]]; then platform="windows-aarch64" else echo "Platform not recognized: '$sig_file'" @@ -1007,6 +1065,13 @@ jobs: exit 1 fi + for platform in darwin-aarch64 darwin-x86_64 linux-aarch64 linux-x86_64 windows-aarch64 windows-x86_64; do + if ! jq -e --arg platform "$platform" '.platforms[$platform]' $GITHUB_WORKSPACE/release/assets/latest.json > /dev/null; then + echo "The generated latest.json is missing platform '$platform'." + exit 1 + fi + done + - name: Show all release assets run: ls -Rlhat $GITHUB_WORKSPACE/release/assets @@ -1113,7 +1178,7 @@ jobs: with: prerelease: true draft: false - make_latest: true + make_latest: false body: ${{ env.CHANGELOG }} name: "Release ${{ env.FORMATTED_VERSION }}" fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 3175fdb1..6c081ead 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,6 @@ orleans.codegen.cs # Ignore GitHub Copilot migration files: **/copilot.data.migration.*.xml + +# Tauri generated schemas/manifests +/runtime/gen/ diff --git a/AGENTS.md b/AGENTS.md index 7908fdcd..48a25021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Currently, no automated test suite exists in the repository. Key modules: - `app_window.rs` - Tauri window management, updater integration - `dotnet.rs` - Launches and manages the .NET sidecar process -- `runtime_api.rs` - Rocket-based HTTPS API for .NET ↔ Rust communication +- `runtime_api.rs` - Axum-based HTTPS API for .NET ↔ Rust communication - `certificate.rs` - Generates self-signed TLS certificates for secure IPC - `secret.rs` - Secure secret storage using OS keyring (Keychain/Credential Manager) - `clipboard.rs` - Cross-platform clipboard operations @@ -152,7 +152,7 @@ Multi-level confidence scheme allows users to control which providers see which **Rust:** - Tauri 1.8 - Desktop application framework -- Rocket - HTTPS API server +- Axum - HTTPS API server - tokio - Async runtime - keyring - OS keyring integration - pdfium-render - PDF text extraction @@ -187,6 +187,7 @@ Multi-level confidence scheme allows users to control which providers see which - **File changes require Write/Edit tools** - Never use bash commands like `cat <` - **End of file formatting** - Do not append an extra empty line at the end of files. - **No automated formatting for Rust or .NET files** - Never run automated formatters on Rust files (`.rs`) or .NET files (`.cs`, `.razor`, `.csproj`, etc.). Only make the minimal manual formatting changes required for the specific edit. +- **I18N resources are generated** - Do not manually edit `app/MindWork AI Studio/Assistants/I18N/allTexts.lua`, `app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua`, or `app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua`. These files are updated automatically by the I18N process. - **Spaces in paths** - Always quote paths with spaces in bash commands - **Agent-run .NET builds** - Do not run `.NET` builds from an agent. Ask the user to run the build locally in their IDE, preferably via `cd app/Build && dotnet run build` in an IDE terminal, then wait for their feedback before continuing. - **Debug environment** - Reads `startup.env` file with IPC credentials diff --git a/README.md b/README.md index 5b69c065..73cc6c8b 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,11 @@ Since November 2024: Work on RAG (integration of your data and files) has begun. - [x] ~~App: Implement an [ERI](https://github.com/MindWorkAI/ERI) server coding assistant (PR [#231](https://github.com/MindWorkAI/AI-Studio/pull/231))~~ - [x] ~~App: Management of data sources (local & external data via [ERI](https://github.com/MindWorkAI/ERI)) (PR [#259](https://github.com/MindWorkAI/AI-Studio/pull/259), [#273](https://github.com/MindWorkAI/AI-Studio/pull/273))~~ - [x] ~~Runtime: Extract data from txt / md / pdf / docx / xlsx files (PR [#374](https://github.com/MindWorkAI/AI-Studio/pull/374))~~ -- [ ] (*Optional*) Runtime: Implement internal embedding provider through [fastembed-rs](https://github.com/Anush008/fastembed-rs) - [x] ~~App: Implement dialog for checking & handling [pandoc](https://pandoc.org/) installation ([PR #393](https://github.com/MindWorkAI/AI-Studio/pull/393), [PR #487](https://github.com/MindWorkAI/AI-Studio/pull/487))~~ - [x] ~~App: Implement external embedding providers ([PR #654](https://github.com/MindWorkAI/AI-Studio/pull/654))~~ -- [ ] App: Implement the process to vectorize one local file using embeddings +- [ ] App: Implement the process to vectorize one local file using embeddings (PR [#756](https://github.com/MindWorkAI/AI-Studio/pull/756)) - [x] ~~Runtime: Integration of the vector database [Qdrant](https://github.com/qdrant/qdrant) ([PR #580](https://github.com/MindWorkAI/AI-Studio/pull/580))~~ -- [ ] App: Implement the continuous process of vectorizing data +- [ ] App: Implement the continuous process of vectorizing data (PR [#756](https://github.com/MindWorkAI/AI-Studio/pull/756)) - [x] ~~App: Define a common retrieval context interface for the integration of RAG processes in chats (PR [#281](https://github.com/MindWorkAI/AI-Studio/pull/281), [#284](https://github.com/MindWorkAI/AI-Studio/pull/284), [#286](https://github.com/MindWorkAI/AI-Studio/pull/286), [#287](https://github.com/MindWorkAI/AI-Studio/pull/287))~~ - [x] ~~App: Define a common augmentation interface for the integration of RAG processes in chats (PR [#288](https://github.com/MindWorkAI/AI-Studio/pull/288), [#289](https://github.com/MindWorkAI/AI-Studio/pull/289))~~ - [x] ~~App: Integrate data sources in chats (PR [#282](https://github.com/MindWorkAI/AI-Studio/pull/282))~~ @@ -79,6 +78,7 @@ Since March 2025: We have started developing the plugin system. There will be la +- v26.5.5: Released voice recording and transcription for all users; added support for multiple chats running at the same time, export options for profiles, chat templates, and ERI data sources, organization-managed ERI servers, and configurable request timeouts; upgraded the native runtime to Tauri v2. - v26.4.1: Added support for the latest AI models, assistant plugins, a slide planner assistant, a prompt optimization assistant, math rendering in chats, and a configurable start page; released the document analysis assistant and improved enterprise deployment, chat performance, file attachments, and reliability across voice recording, logging, and provider validation. - v26.2.2: Added Qdrant as a building block for our local RAG preview, added an embedding test option to validate embedding providers, and improved enterprise and configuration plugins with preselected providers, additive preview features, support for multiple configurations, and more reliable synchronization. - v26.1.1: Added the option to attach files, including images, to chat templates; added support for source code file attachments in chats and document analysis; added a preview feature for recording your own voice for transcription; fixed various bugs in provider dialogs and profile selection. @@ -90,7 +90,6 @@ Since March 2025: We have started developing the plugin system. There will be la - v0.9.44: Added PDF import to the text summarizer, translation, and legal check assistants, allowing you to import PDF files and use them as input for the assistants. - v0.9.40: Added support for the `o4` models from OpenAI. Also, we added Alibaba Cloud & Hugging Face as LLM providers. - v0.9.39: Added the plugin system as a preview feature. -- v0.9.31: Added Helmholtz & GWDG as LLM providers. This is a huge improvement for many researchers out there who can use these providers for free. We added DeepSeek as a provider as well. diff --git a/app/.idea/.idea.MindWork AI Studio/.idea/indexLayout.xml b/app/.idea/.idea.MindWork AI Studio/.idea/indexLayout.xml index 7b08163c..30a3b985 100644 --- a/app/.idea/.idea.MindWork AI Studio/.idea/indexLayout.xml +++ b/app/.idea/.idea.MindWork AI Studio/.idea/indexLayout.xml @@ -1,7 +1,9 @@ - + + ../../mindwork-ai-studio + diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index 5ec929ab..f3b0799e 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -245,7 +245,7 @@ public sealed partial class UpdateMetadataCommands Console.WriteLine("- Start building the Rust runtime ..."); var pathRuntime = Environment.GetRustRuntimeDirectory(); - var rustBuildOutput = await this.ReadCommandOutput(pathRuntime, "cargo", "tauri build --bundles none", true); + var rustBuildOutput = await this.ReadCommandOutput(pathRuntime, "cargo", "tauri build --no-bundle", true); var rustBuildOutputLines = rustBuildOutput.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); var foundRustIssue = false; foreach (var buildOutputLine in rustBuildOutputLines) diff --git a/app/MindWork AI Studio.sln.DotSettings b/app/MindWork AI Studio.sln.DotSettings index d35acefd..8919d73e 100644 --- a/app/MindWork AI Studio.sln.DotSettings +++ b/app/MindWork AI Studio.sln.DotSettings @@ -2,6 +2,7 @@ AI EDI ERI + ERIV FNV GWDG HF diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index d9b553dd..d9cf2afe 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -328,22 +328,40 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.isProcessing = true; this.StateHasChanged(); - // Use the selected provider to get the AI response. - // By awaiting this line, we wait for the entire - // content to be streamed. - this.ChatThread = await aiText.CreateFromProviderAsync(this.ProviderSettings.CreateProvider(), this.ProviderSettings.Model, this.LastUserPrompt, this.ChatThread, this.CancellationTokenSource!.Token); - - this.isProcessing = false; - this.StateHasChanged(); - - if(manageCancellationLocally) + try { - this.CancellationTokenSource.Dispose(); - this.CancellationTokenSource = null; + // Use the selected provider to get the AI response. + // By awaiting this line, we wait for the entire + // content to be streamed. + this.ChatThread = await aiText.CreateFromProviderAsync(this.ProviderSettings.CreateProvider(), this.ProviderSettings.Model, this.LastUserPrompt, this.ChatThread, this.CancellationTokenSource!.Token); + + // Return the AI response: + return aiText.Text; } + catch (ProviderRequestException e) + { + this.Logger.LogError(e, "The provider request failed for assistant '{AssistantTitle}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", this.Title, e.StatusCode, e.ReasonPhrase, e.ResponseBody); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); + + if (this.resultingContentBlock is not null && string.IsNullOrWhiteSpace(aiText.Text)) + { + this.ChatThread?.Blocks.Remove(this.resultingContentBlock); + this.resultingContentBlock = null; + } + + return string.Empty; + } + finally + { + this.isProcessing = false; + this.StateHasChanged(); - // Return the AI response: - return aiText.Text; + if(manageCancellationLocally) + { + this.CancellationTokenSource?.Dispose(); + this.CancellationTokenSource = null; + } + } } private async Task CancelStreaming() diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index 77522cd8..e7b4bf38 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -10,6 +10,8 @@ using AIStudio.Settings.DataModel; using Microsoft.AspNetCore.Components; +using SharedTools; + using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Assistants.DocumentAnalysis; @@ -747,16 +749,12 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore @T("You might want to specify important aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.") - + @T("Extent of the planned presentation") diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 6a116278..4c8be646 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -93,59 +93,70 @@ public sealed class ContentText : IContent // Start another thread by using a task to uncouple // the UI thread from the AI processing: - await Task.Run(async () => + try { - // We show the waiting animation until we get the first response: - this.InitialRemoteWait = true; - - // Iterate over the responses from the AI: - await foreach (var contentStreamChunk in provider.StreamChatCompletion(chatModel, chatThread, settings, token)) + await Task.Run(async () => { - // When the user cancels the request, we stop the loop: - if (token.IsCancellationRequested) - break; - - // Stop the waiting animation: - this.InitialRemoteWait = false; - this.IsStreaming = true; - - // Add the response to the text: - this.Text += contentStreamChunk; - - // Merge the sources: - this.Sources.MergeSources(contentStreamChunk.Sources); - - // Notify the UI that the content has changed, - // depending on the energy saving mode: - var now = DateTimeOffset.Now; - switch (settings.ConfigurationData.App.IsSavingEnergy) + try { - // Energy saving mode is off. We notify the UI - // as fast as possible -- no matter the odds: - case false: - await this.StreamingEvent(); - break; - - // Energy saving mode is on. We notify the UI - // only when the time between two events is - // greater than the minimum time: - case true when now - last > MIN_TIME: - last = now; - await this.StreamingEvent(); - break; + // We show the waiting animation until we get the first response: + this.InitialRemoteWait = true; + + // Iterate over the responses from the AI: + await foreach (var contentStreamChunk in provider.StreamChatCompletion(chatModel, chatThread, settings, token)) + { + // When the user cancels the request, we stop the loop: + if (token.IsCancellationRequested) + break; + + // Stop the waiting animation: + this.InitialRemoteWait = false; + this.IsStreaming = true; + + // Add the response to the text: + this.Text += contentStreamChunk; + + // Merge the sources: + this.Sources.MergeSources(contentStreamChunk.Sources); + + // Notify the UI that the content has changed, + // depending on the energy saving mode: + var now = DateTimeOffset.Now; + switch (settings.ConfigurationData.App.IsSavingEnergy) + { + // Energy saving mode is off. We notify the UI + // as fast as possible -- no matter the odds: + case false: + await this.StreamingEvent(); + break; + + // Energy saving mode is on. We notify the UI + // only when the time between two events is + // greater than the minimum time: + case true when now - last > MIN_TIME: + last = now; + await this.StreamingEvent(); + break; + } + } } - } - - // Stop the waiting animation (in case the loop - // was stopped, or no content was received): - this.InitialRemoteWait = false; - this.IsStreaming = false; - }, token); - - this.Text = this.Text.RemoveThinkTags().Trim(); + finally + { + // Stop the waiting animation (in case the loop + // was stopped, or no content was received): + this.InitialRemoteWait = false; + this.IsStreaming = false; + } + }, token); + } + finally + { + this.Text = this.Text.RemoveThinkTags().Trim(); - // Inform the UI that the streaming is done: - await this.StreamingDone(); + // Inform the UI that the streaming is done: + await this.StreamingDone(); + } + return chatThread; } diff --git a/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs b/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs index c6461643..6c3f204f 100644 --- a/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs +++ b/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs @@ -89,8 +89,10 @@ public static class IImageSourceExtensions case ContentImageSource.URL: { - using var httpClient = new HttpClient(); - using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, token); + using var httpClient = ExternalHttpClientTimeout.CreateHttpClient(); + using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); + var timeoutToken = timeoutTokenSource.Token; + using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, timeoutToken); if(response.IsSuccessStatusCode) { // Read the length of the content: @@ -101,7 +103,7 @@ public static class IImageSourceExtensions return (success: false, string.Empty); } - var bytes = await response.Content.ReadAsByteArrayAsync(token); + var bytes = await response.Content.ReadAsByteArrayAsync(timeoutToken); return (success: true, Convert.ToBase64String(bytes)); } diff --git a/app/MindWork AI Studio/Components/Changelog.Logs.cs b/app/MindWork AI Studio/Components/Changelog.Logs.cs index 02d21b52..3d9cd1a0 100644 --- a/app/MindWork AI Studio/Components/Changelog.Logs.cs +++ b/app/MindWork AI Studio/Components/Changelog.Logs.cs @@ -13,6 +13,11 @@ public partial class Changelog public static readonly Log[] LOGS = [ + new (240, "v26.5.5, build 240 (2026-05-25 18:52 UTC)", "v26.5.5.md"), + new (239, "v26.5.4, build 239 (2026-05-13 11:58 UTC)", "v26.5.4.md"), + new (238, "v26.5.3, build 238 (2026-05-13 09:50 UTC)", "v26.5.3.md"), + new (237, "v26.5.2, build 237 (2026-05-06 16:38 UTC)", "v26.5.2.md"), + new (236, "v26.5.1, build 236 (2026-05-06 13:06 UTC)", "v26.5.1.md"), new (235, "v26.4.1, build 235 (2026-04-17 17:25 UTC)", "v26.4.1.md"), new (234, "v26.2.2, build 234 (2026-02-22 14:16 UTC)", "v26.2.2.md"), new (233, "v26.2.1, build 233 (2026-02-01 19:16 UTC)", "v26.2.1.md"), diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 1998aee6..30261d8d 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -37,7 +37,7 @@ - + } @@ -92,35 +92,35 @@ @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) { - + } @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) { - + } - + - + - + - + - + - + @@ -137,10 +137,10 @@ } - @if (this.isStreaming && this.cancellationTokenSource is not null) + @if (this.IsCurrentChatStreaming) { - + } diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 77b28fb5..84d69add 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -3,7 +3,7 @@ using AIStudio.Dialogs; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; -using AIStudio.Tools.Services; +using AIStudio.Tools.AIJobs; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -38,6 +38,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable [Parameter] public Workspaces? Workspaces { get; set; } + + [Parameter] + public ChatComposerState ComposerState { get; set; } = new(); [Inject] private ILogger Logger { get; set; } = null!; @@ -50,6 +53,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable [Inject] private IJSRuntime JsRuntime { get; init; } = null!; + [Inject] + private AIJobService AIJobService { get; init; } = null!; + private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); @@ -61,8 +67,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private bool mustScrollToBottomAfterRender; private InnerScrolling scrollingArea = null!; private byte scrollRenderCountdown; - private bool isStreaming; - private string userInput = string.Empty; private bool mustStoreChat; private bool mustLoadChat; private LoadChat loadChat; @@ -70,6 +74,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private string currentWorkspaceName = string.Empty; private Guid currentWorkspaceId = Guid.Empty; private Guid currentChatThreadId = Guid.Empty; + private Guid loadedParameterChatId = Guid.Empty; + private Guid loadedParameterWorkspaceId = Guid.Empty; + private Guid foregroundChatId = Guid.Empty; private int workspaceHeaderSyncVersion; private CancellationTokenSource? cancellationTokenSource; private HashSet chatDocumentPaths = []; @@ -80,12 +87,27 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // this, we cannot clear the input field. private UserPromptComponent inputField = null!; + /// + /// Represents the user's input in the chat interface. + /// + /// + /// This property serves as a bridge between the chat component and the + /// underlying composer state, allowing user input to be dynamically updated + /// and managed. The setter also triggers state changes within the composer + /// to track whether the user has drafted any input. + /// + private string UserInput + { + get => this.ComposerState.UserInput; + set => this.ComposerState.SetUserInput(value); + } + #region Overrides of ComponentBase protected override async Task OnInitializedAsync() { // Apply the filters for the message bus: - this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.WORKSPACE_LOADED_CHAT_CHANGED ]); + this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]); // Configure the spellchecking for the user input: this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); @@ -96,15 +118,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Get the preselected chat template: this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT); - this.userInput = this.currentChatTemplate.PredefinedUserPrompt; + if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent) + this.ComposerState.ApplyTemplate(this.currentChatTemplate); var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_CHAT_INPUT).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInput)) - this.userInput = deferredInput; - - // Apply template's file attachments, if any: - foreach (var attachment in this.currentChatTemplate.FileAttachments) - this.chatDocumentPaths.Add(attachment.Normalize()); + this.ComposerState.SetUserInput(deferredInput); // // Check for deferred messages of the kind 'SEND_TO_CHAT', @@ -122,6 +141,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ChatThread.IncludeDateTime = true; this.Logger.LogInformation($"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now."); + this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); // We know already that the chat thread is not null, @@ -222,6 +242,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Select the correct provider: await this.SelectProviderWhenLoadingChat(); + await this.SyncForegroundChatAsync(); await base.OnInitializedAsync(); } @@ -247,6 +268,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(this.ChatThread is not null) { + this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully."); @@ -277,12 +299,35 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable protected override async Task OnParametersSetAsync() { - await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await this.ApplyLoadedChatParameterAsync(); + await this.SyncForegroundChatAsync(); await base.OnParametersSetAsync(); } #endregion + private async Task ApplyLoadedChatParameterAsync() + { + var chatId = this.ChatThread?.ChatId ?? Guid.Empty; + var workspaceId = this.ChatThread?.WorkspaceId ?? Guid.Empty; + + if (this.loadedParameterChatId == chatId && this.loadedParameterWorkspaceId == workspaceId) + { + await this.SyncWorkspaceHeaderWithChatThreadAsync(); + return; + } + + this.loadedParameterChatId = chatId; + this.loadedParameterWorkspaceId = workspaceId; + await this.LoadedChatChanged(notifyParent: false); + } + + private void MarkCurrentChatAsLoadedParameter() + { + this.loadedParameterChatId = this.ChatThread?.ChatId ?? Guid.Empty; + this.loadedParameterWorkspaceId = this.ChatThread?.WorkspaceId ?? Guid.Empty; + } + private async Task SyncWorkspaceHeaderWithChatThreadAsync() { var syncVersion = Interlocked.Increment(ref this.workspaceHeaderSyncVersion); @@ -338,7 +383,23 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.WorkspaceName(this.currentWorkspaceName); } + private async Task SyncForegroundChatAsync() + { + var nextForegroundChatId = this.ChatThread?.ChatId ?? Guid.Empty; + if (this.foregroundChatId == nextForegroundChatId) + return; + + if (this.foregroundChatId != Guid.Empty) + await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false); + + this.foregroundChatId = nextForegroundChatId; + if (this.foregroundChatId != Guid.Empty) + await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, true); + } + private bool IsProviderSelected => this.Provider.UsedLLMProvider != LLMProviders.NONE; + + private bool IsCurrentChatStreaming => this.ChatThread is not null && this.AIJobService.IsChatGenerationActive(this.ChatThread.ChatId); private string ProviderPlaceholder => this.IsProviderSelected ? T("Type your input here...") : T("Select a provider first"); @@ -408,12 +469,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable { this.currentChatTemplate = chatTemplate; if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt)) - this.userInput = this.currentChatTemplate.PredefinedUserPrompt; + this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt); // Apply template's file attachments (replaces existing): - this.chatDocumentPaths.Clear(); - foreach (var attachment in this.currentChatTemplate.FileAttachments) - this.chatDocumentPaths.Add(attachment.Normalize()); + this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments); if(this.ChatThread is null) return; @@ -458,7 +517,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if (!this.IsProviderSelected) return true; - if(this.isStreaming) + if(this.IsCurrentChatStreaming) return true; if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) @@ -473,6 +532,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.dataSourceSelectionComponent.Hide(); this.hasUnsavedChanges = true; + this.ComposerState.MarkUserDraft(); var key = keyEvent.Code.ToLowerInvariant(); // Was the enter key (either enter or numpad enter) pressed? @@ -507,7 +567,16 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(this.dataSourceSelectionComponent?.IsVisible ?? false) this.dataSourceSelectionComponent.Hide(); - this.userInput = await this.JsRuntime.InvokeAsync("formatChatInputMarkdown", CHAT_INPUT_ID, formatType); + this.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync("formatChatInputMarkdown", CHAT_INPUT_ID, formatType)); + this.hasUnsavedChanges = true; + } + + private void ComposerAttachmentsChanged(HashSet attachments) + { + if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments)) + this.ComposerState.ReplaceFileAttachments(attachments); + + this.ComposerState.MarkUserDraft(); this.hasUnsavedChanges = true; } @@ -535,17 +604,18 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable WorkspaceId = this.currentWorkspaceId, ChatId = Guid.NewGuid(), DataSourceOptions = this.earlyDataSourceOptions, - Name = this.ExtractThreadName(this.userInput), + Name = this.ExtractThreadName(this.ComposerState.UserInput), Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(), }; + this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } else { // Set the thread name if it is empty: if (string.IsNullOrWhiteSpace(this.ChatThread.Name)) - this.ChatThread.Name = this.ExtractThreadName(this.userInput); + this.ChatThread.Name = this.ExtractThreadName(this.ComposerState.UserInput); // Update provider, profile and chat template: this.ChatThread.SelectedProvider = this.Provider.Id; @@ -562,14 +632,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable IContent? lastUserPrompt; if (!reuseLastUserPrompt) { - var normalizedAttachments = this.chatDocumentPaths + var normalizedAttachments = this.ComposerState.FileAttachments .Select(attachment => attachment.Normalize()) .Where(attachment => attachment.IsValid) .ToList(); lastUserPrompt = new ContentText { - Text = this.userInput, + Text = this.ComposerState.UserInput, FileAttachments = normalizedAttachments, }; @@ -616,14 +686,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Clear the input field: await this.inputField.FocusAsync(); - this.userInput = string.Empty; - this.chatDocumentPaths.Clear(); + this.ComposerState.Clear(); await this.inputField.BlurAsync(); this.tokenCount = "0"; // Enable the stream state for the chat component: - this.isStreaming = true; this.hasUnsavedChanges = true; if (this.SettingsManager.ConfigurationData.Chat.ShowLatestMessageAfterLoading) @@ -633,38 +701,23 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable } this.Logger.LogDebug($"Start processing user input using provider '{this.Provider.InstanceName}' with model '{this.Provider.Model}'."); - - using (this.cancellationTokenSource = new()) + await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest { - this.StateHasChanged(); - - // Use the selected provider to get the AI response. - // By awaiting this line, we wait for the entire - // content to be streamed. - this.ChatThread = await aiText.CreateFromProviderAsync(this.Provider.CreateProvider(), this.Provider.Model, lastUserPrompt, this.ChatThread, this.cancellationTokenSource.Token); - } - - this.cancellationTokenSource = null; + ChatThread = this.ChatThread!, + AIText = aiText, + LastUserPrompt = lastUserPrompt, + ProviderSettings = this.Provider, + IsForeground = true, + }); - // Save the chat: - if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) - { - await this.SaveThread(); - this.hasUnsavedChanges = false; - } - - // Disable the stream state: - this.isStreaming = false; - - // Update the UI: + await this.SyncForegroundChatAsync(); this.StateHasChanged(); } private async Task CancelStreaming() { - if (this.cancellationTokenSource is not null) - if(!this.cancellationTokenSource.IsCancellationRequested) - await this.cancellationTokenSource.CancelAsync(); + if (this.ChatThread is not null) + await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId); } private async Task SaveThread() @@ -694,7 +747,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Want the user to manage the chat storage manually? In that case, we have to ask the user // about possible data loss: // - if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) + if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming) { var dialogParameters = new DialogParameters { @@ -727,9 +780,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // // Reset our state: // - this.isStreaming = false; this.hasUnsavedChanges = false; - this.userInput = string.Empty; + this.ComposerState.Clear(); // // Reset the LLM provider considering the user's settings: @@ -786,17 +838,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable }; } - this.userInput = this.currentChatTemplate.PredefinedUserPrompt; - - // Apply template's file attachments: - this.chatDocumentPaths.Clear(); - foreach (var attachment in this.currentChatTemplate.FileAttachments) - this.chatDocumentPaths.Add(attachment.Normalize()); + this.ComposerState.ApplyTemplate(this.currentChatTemplate); // Now, we have to reset the data source options as well: this.ApplyStandardDataSourceOptions(); // Notify the parent component about the change: + await this.SyncForegroundChatAsync(); + this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } @@ -805,7 +854,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(this.ChatThread is null) return; - if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) + if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming) { var confirmationDialogParameters = new DialogParameters { @@ -838,25 +887,35 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); this.ChatThread!.WorkspaceId = workspaceId; + this.MarkCurrentChatAsLoadedParameter(); await this.SaveThread(); await this.SyncWorkspaceHeaderWithChatThreadAsync(); } - private async Task LoadedChatChanged() + private async Task LoadedChatChanged(bool notifyParent = true) { - this.isStreaming = false; this.hasUnsavedChanges = false; - this.userInput = string.Empty; + this.ComposerState.Clear(); if (this.ChatThread is not null) { + this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread; + this.loadedParameterChatId = this.ChatThread.ChatId; + this.loadedParameterWorkspaceId = this.ChatThread.WorkspaceId; + if (notifyParent) + await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await this.SyncForegroundChatAsync(); this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources); } else { + this.loadedParameterChatId = Guid.Empty; + this.loadedParameterWorkspaceId = Guid.Empty; this.ClearWorkspaceHeaderState(); + await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); } @@ -872,12 +931,13 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private async Task ResetState() { - this.isStreaming = false; this.hasUnsavedChanges = false; - this.userInput = string.Empty; + this.ComposerState.Clear(); this.ClearWorkspaceHeaderState(); this.ChatThread = null; + this.MarkCurrentChatAsLoadedParameter(); + await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } @@ -948,7 +1008,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(lastBlockContent is null) return Task.CompletedTask; - this.userInput = textBlock.Text; + this.RestoreComposerFromTextBlock(textBlock); this.ChatThread.Remove(block); this.ChatThread.Remove(lastBlockContent); this.hasUnsavedChanges = true; @@ -965,13 +1025,18 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if (block is not ContentText textBlock) return Task.CompletedTask; - this.userInput = textBlock.Text; + this.RestoreComposerFromTextBlock(textBlock); this.ChatThread.Remove(block); this.hasUnsavedChanges = true; this.StateHasChanged(); return Task.CompletedTask; } + + private void RestoreComposerFromTextBlock(ContentText textBlock) + { + this.ComposerState.RestoreFromTextBlock(textBlock); + } private async Task CalculateTokenCount() { @@ -1021,8 +1086,17 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.SaveThread(); break; - case Event.WORKSPACE_LOADED_CHAT_CHANGED: - await this.LoadedChatChanged(); + case Event.AI_JOB_CHANGED: + case Event.AI_JOB_FINISHED: + case Event.CHAT_GENERATION_CHANGED: + if (data is AIJobSnapshot { Kind: AIJobKind.CHAT_GENERATION } snapshot && this.ChatThread?.ChatId == snapshot.SubjectId) + { + this.ChatThread = this.AIJobService.TryGetLiveChatThread(snapshot.SubjectId) ?? this.ChatThread; + if (!snapshot.IsActive) + this.hasUnsavedChanges = false; + + this.StateHasChanged(); + } break; } } @@ -1034,8 +1108,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable case Event.HAS_CHAT_UNSAVED_CHANGES: if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) return Task.FromResult((TResult?) (object) false); + + if (this.IsCurrentChatStreaming) + return Task.FromResult((TResult?) (object) false); - return Task.FromResult((TResult?)(object)this.hasUnsavedChanges); + return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || this.ComposerState.HasVisibleUserDraft)); } return Task.FromResult(default(TResult)); @@ -1053,21 +1130,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.hasUnsavedChanges = false; } - if (this.cancellationTokenSource is not null) - { - try - { - if(!this.cancellationTokenSource.IsCancellationRequested) - await this.cancellationTokenSource.CancelAsync(); - - this.cancellationTokenSource.Dispose(); - } - catch - { - // ignored - } - } + await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false); + this.Dispose(); } #endregion -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ChatComposerState.cs b/app/MindWork AI Studio/Components/ChatComposerState.cs new file mode 100644 index 00000000..a1565611 --- /dev/null +++ b/app/MindWork AI Studio/Components/ChatComposerState.cs @@ -0,0 +1,65 @@ +using AIStudio.Chat; +using AIStudio.Settings; + +namespace AIStudio.Components; + +public sealed class ChatComposerState +{ + public string UserInput { get; private set; } = string.Empty; + + public HashSet FileAttachments { get; } = []; + + public bool HasUserDraft { get; private set; } + + public bool HasComposerContent => !string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0; + + public bool HasVisibleUserDraft => this.HasUserDraft && (!string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0); + + public void ApplyTemplate(ChatTemplate chatTemplate) + { + this.UserInput = chatTemplate.PredefinedUserPrompt; + this.FileAttachments.Clear(); + foreach (var attachment in chatTemplate.FileAttachments) + this.FileAttachments.Add(attachment.Normalize()); + + this.HasUserDraft = false; + } + + public void SetUserInput(string? userInput) + { + this.UserInput = userInput ?? string.Empty; + this.HasUserDraft = !string.IsNullOrWhiteSpace(userInput); + } + + public void SetSystemInput(string? userInput) + { + this.UserInput = userInput ?? string.Empty; + this.HasUserDraft = false; + } + + public void MarkUserDraft() + { + this.HasUserDraft = true; + } + + public void ReplaceFileAttachments(IEnumerable fileAttachments) + { + this.FileAttachments.Clear(); + foreach (var attachment in fileAttachments) + this.FileAttachments.Add(attachment.Normalize()); + } + + public void Clear() + { + this.UserInput = string.Empty; + this.FileAttachments.Clear(); + this.HasUserDraft = false; + } + + public void RestoreFromTextBlock(ContentText textBlock) + { + this.UserInput = textBlock.Text; + this.ReplaceFileAttachments(textBlock.FileAttachments); + this.HasUserDraft = true; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index c5ae753f..2237ebb0 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -14,6 +14,7 @@ + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index 7b417e58..d99a2e14 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -5,7 +5,6 @@ @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) { - @T("Configured Transcription Providers") diff --git a/app/MindWork AI Studio/Components/TreeItemData.cs b/app/MindWork AI Studio/Components/TreeItemData.cs index 9ae74dab..4bd951f6 100644 --- a/app/MindWork AI Studio/Components/TreeItemData.cs +++ b/app/MindWork AI Studio/Components/TreeItemData.cs @@ -12,10 +12,16 @@ public class TreeItemData : ITreeItem public string Icon { get; init; } = string.Empty; + public string DefaultIcon { get; init; } = string.Empty; + public TreeItemType Type { get; init; } public string Path { get; init; } = string.Empty; + public Guid ChatId { get; init; } + + public Guid WorkspaceId { get; init; } + public bool Expandable { get; init; } = true; public DateTimeOffset LastEditTime { get; init; } diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index 686656dd..669932f6 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -132,6 +132,7 @@ public partial class VoiceRecorder : MSGComponentBase } var mimeTypes = GetPreferredMimeTypes( + Builder.Create().UseAudio().UseSubtype(AudioSubtype.WEBM).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.AAC).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(), @@ -361,7 +362,18 @@ public partial class VoiceRecorder : MSGComponentBase // Call the transcription API: this.Logger.LogInformation("Starting transcription with provider '{ProviderName}' and model '{ModelName}'.", transcriptionProviderSettings.UsedLLMProvider, transcriptionProviderSettings.Model.ToString()); - var transcribedText = await provider.TranscribeAudioAsync(transcriptionProviderSettings.Model, this.finalRecordingPath, this.SettingsManager); + var transcriptionResult = await provider.TranscribeAudioAsync(transcriptionProviderSettings.Model, this.finalRecordingPath, this.SettingsManager); + if (!transcriptionResult.Success) + { + this.Logger.LogWarning("The transcription request failed."); + var userMessage = string.IsNullOrWhiteSpace(transcriptionResult.ErrorMessage) + ? this.T("Unfortunately, there was an error communicating with the AI system.") + : transcriptionResult.ErrorMessage; + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, userMessage)); + return; + } + + var transcribedText = transcriptionResult.Text; if (string.IsNullOrWhiteSpace(transcribedText)) { diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index 75d840e9..f49864fc 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -24,7 +24,7 @@ else case TreeItemData treeItem: @if (treeItem.Type is TreeItemType.LOADING) { - + @@ -32,7 +32,7 @@ else } else if (treeItem.Type is TreeItemType.CHAT) { - +
@@ -48,15 +48,15 @@ else
- + - + - +
@@ -65,7 +65,7 @@ else } else if (treeItem.Type is TreeItemType.WORKSPACE) { - +
@@ -86,7 +86,7 @@ else } else { - +
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index 106d5719..ef9c15c8 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -4,6 +4,7 @@ using System.Text.Json; using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Settings; +using AIStudio.Tools.AIJobs; using Microsoft.AspNetCore.Components; @@ -18,6 +19,9 @@ public partial class Workspaces : MSGComponentBase [Inject] private ILogger Logger { get; init; } = null!; + + [Inject] + private AIJobService AIJobService { get; init; } = null!; [Parameter] public ChatThread? CurrentChatThread { get; set; } @@ -42,6 +46,7 @@ public partial class Workspaces : MSGComponentBase protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); + this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]); _ = this.LoadTreeItemsAsync(startPrefetch: true); } @@ -111,7 +116,7 @@ public partial class Workspaces : MSGComponentBase var temporaryChatsChildren = new List>(); foreach (var temporaryChat in snapshot.TemporaryChats.OrderByDescending(x => x.LastEditTime)) - temporaryChatsChildren.Add(CreateChatTreeItem(temporaryChat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer)); + temporaryChatsChildren.Add(this.CreateChatTreeItem(temporaryChat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer)); this.treeItems.Add(new TreeItemData { @@ -136,7 +141,7 @@ public partial class Workspaces : MSGComponentBase if (workspace.ChatsLoaded) { foreach (var workspaceChat in workspace.Chats.OrderByDescending(x => x.LastEditTime)) - children.Add(CreateChatTreeItem(workspaceChat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat)); + children.Add(this.CreateChatTreeItem(workspaceChat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat)); } else if (this.loadingWorkspaceChatLists.Contains(workspace.WorkspaceId)) children.AddRange(this.CreateLoadingRows(workspace.WorkspacePath)); @@ -192,7 +197,7 @@ public partial class Workspaces : MSGComponentBase }; } - private static TreeItemData CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon) + private TreeItemData CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon) { return new TreeItemData { @@ -204,13 +209,44 @@ public partial class Workspaces : MSGComponentBase Branch = branch, Text = chat.Name, Icon = icon, + DefaultIcon = icon, Expandable = false, Path = chat.ChatPath, + ChatId = chat.ChatId, + WorkspaceId = chat.WorkspaceId, LastEditTime = chat.LastEditTime, }, }; } + private string GetTreeItemIcon(TreeItemData treeItem) + { + if (treeItem.Type is not TreeItemType.CHAT) + return treeItem.Icon; + + var defaultIcon = string.IsNullOrWhiteSpace(treeItem.DefaultIcon) ? treeItem.Icon : treeItem.DefaultIcon; + return this.GetChatTreeIcon(treeItem.ChatId, defaultIcon); + } + + private bool IsChatTreeItemBusy(TreeItemData treeItem) + { + return treeItem.Type is TreeItemType.CHAT && this.AIJobService.IsChatGenerationActive(treeItem.ChatId); + } + + private string GetChatTreeIcon(Guid chatId, string defaultIcon) + { + var snapshot = this.AIJobService.TryGetChatSnapshot(chatId); + if (snapshot is null || !snapshot.IsActive) + return defaultIcon; + + return snapshot.Status switch + { + AIJobStatus.WAITING_FOR_REMOTE => Icons.Material.Filled.HourglassTop, + AIJobStatus.RUNNING => Icons.Material.Filled.ChangeCircle, + _ => defaultIcon, + }; + } + private async Task SafeStateHasChanged() { if (this.isDisposed) @@ -348,11 +384,13 @@ public partial class Workspaces : MSGComponentBase { var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); var chat = JsonSerializer.Deserialize(chatData, WorkspaceBehaviour.JSON_OPTIONS); + if (chat is not null) + chat = this.AIJobService.TryGetLiveChatThread(chat.ChatId) ?? chat; + if (switchToChat) { this.CurrentChatThread = chat; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } return chat; @@ -371,6 +409,9 @@ public partial class Workspaces : MSGComponentBase if (chat is null) return; + if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + return; + if (askForConfirmation) { var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(chat.WorkspaceId); @@ -398,7 +439,6 @@ public partial class Workspaces : MSGComponentBase { this.CurrentChatThread = null; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } } @@ -407,6 +447,9 @@ public partial class Workspaces : MSGComponentBase var chat = await this.LoadChatAsync(chatPath, false); if (chat is null) return; + + if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + return; var dialogParameters = new DialogParameters { @@ -429,7 +472,6 @@ public partial class Workspaces : MSGComponentBase { this.CurrentChatThread.Name = chat.Name; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } await WorkspaceBehaviour.StoreChatAsync(chat); @@ -525,6 +567,9 @@ public partial class Workspaces : MSGComponentBase var chat = await this.LoadChatAsync(chatPath, false); if (chat is null) return; + + if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + return; var dialogParameters = new DialogParameters { @@ -549,7 +594,6 @@ public partial class Workspaces : MSGComponentBase { this.CurrentChatThread = chat; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } await WorkspaceBehaviour.StoreChatAsync(chat); @@ -597,6 +641,12 @@ public partial class Workspaces : MSGComponentBase case Event.PLUGINS_RELOADED: await this.ForceRefreshFromDiskAsync(); break; + + case Event.AI_JOB_CHANGED: + case Event.AI_JOB_FINISHED: + case Event.CHAT_GENERATION_CHANGED: + await this.SafeStateHasChanged(); + break; } } diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor new file mode 100644 index 00000000..088be8ea --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor @@ -0,0 +1,26 @@ +@inherits MSGComponentBase + + + + + @string.Format(T("How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?"), this.DataSource.Name) + + + + @foreach (var mode in this.availableUsernamePasswordModes) + { + + @this.GetUsernamePasswordModeText(mode) + + } + + + + + @T("Cancel") + + + @T("Export") + + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor.cs new file mode 100644 index 00000000..cf0ec960 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialog.razor.cs @@ -0,0 +1,37 @@ +using AIStudio.Components; +using AIStudio.Settings.DataModel; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Dialogs; + +public partial class DataSourceERIV1UsernamePasswordExportDialog : MSGComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public DataSourceERI_V1 DataSource { get; set; } + + private readonly DataSourceERIUsernamePasswordMode[] availableUsernamePasswordModes = + [ + DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD, + DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD + ]; + + private DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD; + + private string GetUsernamePasswordModeText() => this.GetUsernamePasswordModeText(this.usernamePasswordMode); + + private string GetUsernamePasswordModeText(DataSourceERIUsernamePasswordMode mode) => mode switch + { + DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD => T("Read each user's username from the operating system and share one password"), + DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD => T("Use the same username and password for all users"), + + _ => T("User-managed username and password"), + }; + + private void Cancel() => this.MudDialog.Cancel(); + + private void Export() => this.MudDialog.Close(DialogResult.Ok(new DataSourceERIV1UsernamePasswordExportDialogResult(this.usernamePasswordMode))); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialogResult.cs b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialogResult.cs new file mode 100644 index 00000000..907f920e --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/DataSourceERIV1UsernamePasswordExportDialogResult.cs @@ -0,0 +1,5 @@ +using AIStudio.Settings.DataModel; + +namespace AIStudio.Dialogs; + +public readonly record struct DataSourceERIV1UsernamePasswordExportDialogResult(DataSourceERIUsernamePasswordMode UsernamePasswordMode); \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs index 4a16bd18..8bec772a 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs @@ -116,7 +116,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId if (this.dataAuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD) { // Load the secret: - var requestedSecret = await this.RustService.GetSecret(this); + var requestedSecret = await this.RustService.GetSecret(this, SecretStoreType.DATA_SOURCE); if (requestedSecret.Success) this.dataSecret = await requestedSecret.Secret.Decrypt(this.encryption); else @@ -169,6 +169,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname, AuthMethod = this.dataAuthMethod, Username = this.dataUsername, + UsernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED, Type = DataSourceType.ERI_V1, SecurityPolicy = this.dataSecurityPolicy, SelectedRetrievalId = this.dataSelectedRetrievalProcess.Id, @@ -323,7 +324,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId if (!string.IsNullOrWhiteSpace(this.dataSecret)) { // Store the secret in the OS secure storage: - var storeResponse = await this.RustService.SetSecret(this, this.dataSecret); + var storeResponse = await this.RustService.SetSecret(this, this.dataSecret, SecretStoreType.DATA_SOURCE); if (!storeResponse.Success) { this.dataSecretStorageIssue = string.Format(T("Failed to store the auth. secret in the operating system. The message was: {0}. Please try again."), storeResponse.Issue); diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor index aa6b7b7d..fa9766fe 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor @@ -21,7 +21,7 @@ @if (this.DataSource.AuthMethod is AuthMethod.USERNAME_PASSWORD) { - + } diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs index 38ed220a..02d522b6 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs @@ -41,6 +41,7 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos private readonly List dataIssues = []; private string serverDescription = string.Empty; + private string effectiveUsername = string.Empty; private ProviderType securityRequirements = ProviderType.NONE; private IReadOnlyList retrievalInfoformation = []; private RetrievalInfo selectedRetrievalInfo; @@ -51,6 +52,27 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos private string Port => this.DataSource.Port == 0 ? string.Empty : $"{this.DataSource.Port}"; + private async Task<(bool Success, DataSourceERI_V1 EffectiveDataSource)> CreateEffectiveDataSource() + { + this.effectiveUsername = this.DataSource.Username; + if (this.DataSource is not { AuthMethod: AuthMethod.USERNAME_PASSWORD, UsernamePasswordMode: DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD }) + return (true, this.DataSource); + + var osUsername = await this.RustService.ReadUserName(); + if (string.IsNullOrWhiteSpace(osUsername)) + { + this.dataIssues.Add(T("Failed to read the user's username from the operating system.")); + return (false, this.DataSource); + } + + this.effectiveUsername = osUsername; + return (true, this.DataSource with + { + Username = osUsername, + UsernamePasswordMode = DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD, + }); + } + private string RetrievalName(RetrievalInfo retrievalInfo) { var hasId = !string.IsNullOrWhiteSpace(retrievalInfo.Id); @@ -91,15 +113,19 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos { this.IsOperationInProgress = true; this.StateHasChanged(); + + var effectiveDataSourceResult = await this.CreateEffectiveDataSource(); + if (!effectiveDataSourceResult.Success) + return; - using var client = ERIClientFactory.Get(ERIVersion.V1, this.DataSource); + using var client = ERIClientFactory.Get(ERIVersion.V1, effectiveDataSourceResult.EffectiveDataSource); if(client is null) { this.dataIssues.Add(T("Failed to connect to the ERI v1 server. The server is not supported.")); return; } - var loginResult = await client.AuthenticateAsync(this.RustService); + var loginResult = await client.AuthenticateAsync(this.RustService, cancellationToken: this.cts.Token); if (!loginResult.Successful) { this.dataIssues.Add(loginResult.Message); diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBase.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBase.cs index b3179414..3fc5f45e 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBase.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBase.cs @@ -18,6 +18,9 @@ public abstract class SettingsDialogBase : MSGComponentBase [Inject] protected RustService RustService { get; init; } = null!; + + [Inject] + protected ISnackbar Snackbar { get; init; } = null!; protected readonly List> AvailableLLMProviders = new(); protected readonly List> AvailableEmbeddingProviders = new(); diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor index 060dc0ee..2f8600a8 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor @@ -43,6 +43,28 @@ + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + @if (context.FileAttachments.Count == 0) + { + + + + } + else + { + + + + @T("Use shared attachment paths") + + + @T("Copy attachments into plugin") + + + + } + } diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs index 579fff22..89473518 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs @@ -98,4 +98,66 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private async Task ExportChatTemplateWithSharedAttachmentPaths(ChatTemplate chatTemplate) + { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + + if (chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE || chatTemplate.IsEnterpriseConfiguration) + return; + + await this.CopyChatTemplateLuaToClipboard(chatTemplate); + } + + private async Task ExportChatTemplateWithPackagedAttachments(ChatTemplate chatTemplate) + { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + + if (chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE || chatTemplate.IsEnterpriseConfiguration) + return; + + if (chatTemplate.FileAttachments.Count == 0) + { + await this.ExportChatTemplateWithSharedAttachmentPaths(chatTemplate); + return; + } + + var pluginDirectoryResponse = await this.RustService.SelectDirectory(T("Select configuration plugin folder")); + if (pluginDirectoryResponse.UserCancelled) + return; + + await this.CopyPackagedChatTemplateLuaToClipboard(chatTemplate, pluginDirectoryResponse.SelectedDirectory); + } + + private async Task CopyChatTemplateLuaToClipboard(ChatTemplate chatTemplate) + { + if (!chatTemplate.TryExportAsConfigurationSection(out var luaCode, out var issue)) + { + await this.DialogService.ShowMessageBox( + T("Export Chat Template"), + issue, + T("Close")); + return; + } + + if (!string.IsNullOrWhiteSpace(luaCode)) + await this.RustService.CopyText2Clipboard(this.Snackbar, luaCode); + } + + private async Task CopyPackagedChatTemplateLuaToClipboard(ChatTemplate chatTemplate, string pluginDirectory) + { + if (!chatTemplate.TryExportAsConfigurationSectionWithPackagedAttachments(pluginDirectory, out var luaCode, out var issue)) + { + await this.DialogService.ShowMessageBox( + T("Export Chat Template"), + issue, + T("Close")); + return; + } + + if (!string.IsNullOrWhiteSpace(luaCode)) + await this.RustService.CopyText2Clipboard(this.Snackbar, luaCode); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor index 1f867e2a..11ef5b70 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor @@ -45,15 +45,30 @@ - - @T("Refresh") - - - @T("Edit") - - - @T("Delete") - + @if (context.IsEnterpriseConfiguration) + { + + + + } + else + { + + @T("Edit") + + + @T("Refresh") + + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings && context is DataSourceERI_V1) + { + + + + } + + @T("Delete") + + } diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs index 020a87d6..83bca086 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs @@ -1,9 +1,7 @@ using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.ERIClient.DataModel; -using AIStudio.Tools.Services; - -using Microsoft.AspNetCore.Components; +using AIStudio.Tools.PluginSystem; namespace AIStudio.Dialogs.Settings; @@ -126,9 +124,106 @@ public partial class SettingsDialogDataSources : SettingsDialogBase await this.DataSourceEmbeddingService.QueueDataSourceAsync(addedDataSource); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private async Task ExportDataSource(IDataSource dataSource) + { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + + if (dataSource is not DataSourceERI_V1 eriDataSource) + return; + + if (eriDataSource.AuthMethod is AuthMethod.KERBEROS) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + T("Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin."), + T("Close")); + return; + } + + var needsSecret = eriDataSource.AuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD; + if (!needsSecret) + { + var publicLuaCode = eriDataSource.ExportAsConfigurationSection(); + if (!string.IsNullOrWhiteSpace(publicLuaCode)) + await this.RustService.CopyText2Clipboard(this.Snackbar, publicLuaCode); + + return; + } + + var secretResponse = await this.RustService.GetSecret(eriDataSource, SecretStoreType.DATA_SOURCE, isTrying: true); + if (!secretResponse.Success) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + string.Format(T("Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}"), secretResponse.Issue), + T("Close")); + return; + } + + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable != true) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + T("Cannot export this ERI data source because no enterprise encryption secret is configured."), + T("Close")); + return; + } + + var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED; + if (eriDataSource.AuthMethod is AuthMethod.TOKEN) + { + var dialogParameters = new DialogParameters + { + { x => x.Message, T("This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token.") }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Export Access Token?"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + } + else if (eriDataSource.AuthMethod is AuthMethod.USERNAME_PASSWORD) + { + var dialogParameters = new DialogParameters + { + { x => x.DataSource, eriDataSource }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Export ERI Data Source"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not DataSourceERIV1UsernamePasswordExportDialogResult exportResult) + return; + + usernamePasswordMode = exportResult.UsernamePasswordMode; + } + + var decryptedSecret = await secretResponse.Secret.Decrypt(Program.ENCRYPTION); + if (!encryption.TryEncrypt(decryptedSecret, out var encryptedSecret)) + { + await this.DialogService.ShowMessageBox( + T("Export ERI Data Source"), + T("Cannot export this ERI data source because the authentication secret could not be encrypted."), + T("Close")); + return; + } + + var luaCode = eriDataSource.ExportAsConfigurationSection( + encryptedSecret, + usernamePasswordMode); + if (string.IsNullOrWhiteSpace(luaCode)) + return; + + await this.RustService.CopyText2Clipboard(this.Snackbar, luaCode); + } private async Task EditDataSource(IDataSource dataSource) { + if (dataSource.IsEnterpriseConfiguration) + return; + IDataSource? editedDataSource = null; switch (dataSource) { @@ -192,6 +287,9 @@ public partial class SettingsDialogDataSources : SettingsDialogBase private async Task DeleteDataSource(IDataSource dataSource) { + if (dataSource.IsEnterpriseConfiguration) + return; + var dialogParameters = new DialogParameters { { x => x.Message, string.Format(T("Are you sure you want to delete the data source '{0}' of type {1}?"), dataSource.Name, dataSource.Type.GetDisplayName()) }, @@ -215,7 +313,7 @@ public partial class SettingsDialogDataSources : SettingsDialogBase // All other auth methods require a secret, which we need to delete now: else { - var deleteSecretResponse = await this.RustService.DeleteSecret(externalDataSource); + var deleteSecretResponse = await this.RustService.DeleteSecret(externalDataSource, SecretStoreType.DATA_SOURCE); if (deleteSecretResponse.Success) applyChanges = true; } diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor index c251673b..784bfffc 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor @@ -42,6 +42,12 @@ + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + + + } diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs index 6547257c..4fb6c67a 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs @@ -49,6 +49,19 @@ public partial class SettingsDialogProfiles : SettingsDialogBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + private async Task ExportProfile(Profile profile) + { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + + if (profile == Profile.NO_PROFILE || profile.IsEnterpriseConfiguration) + return; + + var luaCode = profile.ExportAsConfigurationSection(); + if (!string.IsNullOrWhiteSpace(luaCode)) + await this.RustService.CopyText2Clipboard(this.Snackbar, luaCode); + } + private async Task DeleteProfile(Profile profile) { var dialogParameters = new DialogParameters diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor b/app/MindWork AI Studio/Layout/MainLayout.razor index aad5b4c8..37ba61cd 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor +++ b/app/MindWork AI Studio/Layout/MainLayout.razor @@ -17,7 +17,7 @@ @foreach (var navBarItem in this.navItems) { - + @navBarItem.Name } diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index f99446ff..adadce30 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using AIStudio.Dialogs; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.AIJobs; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; @@ -27,6 +28,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [Inject] private RustService RustService { get; init; } = null!; + + [Inject] + private AIJobService AIJobService { get; init; } = null!; [Inject] private ISnackbar Snackbar { get; init; } = null!; @@ -90,7 +94,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan // Read the user language from Rust: // var userLanguage = await this.RustService.ReadUserLanguage(); + var userName = await this.RustService.ReadUserName(); this.Logger.LogInformation($"The OS says '{userLanguage}' is the user language."); + this.Logger.LogInformation($"The OS says '{userName}' is the username."); // Ensure that all settings are loaded: await this.SettingsManager.LoadSettings(); @@ -102,7 +108,8 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [ Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR, Event.SHOW_WARNING, Event.SHOW_SUCCESS, Event.STARTUP_PLUGIN_SYSTEM, Event.PLUGINS_RELOADED, - Event.INSTALL_UPDATE, Event.STARTUP_COMPLETED, Event.RAG_EMBEDDING_STATUS_CHANGED, + Event.INSTALL_UPDATE, Event.STARTUP_COMPLETED, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, + Event.CHAT_GENERATION_CHANGED, Event.RAG_EMBEDDING_STATUS_CHANGED, ]); // Set the snackbar for the update service: @@ -194,6 +201,13 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan this.StateHasChanged(); break; + case Event.AI_JOB_CHANGED: + case Event.AI_JOB_FINISHED: + case Event.CHAT_GENERATION_CHANGED: + this.LoadNavItems(); + this.StateHasChanged(); + break; + case Event.SHOW_SUCCESS: if (data is DataSuccessMessage success) success.Show(this.Snackbar); @@ -311,7 +325,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager); yield return new(T("Home"), Icons.Material.Filled.Home, palette.DarkLighten, palette.GrayLight, Routes.HOME, true); - yield return new(T("Chat"), Icons.Material.Filled.Chat, palette.DarkLighten, palette.GrayLight, Routes.CHAT, false); + yield return new(T("Chat"), this.AIJobService.HasActiveJobs ? Icons.Material.Filled.Chat : Icons.Material.Outlined.Chat, palette.DarkLighten, palette.GrayLight, Routes.CHAT, false); yield return new(T("Assistants"), Icons.Material.Filled.Apps, palette.DarkLighten, palette.GrayLight, Routes.ASSISTANTS, false); if (PreviewFeatures.PRE_WRITER_MODE_2024.IsEnabled(this.SettingsManager)) diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index e214e7e6..7cebafb9 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -50,12 +50,12 @@ - + - + - + diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index b1b48dc3..f35a00a6 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -89,6 +89,7 @@ @@ -115,6 +116,7 @@ @@ -125,6 +127,7 @@ } diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 9adef5bb..0f271076 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -26,6 +26,7 @@ public partial class Chat : MSGComponentBase private string currentWorkspaceName = string.Empty; private Workspaces? workspaces; private double splitterPosition = 30; + private readonly ChatComposerState composerState = new(); private readonly Timer splitterSaveTimer = new(TimeSpan.FromSeconds(1.6)); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 4102f666..219e5896 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -47,6 +47,7 @@ + @switch (HasAnyActiveEnvironment) { @@ -278,11 +279,19 @@ + + @if (OperatingSystem.IsLinux()) + { + + } + - + + + - + @@ -299,6 +308,7 @@ + @@ -314,4 +324,4 @@ -
\ No newline at end of file +
diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index 302e042e..0cc199e0 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -29,17 +29,18 @@ public partial class Information : MSGComponentBase private ISnackbar Snackbar { get; init; } = null!; [Inject] - private EmbeddingStore EmbeddingStore { get; init; } = null!; + private DatabaseClient DatabaseClient { get; init; } = null!; private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute()!; private static readonly MetaDataArchitectureAttribute META_DATA_ARCH = ASSEMBLY.GetCustomAttribute()!; private static readonly MetaDataLibrariesAttribute META_DATA_LIBRARIES = ASSEMBLY.GetCustomAttribute()!; - private static readonly MetaDataDatabasesAttribute META_DATA_DATABASES = ASSEMBLY.GetCustomAttribute()!; + private static readonly MetaDataEmbeddingStoreAttribute META_DATA_EMBEDDING_STORE = ASSEMBLY.GetCustomAttribute()!; private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(Information).Namespace, nameof(Information)); private string osLanguage = string.Empty; + private string osUserName = string.Empty; private static string VersionApp => $"MindWork AI Studio: v{META_DATA.Version} (commit {META_DATA.AppCommitHash}, build {META_DATA.BuildNum}, {META_DATA_ARCH.Architecture.ToRID().ToUserFriendlyName()})"; @@ -49,6 +50,8 @@ public partial class Information : MSGComponentBase private string OSLanguage => $"{T("User-language provided by the OS")}: '{this.osLanguage}'"; + private string OSUserName => $"{T("Username provided by the OS")}: '{this.osUserName}'"; + private string VersionRust => $"{T("Used Rust compiler")}: v{META_DATA.RustVersion}"; private string VersionDotnetRuntime => $"{T("Used .NET runtime")}: v{META_DATA.DotnetVersion}"; @@ -59,9 +62,21 @@ public partial class Information : MSGComponentBase private string VersionPdfium => $"{T("Used PDFium version")}: v{META_DATA_LIBRARIES.PdfiumVersion}"; - private string VersionDatabase => this.EmbeddingStore.IsAvailable - ? $"{T("Database version")}: {this.EmbeddingStore.Name} v{META_DATA_DATABASES.DatabaseVersion}" - : $"{T("Database")}: {this.EmbeddingStore.Name} - {T("not available")}"; + private string VersionEmbeddingStore + { + get + { + if (this.embeddingStore is null) + return $"{T("Embedding store")}: {T("checking availability")}"; + + return this.embeddingStore.Status switch + { + EmbeddingStoreStatus.AVAILABLE => $"{T("Embedding store version")}: {this.embeddingStore.Name} v{META_DATA_EMBEDDING_STORE.DatabaseVersion}", + EmbeddingStoreStatus.STARTING => $"{T("Embedding store")}: {this.embeddingStore.Name} - {T("starting")}", + _ => $"{T("Embedding store")}: {this.embeddingStore.Name} - {T("not available")}" + }; + } + } private string versionPandoc = TB("Determine Pandoc version, please wait..."); private PandocInstallation pandocInstallation; @@ -70,7 +85,7 @@ public partial class Information : MSGComponentBase private bool showEnterpriseConfigDetails; - private bool showDatabaseDetails; + private bool showEmbeddingStoreDetails; private List configPlugins = PluginFactory.AvailablePlugins .Where(x => x.Type is PluginType.CONFIGURATION) @@ -81,11 +96,13 @@ public partial class Information : MSGComponentBase private List mandatoryInfoPanels = []; - private sealed record DatabaseDisplayInfo(string Label, string Value); + private sealed record EmbeddingStoreDisplayInfo(string Label, string Value); private sealed record MandatoryInfoPanelData(string HeaderText, string PluginName, DataMandatoryInfo Info, DataMandatoryInfoAcceptance? Acceptance); - private readonly List databaseDisplayInfo = new(); + private readonly List embeddingStoreDisplayInfo = new(); + private DatabaseClient? embeddingStore; + private CancellationTokenSource? databaseRefreshCancellationTokenSource; private bool HasAnyActiveEnvironment => this.enterpriseEnvironments.Any(e => e.IsActive); @@ -128,12 +145,12 @@ public partial class Information : MSGComponentBase this.RefreshEnterpriseConfigurationState(); this.osLanguage = await this.RustService.ReadUserLanguage(); + this.osUserName = await this.RustService.ReadUserName(); this.logPaths = await this.RustService.GetLogPaths(); - await foreach (var (label, value) in this.EmbeddingStore.GetDisplayInfo()) - { - this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value)); - } + await this.RefreshDatabaseInfo(CancellationToken.None); + if (this.databaseClient?.Status is DatabaseClientStatus.STARTING) + this.StartShortDatabaseRefreshLoop(); // Determine the Pandoc version may take some time, so we start it here // without waiting for the result: @@ -234,7 +251,70 @@ public partial class Information : MSGComponentBase private void ToggleDatabaseDetails() { - this.showDatabaseDetails = !this.showDatabaseDetails; + this.showEmbeddingStoreDetails = !this.showEmbeddingStoreDetails; + } + + private async Task RefreshDatabaseInfo(CancellationToken cancellationToken) + { + var refreshedClient = await this.DatabaseClientProvider.RefreshClientAsync(DatabaseRole.VECTOR_STORE, cancellationToken); + this.databaseClient = refreshedClient; + this.embeddingStoreDisplayInfo.Clear(); + + try + { + await foreach (var (label, value) in refreshedClient.GetDisplayInfo().WithCancellation(cancellationToken)) + { + this.embeddingStoreDisplayInfo.Add(new EmbeddingStoreDisplayInfo(label, value)); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + this.databaseClient = new NoDatabaseClient(refreshedClient.Name, e.Message, DatabaseClientStatus.STARTING); + await foreach (var (label, value) in this.databaseClient.GetDisplayInfo().WithCancellation(cancellationToken)) + { + this.embeddingStoreDisplayInfo.Add(new EmbeddingStoreDisplayInfo(label, value)); + } + } + } + + private void StartShortDatabaseRefreshLoop() + { + this.databaseRefreshCancellationTokenSource?.Cancel(); + this.databaseRefreshCancellationTokenSource?.Dispose(); + this.databaseRefreshCancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = this.databaseRefreshCancellationTokenSource.Token; + + _ = Task.Run(async () => + { + const int MAX_TRIES = 12; + for (var attempt = 0; attempt < MAX_TRIES; attempt++) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + await this.InvokeAsync(async () => + { + await this.RefreshDatabaseInfo(cancellationToken); + this.StateHasChanged(); + }); + + if (this.databaseClient?.Status is not DatabaseClientStatus.STARTING) + return; + } + catch (OperationCanceledException) + { + return; + } + catch + { + return; + } + } + }, cancellationToken); } private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId) @@ -249,6 +329,13 @@ public partial class Information : MSGComponentBase return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId; } + protected override void DisposeResources() + { + this.databaseRefreshCancellationTokenSource?.Cancel(); + this.databaseRefreshCancellationTokenSource?.Dispose(); + base.DisposeResources(); + } + private async Task CopyStartupLogPath() { await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath); diff --git a/app/MindWork AI Studio/Pages/Writer.razor.cs b/app/MindWork AI Studio/Pages/Writer.razor.cs index 9f1dcd26..a2a70ea3 100644 --- a/app/MindWork AI Studio/Pages/Writer.razor.cs +++ b/app/MindWork AI Studio/Pages/Writer.razor.cs @@ -10,6 +10,7 @@ namespace AIStudio.Pages; public partial class Writer : MSGComponentBase { + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); private readonly Timer typeTimer = new(TimeSpan.FromMilliseconds(1_500)); @@ -106,22 +107,38 @@ public partial class Writer : MSGComponentBase InitialRemoteWait = true, }; - this.chatThread?.Blocks.Add(new ContentBlock + var aiBlock = new ContentBlock { Time = time, ContentType = ContentType.TEXT, Role = ChatRole.AI, Content = aiText, - }); + }; + + this.chatThread?.Blocks.Add(aiBlock); this.isStreaming = true; this.StateHasChanged(); - - this.chatThread = await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(), this.providerSettings.Model, lastUserPrompt, this.chatThread); - this.suggestion = aiText.Text; - - this.isStreaming = false; - this.StateHasChanged(); + + try + { + this.chatThread = await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(), this.providerSettings.Model, lastUserPrompt, this.chatThread); + this.suggestion = aiText.Text; + } + catch (ProviderRequestException e) + { + LOGGER.LogError(e, "The provider request failed for writer suggestions. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", e.StatusCode, e.ReasonPhrase, e.ResponseBody); + await this.MessageBus.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); + this.suggestion = string.Empty; + + if (string.IsNullOrWhiteSpace(aiText.Text)) + this.chatThread?.Blocks.Remove(aiBlock); + } + finally + { + this.isStreaming = false; + this.StateHasChanged(); + } } private void AcceptEntireSuggestion() diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 2f2c5394..9075425f 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -142,6 +142,54 @@ CONFIG["EMBEDDING_PROVIDERS"] = {} -- } -- } +-- ERI v1 data sources for retrieval-augmented generation: +CONFIG["DATA_SOURCES"] = {} + +-- Example: ERI v1 data source with a shared access token. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "TOKEN", +-- ["Token"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + +-- Example: ERI v1 data source with a shared username and password. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "USERNAME_PASSWORD", +-- ["UsernamePasswordMode"] = "SHARED_USERNAME_AND_PASSWORD", +-- ["Username"] = "", +-- ["Password"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + +-- Example: ERI v1 data source using the user's username and a shared password. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "USERNAME_PASSWORD", +-- ["UsernamePasswordMode"] = "OS_USERNAME_SHARED_PASSWORD", +-- ["Password"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + CONFIG["SETTINGS"] = {} -- Configure the update check interval: @@ -178,9 +226,9 @@ CONFIG["SETTINGS"] = {} -- CONFIG["SETTINGS"]["DataApp.PreviewVisibility"] = "NONE" -- Configure the enabled preview features: --- Allowed values are can be found in https://github.com/MindWorkAI/AI-Studio/app/MindWork%20AI%20Studio/Settings/DataModel/PreviewFeatures.cs --- Examples are PRE_WRITER_MODE_2024, PRE_RAG_2024, PRE_SPEECH_TO_TEXT_2026. --- CONFIG["SETTINGS"]["DataApp.EnabledPreviewFeatures"] = { "PRE_RAG_2024", "PRE_SPEECH_TO_TEXT_2026" } +-- Allowed values are can be found in https://github.com/MindWorkAI/AI-Studio/blob/main/app/MindWork%20AI%20Studio/Settings/DataModel/PreviewFeatures.cs +-- Examples are PRE_WRITER_MODE_2024 and PRE_RAG_2024. +-- CONFIG["SETTINGS"]["DataApp.EnabledPreviewFeatures"] = { "PRE_RAG_2024" } -- Configure the preselected provider. -- It must be one of the provider IDs defined in CONFIG["LLM_PROVIDERS"]. @@ -218,6 +266,10 @@ CONFIG["SETTINGS"] = {} -- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8" -- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1" +-- Configure the HTTP timeout for external requests, in seconds. +-- The default is 3600 (1 hour). +-- CONFIG["SETTINGS"]["DataApp.HttpClientTimeoutSeconds"] = 3600 + -- Example chat templates for this configuration: CONFIG["CHAT_TEMPLATES"] = {} @@ -252,7 +304,8 @@ CONFIG["CHAT_TEMPLATES"] = {} -- ["AllowProfileUsage"] = true, -- -- Optional: Pre-attach files that will be automatically included when using this template. -- -- These files will be loaded when the user selects this chat template. --- -- Note: File paths must be absolute paths and accessible to all users. +-- -- Note: File paths can be absolute paths that are accessible to all users, or relative paths +-- -- inside this plugin folder, for example "attachments/00000000-0000-0000-0000-000000000001/Guidelines.pdf". -- ["FileAttachments"] = { -- "G:\\Company\\Documents\\Guidelines.pdf", -- "G:\\Company\\Documents\\CompanyPolicies.docx" diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 8cad2845..a1646515 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -2649,6 +2649,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Möchten Sie eines ihrer Profile als Standard für die gesamte App festlegen? Wenn Sie einem Assistenten ein anderes Profil zuweisen, hat dieses immer Vorrang." +-- seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"] = "Sekunden" + -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Wählen Sie für die Transkription Ihrer Stimme einen Anbieter für Transkriptionen aus. Ohne einen ausgewählten Anbieter wird die Diktier- und Transkriptions-Funktion deaktiviert." @@ -2697,6 +2700,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] -- Spellchecking is enabled UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] = "Rechtschreibprüfung ist aktiviert" +-- Request timeout +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3569531009"] = "Zeitüberschreitung bei der Anfrage" + -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App-Einstellungen" @@ -2724,6 +2730,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] -- Select a transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4174666315"] = "Wählen Sie einen Transkriptionsanbieter aus" +-- How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4192032183"] = "Wie lange AI Studio auf externe HTTP-Anfragen wartet, z. B. an KI-Anbieter, Einbettungen, Transkription, ERI-Datenquellen und Downloads von Enterprise-Konfigurationen." + -- Navigation bar behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Verhalten der Navigationsleiste" @@ -3123,6 +3132,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Beginnen -- Transcription in progress... UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2851219233"] = "Transkription läuft …" +-- Unfortunately, there was an error communicating with the AI system. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3236134591"] = "Leider ist bei der Kommunikation mit dem KI-System ein Fehler aufgetreten." + -- The configured transcription provider was not found. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T331613105"] = "Der konfigurierte Anbieter für die Transkription wurde nicht gefunden." @@ -3636,6 +3648,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2879113658"] = -- Maximum matches per query UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2889706179"] = "Maximale Treffer pro Abfrage" +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2909734556"] = "Der Benutzername des Nutzers konnte nicht aus dem Betriebssystem gelesen werden." + -- Open web link, show more information UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2968752071"] = "Weblink öffnen & mehr Informationen anzeigen" @@ -3687,6 +3702,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = " -- Embeddings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Einbettungen" +-- Use the same username and password for all users +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Für alle Benutzer denselben Benutzernamen und dasselbe Passwort verwenden" + +-- Username and password mode +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Modus für den Benutzernamen und das Passwort" + +-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "Wie soll AI Studio die Konfiguration von Benutzername und Passwort für die ERI-v1-Datenquelle „{0}“ exportieren?" + +-- User-managed username and password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "Vom Benutzer verwaltete Anmeldedaten (Benutzername und Passwort)" + +-- Export +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Exportieren" + +-- Read each user's username from the operating system and share one password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Den Benutzernamen jedes Benutzers aus dem Betriebssystem auslesen und ein Passwort teilen." + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Abbrechen" + -- Describe what data this directory contains to help the AI select it. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1136409150"] = "Beschreiben Sie, welche Daten dieses Verzeichnis enthält, um der KI bei der Auswahl zu helfen." @@ -4722,6 +4758,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T582516016"] = -- Customize your AI experience with chat templates. Whether you want to experiment with prompt engineering, simply use a custom system prompt in the standard chat interface, or create a specialized assistant, chat templates give you full control. Similar to common AI companies' playgrounds, you can define your own system prompts and leverage assistant prompts for providers that support them. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1172171653"] = "Passen Sie ihre KI-Erfahrung mit Chat-Vorlagen an. Egal, ob Sie mit Prompt-Engineering experimentieren, einfach einen eigenen System-Prompt im normalen Chat verwenden oder einen spezialisierten Assistenten erstellen möchten – mit Chat-Vorlagen haben Sie die volle Kontrolle. Ähnlich wie in den Playgrounds gängiger KI-Anbieter können Sie eigene System-Prompts festlegen und bei unterstützenden Anbietern auch Assistenten-Prompts nutzen." +-- Copy attachments into plugin +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1345613295"] = "Anhänge in das Plugin kopieren" + -- Delete UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1469573738"] = "Löschen" @@ -4731,6 +4770,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T15483 -- Note: This advanced feature is designed for users familiar with prompt engineering concepts. Furthermore, you have to make sure yourself that your chosen provider supports the use of assistant prompts. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1909110760"] = "Hinweis: Diese fortgeschrittene Funktion richtet sich an Nutzer, die mit den Grundlagen des Prompt Engineerings vertraut sind. Außerdem müssen Sie selbst sicherstellen, dass Ihr gewählter Anbieter die Verwendung von Assistenten-Prompts unterstützt." +-- Use shared attachment paths +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T2054531878"] = "Gemeinsame Pfade für Anhänge verwenden" + -- No chat templates configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T2319860307"] = "Noch keine Chat-Vorlagen konfiguriert." @@ -4749,6 +4791,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T34481 -- This template is managed by your organization. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T3576775249"] = "Diesee Vorlage wird von Ihrer Organisation verwaltet." +-- Select configuration plugin folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T3576816894"] = "Konfigurationsordner für Plugins auswählen" + -- Edit Chat Template UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T3596030597"] = "Chat-Vorlage bearbeiten" @@ -4761,6 +4806,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T38650 -- Delete Chat Template UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T4025180906"] = "Chat-Vorlage löschen" +-- Export Chat Template +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T491504763"] = "Chat-Vorlage exportieren" + +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T975426229"] = "Konfiguration exportieren" + -- Which programming language should be preselected for added contexts? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCODING::T1073540083"] = "Welche Programmiersprache soll für hinzugefügte Kontexte vorausgewählt werden?" @@ -4815,6 +4866,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T145419 -- Delete UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1469573738"] = "Löschen" +-- Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1577531115"] = "Kerberos-/SSO-ERI-Datenquellen können noch nicht exportiert werden. Bitte konfigurieren Sie diese manuell im Konfigurations-Plugin." + +-- Cannot export this ERI data source because the authentication secret could not be encrypted. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1592527757"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da das Authentifizierungsgeheimnis nicht verschlüsselt werden konnte." + -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1652430727"] = "Extern (ERI)" @@ -4845,6 +4902,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T269820 -- Embedding UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T2838542994"] = "Einbettung" +-- This data source is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3031462878"] = "Diese Datenquelle wird von Ihrer Organisation verwaltet." + -- Edit UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3267849393"] = "Bearbeiten" @@ -4869,21 +4929,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T352566 -- No data sources configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3549650120"] = "Noch keine Datenquellen konfiguriert." +-- Export Access Token? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3595669127"] = "Zugriffstoken exportieren?" + +-- Export ERI Data Source +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3831281036"] = "ERI-Datenquelle exportieren" + -- Actions UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3865031940"] = "Aktionen" +-- This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T4027572258"] = "Für diese ERI-Datenquelle ist ein Zugriffstoken konfiguriert. Möchten Sie das verschlüsselte Zugriffstoken in den Export aufnehmen? Hinweis: Der Empfänger benötigt dasselbe Geheimnis für die Verschlüsselung, um das Zugriffstoken verwenden zu können." + -- Configured Data Sources UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T543942217"] = "Konfigurierte Datenquellen" -- Add ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T590005498"] = "ERI v1 Datenquelle hinzufügen" +-- Cannot export this ERI data source because no enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T750361472"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da kein Geheimnis für die Verschlüsselung konfiguriert ist." + -- External Data (ERI-Server v1) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473996"] = "Externe Daten (ERI-Server v1)" +-- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T782820095"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da kein Authentifizierungsgeheimnis konfiguriert ist. Das Problem war: {0}" + -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Lokaler Ordner" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T975426229"] = "Konfiguration exportieren" + -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "Wenn aktiviert, können Sie einige ERI-Serveroptionen vorauswählen." @@ -5169,6 +5247,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T55364659" -- Are you a project manager in a research facility? You might want to create a profile for your project management activities, one for your scientific work, and a profile for when you need to write program code. In these profiles, you can record how much experience you have or which methods you like or dislike using. Later, you can choose when and where you want to use each profile. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T56359901"] = "Sind Sie Projektleiter in einer Forschungseinrichtung? Dann möchten Sie vielleicht ein Profil für ihre Projektmanagement-Aktivitäten anlegen, eines für ihre wissenschaftliche Arbeit und ein weiteres Profil, wenn Sie Programmcode schreiben müssen. In diesen Profilen können Sie festhalten, wie viel Erfahrung Sie haben oder welche Methoden Sie bevorzugen oder nicht gerne verwenden. Später können Sie dann auswählen, wann und wo Sie jedes Profil nutzen möchten." +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T975426229"] = "Konfiguration exportieren" + -- Preselect the target language UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROMPTOPTIMIZER::T1417990312"] = "Zielsprache vorwählen" @@ -6027,18 +6108,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1890416390"] = "Nach Updates suc -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1892426825"] = "Vision" --- In order to use any LLM, each user must store their so-called API key for each LLM provider. This key must be kept secure, similar to a password. The safest way to do this is offered by operating systems like macOS, Windows, and Linux: They have mechanisms to store such data, if available, on special security hardware. Since this is currently not possible in .NET, we use this Rust library. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "Um ein beliebiges LLM nutzen zu können, muss jeder User seinen sogenannten API-Schlüssel für jeden LLM-Anbieter speichern. Dieser Schlüssel muss sicher aufbewahrt werden – ähnlich wie ein Passwort. Die sicherste Methode hierfür bieten Betriebssysteme wie macOS, Windows und Linux: Sie verfügen über Mechanismen, solche Daten – sofern vorhanden – auf spezieller Sicherheits-Hardware zu speichern. Da dies derzeit in .NET nicht möglich ist, verwenden wir diese Rust-Bibliothek." - -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "Diese Bibliothek wird verwendet, um HTML in Markdown umzuwandeln. Das ist zum Beispiel notwendig, wenn Sie eine URL als Eingabe für einen Assistenten angeben." -- Encryption secret: is configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Geheimnis für die Verschlüsselung: ist konfiguriert" --- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "Wir verwenden Rocket zur Implementierung der Runtime-API. Dies ist notwendig, da die Runtime mit der Benutzeroberfläche (IPC) kommunizieren muss. Rocket ist ein ausgezeichnetes Framework zur Umsetzung von Web-APIs in Rust." - -- Copies the following to the clipboard UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Kopiert Folgendes in die Zwischenablage" @@ -6066,6 +6141,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Konfigurations-P -- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "Die Programmiersprache C# wird für die Umsetzung der Benutzeroberfläche und des Backends verwendet. Für die Entwicklung der Benutzeroberfläche mit C# kommt die Blazor-Technologie aus ASP.NET Core zum Einsatz. Alle diese Technologien sind im .NET SDK integriert." +-- Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux-AppImages bündeln GStreamer-Komponenten, um den Mikrofonzugriff und WebM-Audioaufnahmen in der eingebetteten WebKitGTK-Webansicht zu unterstützen." + -- Used PDFium version UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2368247719"] = "Verwendete PDFium-Version" @@ -6120,6 +6198,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840227993"] = "Verwendete .NET- -- Explanation UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Erklärung" +-- checking availability +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2855535668"] = "Verfügbarkeit wird geprüft" + -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "Das .NET-Backend kann nicht als Desktop-App gestartet werden. Deshalb verwende ich ein zweites Backend in Rust, das ich „Runtime“ nenne. Mit Rust als Runtime kann Tauri genutzt werden, um eine typische Desktop-App zu realisieren. Dank Rust kann diese App für Windows-, macOS- und Linux-Desktops angeboten werden. Rust ist eine großartige Sprache für die Entwicklung sicherer und leistungsstarker Software." @@ -6141,6 +6222,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3178730036"] = "Haben Sie Ideen -- Hide Details UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3183837919"] = "Details ausblenden" +-- Axum server runs the internal axum service over a secure local connection. This helps AI Studio protect the communication between the Rust runtime and the user interface. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3208719461"] = "Der Axum-Server führt den internen Axum-Dienst über eine sichere lokale Verbindung aus. Dadurch kann AI Studio die Kommunikation zwischen der Rust-Laufzeitumgebung und der Benutzeroberfläche schützen." + +-- Rustls helps secure the internal connection between the app's user interface and the Rust runtime. This protects the local communication that AI Studio needs while it is running. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3239817808"] = "Rustls hilft dabei, die interne Verbindung zwischen der Benutzeroberfläche der App und der Rust-Laufzeitumgebung abzusichern. Dadurch wird die lokale Kommunikation geschützt, die AI Studio während der Ausführung benötigt." + -- Update Pandoc UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3249965383"] = "Pandoc aktualisieren" @@ -6165,6 +6252,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio wird m -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri wird verwendet, um die Blazor-Benutzeroberfläche bereitzustellen. Es ist ein großartiges Projekt, das die Erstellung von Desktop-Anwendungen mit Webtechnologien ermöglicht. Ich liebe Tauri!" +-- AI Studio stores secrets like API keys in your operating system’s secure credential store. The keyring-core library handles this by connecting to macOS Keychain, Windows Credential Manager, and Linux Secret Service. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3527399572"] = "AI Studio speichert vertrauliche Daten wie API-Schlüssel im sicheren Speicher Ihres Betriebssystems. Die Bibliothek keyring-core übernimmt dies, indem sie eine Verbindung zum macOS-Schlüsselbund, zur Windows-Anmeldeinformationsverwaltung und zum Linux Secret Service herstellt." + -- Motivation UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" @@ -6174,6 +6264,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3574465749"] = "nicht verfügbar -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "Diese Bibliothek wird verwendet, um Excel- und OpenDocument-Tabellendateien zu lesen. Dies ist zum Beispiel notwendig, wenn Tabellen als Datenquelle für einen Chat verwendet werden sollen." +-- Username provided by the OS +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3764549776"] = "Vom Betriebssystem bereitgestellter Benutzername" + -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "diese Version erfüllt die Anforderungen nicht" @@ -6195,6 +6288,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4010195468"] = "Versionen" -- Database UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4036243672"] = "Datenbank" +-- This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "Diese Bibliothek wird von der Rust-Laufzeitumgebung verwendet, um den Benutzernamen des aktuellen Benutzers auszulesen, z. B. wenn ein von einer Organisation verwalteter ERI-Server den OS-Benutzernamen für die Authentifizierung verwendet." + -- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "Diese Bibliothek wird verwendet, um asynchrone Datenströme in Rust zu erstellen. Sie ermöglicht es uns, mit Datenströmen zu arbeiten, die asynchron bereitgestellt werden, wodurch sich Ereignisse oder Daten, die nach und nach eintreffen, leichter verarbeiten lassen. Wir nutzen dies zum Beispiel, um beliebige Daten aus dem Dateisystem an das Einbettungssystem zu übertragen." @@ -6215,6 +6311,8 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Verwendetes .NET -- We use the DeepSeek Tokenizer to estimate the number of tokens an input will generate. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T591393704"] = "Wir verwenden den DeepSeek‑Tokenizer, um die Token‑Anzahl einer Eingabe zu schätzen." +-- starting +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T594602073"] = "wird gestartet" -- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "Diese Bibliothek wird verwendet, um Sidecar-Prozesse zu verwalten und sicherzustellen, dass veraltete oder Zombie-Sidecars erkannt und beendet werden." @@ -6237,6 +6335,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T836298648"] = "Bereitgestellt vo -- We use this library to be able to read PowerPoint files. This allows us to insert content from slides into prompts and take PowerPoint files into account in RAG processes. We thank Nils Kruthoff for his work on this Rust crate. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T855925638"] = "Wir verwenden diese Bibliothek, um PowerPoint-Dateien lesen zu können. So ist es möglich, Inhalte aus Folien in Prompts einzufügen und PowerPoint-Dateien in RAG-Prozessen zu berücksichtigen. Wir danken Nils Kruthoff für seine Arbeit an diesem Rust-Crate." +-- Axum is used to provide the small internal service that connects the Rust runtime with the app's user interface. This lets both parts of AI Studio exchange information while the app is running. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T864851737"] = "Axum wird verwendet, um den kleinen internen Dienst bereitzustellen, der die Rust-Laufzeitumgebung mit der Benutzeroberfläche der App verbindet. So können beide Teile von AI Studio Informationen austauschen, während die App läuft." + -- For some data transfers, we need to encode the data in base64. This Rust library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T870640199"] = "Für einige Datenübertragungen müssen wir die Daten in Base64 kodieren. Diese Rust-Bibliothek eignet sich dafür hervorragend." @@ -6378,6 +6479,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Ihre Regieanweisungen" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "Wir haben versucht, mit dem LLM-Anbieter „{0}“ (Typ={1}) zu kommunizieren. Der Server ist möglicherweise nicht erreichbar oder hat Probleme. Die Nachricht des Anbieters lautet: „{2}“" +-- The provider '{0}' reported an error while streaming the response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1008706234"] = "Der Anbieter „{0}“ hat einen Fehler beim Streamen der Antwort gemeldet." + +-- The provider rejected the request because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1028424693"] = "Der Anbieter hat die Anfrage abgelehnt, weil zu viele Anfragen gesendet wurden. Bitte warten Sie einen Moment und versuchen Sie es erneut." + +-- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "Die Anfrage an den LLM-Anbieter „{0}“ (Typ={1}) hat nach {2} während „{3}“ das Zeitlimit überschritten. Bitte versuchen Sie es erneut oder prüfen Sie, ob der Anbieter noch antwortet." + -- Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1487597412"] = "Beim Versuch, die Antwort des LLM-Anbieters '{0}' zu streamen, sind Probleme aufgetreten. Die Meldung lautet: '{1}'" @@ -6408,6 +6518,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T3759732886"] = "Wir haben ve -- We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "Wir haben versucht, mit dem LLM-Anbieter „{0}“ (Typ={1}) zu kommunizieren. Die Daten des Chats, einschließlich aller Dateianhänge, sind vermutlich zu groß für das ausgewählte Modell und den Anbieter. Die Nachricht des Anbieters lautet: „{2}“" +-- The provider '{0}' reported an error: {1} +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "Der Anbieter „{0}“ hat einen Fehler gemeldet: {1}" + -- The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T1014558951"] = "Das Vertrauensniveau dieses Anbieters wurde **noch nicht** gründlich **untersucht und bewertet**. Wir wissen nicht, ob ihre Daten sicher sind." @@ -6468,6 +6581,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "Kein Modell ausgew -- We could not load models from '{0}'. The account or API key does not have the required permissions. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "Wir konnten keine Modelle von '{0}' laden. Das Konto oder der API-Schlüssel verfügt nicht über die erforderlichen Berechtigungen." +-- We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T155481725"] = "Wir konnten keine Modelle von „{0}“ laden, da zu viele Anfragen gesendet wurden. Bitte warten Sie einen Moment und versuchen Sie es erneut." + -- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "Modelle aus '{0}' konnten nicht geladen werden. Wahrscheinlich fehlt der API-Schlüssel, ist ungültig oder abgelaufen." @@ -6477,15 +6593,39 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T21156887 -- We could not load models from '{0}' because the provider returned an unexpected response. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "Wir konnten keine Modelle von '{0}' laden, da der Anbieter eine unerwartete Antwort zurückgegeben hat." +-- We could not load models from '{0}' because the account appears to have no API credits left. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T373339048"] = "Modelle konnten nicht von „{0}“ geladen werden, da das Konto offenbar keine API-Guthaben mehr hat." + -- We could not load models from '{0}' due to an unknown error. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "Wir konnten die Modelle aus '{0}' aufgrund eines unbekannten Fehlers nicht laden." +-- It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::OPENAI::PROVIDEROPENAI::T757371511"] = "Anscheinend haben Sie bei OpenAI kein API-Guthaben mehr. Bitte fügen Sie Ihrem Konto Guthaben hinzu und versuchen Sie es erneut." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Modell wie in whisper.cpp konfiguriert" +-- Cannot export this chat template because example message {0} is not a text message. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T1861800849"] = "Diese Chatvorlage kann nicht exportiert werden, da die Beispielnachricht {0} keine Textnachricht ist." + +-- Cannot export this chat template because example message {0} uses a role that is not supported by configuration plugins. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T2407395493"] = "Diese Chat-Vorlage kann nicht exportiert werden, da die Beispielnachricht {0} eine Rolle verwendet, die von Konfigurations-Plugins nicht unterstützt wird." + +-- Please select a valid configuration plugin folder. The folder must contain a plugin.lua file. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T2542895569"] = "Bitte wählen Sie einen gültigen Konfigurations-Plug-in-Ordner aus. Der Ordner muss eine Datei „plugin.lua“ enthalten." + +-- Cannot package the chat template attachments. The issue was: {0} +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T3635593138"] = "Die Anhänge der Chat-Vorlage können nicht verpackt werden. Das Problem war: {0}" + +-- Cannot package the attachment '{0}' because the file does not exist. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T4121340492"] = "Der Anhang „{0}“ kann nicht gepackt werden, da die Datei nicht existiert." + -- Use no chat template UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T4258819635"] = "Keine Chat-Vorlage verwenden" +-- Cannot export this chat template because example message {0} is empty. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T477540958"] = "Diese Chatvorlage kann nicht exportiert werden, da die Beispielnachricht {0} leer ist." + -- Navigation never expands, but there are tooltips UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1095779033"] = "Die Navigationsleiste wird nie ausgeklappt, aber es gibt Tooltips" @@ -6681,8 +6821,8 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2708 -- Unknown preview feature UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2722827307"] = "Unbekannte Vorschau-Funktion" --- Transcription: Preview of our speech to text system where you can transcribe recordings and audio files into text -UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T714355911"] = "Transkription: Vorschau unseres Sprache-zu-Text-Systems, mit dem Sie Aufnahmen und Audiodateien in Text transkribieren können" +-- Transcription: Convert recordings and audio files into text +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T4247148645"] = "Transkription: Aufnahmen und Audiodateien in Text umwandeln" -- Use no data sources, when sending an assistant result to a chat UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::SENDTOCHATDATASOURCEBEHAVIOREXTENSIONS::T1223925477"] = "Keine Datenquellen vorauswählen, wenn ein Ergebnis von einem Assistenten an einen neuen Chat gesendet wird" @@ -6708,6 +6848,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = -- Use no profile UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Kein Profil verwenden" +-- The selected model is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T1578005752"] = "Das ausgewählte Modell ist nicht verfügbar." + +-- The selected provider is not allowed for this chat. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T174545104"] = "Der ausgewählte Anbieter ist für diesen Chat nicht zulässig." + +-- The AI job failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T237448388"] = "Der KI-Auftrag ist fehlgeschlagen. Die Meldung lautet: „{0}“" + +-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "Das ausgewählte Modell „{0}“ ist bei „{1}“ nicht mehr verfügbar (Anbieter={2}). Bitte passen Sie Ihre Anbietereinstellungen an." + +-- We could load models from '{0}', but the provider did not return any usable text models. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "Wir konnten Modelle von „{0}“ laden, aber der Anbieter hat keine verwendbaren Textmodelle zurückgegeben." + -- SSO (Kerberos) UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)" @@ -6849,6 +7004,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = " -- Reason UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Grund" +-- Starting +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1233211769"] = "Wird gestartet" + -- Unavailable UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T3662391977"] = "Nicht verfügbar" @@ -6933,6 +7091,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2858189239"] = "Authe -- Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T286437836"] = "Die Sicherheitsanforderungen konnten nicht abgerufen werden: Die Anfrage wurde entweder vom Benutzer abgebrochen oder ist aufgrund eines Zeitüberschreitungsfehlers fehlgeschlagen." +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2909734556"] = "Der Benutzername konnte nicht aus dem Betriebssystem ausgelesen werden." + -- Failed to retrieve the security requirements due to an exception: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T3221004295"] = "Die Sicherheitsanforderungen konnten wegen eines Problems nicht abgerufen werden: {0}" @@ -6978,6 +7139,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T816853779"] = "Fehler -- Failed to retrieve the authentication methods: the ERI server did not return a valid response. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T984407320"] = "Fehler beim Abrufen der Authentifizierungsmethoden: Der ERI-Server hat keine gültige Antwort zurückgegeben." +-- AI Studio couldn't install Pandoc because the archive was not found. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio konnte Pandoc nicht installieren, da das Archiv nicht gefunden wurde." + +-- Pandoc doesn't seem to be installed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1090474732"] = "Pandoc scheint nicht installiert zu sein." + -- Was not able to validate the Pandoc installation. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1364844008"] = "Die Pandoc-Installation konnte nicht überprüft werden." @@ -6999,20 +7166,20 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T2550598062"] = "Pandoc v{0} ist insta -- Pandoc v{0} is installed, but it does not match the required version (v{1}). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T2555465873"] = "Pandoc v{0} ist installiert, entspricht aber nicht der benötigten Version (v{1})." --- Pandoc was not installed successfully, because the archive was not found. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T34210248"] = "Pandoc wurde nicht erfolgreich installiert, da das Archiv nicht gefunden wurde." +-- AI Studio couldn't install Pandoc because the archive type is unknown. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T3492710362"] = "AI Studio konnte Pandoc nicht installieren, da der Archivtyp unbekannt ist." -- Pandoc is not available on the system or the process had issues. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T3746116957"] = "Pandoc ist auf dem System nicht verfügbar oder der Vorgang ist auf Probleme gestoßen." --- Pandoc was not installed successfully, because the archive type is unknown. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T3962211670"] = "Pandoc wurde nicht erfolgreich installiert, da der Archivtyp unbekannt ist." +-- AI Studio couldn't install Pandoc because the executable was not found in the archive. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T403983772"] = "AI Studio konnte Pandoc nicht installieren, da die ausführbare Datei im Archiv nicht gefunden wurde." --- It seems that Pandoc is not installed. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T567205144"] = "Es scheint, dass Pandoc nicht installiert ist." +-- AI Studio couldn't find the latest Pandoc version and will install version {0} instead. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T695293525"] = "AI Studio konnte die neueste Pandoc-Version nicht finden und installiert stattdessen Version {0}." --- The latest Pandoc version was not found, installing version {0} instead. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T726914939"] = "Die neueste Pandoc-Version wurde nicht gefunden, stattdessen wird Version {0} installiert." +-- AI Studio couldn't install Pandoc. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T932858631"] = "AI Studio konnte Pandoc nicht installieren." -- Pandoc is required for Microsoft Word export. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T1473115556"] = "Pandoc wird für den Export nach Microsoft Word benötigt." @@ -7503,6 +7670,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701 -- Pandoc may be required for importing files. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Zum Importieren von Dateien kann Pandoc erforderlich sein." +-- Failed to store the secret data due to an API issue. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Fehler beim Speichern der geheimen Daten aufgrund eines API-Problems." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Das Löschen der geheimen Daten ist aufgrund eines API-Problems fehlgeschlagen." @@ -7632,6 +7802,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T29806295 -- Images are not supported at this place UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T305247150"] = "Bilder werden an dieser Stelle nicht unterstützt." +-- This file format is not supported. Please convert the .doc file to .docx (e.g. with Microsoft Word). +UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T3740637731"] = "Dieses Dateiformat wird nicht unterstützt. Bitte konvertieren Sie die .doc-Datei in eine .docx-Datei (z. B. mit Microsoft Word)." + -- Unsupported file type UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T4041351522"] = "Nicht unterstützter Dateityp" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index ca76491c..0f8ae35d 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -2649,6 +2649,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence." +-- seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"] = "seconds" + -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled." @@ -2697,6 +2700,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] -- Spellchecking is enabled UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] = "Spellchecking is enabled" +-- Request timeout +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3569531009"] = "Request timeout" + -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App Options" @@ -2724,6 +2730,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] -- Select a transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4174666315"] = "Select a transcription provider" +-- How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4192032183"] = "How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads." + -- Navigation bar behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Navigation bar behavior" @@ -3123,6 +3132,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Start rec -- Transcription in progress... UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2851219233"] = "Transcription in progress..." +-- Unfortunately, there was an error communicating with the AI system. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3236134591"] = "Unfortunately, there was an error communicating with the AI system." + -- The configured transcription provider was not found. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T331613105"] = "The configured transcription provider was not found." @@ -3636,6 +3648,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2879113658"] = -- Maximum matches per query UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2889706179"] = "Maximum matches per query" +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2909734556"] = "Failed to read the user's username from the operating system." + -- Open web link, show more information UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T2968752071"] = "Open web link, show more information" @@ -3687,6 +3702,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = " -- Embeddings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Embeddings" +-- Use the same username and password for all users +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Use the same username and password for all users" + +-- Username and password mode +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Username and password mode" + +-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?" + +-- User-managed username and password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "User-managed username and password" + +-- Export +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Export" + +-- Read each user's username from the operating system and share one password +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Read each user's username from the operating system and share one password" + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel" + -- Describe what data this directory contains to help the AI select it. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1136409150"] = "Describe what data this directory contains to help the AI select it." @@ -4722,6 +4758,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T582516016"] = -- Customize your AI experience with chat templates. Whether you want to experiment with prompt engineering, simply use a custom system prompt in the standard chat interface, or create a specialized assistant, chat templates give you full control. Similar to common AI companies' playgrounds, you can define your own system prompts and leverage assistant prompts for providers that support them. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1172171653"] = "Customize your AI experience with chat templates. Whether you want to experiment with prompt engineering, simply use a custom system prompt in the standard chat interface, or create a specialized assistant, chat templates give you full control. Similar to common AI companies' playgrounds, you can define your own system prompts and leverage assistant prompts for providers that support them." +-- Copy attachments into plugin +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1345613295"] = "Copy attachments into plugin" + -- Delete UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1469573738"] = "Delete" @@ -4731,6 +4770,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T15483 -- Note: This advanced feature is designed for users familiar with prompt engineering concepts. Furthermore, you have to make sure yourself that your chosen provider supports the use of assistant prompts. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1909110760"] = "Note: This advanced feature is designed for users familiar with prompt engineering concepts. Furthermore, you have to make sure yourself that your chosen provider supports the use of assistant prompts." +-- Use shared attachment paths +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T2054531878"] = "Use shared attachment paths" + -- No chat templates configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T2319860307"] = "No chat templates configured yet." @@ -4749,6 +4791,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T34481 -- This template is managed by your organization. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T3576775249"] = "This template is managed by your organization." +-- Select configuration plugin folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T3576816894"] = "Select configuration plugin folder" + -- Edit Chat Template UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T3596030597"] = "Edit Chat Template" @@ -4761,6 +4806,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T38650 -- Delete Chat Template UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T4025180906"] = "Delete Chat Template" +-- Export Chat Template +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T491504763"] = "Export Chat Template" + +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T975426229"] = "Export configuration" + -- Which programming language should be preselected for added contexts? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCODING::T1073540083"] = "Which programming language should be preselected for added contexts?" @@ -4815,6 +4866,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T145419 -- Delete UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1469573738"] = "Delete" +-- Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1577531115"] = "Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin." + +-- Cannot export this ERI data source because the authentication secret could not be encrypted. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1592527757"] = "Cannot export this ERI data source because the authentication secret could not be encrypted." + -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T1652430727"] = "External (ERI)" @@ -4845,6 +4902,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T269820 -- Embedding UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T2838542994"] = "Embedding" +-- This data source is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3031462878"] = "This data source is managed by your organization." + -- Edit UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3267849393"] = "Edit" @@ -4869,21 +4929,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T352566 -- No data sources configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3549650120"] = "No data sources configured yet." +-- Export Access Token? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3595669127"] = "Export Access Token?" + +-- Export ERI Data Source +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3831281036"] = "Export ERI Data Source" + -- Actions UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T3865031940"] = "Actions" +-- This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T4027572258"] = "This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token." + -- Configured Data Sources UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T543942217"] = "Configured Data Sources" -- Add ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T590005498"] = "Add ERI v1 Data Source" +-- Cannot export this ERI data source because no enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T750361472"] = "Cannot export this ERI data source because no enterprise encryption secret is configured." + -- External Data (ERI-Server v1) UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473996"] = "External Data (ERI-Server v1)" +-- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T782820095"] = "Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}" + -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Local Directory" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T975426229"] = "Export configuration" + -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "When enabled, you can preselect some ERI server options." @@ -5169,6 +5247,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T55364659" -- Are you a project manager in a research facility? You might want to create a profile for your project management activities, one for your scientific work, and a profile for when you need to write program code. In these profiles, you can record how much experience you have or which methods you like or dislike using. Later, you can choose when and where you want to use each profile. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T56359901"] = "Are you a project manager in a research facility? You might want to create a profile for your project management activities, one for your scientific work, and a profile for when you need to write program code. In these profiles, you can record how much experience you have or which methods you like or dislike using. Later, you can choose when and where you want to use each profile." +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T975426229"] = "Export configuration" + -- Preselect the target language UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROMPTOPTIMIZER::T1417990312"] = "Preselect the target language" @@ -6027,18 +6108,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1890416390"] = "Check for update -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1892426825"] = "Vision" --- In order to use any LLM, each user must store their so-called API key for each LLM provider. This key must be kept secure, similar to a password. The safest way to do this is offered by operating systems like macOS, Windows, and Linux: They have mechanisms to store such data, if available, on special security hardware. Since this is currently not possible in .NET, we use this Rust library. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "In order to use any LLM, each user must store their so-called API key for each LLM provider. This key must be kept secure, similar to a password. The safest way to do this is offered by operating systems like macOS, Windows, and Linux: They have mechanisms to store such data, if available, on special security hardware. Since this is currently not possible in .NET, we use this Rust library." - -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant." -- Encryption secret: is configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured" --- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust." - -- Copies the following to the clipboard UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Copies the following to the clipboard" @@ -6066,6 +6141,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration pl -- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK." +-- Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view." + -- Used PDFium version UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2368247719"] = "Used PDFium version" @@ -6120,6 +6198,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840227993"] = "Used .NET runtim -- Explanation UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation" +-- checking availability +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2855535668"] = "checking availability" + -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software." @@ -6141,6 +6222,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3178730036"] = "Have feature ide -- Hide Details UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3183837919"] = "Hide Details" +-- Axum server runs the internal axum service over a secure local connection. This helps AI Studio protect the communication between the Rust runtime and the user interface. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3208719461"] = "Axum server runs the internal axum service over a secure local connection. This helps AI Studio protect the communication between the Rust runtime and the user interface." + +-- Rustls helps secure the internal connection between the app's user interface and the Rust runtime. This protects the local communication that AI Studio needs while it is running. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3239817808"] = "Rustls helps secure the internal connection between the app's user interface and the Rust runtime. This protects the local communication that AI Studio needs while it is running." + -- Update Pandoc UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3249965383"] = "Update Pandoc" @@ -6165,6 +6252,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio runs w -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!" +-- AI Studio stores secrets like API keys in your operating system’s secure credential store. The keyring-core library handles this by connecting to macOS Keychain, Windows Credential Manager, and Linux Secret Service. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3527399572"] = "AI Studio stores secrets like API keys in your operating system’s secure credential store. The keyring-core library handles this by connecting to macOS Keychain, Windows Credential Manager, and Linux Secret Service." + -- Motivation UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" @@ -6174,6 +6264,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3574465749"] = "not available" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat." +-- Username provided by the OS +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3764549776"] = "Username provided by the OS" + -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements" @@ -6195,6 +6288,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4010195468"] = "Versions" -- Database UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4036243672"] = "Database" +-- This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "This library is used by the Rust runtime to read the current user's username, e.g. when an organization-managed ERI server uses the OS username for authentication." + -- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system." @@ -6215,6 +6311,8 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK" -- We use the DeepSeek Tokenizer to estimate the number of tokens an input will generate. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T591393704"] = "We use the DeepSeek Tokenizer to estimate the number of tokens an input will generate." +-- starting +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T594602073"] = "starting" -- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated." @@ -6237,6 +6335,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T836298648"] = "Provided by confi -- We use this library to be able to read PowerPoint files. This allows us to insert content from slides into prompts and take PowerPoint files into account in RAG processes. We thank Nils Kruthoff for his work on this Rust crate. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T855925638"] = "We use this library to be able to read PowerPoint files. This allows us to insert content from slides into prompts and take PowerPoint files into account in RAG processes. We thank Nils Kruthoff for his work on this Rust crate." +-- Axum is used to provide the small internal service that connects the Rust runtime with the app's user interface. This lets both parts of AI Studio exchange information while the app is running. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T864851737"] = "Axum is used to provide the small internal service that connects the Rust runtime with the app's user interface. This lets both parts of AI Studio exchange information while the app is running." + -- For some data transfers, we need to encode the data in base64. This Rust library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T870640199"] = "For some data transfers, we need to encode the data in base64. This Rust library is great for this purpose." @@ -6378,6 +6479,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Your stage directions" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}'" +-- The provider '{0}' reported an error while streaming the response. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1008706234"] = "The provider '{0}' reported an error while streaming the response." + +-- The provider rejected the request because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1028424693"] = "The provider rejected the request because too many requests were sent. Please wait a moment and try again." + +-- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding." + -- Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1487597412"] = "Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'" @@ -6408,6 +6518,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T3759732886"] = "We tried to -- We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}'" +-- The provider '{0}' reported an error: {1} +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "The provider '{0}' reported an error: {1}" + -- The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T1014558951"] = "The trust level of this provider **has not yet** been thoroughly **investigated and evaluated**. We do not know if your data is safe." @@ -6468,6 +6581,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODEL::T2234274832"] = "no model selected" -- We could not load models from '{0}'. The account or API key does not have the required permissions. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T1143085203"] = "We could not load models from '{0}'. The account or API key does not have the required permissions." +-- We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T155481725"] = "We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again." + -- We could not load models from '{0}'. The API key is probably missing, invalid, or expired. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2041046579"] = "We could not load models from '{0}'. The API key is probably missing, invalid, or expired." @@ -6477,15 +6593,39 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T21156887 -- We could not load models from '{0}' because the provider returned an unexpected response. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T2186844789"] = "We could not load models from '{0}' because the provider returned an unexpected response." +-- We could not load models from '{0}' because the account appears to have no API credits left. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T373339048"] = "We could not load models from '{0}' because the account appears to have no API credits left." + -- We could not load models from '{0}' due to an unknown error. UI_TEXT_CONTENT["AISTUDIO::PROVIDER::MODELLOADFAILUREREASONEXTENSIONS::T3907712809"] = "We could not load models from '{0}' due to an unknown error." +-- It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::OPENAI::PROVIDEROPENAI::T757371511"] = "It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again." + -- Model as configured by whisper.cpp UI_TEXT_CONTENT["AISTUDIO::PROVIDER::SELFHOSTED::PROVIDERSELFHOSTED::T3313940770"] = "Model as configured by whisper.cpp" +-- Cannot export this chat template because example message {0} is not a text message. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T1861800849"] = "Cannot export this chat template because example message {0} is not a text message." + +-- Cannot export this chat template because example message {0} uses a role that is not supported by configuration plugins. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T2407395493"] = "Cannot export this chat template because example message {0} uses a role that is not supported by configuration plugins." + +-- Please select a valid configuration plugin folder. The folder must contain a plugin.lua file. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T2542895569"] = "Please select a valid configuration plugin folder. The folder must contain a plugin.lua file." + +-- Cannot package the chat template attachments. The issue was: {0} +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T3635593138"] = "Cannot package the chat template attachments. The issue was: {0}" + +-- Cannot package the attachment '{0}' because the file does not exist. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T4121340492"] = "Cannot package the attachment '{0}' because the file does not exist." + -- Use no chat template UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T4258819635"] = "Use no chat template" +-- Cannot export this chat template because example message {0} is empty. +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CHATTEMPLATE::T477540958"] = "Cannot export this chat template because example message {0} is empty." + -- Navigation never expands, but there are tooltips UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1095779033"] = "Navigation never expands, but there are tooltips" @@ -6681,8 +6821,8 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2708 -- Unknown preview feature UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2722827307"] = "Unknown preview feature" --- Transcription: Preview of our speech to text system where you can transcribe recordings and audio files into text -UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T714355911"] = "Transcription: Preview of our speech to text system where you can transcribe recordings and audio files into text" +-- Transcription: Convert recordings and audio files into text +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T4247148645"] = "Transcription: Convert recordings and audio files into text" -- Use no data sources, when sending an assistant result to a chat UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::SENDTOCHATDATASOURCEBEHAVIOREXTENSIONS::T1223925477"] = "Use no data sources, when sending an assistant result to a chat" @@ -6708,6 +6848,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = -- Use no profile UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile" +-- The selected model is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T1578005752"] = "The selected model is not available." + +-- The selected provider is not allowed for this chat. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T174545104"] = "The selected provider is not allowed for this chat." + +-- The AI job failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T237448388"] = "The AI job failed. The message is: '{0}'" + +-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings." + +-- We could load models from '{0}', but the provider did not return any usable text models. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "We could load models from '{0}', but the provider did not return any usable text models." + -- SSO (Kerberos) UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)" @@ -6849,6 +7004,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = " -- Reason UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Reason" +-- Starting +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1233211769"] = "Starting" + -- Unavailable UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T3662391977"] = "Unavailable" @@ -6933,6 +7091,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2858189239"] = "Faile -- Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T286437836"] = "Failed to retrieve the security requirements: the request was canceled either by the user or due to a timeout." +-- Failed to read the user's username from the operating system. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T2909734556"] = "Failed to read the user's username from the operating system." + -- Failed to retrieve the security requirements due to an exception: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T3221004295"] = "Failed to retrieve the security requirements due to an exception: {0}" @@ -6978,6 +7139,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T816853779"] = "Failed -- Failed to retrieve the authentication methods: the ERI server did not return a valid response. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::ERICLIENTV1::T984407320"] = "Failed to retrieve the authentication methods: the ERI server did not return a valid response." +-- AI Studio couldn't install Pandoc because the archive was not found. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found." + +-- Pandoc doesn't seem to be installed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1090474732"] = "Pandoc doesn't seem to be installed." + -- Was not able to validate the Pandoc installation. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1364844008"] = "Was not able to validate the Pandoc installation." @@ -6999,20 +7166,20 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T2550598062"] = "Pandoc v{0} is instal -- Pandoc v{0} is installed, but it does not match the required version (v{1}). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T2555465873"] = "Pandoc v{0} is installed, but it does not match the required version (v{1})." --- Pandoc was not installed successfully, because the archive was not found. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T34210248"] = "Pandoc was not installed successfully, because the archive was not found." +-- AI Studio couldn't install Pandoc because the archive type is unknown. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T3492710362"] = "AI Studio couldn't install Pandoc because the archive type is unknown." -- Pandoc is not available on the system or the process had issues. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T3746116957"] = "Pandoc is not available on the system or the process had issues." --- Pandoc was not installed successfully, because the archive type is unknown. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T3962211670"] = "Pandoc was not installed successfully, because the archive type is unknown." +-- AI Studio couldn't install Pandoc because the executable was not found in the archive. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T403983772"] = "AI Studio couldn't install Pandoc because the executable was not found in the archive." --- It seems that Pandoc is not installed. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T567205144"] = "It seems that Pandoc is not installed." +-- AI Studio couldn't find the latest Pandoc version and will install version {0} instead. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T695293525"] = "AI Studio couldn't find the latest Pandoc version and will install version {0} instead." --- The latest Pandoc version was not found, installing version {0} instead. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T726914939"] = "The latest Pandoc version was not found, installing version {0} instead." +-- AI Studio couldn't install Pandoc. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T932858631"] = "AI Studio couldn't install Pandoc." -- Pandoc is required for Microsoft Word export. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T1473115556"] = "Pandoc is required for Microsoft Word export." @@ -7503,6 +7670,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701 -- Pandoc may be required for importing files. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Pandoc may be required for importing files." +-- Failed to store the secret data due to an API issue. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed to store the secret data due to an API issue." + -- Failed to delete the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2303057928"] = "Failed to delete the secret data due to an API issue." @@ -7632,6 +7802,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T29806295 -- Images are not supported at this place UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T305247150"] = "Images are not supported at this place" +-- This file format is not supported. Please convert the .doc file to .docx (e.g. with Microsoft Word). +UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T3740637731"] = "This file format is not supported. Please convert the .doc file to .docx (e.g. with Microsoft Word)." + -- Unsupported file type UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::FILEEXTENSIONVALIDATION::T4041351522"] = "Unsupported file type" diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 04233468..b6ac9c21 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -2,6 +2,7 @@ using AIStudio.Agents; using AIStudio.Agents.AssistantAudit; using AIStudio.Settings; using AIStudio.Tools.Databases; +using AIStudio.Tools.AIJobs; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.Services; @@ -132,6 +133,7 @@ internal sealed class Program builder.Services.AddMudMarkdownClipboardService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs index 888a52a6..7be6cdc5 100644 --- a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs +++ b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs @@ -60,9 +60,9 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index 4a57a3a2..5274358a 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -116,9 +116,9 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// @@ -179,6 +179,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " { System.Net.HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, System.Net.HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + System.Net.HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, }, requestConfigurator: (request, secretKey) => diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 56dc1c29..fc739662 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -29,7 +29,7 @@ public abstract class BaseProvider : IProvider, ISecretId /// /// The HTTP client to use it for all requests. /// - protected readonly HttpClient HttpClient = new(); + protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(); /// /// The logger to use. @@ -103,7 +103,7 @@ public abstract class BaseProvider : IProvider, ISecretId public abstract IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, CancellationToken token = default); /// - public abstract Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default); + public abstract Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default); /// public abstract Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts); @@ -139,6 +139,23 @@ public abstract class BaseProvider : IProvider, ISecretId protected static ModelLoadResult FailedModelLoadResult(ModelLoadFailureReason failureReason, string? technicalDetails = null) => ModelLoadResult.Failure(failureReason, technicalDetails); + protected bool IsTimeoutException(Exception exception, CancellationToken token = default) + { + if (token.IsCancellationRequested) + return false; + + return ExternalHttpClientTimeout.IsTimeoutException(exception, token); + } + + protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new( + Icons.Material.Filled.HourglassTop, + string.Format( + TB("The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding."), + this.InstanceName, + this.Provider, + ExternalHttpClientTimeout.GetTimeoutDescription(), + action))); + protected async Task GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch { not null => apiKeyProvisional, @@ -153,10 +170,18 @@ public abstract class BaseProvider : IProvider, ISecretId { HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, + HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, }; + protected ModelLoadFailureReason GetModelLoadFailureReason(HttpResponseMessage response, string responseBody) => this.ClassifyProviderRequestFailure(response.StatusCode, responseBody) switch + { + ProviderRequestFailureReason.INSUFFICIENT_QUOTA => ModelLoadFailureReason.INSUFFICIENT_QUOTA, + ProviderRequestFailureReason.TOO_MANY_REQUESTS => ModelLoadFailureReason.TOO_MANY_REQUESTS, + _ => GetDefaultModelLoadFailureReason(response), + }; + protected async Task LoadModelsResponse( SecretStoreType storeType, string requestPath, @@ -178,42 +203,220 @@ public abstract class BaseProvider : IProvider, ISecretId else if (!string.IsNullOrWhiteSpace(secretKey)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - using var response = await this.HttpClient.SendAsync(request, token); - var responseBody = await response.Content.ReadAsStringAsync(token); - if (!response.IsSuccessStatusCode) + try { - var failureReason = failureReasonSelector?.Invoke(response, responseBody) ?? GetDefaultModelLoadFailureReason(response); - return FailedModelLoadResult(failureReason, $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{responseBody}'"); + using var response = await this.HttpClient.SendAsync(request, token); + var responseBody = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + { + var failureReason = failureReasonSelector?.Invoke(response, responseBody) ?? this.GetModelLoadFailureReason(response, responseBody); + this.logger.LogError("Model loading request failed with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", response.StatusCode, response.ReasonPhrase, responseBody); + return FailedModelLoadResult(failureReason, $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{responseBody}'"); + } + + try + { + var parsedResponse = JsonSerializer.Deserialize(responseBody, jsonSerializerOptions ?? JSON_SERIALIZER_OPTIONS); + if (parsedResponse is null) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, "Model list response could not be deserialized."); + + return SuccessfulModelLoadResult(modelFactory(parsedResponse)); + } + catch (Exception e) + { + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, e.Message); + } } + catch (Exception e) when (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("loading the available models"); + this.logger.LogError(e, "Timed out while loading models from provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider); + return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); + } + } + + protected virtual string GetProviderRequestFailureUserMessage(ProviderRequestFailureReason failureReason) => failureReason switch + { + ProviderRequestFailureReason.TOO_MANY_REQUESTS => TB("The provider rejected the request because too many requests were sent. Please wait a moment and try again."), + _ => string.Empty, + }; + + protected virtual ProviderRequestFailureReason ClassifyProviderRequestFailure(HttpStatusCode statusCode, string responseBody) + { + if (statusCode is not HttpStatusCode.TooManyRequests) + return ProviderRequestFailureReason.NONE; + + return ProviderRequestFailureReason.TOO_MANY_REQUESTS; + } + + protected virtual ProviderRequestFailureReason ClassifyProviderRequestFailure(string? errorCode, string? errorType, string? errorMessage, string responseBody) + { + if (IsTooManyRequestsError(errorCode) || IsTooManyRequestsError(errorType) || IsTooManyRequestsError(errorMessage)) + return ProviderRequestFailureReason.TOO_MANY_REQUESTS; + + return ProviderRequestFailureReason.NONE; + } + + private static bool IsTooManyRequestsError(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return value.Equals("rate_limit_exceeded", StringComparison.OrdinalIgnoreCase) || + value.Equals("too_many_requests", StringComparison.OrdinalIgnoreCase) || + value.Equals("too_many_request", StringComparison.OrdinalIgnoreCase) || + value.Contains("too many requests", StringComparison.OrdinalIgnoreCase) || + value.Contains("rate limit", StringComparison.OrdinalIgnoreCase) || + value.Contains("rate_limit", StringComparison.OrdinalIgnoreCase) || + value.Contains("throttl", StringComparison.OrdinalIgnoreCase); + } + + private bool TryCreateProviderRequestExceptionFromStreamLine(string providerName, string line, out ProviderRequestException exception) + { + exception = new(); + + if (!line.StartsWith("data: ", StringComparison.InvariantCulture)) + return false; + + var jsonData = line[6..].Trim(); + if (string.IsNullOrWhiteSpace(jsonData) || jsonData is "[DONE]") + return false; try { - var parsedResponse = JsonSerializer.Deserialize(responseBody, jsonSerializerOptions ?? JSON_SERIALIZER_OPTIONS); - if (parsedResponse is null) - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, "Model list response could not be deserialized."); + using var document = JsonDocument.Parse(jsonData); + var root = document.RootElement; + if (!IsProviderStreamFailure(root)) + return false; - return SuccessfulModelLoadResult(modelFactory(parsedResponse)); + var eventType = TryGetString(root, "type"); + TryGetProviderStreamError(root, out var errorCode, out var errorType, out var errorMessage); + var failureReason = this.ClassifyProviderRequestFailure(errorCode, errorType, errorMessage, jsonData); + var userMessage = this.GetProviderRequestFailureUserMessage(failureReason); + if (string.IsNullOrWhiteSpace(userMessage)) + { + userMessage = string.IsNullOrWhiteSpace(errorMessage) + ? string.Format(TB("The provider '{0}' reported an error while streaming the response."), this.InstanceName) + : string.Format(TB("The provider '{0}' reported an error: {1}"), this.InstanceName, errorMessage); + } + + this.logger.LogError("The {ProviderName} stream returned an error for provider '{ProviderInstanceName}' (provider={ProviderType}). EventType={StreamEventType}, ErrorCode={ErrorCode}, ErrorType={ErrorType}, ErrorMessage='{ErrorMessage}', Body='{ErrorBody}'", providerName, this.InstanceName, this.Provider, eventType, errorCode, errorType, errorMessage, jsonData); + exception = new ProviderRequestException(failureReason, userMessage, responseBody: jsonData); + return true; } - catch (Exception e) + catch (JsonException) { - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, e.Message); + return false; } } + + private static bool IsProviderStreamFailure(JsonElement root) + { + var eventType = TryGetString(root, "type"); + if (eventType is not null && ( + eventType.Equals("error", StringComparison.OrdinalIgnoreCase) || + eventType.Equals("response.error", StringComparison.OrdinalIgnoreCase) || + eventType.Equals("response.failed", StringComparison.OrdinalIgnoreCase))) + return true; + + if (HasObjectProperty(root, "error")) + return true; + + if (IsTooManyRequestsError(TryGetString(root, "code")) || + IsTooManyRequestsError(TryGetString(root, "type")) || + IsTooManyRequestsError(TryGetString(root, "message"))) + return true; + + if (TryGetString(root, "message") is not null && + (TryGetString(root, "code") is not null || TryGetString(root, "type") is not null) && + !root.TryGetProperty("choices", out _) && + !root.TryGetProperty("delta", out _)) + return true; + + if (!root.TryGetProperty("response", out var responseElement) || responseElement.ValueKind is not JsonValueKind.Object) + return false; + + if (HasObjectProperty(responseElement, "error")) + return true; + + var responseStatus = TryGetString(responseElement, "status"); + return responseStatus is not null && responseStatus.Equals("failed", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasObjectProperty(JsonElement element, string propertyName) + { + return element.ValueKind is JsonValueKind.Object && + element.TryGetProperty(propertyName, out var propertyElement) && + propertyElement.ValueKind is JsonValueKind.Object; + } + + private static void TryGetProviderStreamError(JsonElement root, out string? errorCode, out string? errorType, out string? errorMessage) + { + errorCode = null; + errorType = null; + errorMessage = null; + + if (TryGetErrorElement(root, out var errorElement)) + { + errorCode = TryGetString(errorElement, "code"); + errorType = TryGetString(errorElement, "type"); + errorMessage = TryGetString(errorElement, "message"); + return; + } + + errorCode = TryGetString(root, "code"); + errorType = TryGetString(root, "type"); + errorMessage = TryGetString(root, "message"); + } + + private static bool TryGetErrorElement(JsonElement root, out JsonElement errorElement) + { + if (root.ValueKind is JsonValueKind.Object && + root.TryGetProperty("error", out errorElement) && + errorElement.ValueKind is JsonValueKind.Object) + return true; + + if (root.ValueKind is JsonValueKind.Object && + root.TryGetProperty("response", out var responseElement) && + responseElement.ValueKind is JsonValueKind.Object && + responseElement.TryGetProperty("error", out errorElement) && + errorElement.ValueKind is JsonValueKind.Object) + return true; + + errorElement = default; + return false; + } + + private static string? TryGetString(JsonElement element, string propertyName) + { + if (element.ValueKind is not JsonValueKind.Object || + !element.TryGetProperty(propertyName, out var propertyElement) || + propertyElement.ValueKind is not JsonValueKind.String) + return null; + + return propertyElement.GetString(); + } /// /// Sends a request and handles rate limiting by exponential backoff. /// /// A function that builds the request. - /// The cancellation token. + /// The user cancellation token. + /// The token to use for the HTTP request. /// The status object of the request. - private async Task SendRequest(Func> requestBuilder, CancellationToken token = default) + private async Task SendRequest(Func> requestBuilder, CancellationToken userCancellationToken = default, CancellationToken requestCancellationToken = default) { const int MAX_RETRIES = 6; const double RETRY_DELAY_SECONDS = 4; + var effectiveCancellationToken = requestCancellationToken.CanBeCanceled ? requestCancellationToken : userCancellationToken; var retry = 0; var response = default(HttpResponseMessage); var errorMessage = string.Empty; + var lastProviderRequestFailure = ProviderRequestFailureReason.NONE; + HttpStatusCode? lastResponseStatusCode = null; + var lastResponseReasonPhrase = string.Empty; + var lastErrorBody = string.Empty; while (retry++ < MAX_RETRIES) { using var request = await requestBuilder(); @@ -226,14 +429,39 @@ public abstract class BaseProvider : IProvider, ISecretId // Please notice: We do not dispose the response here. The caller is responsible // for disposing the response object. This is important because the response // object is used to read the stream. - var nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + HttpResponseMessage nextResponse; + try + { + nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCancellationToken); + } + catch (Exception e) when (this.IsTimeoutException(e, userCancellationToken)) + { + await this.SendTimeoutError("waiting for the chat response"); + this.logger.LogError(e, "Timed out while sending a streaming request to provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider); + return new HttpRateLimitedStreamResult(false, true, e.Message, response); + } + if (nextResponse.IsSuccessStatusCode) { response = nextResponse; + errorMessage = string.Empty; + lastProviderRequestFailure = ProviderRequestFailureReason.NONE; break; } - var errorBody = await nextResponse.Content.ReadAsStringAsync(token); + var errorBody = await nextResponse.Content.ReadAsStringAsync(effectiveCancellationToken); + lastResponseStatusCode = nextResponse.StatusCode; + lastResponseReasonPhrase = nextResponse.ReasonPhrase ?? string.Empty; + lastErrorBody = errorBody; + var providerRequestFailure = this.ClassifyProviderRequestFailure(nextResponse.StatusCode, errorBody); + lastProviderRequestFailure = providerRequestFailure; + if (providerRequestFailure is ProviderRequestFailureReason.INSUFFICIENT_QUOTA) + { + var userMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure); + this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody); + throw new ProviderRequestException(providerRequestFailure, userMessage, nextResponse.StatusCode, nextResponse.ReasonPhrase ?? string.Empty, errorBody); + } + if (nextResponse.StatusCode is HttpStatusCode.Forbidden) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Block, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). You might not be able to use this provider from your location. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); @@ -299,11 +527,18 @@ public abstract class BaseProvider : IProvider, ISecretId timeSeconds = 90; this.logger.LogDebug("Failed request with status code {ResponseStatusCode} (message = '{ErrorMessage}'). Retrying in {TimeSeconds:0.00} seconds.", nextResponse.StatusCode, errorMessage, timeSeconds); - await Task.Delay(TimeSpan.FromSeconds(timeSeconds), token); + await Task.Delay(TimeSpan.FromSeconds(timeSeconds), effectiveCancellationToken); } if(retry >= MAX_RETRIES || !string.IsNullOrWhiteSpace(errorMessage)) { + if (lastProviderRequestFailure is not ProviderRequestFailureReason.NONE) + { + var userMessage = this.GetProviderRequestFailureUserMessage(lastProviderRequestFailure); + this.logger.LogError("The request to provider '{ProviderInstanceName}' (provider={ProviderType}) failed after {MaxRetries} retries with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}'): {ErrorMessage}", this.InstanceName, this.Provider, MAX_RETRIES, lastResponseStatusCode, lastResponseReasonPhrase, lastErrorBody, userMessage); + throw new ProviderRequestException(lastProviderRequestFailure, userMessage, lastResponseStatusCode, lastResponseReasonPhrase, lastErrorBody); + } + await MessageBus.INSTANCE.SendError(new DataErrorMessage(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). Even after {2} retries, there were some problems with the request. The provider message is: '{3}'."), this.InstanceName, this.Provider, MAX_RETRIES, errorMessage))); return new HttpRateLimitedStreamResult(false, true, errorMessage ?? $"Failed after {MAX_RETRIES} retries; no provider message available", response); } @@ -326,10 +561,12 @@ public abstract class BaseProvider : IProvider, ISecretId var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); StreamReader? streamReader = null; + using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); + var timeoutToken = timeoutTokenSource.Token; try { // Send the request using exponential backoff: - var responseData = await this.SendRequest(requestBuilder, token); + var responseData = await this.SendRequest(requestBuilder, token, timeoutToken); if(responseData.IsFailedAfterAllRetries) { this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}"); @@ -337,15 +574,31 @@ public abstract class BaseProvider : IProvider, ISecretId } // Open the response stream: - var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token); + var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken); // Add a stream reader to read the stream, line by line: streamReader = new StreamReader(providerStream); } + catch(ProviderRequestException) + { + throw; + } catch(Exception e) { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to stream chat completion from {providerName} '{this.InstanceName}': {e.Message}"); + if (token.IsCancellationRequested) + { + this.logger.LogWarning("The user canceled the chat completion request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName); + } + else if (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("opening the chat response stream"); + this.logger.LogError(e, "Timed out while opening the chat completion stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); + } + else + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogError($"Failed to stream chat completion from {providerName} '{this.InstanceName}': {e.Message}"); + } } if (streamReader is null) @@ -367,7 +620,7 @@ public abstract class BaseProvider : IProvider, ISecretId this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); break; } - + // Check if the token is canceled: if (token.IsCancellationRequested) { @@ -382,19 +635,38 @@ public abstract class BaseProvider : IProvider, ISecretId string? line; try { - line = await streamReader.ReadLineAsync(token); + line = await streamReader.ReadLineAsync(timeoutToken); } catch (Exception e) { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); + if (token.IsCancellationRequested) + { + this.logger.LogWarning("The user canceled the chat completion stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName); + } + else if (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("reading the chat response stream"); + this.logger.LogError(e, "Timed out while reading the chat stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); + } + else + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); + } + break; } + if (line is null) + break; + // Skip empty lines: if (string.IsNullOrWhiteSpace(line)) continue; + if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) + throw providerRequestException; + // Skip lines that do not start with "data: ". Regard // to the specification, we only want to read the data lines: if (!line.StartsWith("data: ", StringComparison.InvariantCulture)) @@ -490,10 +762,12 @@ public abstract class BaseProvider : IProvider, ISecretId var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); StreamReader? streamReader = null; + using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); + var timeoutToken = timeoutTokenSource.Token; try { // Send the request using exponential backoff: - var responseData = await this.SendRequest(requestBuilder, token); + var responseData = await this.SendRequest(requestBuilder, token, timeoutToken); if(responseData.IsFailedAfterAllRetries) { this.logger.LogError($"The {providerName} responses call failed: {responseData.ErrorMessage}"); @@ -501,15 +775,31 @@ public abstract class BaseProvider : IProvider, ISecretId } // Open the response stream: - var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token); + var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken); // Add a stream reader to read the stream, line by line: streamReader = new StreamReader(providerStream); } + catch(ProviderRequestException) + { + throw; + } catch(Exception e) { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to stream responses from {providerName} '{this.InstanceName}': {e.Message}"); + if (token.IsCancellationRequested) + { + this.logger.LogWarning("The user canceled the responses request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName); + } + else if (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("opening the chat response stream"); + this.logger.LogError(e, "Timed out while opening the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); + } + else + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogError($"Failed to stream responses from {providerName} '{this.InstanceName}': {e.Message}"); + } } if (streamReader is null) @@ -531,7 +821,7 @@ public abstract class BaseProvider : IProvider, ISecretId this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); break; } - + // Check if the token is canceled: if (token.IsCancellationRequested) { @@ -546,19 +836,38 @@ public abstract class BaseProvider : IProvider, ISecretId string? line; try { - line = await streamReader.ReadLineAsync(token); + line = await streamReader.ReadLineAsync(timeoutToken); } catch (Exception e) { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); + if (token.IsCancellationRequested) + { + this.logger.LogWarning("The user canceled the responses stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName); + } + else if (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("reading the chat response stream"); + this.logger.LogError(e, "Timed out while reading the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); + } + else + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); + } + break; } + if (line is null) + break; + // Skip empty lines: if (string.IsNullOrWhiteSpace(line)) continue; + if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException)) + throw providerRequestException; + // Check if the line is the end of the stream: if (line.StartsWith("event: response.completed", StringComparison.InvariantCulture)) yield break; @@ -708,7 +1017,7 @@ public abstract class BaseProvider : IProvider, ISecretId yield return content; } - protected async Task PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default) + protected async Task PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default) { try { @@ -750,7 +1059,7 @@ public abstract class BaseProvider : IProvider, ISecretId if(!requestedSecret.Success) { this.logger.LogError("No valid API key available for transcription request."); - return string.Empty; + return TranscriptionResult.Failure(); } request.Headers.Add("Authorization", await requestedSecret.Secret.Decrypt(ENCRYPTION)); @@ -760,7 +1069,7 @@ public abstract class BaseProvider : IProvider, ISecretId if(!requestedSecret.Success) { this.logger.LogError("No valid API key available for transcription request."); - return string.Empty; + return TranscriptionResult.Failure(); } request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); @@ -768,27 +1077,31 @@ public abstract class BaseProvider : IProvider, ISecretId } using var response = await this.HttpClient.SendAsync(request, token); - var responseBody = response.Content.ReadAsStringAsync(token).Result; + var responseBody = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { this.logger.LogError("Transcription request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); - return string.Empty; + var providerRequestFailure = this.ClassifyProviderRequestFailure(response.StatusCode, responseBody); + return TranscriptionResult.Failure(this.GetProviderRequestFailureUserMessage(providerRequestFailure)); } var transcriptionResponse = JsonSerializer.Deserialize(responseBody, JSON_SERIALIZER_OPTIONS); if(transcriptionResponse is null) { this.logger.LogError("Was not able to deserialize the transcription response."); - return string.Empty; + return TranscriptionResult.Failure(); } - return transcriptionResponse.Text; + return TranscriptionResult.FromText(transcriptionResponse.Text); } catch (Exception e) { + if (this.IsTimeoutException(e, token)) + await this.SendTimeoutError("transcribing audio"); + this.logger.LogError("Failed to perform transcription request: '{Message}'.", e.Message); - return string.Empty; + return TranscriptionResult.Failure(); } } @@ -838,11 +1151,16 @@ public abstract class BaseProvider : IProvider, ISecretId // Set the content: request.Content = new StringContent(embeddingRequest, Encoding.UTF8, "application/json"); using var response = await this.HttpClient.SendAsync(request, token); - var responseBody = response.Content.ReadAsStringAsync(token).Result; + var responseBody = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { this.logger.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); + var providerRequestFailure = this.ClassifyProviderRequestFailure(response.StatusCode, responseBody); + var userMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure); + if (!string.IsNullOrWhiteSpace(userMessage)) + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage)); + return []; } @@ -862,6 +1180,9 @@ public abstract class BaseProvider : IProvider, ISecretId } catch (Exception e) { + if (this.IsTimeoutException(e, token)) + await this.SendTimeoutError("creating embeddings"); + this.logger.LogError("Failed to perform embedding request: '{Message}'.", e.Message); return []; } @@ -1016,4 +1337,4 @@ public abstract class BaseProvider : IProvider, ISecretId _ => string.Empty, }; -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs index 05910bab..a24e6b3d 100644 --- a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs +++ b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs @@ -60,9 +60,9 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, "h #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// diff --git a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs index 160bc9fb..2849f6c8 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs @@ -61,7 +61,7 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, "https:/ #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override async Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override async Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); diff --git a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs index a68eacb2..07787c87 100644 --- a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs +++ b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs @@ -60,7 +60,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, "https://ch #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override async Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override async Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index 03df306c..d83d21b7 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -63,9 +63,9 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// @@ -135,6 +135,9 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener } catch (Exception e) { + if (this.IsTimeoutException(e, token)) + await this.SendTimeoutError("creating embeddings"); + LOGGER.LogError("Failed to perform embedding request: '{Message}'.", e.Message); return []; } @@ -197,6 +200,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener { System.Net.HttpStatusCode.Forbidden => ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR, System.Net.HttpStatusCode.Unauthorized => ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, + System.Net.HttpStatusCode.TooManyRequests => ModelLoadFailureReason.TOO_MANY_REQUESTS, _ => ModelLoadFailureReason.PROVIDER_UNAVAILABLE, }); } diff --git a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs index 52b9416a..ae59bf7d 100644 --- a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs +++ b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs @@ -64,9 +64,9 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, "https://api.groq. #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// diff --git a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs index 9f757eee..df7fbe14 100644 --- a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs +++ b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs @@ -62,9 +62,9 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, " #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// @@ -125,31 +125,40 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, " if (string.IsNullOrWhiteSpace(secretKey)) return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, "No API key available for model loading."); - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.HttpClient.SendAsync(request, token); - var body = await response.Content.ReadAsStringAsync(token); - if (!response.IsSuccessStatusCode) - return FailedModelLoadResult(GetDefaultModelLoadFailureReason(response), $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{body}'"); - try { - var modelResponse = JsonSerializer.Deserialize(body, JSON_SERIALIZER_OPTIONS); - return SuccessfulModelLoadResult(modelResponse.Data); + using var request = new HttpRequestMessage(HttpMethod.Get, "models"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + + using var response = await this.HttpClient.SendAsync(request, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + return FailedModelLoadResult(GetDefaultModelLoadFailureReason(response), $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{body}'"); + + try + { + var modelResponse = JsonSerializer.Deserialize(body, JSON_SERIALIZER_OPTIONS); + return SuccessfulModelLoadResult(modelResponse.Data); + } + catch (JsonException e) + { + if (body.Contains("API key", StringComparison.InvariantCultureIgnoreCase)) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, body); + + LOGGER.LogError(e, "Unexpected error while parsing models from Helmholtz API response. Status Code: {StatusCode}. Reason: {ReasonPhrase}. Response Body: '{ResponseBody}'", response.StatusCode, response.ReasonPhrase, body); + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, body); + } + catch (Exception e) + { + LOGGER.LogError(e, "Unexpected error while loading models from Helmholtz API. Status Code: {StatusCode}. Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); + return FailedModelLoadResult(ModelLoadFailureReason.UNKNOWN, e.Message); + } } - catch (JsonException e) + catch (Exception e) when (this.IsTimeoutException(e, token)) { - if (body.Contains("API key", StringComparison.InvariantCultureIgnoreCase)) - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, body); - - LOGGER.LogError(e, "Unexpected error while parsing models from Helmholtz API response. Status Code: {StatusCode}. Reason: {ReasonPhrase}. Response Body: '{ResponseBody}'", response.StatusCode, response.ReasonPhrase, body); - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, body); - } - catch (Exception e) - { - LOGGER.LogError(e, "Unexpected error while loading models from Helmholtz API. Status Code: {StatusCode}. Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); - return FailedModelLoadResult(ModelLoadFailureReason.UNKNOWN, e.Message); + await this.SendTimeoutError("loading the available models"); + LOGGER.LogError(e, "Timed out while loading models from Helmholtz provider '{ProviderInstanceName}'.", this.InstanceName); + return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs index 74d969a5..b3728521 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs @@ -65,9 +65,9 @@ public sealed class ProviderHuggingFace : BaseProvider #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// diff --git a/app/MindWork AI Studio/Provider/IProvider.cs b/app/MindWork AI Studio/Provider/IProvider.cs index d7d4af6f..dcf5e42c 100644 --- a/app/MindWork AI Studio/Provider/IProvider.cs +++ b/app/MindWork AI Studio/Provider/IProvider.cs @@ -68,7 +68,7 @@ public interface IProvider /// The settings manager instance to use. /// The cancellation token. /// >The transcription result. - public Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default); + public Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default); /// /// Embed a text file. diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index 65964e83..04ac9898 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -67,7 +67,7 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, "http #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override async Task TranscribeAudioAsync(Provider.Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override async Task TranscribeAudioAsync(Provider.Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); diff --git a/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs b/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs index b24ce1d4..786bfedd 100644 --- a/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs +++ b/app/MindWork AI Studio/Provider/ModelLoadFailureReason.cs @@ -5,6 +5,8 @@ public enum ModelLoadFailureReason NONE, INVALID_OR_MISSING_API_KEY, AUTHENTICATION_OR_PERMISSION_ERROR, + INSUFFICIENT_QUOTA, + TOO_MANY_REQUESTS, PROVIDER_UNAVAILABLE, INVALID_RESPONSE, UNKNOWN, diff --git a/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs b/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs index eaf7dcb7..542fbccb 100644 --- a/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs +++ b/app/MindWork AI Studio/Provider/ModelLoadFailureReasonExtensions.cs @@ -10,6 +10,8 @@ public static class ModelLoadFailureReasonExtensions { ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY => string.Format(TB("We could not load models from '{0}'. The API key is probably missing, invalid, or expired."), providerName), ModelLoadFailureReason.AUTHENTICATION_OR_PERMISSION_ERROR => string.Format(TB("We could not load models from '{0}'. The account or API key does not have the required permissions."), providerName), + ModelLoadFailureReason.INSUFFICIENT_QUOTA => string.Format(TB("We could not load models from '{0}' because the account appears to have no API credits left."), providerName), + ModelLoadFailureReason.TOO_MANY_REQUESTS => string.Format(TB("We could not load models from '{0}' because too many requests were sent. Please wait a moment and try again."), providerName), ModelLoadFailureReason.PROVIDER_UNAVAILABLE => string.Format(TB("We could not load models from '{0}' because the provider is currently unavailable or could not be reached."), providerName), ModelLoadFailureReason.INVALID_RESPONSE => string.Format(TB("We could not load models from '{0}' because the provider returned an unexpected response."), providerName), ModelLoadFailureReason.UNKNOWN => string.Format(TB("We could not load models from '{0}' due to an unknown error."), providerName), diff --git a/app/MindWork AI Studio/Provider/NoProvider.cs b/app/MindWork AI Studio/Provider/NoProvider.cs index 64e4e6c1..d2a8c9e1 100644 --- a/app/MindWork AI Studio/Provider/NoProvider.cs +++ b/app/MindWork AI Studio/Provider/NoProvider.cs @@ -43,7 +43,7 @@ public class NoProvider : IProvider yield break; } - public Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) => Task.FromResult(string.Empty); + public Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) => Task.FromResult(TranscriptionResult.Failure()); public Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) => Task.FromResult>>([]); diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index 28cf2327..80161caf 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text; @@ -5,6 +6,7 @@ using System.Text.Json; using AIStudio.Chat; using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; namespace AIStudio.Provider.OpenAI; @@ -15,6 +17,8 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ProviderOpenAI).Namespace, nameof(ProviderOpenAI)); + #region Implementation of IProvider /// @@ -26,6 +30,28 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https /// public override bool HasModelLoadingCapability => true; + protected override ProviderRequestFailureReason ClassifyProviderRequestFailure(HttpStatusCode statusCode, string responseBody) + { + if (statusCode is HttpStatusCode.TooManyRequests && HasInsufficientQuotaError(responseBody)) + return ProviderRequestFailureReason.INSUFFICIENT_QUOTA; + + return base.ClassifyProviderRequestFailure(statusCode, responseBody); + } + + protected override ProviderRequestFailureReason ClassifyProviderRequestFailure(string? errorCode, string? errorType, string? errorMessage, string responseBody) + { + if (IsInsufficientQuota(errorCode) || IsInsufficientQuota(errorType) || HasInsufficientQuotaError(responseBody)) + return ProviderRequestFailureReason.INSUFFICIENT_QUOTA; + + return base.ClassifyProviderRequestFailure(errorCode, errorType, errorMessage, responseBody); + } + + protected override string GetProviderRequestFailureUserMessage(ProviderRequestFailureReason failureReason) => failureReason switch + { + ProviderRequestFailureReason.INSUFFICIENT_QUOTA => TB("It looks like you do not have any API credits left with OpenAI. Please add credits to your account and try again."), + _ => base.GetProviderRequestFailureUserMessage(failureReason), + }; + /// public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { @@ -222,7 +248,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override async Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override async Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); @@ -289,4 +315,59 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https token, apiKeyProvisional); } + + private static bool HasInsufficientQuotaError(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + return false; + + try + { + using var document = JsonDocument.Parse(responseBody); + return HasInsufficientQuotaError(document.RootElement); + } + catch (JsonException) + { + return false; + } + } + + private static bool HasInsufficientQuotaError(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + if (HasJsonStringValue(element, "type", "insufficient_quota") || + HasJsonStringValue(element, "code", "insufficient_quota")) + return true; + + foreach (var property in element.EnumerateObject()) + if (HasInsufficientQuotaError(property.Value)) + return true; + + return false; + + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + if (HasInsufficientQuotaError(item)) + return true; + + return false; + + default: + return false; + } + } + + private static bool IsInsufficientQuota(string? value) + { + return value is not null && value.Equals("insufficient_quota", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasJsonStringValue(JsonElement element, string propertyName, string expectedValue) + { + return element.TryGetProperty(propertyName, out var propertyElement) && + propertyElement.ValueKind is JsonValueKind.String && + string.Equals(propertyElement.GetString(), expectedValue, StringComparison.OrdinalIgnoreCase); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index 9b5bdd69..d84431e3 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -71,9 +71,9 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// diff --git a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs index c019d77c..8d714985 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs @@ -68,9 +68,9 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY, #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// diff --git a/app/MindWork AI Studio/Provider/ProviderRequestException.cs b/app/MindWork AI Studio/Provider/ProviderRequestException.cs new file mode 100644 index 00000000..edda3ad5 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ProviderRequestException.cs @@ -0,0 +1,25 @@ +using System.Net; + +namespace AIStudio.Provider; + +public sealed class ProviderRequestException( + ProviderRequestFailureReason failureReason, + string userMessage, + HttpStatusCode? statusCode = null, + string reasonPhrase = "", + string responseBody = "") : Exception(userMessage) +{ + public ProviderRequestException() : this(ProviderRequestFailureReason.NONE, string.Empty) + { + } + + public ProviderRequestFailureReason FailureReason { get; } = failureReason; + + public string UserMessage { get; } = userMessage; + + public HttpStatusCode? StatusCode { get; } = statusCode; + + public string ReasonPhrase { get; } = reasonPhrase; + + public string ResponseBody { get; } = responseBody; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs b/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs new file mode 100644 index 00000000..c56fcc4f --- /dev/null +++ b/app/MindWork AI Studio/Provider/ProviderRequestFailureReason.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Provider; + +public enum ProviderRequestFailureReason +{ + NONE, + INSUFFICIENT_QUOTA, + TOO_MANY_REQUESTS, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index b3008209..595a94ef 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -73,7 +73,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override async Task TranscribeAudioAsync(Provider.Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override async Task TranscribeAudioAsync(Provider.Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER, isTrying: true); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, host, token); @@ -172,19 +172,32 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide private async Task LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, CancellationToken token, string? apiKeyProvisional = null) { var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, true); - - using var lmStudioRequest = new HttpRequestMessage(HttpMethod.Get, "models"); - if(secretKey is not null) - lmStudioRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var lmStudioResponse = await this.HttpClient.SendAsync(lmStudioRequest, token); - if(!lmStudioResponse.IsSuccessStatusCode) - return FailedModelLoadResult(GetDefaultModelLoadFailureReason(lmStudioResponse), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}"); - var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(token); - return SuccessfulModelLoadResult(lmStudioModelResponse.Data. - Where(model => !ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCulture)) && - filterPhrases.All( filter => model.Id.Contains(filter, StringComparison.InvariantCulture))) - .Select(n => new Provider.Model(n.Id, null))); + try + { + using var lmStudioRequest = new HttpRequestMessage(HttpMethod.Get, "models"); + if(secretKey is not null) + lmStudioRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + + using var lmStudioResponse = await this.HttpClient.SendAsync(lmStudioRequest, token); + if(!lmStudioResponse.IsSuccessStatusCode) + { + var responseBody = await lmStudioResponse.Content.ReadAsStringAsync(token); + LOGGER.LogError("Model loading request failed with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", lmStudioResponse.StatusCode, lmStudioResponse.ReasonPhrase, responseBody); + return FailedModelLoadResult(this.GetModelLoadFailureReason(lmStudioResponse, responseBody), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}; Body='{responseBody}'"); + } + + var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(token); + return SuccessfulModelLoadResult(lmStudioModelResponse.Data. + Where(model => !ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCulture)) && + filterPhrases.All( filter => model.Id.Contains(filter, StringComparison.InvariantCulture))) + .Select(n => new Provider.Model(n.Id, null))); + } + catch (Exception e) when (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("loading the available models"); + LOGGER.LogError(e, "Timed out while loading models from self-hosted provider '{ProviderInstanceName}'.", this.InstanceName); + return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/TranscriptionResult.cs b/app/MindWork AI Studio/Provider/TranscriptionResult.cs new file mode 100644 index 00000000..9e32d9d1 --- /dev/null +++ b/app/MindWork AI Studio/Provider/TranscriptionResult.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Provider; + +public sealed record TranscriptionResult(bool Success, string Text, string ErrorMessage = "") +{ + public static TranscriptionResult FromText(string text) => new(true, text); + + public static TranscriptionResult Failure(string errorMessage = "") => new(false, string.Empty, errorMessage); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/X/ProviderX.cs b/app/MindWork AI Studio/Provider/X/ProviderX.cs index 5d63850d..ecfa87b0 100644 --- a/app/MindWork AI Studio/Provider/X/ProviderX.cs +++ b/app/MindWork AI Studio/Provider/X/ProviderX.cs @@ -61,9 +61,9 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, "https://api.x.ai #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(TranscriptionResult.Failure()); } /// diff --git a/app/MindWork AI Studio/Settings/ChatTemplate.cs b/app/MindWork AI Studio/Settings/ChatTemplate.cs index 78879a62..c3d93ad9 100644 --- a/app/MindWork AI Studio/Settings/ChatTemplate.cs +++ b/app/MindWork AI Studio/Settings/ChatTemplate.cs @@ -1,7 +1,11 @@ +using System.Text; + using AIStudio.Chat; using AIStudio.Tools.PluginSystem; -using Lua; +using SharedTools; + +using LuaTable = Lua.LuaTable; namespace AIStudio.Settings; @@ -17,6 +21,8 @@ public record ChatTemplate( bool IsEnterpriseConfiguration = false, Guid EnterpriseConfigurationPluginId = default) : ConfigurationBaseObject { + private const string ATTACHMENTS_DIRECTORY = "attachments"; + public ChatTemplate() : this(0, Guid.Empty.ToString(), string.Empty, string.Empty, string.Empty, [], [], false) { } @@ -73,8 +79,8 @@ public record ChatTemplate( return this.SystemPrompt; } - - public static bool TryParseChatTemplateTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject template) + + public static bool TryParseChatTemplateTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject template) { template = NO_CHAT_TEMPLATE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) @@ -103,7 +109,7 @@ public record ChatTemplate( if (table.TryGetValue("AllowProfileUsage", out var allowProfileValue) && allowProfileValue.TryRead(out var allow)) allowProfileUsage = allow; - var fileAttachments = ParseFileAttachments(idx, table); + var fileAttachments = ParseFileAttachments(idx, table, pluginPath); template = new ChatTemplate { @@ -169,7 +175,7 @@ public record ChatTemplate( return exampleConversation; } - private static List ParseFileAttachments(int idx, LuaTable table) + private static List ParseFileAttachments(int idx, LuaTable table, string pluginPath) { var fileAttachments = new List(); if (!table.TryGetValue("FileAttachments", out var fileAttValue) || !fileAttValue.TryRead(out var fileAttTable)) @@ -185,9 +191,227 @@ public record ChatTemplate( continue; } - fileAttachments.Add(FileAttachment.FromPath(filePath)); + if (TryResolveFileAttachmentPath(idx, attachmentNum, filePath, pluginPath, out var resolvedFilePath)) + fileAttachments.Add(FileAttachment.FromPath(resolvedFilePath)); } return fileAttachments; } + + private static bool TryResolveFileAttachmentPath(int idx, int attachmentNum, string filePath, string pluginPath, out string resolvedFilePath) + { + resolvedFilePath = filePath; + if (string.IsNullOrWhiteSpace(filePath)) + { + LOGGER.LogWarning("The FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} is empty.", attachmentNum, idx); + return false; + } + + if (Path.IsPathFullyQualified(filePath)) + return true; + + if (string.IsNullOrWhiteSpace(pluginPath)) + { + LOGGER.LogWarning("The relative FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} cannot be resolved because the plugin path is unknown.", attachmentNum, idx); + return false; + } + + var pluginRoot = Path.GetFullPath(pluginPath); + var relativePath = filePath + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + + if (relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == "..")) + { + LOGGER.LogWarning("The relative FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} contains '..' path segments and will be ignored.", attachmentNum, idx); + return false; + } + + var combinedPath = Path.GetFullPath(Path.Combine(pluginRoot, relativePath)); + var pluginRootWithSeparator = pluginRoot.EndsWith(Path.DirectorySeparatorChar) + ? pluginRoot + : pluginRoot + Path.DirectorySeparatorChar; + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!combinedPath.StartsWith(pluginRootWithSeparator, comparison)) + { + LOGGER.LogWarning("The relative FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} points outside of the plugin folder and will be ignored.", attachmentNum, idx); + return false; + } + + resolvedFilePath = combinedPath; + return true; + } + + public bool TryExportAsConfigurationSection(out string luaCode, out string issue) => this.TryExportAsConfigurationSection(null, Guid.NewGuid().ToString(), out luaCode, out issue); + + private bool TryExportAsConfigurationSection(IReadOnlyList? fileAttachmentPaths, string exportId, out string luaCode, out string issue) + { + luaCode = string.Empty; + issue = string.Empty; + if (!this.TryBuildExampleConversationLua(out var exampleConversationLua, out issue)) + return false; + + return this.TryExportAsConfigurationSection(fileAttachmentPaths, exportId, exampleConversationLua, out luaCode, out issue); + } + + private bool TryExportAsConfigurationSection(IReadOnlyList? fileAttachmentPaths, string exportId, string exampleConversationLua, out string luaCode, out string issue) + { + issue = string.Empty; + var fileAttachmentsLua = this.BuildFileAttachmentsLua(fileAttachmentPaths); + luaCode = $$""" + CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { + ["Id"] = "{{LuaTools.EscapeLuaString(exportId)}}", + ["Name"] = {{LuaTools.ToLuaStringLiteral(this.Name)}}, + ["SystemPrompt"] = {{LuaTools.ToLuaStringLiteral(this.SystemPrompt)}}, + ["PredefinedUserPrompt"] = {{LuaTools.ToLuaStringLiteral(this.PredefinedUserPrompt)}}, + ["AllowProfileUsage"] = {{this.AllowProfileUsage.ToString().ToLowerInvariant()}}, + ["FileAttachments"] = {{fileAttachmentsLua}}, + ["ExampleConversation"] = {{exampleConversationLua}}, + } + """; + return true; + } + + public bool TryExportAsConfigurationSectionWithPackagedAttachments(string pluginDirectory, out string luaCode, out string issue) + { + luaCode = string.Empty; + issue = string.Empty; + var exportId = Guid.NewGuid().ToString(); + + if (!this.TryBuildExampleConversationLua(out var exampleConversationLua, out issue)) + return false; + + if (this.FileAttachments.Count == 0) + return this.TryExportAsConfigurationSection(null, exportId, exampleConversationLua, out luaCode, out issue); + + if (string.IsNullOrWhiteSpace(pluginDirectory) || !File.Exists(Path.Combine(pluginDirectory, "plugin.lua"))) + { + issue = TB("Please select a valid configuration plugin folder. The folder must contain a plugin.lua file."); + return false; + } + + var sourcePaths = new List(); + foreach (var attachment in this.FileAttachments) + { + if (string.IsNullOrWhiteSpace(attachment.FilePath) || !File.Exists(attachment.FilePath)) + { + issue = string.Format(TB("Cannot package the attachment '{0}' because the file does not exist."), attachment.FileName); + return false; + } + + sourcePaths.Add(attachment.FilePath); + } + + var targetDirectory = Path.Combine(pluginDirectory, ATTACHMENTS_DIRECTORY, exportId); + var relativeAttachmentPaths = new List(); + var usedFileNames = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + Directory.CreateDirectory(targetDirectory); + foreach (var sourcePath in sourcePaths) + { + var targetFileName = CreateUniqueAttachmentFileName(sourcePath, usedFileNames); + var targetPath = Path.Combine(targetDirectory, targetFileName); + File.Copy(sourcePath, targetPath, overwrite: false); + relativeAttachmentPaths.Add($"{ATTACHMENTS_DIRECTORY}/{exportId}/{targetFileName}"); + } + } + catch (Exception e) + { + try + { + if (Directory.Exists(targetDirectory)) + Directory.Delete(targetDirectory, true); + } + catch + { + // Keep the original packaging error as the user-facing issue. + } + + issue = string.Format(TB("Cannot package the chat template attachments. The issue was: {0}"), e.Message); + return false; + } + + return this.TryExportAsConfigurationSection(relativeAttachmentPaths, exportId, exampleConversationLua, out luaCode, out issue); + } + + private bool TryBuildExampleConversationLua(out string luaTable, out string issue) + { + luaTable = "{}"; + issue = string.Empty; + if (this.ExampleConversation.Count == 0) + return true; + + var builder = new StringBuilder(); + builder.AppendLine("{"); + for (var i = 0; i < this.ExampleConversation.Count; i++) + { + var block = this.ExampleConversation[i]; + if (block.Role is not ChatRole.USER and not ChatRole.AI) + { + issue = string.Format(TB("Cannot export this chat template because example message {0} uses a role that is not supported by configuration plugins."), i + 1); + return false; + } + + if (block.Content is not ContentText textContent) + { + issue = string.Format(TB("Cannot export this chat template because example message {0} is not a text message."), i + 1); + return false; + } + + if (string.IsNullOrWhiteSpace(textContent.Text)) + { + issue = string.Format(TB("Cannot export this chat template because example message {0} is empty."), i + 1); + return false; + } + + builder.AppendLine(" {"); + builder.AppendLine($" [\"Role\"] = \"{block.Role}\","); + builder.AppendLine($" [\"Content\"] = {LuaTools.ToLuaStringLiteral(textContent.Text)},"); + builder.AppendLine(" },"); + } + + builder.Append(" }"); + luaTable = builder.ToString(); + return true; + } + + private string BuildFileAttachmentsLua(IReadOnlyList? fileAttachmentPaths) + { + var paths = fileAttachmentPaths ?? this.FileAttachments.Select(attachment => attachment.FilePath).ToList(); + if (paths.Count == 0) + return "{}"; + + var builder = new StringBuilder(); + builder.AppendLine("{"); + foreach (var path in paths) + builder.AppendLine($" \"{LuaTools.EscapeLuaString(path)}\","); + + builder.Append(" }"); + return builder.ToString(); + } + + private static string CreateUniqueAttachmentFileName(string sourcePath, HashSet usedFileNames) + { + var fileName = SanitizeFileName(Path.GetFileName(sourcePath)); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "attachment"; + + var extension = Path.GetExtension(fileName); + var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); + var candidate = fileName; + var counter = 2; + while (!usedFileNames.Add(candidate)) + candidate = $"{nameWithoutExtension}-{counter++}{extension}"; + + return candidate; + } + + private static string SanitizeFileName(string fileName) + { + foreach (var invalidChar in Path.GetInvalidFileNameChars()) + fileName = fileName.Replace(invalidChar, '_'); + + return fileName; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index 3a62164b..ad027064 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -94,6 +94,11 @@ public sealed class DataApp(Expression>? configSelection = n /// public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty); + /// + /// The HTTP timeout in seconds for external HTTP clients. + /// + public int HttpClientTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HttpClientTimeoutSeconds, ExternalHttpClientTimeout.DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS); + /// /// Should the user be allowed to add providers? /// diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs new file mode 100644 index 00000000..67a86c41 --- /dev/null +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs @@ -0,0 +1,19 @@ +namespace AIStudio.Settings.DataModel; + +public enum DataSourceERIUsernamePasswordMode +{ + /// + /// The user manages the username and password locally. + /// + USER_MANAGED, + + /// + /// The username and password are shared by all users and provided by configuration. + /// + SHARED_USERNAME_AND_PASSWORD, + + /// + /// The username is read from the operating system, and the password is shared by all users. + /// + OS_USERNAME_SHARED_PASSWORD, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs index cbc3839c..3fb7bd1a 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs @@ -4,11 +4,15 @@ using AIStudio.Assistants.ERI; using AIStudio.Chat; using AIStudio.Tools.ERIClient; using AIStudio.Tools.ERIClient.DataModel; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.RAG; using AIStudio.Tools.Services; +using SharedTools; + using ChatThread = AIStudio.Chat.ChatThread; using ContentType = AIStudio.Tools.ERIClient.DataModel.ContentType; +using LuaTable = Lua.LuaTable; namespace AIStudio.Settings.DataModel; @@ -17,6 +21,8 @@ namespace AIStudio.Settings.DataModel; /// public readonly record struct DataSourceERI_V1 : IERIDataSource { + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + public DataSourceERI_V1() { } @@ -45,8 +51,17 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource /// public string Username { get; init; } = string.Empty; + /// + public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; } = DataSourceERIUsernamePasswordMode.USER_MANAGED; + /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ERIVersion Version { get; init; } = ERIVersion.V1; @@ -82,7 +97,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource Thread = await thread.ToERIChatThread(token), MaxMatches = this.MaxMatches, - RetrievalProcessId = string.IsNullOrWhiteSpace(this.SelectedRetrievalId) ? null : this.SelectedRetrievalId, + RetrievalProcessId = this.SelectedRetrievalId, Parameters = null, // The ERI server selects useful default parameters }; @@ -139,4 +154,240 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource logger.LogWarning($"Was not able to authenticate with the ERI data source '{this.Name}'. Message: {authResponse.Message}"); return []; } + + public static bool TryParseConfiguration(int idx, LuaTable table, Guid configPluginId, out DataSourceERI_V1 dataSource) + { + dataSource = default; + if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead(out var name) || string.IsNullOrWhiteSpace(name)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid name. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Type", out var typeValue) || !typeValue.TryRead(out var typeText) || !Enum.TryParse(typeText, true, out var type) || type is not DataSourceType.ERI_V1) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a supported data source type. Only ERI_V1 is supported. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname) || string.IsNullOrWhiteSpace(hostname)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Port", out var portValue) || !portValue.TryRead(out var port) || port is < 1 or > 65535) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid port. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("AuthMethod", out var authMethodValue) || !authMethodValue.TryRead(out var authMethodText) || !Enum.TryParse(authMethodText, true, out var authMethod)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid auth method. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("SecurityPolicy", out var securityPolicyValue) || !securityPolicyValue.TryRead(out var securityPolicyText) || !Enum.TryParse(securityPolicyText, true, out var securityPolicy)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid security policy. (Plugin ID: {configPluginId})"); + return false; + } + + if (securityPolicy is DataSourceSecurity.NOT_SPECIFIED) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a security policy. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("SelectedRetrievalId", out var selectedRetrievalIdValue) || !selectedRetrievalIdValue.TryRead(out var selectedRetrievalId) || string.IsNullOrWhiteSpace(selectedRetrievalId)) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a selected retrieval ID. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("MaxMatches", out var maxMatchesValue) || !maxMatchesValue.TryRead(out var maxMatches) || maxMatches is < 1 or > ushort.MaxValue) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid maximum number of matches. (Plugin ID: {configPluginId})"); + return false; + } + + var username = string.Empty; + var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED; + if (table.TryGetValue("UsernamePasswordMode", out var usernamePasswordModeValue) && usernamePasswordModeValue.TryRead(out var usernamePasswordModeText)) + { + if (!Enum.TryParse(usernamePasswordModeText, true, out usernamePasswordMode)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid username/password mode. (Plugin ID: {configPluginId})"); + return false; + } + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + LOGGER.LogWarning($"The configured data source {idx} uses the user-managed username/password mode. This mode is not allowed in configuration plugins. (Plugin ID: {configPluginId})"); + return false; + } + } + + if (authMethod is AuthMethod.USERNAME_PASSWORD) + { + if (!table.TryGetValue("UsernamePasswordMode", out _) || usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + LOGGER.LogWarning($"The configured data source {idx} must specify an organization-managed username/password mode. (Plugin ID: {configPluginId})"); + return false; + } + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD && + (!table.TryGetValue("Username", out var usernameValue) || !usernameValue.TryRead(out username) || string.IsNullOrWhiteSpace(username))) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a username. (Plugin ID: {configPluginId})"); + return false; + } + } + + dataSource = new DataSourceERI_V1 + { + Num = 0, + Id = id.ToString(), + Name = name, + Type = DataSourceType.ERI_V1, + Hostname = CleanHostname(hostname), + Port = port, + AuthMethod = authMethod, + Username = username, + UsernamePasswordMode = usernamePasswordMode, + SecurityPolicy = securityPolicy, + Version = ERIVersion.V1, + SelectedRetrievalId = selectedRetrievalId, + MaxMatches = (ushort)maxMatches, + IsEnterpriseConfiguration = true, + EnterpriseConfigurationPluginId = configPluginId, + }; + + return TryQueueEnterpriseSecret(idx, table, configPluginId, dataSource); + } + + /// + /// Exports the ERI v1 data source configuration as a Lua configuration section. + /// + /// Optional encrypted token or password to include in the export. + /// The organization-managed username/password mode to export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedSecret = null, DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + var secretLine = string.Empty; + var usernamePasswordModeLine = string.Empty; + var usernameLine = string.Empty; + + switch (this.AuthMethod) + { + case AuthMethod.TOKEN: + secretLine = CreateSecretLine("Token", encryptedSecret); + break; + + case AuthMethod.USERNAME_PASSWORD: + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + usernamePasswordMode = DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD; + + usernamePasswordModeLine = $""" + ["UsernamePasswordMode"] = "{usernamePasswordMode}", + """; + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD) + { + var username = string.IsNullOrWhiteSpace(this.Username) ? "" : this.Username; + usernameLine = $""" + ["Username"] = "{LuaTools.EscapeLuaString(username)}", + """; + } + + secretLine = CreateSecretLine("Password", encryptedSecret); + break; + } + + return $$""" + CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}", + ["Type"] = "ERI_V1", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + ["Port"] = {{this.Port}}, + ["AuthMethod"] = "{{this.AuthMethod}}", + {{usernamePasswordModeLine}} + {{usernameLine}} + {{secretLine}} + ["SecurityPolicy"] = "{{this.SecurityPolicy}}", + ["SelectedRetrievalId"] = "{{LuaTools.EscapeLuaString(this.SelectedRetrievalId)}}", + ["MaxMatches"] = {{this.MaxMatches}}, + } + """; + } + + private static bool TryQueueEnterpriseSecret(int idx, LuaTable table, Guid configPluginId, DataSourceERI_V1 dataSource) + { + var secretFieldName = dataSource.AuthMethod switch + { + AuthMethod.TOKEN => "Token", + AuthMethod.USERNAME_PASSWORD => "Password", + _ => string.Empty, + }; + + if (string.IsNullOrWhiteSpace(secretFieldName)) + return true; + + if (!table.TryGetValue(secretFieldName, out var secretValue) || !secretValue.TryRead(out var encryptedSecret) || string.IsNullOrWhiteSpace(encryptedSecret)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid encrypted {secretFieldName}. (Plugin ID: {configPluginId})"); + return false; + } + + if (!EnterpriseEncryption.IsEncrypted(encryptedSecret)) + { + LOGGER.LogWarning($"The configured data source {idx} contains a plaintext {secretFieldName}. Only encrypted secrets (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); + return false; + } + + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable != true) + { + LOGGER.LogWarning($"The configured data source {idx} contains an encrypted {secretFieldName}, but no encryption secret is configured. (Plugin ID: {configPluginId})"); + return false; + } + + if (!encryption.TryDecrypt(encryptedSecret, out var decryptedSecret)) + { + LOGGER.LogWarning($"Failed to decrypt the {secretFieldName} for data source {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); + return false; + } + + PendingEnterpriseSecrets.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{dataSource.Id}", + dataSource.Name, + decryptedSecret, + SecretStoreType.DATA_SOURCE)); + LOGGER.LogDebug($"Successfully decrypted the {secretFieldName} for data source {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); + return true; + } + + private static string CreateSecretLine(string fieldName, string? encryptedSecret) + { + if (string.IsNullOrWhiteSpace(encryptedSecret)) + return string.Empty; + + return $""" + ["{fieldName}"] = "{LuaTools.EscapeLuaString(encryptedSecret)}", + """; + } + + private static string CleanHostname(string hostname) + { + var cleanedHostname = hostname.Trim(); + return cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs index d8b263c3..a7531e74 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs @@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalDirectory : IInternalDataSource /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ushort MaxMatches { get; init; } = 10; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs index 11b857d0..0df0790f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs @@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalFile : IInternalDataSource /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ushort MaxMatches { get; init; } = 10; diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs index fa10ecad..8fdc8d4e 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs @@ -14,7 +14,7 @@ public static class PreviewFeaturesExtensions PreviewFeatures.PRE_PLUGINS_2025 => TB("Plugins: Preview of our plugin system where you can extend the functionality of the app"), PreviewFeatures.PRE_READ_PDF_2025 => TB("Read PDF: Preview of our PDF reading system where you can read and extract text from PDF files"), PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025 => TB("Document Analysis: Preview of our document analysis system where you can analyze and extract information from documents"), - PreviewFeatures.PRE_SPEECH_TO_TEXT_2026 => TB("Transcription: Preview of our speech to text system where you can transcribe recordings and audio files into text"), + PreviewFeatures.PRE_SPEECH_TO_TEXT_2026 => TB("Transcription: Convert recordings and audio files into text"), _ => TB("Unknown preview feature") }; @@ -33,6 +33,7 @@ public static class PreviewFeaturesExtensions PreviewFeatures.PRE_READ_PDF_2025 => true, PreviewFeatures.PRE_PLUGINS_2025 => true, PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025 => true, + PreviewFeatures.PRE_SPEECH_TO_TEXT_2026 => true, _ => false }; diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs index 30764bfe..30a1b4ea 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs @@ -12,7 +12,6 @@ public static class PreviewVisibilityExtensions if (visibility >= PreviewVisibility.BETA) { features.Add(PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025); - features.Add(PreviewFeatures.PRE_SPEECH_TO_TEXT_2026); } if (visibility >= PreviewVisibility.ALPHA) diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs index 8d6042df..eb227489 100644 --- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs +++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs @@ -3,9 +3,10 @@ using System.Text.Json.Serialization; using AIStudio.Provider; using AIStudio.Tools.PluginSystem; -using Lua; +using SharedTools; using Host = AIStudio.Provider.SelfHosted.Host; +using LuaTable = Lua.LuaTable; namespace AIStudio.Settings; diff --git a/app/MindWork AI Studio/Settings/IDataSource.cs b/app/MindWork AI Studio/Settings/IDataSource.cs index 9ce3dc9f..7e29123d 100644 --- a/app/MindWork AI Studio/Settings/IDataSource.cs +++ b/app/MindWork AI Studio/Settings/IDataSource.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using AIStudio.Chat; using AIStudio.Settings.DataModel; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.RAG; namespace AIStudio.Settings; @@ -13,23 +14,8 @@ namespace AIStudio.Settings; [JsonDerivedType(typeof(DataSourceLocalDirectory), nameof(DataSourceType.LOCAL_DIRECTORY))] [JsonDerivedType(typeof(DataSourceLocalFile), nameof(DataSourceType.LOCAL_FILE))] [JsonDerivedType(typeof(DataSourceERI_V1), nameof(DataSourceType.ERI_V1))] -public interface IDataSource +public interface IDataSource : IConfigurationObject { - /// - /// The number of the data source. - /// - public uint Num { get; init; } - - /// - /// The unique identifier of the data source. - /// - public string Id { get; init; } - - /// - /// The name of the data source. - /// - public string Name { get; init; } - /// /// Which type of data source is this? /// diff --git a/app/MindWork AI Studio/Settings/IERIDataSource.cs b/app/MindWork AI Studio/Settings/IERIDataSource.cs index 55138978..40dd625d 100644 --- a/app/MindWork AI Studio/Settings/IERIDataSource.cs +++ b/app/MindWork AI Studio/Settings/IERIDataSource.cs @@ -1,4 +1,5 @@ using AIStudio.Assistants.ERI; +using AIStudio.Settings.DataModel; using AIStudio.Tools.ERIClient.DataModel; namespace AIStudio.Settings; @@ -24,6 +25,11 @@ public interface IERIDataSource : IExternalDataSource /// The username to use for authentication, when the auth. method is USERNAME_PASSWORD. /// public string Username { get; init; } + + /// + /// How username/password authentication should obtain the username. + /// + public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; } /// /// The ERI specification to use. diff --git a/app/MindWork AI Studio/Settings/IExternalDataSource.cs b/app/MindWork AI Studio/Settings/IExternalDataSource.cs index 8a7c067c..6b75fa56 100644 --- a/app/MindWork AI Studio/Settings/IExternalDataSource.cs +++ b/app/MindWork AI Studio/Settings/IExternalDataSource.cs @@ -7,7 +7,7 @@ public interface IExternalDataSource : IDataSource, ISecretId #region Implementation of ISecretId [JsonIgnore] - string ISecretId.SecretId => this.Id; + string ISecretId.SecretId => this.IsEnterpriseConfiguration ? $"{ENTERPRISE_KEY_PREFIX}::{this.Id}" : this.Id; [JsonIgnore] string ISecretId.SecretName => this.Name; diff --git a/app/MindWork AI Studio/Settings/Profile.cs b/app/MindWork AI Studio/Settings/Profile.cs index 2129b04c..925a5936 100644 --- a/app/MindWork AI Studio/Settings/Profile.cs +++ b/app/MindWork AI Studio/Settings/Profile.cs @@ -1,5 +1,8 @@ using AIStudio.Tools.PluginSystem; -using Lua; + +using SharedTools; + +using LuaTable = Lua.LuaTable; namespace AIStudio.Settings; @@ -132,4 +135,20 @@ public record Profile( return true; } + + /// + /// Exports the profile configuration as a Lua configuration section. + /// + /// A Lua configuration section string. + public string ExportAsConfigurationSection() + { + return $$""" + CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["Name"] = {{LuaTools.ToLuaStringLiteral(this.Name)}}, + ["NeedToKnow"] = {{LuaTools.ToLuaStringLiteral(this.NeedToKnow)}}, + ["Actions"] = {{LuaTools.ToLuaStringLiteral(this.Actions)}}, + } + """; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index f7ac1a07..a793c4a9 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -4,9 +4,10 @@ using AIStudio.Provider; using AIStudio.Provider.HuggingFace; using AIStudio.Tools.PluginSystem; -using Lua; +using SharedTools; using Host = AIStudio.Provider.SelfHosted.Host; +using LuaTable = Lua.LuaTable; namespace AIStudio.Settings; diff --git a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs index 4c6ca871..ca95d821 100644 --- a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs +++ b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs @@ -3,9 +3,10 @@ using System.Text.Json.Serialization; using AIStudio.Provider; using AIStudio.Tools.PluginSystem; -using Lua; +using SharedTools; using Host = AIStudio.Provider.SelfHosted.Host; +using LuaTable = Lua.LuaTable; namespace AIStudio.Settings; diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobKind.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobKind.cs new file mode 100644 index 00000000..b6216caa --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobKind.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Tools.AIJobs; + +public enum AIJobKind +{ + NONE, + CHAT_GENERATION, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobSchedulingClass.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobSchedulingClass.cs new file mode 100644 index 00000000..687a032a --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobSchedulingClass.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.AIJobs; + +public enum AIJobSchedulingClass +{ + NONE, + TOP_LEVEL_USER_JOB, + INTERNAL_DEPENDENCY, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs new file mode 100644 index 00000000..7619b6f7 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -0,0 +1,458 @@ +using System.Collections.Concurrent; + +using AIStudio.Chat; +using AIStudio.Provider; +using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.RAG.RAGProcesses; + +namespace AIStudio.Tools.AIJobs; + +public sealed class AIJobService( + SettingsManager settingsManager, + MessageBus messageBus, + ILogger logger) +{ + private sealed class AIJobState + { + public required CancellationTokenSource CancellationTokenSource { get; init; } + + public required CancellationToken CancellationToken { get; init; } + + public required ChatGenerationRequest ChatGenerationRequest { get; init; } + + public required AIJobSnapshot Snapshot { get; set; } + + public DateTimeOffset LastCheckpoint { get; set; } + + public bool IsCompletionStarted { get; set; } + + public readonly Lock SyncRoot = new(); + } + + private static readonly TimeSpan STREAMING_EVENT_MIN_TIME = TimeSpan.FromSeconds(3); + + private static readonly TimeSpan CHECKPOINT_MIN_TIME = TimeSpan.FromSeconds(3); + + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AIJobService).Namespace, nameof(AIJobService)); + + private readonly ConcurrentDictionary jobs = new(); + private readonly ConcurrentDictionary activeChatJobsByChatId = new(); + + public IReadOnlyCollection GetSnapshots() + { + return this.jobs.Values + .Select(job => job.Snapshot) + .OrderByDescending(snapshot => snapshot.UpdatedAt) + .ToList(); + } + + public bool HasActiveJobs => this.jobs.Values.Any(job => job.Snapshot.IsActive); + + public bool IsChatGenerationActive(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return false; + + return this.jobs.TryGetValue(jobId, out var job) && job.Snapshot.IsActive; + } + + public AIJobSnapshot? TryGetChatSnapshot(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return this.jobs.Values + .Select(job => job.Snapshot) + .Where(snapshot => snapshot.Kind is AIJobKind.CHAT_GENERATION && snapshot.SubjectId == chatId) + .MaxBy(snapshot => snapshot.UpdatedAt); + + return this.jobs.TryGetValue(jobId, out var activeJob) ? activeJob.Snapshot : null; + } + + public ChatThread? TryGetLiveChatThread(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return null; + + return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest.ChatThread : null; + } + + public async Task TryStartChatGenerationAsync(ChatGenerationRequest request) + { + if (this.activeChatJobsByChatId.TryGetValue(request.ChatThread.ChatId, out var existingJobId)) + return this.jobs.TryGetValue(existingJobId, out var existingJob) ? existingJob.Snapshot : null; + + var jobId = Guid.NewGuid(); + var rootJobId = request.ParentJobId ?? jobId; + var snapshot = new AIJobSnapshot + { + JobId = jobId, + Kind = AIJobKind.CHAT_GENERATION, + SubjectId = request.ChatThread.ChatId, + ParentJobId = request.ParentJobId, + RootJobId = rootJobId, + Priority = request.Priority, + IsForeground = request.IsForeground, + SchedulingClass = AIJobSchedulingClass.TOP_LEVEL_USER_JOB, + Status = AIJobStatus.WAITING_FOR_REMOTE, + Title = request.ChatThread.Name, + ProviderId = request.ProviderSettings.Id, + ModelId = request.ProviderSettings.Model.Id, + UpdatedAt = DateTimeOffset.Now, + }; + + var cancellationTokenSource = new CancellationTokenSource(); + var state = new AIJobState + { + CancellationTokenSource = cancellationTokenSource, + CancellationToken = cancellationTokenSource.Token, + ChatGenerationRequest = request, + Snapshot = snapshot, + LastCheckpoint = DateTimeOffset.MinValue, + }; + + if (!this.activeChatJobsByChatId.TryAdd(request.ChatThread.ChatId, jobId)) + { + state.CancellationTokenSource.Dispose(); + return this.TryGetChatSnapshot(request.ChatThread.ChatId); + } + + if (!this.jobs.TryAdd(jobId, state)) + { + this.activeChatJobsByChatId.TryRemove(request.ChatThread.ChatId, out _); + state.CancellationTokenSource.Dispose(); + return null; + } + + request.AIText.InitialRemoteWait = true; + request.AIText.IsStreaming = false; + await CheckpointChatAsync(state, force: true); + await this.NotifyChangedAsync(state); + + _ = Task.Factory.StartNew(async () => await this.RunChatGenerationAsync(state), TaskCreationOptions.LongRunning); + return state.Snapshot; + } + + public async Task CancelAsync(Guid jobId) + { + if (!this.jobs.TryGetValue(jobId, out var job)) + return; + + lock (job.SyncRoot) + { + if (job.IsCompletionStarted) + return; + } + + try + { + if (!job.CancellationTokenSource.IsCancellationRequested) + await job.CancellationTokenSource.CancelAsync(); + } + catch (ObjectDisposedException) + { + return; + } + + await this.CompleteChatGenerationAsync(job, AIJobStatus.CANCELED); + } + + public async Task CancelChatGenerationAsync(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return; + + await this.CancelAsync(jobId); + } + + public async Task SetForegroundAsync(AIJobKind kind, Guid subjectId, bool isForeground) + { + var matchingJobs = this.jobs.Values + .Where(job => job.Snapshot.Kind == kind && job.Snapshot.SubjectId == subjectId && job.Snapshot.IsActive) + .ToList(); + + foreach (var job in matchingJobs) + { + lock (job.SyncRoot) + { + job.Snapshot = job.Snapshot with + { + IsForeground = isForeground, + UpdatedAt = DateTimeOffset.Now, + }; + } + + await this.NotifyChangedAsync(job); + } + } + + private async Task RunChatGenerationAsync(AIJobState state) + { + var request = state.ChatGenerationRequest; + var token = state.CancellationToken; + + try + { + token.ThrowIfCancellationRequested(); + + var provider = request.ProviderSettings.CreateProvider(); + var chatThread = request.ChatThread; + + if (!chatThread.IsLLMProviderAllowed(provider)) + { + logger.LogError("The provider is not allowed for chat '{ChatId}' due to data security reasons. Skipping the AI process.", chatThread.ChatId); + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, TB("The selected provider is not allowed for this chat.")); + return; + } + + if (!await this.CheckSelectedModelAvailability(provider, request.ProviderSettings.Model, token)) + { + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, TB("The selected model is not available.")); + return; + } + + token.ThrowIfCancellationRequested(); + + try + { + var rag = new AISrcSelWithRetCtxVal(); + if (request.LastUserPrompt is not null) + { + chatThread = await rag.ProcessAsync(provider, request.LastUserPrompt, chatThread, token); + request.ChatThread = chatThread; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + await this.CompleteChatGenerationAsync(state, AIJobStatus.CANCELED); + return; + } + catch (Exception e) + { + logger.LogError(e, "Skipping the RAG process due to an error."); + } + + token.ThrowIfCancellationRequested(); + + var lastStreamingEvent = DateTimeOffset.MinValue; + if (!TrySetWaitingForRemote(state, token)) + return; + + await this.NotifyChangedAsync(state); + await foreach (var contentStreamChunk in provider.StreamChatCompletion(request.ProviderSettings.Model, chatThread, settingsManager, token)) + { + if (!TryApplyStreamChunk(state, contentStreamChunk, token)) + break; + + var now = DateTimeOffset.Now; + if (!settingsManager.ConfigurationData.App.IsSavingEnergy || now - lastStreamingEvent > STREAMING_EVENT_MIN_TIME) + { + lastStreamingEvent = now; + await this.NotifyChangedAsync(state); + } + + await CheckpointChatAsync(state); + } + + await this.CompleteChatGenerationAsync(state, token.IsCancellationRequested ? AIJobStatus.CANCELED : AIJobStatus.COMPLETED); + } + catch (OperationCanceledException) + { + await this.CompleteChatGenerationAsync(state, AIJobStatus.CANCELED); + } + catch (ProviderRequestException e) + { + logger.LogError(e, "The provider request failed for chat generation job '{JobId}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", state.Snapshot.JobId, e.StatusCode, e.ReasonPhrase, e.ResponseBody); + RemoveEmptyAIResponse(state); + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.UserMessage); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); + } + catch (Exception e) + { + logger.LogError(e, "The chat generation job '{JobId}' failed.", state.Snapshot.JobId); + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.Message); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("The AI job failed. The message is: '{0}'"), e.Message))); + } + } + + private async Task CompleteChatGenerationAsync(AIJobState state, AIJobStatus status, string errorMessage = "") + { + lock (state.SyncRoot) + { + if (state.IsCompletionStarted) + return; + + state.IsCompletionStarted = true; + } + + var aiText = state.ChatGenerationRequest.AIText; + aiText.InitialRemoteWait = false; + aiText.IsStreaming = false; + aiText.Text = aiText.Text.RemoveThinkTags().Trim(); + + RemoveEmptyAIResponse(state); + + lock (state.SyncRoot) + { + state.Snapshot = state.Snapshot with + { + Status = status, + ErrorMessage = errorMessage, + UpdatedAt = DateTimeOffset.Now, + }; + } + + this.activeChatJobsByChatId.TryRemove(state.ChatGenerationRequest.ChatThread.ChatId, out _); + await CheckpointChatAsync(state, force: true); + await this.NotifyChangedAsync(state); + await messageBus.SendMessage(null, Event.AI_JOB_FINISHED, state.Snapshot); + state.CancellationTokenSource.Dispose(); + } + + private static void RemoveEmptyAIResponse(AIJobState state) + { + var aiText = state.ChatGenerationRequest.AIText; + if (!string.IsNullOrWhiteSpace(aiText.Text)) + return; + + var aiBlock = state.ChatGenerationRequest.ChatThread.Blocks + .LastOrDefault(block => ReferenceEquals(block.Content, aiText)); + + if (aiBlock is not null) + state.ChatGenerationRequest.ChatThread.Blocks.Remove(aiBlock); + } + + private static bool TrySetWaitingForRemote(AIJobState state, CancellationToken token) + { + lock (state.SyncRoot) + { + if (state.IsCompletionStarted || token.IsCancellationRequested) + return false; + + state.ChatGenerationRequest.AIText.InitialRemoteWait = true; + return true; + } + } + + private static bool TryApplyStreamChunk(AIJobState state, ContentStreamChunk contentStreamChunk, CancellationToken token) + { + lock (state.SyncRoot) + { + if (state.IsCompletionStarted || token.IsCancellationRequested) + return false; + + var aiText = state.ChatGenerationRequest.AIText; + aiText.InitialRemoteWait = false; + aiText.IsStreaming = true; + aiText.Text += contentStreamChunk; + aiText.Sources.MergeSources(contentStreamChunk.Sources); + + if (state.Snapshot.Status is not AIJobStatus.RUNNING) + { + state.Snapshot = state.Snapshot with + { + Status = AIJobStatus.RUNNING, + UpdatedAt = DateTimeOffset.Now, + }; + } + + return true; + } + } + + private async Task NotifyChangedAsync(AIJobState state) + { + lock (state.SyncRoot) + { + state.Snapshot = state.Snapshot with + { + Title = state.ChatGenerationRequest.ChatThread.Name, + UpdatedAt = DateTimeOffset.Now, + }; + } + + await messageBus.SendMessage(null, Event.AI_JOB_CHANGED, state.Snapshot); + } + + private static async Task CheckpointChatAsync(AIJobState state, bool force = false) + { + var now = DateTimeOffset.Now; + if (!force && now - state.LastCheckpoint < CHECKPOINT_MIN_TIME) + return; + + state.LastCheckpoint = now; + await WorkspaceBehaviour.StoreChatAsync(state.ChatGenerationRequest.ChatThread); + } + + private static bool ModelsMatch(Model modelA, Model modelB) + { + var idA = modelA.Id.Trim(); + var idB = modelB.Id.Trim(); + return string.Equals(idA, idB, StringComparison.OrdinalIgnoreCase); + } + + private async Task CheckSelectedModelAvailability(IProvider provider, Model chatModel, CancellationToken token = default) + { + if (chatModel.IsSystemModel) + return true; + + if (string.IsNullOrWhiteSpace(chatModel.Id)) + { + logger.LogWarning("Skipping AI request because model ID is null or white space."); + return false; + } + + if (!provider.HasModelLoadingCapability) + return true; + + IReadOnlyList loadedModels; + try + { + var modelLoadResult = await provider.GetTextModels(token: token); + if (!modelLoadResult.Success) + { + var userMessage = modelLoadResult.FailureReason.ToUserMessage(provider.InstanceName); + if (!string.IsNullOrWhiteSpace(userMessage)) + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage)); + + logger.LogWarning("Skipping selected model availability check for '{ProviderInstanceName}' (provider={ProviderType}) because loading the model list failed with reason {FailureReason}.", provider.InstanceName, provider.Provider, modelLoadResult.FailureReason); + return false; + } + + loadedModels = modelLoadResult.Models; + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception e) + { + logger.LogWarning(e, "Skipping selected model availability check for '{ProviderInstanceName}' (provider={ProviderType}) because the model list could not be loaded.", provider.InstanceName, provider.Provider); + return true; + } + + var availableModels = loadedModels.Where(model => !string.IsNullOrWhiteSpace(model.Id)).ToList(); + if (availableModels.Count == 0) + { + var emptyModelsMessage = string.Format( + TB("We could load models from '{0}', but the provider did not return any usable text models."), + provider.InstanceName); + + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, emptyModelsMessage)); + logger.LogWarning("Skipping AI request because there are no models available from '{ProviderInstanceName}' (provider={ProviderType}).", provider.InstanceName, provider.Provider); + return false; + } + + if (availableModels.Any(model => ModelsMatch(model, chatModel))) + return true; + + var message = string.Format( + TB("The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings."), + chatModel.Id, + provider.InstanceName, + provider.Provider); + + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, message)); + logger.LogWarning("Skipping AI request because model '{ModelId}' is not available from '{ProviderInstanceName}' (provider={ProviderType}).", chatModel.Id, provider.InstanceName, provider.Provider); + return false; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobSnapshot.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobSnapshot.cs new file mode 100644 index 00000000..c8037e87 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobSnapshot.cs @@ -0,0 +1,34 @@ +namespace AIStudio.Tools.AIJobs; + +public sealed record AIJobSnapshot +{ + public Guid JobId { get; init; } + + public AIJobKind Kind { get; init; } + + public Guid SubjectId { get; init; } + + public Guid? ParentJobId { get; init; } + + public Guid RootJobId { get; init; } + + public int Priority { get; init; } + + public bool IsForeground { get; init; } + + public AIJobSchedulingClass SchedulingClass { get; init; } + + public AIJobStatus Status { get; init; } + + public string Title { get; init; } = string.Empty; + + public string ProviderId { get; init; } = string.Empty; + + public string ModelId { get; init; } = string.Empty; + + public DateTimeOffset UpdatedAt { get; init; } + + public string ErrorMessage { get; init; } = string.Empty; + + public bool IsActive => this.Status is AIJobStatus.QUEUED or AIJobStatus.WAITING_FOR_REMOTE or AIJobStatus.RUNNING; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobStatus.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobStatus.cs new file mode 100644 index 00000000..49657ed8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobStatus.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Tools.AIJobs; + +public enum AIJobStatus +{ + NONE, + QUEUED, + WAITING_FOR_REMOTE, + RUNNING, + COMPLETED, + CANCELED, + FAILED, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/ChatGenerationRequest.cs b/app/MindWork AI Studio/Tools/AIJobs/ChatGenerationRequest.cs new file mode 100644 index 00000000..4d04d5d3 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/ChatGenerationRequest.cs @@ -0,0 +1,20 @@ +using AIStudio.Chat; + +namespace AIStudio.Tools.AIJobs; + +public sealed record ChatGenerationRequest +{ + public required ChatThread ChatThread { get; set; } + + public required ContentText AIText { get; init; } + + public IContent? LastUserPrompt { get; init; } + + public required AIStudio.Settings.Provider ProviderSettings { get; init; } + + public Guid? ParentJobId { get; init; } + + public int Priority { get; init; } + + public bool IsForeground { get; init; } = true; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/EmbeddingStore.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs similarity index 76% rename from app/MindWork AI Studio/Tools/Databases/EmbeddingStore.cs rename to app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs index abbae8ef..dd016fec 100644 --- a/app/MindWork AI Studio/Tools/Databases/EmbeddingStore.cs +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs @@ -4,7 +4,11 @@ public abstract class EmbeddingStore(string name, string path) { public string Name => name; - public virtual bool IsAvailable => true; + public virtual string CacheKey => name; + + public virtual DatabaseClientStatus Status => DatabaseClientStatus.AVAILABLE; + + public bool IsAvailable => this.Status is DatabaseClientStatus.AVAILABLE; private string Path => path; @@ -50,15 +54,6 @@ public abstract class EmbeddingStore(string name, string path) { this.logger = logService; } - - - public abstract Task EnsureEmbeddingStoreExists(string collectionName, int vectorSize, CancellationToken token); - - public abstract Task InsertEmbedding(string collectionName, IReadOnlyList points, CancellationToken token); - - public abstract Task DeleteEmbeddingByFile(string collectionName, string filePath, CancellationToken token); - - public abstract Task DeleteEmbeddingStore(string collectionName, CancellationToken token); public abstract void Dispose(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.Qdrant.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.Qdrant.cs new file mode 100644 index 00000000..c01cdc13 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.Qdrant.cs @@ -0,0 +1,79 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Databases; + +public sealed partial class DatabaseClientProvider +{ + private async Task CreateQdrantClientAsync(CancellationToken cancellationToken) + { + var qdrantInfo = await this.rustService.GetQdrantInfo(cancellationToken); + if (qdrantInfo.Status is QdrantStatus.STARTING) + { + return this.CreateNoDatabaseClient( + "Qdrant", + "Qdrant is starting. Details will appear shortly.", + DatabaseClientStatus.STARTING); + } + + if (!qdrantInfo.IsAvailable || qdrantInfo.Status is QdrantStatus.UNAVAILABLE) + { + var reason = qdrantInfo.UnavailableReason ?? "unknown"; + this.logger.LogWarning("Qdrant is not available. Starting without vector database. Reason: '{Reason}'.", reason); + return this.CreateNoDatabaseClient("Qdrant", qdrantInfo.UnavailableReason, DatabaseClientStatus.UNAVAILABLE); + } + + if (!HasValidQdrantConnectionInfo(qdrantInfo, out var invalidReason)) + return this.CreateNoDatabaseClient("Qdrant", invalidReason, DatabaseClientStatus.UNAVAILABLE); + + var client = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken); + client.SetLogger(this.databaseClientLogger); + + try + { + await client.CheckAvailabilityAsync(); + return client; + } + catch (Exception e) + { + client.Dispose(); + this.logger.LogWarning(e, "Qdrant reported as available by Rust, but the health check failed."); + return this.CreateNoDatabaseClient("Qdrant", e.Message, DatabaseClientStatus.STARTING); + } + } + + private static bool HasValidQdrantConnectionInfo(QdrantInfo qdrantInfo, out string invalidReason) + { + if (qdrantInfo.Path == string.Empty) + { + invalidReason = "Failed to get the Qdrant path from Rust."; + return false; + } + + if (qdrantInfo.PortHttp == 0) + { + invalidReason = "Failed to get the Qdrant HTTP port from Rust."; + return false; + } + + if (qdrantInfo.PortGrpc == 0) + { + invalidReason = "Failed to get the Qdrant gRPC port from Rust."; + return false; + } + + if (qdrantInfo.Fingerprint == string.Empty) + { + invalidReason = "Failed to get the Qdrant fingerprint from Rust."; + return false; + } + + if (qdrantInfo.ApiToken == string.Empty) + { + invalidReason = "Failed to get the Qdrant API token from Rust."; + return false; + } + + invalidReason = string.Empty; + return true; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs new file mode 100644 index 00000000..a6f98e60 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs @@ -0,0 +1,180 @@ +using AIStudio.Tools.Databases.Qdrant; +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +namespace AIStudio.Tools.Databases; + +public sealed class EmbeddingStoreProvider(RustService rustService, ILoggerFactory loggerFactory) : IDisposable +{ + private readonly Dictionary clients = new(); + private readonly Dictionary locks = new(); + private readonly Lock locksLock = new(); + private readonly ILogger logger = loggerFactory.CreateLogger(); + private readonly ILogger databaseClientLogger = loggerFactory.CreateLogger(); + + public async Task GetClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken = default) + { + var databaseLock = this.GetLock(databaseRole); + await databaseLock.WaitAsync(cancellationToken); + try + { + if (this.clients.TryGetValue(databaseRole, out var cachedClient) && cachedClient.IsAvailable) + return cachedClient; + + var client = await this.CreateClientAsync(databaseRole, cancellationToken); + return this.CacheIfAvailable(databaseRole, client); + } + finally + { + databaseLock.Release(); + } + } + + public async Task RefreshClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken = default) + { + var databaseLock = this.GetLock(databaseRole); + await databaseLock.WaitAsync(cancellationToken); + try + { + var client = await this.CreateClientAsync(databaseRole, cancellationToken); + return this.CacheIfAvailable(databaseRole, client); + } + finally + { + databaseLock.Release(); + } + } + + private DatabaseClient CacheIfAvailable(DatabaseRole databaseRole, DatabaseClient client) + { + if (!client.IsAvailable) + return client; + + if (this.clients.TryGetValue(databaseRole, out var cachedClient)) + { + if (IsSameClient(cachedClient, client)) + { + client.Dispose(); + return cachedClient; + } + + cachedClient.Dispose(); + } + + this.clients[databaseRole] = client; + return client; + } + + private SemaphoreSlim GetLock(DatabaseRole databaseRole) + { + lock (this.locksLock) + { + if (this.locks.TryGetValue(databaseRole, out var databaseLock)) + return databaseLock; + + databaseLock = new SemaphoreSlim(1, 1); + this.locks[databaseRole] = databaseLock; + return databaseLock; + } + } + + private async Task CreateClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken) => databaseRole switch + { + DatabaseRole.VECTOR_STORE => await this.CreateQdrantClientAsync(cancellationToken), + _ => new NoDatabaseClient(databaseRole.ToString(), "The requested database role is not supported.") + }; + + private async Task CreateQdrantClientAsync(CancellationToken cancellationToken) + { + var qdrantInfo = await rustService.GetQdrantInfo(cancellationToken); + if (qdrantInfo.Status is QdrantStatus.STARTING) + { + return this.CreateNoDatabaseClient( + "Qdrant", + "Qdrant is starting. Details will appear shortly.", + DatabaseClientStatus.STARTING); + } + + if (!qdrantInfo.IsAvailable || qdrantInfo.Status is QdrantStatus.UNAVAILABLE) + { + var reason = qdrantInfo.UnavailableReason ?? "unknown"; + this.logger.LogWarning("Qdrant is not available. Starting without vector database. Reason: '{Reason}'.", reason); + return this.CreateNoDatabaseClient("Qdrant", qdrantInfo.UnavailableReason, DatabaseClientStatus.UNAVAILABLE); + } + + if (!HasValidQdrantConnectionInfo(qdrantInfo, out var invalidReason)) + return this.CreateNoDatabaseClient("Qdrant", invalidReason, DatabaseClientStatus.UNAVAILABLE); + + var client = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken); + client.SetLogger(this.databaseClientLogger); + + try + { + await client.CheckAvailabilityAsync(); + return client; + } + catch (Exception e) + { + client.Dispose(); + this.logger.LogWarning(e, "Qdrant reported as available by Rust, but the health check failed."); + return this.CreateNoDatabaseClient("Qdrant", e.Message, DatabaseClientStatus.STARTING); + } + } + + private static bool HasValidQdrantConnectionInfo(QdrantInfo qdrantInfo, out string invalidReason) + { + if (qdrantInfo.Path == string.Empty) + { + invalidReason = "Failed to get the Qdrant path from Rust."; + return false; + } + + if (qdrantInfo.PortHttp == 0) + { + invalidReason = "Failed to get the Qdrant HTTP port from Rust."; + return false; + } + + if (qdrantInfo.PortGrpc == 0) + { + invalidReason = "Failed to get the Qdrant gRPC port from Rust."; + return false; + } + + if (qdrantInfo.Fingerprint == string.Empty) + { + invalidReason = "Failed to get the Qdrant fingerprint from Rust."; + return false; + } + + if (qdrantInfo.ApiToken == string.Empty) + { + invalidReason = "Failed to get the Qdrant API token from Rust."; + return false; + } + + invalidReason = string.Empty; + return true; + } + + private NoDatabaseClient CreateNoDatabaseClient(string name, string? unavailableReason, DatabaseClientStatus status) + { + var client = new NoDatabaseClient(name, unavailableReason, status); + client.SetLogger(this.databaseClientLogger); + return client; + } + + private static bool IsSameClient(DatabaseClient left, DatabaseClient right) => + left.IsAvailable + && right.IsAvailable + && left.CacheKey == right.CacheKey; + + public void Dispose() + { + foreach (var client in this.clients.Values) + client.Dispose(); + + foreach (var databaseLock in this.locks.Values) + databaseLock.Dispose(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClientStatus.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClientStatus.cs new file mode 100644 index 00000000..c9084353 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClientStatus.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.Databases; + +public enum DatabaseClientStatus +{ + STARTING, + AVAILABLE, + UNAVAILABLE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseRole.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseRole.cs new file mode 100644 index 00000000..d4b5be3c --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseRole.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.Databases; + +public enum DatabaseRole +{ + VECTOR_STORE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/NoDatabaseClient.cs b/app/MindWork AI Studio/Tools/Databases/NoDatabaseClient.cs new file mode 100644 index 00000000..230ec1fd --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/NoDatabaseClient.cs @@ -0,0 +1,23 @@ +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Tools.Databases; + +public sealed class NoEmbeddingStore(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : EmbeddingStore(name, string.Empty) +{ + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoEmbeddingStore).Namespace, nameof(NoEmbeddingStore)); + + public override DatabaseClientStatus Status => status; + + public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() + { + yield return (TB("Status"), TB("Unavailable")); + + if (!string.IsNullOrWhiteSpace(unavailableReason)) + yield return (TB("Reason"), unavailableReason); + + await Task.CompletedTask; + } + public override void Dispose() + { + } +} diff --git a/app/MindWork AI Studio/Tools/Databases/NoEmbeddingStore.cs b/app/MindWork AI Studio/Tools/Databases/NoEmbeddingStore.cs deleted file mode 100644 index 396a4894..00000000 --- a/app/MindWork AI Studio/Tools/Databases/NoEmbeddingStore.cs +++ /dev/null @@ -1,39 +0,0 @@ -using AIStudio.Tools.PluginSystem; - -namespace AIStudio.Tools.Databases; - -public sealed class NoEmbeddingStore(string name, string? unavailableReason) : EmbeddingStore(name, string.Empty) -{ - private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoEmbeddingStore).Namespace, nameof(NoEmbeddingStore)); - - public override bool IsAvailable => false; - - public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() - { - yield return (TB("Status"), TB("Unavailable")); - - if (!string.IsNullOrWhiteSpace(unavailableReason)) - yield return (TB("Reason"), unavailableReason); - - await Task.CompletedTask; - } - - public override Task EnsureEmbeddingStoreExists(string collectionName, int vectorSize, CancellationToken token) => throw this.BuildUnavailableException(); - - public override Task InsertEmbedding(string collectionName, IReadOnlyList points, CancellationToken token) => throw this.BuildUnavailableException(); - - public override Task DeleteEmbeddingByFile(string collectionName, string filePath, CancellationToken token) => Task.CompletedTask; - - public override Task DeleteEmbeddingStore(string collectionName, CancellationToken token) => Task.CompletedTask; - - public override void Dispose() - { - } - - private InvalidOperationException BuildUnavailableException() - { - return new InvalidOperationException(string.IsNullOrWhiteSpace(unavailableReason) - ? "The vector database is not available." - : unavailableReason); - } -} diff --git a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs index a4d47842..e3265940 100644 --- a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs +++ b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs @@ -28,6 +28,8 @@ public class QdrantClientImplementation : EmbeddingStore this.ApiToken = apiToken ?? string.Empty; this.GrpcClient = this.CreateQdrantClient(); } + + public override string CacheKey => $"{this.Name}:{this.HttpPort}:{this.GrpcPort}:{this.Fingerprint}"; private const string IP_ADDRESS = "localhost"; @@ -49,6 +51,11 @@ public class QdrantClientImplementation : EmbeddingStore return $"v{operation.Version}"; } + public async Task CheckAvailabilityAsync() + { + await this.GrpcClient.HealthAsync(); + } + private async Task GetCollectionsAmount() { var operation = await this.GrpcClient.ListCollectionsAsync(); diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs index 338401e3..389a90e3 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs @@ -23,10 +23,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable } }; - protected readonly HttpClient HttpClient = new() - { - BaseAddress = new Uri($"{dataSource.Hostname}:{dataSource.Port}"), - }; + protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(new Uri($"{dataSource.Hostname}:{dataSource.Port}")); protected string SecurityToken = string.Empty; diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs index 2653ca2a..f00976b9 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using AIStudio.Settings; +using AIStudio.Settings.DataModel; using AIStudio.Tools.ERIClient.DataModel; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Services; @@ -102,10 +103,23 @@ public class ERIClientV1(IERIDataSource dataSource) : ERIClientBase(dataSource), } case AuthMethod.USERNAME_PASSWORD: + if (this.DataSource.UsernamePasswordMode is DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD) + { + username = await rustService.ReadUserName(); + if (string.IsNullOrWhiteSpace(username)) + { + return new() + { + Successful = false, + Message = TB("Failed to read the user's username from the operating system.") + }; + } + } + string password; if (string.IsNullOrWhiteSpace(temporarySecret)) { - var passwordResponse = await rustService.GetSecret(this.DataSource); + var passwordResponse = await rustService.GetSecret(this.DataSource, SecretStoreType.DATA_SOURCE); if (!passwordResponse.Success) { return new() @@ -159,7 +173,7 @@ public class ERIClientV1(IERIDataSource dataSource) : ERIClientBase(dataSource), string token; if (string.IsNullOrWhiteSpace(temporarySecret)) { - var tokenResponse = await rustService.GetSecret(this.DataSource); + var tokenResponse = await rustService.GetSecret(this.DataSource, SecretStoreType.DATA_SOURCE); if (!tokenResponse.Success) { return new() diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index 5c3c4c81..59074a2d 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -1,64 +1,282 @@ namespace AIStudio.Tools; +/// +/// Defines message bus events used for communication between UI components and services. +/// public enum Event { + /// + /// Represents the absence of a message bus event. + /// NONE, + + + + + // // Common events: + // + + /// + /// Requests registered receivers to refresh state that depends on the current UI state. + /// STATE_HAS_CHANGED, + + /// + /// Notifies receivers that the application configuration was changed and should be reloaded or re-applied. + /// CONFIGURATION_CHANGED, + + /// + /// Notifies receivers that the active color theme changed. + /// COLOR_THEME_CHANGED, + + /// + /// Requests startup initialization of the plugin system. + /// STARTUP_PLUGIN_SYSTEM, + + /// + /// Notifies receivers that the startup initialization completed. + /// STARTUP_COMPLETED, + + /// + /// Carries an enterprise environment that should be processed during startup. + /// STARTUP_ENTERPRISE_ENVIRONMENT, + + /// + /// Notifies receivers that the known enterprise environments changed. + /// ENTERPRISE_ENVIRONMENTS_CHANGED, + + /// + /// Notifies receivers that plugins were reloaded. + /// PLUGINS_RELOADED, + + /// + /// Requests display of an error notification. + /// SHOW_ERROR, + + /// + /// Requests display of a warning notification. + /// SHOW_WARNING, + + /// + /// Requests display of a success notification. + /// SHOW_SUCCESS, + + /// + /// Carries an event received from the Tauri runtime. + /// TAURI_EVENT_RECEIVED, + + /// + /// Notifies receivers that the Rust service is unavailable or failed a health check. + /// RUST_SERVICE_UNAVAILABLE, + + /// + /// Notifies receivers that voice recording availability changed. + /// VOICE_RECORDING_AVAILABILITY_CHANGED, // Update events: + /// + /// Requests a user-triggered search for application updates. + /// USER_SEARCH_FOR_UPDATE, + + /// + /// Notifies receivers that an application update is available. + /// UPDATE_AVAILABLE, + + /// + /// Requests installation of the available application update. + /// INSTALL_UPDATE, + + + // // Chat events: + // + + /// + /// Queries whether the current chat has unsaved changes. + /// HAS_CHAT_UNSAVED_CHANGES, + + /// + /// Requests the current chat state to be reset. + /// RESET_CHAT_STATE, + + /// + /// Carries a chat that should be loaded by the chat component. + /// LOAD_CHAT, + + /// + /// Notifies receivers that chat response streaming has completed. + /// CHAT_STREAMING_DONE, + + /// + /// Notifies receivers that an AI job changed. + /// + AI_JOB_CHANGED, + + /// + /// Notifies receivers that an AI job finished. + /// + AI_JOB_FINISHED, + + /// + /// Notifies receivers that chat generation state changed. + /// + CHAT_GENERATION_CHANGED, // Workspace events: + /// + /// Notifies receivers that the chat loaded in the workspace changed. + /// WORKSPACE_LOADED_CHAT_CHANGED, + + /// + /// Requests the chat workspace overlay to be toggled. + /// WORKSPACE_TOGGLE_OVERLAY, + + + + + // // RAG events: + // + + /// + /// Carries data sources that were automatically selected for retrieval-augmented generation. + /// RAG_AUTO_DATA_SOURCES_SELECTED, RAG_EMBEDDING_STATUS_CHANGED, + + + + + // // File attachment events: + // + + /// + /// Registers a file drop area for file attachment handling. + /// REGISTER_FILE_DROP_AREA, + + /// + /// Unregisters a file drop area from file attachment handling. + /// UNREGISTER_FILE_DROP_AREA, + + + + // // Send events: + // + + /// + /// Sends content to the grammar and spelling assistant. + /// SEND_TO_GRAMMAR_SPELLING_ASSISTANT, + + /// + /// Sends content to the icon finder assistant. + /// SEND_TO_ICON_FINDER_ASSISTANT, + + /// + /// Sends content to the rewrite assistant. + /// SEND_TO_REWRITE_ASSISTANT, + + /// + /// Sends content to the prompt optimizer assistant. + /// SEND_TO_PROMPT_OPTIMIZER_ASSISTANT, + + /// + /// Sends content to the translation assistant. + /// SEND_TO_TRANSLATION_ASSISTANT, + + /// + /// Sends content to the agenda assistant. + /// SEND_TO_AGENDA_ASSISTANT, + + /// + /// Sends content to the coding assistant. + /// SEND_TO_CODING_ASSISTANT, + + /// + /// Sends content to the text summarizer assistant. + /// SEND_TO_TEXT_SUMMARIZER_ASSISTANT, + + /// + /// Sends the result of the current assistant to the chat component. + /// SEND_TO_CHAT, + + /// + /// Sends text to the chat input field, aka the user prompt. + /// SEND_TO_CHAT_INPUT, + + /// + /// Sends content to the email assistant. + /// SEND_TO_EMAIL_ASSISTANT, + + /// + /// Sends content to the legal check assistant. + /// SEND_TO_LEGAL_CHECK_ASSISTANT, + + /// + /// Sends content to the synonym assistant. + /// SEND_TO_SYNONYMS_ASSISTANT, + + /// + /// Sends content to the "my tasks assistant". + /// SEND_TO_MY_TASKS_ASSISTANT, + + /// + /// Sends content to the job posting assistant. + /// SEND_TO_JOB_POSTING_ASSISTANT, + + /// + /// Sends content to the document analysis assistant. + /// SEND_TO_DOCUMENT_ANALYSIS_ASSISTANT, + + /// + /// Sends content to the slide builder assistant. + /// SEND_TO_SLIDE_BUILDER_ASSISTANT -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ExternalHttpClientTimeout.cs b/app/MindWork AI Studio/Tools/ExternalHttpClientTimeout.cs new file mode 100644 index 00000000..2cb9fa45 --- /dev/null +++ b/app/MindWork AI Studio/Tools/ExternalHttpClientTimeout.cs @@ -0,0 +1,81 @@ +using AIStudio.Settings; + +namespace AIStudio.Tools; + +/// +/// Provides utility methods to standardize the management of HTTP client timeouts +/// across various components in the application. +/// +public static class ExternalHttpClientTimeout +{ + public const int MIN_HTTP_CLIENT_TIMEOUT_SECONDS = 120; + public const int MAX_HTTP_CLIENT_TIMEOUT_SECONDS = 3600; + public const int DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS = 3600; + + private static readonly Lazy SETTINGS_MANAGER = new(() => Program.SERVICE_PROVIDER.GetRequiredService()); + + public static HttpClient CreateHttpClient(Uri? baseAddress = null) + { + var httpClient = new HttpClient(); + Configure(httpClient, baseAddress); + return httpClient; + } + + public static string GetTimeoutDescription() + { + var timeout = GetTimeout(); + + if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0) + { + var hours = (int)timeout.TotalHours; + return hours == 1 ? "1 hour" : $"{hours} hours"; + } + + if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0) + { + var minutes = (int)timeout.TotalMinutes; + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + } + + var seconds = (int)timeout.TotalSeconds; + return seconds == 1 ? "1 second" : $"{seconds} seconds"; + } + + public static CancellationTokenSource CreateTimeoutTokenSource(CancellationToken cancellationToken) + { + var timeoutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutTokenSource.CancelAfter(GetTimeout()); + return timeoutTokenSource; + } + + public static bool IsTimeoutException(Exception exception, CancellationToken userCancellationToken = default) + { + if (userCancellationToken.IsCancellationRequested) + return false; + + if (exception is TimeoutException) + return true; + + if (exception is OperationCanceledException) + return true; + + return exception.InnerException is not null && IsTimeoutException(exception.InnerException, userCancellationToken); + } + + private static TimeSpan GetTimeout() + { + var seconds = SETTINGS_MANAGER.Value.ConfigurationData.App.HttpClientTimeoutSeconds; + if (seconds <= 0) + seconds = DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS; + + seconds = Math.Clamp(seconds, MIN_HTTP_CLIENT_TIMEOUT_SECONDS, MAX_HTTP_CLIENT_TIMEOUT_SECONDS); + return TimeSpan.FromSeconds(seconds); + } + + private static void Configure(HttpClient httpClient, Uri? baseAddress = null) + { + httpClient.Timeout = GetTimeout(); + if (baseAddress is not null) + httpClient.BaseAddress = baseAddress; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/LuaTools.cs b/app/MindWork AI Studio/Tools/LuaTools.cs deleted file mode 100644 index 0df50cd0..00000000 --- a/app/MindWork AI Studio/Tools/LuaTools.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace AIStudio.Tools; - -public static class LuaTools -{ - public static string EscapeLuaString(string? value) - { - if (string.IsNullOrEmpty(value)) - return string.Empty; - - return value - .Replace("\\", "\\\\") - .Replace("\"", "\\\"") - .Replace("\r", "\\r") - .Replace("\n", "\\n"); - } -} diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs index f7feb24a..d0e3452b 100644 --- a/app/MindWork AI Studio/Tools/MessageBus.cs +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -64,11 +64,11 @@ public sealed class MessageBus { foreach (var (receiver, componentFilter) in this.componentFilters) { - if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent)) + if (componentFilter.Length > 0 && message.SendingComponent is not null && !componentFilter.Contains(message.SendingComponent)) continue; var eventFilter = this.componentEvents[receiver]; - if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent)) + if (eventFilter.Length == 0 || eventFilter.Contains(message.TriggeredEvent)) // We don't await the task here because we don't want to block the message bus: _ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data); diff --git a/app/MindWork AI Studio/Tools/Metadata/MetaDataDatabasesAttribute.cs b/app/MindWork AI Studio/Tools/Metadata/MetaDataEmbeddingStoreAttribute.cs similarity index 100% rename from app/MindWork AI Studio/Tools/Metadata/MetaDataDatabasesAttribute.cs rename to app/MindWork AI Studio/Tools/Metadata/MetaDataEmbeddingStoreAttribute.cs diff --git a/app/MindWork AI Studio/Tools/Pandoc.cs b/app/MindWork AI Studio/Tools/Pandoc.cs index c5826eaa..8767b1ee 100644 --- a/app/MindWork AI Studio/Tools/Pandoc.cs +++ b/app/MindWork AI Studio/Tools/Pandoc.cs @@ -35,12 +35,13 @@ public static partial class Pandoc private static bool HAS_LOGGED_AVAILABILITY_CHECK_ONCE; private static readonly HttpClient WEB_CLIENT = new(); + private static readonly SemaphoreSlim INSTALLATION_LOCK = new(1, 1); /// /// Prepares a Pandoc process by using the Pandoc process builder. /// /// The Pandoc process builder with default settings. - public static PandocProcessBuilder PreparePandocProcess() => PandocProcessBuilder.Create(); + private static PandocProcessBuilder PreparePandocProcess() => PandocProcessBuilder.Create(); /// /// Checks if pandoc is available on the system and can be started as a process or is present in AI Studio's data dir. @@ -145,12 +146,12 @@ public static partial class Pandoc catch (Exception e) { if (showMessages) - await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("It seems that Pandoc is not installed."))); + await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("Pandoc doesn't seem to be installed."))); if(shouldLog) LOG.LogError(e, "Pandoc availability check failed. This usually means Pandoc is not installed or not in the system PATH."); - return new(false, TB("It seems that Pandoc is not installed."), false, string.Empty, false); + return new(false, TB("Pandoc doesn't seem to be installed."), false, string.Empty, false); } finally { @@ -165,76 +166,230 @@ public static partial class Pandoc /// None public static async Task InstallAsync(RustService rustService) { + await INSTALLATION_LOCK.WaitAsync(); + var latestVersion = await FetchLatestVersionAsync(); var installDir = await GetPandocDataFolder(rustService); - ClearFolder(installDir); + var installParentDir = Path.GetDirectoryName(installDir) ?? Path.GetTempPath(); + var stagingDir = Path.Combine(installParentDir, $"pandoc-install-{Guid.NewGuid():N}"); + var pandocTempDownloadFile = Path.GetTempFileName(); LOG.LogInformation("Trying to install Pandoc v{0} to '{1}'...", latestVersion, installDir); try { - if (!Directory.Exists(installDir)) - Directory.CreateDirectory(installDir); - - // Create a temporary file to download the archive to: - var pandocTempDownloadFile = Path.GetTempFileName(); + if (!Directory.Exists(installParentDir)) + Directory.CreateDirectory(installParentDir); // // Download the latest Pandoc archive from GitHub: // - var uri = await GenerateArchiveUriAsync(); - var response = await WEB_CLIENT.GetAsync(uri); + var uri = GenerateArchiveUri(latestVersion); + if (string.IsNullOrWhiteSpace(uri)) + { + await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive type is unknown."))); + LOG.LogError("Pandoc was not installed, no archive is available for architecture '{Architecture}'.", CPU_ARCHITECTURE.ToUserFriendlyName()); + return; + } + + using var response = await WEB_CLIENT.GetAsync(uri); if (!response.IsSuccessStatusCode) { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("Pandoc was not installed successfully, because the archive was not found."))); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive was not found."))); LOG.LogError("Pandoc was not installed successfully, because the archive was not found (status code {0}): url='{1}', message='{2}'", response.StatusCode, uri, response.RequestMessage); return; } // Download the archive to the temporary file: - await using var tempFileStream = File.Create(pandocTempDownloadFile); - await response.Content.CopyToAsync(tempFileStream); + await using (var tempFileStream = File.Create(pandocTempDownloadFile)) + { + await response.Content.CopyToAsync(tempFileStream); + await tempFileStream.FlushAsync(); + } + Directory.CreateDirectory(stagingDir); if (uri.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { - ZipFile.ExtractToDirectory(pandocTempDownloadFile, installDir); + await RunWithRetriesAsync( + () => + { + ZipFile.ExtractToDirectory(pandocTempDownloadFile, stagingDir, true); + return Task.CompletedTask; + }, + "extracting the Pandoc ZIP archive"); } else if (uri.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) { - await using var tgzStream = File.Open(pandocTempDownloadFile, FileMode.Open, FileAccess.Read, FileShare.Read); - await using var uncompressedStream = new GZipStream(tgzStream, CompressionMode.Decompress); - await TarFile.ExtractToDirectoryAsync(uncompressedStream, installDir, true); + await RunWithRetriesAsync( + async () => + { + await using var tgzStream = File.Open(pandocTempDownloadFile, FileMode.Open, FileAccess.Read, FileShare.Read); + await using var uncompressedStream = new GZipStream(tgzStream, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(uncompressedStream, stagingDir, true); + }, + "extracting the Pandoc TAR archive"); } else { - await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("Pandoc was not installed successfully, because the archive type is unknown."))); + await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive type is unknown."))); LOG.LogError("Pandoc was not installed, the archive is unknown: url='{0}'", uri); return; } - File.Delete(pandocTempDownloadFile); - + var stagedPandocExecutable = FindExecutableInDirectory(stagingDir, PandocProcessBuilder.PandocExecutableName); + if (string.IsNullOrWhiteSpace(stagedPandocExecutable)) + { + await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the executable was not found in the archive."))); + LOG.LogError("Pandoc was not installed, the executable was not found in the extracted archive: '{StagingDir}'.", stagingDir); + return; + } + + LOG.LogInformation("Found Pandoc executable in downloaded archive: '{Executable}'.", stagedPandocExecutable); + + await ReplaceInstallationDirectoryAsync(stagingDir, installDir); await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, string.Format(TB("Pandoc v{0} was installed successfully."), latestVersion))); LOG.LogInformation("Pandoc v{0} was installed successfully.", latestVersion); } catch (Exception ex) { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc."))); LOG.LogError(ex, "An error occurred while installing Pandoc."); } + finally + { + TryDeleteFile(pandocTempDownloadFile); + + if (Directory.Exists(stagingDir)) + await TryDeleteFolderAsync(stagingDir); + + INSTALLATION_LOCK.Release(); + } } - private static void ClearFolder(string path) + private static async Task ReplaceInstallationDirectoryAsync(string stagingDir, string installDir) { - if (!Directory.Exists(path)) - return; - + var backupDir = $"{installDir}.backup-{Guid.NewGuid():N}"; + var hasBackup = false; + var stagingWasMoved = false; + try { - Directory.Delete(path, true); + if (Directory.Exists(installDir)) + { + await MoveDirectoryWithRetriesAsync(installDir, backupDir, "moving the previous Pandoc installation to backup"); + hasBackup = true; + } + + await MoveDirectoryWithRetriesAsync(stagingDir, installDir, "moving the new Pandoc installation into place"); + stagingWasMoved = true; } catch (Exception ex) { - LOG.LogError(ex, "Error clearing pandoc installation directory."); + if (hasBackup && !stagingWasMoved && !Directory.Exists(installDir) && Directory.Exists(backupDir)) + { + try + { + await MoveDirectoryWithRetriesAsync(backupDir, installDir, "restoring the previous Pandoc installation"); + hasBackup = false; + } + catch (Exception rollbackEx) + { + LOG.LogError(rollbackEx, "Error restoring previous Pandoc installation directory. Keeping backup directory at: '{BackupDir}'.", backupDir); + } + } + + LOG.LogError(ex, "Error replacing pandoc installation directory."); + throw; + } + finally + { + if (hasBackup && stagingWasMoved && Directory.Exists(backupDir)) + await TryDeleteFolderAsync(backupDir); + } + } + + private static string FindExecutableInDirectory(string rootDirectory, string executableName) + { + if (!Directory.Exists(rootDirectory)) + return string.Empty; + + var rootExecutablePath = Path.Combine(rootDirectory, executableName); + if (File.Exists(rootExecutablePath)) + return rootExecutablePath; + + foreach (var subdirectory in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories)) + { + var pandocPath = Path.Combine(subdirectory, executableName); + if (File.Exists(pandocPath)) + return pandocPath; + } + + return string.Empty; + } + + private static async Task MoveDirectoryWithRetriesAsync(string sourceDir, string destinationDir, string operationName) + { + await RunWithRetriesAsync( + () => + { + Directory.Move(sourceDir, destinationDir); + return Task.CompletedTask; + }, + operationName, + maxAttempts: 8); + } + + private static async Task RunWithRetriesAsync(Func operation, string operationName, int maxAttempts = 4) + { + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + await operation(); + return; + } + catch (Exception ex) when (attempt < maxAttempts && ex is IOException or UnauthorizedAccessException) + { + LOG.LogWarning(ex, "Error while {OperationName}; retrying attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, maxAttempts); + await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt)); + } + } + } + + private static void TryDeleteFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return; + + try + { + File.Delete(path); + } + catch (Exception ex) + { + LOG.LogWarning(ex, "Was not able to delete temporary Pandoc archive: '{Path}'.", path); + } + } + + private static async Task TryDeleteFolderAsync(string path) + { + if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path)) + return; + + try + { + await RunWithRetriesAsync( + () => + { + Directory.Delete(path, true); + return Task.CompletedTask; + }, + $"deleting temporary Pandoc directory '{path}'", + maxAttempts: 3); + } + catch (Exception ex) + { + LOG.LogWarning(ex, "Was not able to delete temporary Pandoc directory: '{Path}'.", path); } } @@ -248,7 +403,7 @@ public static partial class Pandoc if (!response.IsSuccessStatusCode) { LOG.LogError("Code {StatusCode}: Could not fetch Pandoc's latest page: {Response}", response.StatusCode, response.RequestMessage); - await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("The latest Pandoc version was not found, installing version {0} instead."), FALLBACK_VERSION.ToString()))); + await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("AI Studio couldn't find the latest Pandoc version and will install version {0} instead."), FALLBACK_VERSION.ToString()))); return FALLBACK_VERSION.ToString(); } @@ -257,7 +412,7 @@ public static partial class Pandoc if (!versionMatch.Success) { LOG.LogError("The latest version regex returned nothing: {0}", versionMatch.Groups.ToString()); - await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("The latest Pandoc version was not found, installing version {0} instead."), FALLBACK_VERSION.ToString()))); + await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("AI Studio couldn't find the latest Pandoc version and will install version {0} instead."), FALLBACK_VERSION.ToString()))); return FALLBACK_VERSION.ToString(); } @@ -272,6 +427,11 @@ public static partial class Pandoc public static async Task GenerateArchiveUriAsync() { var version = await FetchLatestVersionAsync(); + return GenerateArchiveUri(version); + } + + private static string GenerateArchiveUri(string version) + { var baseUri = $"{DOWNLOAD_URL}/{version}/pandoc-{version}-"; return CPU_ARCHITECTURE switch { diff --git a/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs b/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs index 6d95ad9f..6d0909f8 100644 --- a/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs +++ b/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs @@ -220,6 +220,17 @@ public sealed class PandocProcessBuilder } } + foreach (var candidate in SystemPandocExecutableCandidates(PandocExecutableName)) + { + if (!File.Exists(candidate)) + continue; + + if (shouldLog) + LOGGER.LogInformation("Found system Pandoc installation at: '{Path}'.", candidate); + + return new(candidate, false); + } + // // When no local installation was found, we assume that the pandoc executable is in the system PATH: // @@ -238,4 +249,59 @@ public sealed class PandocProcessBuilder /// Reads the os platform to determine the used executable name. /// public static string PandocExecutableName => CPU_ARCHITECTURE is RID.WIN_ARM64 or RID.WIN_X64 ? "pandoc.exe" : "pandoc"; + + private static IEnumerable SystemPandocExecutableCandidates(string executableName) + { + var candidates = new List(); + + switch (CPU_ARCHITECTURE) + { + case RID.WIN_X64 or RID.WIN_ARM64: + AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Pandoc", executableName); + AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Pandoc", executableName); + AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Pandoc", executableName); + break; + + case RID.OSX_X64 or RID.OSX_ARM64: + AddCandidate(candidates, "/opt/homebrew/bin", executableName); + AddCandidate(candidates, "/usr/local/bin", executableName); + AddCandidate(candidates, "/usr/bin", executableName); + break; + + case RID.LINUX_X64 or RID.LINUX_ARM64: + AddCandidate(candidates, "/usr/local/bin", executableName); + AddCandidate(candidates, "/usr/bin", executableName); + AddCandidate(candidates, "/snap/bin", executableName); + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + AddCandidate(candidates, homeDirectory, ".local", "bin", executableName); + break; + } + + foreach (var pathDirectory in GetPathDirectories()) + AddCandidate(candidates, pathDirectory, executableName); + + var comparer = CPU_ARCHITECTURE is RID.WIN_X64 or RID.WIN_ARM64 + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + return candidates.Distinct(comparer); + } + + private static IEnumerable GetPathDirectories() + { + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(pathValue)) + yield break; + + foreach (var pathDirectory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + yield return pathDirectory; + } + + private static void AddCandidate(List candidates, params string[] pathParts) + { + if (pathParts.Any(string.IsNullOrWhiteSpace)) + return; + + candidates.Add(Path.Combine(pathParts)); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs index 5f1cb58b..63b7ebfb 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs @@ -13,37 +13,4 @@ public sealed record PendingEnterpriseApiKey( string SecretId, string SecretName, string ApiKey, - SecretStoreType StoreType); - -/// -/// Static container for pending API keys during plugin loading. -/// -public static class PendingEnterpriseApiKeys -{ - private static readonly List PENDING_KEYS = []; - private static readonly Lock LOCK = new(); - - /// - /// Adds a pending API key to the list. - /// - /// The pending API key to add. - public static void Add(PendingEnterpriseApiKey key) - { - lock (LOCK) - PENDING_KEYS.Add(key); - } - - /// - /// Gets and clears all pending API keys. - /// - /// A list of all pending API keys. - public static IReadOnlyList GetAndClear() - { - lock (LOCK) - { - var keys = PENDING_KEYS.ToList(); - PENDING_KEYS.Clear(); - return keys; - } - } -} + SecretStoreType StoreType); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKeys.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKeys.cs new file mode 100644 index 00000000..8824424e --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKeys.cs @@ -0,0 +1,34 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Static container for pending API keys during plugin loading. +/// +public static class PendingEnterpriseApiKeys +{ + private static readonly List PENDING_KEYS = []; + private static readonly Lock LOCK = new(); + + /// + /// Adds a pending API key to the list. + /// + /// The pending API key to add. + public static void Add(PendingEnterpriseApiKey key) + { + lock (LOCK) + PENDING_KEYS.Add(key); + } + + /// + /// Gets and clears all pending API keys. + /// + /// A list of all pending API keys. + public static IReadOnlyList GetAndClear() + { + lock (LOCK) + { + var keys = PENDING_KEYS.ToList(); + PENDING_KEYS.Clear(); + return keys; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecret.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecret.cs new file mode 100644 index 00000000..d4be88e4 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecret.cs @@ -0,0 +1,14 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents a pending enterprise secret that needs to be stored in the OS keyring. +/// +/// The secret ID. +/// The secret name. +/// The decrypted secret data. +/// The type of secret store to use. +public sealed record PendingEnterpriseSecret( + string SecretId, + string SecretName, + string SecretData, + SecretStoreType StoreType); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecrets.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecrets.cs new file mode 100644 index 00000000..1fef45fa --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseSecrets.cs @@ -0,0 +1,34 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Static container for pending enterprise secrets during plugin loading. +/// +public static class PendingEnterpriseSecrets +{ + private static readonly List PENDING_SECRETS = []; + private static readonly Lock LOCK = new(); + + /// + /// Adds a pending enterprise secret to the list. + /// + /// The pending enterprise secret to add. + public static void Add(PendingEnterpriseSecret secret) + { + lock (LOCK) + PENDING_SECRETS.Add(secret); + } + + /// + /// Gets and clears all pending enterprise secrets. + /// + /// A list of all pending enterprise secrets. + public static IReadOnlyList GetAndClear() + { + lock (LOCK) + { + var secrets = PENDING_SECRETS.ToList(); + PENDING_SECRETS.Clear(); + return secrets; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index b28ffd64..7cfb4a70 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -41,12 +41,43 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Store any decrypted API keys from enterprise configuration in the OS keyring: await StoreEnterpriseApiKeysAsync(); + await StoreEnterpriseSecretsAsync(); await SETTINGS_MANAGER.StoreSettings(); await MessageBus.INSTANCE.SendMessage(null, Event.CONFIGURATION_CHANGED); } } + /// + /// Stores any pending enterprise secrets in the OS keyring. + /// + private static async Task StoreEnterpriseSecretsAsync() + { + var pendingSecrets = PendingEnterpriseSecrets.GetAndClear(); + if (pendingSecrets.Count == 0) + return; + + LOG.LogInformation($"Storing {pendingSecrets.Count} enterprise secret(s) in the OS keyring."); + var rustService = Program.SERVICE_PROVIDER.GetRequiredService(); + foreach (var pendingSecret in pendingSecrets) + { + try + { + var secretId = new TemporarySecretId(pendingSecret.SecretId, pendingSecret.SecretName); + var result = await rustService.SetSecret(secretId, pendingSecret.SecretData, pendingSecret.StoreType); + + if (result.Success) + LOG.LogDebug($"Successfully stored enterprise secret for '{pendingSecret.SecretName}' in the OS keyring."); + else + LOG.LogWarning($"Failed to store enterprise secret for '{pendingSecret.SecretName}': {result.Issue}"); + } + catch (Exception ex) + { + LOG.LogError(ex, $"Exception while storing enterprise secret for '{pendingSecret.SecretName}'."); + } + } + } + /// /// Stores any pending enterprise API keys in the OS keyring. /// @@ -142,6 +173,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: global voice recording shortcut ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun); + + // Config: timeout for external HTTP requests + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun); // Handle configured LLM providers: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); @@ -153,7 +187,10 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured chat templates: - PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun); + PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath); + + // Handle configured data sources: + PluginConfigurationObject.TryParseDataSources(mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured profiles: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.PROFILE, x => x.Profiles, x => x.NextProfileNum, mainTable, this.Id, ref this.configObjects, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index 724ce5c8..fa9b9610 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -53,6 +53,7 @@ public sealed record PluginConfigurationObject /// This parameter is passed by reference. /// Specifies whether to perform the operation as a dry run, where changes /// are not persisted. + /// An optional parameter specifying the file path of the plugin, used for relative paths in the Lua table. /// Returns true if parsing succeeds and configuration objects are added /// to the list; otherwise, false. public static bool TryParse( @@ -62,7 +63,8 @@ public sealed record PluginConfigurationObject LuaTable mainTable, Guid configPluginId, ref List configObjects, - bool dryRun + bool dryRun, + string pluginPath = "" ) where TClass : ConfigurationBaseObject { var luaTableName = configObjectType switch @@ -105,7 +107,7 @@ public sealed record PluginConfigurationObject var (wasParsingSuccessful, configObject) = configObjectType switch { PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject), - PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject), + PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject), PluginConfigurationObjectType.PROFILE => (Profile.TryParseProfileTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Profile.NO_PROFILE, configurationObject), PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject), PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject), @@ -199,6 +201,87 @@ public sealed record PluginConfigurationObject return wasConfigurationChanged; } + /// + /// Parses configured data sources from a configuration plugin. + /// + /// The Lua table containing entries to parse into data sources. + /// The unique identifier of the plugin associated with the data sources. + /// The list to populate with the parsed configuration objects. + /// Specifies whether to perform the operation as a dry run. + /// True if the table was present and processed; otherwise false. + public static bool TryParseDataSources( + LuaTable mainTable, + Guid configPluginId, + ref List configObjects, + bool dryRun) + { + const string LUA_TABLE_NAME = "DATA_SOURCES"; + if (!mainTable.TryGetValue(LUA_TABLE_NAME, out var luaValue) || !luaValue.TryRead(out var luaTable)) + { + LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, configPluginId); + return false; + } + + var storedObjects = SETTINGS_MANAGER.ConfigurationData.DataSources; + var numberObjects = luaTable.ArrayLength; + ThreadSafeRandom? random = null; + for (var i = 1; i <= numberObjects; i++) + { + var luaObjectTableValue = luaTable[i]; + if (!luaObjectTableValue.TryRead(out var luaObjectTable)) + { + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + continue; + } + + if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, out var configObject)) + { + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + continue; + } + + configObjects.Add(new() + { + ConfigPluginId = configPluginId, + Id = Guid.Parse(configObject.Id), + Type = PluginConfigurationObjectType.DATA_SOURCE, + }); + + if (dryRun) + continue; + + var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id); + if (objectIndex > -1) + { + var existingObject = storedObjects[objectIndex]; + configObject = configObject with { Num = existingObject.Num }; + storedObjects[objectIndex] = configObject; + } + else + { + if (IncrementDataSourceNum() is { Success: true, UpdatedValue: var nextNum }) + { + configObject = configObject with { Num = nextNum }; + storedObjects.Add(configObject); + } + else + { + random ??= new ThreadSafeRandom(); + configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) }; + storedObjects.Add(configObject); + LOG.LogWarning("The next number for the data source '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId); + } + } + } + + return true; + + static IncrementResult IncrementDataSourceNum() + { + return ((Expression>)(x => x.NextDataSourceNum)).TryIncrement(SETTINGS_MANAGER.ConfigurationData, IncrementType.POST); + } + } + /// /// Cleans up configuration objects of a specified type that are no longer associated with any available plugin. /// @@ -208,13 +291,15 @@ public sealed record PluginConfigurationObject /// A list of currently available plugins. /// A list of all existing configuration objects. /// An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable. + /// When true, delete the associated non-API-key secret from the OS keyring. /// Returns true if the configuration was altered during cleanup; otherwise, false. public static async Task CleanLeftOverConfigurationObjects( PluginConfigurationObjectType configObjectType, Expression>> configObjectSelection, IList availablePlugins, IList configObjectList, - SecretStoreType? secretStoreType = null) where TClass : IConfigurationObject + SecretStoreType? secretStoreType = null, + bool deleteSecret = false) where TClass : IConfigurationObject { var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData); var leftOverObjects = new List(); @@ -270,7 +355,15 @@ public sealed record PluginConfigurationObject configuredObjects.Remove(item); // Delete the API key from the OS keyring if the removed object has one: - if(secretStoreType is not null && item is ISecretId secretId) + if(deleteSecret && item is ISecretId regularSecretId) + { + var deleteResult = await RUST_SERVICE.DeleteSecret(regularSecretId, secretStoreType ?? SecretStoreType.DATA_SOURCE); + if (deleteResult.Success) + LOG.LogInformation($"Successfully deleted secret for removed enterprise object '{item.Name}' from the OS keyring."); + else + LOG.LogWarning($"Failed to delete secret for removed enterprise object '{item.Name}' from the OS keyring: {deleteResult.Issue}"); + } + else if(secretStoreType is not null && item is ISecretId secretId) { var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value); if (deleteResult.Success) diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index 9b56e3af..daf77fb0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -15,7 +15,7 @@ public static partial class PluginFactory var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl; var downloadUrl = $"{serverUrl}/{configPlugId}.zip"; - using var http = new HttpClient(); + using var http = ExternalHttpClientTimeout.CreateHttpClient(); using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) @@ -52,7 +52,7 @@ public static partial class PluginFactory try { await LockHotReloadAsync(); - using var httpClient = new HttpClient(); + using var httpClient = ExternalHttpClientTimeout.CreateHttpClient(); var response = await httpClient.GetAsync(downloadUrl, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index aedc7f7e..c939899d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -174,6 +174,10 @@ public static partial class PluginFactory if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER)) wasConfigurationChanged = true; + // Check data sources: + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true)) + wasConfigurationChanged = true; + // Check chat templates: if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; @@ -241,6 +245,10 @@ public static partial class PluginFactory if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + // Check for the external HTTP client timeout: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HttpClientTimeoutSeconds, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + // Check if audit is required before it can be activated if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; @@ -318,7 +326,11 @@ public static partial class PluginFactory return new PluginLanguage(isInternal, state, type); case PluginType.CONFIGURATION: - var configPlug = new PluginConfiguration(isInternal, state, type); + var configPlug = new PluginConfiguration(isInternal, state, type) + { + PluginPath = pluginPath ?? string.Empty + }; + await configPlug.InitializeAsync(true); return configPlug; diff --git a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs index 623a03b0..99e38e13 100644 --- a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs +++ b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs @@ -9,7 +9,7 @@ namespace AIStudio.Tools.Rust; /// public static class FileTypes { - private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileTypeFilter).Namespace, nameof(FileTypeFilter)); + private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileTypes).Namespace, nameof(FileTypes)); public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAMES = FileTypeFilter.Leaf(TB("Source like"), "Dockerfile", "Containerfile", "Jenkinsfile", "Makefile", "GNUmakefile", "Procfile", "Vagrantfile", @@ -45,7 +45,7 @@ public static class FileTypes // Document hierarchy public static readonly FileTypeFilter PDF = FileTypeFilter.Leaf("PDF", "pdf"); public static readonly FileTypeFilter TEXT = FileTypeFilter.Leaf(TB("Text"), "txt", "md", "rtf"); - public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx", "doc"); + public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx"); public static readonly FileTypeFilter WORD = FileTypeFilter.Composite("Word", ["odt"], MS_WORD); public static readonly FileTypeFilter EXCEL = FileTypeFilter.Leaf("Excel", "xls", "xlsx"); public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "ppt", "pptx"); diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs b/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs index 5315eca7..30044596 100644 --- a/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs +++ b/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs @@ -5,6 +5,8 @@ /// public readonly record struct QdrantInfo { + public QdrantStatus Status { get; init; } + public bool IsAvailable { get; init; } public string? UnavailableReason { get; init; } diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs b/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs new file mode 100644 index 00000000..10d6246a --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.Rust; + +public enum QdrantStatus +{ + STARTING, + AVAILABLE, + UNAVAILABLE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SecretStoreType.cs b/app/MindWork AI Studio/Tools/SecretStoreType.cs index c4382b7b..5e9182d7 100644 --- a/app/MindWork AI Studio/Tools/SecretStoreType.cs +++ b/app/MindWork AI Studio/Tools/SecretStoreType.cs @@ -1,10 +1,10 @@ namespace AIStudio.Tools; /// -/// Represents the type of secret store used for API keys. +/// Represents the type of secret store used for API keys and other secrets. /// /// -/// Different provider types use different prefixes for storing API keys. +/// Different provider and secret types use different prefixes for storing secrets. /// This prevents collisions when the same instance name is used across /// different provider types (e.g., LLM, Embedding, Transcription). /// @@ -29,4 +29,9 @@ public enum SecretStoreType /// Image provider secrets. Uses the "image::" prefix. /// IMAGE_PROVIDER, -} + + /// + /// Data source secrets. Uses the "data-source::" prefix. + /// + DATA_SOURCE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs b/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs index d0d4ba9e..5e8ae2f0 100644 --- a/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs +++ b/app/MindWork AI Studio/Tools/SecretStoreTypeExtensions.cs @@ -9,12 +9,14 @@ public static class SecretStoreTypeExtensions /// LLM_PROVIDER uses the legacy "provider" prefix for backward compatibility. /// /// The SecretStoreType enum value. - /// >The corresponding prefix string. + /// The corresponding prefix string. public static string Prefix(this SecretStoreType type) => type switch { SecretStoreType.LLM_PROVIDER => "provider", SecretStoreType.EMBEDDING_PROVIDER => "embedding", SecretStoreType.TRANSCRIPTION_PROVIDER => "transcription", + SecretStoreType.IMAGE_PROVIDER => "image", + SecretStoreType.DATA_SOURCE => "data-source", _ => "provider", }; diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index 656d7358..6db55a6c 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -200,7 +200,7 @@ public sealed class EnterpriseEnvironmentService(ILogger(), SecretStoreType.DATA_SOURCE, secretTargets); return secretTargets.ToList(); } diff --git a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs index 7d701670..9f33c68a 100644 --- a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -185,9 +185,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv return new(shortcut, isEnabled, false); var fallbackShortcut = settingsSnapshot.App.ShortcutVoiceRecording; - var fallbackEnabled = - settingsSnapshot.App.EnabledPreviewFeatures.Contains(PreviewFeatures.PRE_SPEECH_TO_TEXT_2026) && - !string.IsNullOrWhiteSpace(settingsSnapshot.App.UseTranscriptionProvider); + var fallbackEnabled = !string.IsNullOrWhiteSpace(settingsSnapshot.App.UseTranscriptionProvider); if (!fallbackEnabled || string.IsNullOrWhiteSpace(fallbackShortcut)) return new(shortcut, isEnabled, false); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs index 4fcc891b..fda5bdd8 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs @@ -5,49 +5,27 @@ namespace AIStudio.Tools.Services; public sealed partial class RustService { - public async Task GetEmbeddingStoreConfiguration(EmbeddingStoreKind kind) - { - switch (kind) - { - case EmbeddingStoreKind.QDRANT_REMOTE: - { - var qdrantInfo = await this.GetQdrantInfo(); - var invalidFields = new List(); - if (!qdrantInfo.IsAvailable) - invalidFields.Add(qdrantInfo.UnavailableReason ?? "unknown"); - if (string.IsNullOrWhiteSpace(qdrantInfo.Path)) - invalidFields.Add("Path"); - if (qdrantInfo.PortHttp == 0) - invalidFields.Add("HttpPort"); - if (qdrantInfo.PortGrpc == 0) - invalidFields.Add("GrpcPort"); - if (string.IsNullOrWhiteSpace(qdrantInfo.Fingerprint)) - invalidFields.Add("Fingerprint"); - if (string.IsNullOrWhiteSpace(qdrantInfo.ApiToken)) - invalidFields.Add("ApiToken"); - if (invalidFields.Count <= 0) return new EmbeddingStoreConfiguration(kind, "Qdrant", new RemoteLocation(qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken), null); - var reason = string.Join(", ", invalidFields); - Console.WriteLine($"Warning: Qdrant is not available. Starting without vector database. Reason: '{reason}'."); - - return new EmbeddingStoreConfiguration( - EmbeddingStoreKind.NONE, - "Qdrant", - null, - reason); - - } - default: - return new EmbeddingStoreConfiguration(kind, kind.ToString(), null, $"No configuration available for {kind}"); - } - } - - public async Task GetQdrantInfo() + public async Task GetQdrantInfo(CancellationToken cancellationToken = default) { try { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - var response = await this.http.GetFromJsonAsync("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token); - return response; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(45)); + + return await this.http.GetFromJsonAsync("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if(this.logger is not null) + this.logger.LogWarning("Fetching Qdrant info from Rust service was cancelled by caller."); + else + Console.WriteLine("Fetching Qdrant info from Rust service was cancelled by caller."); + + return new QdrantInfo + { + Status = QdrantStatus.UNAVAILABLE, + UnavailableReason = "Operation cancelled by caller." + }; } catch (Exception e) { @@ -56,7 +34,11 @@ public sealed partial class RustService else Console.WriteLine($"Error while fetching Qdrant info from Rust service: '{e}'."); - return default; + return new QdrantInfo + { + Status = QdrantStatus.UNAVAILABLE, + UnavailableReason = e.Message + }; } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index e44dfa7f..a9c0b337 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -6,8 +6,11 @@ public sealed partial class RustService { public async Task SelectDirectory(string title, string? initialDirectory = null) { - PreviousDirectory? previousDirectory = initialDirectory is null ? null : new (initialDirectory); - var result = await this.http.PostAsJsonAsync($"/select/directory?title={title}", previousDirectory, this.jsonRustSerializerOptions); + var encodedTitle = Uri.EscapeDataString(title); + var result = initialDirectory is null + ? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null) + : await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs index 0b81ccfe..9fd151e8 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs @@ -32,4 +32,35 @@ public sealed partial class RustService this.userLanguageLock.Release(); } } + + public async Task ReadUserName(bool forceRequest = false) + { + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName)) + return this.cachedUserName; + + await this.userNameLock.WaitAsync(); + try + { + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName)) + return this.cachedUserName; + + var response = await this.http.GetAsync("/system/username"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to read the user name from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + var userName = (await response.Content.ReadAsStringAsync()).Trim(); + if (string.IsNullOrWhiteSpace(userName)) + return string.Empty; + + this.cachedUserName = userName; + return userName; + } + finally + { + this.userNameLock.Release(); + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs index 2f250be7..a7275208 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs @@ -14,7 +14,16 @@ public sealed partial class RustService var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + this.logger?.LogError( + "Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'", + response.StatusCode, + response.ReasonPhrase, + path, + responseBody); return string.Empty; + } var resultBuilder = new StringBuilder(); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs index 49f51a1d..36ed6b6b 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs @@ -4,26 +4,34 @@ namespace AIStudio.Tools.Services; public sealed partial class RustService { + private static string SecretKey(ISecretId secretId, SecretStoreType storeType) => $"{storeType.Prefix()}::{secretId.SecretId}::{secretId.SecretName}"; + + private static string LegacySecretKey(ISecretId secretId) => $"secret::{secretId.SecretId}::{secretId.SecretName}"; + /// /// Try to get the secret data for the given secret ID. /// /// The secret ID to get the data for. + /// The secret store type. /// Indicates if we are trying to get the data. In that case, we don't log errors. /// The requested secret. - public async Task GetSecret(ISecretId secretId, bool isTrying = false) + public async Task GetSecret(ISecretId secretId, SecretStoreType storeType, bool isTrying = false) { - var secretRequest = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, isTrying); - var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) + var secretKey = SecretKey(secretId, storeType); + var secret = await this.GetSecretByKey(secretKey, isTrying || storeType is SecretStoreType.DATA_SOURCE); + if (secret.Success || storeType is not SecretStoreType.DATA_SOURCE) + return secret; + + var legacySecretKey = LegacySecretKey(secretId); + var legacySecret = await this.GetSecretByKey(legacySecretKey, isTrying: true); + if (legacySecret.Success) { - if(!isTrying) - this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); - return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue.")); + this.logger!.LogDebug($"Successfully retrieved the legacy data source secret for '{legacySecretKey}'."); + return legacySecret; } - - var secret = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + if (!secret.Success && !isTrying) - this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}': '{secret.Issue}'"); + this.logger!.LogError($"Failed to get the secret data for '{secretKey}': '{secret.Issue}'"); return secret; } @@ -33,21 +41,26 @@ public sealed partial class RustService /// /// The secret ID to store the data for. /// The data to store. + /// The secret store type. /// The store secret response. - public async Task SetSecret(ISecretId secretId, string secretData) + public async Task SetSecret(ISecretId secretId, string secretData, SecretStoreType storeType) { + var secretKey = SecretKey(secretId, storeType); var encryptedSecret = await this.encryptor!.Encrypt(secretData); - var request = new StoreSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, encryptedSecret); + var request = new StoreSecretRequest(secretKey, Environment.UserName, encryptedSecret); var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); if (!result.IsSuccessStatusCode) { - this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); - return new StoreSecretResponse(false, TB("Failed to get the secret data due to an API issue.")); + this.logger!.LogError($"Failed to store the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); + return new StoreSecretResponse(false, TB("Failed to store the secret data due to an API issue.")); } var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); if (!state.Success) - this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'"); + this.logger!.LogError($"Failed to store the secret data for '{secretKey}': '{state.Issue}'"); + + if (state.Success && storeType is SecretStoreType.DATA_SOURCE) + await this.DeleteSecretByKey(LegacySecretKey(secretId)); return state; } @@ -56,20 +69,48 @@ public sealed partial class RustService /// Tries to delete the secret data for the given secret ID. /// /// The secret ID to delete the data for. + /// The secret store type. /// The delete secret response. - public async Task DeleteSecret(ISecretId secretId) + public async Task DeleteSecret(ISecretId secretId, SecretStoreType storeType) { - var request = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, false); + var deleteResult = await this.DeleteSecretByKey(SecretKey(secretId, storeType)); + if (storeType is not SecretStoreType.DATA_SOURCE || !deleteResult.Success) + return deleteResult; + + var legacyDeleteResult = await this.DeleteSecretByKey(LegacySecretKey(secretId)); + if (!legacyDeleteResult.Success) + return legacyDeleteResult; + + return deleteResult with { WasEntryFound = deleteResult.WasEntryFound || legacyDeleteResult.WasEntryFound }; + } + + private async Task GetSecretByKey(string secretKey, bool isTrying) + { + var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + if(!isTrying) + this.logger!.LogError($"Failed to get the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); + return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue.")); + } + + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + } + + private async Task DeleteSecretByKey(string secretKey) + { + var request = new SelectSecretRequest(secretKey, Environment.UserName, false); var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); if (!result.IsSuccessStatusCode) { - this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); + this.logger!.LogError($"Failed to delete the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = TB("Failed to delete the secret data due to an API issue.")}; } var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); if (!state.Success) - this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'"); + this.logger!.LogError($"Failed to delete the secret data for '{secretKey}': '{state.Issue}'"); return state; } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 4e23d610..ef8340fd 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -18,6 +18,7 @@ public sealed partial class RustService : BackgroundService private readonly HttpClient http; private readonly SemaphoreSlim userLanguageLock = new(1, 1); + private readonly SemaphoreSlim userNameLock = new(1, 1); private readonly JsonSerializerOptions jsonRustSerializerOptions = new() { @@ -31,6 +32,7 @@ public sealed partial class RustService : BackgroundService private ILogger? logger; private Encryption? encryptor; private string? cachedUserLanguage; + private string? cachedUserName; private readonly string apiPort; private readonly string certificateFingerprint; @@ -92,6 +94,7 @@ public sealed partial class RustService : BackgroundService this.http.Dispose(); this.userLanguageLock.Dispose(); this.tokenizerLock.Dispose(); + this.userNameLock.Dispose(); base.Dispose(); } diff --git a/app/MindWork AI Studio/Tools/Validation/FileExtensionValidation.cs b/app/MindWork AI Studio/Tools/Validation/FileExtensionValidation.cs index efecce3d..2251ecd5 100644 --- a/app/MindWork AI Studio/Tools/Validation/FileExtensionValidation.cs +++ b/app/MindWork AI Studio/Tools/Validation/FileExtensionValidation.cs @@ -43,6 +43,14 @@ public static class FileExtensionValidation /// True if valid, false if invalid (error/warning already sent via MessageBus). public static async Task IsExtensionValidWithNotifyAsync(UseCase useCae, string filePath, bool validateMediaFileTypes = true, Settings.Provider? provider = null) { + if (string.Equals(Path.GetExtension(filePath), ".doc", StringComparison.OrdinalIgnoreCase)) + { + await MessageBus.INSTANCE.SendWarning(new( + Icons.Material.Filled.Description, + TB("This file format is not supported. Please convert the .doc file to .docx (e.g. with Microsoft Word)."))); + return false; + } + if (FileTypes.IsAllowedPath(filePath, FileTypes.EXECUTABLES)) { await MessageBus.INSTANCE.SendError(new( @@ -138,4 +146,4 @@ public static class FileExtensionValidation return true; } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/packages.lock.json b/app/MindWork AI Studio/packages.lock.json index 0ca69dc7..311fe569 100644 --- a/app/MindWork AI Studio/packages.lock.json +++ b/app/MindWork AI Studio/packages.lock.json @@ -22,24 +22,28 @@ }, "LuaCSharp": { "type": "Direct", - "requested": "[0.5.3, )", - "resolved": "0.5.3", - "contentHash": "qpgmCaNx08+eiWOmz7U/mXOH8DXUyLW8fsCukKjN8hVled2y4HrapsZlmrnIf9iaNfEQusUR/8d1M2XX6NIzbQ==" + "requested": "[0.5.5, )", + "resolved": "0.5.5", + "contentHash": "IL44DCbMtEafyiy8DzHFd/f+1pXuDUVFJMCJPAu8vQHNfO3ADSoWSOKMg9Py1za/ZE1K0gs0jll1viInoN+19Q==", + "dependencies": { + "LuaCSharp.Annotations": "0.5.5", + "LuaCSharp.SourceGenerator": "0.5.5" + } }, "Microsoft.Extensions.FileProviders.Embedded": { "type": "Direct", - "requested": "[9.0.15, )", - "resolved": "9.0.15", - "contentHash": "XFlI3ZISL344QdPLtaXG0yPyjkHQR82DYXrJa9aF00Qeu7dDnFxwFgP/ItkkyiLjAe/NSj6vksxOdnelXGT1vQ==", + "requested": "[9.0.16, )", + "resolved": "9.0.16", + "contentHash": "QRlSWz7zEplBxETrySKK3qpPm/7NPaRGnUpEXQNP3k6Ht2KdVy59JcoUPXlNGnNE3tJd3ycXfMeWqxBG6SyV0w==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.15" + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.16" } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[9.0.15, )", - "resolved": "9.0.15", - "contentHash": "EejcbfCMR77Dthy77qxRbEShmzLApHZUPqXMBVQK+A0pNrRThkaHoGGMGvbq/gTkC/waKcDEgjBkbaejB58Wtw==" + "requested": "[9.0.16, )", + "resolved": "9.0.16", + "contentHash": "ccPBYGLPJt8DeJTUzQ0JzOh/iuUAgnjayU63PokVywAhUOx+dzDKSPTL7AG94U/VpvNXflTT2AjsFAIF1+bXBw==" }, "MudBlazor": { "type": "Direct", @@ -64,9 +68,9 @@ }, "Qdrant.Client": { "type": "Direct", - "requested": "[1.17.0, )", - "resolved": "1.17.0", - "contentHash": "QFNtVu4Kiz6NHAAi2UQk+Ia64/qyX1NMecQGIBGnKqFOlpnxI3OCCBRBKXWGPk/c+4vAmR3Dj+cQ9apqX0zU8A==", + "requested": "[1.18.1, )", + "resolved": "1.18.1", + "contentHash": "eBwFLihGMvN02/jr/BNdcop2XmtA10y8VMOclVZ7K2H8yheAhl7jbkf7I8e4X3RYpT+cAxgcalP4xmOhgs4KJg==", "dependencies": { "Google.Protobuf": "3.31.0", "Grpc.Net.Client": "2.71.0" @@ -113,6 +117,16 @@ "Grpc.Core.Api": "2.71.0" } }, + "LuaCSharp.Annotations": { + "type": "Transitive", + "resolved": "0.5.5", + "contentHash": "5VcwcTNGCY5YXLz2BRko5/Z0YGd6MZqNsnnfPOsGHHpAtqWPFbD0vtOZR4jUqaQLtQUvl2+WRfmIOhp6L2S0rw==" + }, + "LuaCSharp.SourceGenerator": { + "type": "Transitive", + "resolved": "0.5.5", + "contentHash": "2xHKGc1bYXTsmSzZCNmKkuAU6A+1azulNiPY/ICKBSHIgEPMNRQ7JS6PvAClrHe6bk8SKcC/fbba6igtDzDaAw==" + }, "Markdig": { "type": "Transitive", "resolved": "0.41.3", @@ -182,10 +196,10 @@ }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.15", - "contentHash": "yzWilnNU/MvHINapPhY6iFAeApZnhToXbEBplORucn01hFc1F6ZaKt0V9dHYpUMun8WR9cSnq1ky35FWREVZbA==", + "resolved": "9.0.16", + "contentHash": "/YLSWDs+p0Y4+UGPoWI3uUNq7R5/f/8zw8XeViuhfSTGnPowoqbllBE9aR4TteFgNfIH4IHkhUwSlhMLB0aL8g==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.15" + "Microsoft.Extensions.Primitives": "9.0.16" } }, "Microsoft.Extensions.Localization": { @@ -223,8 +237,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.15", - "contentHash": "WRPJ9kpIwsOcghRT0tduIqiz7CDv7WsnL4kTJavtHS4j5AW++4LlR63oOSTL2o/zLR4T1z0/FQMgrnsPJ5bpQQ==" + "resolved": "9.0.16", + "contentHash": "w5RE1MR0lnAElsRJaFd2POIXl/H62aBKmfX8ibYmRmbk0JB9V/9jR0VD5NxiP1ETWpnDAnPguTSe7fF/FdsHEQ==" }, "Microsoft.JSInterop": { "type": "Transitive", diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.1.md new file mode 100644 index 00000000..05ccf49c --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.1.md @@ -0,0 +1,2 @@ +# v26.5.1, build 236 (2026-05-06 13:06 UTC) +- Changed the preview update path for a controlled prerelease test. Please do not install this prerelease manually. Production versions such as v26.4.1 will ignore this update. We are using this prerelease to test the clean update path for the migration from the Tauri v1 framework to the Tauri v2 framework. After a successful test, this prerelease will be removed. \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.2.md new file mode 100644 index 00000000..2c814910 --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.2.md @@ -0,0 +1,2 @@ +# v26.5.2, build 237 (2026-05-06 16:38 UTC) +- Updated the underlying Tauri framework from version 1 to the latest version 2. Please do not install this prerelease manually. Production versions such as v26.4.1 will ignore this update. We are using this prerelease to test the clean update path for the migration from the Tauri v1 framework to the Tauri v2 framework. After a successful test, this prerelease will be removed. diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.3.md new file mode 100644 index 00000000..37c3d83e --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.3.md @@ -0,0 +1,2 @@ +# v26.5.3, build 238 (2026-05-13 09:50 UTC) +- Migrated away from Rocket to Axum for our internal IPC API. Please do not install this prerelease manually. Production versions, such as v26.4.1, will ignore this update. We are using this prerelease to test the clean update path. After a successful test, this prerelease will be removed. \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.4.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.4.md new file mode 100644 index 00000000..9e6c72ca --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.4.md @@ -0,0 +1,2 @@ +# v26.5.4, build 239 (2026-05-13 11:58 UTC) +- Migrated away from Rocket to Axum for our internal IPC API. Please do not install this prerelease manually. Production versions, such as v26.4.1, will ignore this update. We are using this prerelease to test the clean update path. After a successful test, this prerelease will be removed. \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md new file mode 100644 index 00000000..1061d071 --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md @@ -0,0 +1,28 @@ +# v26.5.5, build 240 (2026-05-25 18:52 UTC) +- Released the voice recording and transcription feature for all users. You no longer need to enable a preview feature to configure transcription providers, select a transcription provider, or use dictation. +- Added export options for profiles and chat templates, including an option to package chat template attachments into configuration plugins. +- Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users. +- Added an export option for ERI server data sources, so admins can create configuration plugin snippets without writing the Lua code manually. +- Added an option to configure the timeout setting for all requests. This is useful when you have a slow network connection, or you have to work with slow AI servers. It is also possible to configure this timeout for an entire organization using configuration plugins. +- Added the ability to keep multiple chats running at the same time. For example, you can let a complex research chat continue in the background while you use some assistants to improve a text. +- Added the username to the information page to make organization support easier when users share their screen. +- Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base. +- Improved the Pandoc management and detection process to make it more reliable. +- Improved the Qdrant startup and vector database initialization, so AI Studio can start more reliably while the local vector database is still starting. +- Fixed the Pandoc installation, which could fail and prevent AI Studio from installing its local Pandoc dependency. +- Fixed an issue where the spellchecking setting was not applied to all text fields in the slide builder assistant. +- Fixed an issue where legacy `.doc` files could be selected even though AI Studio could not process them. These files are now rejected with a clear error message. Thanks to Bernhard for reporting this issue. +- Fixed an issue where attached documents were detached when editing a previous prompt. They now remain attached. +- Fixed an issue where failed transcription requests could be shown as empty transcription results instead of a clear error message. +- Fixed an issue where an AI response in chat could be interrupted when you interacted with workspaces, such as opening, closing, or resizing the workspace panel. +- Fixed an issue with switching between chat threads while multiple chats are running. +- Fixed error messages for provider requests so missing OpenAI API credits and too many requests are shown clearly in chats, assistants, transcription, and model loading. +- Fixed missing translations for file type names in file selection dialogs. +- Fixed the chat user prompt being cleared when toggling the workspace view. +- Upgraded the native secret storage integration to `keyring-core`, keeping API keys in the secure credential store provided by the operating system. +- Upgraded Rust to v1.95.0. +- Upgraded .NET to v9.0.16. +- Upgraded Tauri to v2.11.1. Thanks to Paul `PaulKoudelka` for working on this migration. +- Upgraded PDFium to v148.0.7763.0. +- Upgraded Qdrant to v1.18.1. +- Upgraded other dependencies as well. \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md new file mode 100644 index 00000000..7e4a82af --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md @@ -0,0 +1 @@ +# v26.6.1, build 241 (2026-06-xx xx:xx UTC) diff --git a/app/SharedTools/LuaTools.cs b/app/SharedTools/LuaTools.cs index 53bd07c6..10eea7ac 100644 --- a/app/SharedTools/LuaTools.cs +++ b/app/SharedTools/LuaTools.cs @@ -2,9 +2,49 @@ namespace SharedTools; public static class LuaTools { - public static string EscapeLuaString(string value) + public static string EscapeLuaString(string? value) { + if (string.IsNullOrEmpty(value)) + return string.Empty; + // Replace backslashes with double backslashes and escape double quotes: - return value.Replace("\\", @"\\").Replace("\"", "\\\""); + return value + .Replace("\\", @"\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } + + public static string ToLuaStringLiteral(string? value, bool forceLongString = false, int longStringLengthThreshold = 80) + { + value ??= string.Empty; + if (!forceLongString && + value.Length <= longStringLengthThreshold && + !value.Contains('\n') && + !value.Contains('\r')) + return $"\"{EscapeLuaString(value)}\""; + + return $"{CreateLongStringOpeningDelimiter(value)}{value}{CreateLongStringClosingDelimiter(value)}"; + } + + private static string CreateLongStringOpeningDelimiter(string value) + { + var equals = CreateLongStringEquals(value); + return $"[{equals}["; + } + + private static string CreateLongStringClosingDelimiter(string value) + { + var equals = CreateLongStringEquals(value); + return $"]{equals}]"; + } + + private static string CreateLongStringEquals(string value) + { + var equalsCount = 3; + while (value.Contains($"]{new string('=', equalsCount)}]")) + equalsCount++; + + return new string('=', equalsCount); } } \ No newline at end of file diff --git a/documentation/Build.md b/documentation/Build.md index 600999fe..8022cd7d 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -9,7 +9,7 @@ Therefore, we cannot provide a static list here that is valid for all Linux syst ## Prerequisites 1. Install the [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0). 2. [Install the Rust compiler](https://www.rust-lang.org/tools/install) in the latest stable version. -3. Met the prerequisites for building [Tauri](https://tauri.app/v1/guides/getting-started/prerequisites/). Node.js is **not** required, though. +3. Meet the prerequisites for building [Tauri](https://v2.tauri.app/start/prerequisites/). Node.js is **not** required, though. 4. The core team uses [JetBrains](https://www.jetbrains.com/) [Rider](https://www.jetbrains.com/rider/) and [RustRover](https://www.jetbrains.com/rust/) for development. Both IDEs are free to use for open-source projects for non-commercial use. They are available for macOS, Linux, and Windows systems. Profiles are provided for these IDEs, so you can get started right away. However, you can also use a different IDE. 4. Clone the repository. @@ -17,7 +17,7 @@ Therefore, we cannot provide a static list here that is valid for all Linux syst Regardless of whether you want to build the app locally for yourself (not trusting the pre-built binaries) or test your changes before creating a PR, you have to run the following commands at least once: 1. Open a terminal. -2. Install the Tauri CLI by running `cargo install --version 1.6.2 tauri-cli`. +2. Install the Tauri CLI by running `cargo install tauri-cli --version 2.11.0 --locked`. 3. Navigate to the `/app/Build` directory within the repository. 4. Run `dotnet run build` to build the entire app. diff --git a/documentation/Setup.md b/documentation/Setup.md index c6e4bfd8..6b545627 100644 --- a/documentation/Setup.md +++ b/documentation/Setup.md @@ -84,4 +84,4 @@ We have to figure out if you have an Intel/AMD or a modern ARM system on your Li 2. Open a terminal and navigate to the Downloads folder: `cd Downloads`. 3. Make the AppImage executable: `chmod +x mind-work-ai-studio_amd64.AppImage`. 4. You might want to move the AppImage to a more convenient location, e.g., your home directory: `mv mind-work-ai-studio_amd64.AppImage ~/`. -4. Now you can run the AppImage from your file manager (double-click) or the terminal: `./mind-work-ai-studio_amd64.AppImage`. \ No newline at end of file +5. Now you can run the AppImage from your file manager (double-click) or the terminal: `./mind-work-ai-studio_amd64.AppImage`. \ No newline at end of file diff --git a/documentation/Ubuntu DEB Install 1.png b/documentation/Ubuntu DEB Install 1.png deleted file mode 100644 index bb09ae75..00000000 Binary files a/documentation/Ubuntu DEB Install 1.png and /dev/null differ diff --git a/documentation/Ubuntu DEB Install 2.png b/documentation/Ubuntu DEB Install 2.png deleted file mode 100644 index bd5ff07a..00000000 Binary files a/documentation/Ubuntu DEB Install 2.png and /dev/null differ diff --git a/documentation/Ubuntu DEB Open.png b/documentation/Ubuntu DEB Open.png deleted file mode 100644 index f53ab5bd..00000000 Binary files a/documentation/Ubuntu DEB Open.png and /dev/null differ diff --git a/metadata.txt b/metadata.txt index d6cfb34a..533a4c14 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,12 +1,12 @@ -26.4.1 -2026-04-17 17:25:44 UTC -235 -9.0.116 (commit fb4af7e1b3) -9.0.15 (commit 4250c8399a) -1.93.1 (commit 01f6ddf75) +26.5.5 +2026-05-25 18:52:12 UTC +240 +9.0.117 (commit 6e241a69c1) +9.0.16 (commit a1e6809fb8) +1.95.0 (commit 59807616e) 8.15.0 -1.8.3 -c6ed7e3c0ce, release +2.11.1 +d05ff26e628, release osx-arm64 -144.0.7543.0 -1.17.1 \ No newline at end of file +148.0.7763.0 +1.18.1 \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 2a943f1d..4639fd67 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -21,10 +21,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.12", ] +[[package]] +name = "aes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +dependencies = [ + "cipher 0.5.1", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -70,6 +81,17 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.4.1" @@ -88,17 +110,26 @@ dependencies = [ "clipboard-win", "image 0.25.2", "log", - "objc2", + "objc2 0.6.4", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", "windows-sys 0.60.2", "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "asn1-rs" version = "0.7.1" @@ -138,6 +169,120 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -160,6 +305,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.81" @@ -173,26 +324,25 @@ dependencies = [ [[package]] name = "atk" -version = "0.15.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3d816ce6f0e2909a96830d6911c2aff044370b1ef92d7f267b43bae5addedd" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" dependencies = [ "atk-sys", - "bitflags 1.3.2", "glib", "libc", ] [[package]] name = "atk-sys" -version = "0.15.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58aeb089fb698e06db8089971c7ee317ab9644bade33383f63631437b03aafb6" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 6.2.2", + "system-deps", ] [[package]] @@ -204,21 +354,6 @@ dependencies = [ "debug_unsafe", ] -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - -[[package]] -name = "atomic" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -254,10 +389,78 @@ dependencies = [ ] [[package]] -name = "base64" -version = "0.13.1" +name = "axum" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] [[package]] name = "base64" @@ -272,16 +475,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "binascii" -version = "0.1.4" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] [[package]] -name = "bincode" -version = "1.3.3" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" dependencies = [ "serde", ] @@ -300,15 +512,12 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -319,6 +528,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -329,10 +547,50 @@ dependencies = [ ] [[package]] -name = "brotli" -version = "7.0.0" +name = "block-padding" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -341,24 +599,14 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "4.0.1" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] -[[package]] -name = "bstr" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "bumpalo" version = "3.16.0" @@ -413,33 +661,34 @@ dependencies = [ [[package]] name = "cairo-rs" -version = "0.15.12" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c76ee391b03d35510d9fa917357c7f1855bd9a6659c95a1b392e33f49b3369bc" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "cairo-sys-rs", "glib", "libc", + "once_cell", "thiserror 1.0.63", ] [[package]] name = "cairo-sys-rs" -version = "0.15.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" dependencies = [ "glib-sys", "libc", - "system-deps 6.2.2", + "system-deps", ] [[package]] name = "calamine" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ae05a4e39297eecf9a994210d27501318c37a9318201f8e11050add82bb6f0" +checksum = "8822fe6253ca47aa5ad9a3be09f6fe7cd20c6a74e41b0aa42e8f4e3d523508df" dependencies = [ "atoi_simd", "byteorder", @@ -453,13 +702,45 @@ dependencies = [ ] [[package]] -name = "cargo_toml" -version = "0.15.3" +name = "camino" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" dependencies = [ "serde", - "toml 0.7.8", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -468,7 +749,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225" +dependencies = [ + "cipher 0.5.1", ] [[package]] @@ -500,15 +790,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "cfg-expr" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3431df59f28accaf4cb4eed4a9acc66bea3f3c3753aa6cdc2f024174ef232af7" -dependencies = [ - "smallvec", -] - [[package]] name = "cfg-expr" version = "0.15.8" @@ -539,7 +820,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core", ] [[package]] @@ -563,8 +844,18 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.6", + "inout 0.1.3", +] + +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "crypto-common 0.2.1", + "inout 0.2.2", ] [[package]] @@ -586,34 +877,10 @@ dependencies = [ ] [[package]] -name = "cocoa" -version = "0.24.1" +name = "cmov" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation 0.9.4", - "core-graphics", - "foreign-types", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types", - "libc", - "objc", -] +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "codepage" @@ -640,6 +907,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -660,39 +936,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "percent-encoding", "time", "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.0" @@ -711,12 +976,25 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.22.3" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -724,15 +1002,21 @@ dependencies = [ [[package]] name = "core-graphics-types" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", + "bitflags 2.11.1", + "core-foundation", "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.12" @@ -826,20 +1110,25 @@ dependencies = [ ] [[package]] -name = "cssparser" -version = "0.27.2" +name = "crypto-common" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ "cssparser-macros", "dtoa-short", - "itoa 0.4.8", - "matches", - "phf 0.8.0", - "proc-macro2", - "quote", + "itoa", + "phf", "smallvec", - "syn 1.0.109", ] [[package]] @@ -854,12 +1143,27 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.8" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -916,15 +1220,30 @@ dependencies = [ [[package]] name = "dbus-secret-service" -version = "4.0.2" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1caa0c241c01ad8d99a78d553567d38f873dd3ac16eca33a5370d650ab25584e" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", "dbus", - "futures-util", + "fastrand", + "hkdf", "num", "once_cell", - "rand 0.8.5", + "sha2 0.10.8", + "zeroize", +] + +[[package]] +name = "dbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8f54da401bb5eb2a4d873ac4b359f4a599df2ca8634bb5b8c045e5ee78757" +dependencies = [ + "dbus-secret-service", + "keyring-core", ] [[package]] @@ -976,96 +1295,79 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.18" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", ] -[[package]] -name = "devise" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" -dependencies = [ - "devise_codegen", - "devise_core", -] - -[[package]] -name = "devise_codegen" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" -dependencies = [ - "devise_core", - "quote", -] - -[[package]] -name = "devise_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" -dependencies = [ - "bitflags 2.6.0", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", -] - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.6", "subtle", ] [[package]] -name = "dirs-next" -version = "2.0.0" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "cfg-if", - "dirs-sys-next", + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", + "option-ext", "redox_users", - "winapi", + "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.6.0", - "objc2", + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", ] [[package]] @@ -1079,6 +1381,53 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + [[package]] name = "dtoa" version = "1.0.9" @@ -1094,12 +1443,33 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.13.0" @@ -1108,16 +1478,16 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "embed-resource" -version = "2.4.3" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4edcacde9351c33139a41e3c97eb2334351a81a2791bebb0b243df837128f602" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.8.16", + "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg 0.52.0", + "winreg", ] [[package]] @@ -1135,12 +1505,50 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1157,6 +1565,27 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.73.0" @@ -1203,25 +1632,11 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic 0.6.0", - "pear", - "serde", - "toml 0.8.16", - "uncased", - "version_check", -] - [[package]] name = "file-format" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eab8aa2fba5f39f494000a22f44bf3c755b7d7f8ffad3f36c6d507893074159" +checksum = "55d9ccda37e95b4f0978a3074b4a9939979103a7256459cfb449c9c84d1adf23" [[package]] name = "filetime" @@ -1265,15 +1680,6 @@ dependencies = [ "thiserror 2.0.12", ] -[[package]] -name = "fluent-uri" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "fnv" version = "1.0.7" @@ -1287,19 +1693,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "foreign-types" -version = "0.3.2" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ + "foreign-types-macros", "foreign-types-shared", ] [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "foreign-types-macros" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" @@ -1310,22 +1734,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -1374,6 +1798,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1414,22 +1851,12 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" -version = "0.15.4" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" dependencies = [ - "bitflags 1.3.2", "cairo-rs", "gdk-pixbuf", "gdk-sys", @@ -1441,35 +1868,35 @@ dependencies = [ [[package]] name = "gdk-pixbuf" -version = "0.15.11" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38dd9cc8b099cceecdf41375bb6d481b1b5a7cd5cd603e10a69a9383f8619a" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" dependencies = [ - "bitflags 1.3.2", "gdk-pixbuf-sys", "gio", "glib", "libc", + "once_cell", ] [[package]] name = "gdk-pixbuf-sys" -version = "0.15.10" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140b2f5378256527150350a8346dbdb08fadc13453a7a2d73aecd5fab3c402a7" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" dependencies = [ "gio-sys", "glib-sys", "gobject-sys", "libc", - "system-deps 6.2.2", + "system-deps", ] [[package]] name = "gdk-sys" -version = "0.15.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -1479,47 +1906,48 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps 6.2.2", + "system-deps", ] [[package]] name = "gdkwayland-sys" -version = "0.15.3" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca49a59ad8cfdf36ef7330fe7bdfbe1d34323220cc16a0de2679ee773aee2c2" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" dependencies = [ "gdk-sys", "glib-sys", "gobject-sys", "libc", "pkg-config", - "system-deps 6.2.2", + "system-deps", ] [[package]] -name = "gdkx11-sys" -version = "0.15.1" +name = "gdkx11" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b7f8c7a84b407aa9b143877e267e848ff34106578b64d1e0a24bf550716178" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" dependencies = [ - "gdk-sys", - "glib-sys", + "gdk", + "gdkx11-sys", + "gio", + "glib", "libc", - "system-deps 6.2.2", "x11", ] [[package]] -name = "generator" -version = "0.7.5" +name = "gdkx11-sys" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" dependencies = [ - "cc", + "gdk-sys", + "glib-sys", "libc", - "log", - "rustversion", - "windows 0.48.0", + "system-deps", + "x11", ] [[package]] @@ -1542,17 +1970,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.15" @@ -1560,10 +1977,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -1589,7 +2004,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.0", + "rand_core", "wasip2", "wasip3", ] @@ -1606,49 +2021,54 @@ dependencies = [ [[package]] name = "gio" -version = "0.15.12" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" dependencies = [ - "bitflags 1.3.2", "futures-channel", "futures-core", "futures-io", + "futures-util", "gio-sys", "glib", "libc", "once_cell", + "pin-project-lite", + "smallvec", "thiserror 1.0.63", ] [[package]] name = "gio-sys" -version = "0.15.10" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 6.2.2", + "system-deps", "winapi", ] [[package]] name = "glib" -version = "0.15.12" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "futures-channel", "futures-core", "futures-executor", "futures-task", + "futures-util", + "gio-sys", "glib-macros", "glib-sys", "gobject-sys", "libc", + "memchr", "once_cell", "smallvec", "thiserror 1.0.63", @@ -1656,27 +2076,26 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.15.13" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ - "anyhow", "heck 0.4.1", - "proc-macro-crate", + "proc-macro-crate 2.0.2", "proc-macro-error", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] name = "glib-sys" -version = "0.15.10" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" dependencies = [ "libc", - "system-deps 6.2.2", + "system-deps", ] [[package]] @@ -1686,37 +2105,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] -name = "globset" -version = "0.4.14" +name = "global-hotkey" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", + "crossbeam-channel", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.12", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", ] [[package]] name = "gobject-sys" -version = "0.15.10" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" dependencies = [ "glib-sys", "libc", - "system-deps 6.2.2", + "system-deps", ] [[package]] name = "gtk" -version = "0.15.5" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e3004a2d5d6d8b5057d2b57b3712c9529b62e82c77f25c1fecde1fd5c23bd0" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" dependencies = [ "atk", - "bitflags 1.3.2", "cairo-rs", "field-offset", "futures-channel", @@ -1727,16 +2150,15 @@ dependencies = [ "gtk-sys", "gtk3-macros", "libc", - "once_cell", "pango", "pkg-config", ] [[package]] name = "gtk-sys" -version = "0.15.3" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5bc2f0587cba247f60246a0ca11fe25fb733eabc3de12d1965fc07efab87c84" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" dependencies = [ "atk-sys", "cairo-sys-rs", @@ -1747,55 +2169,35 @@ dependencies = [ "gobject-sys", "libc", "pango-sys", - "system-deps 6.2.2", + "system-deps", ] [[package]] name = "gtk3-macros" -version = "0.15.6" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" dependencies = [ - "anyhow", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro-error", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] name = "h2" -version = "0.3.26" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.7.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.1.0", - "indexmap 2.7.0", + "http", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1824,17 +2226,14 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] -name = "heck" -version = "0.3.3" +name = "hashbrown" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" -dependencies = [ - "unicode-segmentation", -] +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -1850,15 +2249,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -1866,38 +2259,41 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] name = "html5ever" -version = "0.26.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "mac", "markup5ever", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa 1.0.11", ] [[package]] @@ -1908,18 +2304,7 @@ checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", - "itoa 1.0.11", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", + "itoa", ] [[package]] @@ -1929,7 +2314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http", ] [[package]] @@ -1940,17 +2325,11 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http 1.1.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - [[package]] name = "httparse" version = "1.9.4" @@ -1964,43 +2343,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hyper" -version = "0.14.30" +name = "hybrid-array" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.26", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa 1.0.11", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", + "typenum", ] [[package]] name = "hyper" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", - "h2 0.4.5", - "http 1.1.0", - "http-body 1.0.1", + "futures-core", + "h2", + "http", + "http-body", "httparse", - "itoa 1.0.11", + "httpdate", + "itoa", "pin-project-lite", "smallvec", "tokio", @@ -2014,69 +2380,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", - "http 1.1.0", - "hyper 1.6.0", + "http", + "hyper", "hyper-util", - "rustls 0.23.28", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.1", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.30", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.6.0", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", + "tokio-rustls", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http 1.1.0", - "http-body 1.0.1", - "hyper 1.6.0", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", - "system-configuration 0.6.1", + "socket2", "tokio", "tower-service", "tracing", - "windows-registry 0.5.3", ] [[package]] @@ -2104,12 +2438,12 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.13", ] [[package]] @@ -2263,22 +2597,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "image" version = "0.24.9" @@ -2292,7 +2610,7 @@ dependencies = [ "gif", "jpeg-decoder", "num-traits", - "png", + "png 0.17.13", "qoi", "tiff", ] @@ -2306,7 +2624,7 @@ dependencies = [ "bytemuck", "byteorder-lite", "num-traits", - "png", + "png 0.17.13", "tiff", "zune-core", "zune-jpeg", @@ -2325,47 +2643,43 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.17.0", "serde", + "serde_core", ] [[package]] name = "infer" -version = "0.13.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" dependencies = [ "cfb", ] -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - [[package]] name = "inout" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] [[package]] -name = "instant" -version = "0.1.13" +name = "inout" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "cfg-if", + "block-padding 0.4.2", + "hybrid-array", ] [[package]] @@ -2385,14 +2699,22 @@ dependencies = [ ] [[package]] -name = "is-terminal" -version = "0.4.13" +name = "is-docker" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ - "hermit-abi 0.4.0", - "libc", - "windows-sys 0.52.0", + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", ] [[package]] @@ -2404,12 +2726,6 @@ dependencies = [ "either", ] -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - [[package]] name = "itoa" version = "1.0.11" @@ -2418,9 +2734,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "javascriptcore-rs" -version = "0.16.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf053e7843f2812ff03ef5afe34bb9c06ffee120385caad4f6b9967fcd37d41c" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" dependencies = [ "bitflags 1.3.2", "glib", @@ -2429,28 +2745,14 @@ dependencies = [ [[package]] name = "javascriptcore-rs-sys" -version = "0.4.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905fbb87419c5cde6e3269537e4ea7d46431f3008c5d057e915ef3f115e7793c" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 5.0.0", -] - -[[package]] -name = "jni" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c" -dependencies = [ - "cesu8", - "combine", - "jni-sys", - "log", - "thiserror 1.0.63", - "walkdir", + "system-deps", ] [[package]] @@ -2495,19 +2797,21 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "json-patch" -version = "2.0.0" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" dependencies = [ "jsonptr", "serde", @@ -2517,40 +2821,32 @@ dependencies = [ [[package]] name = "jsonptr" -version = "0.4.7" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c6e529149475ca0b2820835d3dce8fcc41c6b943ca608d32f35b449255e4627" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" dependencies = [ - "fluent-uri", "serde", "serde_json", ] [[package]] -name = "keyring" -version = "3.6.2" +name = "keyboard-types" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1961983669d57bdfe6c0f3ef8e4c229b5ef751afcc7d87e4271d2f71f6ccfa8b" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "byteorder", - "dbus-secret-service", - "log", - "security-framework 2.11.1", - "security-framework 3.5.1", - "windows-sys 0.59.0", + "bitflags 2.11.1", + "serde", + "unicode-segmentation", ] [[package]] -name = "kuchikiki" -version = "0.8.2" +name = "keyring-core" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" dependencies = [ - "cssparser", - "html5ever", - "indexmap 1.9.3", - "matches", - "selectors", + "log", ] [[package]] @@ -2572,10 +2868,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] -name = "libc" -version = "0.2.183" +name = "libappindicator" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2586,6 +2906,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.6" @@ -2598,11 +2928,10 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.6.0", "libc", ] @@ -2655,27 +2984,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loom" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-rs" version = "0.3.0" @@ -2697,49 +3005,22 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" -version = "0.11.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "phf 0.10.1", - "phf_codegen 0.10.0", - "string_cache", - "string_cache_codegen", "tendril", + "web_atoms", ] [[package]] -name = "matchers" -version = "0.2.0" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "maybe-owned" @@ -2770,47 +3051,54 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mindwork-ai-studio" -version = "26.4.1" +version = "26.5.5" dependencies = [ - "aes", + "aes 0.9.0", + "apple-native-keyring-store", "arboard", "async-stream", + "axum", + "axum-server", "base64 0.22.1", "bytes", "calamine", - "cbc", + "cbc 0.2.0", "cfg-if", + "dbus-secret-service-keyring-store", "file-format", "flexi_logger", "futures", - "hmac", - "keyring", + "hmac 0.13.0", + "keyring-core", "log", "once_cell", - "openssl", - "pbkdf2", + "pbkdf2 0.13.0", "pdfium-render", "pptx-to-md", - "rand 0.10.1", - "rand_chacha 0.10.0", + "rand", + "rand_chacha", "rcgen", - "reqwest 0.13.2", - "rocket", + "rustls", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "strum_macros", "sys-locale", "sysinfo", - "tar", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", + "tauri-plugin-opener", + "tauri-plugin-shell", + "tauri-plugin-updater", "tauri-plugin-window-state", "tempfile", - "time", "tokio", "tokio-stream", - "windows-registry 0.6.1", + "whoami", + "windows-native-keyring-store", + "windows-registry", ] [[package]] @@ -2842,80 +3130,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ - "hermit-abi 0.3.9", "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] -name = "multer" -version = "3.1.0" +name = "muda" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.1.0", - "httparse", - "memchr", - "mime", - "spin", - "tokio", - "tokio-util", - "version_check", -] - -[[package]] -name = "native-tls" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.5", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.12", + "windows-sys 0.61.2", ] [[package]] name = "ndk" -version = "0.6.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2032c77e030ddee34a6787a64166008da93f6a352b629261d0fee232b8742dd4" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "jni-sys", + "log", "ndk-sys", "num_enum", + "raw-window-handle", "thiserror 1.0.63", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" -version = "0.3.0" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e5a6ae77c8ee183dcbbba6150e2e6b9f3f4196a7666c02a715a95692ec1fa97" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ "jni-sys", ] @@ -2926,12 +3195,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -3039,65 +3302,52 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi 0.3.9", - "libc", -] - [[package]] name = "num_enum" -version = "0.5.11" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", + "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.5.11" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] -name = "objc" -version = "0.2.7" +name = "objc-sys" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", - "objc_exception", -] +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] -name = "objc-foundation" -version = "0.1.1" +name = "objc2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ - "block", - "objc", - "objc_id", + "objc-sys", + "objc2-encode", ] [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", + "objc2-exception-helper", ] [[package]] @@ -3106,10 +3356,33 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb" dependencies = [ - "bitflags 2.6.0", - "objc2", + "bitflags 2.11.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -3118,9 +3391,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -3129,12 +3402,32 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02" dependencies = [ - "bitflags 2.6.0", - "objc2", + "bitflags 2.11.1", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d31f4c5b5192304996badc466aeadffe1411d73a9bbd3b18b6b2ee9d048b07bd" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3142,13 +3435,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "objc2-foundation" -version = "0.3.0" +name = "objc2-exception-helper" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" dependencies = [ - "bitflags 2.6.0", - "objc2", + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -3168,27 +3484,122 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19" dependencies = [ - "bitflags 2.6.0", - "objc2", + "bitflags 2.11.1", + "objc2 0.6.4", "objc2-core-foundation", ] [[package]] -name = "objc_exception" -version = "0.1.2" +name = "objc2-metal" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "cc", + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc_id" -version = "0.1.1" +name = "objc2-open-directory" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" dependencies = [ - "objc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ac59da3ceebc4a82179b35dc550431ad9458f9cc326e053f49ba371ce76c5a" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.0", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fe793adbf3b5e93686d48a05a7ed7ee53dfa65d106ced4805fae8969059b2" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b717127e4014b0f9f3e8bba3d3f2acec81f1bde01f656823036e823ed2c94dce" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -3208,46 +3619,16 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "3.2.0" +version = "5.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" dependencies = [ - "pathdiff", - "windows-sys 0.42.0", -] - -[[package]] -name = "openssl" -version = "0.10.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags 2.6.0", - "cfg-if", - "foreign-types", + "dunce", + "is-wsl", "libc", - "once_cell", - "openssl-macros", - "openssl-sys", + "pathdiff", ] -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - [[package]] name = "openssl-probe" version = "0.2.0" @@ -3255,25 +3636,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" [[package]] -name = "openssl-src" -version = "300.3.1+3.3.1" +name = "option-ext" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259953d42a81bf137fbbd73bd30a8e1914d6dce43c2b90ed575783a22608b91" -dependencies = [ - "cc", -] +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "openssl-sys" -version = "0.9.112" +name = "ordered-stream" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", + "futures-core", + "pin-project-lite", ] [[package]] @@ -3287,12 +3662,26 @@ dependencies = [ ] [[package]] -name = "pango" -version = "0.15.10" +name = "osakit" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e4045548659aee5313bde6c582b0d83a627b7904dd20dc2d9ef0895d414e4f" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "bitflags 1.3.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", "glib", "libc", "once_cell", @@ -3301,16 +3690,22 @@ dependencies = [ [[package]] name = "pango-sys" -version = "0.15.10" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2a00081cde4661982ed91d80ef437c20eacaf6aa1a5962c0279ae194662c3aa" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 6.2.2", + "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.3" @@ -3346,17 +3741,27 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", ] [[package]] name = "pdfium-render" -version = "0.8.37" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6553f6604a52b3203db7b4e9d51eb4dd193cf455af9e56d40cab6575b547b679" +checksum = "076dd8f3a6c7da9298ddffbcc0d5a109f89caf967fa4871c9a172d5b3498b35b" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", "bytemuck", "bytes", "chrono", @@ -3365,7 +3770,7 @@ dependencies = [ "image 0.25.2", "itertools", "js-sys", - "libloading", + "libloading 0.8.6", "log", "maybe-owned", "once_cell", @@ -3376,29 +3781,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", -] - [[package]] name = "pem" version = "3.0.4" @@ -3417,106 +3799,43 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.8.0", - "phf_shared 0.8.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_shared 0.10.0", -] - -[[package]] -name = "phf" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" -dependencies = [ - "phf_macros 0.11.2", - "phf_shared 0.11.2", + "phf_macros", + "phf_shared", + "serde", ] [[package]] name = "phf_codegen" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator", + "phf_shared", ] [[package]] name = "phf_generator" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" -dependencies = [ - "phf_shared 0.11.2", - "rand 0.8.5", + "fastrand", + "phf_shared", ] [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" -dependencies = [ - "phf_generator 0.11.2", - "phf_shared 0.11.2", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.117", @@ -3524,27 +3843,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.8.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] @@ -3555,6 +3856,17 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "piston-float" version = "1.0.1" @@ -3574,7 +3886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" dependencies = [ "base64 0.22.1", - "indexmap 2.7.0", + "indexmap 2.14.0", "quick-xml 0.32.0", "serde", "time", @@ -3593,6 +3905,33 @@ dependencies = [ "miniz_oxide 0.7.4", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.5", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3645,6 +3984,25 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -3669,34 +4027,15 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", -] - [[package]] name = "qoi" version = "0.4.1" @@ -3725,62 +4064,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls 0.23.28", - "socket2 0.6.2", - "thiserror 2.0.12", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.1", - "lru-slab", - "rand 0.9.1", - "ring", - "rustc-hash", - "rustls 0.23.28", - "rustls-pki-types", - "slab", - "thiserror 2.0.12", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.36" @@ -3796,41 +4079,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.0", -] - [[package]] name = "rand" version = "0.10.1" @@ -3839,37 +4087,7 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.0", + "rand_core", ] [[package]] @@ -3879,35 +4097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ "ppv-lite86", - "rand_core 0.10.0", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.15", -] - -[[package]] -name = "rand_core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff" -dependencies = [ - "getrandom 0.3.1", - "zerocopy", + "rand_core", ] [[package]] @@ -3916,29 +4106,11 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "raw-window-handle" -version = "0.5.2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" @@ -3962,9 +4134,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.7" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "pem", "ring", @@ -3989,38 +4161,18 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", ] [[package]] name = "redox_users" -version = "0.4.5" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror 1.0.63", -] - -[[package]] -name = "ref-cast" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror 2.0.12", ] [[package]] @@ -4052,48 +4204,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.26", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.30", - "hyper-tls 0.5.0", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-native-tls", - "tokio-util", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "winreg 0.50.0", -] - [[package]] name = "reqwest" version = "0.13.2" @@ -4102,61 +4212,59 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "h2 0.4.5", - "http 1.1.0", - "http-body 1.0.1", + "futures-util", + "http", + "http-body", "http-body-util", - "hyper 1.6.0", + "hyper", "hyper-rustls", - "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls 0.23.28", + "rustls", "rustls-pki-types", "rustls-platform-verifier", - "sync_wrapper 1.0.2", + "serde", + "serde_json", + "sync_wrapper", "tokio", - "tokio-native-tls", - "tokio-rustls 0.26.1", + "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] [[package]] name = "rfd" -version = "0.10.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "block", - "dispatch", + "block2 0.6.2", + "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", - "lazy_static", "log", - "objc", - "objc-foundation", - "objc_id", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.37.0", + "windows-sys 0.60.2", ] [[package]] @@ -4173,91 +4281,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rocket" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" -dependencies = [ - "async-stream", - "async-trait", - "atomic 0.5.3", - "binascii", - "bytes", - "either", - "figment", - "futures", - "indexmap 2.7.0", - "log", - "memchr", - "multer", - "num_cpus", - "parking_lot", - "pin-project-lite", - "rand 0.8.5", - "ref-cast", - "rocket_codegen", - "rocket_http", - "serde", - "serde_json", - "state 0.6.0", - "tempfile", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "ubyte", - "version_check", - "yansi", -] - -[[package]] -name = "rocket_codegen" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" -dependencies = [ - "devise", - "glob", - "indexmap 2.7.0", - "proc-macro2", - "quote", - "rocket_http", - "syn 2.0.117", - "unicode-xid", - "version_check", -] - -[[package]] -name = "rocket_http" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" -dependencies = [ - "cookie", - "either", - "futures", - "http 0.2.12", - "hyper 0.14.30", - "indexmap 2.7.0", - "log", - "memchr", - "pear", - "percent-encoding", - "pin-project-lite", - "ref-cast", - "rustls 0.21.12", - "rustls-pemfile", - "serde", - "smallvec", - "stable-pattern", - "state 0.6.0", - "time", - "tokio", - "tokio-rustls 0.24.1", - "uncased", -] - [[package]] name = "roxmltree" version = "0.20.0" @@ -4294,7 +4317,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.14", @@ -4307,25 +4330,13 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.28" @@ -4334,8 +4345,9 @@ checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki", "subtle", "zeroize", ] @@ -4346,19 +4358,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.0", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", + "security-framework", ] [[package]] @@ -4367,7 +4370,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] @@ -4377,16 +4379,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.0", + "core-foundation", "core-foundation-sys", - "jni 0.21.1", + "jni", "log", "once_cell", - "rustls 0.23.28", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.10", - "security-framework 3.5.1", + "rustls-webpki", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -4400,19 +4402,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -4428,9 +4420,9 @@ checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -4451,10 +4443,31 @@ dependencies = [ ] [[package]] -name = "scoped-tls" -version = "1.0.1" +name = "schemars" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] [[package]] name = "scopeguard" @@ -4462,37 +4475,14 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.6.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.6.0", - "core-foundation 0.10.0", + "bitflags 2.11.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4500,9 +4490,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -4510,22 +4500,21 @@ dependencies = [ [[package]] name = "selectors" -version = "0.22.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "cssparser", "derive_more", - "fxhash", "log", - "matches", - "phf 0.8.0", - "phf_codegen 0.8.0", + "new_debug_unreachable", + "phf", + "phf_codegen", "precomputed-hash", + "rustc-hash", "servo_arc", "smallvec", - "thin-slice", ] [[package]] @@ -4547,6 +4536,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4567,20 +4568,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.7.0", - "itoa 1.0.11", + "itoa", "memchr", "serde", "serde_core", "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -4601,6 +4623,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4608,7 +4639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.11", + "itoa", "ryu", "serde", ] @@ -4623,7 +4654,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.7.0", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -4667,11 +4698,10 @@ dependencies = [ [[package]] name = "servo_arc" -version = "0.1.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ - "nodrop", "stable_deref_trait", ] @@ -4683,7 +4713,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.12", - "digest", + "digest 0.10.7", ] [[package]] @@ -4694,16 +4724,18 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures 0.2.12", - "digest", + "digest 0.10.7", ] [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "sha2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "lazy_static", + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4739,9 +4771,9 @@ checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -4760,65 +4792,60 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] -name = "socket2" -version = "0.6.2" +name = "softbuffer" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ - "libc", - "windows-sys 0.60.2", + "bytemuck", + "cfg_aliases", + "core-graphics 0.24.0", + "foreign-types", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall 0.5.3", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", ] [[package]] -name = "soup2" -version = "0.2.1" +name = "soup3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b4d76501d8ba387cf0fefbe055c3e0a59891d09f0f995ae4e4b16f6b60f3c0" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" dependencies = [ - "bitflags 1.3.2", + "futures-channel", "gio", "glib", "libc", - "once_cell", - "soup2-sys", + "soup3-sys", ] [[package]] -name = "soup2-sys" -version = "0.2.0" +name = "soup3-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009ef427103fcb17f802871647a7fa6c60cbb654b4c4e4c0ac60a31c5f6dc9cf" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" dependencies = [ - "bitflags 1.3.2", "gio-sys", "glib-sys", "gobject-sys", "libc", - "system-deps 5.0.0", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "stable-pattern" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" -dependencies = [ - "memchr", + "system-deps", ] [[package]] @@ -4827,46 +4854,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" -[[package]] -name = "state" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b" -dependencies = [ - "loom", -] - -[[package]] -name = "state" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" -dependencies = [ - "loom", -] - [[package]] name = "string_cache" -version = "0.8.7" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", - "once_cell", "parking_lot", - "phf_shared 0.10.0", + "phf_shared", "precomputed-hash", - "serde", ] [[package]] name = "string_cache_codegen" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -4895,6 +4902,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + [[package]] name = "syn" version = "1.0.109" @@ -4917,12 +4935,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4954,130 +4966,69 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.38.4" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", + "objc2-open-directory", "windows 0.62.2", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.6.0", - "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "system-deps" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18db855554db7bd0e73e06cf7ba3df39f97812cb11d3f75e71c39bf45171797e" -dependencies = [ - "cfg-expr 0.9.1", - "heck 0.3.3", - "pkg-config", - "toml 0.5.11", - "version-compare 0.0.11", -] - [[package]] name = "system-deps" version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "cfg-expr 0.15.8", + "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.16", - "version-compare 0.2.0", + "toml 0.8.2", + "version-compare", ] [[package]] name = "tao" -version = "0.16.9" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "575c856fc21e551074869dcfaad8f706412bd5b803dfa0fbf6881c4ff4bfafab" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "cc", - "cocoa", - "core-foundation 0.9.4", - "core-graphics", + "bitflags 2.11.1", + "block2 0.6.2", + "core-foundation", + "core-graphics 0.25.0", "crossbeam-channel", - "dispatch", - "gdk", - "gdk-pixbuf", - "gdk-sys", + "dbus", + "dispatch2", + "dlopen2", + "dpi", "gdkwayland-sys", "gdkx11-sys", - "gio", - "glib", - "glib-sys", "gtk", - "image 0.24.9", - "instant", - "jni 0.20.0", - "lazy_static", + "jni", "libc", "log", "ndk", - "ndk-context", "ndk-sys", - "objc", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit", "once_cell", "parking_lot", - "png", + "percent-encoding", "raw-window-handle", - "scopeguard", - "serde", "tao-macros", "unicode-segmentation", - "uuid", - "windows 0.39.0", - "windows-implement 0.39.0", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", "x11-dl", ] @@ -5111,77 +5062,68 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "1.8.3" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae1f57c291a6ab8e1d2e6b8ad0a35ff769c9925deb8a89de85425ff08762d0c" +checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" dependencies = [ "anyhow", - "base64 0.22.1", "bytes", - "cocoa", - "dirs-next", + "cookie", + "dirs", "dunce", "embed_plist", - "encoding_rs", - "flate2", - "futures-util", - "getrandom 0.2.15", - "glib", + "getrandom 0.3.1", "glob", "gtk", "heck 0.5.0", - "http 0.2.12", - "ignore", - "indexmap 1.9.3", - "infer", + "http", + "jni", + "libc", "log", - "minisign-verify", - "objc", - "once_cell", - "open", - "os_pipe", + "mime", + "muda", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", "percent-encoding", "plist", - "rand 0.8.5", "raw-window-handle", - "regex", - "reqwest 0.11.27", - "rfd", - "semver", + "reqwest", "serde", "serde_json", "serde_repr", "serialize-to-javascript", - "shared_child", - "state 0.5.3", - "tar", + "swift-rs", + "tauri-build", "tauri-macros", "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "tempfile", - "thiserror 1.0.63", - "time", + "thiserror 2.0.12", "tokio", + "tray-icon", "url", - "uuid", "webkit2gtk", "webview2-com", - "windows 0.39.0", - "zip 0.6.6", + "window-vibrancy", + "windows 0.61.3", ] [[package]] name = "tauri-build" -version = "1.5.6" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2db08694eec06f53625cfc6fff3a363e084e5e9a238166d2989996413c346453" +checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" dependencies = [ "anyhow", "cargo_toml", - "dirs-next", + "dirs", + "glob", "heck 0.5.0", "json-patch", + "schemars", "semver", "serde", "serde_json", @@ -5192,137 +5134,307 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "1.4.6" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53438d78c4a037ffe5eafa19e447eea599bedfb10844cb08ec53c2471ac3ac3f" +checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "brotli", "ico", "json-patch", "plist", - "png", + "png 0.17.13", "proc-macro2", "quote", - "regex", "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.8", + "syn 2.0.117", "tauri-utils", - "thiserror 1.0.63", + "thiserror 2.0.12", "time", + "url", "uuid", "walkdir", ] [[package]] name = "tauri-macros" -version = "1.4.7" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233988ac08c1ed3fe794cd65528d48d8f7ed4ab3895ca64cdaa6ad4d00c45c0b" +checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", "tauri-codegen", "tauri-utils", ] [[package]] -name = "tauri-plugin-window-state" -version = "0.1.1" -source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#1a38991689b60aafdf502072082c108ad9149a61" +name = "tauri-plugin" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" dependencies = [ - "bincode", - "bitflags 2.6.0", + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation 0.3.2", + "percent-encoding", + "schemars", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.12", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", "log", "serde", "serde_json", "tauri", - "thiserror 1.0.63", + "tauri-plugin", + "thiserror 2.0.12", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.12", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", ] [[package]] name = "tauri-runtime" -version = "0.14.6" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b" +checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" dependencies = [ + "cookie", + "dpi", "gtk", - "http 0.2.12", - "http-range", - "rand 0.8.5", + "http", + "jni", + "objc2 0.6.4", + "objc2-ui-kit", + "objc2-web-kit", "raw-window-handle", "serde", "serde_json", "tauri-utils", - "thiserror 1.0.63", + "thiserror 2.0.12", "url", - "uuid", + "webkit2gtk", "webview2-com", - "windows 0.39.0", + "windows 0.61.3", ] [[package]] name = "tauri-runtime-wry" -version = "0.14.11" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce361fec1e186705371f1c64ae9dd2a3a6768bc530d0a2d5e75a634bb416ad4d" +checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" dependencies = [ - "cocoa", "gtk", + "http", + "jni", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "once_cell", "percent-encoding", - "rand 0.8.5", "raw-window-handle", + "softbuffer", + "tao", "tauri-runtime", "tauri-utils", - "uuid", + "url", "webkit2gtk", "webview2-com", - "windows 0.39.0", + "windows 0.61.3", "wry", ] [[package]] name = "tauri-utils" -version = "1.6.2" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c357952645e679de02cd35007190fcbce869b93ffc61b029f33fe02648453774" +checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" dependencies = [ + "anyhow", "brotli", + "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "heck 0.5.0", - "html5ever", + "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.2", + "phf", + "plist", "proc-macro2", "quote", + "regex", + "schemars", "semver", "serde", + "serde-untagged", "serde_json", "serde_with", - "thiserror 1.0.63", + "swift-rs", + "thiserror 2.0.12", + "toml 1.1.2+spec-1.1.0", "url", + "urlpattern", + "uuid", "walkdir", - "windows-version", ] [[package]] name = "tauri-winres" -version = "0.1.1" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ + "dunce", "embed-resource", - "toml 0.7.8", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -5340,21 +5452,14 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" dependencies = [ - "futf", - "mac", + "new_debug_unreachable", "utf-8", ] -[[package]] -name = "thin-slice" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" - [[package]] name = "thiserror" version = "1.0.63" @@ -5395,16 +5500,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "thread_local" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" -dependencies = [ - "cfg-if", - "once_cell", -] - [[package]] name = "tiff" version = "0.9.1" @@ -5423,7 +5518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa 1.0.11", + "itoa", "num-conv", "powerfmt", "serde_core", @@ -5457,75 +5552,40 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" dependencies = [ - "rustls 0.23.28", + "rustls", "tokio", ] @@ -5555,72 +5615,124 @@ dependencies = [ [[package]] name = "toml" -version = "0.5.11" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", + "serde_spanned 0.6.7", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] name = "toml" -version = "0.7.8" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit 0.19.15", + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", ] [[package]] name = "toml" -version = "0.8.16" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81967dd0dd2c1ab0bc3468bd7caecc32b8a4aa47d0c8c695d8c2b2108168d62c" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit 0.22.22", + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", ] [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.7.0", - "serde", - "serde_spanned", - "toml_datetime", + "indexmap 2.14.0", + "toml_datetime 0.6.3", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.14.0", "serde", - "serde_spanned", - "toml_datetime", - "winnow 0.6.21", + "serde_spanned 0.6.7", + "toml_datetime 0.6.3", + "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.2" @@ -5630,10 +5742,11 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5642,11 +5755,11 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", "bytes", "futures-util", - "http 1.1.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower", @@ -5672,6 +5785,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5695,36 +5809,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "tray-icon" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ - "log", + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", + "png 0.18.1", + "serde", + "thiserror 2.0.12", + "windows-sys 0.61.2", ] [[package]] @@ -5740,28 +5846,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" [[package]] -name = "typenum" -version = "1.17.0" +name = "typeid" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] -name = "ubyte" -version = "0.10.4" +name = "typenum" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "serde", + "memoffset", + "tempfile", + "windows-sys 0.61.2", ] [[package]] -name = "uncased" -version = "0.9.10" +name = "unic-char-property" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" dependencies = [ - "serde", - "version_check", + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", ] [[package]] @@ -5801,6 +5946,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -5835,20 +5992,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ "getrandom 0.2.15", + "serde", ] -[[package]] -name = "valuable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "vecmath" version = "1.0.0" @@ -5858,12 +6004,6 @@ dependencies = [ "piston-float", ] -[[package]] -name = "version-compare" -version = "0.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b" - [[package]] name = "version-compare" version = "0.2.0" @@ -5915,12 +6055,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -5955,48 +6089,42 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasite" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.13.3+wasi-0.2.2", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.42" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ - "cfg-if", "js-sys", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6004,22 +6132,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.117", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -6041,16 +6169,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.7.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] [[package]] name = "wasm-streams" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -6065,37 +6193,39 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", "hashbrown 0.15.2", - "indexmap 2.7.0", + "indexmap 2.14.0", "semver", ] [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "web_atoms" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "js-sys", - "wasm-bindgen", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] name = "webkit2gtk" -version = "0.18.2" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8f859735e4a452aeb28c6c56a852967a8a76c8eb1cc32dbf931ad28a13d6370" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -6111,20 +6241,18 @@ dependencies = [ "javascriptcore-rs", "libc", "once_cell", - "soup2", + "soup3", "webkit2gtk-sys", ] [[package]] name = "webkit2gtk-sys" -version = "0.18.0" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d76ca6ecc47aeba01ec61e480139dda143796abcae6f83bcddf50d6b5b1dcf3" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ - "atk-sys", "bitflags 1.3.2", "cairo-sys-rs", - "gdk-pixbuf-sys", "gdk-sys", "gio-sys", "glib-sys", @@ -6132,10 +6260,9 @@ dependencies = [ "gtk-sys", "javascriptcore-rs-sys", "libc", - "pango-sys", "pkg-config", - "soup2-sys", - "system-deps 6.2.2", + "soup3-sys", + "system-deps", ] [[package]] @@ -6149,40 +6276,38 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.19.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.39.0", - "windows-implement 0.39.0", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", ] [[package]] name = "webview2-com-macros" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaebe196c01691db62e9e4ca52c5ef1e4fd837dcae27dae3ada599b5a8fd05ac" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] name = "webview2-com-sys" -version = "0.19.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac48ef20ddf657755fdcda8dfed2a7b4fc7e4581acce6fe9b88c3d64f29dee7" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "regex", - "serde", - "serde_json", - "thiserror 1.0.63", - "windows 0.39.0", - "windows-bindgen", - "windows-metadata", + "thiserror 2.0.12", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] @@ -6191,6 +6316,19 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6223,39 +6361,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows" -version = "0.37.0" +name = "window-vibrancy" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "windows_aarch64_msvc 0.37.0", - "windows_i686_gnu 0.37.0", - "windows_i686_msvc 0.37.0", - "windows_x86_64_gnu 0.37.0", - "windows_x86_64_msvc 0.37.0", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", ] [[package]] name = "windows" -version = "0.39.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-implement 0.39.0", - "windows_aarch64_msvc 0.39.0", - "windows_i686_gnu 0.39.0", - "windows_i686_msvc 0.39.0", - "windows_x86_64_gnu 0.39.0", - "windows_x86_64_msvc 0.39.0", -] - -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", ] [[package]] @@ -6264,20 +6394,19 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", + "windows-collections 0.3.2", "windows-core 0.62.2", - "windows-future", - "windows-numerics", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] -name = "windows-bindgen" -version = "0.39.0" +name = "windows-collections" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68003dbd0e38abc0fb85b939240f4bce37c43a5981d3df37ccbaaa981b47cb41" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-metadata", - "windows-tokens", + "windows-core 0.61.2", ] [[package]] @@ -6298,19 +6427,43 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", + "windows-implement", "windows-interface", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + [[package]] name = "windows-future" version = "0.3.2" @@ -6319,17 +6472,7 @@ checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", "windows-link 0.2.1", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba01f98f509cb5dc05f4e5fc95e535f78260f15fea8fe1a8abdd08f774f1cee7" -dependencies = [ - "syn 1.0.109", - "windows-tokens", + "windows-threading 0.2.1", ] [[package]] @@ -6367,10 +6510,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-metadata" -version = "0.39.0" +name = "windows-native-keyring-store" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278" +checksum = "b5fd986f648459dd29aa252ed3a5ad11a60c0b1251bf81625fb03a86c69d274e" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] [[package]] name = "windows-numerics" @@ -6382,17 +6542,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-registry" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" -dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-registry" version = "0.6.1" @@ -6440,21 +6589,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -6464,15 +6598,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -6572,6 +6697,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -6581,12 +6715,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-tokens" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f838de2fe15fe6bac988e74b798f26499a8b21a9d97edec321e79b28d1d7f597" - [[package]] name = "windows-version" version = "0.1.1" @@ -6620,18 +6748,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7711666096bd4096ffa835238905bb33fb87267910e154b18b44eaabb340f2" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6656,18 +6772,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" - -[[package]] -name = "windows_i686_gnu" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763fc57100a5f7042e3057e7e8d9bdd7860d330070251a73d003563a3bb49e1b" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6704,18 +6808,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" - -[[package]] -name = "windows_i686_msvc" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc7cbfe58828921e10a9f446fcaaf649204dcfe6c1ddd712c5eebae6bda1106" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6740,18 +6832,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6868c165637d653ae1e8dc4d82c25d4f97dd6605eaa8d784b5c6e0ab2a252b65" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6800,18 +6880,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e4d40883ae9cae962787ca76ba76390ffa29214667a111db9e0a1ad8377e809" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6847,31 +6915,27 @@ dependencies = [ [[package]] name = "winnow" -version = "0.6.21" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f5bb5257f2407a5425c6e749bfd9692192a73e70a6060516ac04f889087d68" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] [[package]] name = "winreg" -version = "0.50.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" dependencies = [ "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -6900,7 +6964,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.11.1", ] [[package]] @@ -6911,7 +6975,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.7.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -6941,8 +7005,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.6.0", - "indexmap 2.7.0", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -6961,7 +7025,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.7.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -6985,40 +7049,46 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "wry" -version = "0.24.10" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00711278ed357350d44c749c286786ecac644e044e4da410d466212152383b45" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ - "base64 0.13.1", - "block", - "cocoa", - "core-graphics", + "base64 0.22.1", + "block2 0.6.2", + "cookie", "crossbeam-channel", + "dirs", + "dom_query", + "dpi", "dunce", - "gdk", - "gio", - "glib", + "gdkx11", "gtk", - "html5ever", - "http 0.2.12", - "kuchikiki", + "http", + "javascriptcore-rs", + "jni", "libc", - "log", - "objc", - "objc_id", + "ndk", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", "once_cell", - "serde", - "serde_json", - "sha2", - "soup2", - "tao", - "thiserror 1.0.63", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.8", + "soup3", + "tao-macros", + "thiserror 2.0.12", "url", "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.39.0", - "windows-implement 0.39.0", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", ] [[package]] @@ -7088,6 +7158,12 @@ dependencies = [ "rustix 0.38.34", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xz2" version = "0.1.7" @@ -7097,21 +7173,13 @@ dependencies = [ "lzma-sys", ] -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -dependencies = [ - "is-terminal", -] - [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec 0.9.1", "time", ] @@ -7140,23 +7208,64 @@ dependencies = [ ] [[package]] -name = "zerocopy" -version = "0.8.17" +name = "zbus" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa91407dacce3a68c56de03abe2760159582b846c6a4acd2f456618087f12713" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ - "zerocopy-derive", + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] -name = "zerocopy-derive" -version = "0.8.17" +name = "zbus_macros" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06718a168365cad3d5ff0bb133aad346959a2074bd4a85c121255a11304a8626" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant", ] [[package]] @@ -7222,24 +7331,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "byteorder", - "crc32fast", - "crossbeam-utils", -] - [[package]] name = "zip" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" dependencies = [ - "aes", + "aes 0.8.4", "arbitrary", "bzip2", "constant_time_eq", @@ -7248,11 +7346,11 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.3.1", - "hmac", - "indexmap 2.7.0", + "hmac 0.12.1", + "indexmap 2.14.0", "lzma-rs", "memchr", - "pbkdf2", + "pbkdf2 0.12.2", "sha1", "time", "xz2", @@ -7261,6 +7359,18 @@ dependencies = [ "zstd", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zip" version = "7.4.0" @@ -7269,7 +7379,7 @@ checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "crc32fast", "flate2", - "indexmap 2.7.0", + "indexmap 2.14.0", "memchr", "typed-path", "zopfli", @@ -7352,3 +7462,43 @@ checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.2", +] diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 19a6040c..5bb0bf50 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -1,62 +1,67 @@ [package] name = "mindwork-ai-studio" version = "26.4.1" -edition = "2021" +edition = "2024" description = "MindWork AI Studio" authors = ["Thorsten Sommer"] [build-dependencies] -tauri-build = { version = "1.5.6", features = [] } +tauri-build = { version = "2.6.1", features = [] } [dependencies] -tauri = { version = "1.8.3", features = [ "http-all", "updater", "shell-sidecar", "shell-open", "dialog", "global-shortcut"] } -tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } +tauri = { version = "2.11.1", features = [] } +tauri-plugin-window-state = { version = "2.4.1" } +tauri-plugin-shell = "2.3.5" +tauri-plugin-dialog = "2.7.1" +tauri-plugin-opener = "2.5.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -keyring = { version = "3.6.2", features = ["apple-native", "windows-native", "sync-secret-service"] } +keyring-core = "1.0.0" arboard = "3.6.1" -tokio = { version = "1.50.0", features = ["rt", "rt-multi-thread", "macros", "process"] } +tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "macros", "process"] } tokio-stream = "0.1.18" futures = "0.3.32" async-stream = "0.3.6" flexi_logger = "0.31.8" log = { version = "0.4.29", features = ["kv"] } once_cell = "1.21.4" -rocket = { version = "0.5.1", features = ["json", "tls"] } +axum = { version = "0.8.9", features = ["http2", "json", "query", "tokio"] } +axum-server = { version = "0.8.0", features = ["tls-rustls"] } +rustls = { version = "0.23.28", default-features = false, features = ["aws_lc_rs"] } rand = "0.10.1" rand_chacha = "0.10.0" base64 = "0.22.1" -aes = "0.8.4" -cbc = "0.1.2" -pbkdf2 = "0.12.2" -hmac = "0.12.1" -sha2 = "0.10.8" -rcgen = { version = "0.14.7", features = ["pem"] } -file-format = "0.28.0" -calamine = "0.34.0" -pdfium-render = "0.8.37" +aes = "0.9.0" +cbc = "0.2.0" +pbkdf2 = "0.13.0" +hmac = "0.13.0" +sha2 = "0.11.0" +rcgen = { version = "0.14.8", features = ["pem"] } +file-format = "0.29.0" +calamine = "0.35.0" +pdfium-render = "0.9.1" sys-locale = "0.3.2" +whoami = "2.1.2" cfg-if = "1.0.4" pptx-to-md = "0.4.0" tempfile = "3.27.0" strum_macros = "0.28.0" -sysinfo = "0.38.4" -tokenizers = "0.22.2" - -# Fixes security vulnerability downstream, where the upstream is not fixed yet: -time = "0.3.47" # -> Rocket -bytes = "1.11.1" # -> almost every dependency -tar = "0.4.45" # -> Tauri v1 - -[target.'cfg(target_os = "linux")'.dependencies] -# See issue https://github.com/tauri-apps/tauri/issues/4470 -reqwest = { version = "0.13.2", features = ["native-tls-vendored"] } - -# Fixes security vulnerability downstream, where the upstream is not fixed yet: -openssl = "0.10.76" # -> reqwest, Tauri v1 +sysinfo = "0.39.1" +bytes = "1.11.1" [target.'cfg(target_os = "windows")'.dependencies] windows-registry = "0.6.1" +windows-native-keyring-store = "1.0.0" + +[target.'cfg(target_os = "macos")'.dependencies] +apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] } + +[target.'cfg(target_os = "linux")'.dependencies] +dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-global-shortcut = "2" +tauri-plugin-updater = "2.10.0" [features] custom-protocol = ["tauri/custom-protocol"] diff --git a/runtime/build.rs b/runtime/build.rs index c4d1f749..80a92985 100644 --- a/runtime/build.rs +++ b/runtime/build.rs @@ -53,6 +53,18 @@ fn update_cargo_toml(cargo_path: &str, version: &str) { let cargo_toml_lines = cargo_toml.lines(); let mut new_cargo_toml = String::new(); + // Return early when the version already matches to avoid unnecessary rewrites. + let current_version = cargo_toml.lines().find_map(|line| { + let trimmed = line.trim_start(); + let rest = trimmed.strip_prefix("\"version\": ")?; + let quoted = rest.strip_prefix('"')?; + let end_idx = quoted.find('"')?; + Some("ed[..end_idx]) + }); + if current_version == Some(version) { + return; + } + for line in cargo_toml_lines { if line.starts_with("version = ") { new_cargo_toml.push_str(&format!("version = \"{version}\"")); @@ -67,6 +79,19 @@ fn update_cargo_toml(cargo_path: &str, version: &str) { fn update_tauri_conf(tauri_conf_path: &str, version: &str) { let tauri_conf = std::fs::read_to_string(tauri_conf_path).unwrap(); + + // Return early when the version already matches to avoid unnecessary rewrites. + let current_version = tauri_conf.lines().find_map(|line| { + let trimmed = line.trim_start(); + let rest = trimmed.strip_prefix("\"version\": ")?; + let quoted = rest.strip_prefix('"')?; + let end_idx = quoted.find('"')?; + Some("ed[..end_idx]) + }); + if current_version == Some(version) { + return; + } + let tauri_conf_lines = tauri_conf.lines(); let mut new_tauri_conf = String::new(); @@ -75,7 +100,7 @@ fn update_tauri_conf(tauri_conf_path: &str, version: &str) { // "version": "0.1.0-alpha.0" // Please notice, that the version number line might have a leading tab, etc. if line.contains("\"version\": ") { - new_tauri_conf.push_str(&format!("\t\"version\": \"{version}\"")); + new_tauri_conf.push_str(&format!(" \"version\": \"{version}\",")); } else { new_tauri_conf.push_str(line); } diff --git a/runtime/capabilities/default.json b/runtime/capabilities/default.json new file mode 100644 index 00000000..86f14897 --- /dev/null +++ b/runtime/capabilities/default.json @@ -0,0 +1,34 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for MindWork AI Studio", + "remote": { + "urls": [ + "http://localhost:*" + ] + }, + "windows": [ + "main" + ], + "permissions": [ + "core:default", + "updater:default", + "opener:default", + "shell:allow-open", + { + "identifier": "shell:allow-spawn", + "allow": [ + { + "name": "mindworkAIStudioServer", + "sidecar": true, + "args": true + }, + { + "name": "qdrant", + "sidecar": true, + "args": true + } + ] + } + ] +} diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index 31f79831..22d57791 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -1,16 +1,23 @@ use std::collections::HashMap; +use std::convert::Infallible; use std::sync::Mutex; use std::time::Duration; +use async_stream::stream; +use axum::body::Body; +use axum::http::header::CONTENT_TYPE; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use bytes::Bytes; use log::{debug, error, info, trace, warn}; use once_cell::sync::Lazy; -use rocket::{get, post}; -use rocket::response::stream::TextStream; -use rocket::serde::json::Json; -use rocket::serde::Serialize; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use strum_macros::Display; -use tauri::updater::UpdateResponse; -use tauri::{FileDropEvent, GlobalShortcutManager, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent, generate_context}; +use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context}; +use tauri::path::PathResolver; +use tauri::WebviewWindow; +use tauri_plugin_updater::{UpdaterExt, Update}; +use tauri_plugin_global_shortcut::GlobalShortcutExt; +use tauri_plugin_opener::OpenerExt; use tokio::sync::broadcast; use tokio::time; use crate::api_token::APIToken; @@ -18,16 +25,16 @@ use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_serv use crate::environment::{is_prod, is_dev, CONFIG_DIRECTORY, DATA_DIRECTORY}; use crate::log::switch_to_file_logging; use crate::pdfium::PDFIUM_LIB_PATH; -use crate::qdrant::{cleanup_qdrant, start_qdrant_server, stop_qdrant_server}; +use crate::qdrant::{start_qdrant_server, stop_qdrant_server}; #[cfg(debug_assertions)] use crate::dotnet::create_startup_env_file; use crate::tokenizer::set_path_resolver; /// The Tauri main window. -static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); +pub static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); /// The update response coming from the Tauri updater. -static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); +static CHECK_UPDATE_RESPONSE: Lazy>> = Lazy::new(|| Mutex::new(None)); /// The event broadcast sender for Tauri events. static EVENT_BROADCAST: Lazy>>> = Lazy::new(|| Mutex::new(None)); @@ -35,6 +42,9 @@ static EVENT_BROADCAST: Lazy>>> = Lazy::ne /// Stores the currently registered global shortcuts (name -> shortcut string). static REGISTERED_SHORTCUTS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +/// Stores the localhost origin of the Blazor app after the .NET server is ready. +static APPROVED_APP_URL: Lazy>> = Lazy::new(|| Mutex::new(None)); + /// Enum identifying global keyboard shortcuts. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] @@ -76,10 +86,34 @@ pub fn start_tauri() { }); let app = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) + .plugin( + tauri::plugin::Builder::::new("external-link-handler") + .on_navigation(|webview, url| { + if !should_open_in_system_browser(webview, url) { + return true; + } + + match webview.app_handle().opener().open_url(url.as_str(), None::<&str>) { + Ok(_) => { + info!(Source = "Tauri"; "Opening external URL in system browser: {url}"); + }, + Err(error) => { + error!(Source = "Tauri"; "Failed to open external URL '{url}' in system browser: {error}"); + }, + } + false + }) + .build(), + ) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(move |app| { // Get the main window: - let window = app.get_window("main").expect("Failed to get main window."); + let window = app.get_webview_window("main").expect("Failed to get main window."); // Register a callback for window events, such as file drops. We have to use // this handler in addition to the app event handler, because file drop events @@ -100,29 +134,28 @@ pub fn start_tauri() { *MAIN_WINDOW.lock().unwrap() = Some(window); info!(Source = "Bootloader Tauri"; "Setup is running."); - let data_path = app.path_resolver().app_local_data_dir().unwrap(); + let data_path = app.path().app_local_data_dir().unwrap(); let data_path = data_path.join("data"); // Get and store the data and config directories: DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the data directory.")).unwrap(); - CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); + CONFIG_DIRECTORY.set(app.path().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); if is_dev() { #[cfg(debug_assertions)] create_startup_env_file(); } else { cleanup_dotnet_server(); - start_dotnet_server(); + start_dotnet_server(app.handle().clone()); } - cleanup_qdrant(); - start_qdrant_server(app.path_resolver()); + start_qdrant_server(app.handle().clone()); set_path_resolver(app.path_resolver()); info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}"); switch_to_file_logging(data_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap(); - set_pdfium_path(app.path_resolver()); + set_pdfium_path(app.path()); Ok(()) }) @@ -131,7 +164,7 @@ pub fn start_tauri() { .expect("Error while running Tauri application"); // The app event handler: - app.run(|app_handle, event| { + app.run(|_app_handle, event| { if !matches!(event, RunEvent::MainEventsCleared) { debug!(Source = "Tauri"; "Tauri event received: location=app event handler , event={event:?}"); } @@ -151,54 +184,6 @@ pub fn start_tauri() { } } - RunEvent::Updater(updater_event) => { - match updater_event { - UpdaterEvent::UpdateAvailable { body, date, version } => { - let body_len = body.len(); - info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}"); - } - - UpdaterEvent::Pending => { - info!(Source = "Tauri"; "Updater: update is pending!"); - } - - UpdaterEvent::DownloadProgress { chunk_length, content_length: _ } => { - trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes"); - } - - UpdaterEvent::Downloaded => { - info!(Source = "Tauri"; "Updater: update has been downloaded!"); - warn!(Source = "Tauri"; "Try to stop the .NET server now..."); - - if is_prod() { - stop_dotnet_server(); - stop_qdrant_server(); - } else { - warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server."); - } - } - - UpdaterEvent::Updated => { - info!(Source = "Tauri"; "Updater: app has been updated"); - warn!(Source = "Tauri"; "Try to restart the app now..."); - - if is_prod() { - app_handle.restart(); - } else { - warn!(Source = "Tauri"; "Development environment detected; do not restart the app."); - } - } - - UpdaterEvent::AlreadyUpToDate => { - info!(Source = "Tauri"; "Updater: app is already up to date"); - } - - UpdaterEvent::Error(error) => { - warn!(Source = "Tauri"; "Updater: failed to update: {error}"); - } - } - } - RunEvent::ExitRequested { .. } => { warn!(Source = "Tauri"; "Run event: exit was requested."); stop_qdrant_server(); @@ -219,13 +204,62 @@ pub fn start_tauri() { warn!(Source = "Tauri"; "Tauri app was stopped."); } +fn is_local_host(host: Option<&str>) -> bool { + matches!(host, Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]")) +} + +fn is_tauri_asset_host(host: Option<&str>) -> bool { + matches!(host, Some("tauri.localhost")) +} + +fn is_tauri_asset_url(url: &tauri::Url) -> bool { + matches!(url.scheme(), "http" | "https") && is_tauri_asset_host(url.host_str()) +} + +fn is_local_http_url(url: &tauri::Url) -> bool { + matches!(url.scheme(), "http" | "https") && is_local_host(url.host_str()) +} + +fn same_origin(left: &tauri::Url, right: &tauri::Url) -> bool { + left.scheme() == right.scheme() + && left.host_str() == right.host_str() + && left.port_or_known_default() == right.port_or_known_default() +} + +fn should_open_in_system_browser(webview: &tauri::Webview, url: &tauri::Url) -> bool { + match url.scheme() { + "mailto" | "tel" => return true, + "http" | "https" => {}, + _ => return false, + } + + if is_tauri_asset_url(url) { + return false; + } + + if let Some(approved_app_url) = APPROVED_APP_URL.lock().unwrap().as_ref() { + if same_origin(approved_app_url, url) { + return false; + } + + if is_local_http_url(url) { + return true; + } + } + + if let Ok(current_url) = webview.url() && same_origin(¤t_url, url) { + return false; + } + + !is_local_host(url.host_str()) +} + /// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client. /// If no events are available for a certain time, we send a ping event to keep the connection alive. /// When the client disconnects, the stream is closed. But we try to not lose events in between. /// The client is expected to reconnect automatically when the connection is closed and continue /// listening for events. -#[get("/events")] -pub async fn get_event_stream(_token: APIToken) -> TextStream![String] { +pub async fn get_event_stream(_token: APIToken) -> Response { // Get the lock to the event broadcast sender: let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap(); @@ -237,8 +271,7 @@ pub async fn get_event_stream(_token: APIToken) -> TextStream![String] { // Drop the lock to allow other access to the sender: drop(event_broadcast_lock); - // Create the event stream: - TextStream! { + let stream = stream! { loop { // Wait at most 3 seconds for an event: match time::timeout(Duration::from_secs(3), event_receiver.recv()).await { @@ -249,11 +282,11 @@ pub async fn get_event_stream(_token: APIToken) -> TextStream![String] { // is serialized as a single line so that the client can parse it // correctly: let event_json = serde_json::to_string(&event).unwrap(); - yield event_json; + yield Ok::(Bytes::from(event_json)); // The client expects a newline after each event because we are using // a method to read the stream line-by-line: - yield "\n".to_string(); + yield Ok::(Bytes::from("\n")); }, // Case: we lagged behind and missed some events @@ -273,15 +306,17 @@ pub async fn get_event_stream(_token: APIToken) -> TextStream![String] { // Again, we have to serialize the event as a single line: let event_json = serde_json::to_string(&ping_event).unwrap(); - yield event_json; + yield Ok::(Bytes::from(event_json)); // The client expects a newline after each event because we are using // a method to read the stream line-by-line: - yield "\n".to_string(); + yield Ok::(Bytes::from("\n")); }, } } - } + }; + + ([(CONTENT_TYPE, "application/jsonl")], Body::from_stream(stream)).into_response() } /// Data structure representing a Tauri event for our event API. @@ -305,23 +340,21 @@ impl Event { /// Creates an Event instance from a Tauri WindowEvent. pub fn from_window_event(window_event: &WindowEvent) -> Self { match window_event { - WindowEvent::FileDrop(drop_event) => { + WindowEvent::DragDrop(drop_event) => { match drop_event { - FileDropEvent::Hovered(files) => Event::new(TauriEventType::FileDropHovered, - files.iter().map(|f| f.to_string_lossy().to_string()).collect(), + DragDropEvent::Enter { paths, .. } => Event::new( + TauriEventType::FileDropHovered, + paths.iter().map(|p| p.display().to_string()).collect(), ), - FileDropEvent::Dropped(files) => Event::new(TauriEventType::FileDropDropped, - files.iter().map(|f| f.to_string_lossy().to_string()).collect(), + DragDropEvent::Drop { paths, .. } => Event::new( + TauriEventType::FileDropDropped, + paths.iter().map(|p| p.display().to_string()).collect(), ), - FileDropEvent::Cancelled => Event::new(TauriEventType::FileDropCanceled, - Vec::new(), - ), + DragDropEvent::Leave => Event::new(TauriEventType::FileDropCanceled, Vec::new()), - _ => Event::new(TauriEventType::Unknown, - Vec::new(), - ), + _ => Event::new(TauriEventType::Unknown, Vec::new()), } }, @@ -382,6 +415,10 @@ pub async fn change_location_to(url: &str) { } } + if let Ok(parsed_url) = tauri::Url::parse(url) && is_local_http_url(&parsed_url) { + *APPROVED_APP_URL.lock().unwrap() = Some(parsed_url); + } + let js_location_change = format!("window.location = '{url}';"); let main_window = main_window_spawn_clone.lock().unwrap(); let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); @@ -392,7 +429,6 @@ pub async fn change_location_to(url: &str) { } /// Checks for updates. -#[get("/updates/check")] pub async fn check_for_update(_token: APIToken) -> Json { if is_dev() { warn!(Source = "Updater"; "The app is running in development mode; skipping update check."); @@ -404,46 +440,67 @@ pub async fn check_for_update(_token: APIToken) -> Json { }); } - let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle(); - let response = app_handle.updater().check().await; - match response { - Ok(update_response) => match update_response.is_update_available() { - true => { - *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone()); - let new_version = update_response.latest_version(); - info!(Source = "Updater"; "An update to version '{new_version}' is available."); - let changelog = update_response.body(); - Json(CheckUpdateResponse { - update_is_available: true, - error: false, - new_version: new_version.to_string(), - changelog: match changelog { - Some(c) => c.to_string(), - None => String::from(""), - }, - }) - }, - - false => { - info!(Source = "Updater"; "No updates are available."); - Json(CheckUpdateResponse { + let app_handle = { + let main_window = MAIN_WINDOW.lock().unwrap(); + match main_window.as_ref() { + Some(window) => window.app_handle().clone(), + None => { + error!(Source = "Updater"; "Cannot check updates: main window not available."); + return Json(CheckUpdateResponse { update_is_available: false, - error: false, + error: true, new_version: String::from(""), changelog: String::from(""), - }) - }, - }, - + }); + } + } + }; + let response = match app_handle.updater() { + Ok(updater) => updater.check().await, Err(e) => { - warn!(Source = "Updater"; "Failed to check for updates: {e}."); + warn!(Source = "Updater"; "Failed to get updater instance: {e}"); + return Json(CheckUpdateResponse { + update_is_available: false, + error: true, + new_version: String::from(""), + changelog: String::from(""), + }); + } + }; + + match response { + Ok(Some(update)) => { + let body_len = update.body.as_ref().map_or(0, |body| body.len()); + let date = update.date; + let new_version = update.version.clone(); + info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={new_version}"); + let changelog = update.body.clone().unwrap_or_default(); + *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update); + Json(CheckUpdateResponse { + update_is_available: true, + error: false, + new_version, + changelog, + }) + } + Ok(None) => { + info!(Source = "Tauri"; "Updater: app is already up to date"); + Json(CheckUpdateResponse { + update_is_available: false, + error: false, + new_version: String::from(""), + changelog: String::from(""), + }) + } + Err(e) => { + warn!(Source = "Tauri"; "Updater: failed to update: {e}"); Json(CheckUpdateResponse { update_is_available: false, error: true, new_version: String::from(""), changelog: String::from(""), }) - }, + } } } @@ -457,7 +514,6 @@ pub struct CheckUpdateResponse { } /// Installs the update. -#[get("/updates/install")] pub async fn install_update(_token: APIToken) { if is_dev() { warn!(Source = "Updater"; "The app is running in development mode; skipping update installation."); @@ -465,9 +521,51 @@ pub async fn install_update(_token: APIToken) { } let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone(); + let app_handle = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|window| window.app_handle().clone()); + match cloned_response_option { Some(update_response) => { - update_response.download_and_install().await.unwrap(); + info!(Source = "Tauri"; "Updater: update is pending!"); + let result = update_response.download_and_install( + |chunk_length, _content_length| { + trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes"); + }, + || { + info!(Source = "Tauri"; "Updater: update has been downloaded!"); + warn!(Source = "Tauri"; "Try to stop the .NET server now..."); + + if is_prod() { + stop_dotnet_server(); + stop_qdrant_server(); + } else { + warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server."); + } + }, + ).await; + + match result { + Ok(_) => { + info!(Source = "Tauri"; "Updater: app has been updated"); + warn!(Source = "Tauri"; "Try to restart the app now..."); + + if is_prod() { + if let Some(handle) = app_handle { + handle.restart(); + } else { + warn!(Source = "Tauri"; "Cannot restart after update: main window not available."); + } + } else { + warn!(Source = "Tauri"; "Development environment detected; do not restart the app."); + } + } + Err(e) => { + warn!(Source = "Tauri"; "Updater: failed to update: {e}"); + } + } }, None => { @@ -504,47 +602,41 @@ pub struct AppExitResponse { /// Internal helper function to register a shortcut with its callback. /// This is used by both `register_shortcut` and `resume_shortcuts` to /// avoid code duplication. -fn register_shortcut_with_callback( - shortcut_manager: &mut impl GlobalShortcutManager, +fn register_shortcut_with_callback( + app_handle: &tauri::AppHandle, shortcut: &str, shortcut_id: Shortcut, event_sender: broadcast::Sender, -) -> Result<(), tauri::Error> { - // - // Match the shortcut registration to transform the Tauri result into the Rust result: - // - match shortcut_manager.register(shortcut, move || { +) -> Result<(), tauri_plugin_global_shortcut::Error> { + let shortcut_manager = app_handle.global_shortcut(); + shortcut_manager.on_shortcut(shortcut, move |_app, _shortcut, _event| { info!(Source = "Tauri"; "Global shortcut triggered for '{}'.", shortcut_id); let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_id.to_string()]); let sender = event_sender.clone(); tauri::async_runtime::spawn(async move { - match sender.send(event) { - Ok(_) => {} - Err(error) => error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"), + if let Err(error) = sender.send(event) { + error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"); } }); - }) { - Ok(_) => Ok(()), - Err(e) => Err(e.into()), - } + }) } /// Requests a controlled shutdown of the entire desktop application. -#[post("/app/exit")] -pub fn exit_app(_token: APIToken) -> Json { - let main_window_lock = MAIN_WINDOW.lock().unwrap(); - let main_window = match main_window_lock.as_ref() { - Some(window) => window, - None => { - error!(Source = "Tauri"; "Cannot exit app: main window not available."); - return Json(AppExitResponse { - success: false, - error_message: "Main window not available".to_string(), - }); +pub async fn exit_app(_token: APIToken) -> Json { + let app_handle = { + let main_window_lock = MAIN_WINDOW.lock().unwrap(); + match main_window_lock.as_ref() { + Some(window) => window.app_handle().clone(), + None => { + error!(Source = "Tauri"; "Cannot exit app: main window not available."); + return Json(AppExitResponse { + success: false, + error_message: "Main window not available".to_string(), + }); + } } }; - let app_handle = main_window.app_handle(); info!(Source = "Tauri"; "Controlled app exit was requested by the UI."); tauri::async_runtime::spawn(async move { time::sleep(Duration::from_millis(50)).await; @@ -559,8 +651,7 @@ pub fn exit_app(_token: APIToken) -> Json { /// Registers or updates a global shortcut. If the shortcut string is empty, /// the existing shortcut for that name will be unregistered. -#[post("/shortcuts/register", data = "")] -pub fn register_shortcut(_token: APIToken, payload: Json) -> Json { +pub async fn register_shortcut(_token: APIToken, payload: Json) -> Json { let id = payload.id; let new_shortcut = payload.shortcut.clone(); @@ -587,16 +678,15 @@ pub fn register_shortcut(_token: APIToken, payload: Json info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id), - Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"), - } + if let Some(old_shortcut) = registered_shortcuts.get(&id) && !old_shortcut.is_empty() { + match shortcut_manager.unregister(old_shortcut.as_str()) { + Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id), + Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"), } } @@ -626,7 +716,7 @@ pub fn register_shortcut(_token: APIToken, payload: Json { info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{}'.", id); registered_shortcuts.insert(id, new_shortcut); @@ -666,8 +756,7 @@ pub struct ShortcutValidationResponse { /// Validates a shortcut string without registering it. /// Checks if the shortcut syntax is valid and if it /// conflicts with existing shortcuts. -#[post("/shortcuts/validate", data = "")] -pub fn validate_shortcut(_token: APIToken, payload: Json) -> Json { +pub async fn validate_shortcut(_token: APIToken, payload: Json) -> Json { let shortcut = payload.shortcut.clone(); // Empty shortcuts are always valid (means "disabled"): @@ -721,8 +810,7 @@ pub fn validate_shortcut(_token: APIToken, payload: Json Json { +pub async fn suspend_shortcuts(_token: APIToken) -> Json { // Get the main window to access the global shortcut manager: let main_window_lock = MAIN_WINDOW.lock().unwrap(); let main_window = match main_window_lock.as_ref() { @@ -736,7 +824,8 @@ pub fn suspend_shortcuts(_token: APIToken) -> Json { } }; - let mut shortcut_manager = main_window.app_handle().global_shortcut_manager(); + let app_handle = main_window.app_handle(); + let shortcut_manager = app_handle.global_shortcut(); let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); // Unregister all shortcuts from the OS (but keep them in our map): @@ -757,8 +846,7 @@ pub fn suspend_shortcuts(_token: APIToken) -> Json { } /// Resumes shortcut processing by re-registering all shortcuts with the OS. -#[post("/shortcuts/resume")] -pub fn resume_shortcuts(_token: APIToken) -> Json { +pub async fn resume_shortcuts(_token: APIToken) -> Json { // Get the main window to access the global shortcut manager: let main_window_lock = MAIN_WINDOW.lock().unwrap(); let main_window = match main_window_lock.as_ref() { @@ -772,7 +860,7 @@ pub fn resume_shortcuts(_token: APIToken) -> Json { } }; - let mut shortcut_manager = main_window.app_handle().global_shortcut_manager(); + let app_handle = main_window.app_handle(); let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); // Get the event broadcast sender for the shortcut callbacks: @@ -797,7 +885,7 @@ pub fn resume_shortcuts(_token: APIToken) -> Json { continue; } - match register_shortcut_with_callback(&mut shortcut_manager, shortcut, *shortcut_id, event_sender.clone()) { + match register_shortcut_with_callback(app_handle, shortcut, *shortcut_id, event_sender.clone()) { Ok(_) => { info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{}'.", shortcut_id); success_count += 1; @@ -858,15 +946,61 @@ fn validate_shortcut_syntax(shortcut: &str) -> bool { has_key } -fn set_pdfium_path(path_resolver: PathResolver) { - let pdfium_relative_source_path = String::from("resources/libraries/"); - let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path); - if pdfium_source_path.is_none() { - error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path."); - return; +fn set_pdfium_path(path_resolver: &PathResolver) { + let resource_dir = match path_resolver.resource_dir() { + Ok(path) => path, + Err(error) => { + error!(Source = "Bootloader Tauri"; "Failed to resolve resource dir: {error}"); + return; + } + }; + + let candidate_paths = [ + resource_dir.join("resources").join("libraries"), + resource_dir.join("libraries"), + ]; + + let pdfium_source_path = candidate_paths + .iter() + .find(|path| path.exists()) + .map(|path| path.to_string_lossy().to_string()); + + match pdfium_source_path { + Some(path) => { + *PDFIUM_LIB_PATH.lock().unwrap() = Some(path); + } + None => { + error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path."); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tauri_localhost_is_tauri_asset_url() { + let https_url = tauri::Url::parse("https://tauri.localhost/index.html").unwrap(); + let http_url = tauri::Url::parse("http://tauri.localhost/index.html").unwrap(); + + assert!(is_tauri_asset_url(&https_url)); + assert!(is_tauri_asset_url(&http_url)); } - let pdfium_source_path = pdfium_source_path.unwrap(); - let pdfium_source_path = pdfium_source_path.to_str().unwrap().to_string(); - *PDFIUM_LIB_PATH.lock().unwrap() = Some(pdfium_source_path.clone()); -} + #[test] + fn localhost_app_url_is_not_tauri_asset_url() { + let url = tauri::Url::parse("http://localhost:12345/").unwrap(); + + assert!(!is_tauri_asset_url(&url)); + assert!(is_local_http_url(&url)); + } + + #[test] + fn external_url_is_not_internal_url() { + let url = tauri::Url::parse("https://example.com/").unwrap(); + + assert!(!is_tauri_asset_url(&url)); + assert!(!is_local_http_url(&url)); + } +} \ No newline at end of file diff --git a/runtime/src/clipboard.rs b/runtime/src/clipboard.rs index b00617f2..bdb612ff 100644 --- a/runtime/src/clipboard.rs +++ b/runtime/src/clipboard.rs @@ -1,14 +1,13 @@ use arboard::Clipboard; use log::{debug, error}; -use rocket::post; -use rocket::serde::json::Json; +use axum::Json; use serde::Serialize; use crate::api_token::APIToken; use crate::encryption::{EncryptedText, ENCRYPTION}; /// Sets the clipboard text to the provided encrypted text. -#[post("/clipboard/set", data = "")] -pub fn set_clipboard(_token: APIToken, encrypted_text: EncryptedText) -> Json { +pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json { + let encrypted_text = EncryptedText::new(encrypted_text); // Decrypt this text first: let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) { diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 11cc3db5..c5158e13 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -5,9 +5,9 @@ use base64::Engine; use base64::prelude::BASE64_STANDARD; use log::{error, info, warn}; use once_cell::sync::Lazy; -use rocket::get; -use tauri::api::process::{Command, CommandChild, CommandEvent}; use tauri::Url; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; use crate::api_token::APIToken; use crate::runtime_api_token::API_TOKEN; use crate::app_window::change_location_to; @@ -88,8 +88,7 @@ fn sanitize_stdout_line(line: &str) -> String { /// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get /// the port where the .NET server should listen to. -#[get("/system/dotnet/port")] -pub fn dotnet_port(_token: APIToken) -> String { +pub async fn dotnet_port(_token: APIToken) -> String { let dotnet_server_port = *DOTNET_SERVER_PORT; format!("{dotnet_server_port}") } @@ -130,14 +129,14 @@ pub fn create_startup_env_file() { } /// Starts the .NET server in a separate process. -pub fn start_dotnet_server() { +pub fn start_dotnet_server(app_handle: tauri::AppHandle) { // Get the secret password & salt and convert it to a base64 string: let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); let api_port = *API_SERVER_PORT; - let dotnet_server_environment = HashMap::from_iter([ + let dotnet_server_environment: HashMap = HashMap::from_iter([ (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), (String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()), @@ -148,11 +147,13 @@ pub fn start_dotnet_server() { info!("Try to start the .NET server..."); let server_spawn_clone = DOTNET_SERVER.clone(); tauri::async_runtime::spawn(async move { - let (mut rx, child) = Command::new_sidecar("mindworkAIStudioServer") - .expect("Failed to create sidecar") - .envs(dotnet_server_environment) - .spawn() - .expect("Failed to spawn .NET server process."); + let shell = app_handle.shell(); + let (mut rx, child) = shell + .sidecar("mindworkAIStudioServer") + .expect("Failed to create sidecar") + .envs(dotnet_server_environment) + .spawn() + .expect("Failed to spawn .NET server process."); let server_pid = child.pid(); info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}."); log_potential_stale_process(Path::new(DATA_DIRECTORY.get().unwrap()).join(PID_FILE_NAME), server_pid, SIDECAR_TYPE); @@ -163,17 +164,19 @@ pub fn start_dotnet_server() { // Log the output of the .NET server: // NOTE: Log events are sent via structured HTTP API calls. // This loop serves for fundamental output (e.g., startup errors). - while let Some(CommandEvent::Stdout(line)) = rx.recv().await { - let line = sanitize_stdout_line(line.trim_end()); - if !line.trim().is_empty() { - info!(Source = ".NET Server (stdout)"; "{line}"); + while let Some(event) = rx.recv().await { + if let CommandEvent::Stdout(line) = event { + let line_utf8 = String::from_utf8_lossy(&line).to_string(); + let line = sanitize_stdout_line(line_utf8.trim_end()); + if !line.trim().is_empty() { + info!(Source = ".NET Server (stdout)"; "{line}"); + } } } }); } /// This endpoint is called by the .NET server to signal that the server is ready. -#[get("/system/dotnet/ready")] pub async fn dotnet_ready(_token: APIToken) { // We create a manual scope for the lock to be released as soon as possible. diff --git a/runtime/src/encryption.rs b/runtime/src/encryption.rs index 41506855..22e58806 100644 --- a/runtime/src/encryption.rs +++ b/runtime/src/encryption.rs @@ -2,26 +2,20 @@ use std::fmt; use std::time::Instant; use base64::Engine; use base64::prelude::BASE64_STANDARD; -use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit}; use hmac::Hmac; use log::{error, info}; use once_cell::sync::Lazy; use pbkdf2::pbkdf2; use rand::rngs::SysRng; use rand::{Rng, SeedableRng}; -use rocket::{data, Data, Request}; -use rocket::data::ToByteUnit; -use rocket::http::Status; -use rocket::serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use sha2::Sha512; -use tokio::io::AsyncReadExt; type Aes256CbcEnc = cbc::Encryptor; type Aes256CbcDec = cbc::Decryptor; -type DataOutcome<'r, T> = data::Outcome<'r, T>; - /// The encryption instance used for the IPC channel. pub static ENCRYPTION: Lazy = Lazy::new(|| { // @@ -113,7 +107,7 @@ impl Encryption { let mut buffer = vec![0u8; data.len() + 16]; buffer[..data.len()].copy_from_slice(data); let encrypted = cipher - .encrypt_padded_mut::(&mut buffer, data.len()) + .encrypt_padded::(&mut buffer, data.len()) .map_err(|e| format!("Error encrypting data: {e}"))?; let mut result = BASE64_STANDARD.encode(self.secret_key_salt); result.push_str(&BASE64_STANDARD.encode(encrypted)); @@ -136,7 +130,7 @@ impl Encryption { let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into()); let mut buffer = encrypted.to_vec(); let decrypted = cipher - .decrypt_padded_mut::(&mut buffer) + .decrypt_padded::(&mut buffer) .map_err(|e| format!("Error decrypting data: {e}"))?; String::from_utf8(decrypted.to_vec()).map_err(|e| format!("Error converting decrypted data to string: {}", e)) @@ -170,27 +164,4 @@ impl fmt::Display for EncryptedText { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "**********") } -} - -/// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request). -/// We must interpret the body as EncryptedText. -#[rocket::async_trait] -impl<'r> data::FromData<'r> for EncryptedText { - type Error = String; - - /// Parses the data as EncryptedText. - async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> { - let content_type = req.content_type(); - if content_type.map_or(true, |ct| !ct.is_text()) { - return DataOutcome::Forward((data, Status::Ok)); - } - - let mut stream = data.open(2.mebibytes()); - let mut body = String::new(); - if let Err(e) = stream.read_to_string(&mut body).await { - return DataOutcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e))); - } - - DataOutcome::Success(EncryptedText(body)) - } } \ No newline at end of file diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 593ac2d7..3f8dd43c 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,7 +1,6 @@ use crate::api_token::APIToken; -use log::{debug, info, warn}; -use rocket::get; -use rocket::serde::json::Json; +use axum::Json; +use log::{debug, error, info, warn}; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::env; @@ -29,8 +28,7 @@ pub static CONFIG_DIRECTORY: OnceLock = OnceLock::new(); static USER_LANGUAGE: OnceLock = OnceLock::new(); /// Returns the config directory. -#[get("/system/directories/config")] -pub fn get_config_directory(_token: APIToken) -> String { +pub async fn get_config_directory(_token: APIToken) -> String { match CONFIG_DIRECTORY.get() { Some(config_directory) => config_directory.clone(), None => String::from(""), @@ -38,14 +36,21 @@ pub fn get_config_directory(_token: APIToken) -> String { } /// Returns the data directory. -#[get("/system/directories/data")] -pub fn get_data_directory(_token: APIToken) -> String { +pub async fn get_data_directory(_token: APIToken) -> String { match DATA_DIRECTORY.get() { Some(data_directory) => data_directory.clone(), None => String::from(""), } } +/// Returns the current user's username. +pub async fn read_user_name(_token: APIToken) -> String { + whoami::username().unwrap_or_else(|e| { + error!("Failed to read the current OS username: {e}."); + String::new() + }) +} + /// Returns true if the application is running in development mode. pub fn is_dev() -> bool { cfg!(debug_assertions) @@ -90,10 +95,8 @@ fn normalize_locale_tag(locale: &str) -> Option { return None; } - if let Some(region) = segments.next() { - if region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) { - return Some(format!("{}-{}", language, region.to_ascii_uppercase())); - } + if let Some(region) = segments.next() && region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) { + return Some(format!("{}-{}", language, region.to_ascii_uppercase())); } Some(language) @@ -150,8 +153,7 @@ fn detect_user_language() -> (String, LanguageDetectionSource) { ) } -#[get("/system/language")] -pub fn read_user_language(_token: APIToken) -> String { +pub async fn read_user_language(_token: APIToken) -> String { USER_LANGUAGE .get_or_init(|| { let (user_language, source) = detect_user_language(); @@ -194,8 +196,7 @@ struct EnterpriseSourceData { encryption_secret: String, } -#[get("/system/enterprise/config/id")] -pub fn read_enterprise_env_config_id(_token: APIToken) -> String { +pub async fn read_enterprise_env_config_id(_token: APIToken) -> String { debug!("Trying to read the effective enterprise configuration ID."); resolve_effective_enterprise_config_source() .configs @@ -205,8 +206,7 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String { .unwrap_or_default() } -#[get("/system/enterprise/config/server")] -pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { +pub async fn read_enterprise_env_config_server_url(_token: APIToken) -> String { debug!("Trying to read the effective enterprise configuration server URL."); resolve_effective_enterprise_config_source() .configs @@ -216,15 +216,13 @@ pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { .unwrap_or_default() } -#[get("/system/enterprise/config/encryption_secret")] -pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String { +pub async fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String { debug!("Trying to read the effective enterprise configuration encryption secret."); resolve_effective_enterprise_secret_source().encryption_secret } /// Returns all enterprise configurations from the effective source. -#[get("/system/enterprise/configs")] -pub fn read_enterprise_configs(_token: APIToken) -> Json> { +pub async fn read_enterprise_configs(_token: APIToken) -> Json> { info!("Trying to read the effective enterprise configurations."); Json(resolve_effective_enterprise_config_source().configs) } @@ -426,10 +424,9 @@ fn load_policy_values_from_directories(directories: &[PathBuf]) -> HashMap", data = "")] -pub fn select_directory(_token: APIToken, title: &str, previous_directory: Option>) -> Json { - let folder_path = match previous_directory { - Some(previous) => { - let previous_path = previous.path.as_str(); - FileDialogBuilder::new() - .set_title(title) - .set_directory(previous_path) - .pick_folder() - }, - +pub async fn select_directory( + _token: APIToken, + Query(query): Query, + previous_directory: Option>, +) -> Json { + let main_window_lock = MAIN_WINDOW.lock().unwrap(); + let main_window = match main_window_lock.as_ref() { + Some(window) => window, None => { - FileDialogBuilder::new() - .set_title(title) - .pick_folder() - }, + error!(Source = "Tauri"; "Cannot open directory dialog: main window not available."); + return Json(DirectorySelectionResponse { + user_cancelled: true, + selected_directory: String::from(""), + }); + } }; + let mut dialog = main_window.dialog().file().set_parent(main_window).set_title(&query.title); + if let Some(previous) = previous_directory { + dialog = dialog.set_directory(previous.path.clone()); + } + + drop(main_window_lock); + + let folder_path = dialog.blocking_pick_folder(); match folder_path { Some(path) => { - info!("User selected directory: {path:?}"); - Json(DirectorySelectionResponse { - user_cancelled: false, - selected_directory: path.to_str().unwrap().to_string(), - }) + match path.into_path() { + Ok(pb) => { + info!("User selected directory: {pb:?}"); + Json(DirectorySelectionResponse { + user_cancelled: false, + selected_directory: pb.to_string_lossy().to_string(), + }) + } + Err(e) => { + error!(Source = "Tauri"; "Failed to convert directory path: {e}"); + Json(DirectorySelectionResponse { + user_cancelled: true, + selected_directory: String::new(), + }) + } + } }, None => { @@ -98,37 +122,52 @@ pub fn select_directory(_token: APIToken, title: &str, previous_directory: Optio } /// Let the user select a file. -#[post("/select/file", data = "")] -pub fn select_file(_token: APIToken, payload: Json) -> Json { - +pub async fn select_file( + _token: APIToken, + payload: Json, +) -> Json { // Create a new file dialog builder: - let file_dialog = FileDialogBuilder::new(); + let file_dialog = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|w| w.dialog().file().set_parent(w).set_title(&payload.title)); - // Set the title of the file dialog: - let file_dialog = file_dialog.set_title(&payload.title); - - // Set the file type filter if provided: - let file_dialog = apply_filter(file_dialog, &payload.filter); - - // Set the previous file path if provided: - let file_dialog = match &payload.previous_file { - Some(previous) => { - let previous_path = previous.file_path.as_str(); - file_dialog.set_directory(previous_path) - }, - - None => file_dialog, + let Some(mut file_dialog) = file_dialog else { + error!(Source = "Tauri"; "Cannot open file dialog: main window not available."); + return Json(FileSelectionResponse { + user_cancelled: true, + selected_file_path: String::from(""), + }); }; + // Set the file type filter if provided: + file_dialog = apply_filter(file_dialog, &payload.filter); + + // Set the previous file path if provided: + if let Some(previous) = &payload.previous_file { + let previous_path = previous.file_path.as_str(); + file_dialog = file_dialog.set_directory(previous_path); + } + // Show the file dialog and get the selected file path: - let file_path = file_dialog.pick_file(); + let file_path = file_dialog.blocking_pick_file(); match file_path { - Some(path) => { - info!("User selected file: {path:?}"); - Json(FileSelectionResponse { - user_cancelled: false, - selected_file_path: path.to_str().unwrap().to_string(), - }) + Some(path) => match path.into_path() { + Ok(pb) => { + info!("User selected file: {pb:?}"); + Json(FileSelectionResponse { + user_cancelled: false, + selected_file_path: pb.to_string_lossy().to_string(), + }) + } + Err(e) => { + error!(Source = "Tauri"; "Failed to convert file path: {e}"); + Json(FileSelectionResponse { + user_cancelled: true, + selected_file_path: String::new(), + }) + } }, None => { @@ -142,36 +181,43 @@ pub fn select_file(_token: APIToken, payload: Json) -> Json) -> Json { - +pub async fn select_files( + _token: APIToken, + payload: Json, +) -> Json { // Create a new file dialog builder: - let file_dialog = FileDialogBuilder::new(); + let file_dialog = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|w| w.dialog().file().set_parent(w).set_title(&payload.title)); - // Set the title of the file dialog: - let file_dialog = file_dialog.set_title(&payload.title); - - // Set the file type filter if provided: - let file_dialog = apply_filter(file_dialog, &payload.filter); - - // Set the previous file path if provided: - let file_dialog = match &payload.previous_file { - Some(previous) => { - let previous_path = previous.file_path.as_str(); - file_dialog.set_directory(previous_path) - }, - - None => file_dialog, + let Some(mut file_dialog) = file_dialog else { + error!(Source = "Tauri"; "Cannot open file dialog: main window not available."); + return Json(FilesSelectionResponse { + user_cancelled: true, + selected_file_paths: Vec::new(), + }); }; + // Set the file type filter if provided: + file_dialog = apply_filter(file_dialog, &payload.filter); + + // Set the previous file path if provided: + if let Some(previous) = &payload.previous_file { + let previous_path = previous.file_path.as_str(); + file_dialog = file_dialog.set_directory(previous_path); + } + // Show the file dialog and get the selected file path: - let file_paths = file_dialog.pick_files(); + let file_paths = file_dialog.blocking_pick_files(); match file_paths { Some(paths) => { - info!("User selected {} files.", paths.len()); + let converted: Vec = paths.into_iter().filter_map(|p| p.into_path().ok()).map(|pb| pb.to_string_lossy().to_string()).collect(); + info!("User selected {} files.", converted.len()); Json(FilesSelectionResponse { user_cancelled: false, - selected_file_paths: paths.iter().map(|p| p.to_str().unwrap().to_string()).collect(), + selected_file_paths: converted, }) } @@ -185,37 +231,49 @@ pub fn select_files(_token: APIToken, payload: Json) -> Json< } } -#[post("/save/file", data = "")] -pub fn save_file(_token: APIToken, payload: Json) -> Json { - +pub async fn save_file(_token: APIToken, payload: Json) -> Json { // Create a new file dialog builder: - let file_dialog = FileDialogBuilder::new(); + let file_dialog = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|w| w.dialog().file().set_parent(w).set_title(&payload.title)); - // Set the title of the file dialog: - let file_dialog = file_dialog.set_title(&payload.title); - - // Set the file type filter if provided: - let file_dialog = apply_filter(file_dialog, &payload.filter); - - // Set the previous file path if provided: - let file_dialog = match &payload.name_file { - Some(previous) => { - let previous_path = previous.file_path.as_str(); - file_dialog.set_directory(previous_path) - }, - - None => file_dialog, + let Some(mut file_dialog) = file_dialog else { + error!(Source = "Tauri"; "Cannot open save dialog: main window not available."); + return Json(FileSaveResponse { + user_cancelled: true, + save_file_path: String::from(""), + }); }; + // Set the file type filter if provided: + file_dialog = apply_filter(file_dialog, &payload.filter); + + // Set the previous file path if provided: + if let Some(previous) = &payload.name_file { + let previous_path = previous.file_path.as_str(); + file_dialog = file_dialog.set_directory(previous_path); + } + // Displays the file dialogue box and select the file: - let file_path = file_dialog.save_file(); + let file_path = file_dialog.blocking_save_file(); match file_path { - Some(path) => { - info!("User selected file for writing operation: {path:?}"); - Json(FileSaveResponse { - user_cancelled: false, - save_file_path: path.to_str().unwrap().to_string(), - }) + Some(path) => match path.into_path() { + Ok(pb) => { + info!("User selected file for writing operation: {pb:?}"); + Json(FileSaveResponse { + user_cancelled: false, + save_file_path: pb.to_string_lossy().to_string(), + }) + } + Err(e) => { + error!(Source = "Tauri"; "Failed to convert save file path: {e}"); + Json(FileSaveResponse { + user_cancelled: true, + save_file_path: String::new(), + }) + } }, None => { @@ -229,7 +287,7 @@ pub fn save_file(_token: APIToken, payload: Json) -> Json) -> FileDialogBuilder { +fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option) -> FileDialogBuilder { match filter { Some(f) => file_dialog.add_filter( &f.filter_name, diff --git a/runtime/src/file_data.rs b/runtime/src/file_data.rs index b0ba1b24..43446f46 100644 --- a/runtime/src/file_data.rs +++ b/runtime/src/file_data.rs @@ -1,22 +1,24 @@ use std::cmp::min; +use std::convert::Infallible; use crate::api_token::APIToken; use crate::pandoc::PandocProcessBuilder; use crate::pdfium::PdfiumInit; use async_stream::stream; +use axum::extract::Query; +use axum::extract::rejection::QueryRejection; +use axum::response::sse::{Event, Sse}; use base64::{engine::general_purpose, Engine as _}; use calamine::{open_workbook_auto, Reader}; use file_format::{FileFormat, Kind}; use futures::{Stream, StreamExt}; use pdfium_render::prelude::Pdfium; use pptx_to_md::{ImageHandlingMode, ParserConfig, PptxContainer}; -use rocket::get; -use rocket::response::stream::{Event, EventStream}; -use rocket::serde::Serialize; -use rocket::tokio::select; -use rocket::Shutdown; +use serde::{Deserialize, Deserializer, Serialize}; +use serde::de::{Error as SerdeError, Visitor}; use std::path::Path; use std::pin::Pin; -use log::{debug, error}; +use std::fmt; +use log::{debug, error, warn}; use tokio::io::AsyncBufReadExt; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; @@ -82,39 +84,95 @@ const IMAGE_SEGMENT_SIZE_IN_CHARS: usize = 8_192; // equivalent to ~ 5500 token type Result = std::result::Result>; type ChunkStream = Pin> + Send>>; -#[get("/retrieval/fs/extract?&&")] -pub async fn extract_data(_token: APIToken, path: String, stream_id: String, extract_images: bool, mut end: Shutdown) -> EventStream![] { - EventStream! { - let stream_result = stream_data(&path, extract_images).await; - let id_ref = &stream_id; - - match stream_result { - Ok(mut stream) => { - loop { - let chunk = select! { - chunk = stream.next() => match chunk { - Some(Ok(mut chunk)) => { - chunk.set_stream_id(id_ref); - chunk - }, - Some(Err(e)) => { - yield Event::json(&format!("Error: {e}")); - break; - }, - None => break, - }, - _ = &mut end => break, - }; - - yield Event::json(&chunk); - } - }, +#[derive(Deserialize)] +pub struct ExtractDataQuery { + path: String, + stream_id: String, + #[serde(deserialize_with = "deserialize_bool_case_insensitive")] + extract_images: bool, +} - Err(e) => { - yield Event::json(&format!("Error starting stream: {e}")); +fn deserialize_bool_case_insensitive<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + struct BoolVisitor; + + impl<'de> Visitor<'de> for BoolVisitor { + type Value = bool; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a boolean value") + } + + fn visit_bool(self, value: bool) -> std::result::Result { + Ok(value) + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: SerdeError, + { + match value.to_ascii_lowercase().as_str() { + "true" | "1" => Ok(true), + "false" | "0" => Ok(false), + _ => Err(E::invalid_value(serde::de::Unexpected::Str(value), &self)), } } } + + deserializer.deserialize_any(BoolVisitor) +} + +pub async fn extract_data( + _token: APIToken, + query: std::result::Result, QueryRejection>, +) -> Sse>> { + let query = match query { + Ok(Query(query)) => Ok(query), + Err(e) => { + let message = format!("Invalid query for '/retrieval/fs/extract': {e}"); + warn!("{message}"); + Err(message) + }, + }; + + let stream = stream! { + match query { + Ok(query) => { + let stream_result = stream_data(&query.path, query.extract_images).await; + let id_ref = &query.stream_id; + + match stream_result { + Ok(mut stream) => { + while let Some(chunk) = stream.next().await { + match chunk { + Ok(mut chunk) => { + chunk.set_stream_id(id_ref); + yield Ok(Event::default().json_data(&chunk).unwrap_or_else(|e| Event::default().data(format!("Error: {e}")))); + }, + + Err(e) => { + yield Ok(Event::default().json_data(format!("Error: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error: {e}")))); + break; + }, + } + } + }, + + Err(e) => { + yield Ok(Event::default().json_data(format!("Error starting stream: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error starting stream: {e}")))); + } + }; + }, + + Err(e) => { + yield Ok(Event::default().json_data(format!("Error starting stream: {e}")).unwrap_or_else(|_| Event::default().data(format!("Error starting stream: {e}")))); + }, + } + }; + + Sse::new(stream) } async fn stream_data(file_path: &str, extract_images: bool) -> Result { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index d4366e3e..8cab601a 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -19,4 +19,4 @@ pub mod runtime_api_token; pub mod stale_process_cleanup; mod sidecar_types; pub mod tokenizer; -pub mod file_actions; \ No newline at end of file +mod file_actions; diff --git a/runtime/src/log.rs b/runtime/src/log.rs index a38d942c..18f0921a 100644 --- a/runtime/src/log.rs +++ b/runtime/src/log.rs @@ -8,9 +8,8 @@ use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle}; use flexi_logger::writers::FileLogWriter; use log::{kv, Level}; use log::kv::{Key, Value, VisitSource}; -use rocket::{get, post}; -use rocket::serde::json::Json; -use rocket::serde::{Deserialize, Serialize}; +use axum::Json; +use serde::{Deserialize, Serialize}; use crate::api_token::APIToken; use crate::environment::is_dev; @@ -34,14 +33,17 @@ pub fn init_logging() { false => log_config.push_str("info, "), }; - // Set the log level for the Rocket library: - log_config.push_str("rocket=info, "); - - // Set the log level for the Rocket server: - log_config.push_str("rocket::server=warn, "); - - // Set the log level for the Reqwest library: - log_config.push_str("reqwest::async_impl::client=info"); + // Keep noisy HTTP/TLS internals at info level even in development builds: + log_config.push_str("h2=info, "); + log_config.push_str("hyper=info, "); + log_config.push_str("hyper_util=info, "); + log_config.push_str("axum=info, "); + log_config.push_str("axum_server=info, "); + log_config.push_str("tower=info, "); + log_config.push_str("tower_http=info, "); + log_config.push_str("rustls=info, "); + log_config.push_str("tokio_rustls=info, "); + log_config.push_str("reqwest=info"); // Configure the initial filename. On Unix systems, the file should start // with a dot to be hidden. @@ -224,7 +226,6 @@ fn file_logger_format( write!(w, "{}", &record.args()) } -#[get("/log/paths")] pub async fn get_log_paths(_token: APIToken) -> Json { Json(LogPathsResponse { log_startup_path: LOG_STARTUP_PATH.get().expect("No startup log path was set").clone(), @@ -269,9 +270,7 @@ fn log_with_level( } /// Logs an event from the .NET server. -#[post("/log/event", data = "")] -pub fn log_event(_token: APIToken, event: Json) -> Json { - let event = event.into_inner(); +pub async fn log_event(_token: APIToken, Json(event): Json) -> Json { let level = parse_dotnet_log_level(&event.level); let message = event.message.as_str(); let category = event.category.as_str(); diff --git a/runtime/src/main.rs b/runtime/src/main.rs index 04d1b6a1..76b8bb0b 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -1,7 +1,6 @@ // Prevents an additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -extern crate rocket; extern crate core; use log::{info, warn}; @@ -11,6 +10,7 @@ use mindwork_ai_studio::environment::is_dev; use mindwork_ai_studio::log::init_logging; use mindwork_ai_studio::metadata::MetaData; use mindwork_ai_studio::runtime_api::start_runtime_api; +use mindwork_ai_studio::secret::init_secret_store; #[tokio::main] async fn main() { @@ -42,6 +42,7 @@ async fn main() { info!("Running in production mode."); } + init_secret_store(); generate_runtime_certificate(); start_runtime_api(); diff --git a/runtime/src/pandoc.rs b/runtime/src/pandoc.rs index f2dc6a8f..82270059 100644 --- a/runtime/src/pandoc.rs +++ b/runtime/src/pandoc.rs @@ -1,13 +1,16 @@ -use std::path::{Path, PathBuf}; +use std::collections::HashSet; +use std::env; use std::fs; +use std::path::{Path, PathBuf}; use std::sync::OnceLock; -use log::warn; +use log::{info, warn}; use tokio::process::Command; use crate::environment::DATA_DIRECTORY; use crate::metadata::META_DATA; /// Tracks whether the RID mismatch warning has been logged. static HAS_LOGGED_RID_MISMATCH: OnceLock<()> = OnceLock::new(); +static HAS_LOGGED_PANDOC_PATH: OnceLock<()> = OnceLock::new(); pub struct PandocExecutable { pub executable: String, @@ -114,28 +117,42 @@ impl PandocProcessBuilder { // Any local installation should be preferred over the system-wide installation. let data_folder = PathBuf::from(DATA_DIRECTORY.get().unwrap()); let local_installation_root_directory = data_folder.join("pandoc"); + let executable_name = Self::pandoc_executable_name(); - if local_installation_root_directory.exists() { - let executable_name = Self::pandoc_executable_name(); + if local_installation_root_directory.exists() + && let Ok(pandoc_path) = Self::find_executable_in_dir(&local_installation_root_directory, &executable_name) { + HAS_LOGGED_PANDOC_PATH.get_or_init(|| { + info!(Source = "PandocProcessBuilder"; "Found local Pandoc installation at: '{}'.", pandoc_path.to_string_lossy() + ); + }); - if let Ok(entries) = fs::read_dir(&local_installation_root_directory) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if let Ok(pandoc_path) = Self::find_executable_in_dir(&path, &executable_name) { - return PandocExecutable { - executable: pandoc_path.to_string_lossy().to_string(), - is_local_installation: true, - }; - } - } - } + return PandocExecutable { + executable: pandoc_path.to_string_lossy().to_string(), + is_local_installation: true, + }; + } + + for candidate in Self::system_pandoc_executable_candidates(&executable_name) { + if candidate.exists() && candidate.is_file() { + HAS_LOGGED_PANDOC_PATH.get_or_init(|| { + info!(Source = "PandocProcessBuilder"; "Found system Pandoc installation at: '{}'.", candidate.to_string_lossy() + ); + }); + + return PandocExecutable { + executable: candidate.to_string_lossy().to_string(), + is_local_installation: false, + }; } } // When no local installation was found, we assume that the pandoc executable is in the system PATH: + HAS_LOGGED_PANDOC_PATH.get_or_init(|| { + warn!(Source = "PandocProcessBuilder"; "Falling back to system PATH for the Pandoc executable: '{}'.", executable_name); + }); + PandocExecutable { - executable: Self::pandoc_executable_name(), + executable: executable_name, is_local_installation: false, } } @@ -150,10 +167,8 @@ impl PandocProcessBuilder { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); - if path.is_dir() { - if let Ok(found_path) = Self::find_executable_in_dir(&path, executable_name) { - return Ok(found_path); - } + if path.is_dir() && let Ok(found_path) = Self::find_executable_in_dir(&path, executable_name) { + return Ok(found_path); } } } @@ -161,6 +176,56 @@ impl PandocProcessBuilder { Err("Executable not found".into()) } + fn system_pandoc_executable_candidates(executable_name: &str) -> Vec { + let mut candidates: Vec = Vec::new(); + match env::consts::OS { + "windows" => { + Self::push_env_candidate(&mut candidates, "LOCALAPPDATA", &["Pandoc", executable_name]); + Self::push_env_candidate(&mut candidates, "ProgramFiles", &["Pandoc", executable_name]); + Self::push_env_candidate(&mut candidates, "ProgramFiles(x86)", &["Pandoc", executable_name]); + }, + "macos" => { + candidates.push(PathBuf::from("/opt/homebrew/bin").join(executable_name)); + candidates.push(PathBuf::from("/usr/local/bin").join(executable_name)); + candidates.push(PathBuf::from("/usr/bin").join(executable_name)); + }, + "linux" => { + candidates.push(PathBuf::from("/usr/local/bin").join(executable_name)); + candidates.push(PathBuf::from("/usr/bin").join(executable_name)); + candidates.push(PathBuf::from("/snap/bin").join(executable_name)); + + if let Some(home_dir) = env::var_os("HOME") { + candidates.push(PathBuf::from(home_dir).join(".local").join("bin").join(executable_name)); + } + }, + _ => {}, + } + + if let Some(path_value) = env::var_os("PATH") { + for path_dir in env::split_paths(&path_value) { + candidates.push(path_dir.join(executable_name)); + } + } + + let mut seen = HashSet::new(); + candidates + .into_iter() + .filter(|path| seen.insert(path.clone())) + .collect() + } + + fn push_env_candidate(candidates: &mut Vec, env_name: &str, parts: &[&str]) { + if let Some(root) = env::var_os(env_name) { + let mut path = PathBuf::from(root); + + for part in parts { + path.push(part); + } + + candidates.push(path); + } + } + /// Determines the executable name based on the current OS at runtime. /// /// This uses runtime detection instead of metadata to ensure correct behavior @@ -172,33 +237,31 @@ impl PandocProcessBuilder { let runtime_os = std::env::consts::OS; let runtime_arch = std::env::consts::ARCH; - if let Ok(metadata) = META_DATA.lock() { - if let Some(metadata) = metadata.as_ref() { - let metadata_arch = &metadata.architecture; + if let Ok(metadata) = META_DATA.lock() && let Some(metadata) = metadata.as_ref() { + let metadata_arch = &metadata.architecture; - // Determine expected OS from metadata: - let metadata_is_windows = metadata_arch.starts_with("win-"); - let metadata_is_macos = metadata_arch.starts_with("osx-"); - let metadata_is_linux = metadata_arch.starts_with("linux-"); + // Determine expected OS from metadata: + let metadata_is_windows = metadata_arch.starts_with("win-"); + let metadata_is_macos = metadata_arch.starts_with("osx-"); + let metadata_is_linux = metadata_arch.starts_with("linux-"); - // Compare with runtime OS: - let runtime_is_windows = runtime_os == "windows"; - let runtime_is_macos = runtime_os == "macos"; - let runtime_is_linux = runtime_os == "linux"; + // Compare with runtime OS: + let runtime_is_windows = runtime_os == "windows"; + let runtime_is_macos = runtime_os == "macos"; + let runtime_is_linux = runtime_os == "linux"; - let os_mismatch = (metadata_is_windows != runtime_is_windows) - || (metadata_is_macos != runtime_is_macos) - || (metadata_is_linux != runtime_is_linux); + let os_mismatch = (metadata_is_windows != runtime_is_windows) + || (metadata_is_macos != runtime_is_macos) + || (metadata_is_linux != runtime_is_linux); - if os_mismatch { - warn!( - Source = "Pandoc"; - "Runtime-detected OS '{}-{}' differs from metadata architecture '{}'. Using runtime-detected OS. This is expected on dev machines where metadata.txt may be outdated.", - runtime_os, - runtime_arch, - metadata_arch - ); - } + if os_mismatch { + warn!( + Source = "Pandoc"; + "Runtime-detected OS '{}-{}' differs from metadata architecture '{}'. Using runtime-detected OS. This is expected on dev machines where metadata.txt may be outdated.", + runtime_os, + runtime_arch, + metadata_arch + ); } } }); diff --git a/runtime/src/pdfium.rs b/runtime/src/pdfium.rs index 017958c3..98ba4046 100644 --- a/runtime/src/pdfium.rs +++ b/runtime/src/pdfium.rs @@ -1,47 +1,65 @@ +use std::error::Error; use std::sync::Mutex; use once_cell::sync::Lazy; use pdfium_render::prelude::Pdfium; use log::{error, warn}; pub static PDFIUM_LIB_PATH: Lazy>> = Lazy::new(|| Mutex::new(None)); +static PDFIUM: Lazy>> = Lazy::new(|| Mutex::new(None)); pub trait PdfiumInit { - fn ai_studio_init() -> Result>; + fn ai_studio_init() -> Result>; } impl PdfiumInit for Pdfium { /// Initializes the PDFium library for AI Studio. - fn ai_studio_init() -> Result> { - let lib_path = PDFIUM_LIB_PATH.lock().unwrap(); - if let Some(path) = lib_path.as_ref() { - return match Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(path)) { - Ok(binding) => Ok(Pdfium::new(binding)), - Err(library_error) => { - match Pdfium::bind_to_system_library() { - Ok(binding) => Ok(Pdfium::new(binding)), - Err(system_error) => { - error!( - "Failed to load PDFium from '{path}' and the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: library error: '{library_error}'; system error: '{system_error}'." - ); + fn ai_studio_init() -> Result> { + let mut pdfium = PDFIUM.lock().unwrap(); + if let Some(pdfium) = pdfium.as_ref() { + return Ok(pdfium.clone()); + } - Err(Box::new(system_error)) - } + let loaded_pdfium = load_pdfium().map_err(|error| { + Box::new(std::io::Error::other(error)) as Box + })?; + *pdfium = Some(loaded_pdfium.clone()); + + Ok(loaded_pdfium) + } +} + +fn load_pdfium() -> Result { + let lib_path = PDFIUM_LIB_PATH.lock().unwrap().clone(); + if let Some(path) = lib_path.as_ref() { + return match Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(path)) { + Ok(binding) => Ok(Pdfium::new(binding)), + Err(library_error) => { + match Pdfium::bind_to_system_library() { + Ok(binding) => Ok(Pdfium::new(binding)), + Err(system_error) => { + let error_message = format!( + "Failed to load PDFium from '{path}' and the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: library error: '{library_error}'; system error: '{system_error}'." + ); + + error!("{error_message}"); + Err(error_message) } } } } + } - warn!("No custom PDFium library path set; trying to load PDFium from the system library."); - match Pdfium::bind_to_system_library() { - Ok(binding) => Ok(Pdfium::new(binding)), - Err(system_error) => { - error!( - "Failed to load PDFium from the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: '{system_error}'." - ); + warn!("No custom PDFium library path set; trying to load PDFium from the system library."); + match Pdfium::bind_to_system_library() { + Ok(binding) => Ok(Pdfium::new(binding)), + Err(system_error) => { + let error_message = format!( + "Failed to load PDFium from the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: '{system_error}'." + ); - Err(Box::new(system_error)) - } + error!("{error_message}"); + Err(error_message) } } -} +} \ No newline at end of file diff --git a/runtime/src/qdrant.rs b/runtime/src/qdrant.rs index dff8814f..639dd7c7 100644 --- a/runtime/src/qdrant.rs +++ b/runtime/src/qdrant.rs @@ -5,20 +5,23 @@ use std::fs::File; use std::io::Write; use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; use log::{debug, error, info, warn}; use once_cell::sync::Lazy; -use rocket::get; -use rocket::serde::json::Json; -use rocket::serde::Serialize; -use tauri::api::process::{Command, CommandChild, CommandEvent}; +use axum::Json; +use serde::Serialize; use crate::api_token::{APIToken}; use crate::environment::{is_dev, DATA_DIRECTORY}; use crate::certificate_factory::generate_certificate; use std::path::PathBuf; -use tauri::PathResolver; +use tauri::Manager; +use tauri::path::BaseDirectory; use tempfile::{TempDir, Builder}; use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process}; use crate::sidecar_types::SidecarType; +use tokio::time; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; // Qdrant server process started in a separate process and can communicate // via HTTP or gRPC with the .NET server and the runtime process @@ -39,14 +42,24 @@ static API_TOKEN: Lazy = Lazy::new(|| { }); static TMPDIR: Lazy>> = Lazy::new(|| Mutex::new(None)); -static QDRANT_STATUS: Lazy> = Lazy::new(|| Mutex::new(QdrantStatus::default())); +static QDRANT_STATUS: Lazy> = Lazy::new(|| Mutex::new(QdrantStatusInfo::default())); const PID_FILE_NAME: &str = "qdrant.pid"; const SIDECAR_TYPE:SidecarType = SidecarType::Qdrant; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(60); +const STARTUP_CHECK_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)] +enum QdrantStatus { + #[default] + Starting, + Available, + Unavailable, +} #[derive(Default)] -struct QdrantStatus { - is_available: bool, +struct QdrantStatusInfo { + status: QdrantStatus, unavailable_reason: Option, } @@ -59,6 +72,7 @@ fn qdrant_base_path() -> PathBuf { #[derive(Serialize)] pub struct ProvideQdrantInfo { + status: QdrantStatus, path: String, port_http: u16, port_grpc: u16, @@ -68,13 +82,14 @@ pub struct ProvideQdrantInfo { unavailable_reason: Option, } -#[get("/system/qdrant/info")] -pub fn qdrant_port(_token: APIToken) -> Json { +pub async fn qdrant_port(_token: APIToken) -> Json { let status = QDRANT_STATUS.lock().unwrap(); - let is_available = status.is_available; + let current_status = status.status; + let is_available = current_status == QdrantStatus::Available; let unavailable_reason = status.unavailable_reason.clone(); Json(ProvideQdrantInfo { + status: current_status, path: if is_available { qdrant_base_path().to_string_lossy().to_string() } else { @@ -98,14 +113,20 @@ pub fn qdrant_port(_token: APIToken) -> Json { } /// Starts the Qdrant server in a separate process. -pub fn start_qdrant_server(path_resolver: PathResolver){ +pub fn start_qdrant_server(app_handle: tauri::AppHandle){ + set_qdrant_starting(); + tauri::async_runtime::spawn(async move { + cleanup_qdrant(); + start_qdrant_server_internal(app_handle); + }); +} + +fn start_qdrant_server_internal(app_handle: tauri::AppHandle){ let path = qdrant_base_path(); - if !path.exists() { - if let Err(e) = fs::create_dir_all(&path){ - error!(Source="Qdrant"; "The required directory to host the Qdrant database could not be created: {}", e); - set_qdrant_unavailable(format!("The Qdrant data directory could not be created: {e}")); - return; - }; + if !path.exists() && let Err(e) = fs::create_dir_all(&path){ + error!(Source="Qdrant"; "The required directory to host the Qdrant database could not be created: {}", e); + set_qdrant_unavailable(format!("The Qdrant data directory could not be created: {e}")); + return; } let (cert_path, key_path) = match create_temp_tls_files(&path) { @@ -119,12 +140,13 @@ pub fn start_qdrant_server(path_resolver: PathResolver){ let storage_path = path.join("storage").to_string_lossy().to_string(); let snapshot_path = path.join("snapshots").to_string_lossy().to_string(); - let init_path = path.join(".qdrant-initialized").to_string_lossy().to_string(); + let init_path = path.join(".qdrant-initialized"); + let init_path_environment = init_path.to_string_lossy().to_string(); - let qdrant_server_environment = HashMap::from_iter([ + let qdrant_server_environment: HashMap = HashMap::from_iter([ (String::from("QDRANT__SERVICE__HTTP_PORT"), QDRANT_SERVER_PORT_HTTP.to_string()), (String::from("QDRANT__SERVICE__GRPC_PORT"), QDRANT_SERVER_PORT_GRPC.to_string()), - (String::from("QDRANT_INIT_FILE_PATH"), init_path), + (String::from("QDRANT_INIT_FILE_PATH"), init_path_environment), (String::from("QDRANT__STORAGE__STORAGE_PATH"), storage_path), (String::from("QDRANT__STORAGE__SNAPSHOTS_PATH"), snapshot_path), (String::from("QDRANT__TLS__CERT"), cert_path.to_string_lossy().to_string()), @@ -135,9 +157,9 @@ pub fn start_qdrant_server(path_resolver: PathResolver){ let server_spawn_clone = QDRANT_SERVER.clone(); let qdrant_relative_source_path = "resources/databases/qdrant/config.yaml"; - let qdrant_source_path = match path_resolver.resolve_resource(qdrant_relative_source_path) { - Some(path) => path, - None => { + let qdrant_source_path = match app_handle.path().resolve(qdrant_relative_source_path, BaseDirectory::Resource) { + Ok(path) => path, + Err(_) => { let reason = format!("The Qdrant config resource '{qdrant_relative_source_path}' could not be resolved."); error!(Source = "Qdrant"; "{reason} Starting the app without Qdrant."); set_qdrant_unavailable(reason); @@ -147,7 +169,9 @@ pub fn start_qdrant_server(path_resolver: PathResolver){ let qdrant_source_path_display = qdrant_source_path.to_string_lossy().to_string(); tauri::async_runtime::spawn(async move { - let sidecar = match Command::new_sidecar("qdrant") { + let shell = app_handle.shell(); + + let sidecar = match shell.sidecar("qdrant") { Ok(sidecar) => sidecar, Err(e) => { let reason = format!("Failed to create sidecar for Qdrant: {e}"); @@ -172,18 +196,30 @@ pub fn start_qdrant_server(path_resolver: PathResolver){ }; let server_pid = child.pid(); - set_qdrant_available(); info!(Source = "Bootloader Qdrant"; "Qdrant server process started with PID={server_pid}."); log_potential_stale_process(path.join(PID_FILE_NAME), server_pid, SIDECAR_TYPE); // Save the server process to stop it later: *server_spawn_clone.lock().unwrap() = Some(child); + let init_path_clone = init_path.clone(); + tauri::async_runtime::spawn(async move { + if wait_for_qdrant_startup(init_path_clone).await { + set_qdrant_available(); + info!(Source = "Qdrant"; "Qdrant is available."); + } else { + let reason = "Qdrant did not become available within the startup timeout.".to_string(); + error!(Source = "Qdrant"; "{reason}"); + set_qdrant_unavailable(reason); + } + }); + // Log the output of the Qdrant server: while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line) => { - let line = line.trim_end(); + let line_utf8 = String::from_utf8_lossy(&line).to_string(); + let line = line_utf8.trim_end(); if line.contains("INFO") || line.contains("info") { info!(Source = "Qdrant Server"; "{line}"); } else if line.contains("WARN") || line.contains("warning") { @@ -196,12 +232,21 @@ pub fn start_qdrant_server(path_resolver: PathResolver){ }, CommandEvent::Stderr(line) => { - error!(Source = "Qdrant Server (stderr)"; "{line}"); + let line_utf8 = String::from_utf8_lossy(&line).to_string(); + error!(Source = "Qdrant Server (stderr)"; "{line_utf8}"); }, - + _ => {} } } + + let is_available = QDRANT_STATUS.lock().unwrap().status == QdrantStatus::Available; + let unavailable_reason = if is_available { + "Qdrant server process stopped.".to_string() + } else { + "Qdrant server process stopped before it became available.".to_string() + }; + set_qdrant_unavailable(unavailable_reason); }); } @@ -224,6 +269,20 @@ pub fn stop_qdrant_server() { cleanup_qdrant(); } +async fn wait_for_qdrant_startup(init_path: PathBuf) -> bool { + let mut elapsed = Duration::ZERO; + while elapsed < STARTUP_TIMEOUT { + if init_path.exists() { + return true; + } + + time::sleep(STARTUP_CHECK_INTERVAL).await; + elapsed += STARTUP_CHECK_INTERVAL; + } + + false +} + /// Create a temporary directory with TLS relevant files pub fn create_temp_tls_files(path: &PathBuf) -> Result<(PathBuf, PathBuf), Box> { let cert = generate_certificate(); @@ -276,13 +335,19 @@ pub fn cleanup_qdrant() { fn set_qdrant_available() { let mut status = QDRANT_STATUS.lock().unwrap(); - status.is_available = true; + status.status = QdrantStatus::Available; + status.unavailable_reason = None; +} + +fn set_qdrant_starting() { + let mut status = QDRANT_STATUS.lock().unwrap(); + status.status = QdrantStatus::Starting; status.unavailable_reason = None; } fn set_qdrant_unavailable(reason: String) { let mut status = QDRANT_STATUS.lock().unwrap(); - status.is_available = false; + status.status = QdrantStatus::Unavailable; status.unavailable_reason = Some(reason); } diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index e168898a..89f6cec0 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -1,12 +1,16 @@ use log::info; use once_cell::sync::Lazy; -use rocket::config::Shutdown; -use rocket::figment::Figment; -use rocket::routes; +use axum::routing::{get, post}; +use axum::Router; +use axum_server::tls_rustls::RustlsConfig; +use std::net::SocketAddr; +use std::sync::Once; use crate::runtime_certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY}; use crate::environment::is_dev; use crate::network::get_available_port; +static RUSTLS_CRYPTO_PROVIDER_INIT: Once = Once::new(); + /// The port used for the runtime API server. In the development environment, we use a fixed /// port, in the production environment we use the next available port. This differentiation /// is necessary because we cannot communicate the port to the .NET server in the development @@ -24,114 +28,56 @@ pub static API_SERVER_PORT: Lazy = Lazy::new(|| { pub fn start_runtime_api() { let api_port = *API_SERVER_PORT; info!("Try to start the API server on 'http://localhost:{api_port}'..."); - - // Get the shutdown configuration: - let shutdown = create_shutdown(); - // Configure the runtime API server: - let figment = Figment::from(rocket::Config::release_default()) + let app = Router::new() + .route("/system/dotnet/port", get(crate::dotnet::dotnet_port)) + .route("/system/dotnet/ready", get(crate::dotnet::dotnet_ready)) + .route("/system/qdrant/info", get(crate::qdrant::qdrant_port)) + .route("/clipboard/set", post(crate::clipboard::set_clipboard)) + .route("/events", get(crate::app_window::get_event_stream)) + .route("/updates/check", get(crate::app_window::check_for_update)) + .route("/updates/install", get(crate::app_window::install_update)) + .route("/app/exit", post(crate::app_window::exit_app)) + .route("/select/directory", post(crate::file_actions::select_directory)) + .route("/select/file", post(crate::file_actions::select_file)) + .route("/select/files", post(crate::file_actions::select_files)) + .route("/save/file", post(crate::file_actions::save_file)) + .route("/secrets/get", post(crate::secret::get_secret)) + .route("/secrets/store", post(crate::secret::store_secret)) + .route("/secrets/delete", post(crate::secret::delete_secret)) + .route("/system/directories/config", get(crate::environment::get_config_directory)) + .route("/system/directories/data", get(crate::environment::get_data_directory)) + .route("/system/language", get(crate::environment::read_user_language)) + .route("/system/username", get(crate::environment::read_user_name)) + .route("/system/enterprise/config/id", get(crate::environment::read_enterprise_env_config_id)) + .route("/system/enterprise/config/server", get(crate::environment::read_enterprise_env_config_server_url)) + .route("/system/enterprise/config/encryption_secret", get(crate::environment::read_enterprise_env_config_encryption_secret)) + .route("/system/enterprise/configs", get(crate::environment::read_enterprise_configs)) + .route("/retrieval/fs/extract", get(crate::file_data::extract_data)) + .route("/log/paths", get(crate::log::get_log_paths)) + .route("/log/event", post(crate::log::log_event)) + .route("/shortcuts/register", post(crate::app_window::register_shortcut)) + .route("/shortcuts/validate", post(crate::app_window::validate_shortcut)) + .route("/shortcuts/suspend", post(crate::app_window::suspend_shortcuts)) + .route("/shortcuts/resume", post(crate::app_window::resume_shortcuts)); - // We use the next available port which was determined before: - .merge(("port", api_port)) - - // The runtime API server should be accessible only from the local machine: - .merge(("address", "127.0.0.1")) - - // We do not want to use the Ctrl+C signal to stop the server: - .merge(("ctrlc", false)) - - // Set a name for the server: - .merge(("ident", "AI Studio Runtime API")) - - // Set the maximum number of workers and blocking threads: - .merge(("workers", 3)) - .merge(("max_blocking", 12)) - - // No colors and emojis in the log output: - .merge(("cli_colors", false)) - - // Read the TLS certificate and key from the generated certificate data in-memory: - .merge(("tls.certs", CERTIFICATE.get().unwrap())) - .merge(("tls.key", CERTIFICATE_PRIVATE_KEY.get().unwrap())) - - // Set the shutdown configuration: - .merge(("shutdown", shutdown)); - - // - // Start the runtime API server in a separate thread. This is necessary - // because the server is blocking, and we need to run the Tauri app in - // parallel: - // tauri::async_runtime::spawn(async move { - rocket::custom(figment) - .mount("/", routes![ - crate::dotnet::dotnet_port, - crate::dotnet::dotnet_ready, - crate::qdrant::qdrant_port, - crate::clipboard::set_clipboard, - crate::app_window::get_event_stream, - crate::app_window::check_for_update, - crate::app_window::install_update, - crate::file_actions::select_directory, - crate::file_actions::select_file, - crate::file_actions::select_files, - crate::file_actions::save_file, - crate::app_window::exit_app, - crate::secret::get_secret, - crate::secret::store_secret, - crate::secret::delete_secret, - crate::environment::get_data_directory, - crate::environment::get_config_directory, - crate::environment::read_user_language, - crate::environment::read_enterprise_env_config_id, - crate::environment::read_enterprise_env_config_server_url, - crate::environment::read_enterprise_env_config_encryption_secret, - crate::environment::read_enterprise_configs, - crate::file_data::extract_data, - crate::log::get_log_paths, - crate::log::log_event, - crate::tokenizer::token_count, - crate::tokenizer::validate_tokenizer, - crate::tokenizer::store_tokenizer, - crate::tokenizer::delete_tokenizer, - crate::tokenizer::set_tokenizer, - crate::app_window::register_shortcut, - crate::app_window::validate_shortcut, - crate::app_window::suspend_shortcuts, - crate::app_window::resume_shortcuts, - ]) - .ignite().await.unwrap() - .launch().await.unwrap(); + install_rustls_crypto_provider(); + + let cert = CERTIFICATE.get().unwrap().clone(); + let key = CERTIFICATE_PRIVATE_KEY.get().unwrap().clone(); + let tls_config = RustlsConfig::from_pem(cert, key).await.unwrap(); + let addr = SocketAddr::from(([127, 0, 0, 1], api_port)); + + axum_server::bind_rustls(addr, tls_config) + .serve(app.into_make_service()) + .await + .unwrap(); }); } -fn create_shutdown() -> Shutdown { - // - // Create a shutdown configuration, depending on the operating system: - // - #[cfg(unix)] - { - use std::collections::HashSet; - let mut shutdown = Shutdown { - // We do not want to use the Ctrl+C signal to stop the server: - ctrlc: false, - - // Everything else is set to default for now: - ..Shutdown::default() - }; - - shutdown.signals = HashSet::new(); - shutdown - } - - #[cfg(windows)] - { - Shutdown { - // We do not want to use the Ctrl+C signal to stop the server: - ctrlc: false, - - // Everything else is set to default for now: - ..Shutdown::default() - } - } +fn install_rustls_crypto_provider() { + RUSTLS_CRYPTO_PROVIDER_INIT.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); } \ No newline at end of file diff --git a/runtime/src/runtime_api_token.rs b/runtime/src/runtime_api_token.rs index f1e762c9..795f4936 100644 --- a/runtime/src/runtime_api_token.rs +++ b/runtime/src/runtime_api_token.rs @@ -1,33 +1,29 @@ use once_cell::sync::Lazy; -use rocket::http::Status; -use rocket::Request; -use rocket::request::FromRequest; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::StatusCode; use crate::api_token::{generate_api_token, APIToken}; -pub static API_TOKEN: Lazy = Lazy::new(|| generate_api_token()); +pub static API_TOKEN: Lazy = Lazy::new(generate_api_token); -/// The request outcome type used to handle API token requests. -type RequestOutcome = rocket::request::Outcome; +impl FromRequestParts for APIToken +where + S: Send + Sync, +{ + type Rejection = StatusCode; -/// The request outcome implementation for the API token. -#[rocket::async_trait] -impl<'r> FromRequest<'r> for APIToken { - type Error = APITokenError; - - /// Handles the API token requests. - async fn from_request(request: &'r Request<'_>) -> RequestOutcome { - let token = request.headers().get_one("token"); - match token { + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + match parts.headers.get("token").and_then(|value| value.to_str().ok()) { Some(token) => { let received_token = APIToken::from_hex_text(token); if API_TOKEN.validate(&received_token) { - RequestOutcome::Success(received_token) + Ok(received_token) } else { - RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid)) + Err(StatusCode::UNAUTHORIZED) } } - None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)), + None => Err(StatusCode::UNAUTHORIZED), } } } diff --git a/runtime/src/secret.rs b/runtime/src/secret.rs index 5ae07c8b..c587c4d4 100644 --- a/runtime/src/secret.rs +++ b/runtime/src/secret.rs @@ -1,15 +1,45 @@ -use keyring::Entry; +use axum::Json; +use keyring_core::{Entry, Error as KeyringError}; use log::{error, info, warn}; -use rocket::post; -use rocket::serde::json::Json; use serde::{Deserialize, Serialize}; -use keyring::error::Error::NoEntry; use crate::api_token::APIToken; use crate::encryption::{EncryptedText, ENCRYPTION}; +/// Initializes the native credential store used by keyring-core. +pub fn init_secret_store() { + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + match apple_native_keyring_store::keychain::Store::new() { + Ok(store) => { + keyring_core::set_default_store(store); + info!(Source = "Secret Store"; "Initialized the macOS Keychain credential store."); + }, + Err(e) => error!(Source = "Secret Store"; "Failed to initialize the macOS Keychain credential store: {e}."), + } + } else if #[cfg(target_os = "windows")] { + match windows_native_keyring_store::Store::new() { + Ok(store) => { + keyring_core::set_default_store(store); + info!(Source = "Secret Store"; "Initialized the Windows Credential Manager store."); + }, + Err(e) => error!(Source = "Secret Store"; "Failed to initialize the Windows Credential Manager store: {e}."), + } + } else if #[cfg(target_os = "linux")] { + match dbus_secret_service_keyring_store::Store::new() { + Ok(store) => { + keyring_core::set_default_store(store); + info!(Source = "Secret Store"; "Initialized the DBus Secret Service credential store."); + }, + Err(e) => error!(Source = "Secret Store"; "Failed to initialize the DBus Secret Service credential store: {e}."), + } + } else { + warn!(Source = "Secret Store"; "No native credential store is configured for this platform."); + } + } +} + /// Stores a secret in the secret store using the operating system's keyring. -#[post("/secrets/store", data = "")] -pub fn store_secret(_token: APIToken, request: Json) -> Json { +pub async fn store_secret(_token: APIToken, request: Json) -> Json { let user_name = request.user_name.as_str(); let decrypted_text = match ENCRYPTION.decrypt(&request.secret) { Ok(text) => text, @@ -23,7 +53,16 @@ pub fn store_secret(_token: APIToken, request: Json) -> Json entry, + Err(e) => { + error!(Source = "Secret Store"; "Failed to create secret entry for {service} and user {user_name}: {e}."); + return Json(StoreSecretResponse { + success: false, + issue: e.to_string(), + }); + }, + }; let result = entry.set_password(decrypted_text.as_str()); match result { Ok(_) => { @@ -60,11 +99,23 @@ pub struct StoreSecretResponse { } /// Retrieves a secret from the secret store using the operating system's keyring. -#[post("/secrets/get", data = "")] -pub fn get_secret(_token: APIToken, request: Json) -> Json { +pub async fn get_secret(_token: APIToken, request: Json) -> Json { let user_name = request.user_name.as_str(); let service = format!("mindwork-ai-studio::{}", request.destination); - let entry = Entry::new(service.as_str(), user_name).unwrap(); + let entry = match Entry::new(service.as_str(), user_name) { + Ok(entry) => entry, + Err(e) => { + if !request.is_trying { + error!(Source = "Secret Store"; "Failed to create secret entry for '{service}' and user '{user_name}': {e}."); + } + + return Json(RequestedSecret { + success: false, + secret: EncryptedText::new(String::from("")), + issue: format!("Failed to create secret entry for '{service}' and user '{user_name}': {e}"), + }); + }, + }; let secret = entry.get_password(); match secret { Ok(s) => { @@ -121,11 +172,20 @@ pub struct RequestedSecret { } /// Deletes a secret from the secret store using the operating system's keyring. -#[post("/secrets/delete", data = "")] -pub fn delete_secret(_token: APIToken, request: Json) -> Json { +pub async fn delete_secret(_token: APIToken, request: Json) -> Json { let user_name = request.user_name.as_str(); let service = format!("mindwork-ai-studio::{}", request.destination); - let entry = Entry::new(service.as_str(), user_name).unwrap(); + let entry = match Entry::new(service.as_str(), user_name) { + Ok(entry) => entry, + Err(e) => { + error!(Source = "Secret Store"; "Failed to create secret entry for {service} and user {user_name}: {e}."); + return Json(DeleteSecretResponse { + success: false, + was_entry_found: false, + issue: e.to_string(), + }); + }, + }; let result = entry.delete_credential(); match result { @@ -138,7 +198,7 @@ pub fn delete_secret(_token: APIToken, request: Json) -> Json { + Err(KeyringError::NoEntry) => { warn!(Source = "Secret Store"; "No secret for {service} and user {user_name} was found."); Json(DeleteSecretResponse { success: true, diff --git a/runtime/src/stale_process_cleanup.rs b/runtime/src/stale_process_cleanup.rs index 7d177ac8..73e92111 100644 --- a/runtime/src/stale_process_cleanup.rs +++ b/runtime/src/stale_process_cleanup.rs @@ -50,7 +50,7 @@ pub fn kill_stale_process(pid_file_path: PathBuf, sidecar_type: SidecarType) -> let killed = process.kill_with(Signal::Kill).unwrap_or_else(|| process.kill()); if !killed { - return Err(Error::new(ErrorKind::Other, "Failed to kill process")); + return Err(Error::other("Failed to kill process")); } info!(Source="Stale Process Cleanup";"{}: Killed process: \"{}\"", sidecar_type,pid_file_path.display()); } else { diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 40f4cfbd..1e1a96e9 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -1,44 +1,62 @@ { + "productName": "MindWork AI Studio", + "mainBinaryName": "MindWork AI Studio", + "version": "26.5.5", + "identifier": "com.github.mindwork-ai.ai-studio", + "build": { - "devPath": "ui/", - "distDir": "ui/", - "withGlobalTauri": false + "frontendDist": "ui/" }, - "package": { - "productName": "MindWork AI Studio", - "version": "26.4.1" - }, - "tauri": { - "allowlist": { - "all": false, - "shell": { - "sidecar": true, - "all": false, - "open": true, - "scope": [ - { - "name": "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", - "sidecar": true, - "args": true - }, - { - "name": "target/databases/qdrant/qdrant", - "sidecar": true, - "args": true - } - ] - }, - "http" : { - "all": true, - "request": true, - "scope": [ - "http://localhost" - ] - }, - "fs": { - "scope": ["$RESOURCE/resources/*"] + + "bundle": { + "active": true, + "targets": [ + "appimage", + "app", + "dmg", + "nsis" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": [ + "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", + "target/databases/qdrant/qdrant" + ], + "resources": [ + "resources/databases/qdrant/config.yaml", + "resources/libraries/*" + ], + "macOS": { + "exceptionDomain": "localhost" + }, + "linux": { + "appimage": { + "bundleMediaFramework": true } }, + "createUpdaterArtifacts": "v1Compatible" + }, + + "plugins": { + "updater": { + "windows": { + "installMode": "passive" + }, + "endpoints": [ + "https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM3MzE4MTM4RTNDMkM0NEQKUldSTnhNTGpPSUV4TjFkczFxRFJOZWgydzFQN1dmaFlKbXhJS1YyR1RKS1RnR09jYUpMaGsrWXYK" + } + }, + + "app": { + "withGlobalTauri": false, + "windows": [ { "fullscreen": false, @@ -46,51 +64,13 @@ "title": "MindWork AI Studio", "width": 1920, "height": 1080, - "fileDropEnabled": true + "dragDropEnabled": true, + "useHttpsScheme": true } ], + "security": { - "csp": null, - "dangerousRemoteDomainIpcAccess": [ - { - "domain": "localhost", - "windows": ["main"], - "enableTauriAPI": true - } - ] - }, - "bundle": { - "active": true, - "targets": "all", - "identifier": "com.github.mindwork-ai.ai-studio", - "externalBin": [ - "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", - "target/databases/qdrant/qdrant" - ], - "resources": [ - "resources/**" - ], - "macOS": { - "exceptionDomain": "localhost" - }, - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] - }, - "updater": { - "active": true, - "endpoints": [ - "https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json" - ], - "dialog": false, - "windows": { - "installMode": "passive" - }, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM3MzE4MTM4RTNDMkM0NEQKUldSTnhNTGpPSUV4TjFkczFxRFJOZWgydzFQN1dmaFlKbXhJS1YyR1RKS1RnR09jYUpMaGsrWXYK" + "csp": null } } }