diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index b7fb859c..6ae6efca 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -20,6 +20,10 @@ on: type: string required: false default: '["x86_64","aarch64"]' + ref: + type: string + required: false + default: 'refs/heads/main' name: "Reusable workflow to build CLI" @@ -41,6 +45,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 - name: Update version in Cargo.toml if: ${{ inputs.version != '' }} @@ -48,14 +55,8 @@ jobs: sed -i.bak 's/^version = ".*"/version = "'${{ inputs.version }}'"/' Cargo.toml rm -f Cargo.toml.bak - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - target: ${{ matrix.architecture }}-${{ matrix.target-suffix }} - - name: Install cross - run: cargo install cross --git https://github.com/cross-rs/cross + run: source ./bin/activate-hermit && cargo install cross --git https://github.com/cross-rs/cross - name: Build CLI env: @@ -64,6 +65,7 @@ jobs: RUST_BACKTRACE: 1 CROSS_VERBOSE: 1 run: | + source ./bin/activate-hermit export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" rustup target add "${TARGET}" echo "Building for target: ${TARGET}" @@ -72,7 +74,7 @@ jobs: echo "Cross version:" cross --version - # 'cross' is used to cross-compile for different architectures (see Cross.toml) + echo "Building with explicit PROTOC path..." cross build --release --target ${TARGET} -p goose-cli -vv # tar the goose binary as goose-.tar.bz2 @@ -84,4 +86,4 @@ jobs: uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # pin@v4 with: name: goose-${{ matrix.architecture }}-${{ matrix.target-suffix }} - path: ${{ env.ARTIFACT }} + path: ${{ env.ARTIFACT }} \ No newline at end of file diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index e841ac31..b4b9e77b 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -21,6 +21,10 @@ on: required: false default: true type: boolean + ref: + type: string + required: false + default: 'refs/heads/main' secrets: CERTIFICATE_OSX_APPLICATION: description: 'Certificate for macOS application signing' @@ -77,6 +81,9 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 # Update versions before build - name: Update versions @@ -85,20 +92,16 @@ jobs: # Update version in Cargo.toml sed -i.bak 's/^version = ".*"/version = "'${{ inputs.version }}'"/' Cargo.toml rm -f Cargo.toml.bak - - # Update version in package.json + + # Update version in package.json + source ./bin/activate-hermit cd ui/desktop npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - targets: x86_64-apple-darwin - # Pre-build cleanup to ensure enough disk space - name: Pre-build cleanup run: | + source ./bin/activate-hermit echo "Performing pre-build cleanup..." # Clean npm cache npm cache clean --force || true @@ -137,7 +140,7 @@ jobs: # Build specifically for Intel architecture - name: Build goosed for Intel - run: cargo build --release -p goose-server --target x86_64-apple-darwin + run: source ./bin/activate-hermit && cargo build --release -p goose-server --target x86_64-apple-darwin # Post-build cleanup to free space - name: Post-build cleanup @@ -164,13 +167,8 @@ jobs: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} - - name: Set up Node.js - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 - with: - node-version: 'lts/*' - - name: Install dependencies - run: npm ci + run: source ../../bin/activate-hermit && npm ci working-directory: ui/desktop # Configure Electron builder for Intel architecture @@ -187,6 +185,7 @@ jobs: - name: Make Unsigned App if: ${{ !inputs.signing }} run: | + source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do @@ -204,6 +203,7 @@ jobs: - name: Make Signed App if: ${{ inputs.signing }} run: | + source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index bdcbc795..784079cf 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -1,7 +1,7 @@ name: "Bundle Desktop (Windows)" on: -# push: +# push: # branches: [ "main" ] # pull_request: # branches: [ "main" ] @@ -17,32 +17,31 @@ on: required: false WINDOWS_CERTIFICATE_PASSWORD: required: false + ref: + type: string + required: false + default: 'refs/heads/main' jobs: build-desktop-windows: name: Build Desktop (Windows) - runs-on: windows-latest + runs-on: ubuntu-latest # Use Ubuntu for cross-compilation steps: # 1) Check out source - name: Checkout repository uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 - # 2) Set up Rust - - name: Set up Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b - # If you need a specific version, you could do: - # or uses: actions/setup-rust@v1 - # with: - # rust-version: 1.73.0 - - # 3) Set up Node.js + # 2) Set up Node.js - name: Set up Node.js uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # pin@v3 with: - node-version: 16 + node-version: 18 - # 4) Cache dependencies (optional, can add more paths if needed) + # 3) Cache dependencies - name: Cache node_modules uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 with: @@ -53,103 +52,92 @@ jobs: restore-keys: | ${{ runner.os }}-build-desktop-windows- - # 5) Install top-level dependencies if a package.json is in root - - name: Install top-level deps + # 4) Build Rust for Windows using Docker (cross-compilation) + - name: Build Windows executable using Docker run: | - if (Test-Path package.json) { - npm install - } + echo "Building Windows executable using Docker cross-compilation..." + docker volume create goose-windows-cache || true + docker run --rm \ + -v "$(pwd)":/usr/src/myapp \ + -v goose-windows-cache:/usr/local/cargo/registry \ + -w /usr/src/myapp \ + rust:latest \ + sh -c "rustup target add x86_64-pc-windows-gnu && \ + apt-get update && \ + apt-get install -y mingw-w64 protobuf-compiler cmake && \ + export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc && \ + export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ && \ + export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar && \ + export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc && \ + export PKG_CONFIG_ALLOW_CROSS=1 && \ + export PROTOC=/usr/bin/protoc && \ + export PATH=/usr/bin:\$PATH && \ + protoc --version && \ + cargo build --release --target x86_64-pc-windows-gnu && \ + GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && \ + cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ + cp \$GCC_DIR/libgcc_s_seh-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ + cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/" - # 6) Build rust for x86_64-pc-windows-gnu - - name: Install MinGW dependencies - run: | - choco install mingw --version=8.1.0 - # Debug - check installation paths - Write-Host "Checking MinGW installation..." - Get-ChildItem -Path "C:\ProgramData\chocolatey\lib\mingw" -Recurse -Filter "*.dll" | ForEach-Object { - Write-Host $_.FullName - } - Get-ChildItem -Path "C:\tools" -Recurse -Filter "*.dll" | ForEach-Object { - Write-Host $_.FullName - } - - - name: Cargo build for Windows - run: | - cargo build --release --target x86_64-pc-windows-gnu - - # 7) Check that the compiled goosed.exe exists and copy exe/dll to ui/desktop/src/bin + # 5) Prepare Windows binary and DLLs - name: Prepare Windows binary and DLLs run: | - if (!(Test-Path .\target\x86_64-pc-windows-gnu\release\goosed.exe)) { - Write-Error "Windows binary not found."; exit 1; - } - Write-Host "Copying Windows binary and DLLs to ui/desktop/src/bin..." - if (!(Test-Path ui\desktop\src\bin)) { - New-Item -ItemType Directory -Path ui\desktop\src\bin | Out-Null - } - Copy-Item .\target\x86_64-pc-windows-gnu\release\goosed.exe ui\desktop\src\bin\ + if [ ! -f "./target/x86_64-pc-windows-gnu/release/goosed.exe" ]; then + echo "Windows binary not found." + exit 1 + fi - # Copy MinGW DLLs - try both possible locations - $mingwPaths = @( - "C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin", - "C:\tools\mingw64\bin" - ) + echo "Cleaning destination directory..." + rm -rf ./ui/desktop/src/bin + mkdir -p ./ui/desktop/src/bin - foreach ($path in $mingwPaths) { - if (Test-Path "$path\libstdc++-6.dll") { - Write-Host "Found MinGW DLLs in $path" - Copy-Item "$path\libstdc++-6.dll" ui\desktop\src\bin\ - Copy-Item "$path\libgcc_s_seh-1.dll" ui\desktop\src\bin\ - Copy-Item "$path\libwinpthread-1.dll" ui\desktop\src\bin\ - break - } - } + echo "Copying Windows binary and DLLs..." + cp -f ./target/x86_64-pc-windows-gnu/release/goosed.exe ./ui/desktop/src/bin/ + cp -f ./target/x86_64-pc-windows-gnu/release/*.dll ./ui/desktop/src/bin/ - # Copy any other DLLs from the release directory - ls .\target\x86_64-pc-windows-gnu\release\*.dll | ForEach-Object { - Copy-Item $_ ui\desktop\src\bin\ - } + # Copy Windows platform files (tools, scripts, etc.) + if [ -d "./ui/desktop/src/platform/windows/bin" ]; then + echo "Copying Windows platform files..." + for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do + if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then + cp -f "$file" ./ui/desktop/src/bin/ + fi + done + + if [ -d "./ui/desktop/src/platform/windows/bin/goose-npm" ]; then + echo "Setting up npm environment..." + rsync -a --delete ./ui/desktop/src/platform/windows/bin/goose-npm/ ./ui/desktop/src/bin/goose-npm/ + fi + echo "Windows-specific files copied successfully" + fi - # 8) Install & build UI desktop + # 6) Install & build UI desktop - name: Build desktop UI with npm run: | - cd ui\desktop + cd ui/desktop npm install npm run bundle:windows - # 9) Copy exe/dll to final out/Goose-win32-x64/resources/bin + # 7) Copy exe/dll to final out/Goose-win32-x64/resources/bin - name: Copy exe/dll to out folder run: | - cd ui\desktop - if (!(Test-Path .\out\Goose-win32-x64\resources\bin)) { - New-Item -ItemType Directory -Path .\out\Goose-win32-x64\resources\bin | Out-Null - } - Copy-Item .\src\bin\goosed.exe .\out\Goose-win32-x64\resources\bin\ - ls .\src\bin\*.dll | ForEach-Object { - Copy-Item $_ .\out\Goose-win32-x64\resources\bin\ - } + cd ui/desktop + mkdir -p ./out/Goose-win32-x64/resources/bin + rsync -av src/bin/ out/Goose-win32-x64/resources/bin/ - # 10) Code signing (if enabled) + # 8) Code signing (if enabled) - name: Sign Windows executable - # Skip this step by default - enable when we have a certificate if: inputs.signing && inputs.signing == true env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} run: | - # Create a temporary certificate file - $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - $certPath = Join-Path -Path $env:RUNNER_TEMP -ChildPath "certificate.pfx" - [IO.File]::WriteAllBytes($certPath, $certBytes) - - # Sign the main executable - $signtool = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64\signtool.exe" - & $signtool sign /f $certPath /p $env:WINDOWS_CERTIFICATE_PASSWORD /tr http://timestamp.digicert.com /td sha256 /fd sha256 "ui\desktop\out\Goose-win32-x64\Goose.exe" - - # Clean up the certificate - Remove-Item -Path $certPath + # Note: This would need to be adapted for Linux-based signing + # or moved to a Windows runner for the signing step only + echo "Code signing would be implemented here" + echo "Currently skipped as we're running on Ubuntu" - # 11) Upload the final Windows build + # 9) Upload the final Windows build - name: Upload Windows build artifacts uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # pin@v4 with: diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index c4856158..a5875097 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -135,6 +135,7 @@ jobs: sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml rm -f Cargo.toml.bak + source ./bin/activate-hermit # Update version in package.json cd ui/desktop npm version "${VERSION}" --no-git-tag-version --allow-same-version @@ -142,6 +143,7 @@ jobs: # Pre-build cleanup to ensure enough disk space - name: Pre-build cleanup run: | + source ./bin/activate-hermit echo "Performing pre-build cleanup..." # Clean npm cache npm cache clean --force || true @@ -154,16 +156,6 @@ jobs: # Check disk space after cleanup df -h - - name: Install protobuf - run: | - brew install protobuf - echo "PROTOC=$(which protoc)" >> $GITHUB_ENV - - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - - name: Cache Cargo registry uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 with: @@ -190,7 +182,7 @@ jobs: # Build the project - name: Build goosed - run: cargo build --release -p goose-server + run: source ./bin/activate-hermit && cargo build --release -p goose-server # Post-build cleanup to free space - name: Post-build cleanup @@ -216,13 +208,8 @@ jobs: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} - - name: Set up Node.js - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 - with: - node-version: 'lts/*' - - name: Install dependencies - run: npm ci + run: source ../../bin/activate-hermit && npm ci working-directory: ui/desktop # Check disk space before bundling @@ -232,6 +219,7 @@ jobs: - name: Make Unsigned App if: ${{ !inputs.signing }} run: | + source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do @@ -253,6 +241,7 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | + attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37699a1f..982a0beb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: pull_request: branches: - main + merge_group: + branches: + - main workflow_dispatch: name: CI @@ -17,19 +20,14 @@ jobs: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - - name: Run cargo fmt - run: cargo fmt --check + run: source ./bin/activate-hermit && cargo fmt --check rust-build-and-test: name: Build and Test Rust Project runs-on: ubuntu-latest steps: - # Add disk space cleanup before linting + # Add disk space cleanup before linting - name: Check disk space before build run: df -h @@ -50,19 +48,14 @@ jobs: /usr/share/swift df -h - + - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 - name: Install Dependencies run: | sudo apt update -y - sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev protobuf-compiler - - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev - name: Cache Cargo Registry uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 @@ -91,7 +84,7 @@ jobs: - name: Build and Test run: | gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' - cargo test + source ../bin/activate-hermit && cargo test working-directory: crates # Add disk space cleanup before linting @@ -120,7 +113,7 @@ jobs: run: df -h - name: Lint - run: cargo clippy -- -D warnings + run: source ./bin/activate-hermit && cargo clippy -- -D warnings desktop-lint: name: Lint Electron Desktop App @@ -129,22 +122,17 @@ jobs: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 - - name: Set up Node.js - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 - with: - node-version: "lts/*" - - name: Install Dependencies - run: npm ci + run: source ../../bin/activate-hermit && npm ci working-directory: ui/desktop - name: Run Lint - run: npm run lint:check + run: source ../../bin/activate-hermit && npm run lint:check working-directory: ui/desktop # Faster Desktop App build for PRs only bundle-desktop-unsigned: uses: ./.github/workflows/bundle-desktop.yml - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' with: signing: false diff --git a/.github/workflows/pr-comment-build-cli.yml b/.github/workflows/pr-comment-build-cli.yml index e4042c06..158981cf 100644 --- a/.github/workflows/pr-comment-build-cli.yml +++ b/.github/workflows/pr-comment-build-cli.yml @@ -27,6 +27,7 @@ jobs: outputs: continue: ${{ steps.command.outputs.continue || github.event_name == 'workflow_dispatch' }} pr_number: ${{ steps.command.outputs.issue_number || github.event.inputs.pr_number }} + head_sha: ${{ steps.set_head_sha.outputs.head_sha || github.sha }} steps: - if: ${{ github.event_name == 'issue_comment' }} uses: github/command@v1.3.0 @@ -37,10 +38,26 @@ jobs: reaction: "eyes" allowed_contexts: pull_request + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + + - name: Get PR head SHA with gh + id: set_head_sha + run: | + echo "Get PR head SHA with gh" + HEAD_SHA=$(gh pr view "$ISSUE_NUMBER" --json headRefOid -q .headRefOid) + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ steps.command.outputs.issue_number }} + build-cli: needs: [trigger-on-command] if: ${{ needs.trigger-on-command.outputs.continue == 'true' }} uses: ./.github/workflows/build-cli.yml + with: + ref: ${{ needs.trigger-on-command.outputs.head_sha }} pr-comment-cli: name: PR Comment with CLI builds diff --git a/.github/workflows/pr-comment-bundle-intel.yml b/.github/workflows/pr-comment-bundle-intel.yml index b7fa7ca8..24224b1d 100644 --- a/.github/workflows/pr-comment-bundle-intel.yml +++ b/.github/workflows/pr-comment-bundle-intel.yml @@ -30,6 +30,7 @@ jobs: continue: ${{ steps.command.outputs.continue || github.event_name == 'workflow_dispatch' }} # Cannot use github.event.pull_request.number since the trigger is 'issue_comment' pr_number: ${{ steps.command.outputs.issue_number || github.event.inputs.pr_number }} + head_sha: ${{ steps.set_head_sha.outputs.head_sha || github.sha }} steps: - if: ${{ github.event_name == 'issue_comment' }} uses: github/command@319d5236cc34ed2cb72a47c058a363db0b628ebe # pin@v1.3.0 @@ -40,6 +41,20 @@ jobs: reaction: "eyes" allowed_contexts: pull_request + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + + - name: Get PR head SHA with gh + id: set_head_sha + run: | + echo "Get PR head SHA with gh" + HEAD_SHA=$(gh pr view "$ISSUE_NUMBER" --json headRefOid -q .headRefOid) + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ steps.command.outputs.issue_number }} + bundle-desktop-intel: # Only run this if ".bundle-intel" command is detected. needs: [trigger-on-command] @@ -47,6 +62,7 @@ jobs: uses: ./.github/workflows/bundle-desktop-intel.yml with: signing: true + ref: ${{ needs.trigger-on-command.outputs.head_sha }} secrets: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/pr-comment-bundle-windows.yml b/.github/workflows/pr-comment-bundle-windows.yml index 6611863d..a394818e 100644 --- a/.github/workflows/pr-comment-bundle-windows.yml +++ b/.github/workflows/pr-comment-bundle-windows.yml @@ -30,6 +30,7 @@ jobs: continue: ${{ steps.command.outputs.continue || github.event_name == 'workflow_dispatch' }} # Cannot use github.event.pull_request.number since the trigger is 'issue_comment' pr_number: ${{ steps.command.outputs.issue_number || github.event.inputs.pr_number }} + head_sha: ${{ steps.set_head_sha.outputs.head_sha || github.sha }} steps: - if: ${{ github.event_name == 'issue_comment' }} uses: github/command@319d5236cc34ed2cb72a47c058a363db0b628ebe # pin@v1.3.0 @@ -40,6 +41,20 @@ jobs: reaction: "eyes" allowed_contexts: pull_request + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + + - name: Get PR head SHA with gh + id: set_head_sha + run: | + echo "Get PR head SHA with gh" + HEAD_SHA=$(gh pr view "$ISSUE_NUMBER" --json headRefOid -q .headRefOid) + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ steps.command.outputs.issue_number }} + bundle-desktop-windows: # Only run this if ".bundle-windows" command is detected. needs: [trigger-on-command] @@ -47,6 +62,7 @@ jobs: uses: ./.github/workflows/bundle-desktop-windows.yml with: signing: false # false for now as we don't have a cert yet + ref: ${{ needs.trigger-on-command.outputs.head_sha }} secrets: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.gitignore b/.gitignore index 3d5ef353..7a9cea66 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,12 @@ target/ ./ui/desktop/node_modules ./ui/desktop/out +# Generated Goose DLLs (built at build time, not checked in) +ui/desktop/src/bin/goose_ffi.dll +ui/desktop/src/bin/goose_llm.dll + # Hermit -/.hermit/ -/bin/ +.hermit/ debug_*.txt @@ -44,4 +47,4 @@ debug_*.txt benchmark-* benchconf.json scripts/fake.sh -do_not_version/ \ No newline at end of file +do_not_version/ diff --git a/.husky/pre-commit b/.husky/pre-commit index f2796dca..4437aea8 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,12 +2,20 @@ # Only auto-format desktop TS code if relevant files are modified if git diff --cached --name-only | grep -q "^ui/desktop/"; then - . "$(dirname -- "$0")/_/husky.sh" - cd ui/desktop && npx lint-staged + if [ -d "ui/desktop" ]; then + . "$(dirname -- "$0")/_/husky.sh" + cd ui/desktop && npx lint-staged + else + echo "Warning: ui/desktop directory does not exist, skipping lint-staged" + fi fi # Only auto-format ui-v2 TS code if relevant files are modified if git diff --cached --name-only | grep -q "^ui-v2/"; then - . "$(dirname -- "$0")/_/husky.sh" - cd ui-v2 && npx lint-staged + if [ -d "ui-v2" ]; then + . "$(dirname -- "$0")/_/husky.sh" + cd ui-v2 && npx lint-staged + else + echo "Warning: ui-v2 directory does not exist, skipping lint-staged" + fi fi diff --git a/Cargo.lock b/Cargo.lock index dc646e5d..07324dd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2083,8 +2083,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +source = "git+https://github.com/nmathewson/crunchy?branch=cross-compilation-fix#260ec5f08969480c342bb3fe47f88870ed5c6cce" [[package]] name = "crypto-common" @@ -3452,12 +3451,13 @@ dependencies = [ "tokenizers", "tokio", "tokio-cron-scheduler", + "tokio-stream", "tracing", "tracing-subscriber", "url", "utoipa", "uuid", - "webbrowser", + "webbrowser 0.8.15", "winapi", "wiremock", ] @@ -3514,8 +3514,10 @@ version = "1.0.24" dependencies = [ "anyhow", "async-trait", + "axum", "base64 0.22.1", "bat", + "bytes", "chrono", "clap 4.5.31", "cliclack", @@ -3525,6 +3527,8 @@ dependencies = [ "goose", "goose-bench", "goose-mcp", + "http 1.2.0", + "indicatif", "mcp-client", "mcp-core", "mcp-server", @@ -3544,9 +3548,12 @@ dependencies = [ "tempfile", "test-case", "tokio", + "tokio-stream", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", + "webbrowser 1.0.4", "winapi", ] @@ -3639,7 +3646,7 @@ dependencies = [ "url", "urlencoding", "utoipa", - "webbrowser", + "webbrowser 0.8.15", "xcap", ] @@ -3941,6 +3948,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -5902,6 +5915,31 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.0", + "objc2", +] + [[package]] name = "object" version = "0.36.7" @@ -8727,12 +8765,21 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.9.0", "bytes", + "futures-util", "http 1.2.0", "http-body 1.0.1", "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -9420,6 +9467,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webbrowser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5df295f8451142f1856b1bd86a606dfe9587d439bc036e319c827700dbd555e" +dependencies = [ + "core-foundation 0.10.0", + "home", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "0.26.8" diff --git a/Cargo.toml b/Cargo.toml index f44b43e2..89309b36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,8 @@ version = "1.0.24" authors = ["Block "] license = "Apache-2.0" repository = "https://github.com/block/goose" -description = "An AI agent" \ No newline at end of file +description = "An AI agent" + +# Patch for Windows cross-compilation issue with crunchy +[patch.crates-io] +crunchy = { git = "https://github.com/nmathewson/crunchy", branch = "cross-compilation-fix" } \ No newline at end of file diff --git a/Cross.toml b/Cross.toml index fc461fe1..16cd1e3a 100644 --- a/Cross.toml +++ b/Cross.toml @@ -6,29 +6,59 @@ pre-build = [ "dpkg --add-architecture arm64", """\ apt-get update --fix-missing && apt-get install -y \ + curl \ + unzip \ pkg-config \ libssl-dev:arm64 \ libdbus-1-dev:arm64 \ libxcb1-dev:arm64 + """, + """\ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip && \ + unzip -o protoc-31.1-linux-x86_64.zip -d /usr/local && \ + chmod +x /usr/local/bin/protoc && \ + ln -sf /usr/local/bin/protoc /usr/bin/protoc && \ + which protoc && \ + protoc --version """ ] [target.x86_64-unknown-linux-gnu] xargo = false pre-build = [ - # Install necessary dependencies for x86_64 - # We don't need architecture-specific flags because x86_64 dependencies are installable on Ubuntu system """\ apt-get update && apt-get install -y \ + curl \ + unzip \ pkg-config \ libssl-dev \ libdbus-1-dev \ - libxcb1-dev \ + libxcb1-dev + """, + """\ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip && \ + unzip -o protoc-31.1-linux-x86_64.zip -d /usr/local && \ + chmod +x /usr/local/bin/protoc && \ + ln -sf /usr/local/bin/protoc /usr/bin/protoc && \ + which protoc && \ + protoc --version """ ] [target.x86_64-pc-windows-gnu] -image = "dockcross/windows-static-x64:latest" -# Enable verbose output for Windows builds -build-std = true -env = { "RUST_LOG" = "debug", "RUST_BACKTRACE" = "1", "CROSS_VERBOSE" = "1" } +image = "ghcr.io/cross-rs/x86_64-pc-windows-gnu:latest" +env = { "RUST_LOG" = "debug", "RUST_BACKTRACE" = "1", "CROSS_VERBOSE" = "1", "PKG_CONFIG_ALLOW_CROSS" = "1" } +pre-build = [ + """\ + apt-get update && apt-get install -y \ + curl \ + unzip + """, + """\ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip && \ + unzip protoc-31.1-linux-x86_64.zip -d /usr/local && \ + chmod +x /usr/local/bin/protoc && \ + export PROTOC=/usr/local/bin/protoc && \ + protoc --version + """ +] diff --git a/Justfile b/Justfile index 0049be73..fdc38bea 100644 --- a/Justfile +++ b/Justfile @@ -25,7 +25,15 @@ release-windows: rust:latest \ sh -c "rustup target add x86_64-pc-windows-gnu && \ apt-get update && \ - apt-get install -y mingw-w64 && \ + apt-get install -y mingw-w64 protobuf-compiler cmake && \ + export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc && \ + export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ && \ + export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar && \ + export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc && \ + export PKG_CONFIG_ALLOW_CROSS=1 && \ + export PROTOC=/usr/bin/protoc && \ + export PATH=/usr/bin:\$PATH && \ + protoc --version && \ cargo build --release --target x86_64-pc-windows-gnu && \ GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && \ cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ diff --git a/README.md b/README.md index f2baddfe..ab0c9123 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,31 @@ Whether you're prototyping an idea, refining existing code, or managing intricat Designed for maximum flexibility, goose works with any LLM, seamlessly integrates with MCP servers, and is available as both a desktop app as well as CLI - making it the ultimate AI assistant for developers who want to move faster and focus on innovation. +## Multiple Model Configuration + +goose supports using different models for different purposes to optimize performance and cost, which can work across model providers as well as models. + +### Lead/Worker Model Pattern +Use a powerful model for initial planning and complex reasoning, then switch to a faster/cheaper model for execution, this happens automatically by goose: + +```bash +# Required: Enable lead model mode +export GOOSE_LEAD_MODEL=modelY +# Optional: configure a provider for the lead model if not the default provider +export GOOSE_LEAD_PROVIDER=providerX # Defaults to main provider +``` + +### Planning Model Configuration +Use a specialized model for the `/plan` command in CLI mode, this is explicitly invoked when you want to plan (vs execute) + +```bash +# Optional: Use different model for planning +export GOOSE_PLANNER_PROVIDER=openai +export GOOSE_PLANNER_MODEL=gpt-4 +``` + +Both patterns help you balance model capabilities with cost and speed for optimal results, and switch between models and vendors as required. + # Quick Links - [Quickstart](https://block.github.io/goose/docs/quickstart) diff --git a/bin/.node-22.9.0.pkg b/bin/.node-22.9.0.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.node-22.9.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-31.1.pkg b/bin/.protoc-31.1.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.protoc-31.1.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.rustup-1.25.2.pkg b/bin/.rustup-1.25.2.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.rustup-1.25.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md new file mode 100644 index 00000000..e889550b --- /dev/null +++ b/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit new file mode 100755 index 00000000..fe28214d --- /dev/null +++ b/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/bin/activate-hermit.fish b/bin/activate-hermit.fish new file mode 100755 index 00000000..0367d233 --- /dev/null +++ b/bin/activate-hermit.fish @@ -0,0 +1,24 @@ +#!/usr/bin/env fish + +# This file must be sourced with "source bin/activate-hermit.fish" from Fish shell. +# You cannot run it directly. +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if status is-interactive + set BIN_DIR (dirname (status --current-filename)) + + if "$BIN_DIR/hermit" noop > /dev/null + # Source the activation script generated by Hermit + "$BIN_DIR/hermit" activate "$BIN_DIR/.." | source + + # Clear the command cache if applicable + functions -c > /dev/null 2>&1 + + # Display activation message + echo "Hermit environment $($HERMIT_ENV/bin/hermit env HERMIT_ENV) activated" + end +else + echo "You must source this script: source $argv[0]" >&2 + exit 33 +end diff --git a/bin/cargo b/bin/cargo new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-clippy b/bin/cargo-clippy new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo-clippy @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-fmt b/bin/cargo-fmt new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo-fmt @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-miri b/bin/cargo-miri new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo-miri @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/clippy-driver b/bin/clippy-driver new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/clippy-driver @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/corepack b/bin/corepack new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/corepack @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/hermit b/bin/hermit new file mode 100755 index 00000000..31559b7d --- /dev/null +++ b/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="09ed936378857886fd4a7a4878c0f0c7e3d839883f39ca8b4f2f242e3126e1c6" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl new file mode 100644 index 00000000..cc17d794 --- /dev/null +++ b/bin/hermit.hcl @@ -0,0 +1,4 @@ +manage-git = false + +github-token-auth { +} diff --git a/bin/node b/bin/node new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/node @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/npm b/bin/npm new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/npm @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/npx b/bin/npx new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/npx @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/protoc b/bin/protoc new file mode 120000 index 00000000..6bb03478 --- /dev/null +++ b/bin/protoc @@ -0,0 +1 @@ +.protoc-31.1.pkg \ No newline at end of file diff --git a/bin/rls b/bin/rls new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rls @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-analyzer b/bin/rust-analyzer new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-analyzer @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-gdb b/bin/rust-gdb new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-gdb @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-gdbgui b/bin/rust-gdbgui new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-gdbgui @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-lldb b/bin/rust-lldb new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-lldb @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustc b/bin/rustc new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustc @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustdoc b/bin/rustdoc new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustdoc @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustfmt b/bin/rustfmt new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustfmt @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustup b/bin/rustup new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustup @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index dfe73e48..4f4294a6 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -55,6 +55,15 @@ regex = "1.11.1" minijinja = "2.8.0" nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" +# Web server dependencies +axum = { version = "0.8.1", features = ["ws", "macros"] } +tower-http = { version = "0.5", features = ["cors", "fs"] } +tokio-stream = "0.1" +bytes = "1.5" +http = "1.0" +webbrowser = "1.0" + +indicatif = "0.17.11" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/WEB_INTERFACE.md b/crates/goose-cli/WEB_INTERFACE.md new file mode 100644 index 00000000..3665ef97 --- /dev/null +++ b/crates/goose-cli/WEB_INTERFACE.md @@ -0,0 +1,78 @@ +# Goose Web Interface + +The `goose web` command provides a (preview) web-based chat interface for interacting with Goose. +Do not expose this publicly - this is in a preview state as an option. + +## Usage + +```bash +# Start the web server on default port (3000) +goose web + +# Start on a specific port +goose web --port 8080 + +# Start and automatically open in browser +goose web --open + +# Bind to a specific host +goose web --host 0.0.0.0 --port 8080 +``` + +## Features + +- **Real-time chat interface**: Communicate with Goose through a clean web UI +- **WebSocket support**: Real-time message streaming +- **Session management**: Each browser tab maintains its own session +- **Responsive design**: Works on desktop and mobile devices + +## Architecture + +The web interface is built with: +- **Backend**: Rust with Axum web framework +- **Frontend**: Vanilla JavaScript with WebSocket communication +- **Styling**: CSS with dark/light mode support + +## Development Notes + +### Current Implementation + +The web interface provides: +1. A simple chat UI similar to the desktop Electron app +2. WebSocket-based real-time communication +3. Basic session management (messages are stored in memory) + +### Future Enhancements + +- [ ] Persistent session storage +- [ ] Tool call visualization +- [ ] File upload support +- [ ] Multiple session tabs +- [ ] Authentication/authorization +- [ ] Streaming responses with proper formatting +- [ ] Code syntax highlighting +- [ ] Export chat history + +### Integration with Goose Agent + +The web server creates an instance of the Goose Agent and processes messages through the same pipeline as the CLI. However, some features like: +- Extension management +- Tool confirmations +- File system interactions + +...may require additional UI components to be fully functional. + +## Security Considerations + +Currently, the web interface: +- Binds to localhost by default for security +- Does not include authentication (planned for future) +- Should not be exposed to the internet without proper security measures + +## Troubleshooting + +If you encounter issues: + +1. **Port already in use**: Try a different port with `--port` +2. **Cannot connect**: Ensure no firewall is blocking the port +3. **Agent not configured**: Run `goose configure` first to set up a provider \ No newline at end of file diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 4e6deb47..7afc4f03 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -34,7 +34,7 @@ struct Cli { command: Option, } -#[derive(Args)] +#[derive(Args, Debug)] #[group(required = false, multiple = false)] struct Identifier { #[arg( @@ -102,6 +102,19 @@ enum SessionCommand { #[arg(short, long, help = "Regex for removing matched sessions (optional)")] regex: Option, }, + #[command(about = "Export a session to Markdown format")] + Export { + #[command(flatten)] + identifier: Option, + + #[arg( + short, + long, + help = "Output file path (default: stdout)", + long_help = "Path to save the exported Markdown. If not provided, output will be sent to stdout" + )] + output: Option, + }, } #[derive(Subcommand, Debug)] @@ -491,6 +504,31 @@ enum Command { #[command(subcommand)] cmd: BenchCommand, }, + + /// Start a web server with a chat interface + #[command(about = "Start a web server with a chat interface", hide = true)] + Web { + /// Port to run the web server on + #[arg( + short, + long, + default_value = "3000", + help = "Port to run the web server on" + )] + port: u16, + + /// Host to bind the web server to + #[arg( + long, + default_value = "127.0.0.1", + help = "Host to bind the web server to" + )] + host: String, + + /// Open browser automatically + #[arg(long, help = "Open browser automatically when server starts")] + open: bool, + }, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -550,6 +588,23 @@ pub async fn cli() -> Result<()> { handle_session_remove(id, regex)?; return Ok(()); } + Some(SessionCommand::Export { identifier, output }) => { + let session_identifier = if let Some(id) = identifier { + extract_identifier(id) + } else { + // If no identifier is provided, prompt for interactive selection + match crate::commands::session::prompt_interactive_session_selection() { + Ok(id) => id, + Err(e) => { + eprintln!("Error: {}", e); + return Ok(()); + } + } + }; + + crate::commands::session::handle_session_export(session_identifier, output)?; + Ok(()) + } None => { // Run session command by default let mut session: crate::Session = build_session(SessionBuilderConfig { @@ -755,6 +810,10 @@ pub async fn cli() -> Result<()> { } return Ok(()); } + Some(Command::Web { port, host, open }) => { + crate::commands::web::handle_web(port, host, open).await?; + return Ok(()); + } None => { return if !Config::global().exists() { let _ = handle_configure().await; diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index bda22fbd..72ce9be2 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -7,3 +7,4 @@ pub mod recipe; pub mod schedule; pub mod session; pub mod update; +pub mod web; diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index cfe27a47..d25df185 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -34,6 +34,8 @@ pub async fn handle_schedule_add( last_run: None, currently_running: false, paused: false, + current_session_id: None, + process_start_time: None, }; let scheduler_storage_path = diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index add1572d..f3fb97e7 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -1,8 +1,11 @@ +use crate::session::message_to_markdown; use anyhow::{Context, Result}; -use cliclack::{confirm, multiselect}; +use cliclack::{confirm, multiselect, select}; use goose::session::info::{get_session_info, SessionInfo, SortOrder}; +use goose::session::{self, Identifier}; use regex::Regex; use std::fs; +use std::path::{Path, PathBuf}; const TRUNCATED_DESC_LENGTH: usize = 60; @@ -29,7 +32,7 @@ pub fn remove_sessions(sessions: Vec) -> Result<()> { Ok(()) } -fn prompt_interactive_session_selection(sessions: &[SessionInfo]) -> Result> { +fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result> { if sessions.is_empty() { println!("No sessions to delete."); return Ok(vec![]); @@ -105,7 +108,7 @@ pub fn handle_session_remove(id: Option, regex_string: Option) - if all_sessions.is_empty() { return Err(anyhow::anyhow!("No sessions found.")); } - matched_sessions = prompt_interactive_session_selection(&all_sessions)?; + matched_sessions = prompt_interactive_session_removal(&all_sessions)?; } if matched_sessions.is_empty() { @@ -165,3 +168,184 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re } Ok(()) } + +/// Export a session to Markdown without creating a full Session object +/// +/// This function directly reads messages from the session file and converts them to Markdown +/// without creating an Agent or prompting about working directories. +pub fn handle_session_export(identifier: Identifier, output_path: Option) -> Result<()> { + // Get the session file path + let session_file_path = goose::session::get_path(identifier.clone()); + + if !session_file_path.exists() { + return Err(anyhow::anyhow!( + "Session file not found (expected path: {})", + session_file_path.display() + )); + } + + // Read messages directly without using Session + let messages = match goose::session::read_messages(&session_file_path) { + Ok(msgs) => msgs, + Err(e) => { + return Err(anyhow::anyhow!("Failed to read session messages: {}", e)); + } + }; + + // Generate the markdown content using the export functionality + let markdown = export_session_to_markdown(messages, &session_file_path, None); + + // Output the markdown + if let Some(output) = output_path { + fs::write(&output, markdown) + .with_context(|| format!("Failed to write to output file: {}", output.display()))?; + println!("Session exported to {}", output.display()); + } else { + println!("{}", markdown); + } + + Ok(()) +} + +/// Convert a list of messages to markdown format for session export +/// +/// This function handles the formatting of a complete session including headers, +/// message organization, and proper tool request/response pairing. +fn export_session_to_markdown( + messages: Vec, + session_file: &Path, + session_name_override: Option<&str>, +) -> String { + let mut markdown_output = String::new(); + + let session_name = session_name_override.unwrap_or_else(|| { + session_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unnamed Session") + }); + + markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name)); + + if messages.is_empty() { + markdown_output.push_str("*(This session has no messages)*\n"); + return markdown_output; + } + + markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len())); + + // Track if the last message had tool requests to properly handle tool responses + let mut skip_next_if_tool_response = false; + + for message in &messages { + // Check if this is a User message containing only ToolResponses + let is_only_tool_response = message.role == mcp_core::role::Role::User + && message + .content + .iter() + .all(|content| matches!(content, goose::message::MessageContent::ToolResponse(_))); + + // If the previous message had tool requests and this one is just tool responses, + // don't create a new User section - we'll attach the responses to the tool calls + if skip_next_if_tool_response && is_only_tool_response { + // Export the tool responses without a User heading + markdown_output.push_str(&message_to_markdown(message, false)); + markdown_output.push_str("\n\n---\n\n"); + skip_next_if_tool_response = false; + continue; + } + + // Reset the skip flag - we'll update it below if needed + skip_next_if_tool_response = false; + + // Output the role prefix except for tool response-only messages + if !is_only_tool_response { + let role_prefix = match message.role { + mcp_core::role::Role::User => "### User:\n", + mcp_core::role::Role::Assistant => "### Assistant:\n", + }; + markdown_output.push_str(role_prefix); + } + + // Add the message content + markdown_output.push_str(&message_to_markdown(message, false)); + markdown_output.push_str("\n\n---\n\n"); + + // Check if this message has any tool requests, to handle the next message differently + if message + .content + .iter() + .any(|content| matches!(content, goose::message::MessageContent::ToolRequest(_))) + { + skip_next_if_tool_response = true; + } + } + + markdown_output +} + +/// Prompt the user to interactively select a session +/// +/// Shows a list of available sessions and lets the user select one +pub fn prompt_interactive_session_selection() -> Result { + // Get sessions sorted by modification date (newest first) + let sessions = match get_session_info(SortOrder::Descending) { + Ok(sessions) => sessions, + Err(e) => { + tracing::error!("Failed to list sessions: {:?}", e); + return Err(anyhow::anyhow!("Failed to list sessions")); + } + }; + + if sessions.is_empty() { + return Err(anyhow::anyhow!("No sessions found")); + } + + // Build the selection prompt + let mut selector = select("Select a session to export:"); + + // Map to display text + let display_map: std::collections::HashMap = sessions + .iter() + .map(|s| { + let desc = if s.metadata.description.is_empty() { + "(no description)" + } else { + &s.metadata.description + }; + + // Truncate description if too long + let truncated_desc = if desc.len() > 40 { + format!("{}...", &desc[..37]) + } else { + desc.to_string() + }; + + let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id); + (display_text, s.clone()) + }) + .collect(); + + // Add each session as an option + for display_text in display_map.keys() { + selector = selector.item(display_text.clone(), display_text.clone(), ""); + } + + // Add a cancel option + let cancel_value = String::from("cancel"); + selector = selector.item(cancel_value, "Cancel", "Cancel export"); + + // Get user selection + let selected_display_text: String = selector.interact()?; + + if selected_display_text == "cancel" { + return Err(anyhow::anyhow!("Export canceled")); + } + + // Retrieve the selected session + if let Some(session) = display_map.get(&selected_display_text) { + Ok(goose::session::Identifier::Name(session.id.clone())) + } else { + Err(anyhow::anyhow!("Invalid selection")) + } +} diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs new file mode 100644 index 00000000..0ef06290 --- /dev/null +++ b/crates/goose-cli/src/commands/web.rs @@ -0,0 +1,640 @@ +use anyhow::Result; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::{Html, IntoResponse, Response}, + routing::get, + Json, Router, +}; +use futures::{sink::SinkExt, stream::StreamExt}; +use goose::agents::{Agent, AgentEvent}; +use goose::message::Message as GooseMessage; +use goose::session; +use serde::{Deserialize, Serialize}; +use std::{net::SocketAddr, sync::Arc}; +use tokio::sync::{Mutex, RwLock}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::error; + +type SessionStore = Arc>>>>>; +type CancellationStore = Arc>>; + +#[derive(Clone)] +struct AppState { + agent: Arc, + sessions: SessionStore, + cancellations: CancellationStore, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +enum WebSocketMessage { + #[serde(rename = "message")] + Message { + content: String, + session_id: String, + timestamp: i64, + }, + #[serde(rename = "cancel")] + Cancel { session_id: String }, + #[serde(rename = "response")] + Response { + content: String, + role: String, + timestamp: i64, + }, + #[serde(rename = "tool_request")] + ToolRequest { + id: String, + tool_name: String, + arguments: serde_json::Value, + }, + #[serde(rename = "tool_response")] + ToolResponse { + id: String, + result: serde_json::Value, + is_error: bool, + }, + #[serde(rename = "tool_confirmation")] + ToolConfirmation { + id: String, + tool_name: String, + arguments: serde_json::Value, + needs_confirmation: bool, + }, + #[serde(rename = "error")] + Error { message: String }, + #[serde(rename = "thinking")] + Thinking { message: String }, + #[serde(rename = "context_exceeded")] + ContextExceeded { message: String }, + #[serde(rename = "cancelled")] + Cancelled { message: String }, + #[serde(rename = "complete")] + Complete { message: String }, +} + +pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> { + // Setup logging + crate::logging::setup_logging(Some("goose-web"), None)?; + + // Load config and create agent just like the CLI does + let config = goose::config::Config::global(); + + let provider_name: String = match config.get_param("GOOSE_PROVIDER") { + Ok(p) => p, + Err(_) => { + eprintln!("No provider configured. Run 'goose configure' first"); + std::process::exit(1); + } + }; + + let model: String = match config.get_param("GOOSE_MODEL") { + Ok(m) => m, + Err(_) => { + eprintln!("No model configured. Run 'goose configure' first"); + std::process::exit(1); + } + }; + + let model_config = goose::model::ModelConfig::new(model.clone()); + + // Create the agent + let agent = Agent::new(); + let provider = goose::providers::create(&provider_name, model_config)?; + agent.update_provider(provider).await?; + + // Load and enable extensions from config + let extensions = goose::config::ExtensionConfigManager::get_all()?; + for ext_config in extensions { + if ext_config.enabled { + if let Err(e) = agent.add_extension(ext_config.config.clone()).await { + eprintln!( + "Warning: Failed to load extension {}: {}", + ext_config.config.name(), + e + ); + } + } + } + + let state = AppState { + agent: Arc::new(agent), + sessions: Arc::new(RwLock::new(std::collections::HashMap::new())), + cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())), + }; + + // Build router + let app = Router::new() + .route("/", get(serve_index)) + .route("/session/{session_name}", get(serve_session)) + .route("/ws", get(websocket_handler)) + .route("/api/health", get(health_check)) + .route("/api/sessions", get(list_sessions)) + .route("/api/sessions/{session_id}", get(get_session)) + .route("/static/{*path}", get(serve_static)) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .with_state(state); + + let addr: SocketAddr = format!("{}:{}", host, port).parse()?; + + println!("\n🪿 Starting Goose web server"); + println!(" Provider: {} | Model: {}", provider_name, model); + println!( + " Working directory: {}", + std::env::current_dir()?.display() + ); + println!(" Server: http://{}", addr); + println!(" Press Ctrl+C to stop\n"); + + if open { + // Open browser + let url = format!("http://{}", addr); + if let Err(e) = webbrowser::open(&url) { + eprintln!("Failed to open browser: {}", e); + } + } + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn serve_index() -> Html<&'static str> { + Html(include_str!("../../static/index.html")) +} + +async fn serve_session( + axum::extract::Path(session_name): axum::extract::Path, +) -> Html { + let html = include_str!("../../static/index.html"); + // Inject the session name into the HTML so JavaScript can use it + let html_with_session = html.replace( + "", + &format!( + "\n ", + session_name + ) + ); + Html(html_with_session) +} + +async fn serve_static(axum::extract::Path(path): axum::extract::Path) -> Response { + match path.as_str() { + "style.css" => ( + [("content-type", "text/css")], + include_str!("../../static/style.css"), + ) + .into_response(), + "script.js" => ( + [("content-type", "application/javascript")], + include_str!("../../static/script.js"), + ) + .into_response(), + _ => (axum::http::StatusCode::NOT_FOUND, "Not found").into_response(), + } +} + +async fn health_check() -> Json { + Json(serde_json::json!({ + "status": "ok", + "service": "goose-web" + })) +} + +async fn list_sessions() -> Json { + match session::list_sessions() { + Ok(sessions) => { + let session_info: Vec = sessions + .into_iter() + .map(|(name, path)| { + let metadata = session::read_metadata(&path).unwrap_or_default(); + serde_json::json!({ + "name": name, + "path": path, + "description": metadata.description, + "message_count": metadata.message_count, + "working_dir": metadata.working_dir + }) + }) + .collect(); + + Json(serde_json::json!({ + "sessions": session_info + })) + } + Err(e) => Json(serde_json::json!({ + "error": e.to_string() + })), + } +} + +async fn get_session( + axum::extract::Path(session_id): axum::extract::Path, +) -> Json { + let session_file = session::get_path(session::Identifier::Name(session_id)); + + match session::read_messages(&session_file) { + Ok(messages) => { + let metadata = session::read_metadata(&session_file).unwrap_or_default(); + Json(serde_json::json!({ + "metadata": metadata, + "messages": messages + })) + } + Err(e) => Json(serde_json::json!({ + "error": e.to_string() + })), + } +} + +async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + ws.on_upgrade(|socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: AppState) { + let (sender, mut receiver) = socket.split(); + let sender = Arc::new(Mutex::new(sender)); + + while let Some(msg) = receiver.next().await { + if let Ok(msg) = msg { + match msg { + Message::Text(text) => { + match serde_json::from_str::(&text.to_string()) { + Ok(WebSocketMessage::Message { + content, + session_id, + .. + }) => { + // Get session file path from session_id + let session_file = + session::get_path(session::Identifier::Name(session_id.clone())); + + // Get or create session in memory (for fast access during processing) + let session_messages = { + let sessions = state.sessions.read().await; + if let Some(session) = sessions.get(&session_id) { + session.clone() + } else { + drop(sessions); + let mut sessions = state.sessions.write().await; + + // Load existing messages from JSONL file if it exists + let existing_messages = session::read_messages(&session_file) + .unwrap_or_else(|_| Vec::new()); + + let new_session = Arc::new(Mutex::new(existing_messages)); + sessions.insert(session_id.clone(), new_session.clone()); + new_session + } + }; + + // Clone sender for async processing + let sender_clone = sender.clone(); + let agent = state.agent.clone(); + + // Process message in a separate task to allow streaming + let task_handle = tokio::spawn(async move { + let result = process_message_streaming( + &agent, + session_messages, + session_file, + content, + sender_clone, + ) + .await; + + if let Err(e) = result { + error!("Error processing message: {}", e); + } + }); + + // Store the abort handle + { + let mut cancellations = state.cancellations.write().await; + cancellations + .insert(session_id.clone(), task_handle.abort_handle()); + } + + // Wait for task completion and handle abort + let sender_for_abort = sender.clone(); + let session_id_for_cleanup = session_id.clone(); + let cancellations_for_cleanup = state.cancellations.clone(); + + tokio::spawn(async move { + match task_handle.await { + Ok(_) => { + // Task completed normally + } + Err(e) if e.is_cancelled() => { + // Task was aborted + let mut sender = sender_for_abort.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::Cancelled { + message: "Operation cancelled by user" + .to_string(), + }, + ) + .unwrap() + .into(), + )) + .await; + } + Err(e) => { + error!("Task error: {}", e); + } + } + + // Clean up cancellation token + { + let mut cancellations = cancellations_for_cleanup.write().await; + cancellations.remove(&session_id_for_cleanup); + } + }); + } + Ok(WebSocketMessage::Cancel { session_id }) => { + // Cancel the active operation for this session + let abort_handle = { + let mut cancellations = state.cancellations.write().await; + cancellations.remove(&session_id) + }; + + if let Some(handle) = abort_handle { + handle.abort(); + + // Send cancellation confirmation + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Cancelled { + message: "Operation cancelled".to_string(), + }) + .unwrap() + .into(), + )) + .await; + } + } + Ok(_) => { + // Ignore other message types + } + Err(e) => { + error!("Failed to parse WebSocket message: {}", e); + } + } + } + Message::Close(_) => break, + _ => {} + } + } else { + break; + } + } +} + +async fn process_message_streaming( + agent: &Agent, + session_messages: Arc>>, + session_file: std::path::PathBuf, + content: String, + sender: Arc>>, +) -> Result<()> { + use futures::StreamExt; + use goose::agents::SessionConfig; + use goose::message::MessageContent; + use goose::session; + + // Create a user message + let user_message = GooseMessage::user().with_text(content.clone()); + + // Get existing messages from session and add the new user message + let mut messages = { + let mut session_msgs = session_messages.lock().await; + session_msgs.push(user_message.clone()); + session_msgs.clone() + }; + + // Persist messages to JSONL file with provider for automatic description generation + let provider = agent.provider().await; + if provider.is_err() { + let error_msg = "I'm not properly configured yet. Please configure a provider through the CLI first using `goose configure`.".to_string(); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Response { + content: error_msg, + role: "assistant".to_string(), + timestamp: chrono::Utc::now().timestamp_millis(), + }) + .unwrap() + .into(), + )) + .await; + return Ok(()); + } + + let provider = provider.unwrap(); + session::persist_messages(&session_file, &messages, Some(provider.clone())).await?; + + // Create a session config + let session_config = SessionConfig { + id: session::Identifier::Path(session_file.clone()), + working_dir: std::env::current_dir()?, + schedule_id: None, + }; + + // Get response from agent + match agent.reply(&messages, Some(session_config)).await { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(AgentEvent::Message(message)) => { + // Add message to our session + { + let mut session_msgs = session_messages.lock().await; + session_msgs.push(message.clone()); + } + + // Persist messages to JSONL file (no provider needed for assistant messages) + let current_messages = { + let session_msgs = session_messages.lock().await; + session_msgs.clone() + }; + session::persist_messages(&session_file, ¤t_messages, None).await?; + // Handle different message content types + for content in &message.content { + match content { + MessageContent::Text(text) => { + // Send the text response + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Response { + content: text.text.clone(), + role: "assistant".to_string(), + timestamp: chrono::Utc::now().timestamp_millis(), + }) + .unwrap() + .into(), + )) + .await; + } + MessageContent::ToolRequest(req) => { + // Send tool request notification + let mut sender = sender.lock().await; + if let Ok(tool_call) = &req.tool_call { + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ToolRequest { + id: req.id.clone(), + tool_name: tool_call.name.clone(), + arguments: tool_call.arguments.clone(), + }, + ) + .unwrap() + .into(), + )) + .await; + } + } + MessageContent::ToolResponse(_resp) => { + // Tool responses are already included in the complete message stream + // and will be persisted to session history. No need to send separate + // WebSocket messages as this would cause duplicates. + } + MessageContent::ToolConfirmationRequest(confirmation) => { + // Send tool confirmation request + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ToolConfirmation { + id: confirmation.id.clone(), + tool_name: confirmation.tool_name.clone(), + arguments: confirmation.arguments.clone(), + needs_confirmation: true, + }, + ) + .unwrap() + .into(), + )) + .await; + + // For now, auto-approve in web mode + // TODO: Implement proper confirmation UI + agent.handle_confirmation( + confirmation.id.clone(), + goose::permission::PermissionConfirmation { + principal_type: goose::permission::permission_confirmation::PrincipalType::Tool, + permission: goose::permission::Permission::AllowOnce, + } + ).await; + } + MessageContent::Thinking(thinking) => { + // Send thinking indicator + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Thinking { + message: thinking.thinking.clone(), + }) + .unwrap() + .into(), + )) + .await; + } + MessageContent::ContextLengthExceeded(msg) => { + // Send context exceeded notification + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ContextExceeded { + message: msg.msg.clone(), + }, + ) + .unwrap() + .into(), + )) + .await; + + // For now, auto-summarize in web mode + // TODO: Implement proper UI for context handling + let (summarized_messages, _) = + agent.summarize_context(&messages).await?; + messages = summarized_messages; + } + _ => { + // Handle other message types as needed + } + } + } + } + Ok(AgentEvent::McpNotification(_notification)) => { + // Handle MCP notifications if needed + // For now, we'll just log them + tracing::info!("Received MCP notification in web interface"); + } + Err(e) => { + error!("Error in message stream: {}", e); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Error { + message: format!("Error: {}", e), + }) + .unwrap() + .into(), + )) + .await; + break; + } + } + } + } + Err(e) => { + error!("Error calling agent: {}", e); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Error { + message: format!("Error: {}", e), + }) + .unwrap() + .into(), + )) + .await; + } + } + + // Send completion message + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Complete { + message: "Response complete".to_string(), + }) + .unwrap() + .into(), + )) + .await; + + Ok(()) +} + +// Add webbrowser dependency for opening browser +use webbrowser; diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index f7cfeba7..1190220b 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -7,6 +7,7 @@ use goose::session; use goose::session::Identifier; use mcp_client::transport::Error as McpClientError; use std::process; +use std::sync::Arc; use super::output; use super::Session; @@ -55,6 +56,22 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { // Create the agent let agent: Agent = Agent::new(); let new_provider = create(&provider_name, model_config).unwrap(); + + // Keep a reference to the provider for display_session_info + let provider_for_display = Arc::clone(&new_provider); + + // Log model information at startup + if let Some(lead_worker) = new_provider.as_lead_worker() { + let (lead_model, worker_model) = lead_worker.get_model_info(); + tracing::info!( + "🤖 Lead/Worker Mode Enabled: Lead model (first 3 turns): {}, Worker model (turn 4+): {}, Auto-fallback on failures: Enabled", + lead_model, + worker_model + ); + } else { + tracing::info!("🤖 Using model: {}", model); + } + agent .update_provider(new_provider) .await @@ -217,6 +234,12 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { session.agent.override_system_prompt(override_prompt).await; } - output::display_session_info(session_config.resume, &provider_name, &model, &session_file); + output::display_session_info( + session_config.resume, + &provider_name, + &model, + &session_file, + Some(&provider_for_display), + ); session } diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs new file mode 100644 index 00000000..57b83b1e --- /dev/null +++ b/crates/goose-cli/src/session/export.rs @@ -0,0 +1,1095 @@ +use goose::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use mcp_core::content::Content as McpContent; +use mcp_core::resource::ResourceContents; +use mcp_core::role::Role; +use serde_json::Value; + +const MAX_STRING_LENGTH_MD_EXPORT: usize = 4096; // Generous limit for export +const REDACTED_PREFIX_LENGTH: usize = 100; // Show first 100 chars before trimming + +fn value_to_simple_markdown_string(value: &Value, export_full_strings: bool) -> String { + match value { + Value::String(s) => { + if !export_full_strings && s.len() > MAX_STRING_LENGTH_MD_EXPORT { + let prefix = &s[..REDACTED_PREFIX_LENGTH.min(s.len())]; + let trimmed_chars = s.len() - prefix.len(); + format!("`{}[ ... trimmed : {} chars ... ]`", prefix, trimmed_chars) + } else { + // Escape backticks and newlines for inline code. + let escaped = s.replace('`', "\\`").replace("\n", "\\\\n"); + format!("`{}`", escaped) + } + } + Value::Number(n) => n.to_string(), + Value::Bool(b) => format!("*{}*", b), + Value::Null => "_null_".to_string(), + _ => "`[Complex Value]`".to_string(), + } +} + +fn value_to_markdown(value: &Value, depth: usize, export_full_strings: bool) -> String { + let mut md_string = String::new(); + let base_indent_str = " ".repeat(depth); // Basic indentation for nesting + + match value { + Value::Object(map) => { + if map.is_empty() { + md_string.push_str(&format!("{}*empty object*\n", base_indent_str)); + } else { + for (key, val) in map { + md_string.push_str(&format!("{}* **{}**: ", base_indent_str, key)); + match val { + Value::String(s) => { + if s.contains('\n') || s.len() > 80 { + // Heuristic for block + md_string.push_str(&format!( + "\n{} ```\n{}{}\n{} ```\n", + base_indent_str, + base_indent_str, + s.trim(), + base_indent_str + )); + } else { + md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`"))); + } + } + _ => { + // Use recursive call for all values including complex objects/arrays + md_string.push('\n'); + md_string.push_str(&value_to_markdown( + val, + depth + 2, + export_full_strings, + )); + } + } + } + } + } + Value::Array(arr) => { + if arr.is_empty() { + md_string.push_str(&format!("{}* *empty list*\n", base_indent_str)); + } else { + for item in arr { + md_string.push_str(&format!("{}* - ", base_indent_str)); + match item { + Value::String(s) => { + if s.contains('\n') || s.len() > 80 { + // Heuristic for block + md_string.push_str(&format!( + "\n{} ```\n{}{}\n{} ```\n", + base_indent_str, + base_indent_str, + s.trim(), + base_indent_str + )); + } else { + md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`"))); + } + } + _ => { + // Use recursive call for all values including complex objects/arrays + md_string.push('\n'); + md_string.push_str(&value_to_markdown( + item, + depth + 2, + export_full_strings, + )); + } + } + } + } + } + _ => { + md_string.push_str(&format!( + "{}{}\n", + base_indent_str, + value_to_simple_markdown_string(value, export_full_strings) + )); + } + } + md_string +} + +pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> String { + let mut md = String::new(); + match &req.tool_call { + Ok(call) => { + let parts: Vec<_> = call.name.rsplitn(2, "__").collect(); + let (namespace, tool_name_only) = if parts.len() == 2 { + (parts[1], parts[0]) + } else { + ("Tool", parts[0]) + }; + + md.push_str(&format!( + "#### Tool Call: `{}` (namespace: `{}`)\n", + tool_name_only, namespace + )); + md.push_str("**Arguments:**\n"); + + match call.name.as_str() { + "developer__shell" => { + if let Some(Value::String(command)) = call.arguments.get("command") { + md.push_str(&format!( + "* **command**:\n ```sh\n {}\n ```\n", + command.trim() + )); + } + let other_args: serde_json::Map = call + .arguments + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "command") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + if !other_args.is_empty() { + md.push_str(&value_to_markdown( + &Value::Object(other_args), + 0, + export_all_content, + )); + } + } + "developer__text_editor" => { + if let Some(Value::String(path)) = call.arguments.get("path") { + md.push_str(&format!("* **path**: `{}`\n", path)); + } + if let Some(Value::String(code_edit)) = call.arguments.get("code_edit") { + md.push_str(&format!( + "* **code_edit**:\n ```\n{}\n ```\n", + code_edit + )); + } + + let other_args: serde_json::Map = call + .arguments + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "path" && k.as_str() != "code_edit") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + if !other_args.is_empty() { + md.push_str(&value_to_markdown( + &Value::Object(other_args), + 0, + export_all_content, + )); + } + } + _ => { + md.push_str(&value_to_markdown(&call.arguments, 0, export_all_content)); + } + } + } + Err(e) => { + md.push_str(&format!( + "**Error in Tool Call:**\n```\n{} +```\n", + e + )); + } + } + md +} + +pub fn tool_response_to_markdown(resp: &ToolResponse, export_all_content: bool) -> String { + let mut md = String::new(); + md.push_str("#### Tool Response:\n"); + + match &resp.tool_result { + Ok(contents) => { + if contents.is_empty() { + md.push_str("*No textual output from tool.*\n"); + } + + for content in contents { + if !export_all_content { + if let Some(audience) = content.audience() { + if !audience.contains(&Role::Assistant) { + continue; + } + } + } + + match content { + McpContent::Text(text_content) => { + let trimmed_text = text_content.text.trim(); + if (trimmed_text.starts_with('{') && trimmed_text.ends_with('}')) + || (trimmed_text.starts_with('[') && trimmed_text.ends_with(']')) + { + md.push_str(&format!("```json\n{}\n```\n", trimmed_text)); + } else if trimmed_text.starts_with('<') + && trimmed_text.ends_with('>') + && trimmed_text.contains(" { + if image_content.mime_type.starts_with("image/") { + // For actual images, provide a placeholder that indicates it's an image + md.push_str(&format!( + "**Image:** `(type: {}, data: first 30 chars of base64...)`\n\n", + image_content.mime_type + )); + } else { + // For non-image mime types, just indicate it's binary data + md.push_str(&format!( + "**Binary Content:** `(type: {}, length: {} bytes)`\n\n", + image_content.mime_type, + image_content.data.len() + )); + } + } + McpContent::Resource(resource) => { + match &resource.resource { + ResourceContents::TextResourceContents { + uri, + mime_type, + text, + } => { + // Extract file extension from the URI for syntax highlighting + let file_extension = uri.split('.').next_back().unwrap_or(""); + let syntax_type = match file_extension { + "rs" => "rust", + "js" => "javascript", + "ts" => "typescript", + "py" => "python", + "json" => "json", + "yaml" | "yml" => "yaml", + "md" => "markdown", + "html" => "html", + "css" => "css", + "sh" => "bash", + _ => mime_type + .as_ref() + .map(|mime| if mime == "text" { "" } else { mime }) + .unwrap_or(""), + }; + + md.push_str(&format!("**File:** `{}`\n", uri)); + md.push_str(&format!( + "```{}\n{}\n```\n\n", + syntax_type, + text.trim() + )); + } + ResourceContents::BlobResourceContents { + uri, + mime_type, + blob, + } => { + md.push_str(&format!( + "**Binary File:** `{}` (type: {}, {} bytes)\n\n", + uri, + mime_type.as_ref().map(|s| s.as_str()).unwrap_or("unknown"), + blob.len() + )); + } + } + } + } + } + } + Err(e) => { + md.push_str(&format!( + "**Error in Tool Response:**\n```\n{} +```\n", + e + )); + } + } + md +} + +pub fn message_to_markdown(message: &Message, export_all_content: bool) -> String { + let mut md = String::new(); + for content in &message.content { + match content { + MessageContent::Text(text) => { + md.push_str(&text.text); + md.push_str("\n\n"); + } + MessageContent::ToolRequest(req) => { + md.push_str(&tool_request_to_markdown(req, export_all_content)); + md.push('\n'); + } + MessageContent::ToolResponse(resp) => { + md.push_str(&tool_response_to_markdown(resp, export_all_content)); + md.push('\n'); + } + MessageContent::Image(image) => { + md.push_str(&format!( + "**Image:** `(type: {}, data placeholder: {}...)`\n\n", + image.mime_type, + image.data.chars().take(30).collect::() + )); + } + MessageContent::Thinking(thinking) => { + md.push_str("**Thinking:**\n"); + md.push_str("> "); + md.push_str(&thinking.thinking.replace("\n", "\n> ")); + md.push_str("\n\n"); + } + MessageContent::RedactedThinking(_) => { + md.push_str("**Thinking:**\n"); + md.push_str("> *Thinking was redacted*\n\n"); + } + _ => { + md.push_str( + "`WARNING: Message content type could not be rendered to Markdown`\n\n", + ); + } + } + } + md.trim_end_matches("\n").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use goose::message::{Message, ToolRequest, ToolResponse}; + use mcp_core::content::{Content as McpContent, TextContent}; + use mcp_core::tool::ToolCall; + use serde_json::json; + + #[test] + fn test_value_to_simple_markdown_string_normal() { + let value = json!("hello world"); + let result = value_to_simple_markdown_string(&value, true); + assert_eq!(result, "`hello world`"); + } + + #[test] + fn test_value_to_simple_markdown_string_with_backticks() { + let value = json!("hello `world`"); + let result = value_to_simple_markdown_string(&value, true); + assert_eq!(result, "`hello \\`world\\``"); + } + + #[test] + fn test_value_to_simple_markdown_string_long_string_full_export() { + let long_string = "a".repeat(5000); + let value = json!(long_string); + let result = value_to_simple_markdown_string(&value, true); + // When export_full_strings is true, should return full string + assert!(result.starts_with("`")); + assert!(result.ends_with("`")); + assert!(result.contains(&"a".repeat(5000))); + } + + #[test] + fn test_value_to_simple_markdown_string_long_string_trimmed() { + let long_string = "a".repeat(5000); + let value = json!(long_string); + let result = value_to_simple_markdown_string(&value, false); + // When export_full_strings is false, should trim long strings + assert!(result.starts_with("`")); + assert!(result.contains("[ ... trimmed : ")); + assert!(result.contains("4900 chars ... ]`")); + assert!(result.contains(&"a".repeat(100))); // Should contain the prefix + } + + #[test] + fn test_value_to_simple_markdown_string_numbers_and_bools() { + assert_eq!(value_to_simple_markdown_string(&json!(42), true), "42"); + assert_eq!( + value_to_simple_markdown_string(&json!(true), true), + "*true*" + ); + assert_eq!( + value_to_simple_markdown_string(&json!(false), true), + "*false*" + ); + assert_eq!( + value_to_simple_markdown_string(&json!(null), true), + "_null_" + ); + } + + #[test] + fn test_value_to_markdown_empty_object() { + let value = json!({}); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("*empty object*")); + } + + #[test] + fn test_value_to_markdown_empty_array() { + let value = json!([]); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("*empty list*")); + } + + #[test] + fn test_value_to_markdown_simple_object() { + let value = json!({ + "name": "test", + "count": 42, + "active": true + }); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("**name**")); + assert!(result.contains("`test`")); + assert!(result.contains("**count**")); + assert!(result.contains("42")); + assert!(result.contains("**active**")); + assert!(result.contains("*true*")); + } + + #[test] + fn test_value_to_markdown_nested_object() { + let value = json!({ + "user": { + "name": "Alice", + "age": 30 + } + }); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("**user**")); + assert!(result.contains("**name**")); + assert!(result.contains("`Alice`")); + assert!(result.contains("**age**")); + assert!(result.contains("30")); + } + + #[test] + fn test_value_to_markdown_array_with_items() { + let value = json!(["item1", "item2", 42]); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("- `item1`")); + assert!(result.contains("- `item2`")); + // Numbers are handled by recursive call, so they get formatted differently + assert!(result.contains("42")); + } + + #[test] + fn test_tool_request_to_markdown_shell() { + let tool_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "ls -la", + "working_dir": "/home/user" + }), + }; + let tool_request = ToolRequest { + id: "test-id".to_string(), + tool_call: Ok(tool_call), + }; + + let result = tool_request_to_markdown(&tool_request, true); + assert!(result.contains("#### Tool Call: `shell`")); + assert!(result.contains("namespace: `developer`")); + assert!(result.contains("**command**:")); + assert!(result.contains("```sh")); + assert!(result.contains("ls -la")); + assert!(result.contains("**working_dir**")); + } + + #[test] + fn test_tool_request_to_markdown_text_editor() { + let tool_call = ToolCall { + name: "developer__text_editor".to_string(), + arguments: json!({ + "path": "/path/to/file.txt", + "code_edit": "print('Hello World')" + }), + }; + let tool_request = ToolRequest { + id: "test-id".to_string(), + tool_call: Ok(tool_call), + }; + + let result = tool_request_to_markdown(&tool_request, true); + assert!(result.contains("#### Tool Call: `text_editor`")); + assert!(result.contains("**path**: `/path/to/file.txt`")); + assert!(result.contains("**code_edit**:")); + assert!(result.contains("print('Hello World')")); + } + + #[test] + fn test_tool_response_to_markdown_text() { + let text_content = TextContent { + text: "Command executed successfully".to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "test-id".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let result = tool_response_to_markdown(&tool_response, true); + assert!(result.contains("#### Tool Response:")); + assert!(result.contains("Command executed successfully")); + } + + #[test] + fn test_tool_response_to_markdown_json() { + let json_text = r#"{"status": "success", "data": "test"}"#; + let text_content = TextContent { + text: json_text.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "test-id".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let result = tool_response_to_markdown(&tool_response, true); + assert!(result.contains("#### Tool Response:")); + assert!(result.contains("```json")); + assert!(result.contains(json_text)); + } + + #[test] + fn test_message_to_markdown_text() { + let message = Message::user().with_text("Hello, this is a test message"); + + let result = message_to_markdown(&message, true); + assert_eq!(result, "Hello, this is a test message"); + } + + #[test] + fn test_message_to_markdown_with_tool_request() { + let tool_call = ToolCall { + name: "test_tool".to_string(), + arguments: json!({"param": "value"}), + }; + + let message = Message::assistant().with_tool_request("test-id", Ok(tool_call)); + + let result = message_to_markdown(&message, true); + assert!(result.contains("#### Tool Call: `test_tool`")); + assert!(result.contains("**param**")); + } + + #[test] + fn test_message_to_markdown_thinking() { + let message = Message::assistant() + .with_thinking("I need to analyze this problem...", "test-signature"); + + let result = message_to_markdown(&message, true); + assert!(result.contains("**Thinking:**")); + assert!(result.contains("> I need to analyze this problem...")); + } + + #[test] + fn test_message_to_markdown_redacted_thinking() { + let message = Message::assistant().with_redacted_thinking("redacted-data"); + + let result = message_to_markdown(&message, true); + assert!(result.contains("**Thinking:**")); + assert!(result.contains("> *Thinking was redacted*")); + } + + #[test] + fn test_recursive_value_to_markdown() { + // Test that complex nested structures are properly handled with recursion + let value = json!({ + "level1": { + "level2": { + "data": "nested value" + }, + "array": [ + {"item": "first"}, + {"item": "second"} + ] + } + }); + + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("**level1**")); + assert!(result.contains("**level2**")); + assert!(result.contains("**data**")); + assert!(result.contains("`nested value`")); + assert!(result.contains("**array**")); + assert!(result.contains("**item**")); + assert!(result.contains("`first`")); + assert!(result.contains("`second`")); + } + + #[test] + fn test_shell_tool_with_code_output() { + let tool_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "cat main.py" + }), + }; + let tool_request = ToolRequest { + id: "shell-cat".to_string(), + tool_call: Ok(tool_call), + }; + + let python_code = r#"#!/usr/bin/env python3 +def hello_world(): + print("Hello, World!") + +if __name__ == "__main__": + hello_world()"#; + + let text_content = TextContent { + text: python_code.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "shell-cat".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("#### Tool Call: `shell`")); + assert!(request_result.contains("```sh")); + assert!(request_result.contains("cat main.py")); + + // Check response formatting - text content is output as plain text + assert!(response_result.contains("#### Tool Response:")); + assert!(response_result.contains("def hello_world():")); + assert!(response_result.contains("print(\"Hello, World!\")")); + } + + #[test] + fn test_shell_tool_with_git_commands() { + let git_status_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "git status --porcelain" + }), + }; + let tool_request = ToolRequest { + id: "git-status".to_string(), + tool_call: Ok(git_status_call), + }; + + let git_output = " M src/main.rs\n?? temp.txt\n A new_feature.rs"; + let text_content = TextContent { + text: git_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "git-status".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("git status --porcelain")); + assert!(request_result.contains("```sh")); + + // Check response formatting - git output as plain text + assert!(response_result.contains("M src/main.rs")); + assert!(response_result.contains("?? temp.txt")); + } + + #[test] + fn test_shell_tool_with_build_output() { + let cargo_build_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "cargo build" + }), + }; + let _tool_request = ToolRequest { + id: "cargo-build".to_string(), + tool_call: Ok(cargo_build_call), + }; + + let build_output = r#" Compiling goose-cli v0.1.0 (/Users/user/goose) +warning: unused variable `x` + --> src/main.rs:10:9 + | +10 | let x = 5; + | ^ help: if this is intentional, prefix it with an underscore: `_x` + | + = note: `#[warn(unused_variables)]` on by default + + Finished dev [unoptimized + debuginfo] target(s) in 2.45s"#; + + let text_content = TextContent { + text: build_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "cargo-build".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Should format as plain text since it's build output, not code + assert!(response_result.contains("Compiling goose-cli")); + assert!(response_result.contains("warning: unused variable")); + assert!(response_result.contains("Finished dev")); + } + + #[test] + fn test_shell_tool_with_json_api_response() { + let curl_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "curl -s https://api.github.com/repos/microsoft/vscode/releases/latest" + }), + }; + let _tool_request = ToolRequest { + id: "curl-api".to_string(), + tool_call: Ok(curl_call), + }; + + let api_response = r#"{ + "url": "https://api.github.com/repos/microsoft/vscode/releases/90543298", + "tag_name": "1.85.0", + "name": "1.85.0", + "published_at": "2023-12-07T16:54:32Z", + "assets": [ + { + "name": "VSCode-darwin-universal.zip", + "download_count": 123456 + } + ] +}"#; + + let text_content = TextContent { + text: api_response.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "curl-api".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Should detect and format as JSON + assert!(response_result.contains("```json")); + assert!(response_result.contains("\"tag_name\": \"1.85.0\"")); + assert!(response_result.contains("\"download_count\": 123456")); + } + + #[test] + fn test_text_editor_tool_with_code_creation() { + let editor_call = ToolCall { + name: "developer__text_editor".to_string(), + arguments: json!({ + "command": "write", + "path": "/tmp/fibonacci.js", + "file_text": "function fibonacci(n) {\n if (n <= 1) return n;\n return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nconsole.log(fibonacci(10));" + }), + }; + let tool_request = ToolRequest { + id: "editor-write".to_string(), + tool_call: Ok(editor_call), + }; + + let text_content = TextContent { + text: "File created successfully".to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "editor-write".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting - should format code in file_text properly + assert!(request_result.contains("#### Tool Call: `text_editor`")); + assert!(request_result.contains("**path**: `/tmp/fibonacci.js`")); + assert!(request_result.contains("**file_text**:")); + assert!(request_result.contains("function fibonacci(n)")); + assert!(request_result.contains("return fibonacci(n - 1)")); + + // Check response formatting + assert!(response_result.contains("File created successfully")); + } + + #[test] + fn test_text_editor_tool_view_code() { + let editor_call = ToolCall { + name: "developer__text_editor".to_string(), + arguments: json!({ + "command": "view", + "path": "/src/utils.py" + }), + }; + let _tool_request = ToolRequest { + id: "editor-view".to_string(), + tool_call: Ok(editor_call), + }; + + let python_code = r#"import os +import json +from typing import Dict, List, Optional + +def load_config(config_path: str) -> Dict: + """Load configuration from JSON file.""" + if not os.path.exists(config_path): + raise FileNotFoundError(f"Config file not found: {config_path}") + + with open(config_path, 'r') as f: + return json.load(f) + +def process_data(data: List[Dict]) -> List[Dict]: + """Process a list of data dictionaries.""" + return [item for item in data if item.get('active', False)]"#; + + let text_content = TextContent { + text: python_code.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "editor-view".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Text content is output as plain text + assert!(response_result.contains("import os")); + assert!(response_result.contains("def load_config")); + assert!(response_result.contains("typing import Dict")); + } + + #[test] + fn test_shell_tool_with_error_output() { + let error_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "python nonexistent_script.py" + }), + }; + let _tool_request = ToolRequest { + id: "shell-error".to_string(), + tool_call: Ok(error_call), + }; + + let error_output = r#"python: can't open file 'nonexistent_script.py': [Errno 2] No such file or directory +Command failed with exit code 2"#; + + let text_content = TextContent { + text: error_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "shell-error".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Error output should be formatted as plain text + assert!(response_result.contains("can't open file")); + assert!(response_result.contains("Command failed with exit code 2")); + } + + #[test] + fn test_shell_tool_complex_script_execution() { + let script_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "python -c \"import sys; print(f'Python {sys.version}'); [print(f'{i}^2 = {i**2}') for i in range(1, 6)]\"" + }), + }; + let tool_request = ToolRequest { + id: "script-exec".to_string(), + tool_call: Ok(script_call), + }; + + let script_output = r#"Python 3.11.5 (main, Aug 24 2023, 15:18:16) [Clang 14.0.3 ] +1^2 = 1 +2^2 = 4 +3^2 = 9 +4^2 = 16 +5^2 = 25"#; + + let text_content = TextContent { + text: script_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "script-exec".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting for complex command + assert!(request_result.contains("```sh")); + assert!(request_result.contains("python -c")); + assert!(request_result.contains("sys.version")); + + // Check response formatting + assert!(response_result.contains("Python 3.11.5")); + assert!(response_result.contains("1^2 = 1")); + assert!(response_result.contains("5^2 = 25")); + } + + #[test] + fn test_shell_tool_with_multi_command() { + let multi_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "cd /tmp && ls -la | head -5 && pwd" + }), + }; + let _tool_request = ToolRequest { + id: "multi-cmd".to_string(), + tool_call: Ok(multi_call), + }; + + let multi_output = r#"total 24 +drwxrwxrwt 15 root wheel 480 Dec 7 10:30 . +drwxr-xr-x 6 root wheel 192 Nov 15 09:15 .. +-rw-r--r-- 1 user staff 256 Dec 7 09:45 config.json +drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc +/tmp"#; + + let text_content = TextContent { + text: multi_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "multi-cmd".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&_tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting for chained commands + assert!(request_result.contains("cd /tmp && ls -la | head -5 && pwd")); + + // Check response formatting + assert!(response_result.contains("drwxrwxrwt")); + assert!(response_result.contains("config.json")); + assert!(response_result.contains("/tmp")); + } + + #[test] + fn test_developer_tool_grep_code_search() { + let grep_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "rg 'async fn' --type rust -n" + }), + }; + let tool_request = ToolRequest { + id: "grep-search".to_string(), + tool_call: Ok(grep_call), + }; + + let grep_output = r#"src/main.rs:15:async fn process_request(req: Request) -> Result { +src/handler.rs:8:async fn handle_connection(stream: TcpStream) { +src/database.rs:23:async fn query_users(pool: &Pool) -> Result> { +src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Result {"#; + + let text_content = TextContent { + text: grep_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "grep-search".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("rg 'async fn' --type rust -n")); + + // Check response formatting - should be formatted as search results + assert!(response_result.contains("src/main.rs:15:")); + assert!(response_result.contains("async fn process_request")); + assert!(response_result.contains("src/database.rs:23:")); + } + + #[test] + fn test_shell_tool_json_detection_works() { + // This test shows that JSON detection in tool responses DOES work + let tool_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "echo '{\"test\": \"json\"}'" + }), + }; + let _tool_request = ToolRequest { + id: "json-test".to_string(), + tool_call: Ok(tool_call), + }; + + let json_output = r#"{"status": "success", "data": {"count": 42}}"#; + let text_content = TextContent { + text: json_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "json-test".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // JSON should be auto-detected and formatted + assert!(response_result.contains("```json")); + assert!(response_result.contains("\"status\": \"success\"")); + assert!(response_result.contains("\"count\": 42")); + } + + #[test] + fn test_shell_tool_with_package_management() { + let npm_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "npm install express typescript @types/node --save-dev" + }), + }; + let tool_request = ToolRequest { + id: "npm-install".to_string(), + tool_call: Ok(npm_call), + }; + + let npm_output = r#"added 57 packages, and audited 58 packages in 3s + +8 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities"#; + + let text_content = TextContent { + text: npm_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "npm-install".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("npm install express typescript")); + assert!(request_result.contains("--save-dev")); + + // Check response formatting + assert!(response_result.contains("added 57 packages")); + assert!(response_result.contains("found 0 vulnerabilities")); + } +} diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 273ec979..cbabac3c 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1,12 +1,15 @@ mod builder; mod completion; +mod export; mod input; mod output; mod prompt; mod thinking; +pub use self::export::message_to_markdown; pub use builder::{build_session, SessionBuilderConfig}; use console::Color; +use goose::agents::AgentEvent; use goose::permission::permission_confirmation::PrincipalType; use goose::permission::Permission; use goose::permission::PermissionConfirmation; @@ -15,8 +18,7 @@ pub use goose::session::Identifier; use anyhow::{Context, Result}; use completion::GooseCompleter; -use etcetera::choose_app_strategy; -use etcetera::AppStrategy; +use etcetera::{choose_app_strategy, AppStrategy}; use goose::agents::extension::{Envs, ExtensionConfig}; use goose::agents::{Agent, SessionConfig}; use goose::config::Config; @@ -25,6 +27,8 @@ use goose::session; use input::InputResult; use mcp_core::handler::ToolError; use mcp_core::prompt::PromptMessage; +use mcp_core::protocol::JsonRpcMessage; +use mcp_core::protocol::JsonRpcNotification; use rand::{distributions::Alphanumeric, Rng}; use serde_json::Value; @@ -351,9 +355,10 @@ impl Session { // Create and use a global history file in ~/.config/goose directory // This allows command history to persist across different chat sessions // instead of being tied to each individual session's messages - let history_file = choose_app_strategy(crate::APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .in_config_dir("history.txt"); + let strategy = + choose_app_strategy(crate::APP_STRATEGY.clone()).expect("goose requires a home dir"); + let config_dir = strategy.config_dir(); + let history_file = config_dir.join("history.txt"); // Ensure config directory exists if let Some(parent) = history_file.parent() { @@ -379,6 +384,9 @@ impl Session { output::display_greeting(); loop { + // Display context usage before each prompt + self.display_context_usage().await?; + match input::get_input(&mut editor)? { input::InputResult::Message(content) => { match self.run_mode { @@ -713,12 +721,14 @@ impl Session { ) .await?; + let mut progress_bars = output::McpSpinners::new(); + use futures::StreamExt; loop { tokio::select! { result = stream.next() => { match result { - Some(Ok(message)) => { + Some(Ok(AgentEvent::Message(message))) => { // If it's a confirmation request, get approval but otherwise do not render/persist if let Some(MessageContent::ToolConfirmationRequest(confirmation)) = message.content.first() { output::hide_thinking(); @@ -768,56 +778,68 @@ impl Session { } else if let Some(MessageContent::ContextLengthExceeded(_)) = message.content.first() { output::hide_thinking(); - if interactive { - // In interactive mode, ask the user what to do - let prompt = "The model's context length is maxed out. You will need to reduce the # msgs. Do you want to?".to_string(); - let selected_result = cliclack::select(prompt) - .item("clear", "Clear Session", "Removes all messages from Goose's memory") - .item("truncate", "Truncate Messages", "Removes old messages till context is within limits") - .item("summarize", "Summarize Session", "Summarize the session to reduce context length") - .item("cancel", "Cancel", "Cancel and return to chat") - .interact(); + // Check for user-configured default context strategy + let config = Config::global(); + let context_strategy = config.get_param::("GOOSE_CONTEXT_STRATEGY") + .unwrap_or_else(|_| if interactive { "prompt".to_string() } else { "summarize".to_string() }); - let selected = match selected_result { - Ok(s) => s, - Err(e) => { - if e.kind() == std::io::ErrorKind::Interrupted { - "cancel" // If interrupted, set selected to cancel - } else { - return Err(e.into()); - } - } - }; - - match selected { - "clear" => { - self.messages.clear(); - let msg = format!("Session cleared.\n{}", "-".repeat(50)); - output::render_text(&msg, Some(Color::Yellow), true); - break; // exit the loop to hand back control to the user - } - "truncate" => { - // Truncate messages to fit within context length - let (truncated_messages, _) = self.agent.truncate_context(&self.messages).await?; - let msg = format!("Context maxed out\n{}\nGoose tried its best to truncate messages for you.", "-".repeat(50)); - output::render_text("", Some(Color::Yellow), true); - output::render_text(&msg, Some(Color::Yellow), true); - self.messages = truncated_messages; - } - "summarize" => { - // Use the helper function to summarize context - Self::summarize_context_messages(&mut self.messages, &self.agent, "Goose summarized messages for you.").await?; - } - "cancel" => { - break; // Return to main prompt - } - _ => { - unreachable!() + let selected = match context_strategy.as_str() { + "clear" => "clear", + "truncate" => "truncate", + "summarize" => "summarize", + _ => { + if interactive { + // In interactive mode with no default, ask the user what to do + let prompt = "The model's context length is maxed out. You will need to reduce the # msgs. Do you want to?".to_string(); + cliclack::select(prompt) + .item("clear", "Clear Session", "Removes all messages from Goose's memory") + .item("truncate", "Truncate Messages", "Removes old messages till context is within limits") + .item("summarize", "Summarize Session", "Summarize the session to reduce context length") + .interact()? + } else { + // In headless mode, default to summarize + "summarize" } } - } else { - // In headless mode (goose run), automatically use summarize - Self::summarize_context_messages(&mut self.messages, &self.agent, "Goose automatically summarized messages to continue processing.").await?; + }; + + match selected { + "clear" => { + self.messages.clear(); + let msg = if context_strategy == "clear" { + format!("Context maxed out - automatically cleared session.\n{}", "-".repeat(50)) + } else { + format!("Session cleared.\n{}", "-".repeat(50)) + }; + output::render_text(&msg, Some(Color::Yellow), true); + break; // exit the loop to hand back control to the user + } + "truncate" => { + // Truncate messages to fit within context length + let (truncated_messages, _) = self.agent.truncate_context(&self.messages).await?; + let msg = if context_strategy == "truncate" { + format!("Context maxed out - automatically truncated messages.\n{}\nGoose tried its best to truncate messages for you.", "-".repeat(50)) + } else { + format!("Context maxed out\n{}\nGoose tried its best to truncate messages for you.", "-".repeat(50)) + }; + output::render_text("", Some(Color::Yellow), true); + output::render_text(&msg, Some(Color::Yellow), true); + self.messages = truncated_messages; + } + "summarize" => { + // Use the helper function to summarize context + let message_suffix = if context_strategy == "summarize" { + "Goose automatically summarized messages for you." + } else if interactive { + "Goose summarized messages for you." + } else { + "Goose automatically summarized messages to continue processing." + }; + Self::summarize_context_messages(&mut self.messages, &self.agent, message_suffix).await?; + } + _ => { + unreachable!() + } } // Restart the stream after handling ContextLengthExceeded @@ -842,10 +864,55 @@ impl Session { session::persist_messages(&self.session_file, &self.messages, None).await?; if interactive {output::hide_thinking()}; + let _ = progress_bars.hide(); output::render_message(&message, self.debug); if interactive {output::show_thinking()}; } } + Some(Ok(AgentEvent::McpNotification((_id, message)))) => { + if let JsonRpcMessage::Notification(JsonRpcNotification{ + method, + params: Some(Value::Object(o)), + .. + }) = message { + match method.as_str() { + "notifications/message" => { + let data = o.get("data").unwrap_or(&Value::Null); + let message = match data { + Value::String(s) => s.clone(), + Value::Object(o) => { + if let Some(Value::String(output)) = o.get("output") { + output.to_owned() + } else { + data.to_string() + } + }, + v => { + v.to_string() + }, + }; + progress_bars.log(&message); + }, + "notifications/progress" => { + let progress = o.get("progress").and_then(|v| v.as_f64()); + let token = o.get("progressToken").map(|v| v.to_string()); + let message = o.get("message").and_then(|v| v.as_str()); + let total = o + .get("total") + .and_then(|v| v.as_f64()); + if let (Some(progress), Some(token)) = (progress, token) { + progress_bars.update( + token.as_str(), + progress, + total, + message, + ); + } + }, + _ => (), + } + } + } Some(Err(e)) => { eprintln!("Error: {}", e); drop(stream); @@ -872,6 +939,7 @@ impl Session { } } } + Ok(()) } @@ -1054,6 +1122,26 @@ impl Session { Ok(metadata.total_tokens) } + /// Display enhanced context usage with session totals + pub async fn display_context_usage(&self) -> Result<()> { + let provider = self.agent.provider().await?; + let model_config = provider.get_model_config(); + let context_limit = model_config.context_limit.unwrap_or(32000); + + match self.get_metadata() { + Ok(metadata) => { + let total_tokens = metadata.total_tokens.unwrap_or(0) as usize; + + output::display_context_usage(total_tokens, context_limit); + } + Err(_) => { + output::display_context_usage(0, context_limit); + } + } + + Ok(()) + } + /// Handle prompt command execution async fn handle_prompt_command(&mut self, opts: input::PromptCommandOptions) -> Result<()> { // name is required diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 873eec01..525faa74 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -2,12 +2,16 @@ use bat::WrappingMode; use console::{style, Color}; use goose::config::Config; use goose::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use mcp_core::prompt::PromptArgument; use mcp_core::tool::ToolCall; use serde_json::Value; use std::cell::RefCell; use std::collections::HashMap; +use std::io::Error; use std::path::Path; +use std::sync::Arc; +use std::time::Duration; // Re-export theme for use in main #[derive(Clone, Copy)] @@ -144,6 +148,10 @@ pub fn render_message(message: &Message, debug: bool) { } pub fn render_text(text: &str, color: Option, dim: bool) { + render_text_no_newlines(format!("\n{}\n\n", text).as_str(), color, dim); +} + +pub fn render_text_no_newlines(text: &str, color: Option, dim: bool) { let mut styled_text = style(text); if dim { styled_text = styled_text.dim(); @@ -153,7 +161,7 @@ pub fn render_text(text: &str, color: Option, dim: bool) { } else { styled_text = styled_text.green(); } - println!("\n{}\n", styled_text); + print!("{}", styled_text); } pub fn render_enter_plan_mode() { @@ -359,7 +367,6 @@ fn render_shell_request(call: &ToolCall, debug: bool) { } _ => print_params(&call.arguments, 0, debug), } - println!(); } fn render_default_request(call: &ToolCall, debug: bool) { @@ -530,7 +537,13 @@ fn shorten_path(path: &str, debug: bool) -> String { } // Session display functions -pub fn display_session_info(resume: bool, provider: &str, model: &str, session_file: &Path) { +pub fn display_session_info( + resume: bool, + provider: &str, + model: &str, + session_file: &Path, + provider_instance: Option<&Arc>, +) { let start_session_msg = if resume { "resuming session |" } else if session_file.to_str() == Some("/dev/null") || session_file.to_str() == Some("NUL") { @@ -538,14 +551,42 @@ pub fn display_session_info(resume: bool, provider: &str, model: &str, session_f } else { "starting session |" }; - println!( - "{} {} {} {} {}", - style(start_session_msg).dim(), - style("provider:").dim(), - style(provider).cyan().dim(), - style("model:").dim(), - style(model).cyan().dim(), - ); + + // Check if we have lead/worker mode + if let Some(provider_inst) = provider_instance { + if let Some(lead_worker) = provider_inst.as_lead_worker() { + let (lead_model, worker_model) = lead_worker.get_model_info(); + println!( + "{} {} {} {} {} {} {}", + style(start_session_msg).dim(), + style("provider:").dim(), + style(provider).cyan().dim(), + style("lead model:").dim(), + style(&lead_model).cyan().dim(), + style("worker model:").dim(), + style(&worker_model).cyan().dim(), + ); + } else { + println!( + "{} {} {} {} {}", + style(start_session_msg).dim(), + style("provider:").dim(), + style(provider).cyan().dim(), + style("model:").dim(), + style(model).cyan().dim(), + ); + } + } else { + // Fallback to original behavior if no provider instance + println!( + "{} {} {} {} {}", + style(start_session_msg).dim(), + style("provider:").dim(), + style(provider).cyan().dim(), + style("model:").dim(), + style(model).cyan().dim(), + ); + } if session_file.to_str() != Some("/dev/null") && session_file.to_str() != Some("NUL") { println!( @@ -568,6 +609,102 @@ pub fn display_greeting() { println!("\nGoose is running! Enter your instructions, or try asking what goose can do.\n"); } +/// Display context window usage with both current and session totals +pub fn display_context_usage(total_tokens: usize, context_limit: usize) { + use console::style; + + // Calculate percentage used + let percentage = (total_tokens as f64 / context_limit as f64 * 100.0).round() as usize; + + // Create dot visualization + let dot_count = 10; + let filled_dots = ((percentage as f64 / 100.0) * dot_count as f64).round() as usize; + let empty_dots = dot_count - filled_dots; + + let filled = "●".repeat(filled_dots); + let empty = "○".repeat(empty_dots); + + // Combine dots and apply color + let dots = format!("{}{}", filled, empty); + let colored_dots = if percentage < 50 { + style(dots).green() + } else if percentage < 85 { + style(dots).yellow() + } else { + style(dots).red() + }; + + // Print the status line + println!( + "Context: {} {}% ({}/{} tokens)", + colored_dots, percentage, total_tokens, context_limit + ); +} + +pub struct McpSpinners { + bars: HashMap, + log_spinner: Option, + + multi_bar: MultiProgress, +} + +impl McpSpinners { + pub fn new() -> Self { + McpSpinners { + bars: HashMap::new(), + log_spinner: None, + multi_bar: MultiProgress::new(), + } + } + + pub fn log(&mut self, message: &str) { + let spinner = self.log_spinner.get_or_insert_with(|| { + let bar = self.multi_bar.add( + ProgressBar::new_spinner() + .with_style( + ProgressStyle::with_template("{spinner:.green} {msg}") + .unwrap() + .tick_chars("⠋⠙⠚⠛⠓⠒⠊⠉"), + ) + .with_message(message.to_string()), + ); + bar.enable_steady_tick(Duration::from_millis(100)); + bar + }); + + spinner.set_message(message.to_string()); + } + + pub fn update(&mut self, token: &str, value: f64, total: Option, message: Option<&str>) { + let bar = self.bars.entry(token.to_string()).or_insert_with(|| { + if let Some(total) = total { + self.multi_bar.add( + ProgressBar::new((total * 100.0) as u64).with_style( + ProgressStyle::with_template("[{elapsed}] {bar:40} {pos:>3}/{len:3} {msg}") + .unwrap(), + ), + ) + } else { + self.multi_bar.add(ProgressBar::new_spinner()) + } + }); + bar.set_position((value * 100.0) as u64); + if let Some(msg) = message { + bar.set_message(msg.to_string()); + } + } + + pub fn hide(&mut self) -> Result<(), Error> { + self.bars.iter_mut().for_each(|(_, bar)| { + bar.disable_steady_tick(); + }); + if let Some(spinner) = self.log_spinner.as_mut() { + spinner.disable_steady_tick(); + } + self.multi_bar.clear() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/goose-cli/static/index.html b/crates/goose-cli/static/index.html new file mode 100644 index 00000000..f52b03bf --- /dev/null +++ b/crates/goose-cli/static/index.html @@ -0,0 +1,46 @@ + + + + + + Goose Chat + + + +
+
+

Goose Chat

+
Connecting...
+
+ +
+
+
+

Welcome to Goose!

+

I'm your AI assistant. How can I help you today?

+ +
+
What can you do?
+
Demo writing and reading files
+
Make a snake game in a new folder
+
List files in my current directory
+
Take a screenshot and summarize
+
+
+
+ +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/crates/goose-cli/static/script.js b/crates/goose-cli/static/script.js new file mode 100644 index 00000000..3cc9aa99 --- /dev/null +++ b/crates/goose-cli/static/script.js @@ -0,0 +1,523 @@ +// WebSocket connection and chat functionality +let socket = null; +let sessionId = getSessionId(); +let isConnected = false; + +// DOM elements +const messagesContainer = document.getElementById('messages'); +const messageInput = document.getElementById('message-input'); +const sendButton = document.getElementById('send-button'); +const connectionStatus = document.getElementById('connection-status'); + +// Track if we're currently processing +let isProcessing = false; + +// Get session ID - either from URL parameter, injected session name, or generate new one +function getSessionId() { + // Check if session name was injected by server (for /session/:name routes) + if (window.GOOSE_SESSION_NAME) { + return window.GOOSE_SESSION_NAME; + } + + // Check URL parameters + const urlParams = new URLSearchParams(window.location.search); + const sessionParam = urlParams.get('session') || urlParams.get('name'); + if (sessionParam) { + return sessionParam; + } + + // Generate new session ID using CLI format + return generateSessionId(); +} + +// Generate a session ID using timestamp format (yyyymmdd_hhmmss) like CLI +function generateSessionId() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hour = String(now.getHours()).padStart(2, '0'); + const minute = String(now.getMinutes()).padStart(2, '0'); + const second = String(now.getSeconds()).padStart(2, '0'); + + return `${year}${month}${day}_${hour}${minute}${second}`; +} + +// Format timestamp +function formatTimestamp(date) { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); +} + +// Create message element +function createMessageElement(content, role, timestamp) { + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${role}`; + + // Create content div + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.innerHTML = formatMessageContent(content); + messageDiv.appendChild(contentDiv); + + // Add timestamp + const timestampDiv = document.createElement('div'); + timestampDiv.className = 'timestamp'; + timestampDiv.textContent = formatTimestamp(new Date(timestamp || Date.now())); + messageDiv.appendChild(timestampDiv); + + return messageDiv; +} + +// Format message content (handle markdown-like formatting) +function formatMessageContent(content) { + // Escape HTML + let formatted = content + .replace(/&/g, '&') + .replace(//g, '>'); + + // Handle code blocks + formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { + return `
${code.trim()}
`; + }); + + // Handle inline code + formatted = formatted.replace(/`([^`]+)`/g, '$1'); + + // Handle line breaks + formatted = formatted.replace(/\n/g, '
'); + + return formatted; +} + +// Add message to chat +function addMessage(content, role, timestamp) { + // Remove welcome message if it exists + const welcomeMessage = messagesContainer.querySelector('.welcome-message'); + if (welcomeMessage) { + welcomeMessage.remove(); + } + + const messageElement = createMessageElement(content, role, timestamp); + messagesContainer.appendChild(messageElement); + + // Scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Add thinking indicator +function addThinkingIndicator() { + removeThinkingIndicator(); // Remove any existing one first + + const thinkingDiv = document.createElement('div'); + thinkingDiv.id = 'thinking-indicator'; + thinkingDiv.className = 'message thinking-message'; + thinkingDiv.innerHTML = ` +
+ + + +
+ Goose is thinking... + `; + messagesContainer.appendChild(thinkingDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Remove thinking indicator +function removeThinkingIndicator() { + const thinking = document.getElementById('thinking-indicator'); + if (thinking) { + thinking.remove(); + } +} + +// Connect to WebSocket +function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('WebSocket connected'); + isConnected = true; + connectionStatus.textContent = 'Connected'; + connectionStatus.className = 'status connected'; + sendButton.disabled = false; + + // Check if this session exists and load history if it does + loadSessionIfExists(); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleServerMessage(data); + } catch (e) { + console.error('Failed to parse message:', e); + } + }; + + socket.onclose = () => { + console.log('WebSocket disconnected'); + isConnected = false; + connectionStatus.textContent = 'Disconnected'; + connectionStatus.className = 'status disconnected'; + sendButton.disabled = true; + + // Attempt to reconnect after 3 seconds + setTimeout(connectWebSocket, 3000); + }; + + socket.onerror = (error) => { + console.error('WebSocket error:', error); + }; +} + +// Handle messages from server +function handleServerMessage(data) { + switch (data.type) { + case 'response': + // For streaming responses, we need to handle partial messages + handleStreamingResponse(data); + break; + case 'tool_request': + handleToolRequest(data); + break; + case 'tool_response': + handleToolResponse(data); + break; + case 'tool_confirmation': + handleToolConfirmation(data); + break; + case 'thinking': + handleThinking(data); + break; + case 'context_exceeded': + handleContextExceeded(data); + break; + case 'cancelled': + handleCancelled(data); + break; + case 'complete': + handleComplete(data); + break; + case 'error': + removeThinkingIndicator(); + resetSendButton(); + addMessage(`Error: ${data.message}`, 'assistant', Date.now()); + break; + default: + console.log('Unknown message type:', data.type); + } +} + +// Track current streaming message +let currentStreamingMessage = null; + +// Handle streaming responses +function handleStreamingResponse(data) { + removeThinkingIndicator(); + + // If this is the first chunk of a new message, or we don't have a current streaming message + if (!currentStreamingMessage) { + // Create a new message element + const messageElement = createMessageElement(data.content, data.role || 'assistant', data.timestamp); + messageElement.setAttribute('data-streaming', 'true'); + messagesContainer.appendChild(messageElement); + + currentStreamingMessage = { + element: messageElement, + content: data.content, + role: data.role || 'assistant', + timestamp: data.timestamp + }; + } else { + // Append to existing streaming message + currentStreamingMessage.content += data.content; + + // Update the message content using the proper content div + const contentDiv = currentStreamingMessage.element.querySelector('.message-content'); + if (contentDiv) { + contentDiv.innerHTML = formatMessageContent(currentStreamingMessage.content); + } + } + + // Scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle tool requests +function handleToolRequest(data) { + removeThinkingIndicator(); // Remove thinking when tool starts + + // Reset streaming message so tool doesn't interfere with message flow + currentStreamingMessage = null; + + const toolDiv = document.createElement('div'); + toolDiv.className = 'message assistant tool-message'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'tool-header'; + headerDiv.innerHTML = `🔧 ${data.tool_name}`; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'tool-content'; + + // Format the arguments + if (data.tool_name === 'developer__shell' && data.arguments.command) { + contentDiv.innerHTML = `
${escapeHtml(data.arguments.command)}
`; + } else if (data.tool_name === 'developer__text_editor') { + const action = data.arguments.command || 'unknown'; + const path = data.arguments.path || 'unknown'; + contentDiv.innerHTML = `
action: ${action}
`; + contentDiv.innerHTML += `
path: ${escapeHtml(path)}
`; + if (data.arguments.file_text) { + contentDiv.innerHTML += `
content:
${escapeHtml(data.arguments.file_text.substring(0, 200))}${data.arguments.file_text.length > 200 ? '...' : ''}
`; + } + } else { + contentDiv.innerHTML = `
${JSON.stringify(data.arguments, null, 2)}
`; + } + + toolDiv.appendChild(headerDiv); + toolDiv.appendChild(contentDiv); + + // Add a "running" indicator + const runningDiv = document.createElement('div'); + runningDiv.className = 'tool-running'; + runningDiv.innerHTML = '⏳ Running...'; + toolDiv.appendChild(runningDiv); + + messagesContainer.appendChild(toolDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle tool responses +function handleToolResponse(data) { + // Remove the "running" indicator from the last tool message + const toolMessages = messagesContainer.querySelectorAll('.tool-message'); + if (toolMessages.length > 0) { + const lastToolMessage = toolMessages[toolMessages.length - 1]; + const runningIndicator = lastToolMessage.querySelector('.tool-running'); + if (runningIndicator) { + runningIndicator.remove(); + } + } + + if (data.is_error) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'message tool-error'; + errorDiv.innerHTML = `Tool Error: ${escapeHtml(data.result.error || 'Unknown error')}`; + messagesContainer.appendChild(errorDiv); + } else { + // Handle successful tool response + if (Array.isArray(data.result)) { + data.result.forEach(content => { + if (content.type === 'text' && content.text) { + const responseDiv = document.createElement('div'); + responseDiv.className = 'message tool-result'; + responseDiv.innerHTML = `
${escapeHtml(content.text)}
`; + messagesContainer.appendChild(responseDiv); + } + }); + } + } + messagesContainer.scrollTop = messagesContainer.scrollHeight; + + // Reset streaming message so next assistant response creates a new message + currentStreamingMessage = null; + + // Show thinking indicator because assistant will likely follow up with explanation + // Only show if we're still processing (cancel button is active) + if (isProcessing) { + addThinkingIndicator(); + } +} + +// Handle tool confirmations +function handleToolConfirmation(data) { + const confirmDiv = document.createElement('div'); + confirmDiv.className = 'message tool-confirmation'; + confirmDiv.innerHTML = ` +
⚠️ Tool Confirmation Required
+
+ ${data.tool_name} wants to execute with: +
${JSON.stringify(data.arguments, null, 2)}
+
+
Auto-approved in web mode (UI coming soon)
+ `; + messagesContainer.appendChild(confirmDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle thinking messages +function handleThinking(data) { + // For now, just log thinking messages + console.log('Thinking:', data.message); +} + +// Handle context exceeded +function handleContextExceeded(data) { + const contextDiv = document.createElement('div'); + contextDiv.className = 'message context-warning'; + contextDiv.innerHTML = ` +
⚠️ Context Length Exceeded
+
${escapeHtml(data.message)}
+
Auto-summarizing conversation...
+ `; + messagesContainer.appendChild(contextDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle cancelled operation +function handleCancelled(data) { + removeThinkingIndicator(); + resetSendButton(); + + const cancelDiv = document.createElement('div'); + cancelDiv.className = 'message system-message cancelled'; + cancelDiv.innerHTML = `${escapeHtml(data.message)}`; + messagesContainer.appendChild(cancelDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle completion of response +function handleComplete(data) { + removeThinkingIndicator(); + resetSendButton(); + // Finalize any streaming message + if (currentStreamingMessage) { + currentStreamingMessage = null; + } +} + +// Reset send button to normal state +function resetSendButton() { + isProcessing = false; + sendButton.textContent = 'Send'; + sendButton.classList.remove('cancel-mode'); +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Send message or cancel +function sendMessage() { + if (isProcessing) { + // Cancel the current operation + socket.send(JSON.stringify({ + type: 'cancel', + session_id: sessionId + })); + return; + } + + const message = messageInput.value.trim(); + if (!message || !isConnected) return; + + // Add user message to chat + addMessage(message, 'user', Date.now()); + + // Clear input + messageInput.value = ''; + messageInput.style.height = 'auto'; + + // Add thinking indicator + addThinkingIndicator(); + + // Update button to show cancel + isProcessing = true; + sendButton.textContent = 'Cancel'; + sendButton.classList.add('cancel-mode'); + + // Send message through WebSocket + socket.send(JSON.stringify({ + type: 'message', + content: message, + session_id: sessionId, + timestamp: Date.now() + })); +} + +// Handle suggestion pill clicks +function sendSuggestion(text) { + if (!isConnected || isProcessing) return; + + messageInput.value = text; + sendMessage(); +} + +// Load session history if the session exists (like --resume in CLI) +async function loadSessionIfExists() { + try { + const response = await fetch(`/api/sessions/${sessionId}`); + if (response.ok) { + const sessionData = await response.json(); + if (sessionData.messages && sessionData.messages.length > 0) { + // Remove welcome message since we're resuming + const welcomeMessage = messagesContainer.querySelector('.welcome-message'); + if (welcomeMessage) { + welcomeMessage.remove(); + } + + // Display session resumed message + const resumeDiv = document.createElement('div'); + resumeDiv.className = 'message system-message'; + resumeDiv.innerHTML = `Session resumed: ${sessionData.messages.length} messages loaded`; + messagesContainer.appendChild(resumeDiv); + + + // Update page title with session description if available + if (sessionData.metadata && sessionData.metadata.description) { + document.title = `Goose Chat - ${sessionData.metadata.description}`; + } + + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + } + } catch (error) { + console.log('No existing session found or error loading:', error); + // This is fine - just means it's a new session + } +} + + +// Event listeners +sendButton.addEventListener('click', sendMessage); + +messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } +}); + +// Auto-resize textarea +messageInput.addEventListener('input', () => { + messageInput.style.height = 'auto'; + messageInput.style.height = messageInput.scrollHeight + 'px'; +}); + +// Initialize WebSocket connection +connectWebSocket(); + +// Focus on input +messageInput.focus(); + +// Update session title +function updateSessionTitle() { + const titleElement = document.getElementById('session-title'); + // Just show "Goose Chat" - no need to show session ID + titleElement.textContent = 'Goose Chat'; +} + +// Update title on load +updateSessionTitle(); \ No newline at end of file diff --git a/crates/goose-cli/static/style.css b/crates/goose-cli/static/style.css new file mode 100644 index 00000000..f2f1eb3e --- /dev/null +++ b/crates/goose-cli/static/style.css @@ -0,0 +1,480 @@ +:root { + /* Dark theme colors (matching the dark.png) */ + --bg-primary: #000000; + --bg-secondary: #0a0a0a; + --bg-tertiary: #1a1a1a; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --text-muted: #666666; + --border-color: #333333; + --border-subtle: #1a1a1a; + --accent-color: #ffffff; + --accent-hover: #f0f0f0; + --user-bg: #1a1a1a; + --assistant-bg: #0a0a0a; + --input-bg: #0a0a0a; + --input-border: #333333; + --button-bg: #ffffff; + --button-text: #000000; + --button-hover: #e0e0e0; + --pill-bg: transparent; + --pill-border: #333333; + --pill-hover: #1a1a1a; + --tool-bg: #0f0f0f; + --code-bg: #0f0f0f; +} + +/* Light theme */ +@media (prefers-color-scheme: light) { + :root { + --bg-primary: #ffffff; + --bg-secondary: #fafafa; + --bg-tertiary: #f5f5f5; + --text-primary: #000000; + --text-secondary: #666666; + --text-muted: #999999; + --border-color: #e1e5e9; + --border-subtle: #f0f0f0; + --accent-color: #000000; + --accent-hover: #333333; + --user-bg: #f0f0f0; + --assistant-bg: #fafafa; + --input-bg: #ffffff; + --input-border: #e1e5e9; + --button-bg: #000000; + --button-text: #ffffff; + --button-hover: #333333; + --pill-bg: #f5f5f5; + --pill-border: #e1e5e9; + --pill-hover: #e8eaed; + --tool-bg: #f8f9fa; + --code-bg: #f5f5f5; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; + height: 100vh; + overflow: hidden; + font-size: 14px; +} + +.container { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 100%; + margin: 0 auto; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border-subtle); +} + +header h1 { + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.75rem; +} + +header h1::before { + content: "🪿"; + font-size: 1.5rem; +} + +.status { + font-size: 0.75rem; + color: var(--text-secondary); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.status.connected { + color: #10b981; + border-color: #10b981; + background-color: rgba(16, 185, 129, 0.1); +} + +.status.disconnected { + color: #ef4444; + border-color: #ef4444; + background-color: rgba(239, 68, 68, 0.1); +} + +.chat-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.welcome-message { + text-align: center; + padding: 4rem 2rem; + color: var(--text-secondary); +} + +.welcome-message h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: var(--text-primary); + font-weight: 600; +} + +.welcome-message p { + font-size: 1rem; + margin-bottom: 2rem; +} + +/* Suggestion pills like in the design */ +.suggestion-pills { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 2rem; +} + +.suggestion-pill { + padding: 0.75rem 1.25rem; + background-color: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 2rem; + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-block; +} + +.suggestion-pill:hover { + background-color: var(--pill-hover); + border-color: var(--border-color); +} + +.message { + max-width: 80%; + padding: 1rem 1.25rem; + border-radius: 1rem; + word-wrap: break-word; + position: relative; +} + +.message.user { + align-self: flex-end; + background-color: var(--user-bg); + margin-left: auto; + border: 1px solid var(--border-subtle); +} + +.message.assistant { + align-self: flex-start; + background-color: var(--assistant-bg); + border: 1px solid var(--border-subtle); +} + +.message-content { + flex: 1; + margin-bottom: 0.5rem; +} + +.message .timestamp { + font-size: 0.6875rem; + color: var(--text-muted); + margin-top: 0.5rem; + opacity: 0.7; +} + +.message pre { + background-color: var(--code-bg); + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 0.75rem 0; + border: 1px solid var(--border-color); + font-size: 0.8125rem; +} + +.message code { + background-color: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-size: 0.8125rem; + border: 1px solid var(--border-color); +} + +.input-container { + display: flex; + gap: 0.75rem; + padding: 1.5rem; + background-color: var(--bg-primary); + border-top: 1px solid var(--border-subtle); +} + +#message-input { + flex: 1; + padding: 0.875rem 1rem; + border: 1px solid var(--input-border); + border-radius: 0.75rem; + background-color: var(--input-bg); + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + resize: none; + min-height: 2.75rem; + max-height: 8rem; + outline: none; + transition: border-color 0.2s ease; +} + +#message-input:focus { + border-color: var(--accent-color); +} + +#message-input::placeholder { + color: var(--text-muted); +} + +#send-button { + padding: 0.875rem 1.5rem; + background-color: var(--button-bg); + color: var(--button-text); + border: none; + border-radius: 0.75rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: 4rem; +} + +#send-button:hover { + background-color: var(--button-hover); + transform: translateY(-1px); +} + +#send-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +#send-button.cancel-mode { + background-color: #ef4444; + color: #ffffff; +} + +#send-button.cancel-mode:hover { + background-color: #dc2626; +} + +/* Scrollbar styling */ +.messages::-webkit-scrollbar { + width: 6px; +} + +.messages::-webkit-scrollbar-track { + background: transparent; +} + +.messages::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.messages::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Tool call styling */ +.tool-message, .tool-result, .tool-error, .tool-confirmation, .context-warning { + background-color: var(--tool-bg); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + padding: 1rem; + margin: 0.75rem 0; + max-width: 90%; +} + +.tool-header, .tool-confirm-header, .context-header { + font-weight: 600; + color: var(--accent-color); + margin-bottom: 0.75rem; + font-size: 0.875rem; +} + +.tool-content { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.tool-param { + margin: 0.5rem 0; +} + +.tool-param strong { + color: var(--text-primary); +} + +.tool-running { + font-size: 0.8125rem; + color: var(--accent-color); + margin-top: 0.75rem; + font-style: italic; +} + +.tool-error { + border-color: #ef4444; + background-color: rgba(239, 68, 68, 0.05); +} + +.tool-error strong { + color: #ef4444; +} + +.tool-result { + background-color: var(--tool-bg); + border-left: 3px solid var(--accent-color); + margin-left: 1.5rem; + border-radius: 0.5rem; +} + +.tool-confirmation { + border-color: #f59e0b; + background-color: rgba(245, 158, 11, 0.05); +} + +.tool-confirm-note, .context-note { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.75rem; + font-style: italic; +} + +.context-warning { + border-color: #f59e0b; + background-color: rgba(245, 158, 11, 0.05); +} + +.context-header { + color: #f59e0b; +} + +.system-message { + text-align: center; + color: var(--text-secondary); + font-style: italic; + margin: 1rem 0; + font-size: 0.875rem; +} + +.cancelled { + color: #ef4444; +} + +/* Thinking indicator */ +.thinking-message { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-secondary); + font-style: italic; + padding: 1rem 1.25rem; + background-color: var(--bg-secondary); + border-radius: 1rem; + border: 1px solid var(--border-subtle); + max-width: 80%; + font-size: 0.875rem; +} + +.thinking-dots { + display: flex; + gap: 0.25rem; +} + +.thinking-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--text-secondary); + animation: thinking-bounce 1.4s infinite ease-in-out both; +} + +.thinking-dots span:nth-child(1) { + animation-delay: -0.32s; +} + +.thinking-dots span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes thinking-bounce { + 0%, 80%, 100% { + transform: scale(0.6); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* Keep the old loading indicator for backwards compatibility */ +.loading-message { + display: none; +} + +/* Responsive design */ +@media (max-width: 768px) { + .messages { + padding: 1rem; + gap: 1rem; + } + + .message { + max-width: 90%; + padding: 0.875rem 1rem; + } + + .input-container { + padding: 1rem; + } + + header { + padding: 0.75rem 1rem; + } + + .welcome-message { + padding: 2rem 1rem; + } +} \ No newline at end of file diff --git a/crates/goose-ffi/src/lib.rs b/crates/goose-ffi/src/lib.rs index bd2237d7..1afc97e9 100644 --- a/crates/goose-ffi/src/lib.rs +++ b/crates/goose-ffi/src/lib.rs @@ -3,7 +3,7 @@ use std::ptr; use std::sync::Arc; use futures::StreamExt; -use goose::agents::Agent; +use goose::agents::{Agent, AgentEvent}; use goose::message::Message; use goose::model::ModelConfig; use goose::providers::databricks::DatabricksProvider; @@ -256,13 +256,16 @@ pub unsafe extern "C" fn goose_agent_send_message( while let Some(message_result) = stream.next().await { match message_result { - Ok(message) => { + Ok(AgentEvent::Message(message)) => { // Get text or serialize to JSON // Note: Message doesn't have as_text method, we'll serialize to JSON if let Ok(json) = serde_json::to_string(&message) { full_response.push_str(&json); } } + Ok(AgentEvent::McpNotification(_)) => { + // TODO: Handle MCP notifications. + } Err(e) => { full_response.push_str(&format!("\nError in message stream: {}", e)); } diff --git a/crates/goose-llm/src/providers/databricks.rs b/crates/goose-llm/src/providers/databricks.rs index 13e0c1a2..3dd31493 100644 --- a/crates/goose-llm/src/providers/databricks.rs +++ b/crates/goose-llm/src/providers/databricks.rs @@ -138,6 +138,11 @@ impl DatabricksProvider { "reduce the length", "token count", "exceeds", + "exceed context limit", + "input length", + "max_tokens", + "decrease input length", + "context limit", ]; if check_phrases.iter().any(|c| payload_str.contains(c)) { return Err(ProviderError::ContextLengthExceeded(payload_str)); diff --git a/crates/goose-mcp/src/computercontroller/mod.rs b/crates/goose-mcp/src/computercontroller/mod.rs index a2751852..ec8d5f61 100644 --- a/crates/goose-mcp/src/computercontroller/mod.rs +++ b/crates/goose-mcp/src/computercontroller/mod.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; use std::{ collections::HashMap, fs, future::Future, path::PathBuf, pin::Pin, sync::Arc, sync::Mutex, }; -use tokio::process::Command; +use tokio::{process::Command, sync::mpsc}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -14,7 +14,7 @@ use std::os::unix::fs::PermissionsExt; use mcp_core::{ handler::{PromptError, ResourceError, ToolError}, prompt::Prompt, - protocol::ServerCapabilities, + protocol::{JsonRpcMessage, ServerCapabilities}, resource::Resource, tool::{Tool, ToolAnnotations}, Content, @@ -1155,6 +1155,7 @@ impl Router for ComputerControllerRouter { &self, tool_name: &str, arguments: Value, + _notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>> { let this = self.clone(); let tool_name = tool_name.to_string(); diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index 5d7a9696..df95f76d 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -13,13 +13,17 @@ use std::{ path::{Path, PathBuf}, pin::Pin, }; -use tokio::process::Command; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + sync::mpsc, +}; use url::Url; use include_dir::{include_dir, Dir}; use mcp_core::{ handler::{PromptError, ResourceError, ToolError}, - protocol::ServerCapabilities, + protocol::{JsonRpcMessage, JsonRpcNotification, ServerCapabilities}, resource::Resource, tool::Tool, Content, @@ -404,10 +408,20 @@ impl DeveloperRouter { if local_ignore_path.is_file() { let _ = builder.add(local_ignore_path); has_ignore_file = true; + } else { + // If no .gooseignore exists, check for .gitignore as fallback + let gitignore_path = cwd.join(".gitignore"); + if gitignore_path.is_file() { + tracing::debug!( + "No .gooseignore found, using .gitignore as fallback for ignore patterns" + ); + let _ = builder.add(gitignore_path); + has_ignore_file = true; + } } // Only use default patterns if no .gooseignore files were found - // If the file is empty, we will not ignore any file + // AND no .gitignore was used as fallback if !has_ignore_file { // Add some sensible defaults let _ = builder.add_line(None, "**/.env"); @@ -456,7 +470,11 @@ impl DeveloperRouter { } // Shell command execution with platform-specific handling - async fn bash(&self, params: Value) -> Result, ToolError> { + async fn bash( + &self, + params: Value, + notifier: mpsc::Sender, + ) -> Result, ToolError> { let command = params .get("command") @@ -488,27 +506,102 @@ impl DeveloperRouter { // Get platform-specific shell configuration let shell_config = get_shell_config(); - let cmd_with_redirect = format_command_for_platform(command); + let cmd_str = format_command_for_platform(command); // Execute the command using platform-specific shell - let child = Command::new(&shell_config.executable) + let mut child = Command::new(&shell_config.executable) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .stdin(Stdio::null()) .kill_on_drop(true) .arg(&shell_config.arg) - .arg(cmd_with_redirect) + .arg(cmd_str) .spawn() .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + + let mut stdout_reader = BufReader::new(stdout); + let mut stderr_reader = BufReader::new(stderr); + + let output_task = tokio::spawn(async move { + let mut combined_output = String::new(); + + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + + let mut stdout_done = false; + let mut stderr_done = false; + + loop { + tokio::select! { + n = stdout_reader.read_until(b'\n', &mut stdout_buf), if !stdout_done => { + if n? == 0 { + stdout_done = true; + } else { + let line = String::from_utf8_lossy(&stdout_buf); + + notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/message".to_string(), + params: Some(json!({ + "data": { + "type": "shell", + "stream": "stdout", + "output": line.to_string(), + } + })), + })).ok(); + + combined_output.push_str(&line); + stdout_buf.clear(); + } + } + + n = stderr_reader.read_until(b'\n', &mut stderr_buf), if !stderr_done => { + if n? == 0 { + stderr_done = true; + } else { + let line = String::from_utf8_lossy(&stderr_buf); + + notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/message".to_string(), + params: Some(json!({ + "data": { + "type": "shell", + "stream": "stderr", + "output": line.to_string(), + } + })), + })).ok(); + + combined_output.push_str(&line); + stderr_buf.clear(); + } + } + + else => break, + } + + if stdout_done && stderr_done { + break; + } + } + Ok::<_, std::io::Error>(combined_output) + }); + // Wait for the command to complete and get output - let output = child - .wait_with_output() + child + .wait() .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; - let stdout_str = String::from_utf8_lossy(&output.stdout); - let output_str = stdout_str; + let output_str = match output_task.await { + Ok(result) => result.map_err(|e| ToolError::ExecutionError(e.to_string()))?, + Err(e) => return Err(ToolError::ExecutionError(e.to_string())), + }; // Check the character count of the output const MAX_CHAR_COUNT: usize = 400_000; // 409600 chars = 400KB @@ -1048,12 +1141,13 @@ impl Router for DeveloperRouter { &self, tool_name: &str, arguments: Value, + notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>> { let this = self.clone(); let tool_name = tool_name.to_string(); Box::pin(async move { match tool_name.as_str() { - "shell" => this.bash(arguments).await, + "shell" => this.bash(arguments, notifier).await, "text_editor" => this.text_editor(arguments).await, "list_windows" => this.list_windows(arguments).await, "screen_capture" => this.screen_capture(arguments).await, @@ -1195,6 +1289,10 @@ mod tests { .await } + fn dummy_sender() -> mpsc::Sender { + mpsc::channel(1).0 + } + #[tokio::test] #[serial] async fn test_shell_missing_parameters() { @@ -1202,7 +1300,7 @@ mod tests { std::env::set_current_dir(&temp_dir).unwrap(); let router = get_router().await; - let result = router.call_tool("shell", json!({})).await; + let result = router.call_tool("shell", json!({}), dummy_sender()).await; assert!(result.is_err()); let err = result.err().unwrap(); @@ -1263,6 +1361,7 @@ mod tests { "command": "view", "path": large_file_str }), + dummy_sender(), ) .await; @@ -1288,6 +1387,7 @@ mod tests { "command": "view", "path": many_chars_str }), + dummy_sender(), ) .await; @@ -1319,6 +1419,7 @@ mod tests { "path": file_path_str, "file_text": "Hello, world!" }), + dummy_sender(), ) .await .unwrap(); @@ -1331,6 +1432,7 @@ mod tests { "command": "view", "path": file_path_str }), + dummy_sender(), ) .await .unwrap(); @@ -1369,6 +1471,7 @@ mod tests { "path": file_path_str, "file_text": "Hello, world!" }), + dummy_sender(), ) .await .unwrap(); @@ -1383,6 +1486,7 @@ mod tests { "old_str": "world", "new_str": "Rust" }), + dummy_sender(), ) .await .unwrap(); @@ -1407,6 +1511,7 @@ mod tests { "command": "view", "path": file_path_str }), + dummy_sender(), ) .await .unwrap(); @@ -1444,6 +1549,7 @@ mod tests { "path": file_path_str, "file_text": "First line" }), + dummy_sender(), ) .await .unwrap(); @@ -1458,6 +1564,7 @@ mod tests { "old_str": "First line", "new_str": "Second line" }), + dummy_sender(), ) .await .unwrap(); @@ -1470,6 +1577,7 @@ mod tests { "command": "undo_edit", "path": file_path_str }), + dummy_sender(), ) .await .unwrap(); @@ -1485,6 +1593,7 @@ mod tests { "command": "view", "path": file_path_str }), + dummy_sender(), ) .await .unwrap(); @@ -1583,6 +1692,7 @@ mod tests { "path": temp_dir.path().join("secret.txt").to_str().unwrap(), "file_text": "test content" }), + dummy_sender(), ) .await; @@ -1601,6 +1711,7 @@ mod tests { "path": temp_dir.path().join("allowed.txt").to_str().unwrap(), "file_text": "test content" }), + dummy_sender(), ) .await; @@ -1642,6 +1753,7 @@ mod tests { json!({ "command": format!("cat {}", secret_file_path.to_str().unwrap()) }), + dummy_sender(), ) .await; @@ -1658,6 +1770,202 @@ mod tests { json!({ "command": format!("cat {}", allowed_file_path.to_str().unwrap()) }), + dummy_sender(), + ) + .await; + + assert!(result.is_ok(), "Should be able to cat non-ignored file"); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_gitignore_fallback_when_no_gooseignore() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log\n*.tmp\n.env").unwrap(); + + let router = DeveloperRouter::new(); + + // Test that gitignore patterns are respected + assert!( + router.is_ignored(Path::new("test.log")), + "*.log pattern from .gitignore should be ignored" + ); + assert!( + router.is_ignored(Path::new("build.tmp")), + "*.tmp pattern from .gitignore should be ignored" + ); + assert!( + router.is_ignored(Path::new(".env")), + ".env pattern from .gitignore should be ignored" + ); + assert!( + !router.is_ignored(Path::new("test.txt")), + "test.txt should not be ignored" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_gooseignore_takes_precedence_over_gitignore() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create both .gooseignore and .gitignore files with different patterns + std::fs::write(temp_dir.path().join(".gooseignore"), "*.secret").unwrap(); + std::fs::write(temp_dir.path().join(".gitignore"), "*.log\ntarget/").unwrap(); + + let router = DeveloperRouter::new(); + + // .gooseignore patterns should be used + assert!( + router.is_ignored(Path::new("test.secret")), + "*.secret pattern from .gooseignore should be ignored" + ); + + // .gitignore patterns should NOT be used when .gooseignore exists + assert!( + !router.is_ignored(Path::new("test.log")), + "*.log pattern from .gitignore should NOT be ignored when .gooseignore exists" + ); + assert!( + !router.is_ignored(Path::new("build.tmp")), + "*.tmp pattern from .gitignore should NOT be ignored when .gooseignore exists" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_default_patterns_when_no_ignore_files() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Don't create any ignore files + let router = DeveloperRouter::new(); + + // Default patterns should be used + assert!( + router.is_ignored(Path::new(".env")), + ".env should be ignored by default patterns" + ); + assert!( + router.is_ignored(Path::new(".env.local")), + ".env.local should be ignored by default patterns" + ); + assert!( + router.is_ignored(Path::new("secrets.txt")), + "secrets.txt should be ignored by default patterns" + ); + assert!( + !router.is_ignored(Path::new("normal.txt")), + "normal.txt should not be ignored" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_text_editor_respects_gitignore_fallback() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); + + let router = DeveloperRouter::new(); + + // Try to write to a file ignored by .gitignore + let result = router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": temp_dir.path().join("test.log").to_str().unwrap(), + "file_text": "test content" + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_err(), + "Should not be able to write to file ignored by .gitignore fallback" + ); + assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_))); + + // Try to write to a non-ignored file + let result = router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": temp_dir.path().join("allowed.txt").to_str().unwrap(), + "file_text": "test content" + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_ok(), + "Should be able to write to non-ignored file" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_bash_respects_gitignore_fallback() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); + + let router = DeveloperRouter::new(); + + // Create a file that would be ignored by .gitignore + let log_file_path = temp_dir.path().join("test.log"); + std::fs::write(&log_file_path, "log content").unwrap(); + + // Try to cat the ignored file + let result = router + .call_tool( + "shell", + json!({ + "command": format!("cat {}", log_file_path.to_str().unwrap()) + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_err(), + "Should not be able to cat file ignored by .gitignore fallback" + ); + assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_))); + + // Try to cat a non-ignored file + let allowed_file_path = temp_dir.path().join("allowed.txt"); + std::fs::write(&allowed_file_path, "allowed content").unwrap(); + + let result = router + .call_tool( + "shell", + json!({ + "command": format!("cat {}", allowed_file_path.to_str().unwrap()) + }), + dummy_sender(), ) .await; diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs index 34e531f2..cb60f9ba 100644 --- a/crates/goose-mcp/src/developer/shell.rs +++ b/crates/goose-mcp/src/developer/shell.rs @@ -4,7 +4,6 @@ use std::env; pub struct ShellConfig { pub executable: String, pub arg: String, - pub redirect_syntax: String, } impl Default for ShellConfig { @@ -14,13 +13,11 @@ impl Default for ShellConfig { Self { executable: "powershell.exe".to_string(), arg: "-NoProfile -NonInteractive -Command".to_string(), - redirect_syntax: "2>&1".to_string(), } } else { Self { executable: "bash".to_string(), arg: "-c".to_string(), - redirect_syntax: "2>&1".to_string(), } } } @@ -31,13 +28,12 @@ pub fn get_shell_config() -> ShellConfig { } pub fn format_command_for_platform(command: &str) -> String { - let config = get_shell_config(); if cfg!(windows) { // For PowerShell, wrap the command in braces to handle special characters - format!("{{ {} }} {}", command, config.redirect_syntax) + format!("{{ {} }}", command) } else { // For other shells, no braces needed - format!("{} {}", command, config.redirect_syntax) + command.to_string() } } diff --git a/crates/goose-mcp/src/google_drive/mod.rs b/crates/goose-mcp/src/google_drive/mod.rs index 710f42ae..1f1aeae7 100644 --- a/crates/goose-mcp/src/google_drive/mod.rs +++ b/crates/goose-mcp/src/google_drive/mod.rs @@ -7,6 +7,7 @@ use base64::Engine; use chrono::NaiveDate; use indoc::indoc; use lazy_static::lazy_static; +use mcp_core::protocol::JsonRpcMessage; use mcp_core::tool::ToolAnnotations; use oauth_pkce::PkceOAuth2Client; use regex::Regex; @@ -14,6 +15,7 @@ use serde_json::{json, Value}; use std::io::Cursor; use std::{env, fs, future::Future, path::Path, pin::Pin, sync::Arc}; use storage::CredentialsManager; +use tokio::sync::mpsc; use mcp_core::content::Content; use mcp_core::{ @@ -3281,6 +3283,7 @@ impl Router for GoogleDriveRouter { &self, tool_name: &str, arguments: Value, + _notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>> { let this = self.clone(); let tool_name = tool_name.to_string(); diff --git a/crates/goose-mcp/src/jetbrains/mod.rs b/crates/goose-mcp/src/jetbrains/mod.rs index 0cdf8018..c015b9de 100644 --- a/crates/goose-mcp/src/jetbrains/mod.rs +++ b/crates/goose-mcp/src/jetbrains/mod.rs @@ -5,7 +5,7 @@ use mcp_core::{ content::Content, handler::{PromptError, ResourceError, ToolError}, prompt::Prompt, - protocol::ServerCapabilities, + protocol::{JsonRpcMessage, ServerCapabilities}, resource::Resource, role::Role, tool::Tool, @@ -16,7 +16,7 @@ use serde_json::Value; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use tokio::sync::Mutex; +use tokio::sync::{mpsc, Mutex}; use tokio::time::{sleep, Duration}; use tracing::error; @@ -158,6 +158,7 @@ impl Router for JetBrainsRouter { &self, tool_name: &str, arguments: Value, + _notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>> { let this = self.clone(); let tool_name = tool_name.to_string(); diff --git a/crates/goose-mcp/src/memory/mod.rs b/crates/goose-mcp/src/memory/mod.rs index 24dff4f1..8c814478 100644 --- a/crates/goose-mcp/src/memory/mod.rs +++ b/crates/goose-mcp/src/memory/mod.rs @@ -10,11 +10,12 @@ use std::{ path::PathBuf, pin::Pin, }; +use tokio::sync::mpsc; use mcp_core::{ handler::{PromptError, ResourceError, ToolError}, prompt::Prompt, - protocol::ServerCapabilities, + protocol::{JsonRpcMessage, ServerCapabilities}, resource::Resource, tool::{Tool, ToolAnnotations, ToolCall}, Content, @@ -520,6 +521,7 @@ impl Router for MemoryRouter { &self, tool_name: &str, arguments: Value, + _notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>> { let this = self.clone(); let tool_name = tool_name.to_string(); diff --git a/crates/goose-mcp/src/tutorial/mod.rs b/crates/goose-mcp/src/tutorial/mod.rs index b2c26906..ea9e32f0 100644 --- a/crates/goose-mcp/src/tutorial/mod.rs +++ b/crates/goose-mcp/src/tutorial/mod.rs @@ -3,11 +3,12 @@ use include_dir::{include_dir, Dir}; use indoc::formatdoc; use serde_json::{json, Value}; use std::{future::Future, pin::Pin}; +use tokio::sync::mpsc; use mcp_core::{ handler::{PromptError, ResourceError, ToolError}, prompt::Prompt, - protocol::ServerCapabilities, + protocol::{JsonRpcMessage, ServerCapabilities}, resource::Resource, role::Role, tool::{Tool, ToolAnnotations}, @@ -130,6 +131,7 @@ impl Router for TutorialRouter { &self, tool_name: &str, arguments: Value, + _notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>> { let this = self.clone(); let tool_name = tool_name.to_string(); diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 24e2d515..317376f4 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -45,6 +45,8 @@ use utoipa::OpenApi; super::routes::schedule::run_now_handler, super::routes::schedule::pause_schedule, super::routes::schedule::unpause_schedule, + super::routes::schedule::kill_running_job, + super::routes::schedule::inspect_running_job, super::routes::schedule::sessions_handler ), components(schemas( @@ -95,6 +97,8 @@ use utoipa::OpenApi; SessionMetadata, super::routes::schedule::CreateScheduleRequest, super::routes::schedule::UpdateScheduleRequest, + super::routes::schedule::KillJobResponse, + super::routes::schedule::InspectJobResponse, goose::scheduler::ScheduledJob, super::routes::schedule::RunNowResponse, super::routes::schedule::ListSchedulesResponse, diff --git a/crates/goose-server/src/routes/providers_and_keys.json b/crates/goose-server/src/routes/providers_and_keys.json index 830cf665..fc61c5b9 100644 --- a/crates/goose-server/src/routes/providers_and_keys.json +++ b/crates/goose-server/src/routes/providers_and_keys.json @@ -20,7 +20,7 @@ "gcp_vertex_ai": { "name": "GCP Vertex AI", "description": "Use Vertex AI platform models", - "models": ["claude-3-5-haiku@20241022", "claude-3-5-sonnet@20240620", "claude-3-5-sonnet-v2@20241022", "claude-3-7-sonnet@20250219", "gemini-1.5-pro-002", "gemini-2.0-flash-001", "gemini-2.0-pro-exp-02-05", "gemini-2.5-pro-exp-03-25"], + "models": ["claude-3-5-haiku@20241022", "claude-3-5-sonnet@20240620", "claude-3-5-sonnet-v2@20241022", "claude-3-7-sonnet@20250219", "gemini-1.5-pro-002", "gemini-2.0-flash-001", "gemini-2.0-pro-exp-02-05", "gemini-2.5-pro-exp-03-25", "gemini-2.5-flash-preview-05-20", "gemini-2.5-pro-preview-05-06"], "required_keys": ["GCP_PROJECT_ID", "GCP_LOCATION"] }, "google": { diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index ef84dc58..ed92834d 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -10,7 +10,7 @@ use axum::{ use bytes::Bytes; use futures::{stream::StreamExt, Stream}; use goose::{ - agents::SessionConfig, + agents::{AgentEvent, SessionConfig}, message::{Message, MessageContent}, permission::permission_confirmation::PrincipalType, }; @@ -18,7 +18,7 @@ use goose::{ permission::{Permission, PermissionConfirmation}, session, }; -use mcp_core::{role::Role, Content, ToolResult}; +use mcp_core::{protocol::JsonRpcMessage, role::Role, Content, ToolResult}; use serde::{Deserialize, Serialize}; use serde_json::json; use serde_json::Value; @@ -79,9 +79,19 @@ impl IntoResponse for SseResponse { #[derive(Debug, Serialize)] #[serde(tag = "type")] enum MessageEvent { - Message { message: Message }, - Error { error: String }, - Finish { reason: String }, + Message { + message: Message, + }, + Error { + error: String, + }, + Finish { + reason: String, + }, + Notification { + request_id: String, + message: JsonRpcMessage, + }, } async fn stream_event( @@ -200,7 +210,7 @@ async fn handler( tokio::select! { response = timeout(Duration::from_millis(500), stream.next()) => { match response { - Ok(Some(Ok(message))) => { + Ok(Some(Ok(AgentEvent::Message(message)))) => { all_messages.push(message.clone()); if let Err(e) = stream_event(MessageEvent::Message { message }, &tx).await { tracing::error!("Error sending message through channel: {}", e); @@ -223,6 +233,20 @@ async fn handler( } }); } + Ok(Some(Ok(AgentEvent::McpNotification((request_id, n))))) => { + if let Err(e) = stream_event(MessageEvent::Notification{ + request_id: request_id.clone(), + message: n, + }, &tx).await { + tracing::error!("Error sending message through channel: {}", e); + let _ = stream_event( + MessageEvent::Error { + error: e.to_string(), + }, + &tx, + ).await; + } + } Ok(Some(Err(e))) => { tracing::error!("Error processing message: {}", e); let _ = stream_event( @@ -317,7 +341,7 @@ async fn ask_handler( while let Some(response) = stream.next().await { match response { - Ok(message) => { + Ok(AgentEvent::Message(message)) => { if message.role == Role::Assistant { for content in &message.content { if let MessageContent::Text(text) = content { @@ -328,6 +352,10 @@ async fn ask_handler( } } } + Ok(AgentEvent::McpNotification(n)) => { + // Handle notifications if needed + tracing::info!("Received notification: {:?}", n); + } Err(e) => { tracing::error!("Error processing as_ai message: {}", e); return Err(StatusCode::INTERNAL_SERVER_ERROR); diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index fb7d852a..2caf7a14 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -31,6 +31,21 @@ pub struct ListSchedulesResponse { jobs: Vec, } +// Response for the kill endpoint +#[derive(Serialize, utoipa::ToSchema)] +pub struct KillJobResponse { + message: String, +} + +// Response for the inspect endpoint +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct InspectJobResponse { + session_id: Option, + process_start_time: Option, + running_duration_seconds: Option, +} + // Response for the run_now endpoint #[derive(Serialize, utoipa::ToSchema)] pub struct RunNowResponse { @@ -100,6 +115,8 @@ async fn create_schedule( last_run: None, currently_running: false, paused: false, + current_session_id: None, + process_start_time: None, }; scheduler .add_scheduled_job(job.clone()) @@ -199,6 +216,17 @@ async fn run_now_handler( eprintln!("Error running schedule '{}' now: {:?}", id, e); match e { goose::scheduler::SchedulerError::JobNotFound(_) => Err(StatusCode::NOT_FOUND), + goose::scheduler::SchedulerError::AnyhowError(ref err) => { + // Check if this is a cancellation error + if err.to_string().contains("was successfully cancelled") { + // Return a special session_id to indicate cancellation + Ok(Json(RunNowResponse { + session_id: "CANCELLED".to_string(), + })) + } else { + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } _ => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -389,6 +417,92 @@ async fn update_schedule( Ok(Json(updated_job)) } +#[utoipa::path( + post, + path = "/schedule/{id}/kill", + responses( + (status = 200, description = "Running job killed successfully"), + ), + tag = "schedule" +)] +#[axum::debug_handler] +pub async fn kill_running_job( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + scheduler.kill_running_job(&id).await.map_err(|e| { + eprintln!("Error killing running job '{}': {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, + goose::scheduler::SchedulerError::AnyhowError(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + Ok(Json(KillJobResponse { + message: format!("Successfully killed running job '{}'", id), + })) +} + +#[utoipa::path( + get, + path = "/schedule/{id}/inspect", + params( + ("id" = String, Path, description = "ID of the schedule to inspect") + ), + responses( + (status = 200, description = "Running job information", body = InspectJobResponse), + (status = 404, description = "Scheduled job not found"), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +pub async fn inspect_running_job( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match scheduler.get_running_job_info(&id).await { + Ok(info) => { + if let Some((session_id, start_time)) = info { + let duration = chrono::Utc::now().signed_duration_since(start_time); + Ok(Json(InspectJobResponse { + session_id: Some(session_id), + process_start_time: Some(start_time.to_rfc3339()), + running_duration_seconds: Some(duration.num_seconds()), + })) + } else { + Ok(Json(InspectJobResponse { + session_id: None, + process_start_time: None, + running_duration_seconds: None, + })) + } + } + Err(e) => { + eprintln!("Error inspecting running job '{}': {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => Err(StatusCode::NOT_FOUND), + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } + } + } +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/schedule/create", post(create_schedule)) @@ -398,6 +512,8 @@ pub fn routes(state: Arc) -> Router { .route("/schedule/{id}/run_now", post(run_now_handler)) // Corrected .route("/schedule/{id}/pause", post(pause_schedule)) .route("/schedule/{id}/unpause", post(unpause_schedule)) + .route("/schedule/{id}/kill", post(kill_running_job)) + .route("/schedule/{id}/inspect", get(inspect_running_job)) .route("/schedule/{id}/sessions", get(sessions_handler)) // Corrected .with_state(state) } diff --git a/crates/goose-server/ui/desktop/openapi.json b/crates/goose-server/ui/desktop/openapi.json index 5e78c8f6..0533f2f7 100644 --- a/crates/goose-server/ui/desktop/openapi.json +++ b/crates/goose-server/ui/desktop/openapi.json @@ -10,15 +10,58 @@ "license": { "name": "Apache-2.0" }, - "version": "1.0.4" + "version": "1.0.24" }, "paths": { + "/agent/tools": { + "get": { + "tags": [ + "super::routes::agent" + ], + "operationId": "get_tools", + "parameters": [ + { + "name": "extension_name", + "in": "query", + "description": "Optional extension name to filter tools", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Tools retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolInfo" + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/config": { "get": { "tags": [ "super::routes::config_management" ], - "summary": "Read all configuration values", "operationId": "read_all_config", "responses": { "200": { @@ -34,13 +77,56 @@ } } }, - "/config/extension": { + "/config/backup": { "post": { "tags": [ - "config" + "super::routes::config_management" ], - "summary": "Add an extension configuration", - "operationId": "add_extension_config", + "operationId": "backup_config", + "responses": { + "200": { + "description": "Config file backed up", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/extensions": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_extensions", + "responses": { + "200": { + "description": "All extensions retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "add_extension", "requestBody": { "content": { "application/json": { @@ -53,7 +139,7 @@ }, "responses": { "200": { - "description": "Extension added successfully", + "description": "Extension added or updated successfully", "content": { "text/plain": { "schema": { @@ -65,27 +151,31 @@ "400": { "description": "Invalid request" }, + "422": { + "description": "Could not serialize config.yaml" + }, "500": { "description": "Internal server error" } } - }, + } + }, + "/config/extensions/{name}": { "delete": { "tags": [ "super::routes::config_management" ], - "summary": "Remove an extension configuration", "operationId": "remove_extension", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfigKeyQuery" - } + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Extension removed successfully", @@ -106,12 +196,90 @@ } } }, - "/config/read": { + "/config/init": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "init_config", + "responses": { + "200": { + "description": "Config initialization check completed", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/permissions": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "upsert_permissions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertPermissionsQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Permission update completed", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request" + } + } + } + }, + "/config/providers": { "get": { "tags": [ "super::routes::config_management" ], - "summary": "Read a configuration value", + "operationId": "providers", + "responses": { + "200": { + "description": "All configuration values retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderDetails" + } + } + } + } + } + } + } + }, + "/config/read": { + "post": { + "tags": [ + "super::routes::config_management" + ], "operationId": "read_config", "requestBody": { "content": { @@ -143,7 +311,6 @@ "tags": [ "super::routes::config_management" ], - "summary": "Remove a configuration value", "operationId": "remove_config", "requestBody": { "content": { @@ -180,7 +347,6 @@ "tags": [ "super::routes::config_management" ], - "summary": "Upsert a configuration value", "operationId": "upsert_config", "requestBody": { "content": { @@ -209,20 +375,99 @@ } } }, + "/confirm": { + "post": { + "tags": [ + "super::routes::reply" + ], + "operationId": "confirm_permission", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionConfirmationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Permission action is confirmed", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/context/manage": { + "post": { + "tags": [ + "Context Management" + ], + "operationId": "manage_context", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContextManageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Context managed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContextManageResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "412": { + "description": "Precondition failed - Agent not available" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/schedule/create": { "post": { - "tags": ["schedule"], - "summary": "Create a new scheduled job", + "tags": [ + "schedule" + ], "operationId": "create_schedule", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateScheduleRequest" } } - } + }, + "required": true }, "responses": { "200": { @@ -241,47 +486,18 @@ } } }, - "/schedule/list": { - "get": { - "tags": ["schedule"], - "summary": "List all scheduled jobs", - "operationId": "list_schedules", - "responses": { - "200": { - "description": "A list of scheduled jobs", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jobs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduledJob" - } - } - } - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - } - }, "/schedule/delete/{id}": { "delete": { - "tags": ["schedule"], - "summary": "Delete a scheduled job by ID", + "tags": [ + "schedule" + ], "operationId": "delete_schedule", "parameters": [ { "name": "id", "in": "path", - "required": true, "description": "ID of the schedule to delete", + "required": true, "schema": { "type": "string" } @@ -300,17 +516,201 @@ } } }, - "/schedule/{id}/run_now": { - "post": { - "tags": ["schedule"], - "summary": "Run a scheduled job immediately", - "operationId": "run_schedule_now", + "/schedule/list": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "list_schedules", + "responses": { + "200": { + "description": "A list of scheduled jobs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSchedulesResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}": { + "put": { + "tags": [ + "schedule" + ], + "operationId": "update_schedule", "parameters": [ { "name": "id", "in": "path", + "description": "ID of the schedule to update", "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Scheduled job updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "400": { + "description": "Cannot update a currently running job or invalid request" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/inspect": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "inspect_running_job", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to inspect", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Running job information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InspectJobResponse" + } + } + } + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/kill": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "kill_running_job", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to kill", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Running job killed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KillJobResponse" + } + } + } + }, + "400": { + "description": "Job is not currently running" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/pause": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "pause_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to pause", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job paused successfully" + }, + "400": { + "description": "Cannot pause a currently running job" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/run_now": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "run_now_handler", + "parameters": [ + { + "name": "id", + "in": "path", "description": "ID of the schedule to run", + "required": true, "schema": { "type": "string" } @@ -338,15 +738,16 @@ }, "/schedule/{id}/sessions": { "get": { - "tags": ["schedule"], - "summary": "List sessions created by a specific schedule", - "operationId": "list_schedule_sessions", + "tags": [ + "schedule" + ], + "operationId": "sessions_handler", "parameters": [ { "name": "id", "in": "path", - "required": true, "description": "ID of the schedule", + "required": true, "schema": { "type": "string" } @@ -354,24 +755,23 @@ { "name": "limit", "in": "query", - "description": "Maximum number of sessions to return", "required": false, "schema": { "type": "integer", "format": "int32", - "default": 50 + "minimum": 0 } } ], "responses": { "200": { - "description": "A list of session metadata", + "description": "A list of session display info", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionMetadata" + "$ref": "#/components/schemas/SessionDisplayInfo" } } } @@ -382,19 +782,173 @@ } } } + }, + "/schedule/{id}/unpause": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "unpause_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to unpause", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job unpaused successfully" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/sessions": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "list_sessions", + "responses": { + "200": { + "description": "List of available sessions retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/sessions/{session_id}": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "get_session_history", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session history retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionHistoryResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } } }, "components": { "schemas": { + "Annotations": { + "type": "object", + "properties": { + "audience": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Role" + }, + "nullable": true + }, + "priority": { + "type": "number", + "format": "float", + "nullable": true + }, + "timestamp": { + "type": "string", + "format": "date-time", + "example": "2023-01-01T00:00:00Z" + } + } + }, + "ConfigKey": { + "type": "object", + "required": [ + "name", + "required", + "secret" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "secret": { + "type": "boolean" + } + } + }, "ConfigKeyQuery": { "type": "object", "required": [ - "key" + "key", + "is_secret" ], "properties": { + "is_secret": { + "type": "boolean" + }, "key": { - "type": "string", - "description": "The configuration key to operate on" + "type": "string" } } }, @@ -406,45 +960,134 @@ "properties": { "config": { "type": "object", - "description": "The configuration values", "additionalProperties": {} } } }, - "ExtensionQuery": { + "Content": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ImageContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/EmbeddedResource" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "resource" + ] + } + } + } + ] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "ContextLengthExceeded": { "type": "object", "required": [ - "name", - "config" + "msg" ], "properties": { - "config": { - "description": "The configuration for the extension" - }, - "name": { - "type": "string", - "description": "The name of the extension" + "msg": { + "type": "string" } } }, - "UpsertConfigQuery": { + "ContextManageRequest": { "type": "object", + "description": "Request payload for context management operations", "required": [ - "key", - "value" + "messages", + "manageAction" ], "properties": { - "is_secret": { - "type": "boolean", - "description": "Whether this configuration value should be treated as a secret", - "nullable": true - }, - "key": { + "manageAction": { "type": "string", - "description": "The configuration key to upsert" + "description": "Operation to perform: \"truncation\" or \"summarize\"" }, - "value": { - "description": "The value to set for the configuration" + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + }, + "description": "Collection of messages to be managed" + } + } + }, + "ContextManageResponse": { + "type": "object", + "description": "Response from context management operations", + "required": [ + "messages", + "tokenCounts" + ], + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + }, + "description": "Processed messages after the operation" + }, + "tokenCounts": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "description": "Token counts for each processed message" } } }, @@ -456,17 +1099,804 @@ "cron" ], "properties": { + "cron": { + "type": "string" + }, "id": { - "type": "string", - "description": "Unique ID for the new schedule." + "type": "string" }, "recipe_source": { - "type": "string", - "description": "Path to the recipe file to be executed by this schedule." + "type": "string" + } + } + }, + "EmbeddedResource": { + "type": "object", + "required": [ + "resource" + ], + "properties": { + "annotations": { + "allOf": [ + { + "$ref": "#/components/schemas/Annotations" + } + ], + "nullable": true }, - "cron": { + "resource": { + "$ref": "#/components/schemas/ResourceContents" + } + } + }, + "Envs": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host" + } + }, + "ExtensionConfig": { + "oneOf": [ + { + "type": "object", + "description": "Server-sent events client with a URI endpoint", + "required": [ + "name", + "uri", + "type" + ], + "properties": { + "bundled": { + "type": "boolean", + "description": "Whether this extension is bundled with Goose", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "env_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "envs": { + "$ref": "#/components/schemas/Envs" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "sse" + ] + }, + "uri": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Standard I/O client with command and arguments", + "required": [ + "name", + "cmd", + "args", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "bundled": { + "type": "boolean", + "description": "Whether this extension is bundled with Goose", + "nullable": true + }, + "cmd": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "env_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "envs": { + "$ref": "#/components/schemas/Envs" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "stdio" + ] + } + } + }, + { + "type": "object", + "description": "Built-in extension that is part of the goose binary", + "required": [ + "name", + "type" + ], + "properties": { + "bundled": { + "type": "boolean", + "description": "Whether this extension is bundled with Goose", + "nullable": true + }, + "display_name": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "builtin" + ] + } + } + }, + { + "type": "object", + "description": "Frontend-provided tools that will be called through the frontend", + "required": [ + "name", + "tools", + "type" + ], + "properties": { + "bundled": { + "type": "boolean", + "description": "Whether this extension is bundled with Goose", + "nullable": true + }, + "instructions": { + "type": "string", + "description": "Instructions for how to use these tools", + "nullable": true + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tool" + }, + "description": "The tools provided by the frontend" + }, + "type": { + "type": "string", + "enum": [ + "frontend" + ] + } + } + } + ], + "description": "Represents the different types of MCP extensions that can be added to the manager", + "discriminator": { + "propertyName": "type" + } + }, + "ExtensionEntry": { + "allOf": [ + { + "$ref": "#/components/schemas/ExtensionConfig" + }, + { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + } + ] + }, + "ExtensionQuery": { + "type": "object", + "required": [ + "name", + "config", + "enabled" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "ExtensionResponse": { + "type": "object", + "required": [ + "extensions" + ], + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionEntry" + } + } + } + }, + "FrontendToolRequest": { + "type": "object", + "required": [ + "id", + "toolCall" + ], + "properties": { + "id": { + "type": "string" + }, + "toolCall": { + "type": "object" + } + } + }, + "ImageContent": { + "type": "object", + "required": [ + "data", + "mimeType" + ], + "properties": { + "annotations": { + "allOf": [ + { + "$ref": "#/components/schemas/Annotations" + } + ], + "nullable": true + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + } + }, + "InspectJobResponse": { + "type": "object", + "properties": { + "processStartTime": { "type": "string", - "description": "Cron string defining when the job should run." + "nullable": true + }, + "runningDurationSeconds": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "KillJobResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "ListSchedulesResponse": { + "type": "object", + "required": [ + "jobs" + ], + "properties": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "Message": { + "type": "object", + "description": "A message to or from an LLM", + "required": [ + "role", + "created", + "content" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageContent" + } + }, + "created": { + "type": "integer", + "format": "int64" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + } + }, + "MessageContent": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ImageContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ToolRequest" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "toolRequest" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ToolResponse" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "toolResponse" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ToolConfirmationRequest" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "toolConfirmationRequest" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/FrontendToolRequest" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "frontendToolRequest" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ThinkingContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "thinking" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/RedactedThinkingContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "redactedThinking" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ContextLengthExceeded" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "contextLengthExceeded" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/SummarizationRequested" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "summarizationRequested" + ] + } + } + } + ] + } + ], + "description": "Content passed inside a message, which can be both simple content and tool content", + "discriminator": { + "propertyName": "type" + } + }, + "ModelInfo": { + "type": "object", + "description": "Information about a model's capabilities", + "required": [ + "name", + "context_limit" + ], + "properties": { + "context_limit": { + "type": "integer", + "description": "The maximum context length this model supports", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "The name of the model" + } + } + }, + "PermissionConfirmationRequest": { + "type": "object", + "required": [ + "id", + "action" + ], + "properties": { + "action": { + "type": "string" + }, + "id": { + "type": "string" + }, + "principal_type": { + "$ref": "#/components/schemas/PrincipalType" + } + } + }, + "PermissionLevel": { + "type": "string", + "description": "Enum representing the possible permission levels for a tool.", + "enum": [ + "always_allow", + "ask_before", + "never_allow" + ] + }, + "PrincipalType": { + "type": "string", + "enum": [ + "Extension", + "Tool" + ] + }, + "ProviderDetails": { + "type": "object", + "required": [ + "name", + "metadata", + "is_configured" + ], + "properties": { + "is_configured": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/ProviderMetadata" + }, + "name": { + "type": "string" + } + } + }, + "ProviderMetadata": { + "type": "object", + "description": "Metadata about a provider's configuration requirements and capabilities", + "required": [ + "name", + "display_name", + "description", + "default_model", + "known_models", + "model_doc_link", + "config_keys" + ], + "properties": { + "config_keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigKey" + }, + "description": "Required configuration keys" + }, + "default_model": { + "type": "string", + "description": "The default/recommended model for this provider" + }, + "description": { + "type": "string", + "description": "Description of the provider's capabilities" + }, + "display_name": { + "type": "string", + "description": "Display name for the provider in UIs" + }, + "known_models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelInfo" + }, + "description": "A list of currently known models with their capabilities\nTODO: eventually query the apis directly" + }, + "model_doc_link": { + "type": "string", + "description": "Link to the docs where models can be found" + }, + "name": { + "type": "string", + "description": "The unique identifier for this provider" + } + } + }, + "ProvidersResponse": { + "type": "object", + "required": [ + "providers" + ], + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderDetails" + } + } + } + }, + "RedactedThinkingContent": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "string" + } + } + }, + "ResourceContents": { + "oneOf": [ + { + "type": "object", + "required": [ + "uri", + "text" + ], + "properties": { + "mime_type": { + "type": "string", + "nullable": true + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "uri", + "blob" + ], + "properties": { + "blob": { + "type": "string" + }, + "mime_type": { + "type": "string", + "nullable": true + }, + "uri": { + "type": "string" + } + } + } + ] + }, + "Role": { + "type": "string", + "enum": [ + "user", + "assistant" + ] + }, + "RunNowResponse": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" } } }, @@ -478,96 +1908,503 @@ "cron" ], "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the scheduled job." - }, - "source": { - "type": "string", - "description": "Path to the recipe file for this job." - }, "cron": { + "type": "string" + }, + "current_session_id": { "type": "string", - "description": "Cron string defining the schedule." + "nullable": true + }, + "currently_running": { + "type": "boolean" + }, + "id": { + "type": "string" }, "last_run": { "type": "string", "format": "date-time", - "description": "Timestamp of the last time the job was run.", "nullable": true + }, + "paused": { + "type": "boolean" + }, + "process_start_time": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "source": { + "type": "string" + } + } + }, + "SessionDisplayInfo": { + "type": "object", + "required": [ + "id", + "name", + "createdAt", + "workingDir", + "messageCount" + ], + "properties": { + "accumulatedInputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedOutputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedTotalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "inputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "messageCount": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "outputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "scheduleId": { + "type": "string", + "nullable": true + }, + "totalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "workingDir": { + "type": "string" + } + } + }, + "SessionHistoryResponse": { + "type": "object", + "required": [ + "sessionId", + "metadata", + "messages" + ], + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + }, + "description": "List of messages in the session conversation" + }, + "metadata": { + "$ref": "#/components/schemas/SessionMetadata" + }, + "sessionId": { + "type": "string", + "description": "Unique identifier for the session" + } + } + }, + "SessionInfo": { + "type": "object", + "required": [ + "id", + "path", + "modified", + "metadata" + ], + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/SessionMetadata" + }, + "modified": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "SessionListResponse": { + "type": "object", + "required": [ + "sessions" + ], + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + }, + "description": "List of available session information objects" } } }, "SessionMetadata": { "type": "object", + "description": "Metadata for a session, stored as the first line in the session file", "required": [ "working_dir", "description", "message_count" ], "properties": { - "working_dir": { - "type": "string", - "description": "Working directory for the session." + "accumulated_input_tokens": { + "type": "integer", + "format": "int32", + "description": "The number of input tokens used in the session. Accumulated across all messages.", + "nullable": true + }, + "accumulated_output_tokens": { + "type": "integer", + "format": "int32", + "description": "The number of output tokens used in the session. Accumulated across all messages.", + "nullable": true + }, + "accumulated_total_tokens": { + "type": "integer", + "format": "int32", + "description": "The total number of tokens used in the session. Accumulated across all messages (useful for tracking cost over an entire session).", + "nullable": true }, "description": { "type": "string", - "description": "A short description of the session." + "description": "A short description of the session, typically 3 words or less" }, - "schedule_id": { - "type": "string", - "description": "ID of the schedule that triggered this session, if any.", + "input_tokens": { + "type": "integer", + "format": "int32", + "description": "The number of input tokens used in the session. Retrieved from the provider's last usage.", "nullable": true }, "message_count": { "type": "integer", - "format": "int64", - "description": "Number of messages in the session." - }, - "total_tokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "input_tokens": { - "type": "integer", - "format": "int32", - "nullable": true + "description": "Number of messages in the session", + "minimum": 0 }, "output_tokens": { - "type": "integer", - "format": "int32", - "nullable": true + "type": "integer", + "format": "int32", + "description": "The number of output tokens used in the session. Retrieved from the provider's last usage.", + "nullable": true }, - "accumulated_total_tokens": { - "type": "integer", - "format": "int32", - "nullable": true + "schedule_id": { + "type": "string", + "description": "ID of the schedule that triggered this session, if any", + "nullable": true }, - "accumulated_input_tokens": { - "type": "integer", - "format": "int32", - "nullable": true + "total_tokens": { + "type": "integer", + "format": "int32", + "description": "The total number of tokens used in the session. Retrieved from the provider's last usage.", + "nullable": true }, - "accumulated_output_tokens": { - "type": "integer", - "format": "int32", - "nullable": true + "working_dir": { + "type": "string", + "description": "Working directory for the session", + "example": "/home/user/sessions/session1" } } }, - "RunNowResponse": { + "SessionsQuery": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SummarizationRequested": { "type": "object", "required": [ - "session_id" + "msg" ], "properties": { - "session_id": { + "msg": { + "type": "string" + } + } + }, + "TextContent": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "annotations": { + "allOf": [ + { + "$ref": "#/components/schemas/Annotations" + } + ], + "nullable": true + }, + "text": { + "type": "string" + } + } + }, + "ThinkingContent": { + "type": "object", + "required": [ + "thinking", + "signature" + ], + "properties": { + "signature": { + "type": "string" + }, + "thinking": { + "type": "string" + } + } + }, + "Tool": { + "type": "object", + "description": "A tool that can be used by a model.", + "required": [ + "name", + "description", + "inputSchema" + ], + "properties": { + "annotations": { + "allOf": [ + { + "$ref": "#/components/schemas/ToolAnnotations" + } + ], + "nullable": true + }, + "description": { "type": "string", - "description": "The ID of the newly created session." + "description": "A description of what the tool does" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool" + }, + "name": { + "type": "string", + "description": "The name of the tool" + } + } + }, + "ToolAnnotations": { + "type": "object", + "description": "Additional properties describing a tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "type": "boolean", + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `read_only_hint == false`)\n\nDefault: true" + }, + "idempotentHint": { + "type": "boolean", + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `read_only_hint == false`)\n\nDefault: false" + }, + "openWorldHint": { + "type": "boolean", + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true" + }, + "readOnlyHint": { + "type": "boolean", + "description": "If true, the tool does not modify its environment.\n\nDefault: false" + }, + "title": { + "type": "string", + "description": "A human-readable title for the tool.", + "nullable": true + } + } + }, + "ToolConfirmationRequest": { + "type": "object", + "required": [ + "id", + "toolName", + "arguments" + ], + "properties": { + "arguments": {}, + "id": { + "type": "string" + }, + "prompt": { + "type": "string", + "nullable": true + }, + "toolName": { + "type": "string" + } + } + }, + "ToolInfo": { + "type": "object", + "description": "Information about the tool used for building prompts", + "required": [ + "name", + "description", + "parameters" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "type": "string" + } + }, + "permission": { + "allOf": [ + { + "$ref": "#/components/schemas/PermissionLevel" + } + ], + "nullable": true + } + } + }, + "ToolPermission": { + "type": "object", + "required": [ + "tool_name", + "permission" + ], + "properties": { + "permission": { + "$ref": "#/components/schemas/PermissionLevel" + }, + "tool_name": { + "type": "string" + } + } + }, + "ToolRequest": { + "type": "object", + "required": [ + "id", + "toolCall" + ], + "properties": { + "id": { + "type": "string" + }, + "toolCall": { + "type": "object" + } + } + }, + "ToolResponse": { + "type": "object", + "required": [ + "id", + "toolResult" + ], + "properties": { + "id": { + "type": "string" + }, + "toolResult": { + "type": "object" + } + } + }, + "ToolResultSchema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string", + "example": "Operation completed successfully", + "nullable": true + }, + "success": { + "type": "boolean", + "example": true + } + }, + "example": { + "data": {}, + "success": true + } + }, + "UpdateScheduleRequest": { + "type": "object", + "required": [ + "cron" + ], + "properties": { + "cron": { + "type": "string" + } + } + }, + "UpsertConfigQuery": { + "type": "object", + "required": [ + "key", + "value", + "is_secret" + ], + "properties": { + "is_secret": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "value": {} + } + }, + "UpsertPermissionsQuery": { + "type": "object", + "required": [ + "tool_permissions" + ], + "properties": { + "tool_permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolPermission" + } } } } } } -} +} \ No newline at end of file diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 6809dd73..4aa3f129 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -71,10 +71,10 @@ aws-sdk-bedrockruntime = "1.74.0" # For GCP Vertex AI provider auth jsonwebtoken = "9.3.1" -# Added blake3 hashing library as a dependency blake3 = "1.5" fs2 = "0.4.3" futures-util = "0.3.31" +tokio-stream = "0.1.17" # Vector database for tool selection lancedb = "0.13" diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index bc3badac..c5ac11cb 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use dotenv::dotenv; use futures::StreamExt; -use goose::agents::{Agent, ExtensionConfig}; +use goose::agents::{Agent, AgentEvent, ExtensionConfig}; use goose::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT}; use goose::message::Message; use goose::providers::databricks::DatabricksProvider; @@ -20,10 +20,11 @@ async fn main() { let config = ExtensionConfig::stdio( "developer", - "./target/debug/developer", + "./target/debug/goose", DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT, - ); + ) + .with_args(vec!["mcp", "developer"]); agent.add_extension(config).await.unwrap(); println!("Extensions:"); @@ -35,11 +36,8 @@ async fn main() { .with_text("can you summarize the readme.md in this dir using just a haiku?")]; let mut stream = agent.reply(&messages, None).await.unwrap(); - while let Some(message) = stream.next().await { - println!( - "{}", - serde_json::to_string_pretty(&message.unwrap()).unwrap() - ); + while let Some(Ok(AgentEvent::Message(message))) = stream.next().await { + println!("{}", serde_json::to_string_pretty(&message).unwrap()); println!("\n"); } } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 02bda342..99160300 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1,9 +1,14 @@ use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use anyhow::{anyhow, Result}; use futures::stream::BoxStream; -use futures::TryStreamExt; +use futures::{FutureExt, Stream, TryStreamExt}; +use futures_util::stream; +use futures_util::stream::StreamExt; +use mcp_core::protocol::JsonRpcMessage; use crate::config::{Config, ExtensionConfigManager, PermissionManager}; use crate::message::Message; @@ -39,7 +44,7 @@ use mcp_core::{ use super::platform_tools; use super::router_tools; -use super::tool_execution::{ToolFuture, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; +use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; /// The main goose Agent pub struct Agent { @@ -56,6 +61,12 @@ pub struct Agent { pub(super) router_tool_selector: Mutex>>>, } +#[derive(Clone, Debug)] +pub enum AgentEvent { + Message(Message), + McpNotification((String, JsonRpcMessage)), +} + impl Agent { pub fn new() -> Self { // Create channels with buffer size 32 (adjust if needed) @@ -100,6 +111,40 @@ impl Default for Agent { } } +pub enum ToolStreamItem { + Message(JsonRpcMessage), + Result(T), +} + +pub type ToolStream = Pin>>> + Send>>; + +// tool_stream combines a stream of JsonRpcMessages with a future representing the +// final result of the tool call. MCP notifications are not request-scoped, but +// this lets us capture all notifications emitted during the tool call for +// simpler consumption +pub fn tool_stream(rx: S, done: F) -> ToolStream +where + S: Stream + Send + Unpin + 'static, + F: Future>> + Send + 'static, +{ + Box::pin(async_stream::stream! { + tokio::pin!(done); + let mut rx = rx; + + loop { + tokio::select! { + Some(msg) = rx.next() => { + yield ToolStreamItem::Message(msg); + } + r = &mut done => { + yield ToolStreamItem::Result(r); + break; + } + } + } + }) +} + impl Agent { /// Get a reference count clone to the provider pub async fn provider(&self) -> Result, anyhow::Error> { @@ -143,7 +188,7 @@ impl Agent { &self, tool_call: mcp_core::tool::ToolCall, request_id: String, - ) -> (String, Result, ToolError>) { + ) -> (String, Result) { // Check if this tool call should be allowed based on repetition monitoring if let Some(monitor) = self.tool_monitor.lock().await.as_mut() { let tool_call_info = ToolCall::new(tool_call.name.clone(), tool_call.arguments.clone()); @@ -171,52 +216,65 @@ impl Agent { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - return self + let (request_id, result) = self .manage_extensions(action, extension_name, request_id) .await; + + return (request_id, Ok(ToolCallResult::from(result))); } let extension_manager = self.extension_manager.lock().await; - let result = if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME { + let result: ToolCallResult = if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME { // Check if the tool is read_resource and handle it separately - extension_manager - .read_resource(tool_call.arguments.clone()) - .await + ToolCallResult::from( + extension_manager + .read_resource(tool_call.arguments.clone()) + .await, + ) } else if tool_call.name == PLATFORM_LIST_RESOURCES_TOOL_NAME { - extension_manager - .list_resources(tool_call.arguments.clone()) - .await + ToolCallResult::from( + extension_manager + .list_resources(tool_call.arguments.clone()) + .await, + ) } else if tool_call.name == PLATFORM_SEARCH_AVAILABLE_EXTENSIONS_TOOL_NAME { - extension_manager.search_available_extensions().await + ToolCallResult::from(extension_manager.search_available_extensions().await) } else if self.is_frontend_tool(&tool_call.name).await { // For frontend tools, return an error indicating we need frontend execution - Err(ToolError::ExecutionError( + ToolCallResult::from(Err(ToolError::ExecutionError( "Frontend tool execution required".to_string(), - )) + ))) } else if tool_call.name == ROUTER_VECTOR_SEARCH_TOOL_NAME { let selector = self.router_tool_selector.lock().await.clone(); - if let Some(selector) = selector { + ToolCallResult::from(if let Some(selector) = selector { selector.select_tools(tool_call.arguments.clone()).await } else { Err(ToolError::ExecutionError( "Encountered vector search error.".to_string(), )) - } + }) } else { - extension_manager + // Clone the result to ensure no references to extension_manager are returned + let result = extension_manager .dispatch_tool_call(tool_call.clone()) - .await + .await; + match result { + Ok(call_result) => call_result, + Err(e) => ToolCallResult::from(Err(ToolError::ExecutionError(e.to_string()))), + } }; - debug!( - "input" = serde_json::to_string(&tool_call).unwrap(), - "output" = serde_json::to_string(&result).unwrap(), - ); - - // Process the response to handle large text content - let processed_result = super::large_response_handler::process_tool_response(result); - - (request_id, processed_result) + ( + request_id, + Ok(ToolCallResult { + notification_stream: result.notification_stream, + result: Box::new( + result + .result + .map(super::large_response_handler::process_tool_response), + ), + }), + ) } pub(super) async fn manage_extensions( @@ -466,7 +524,7 @@ impl Agent { &self, messages: &[Message], session: Option, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { let mut messages = messages.to_vec(); let reply_span = tracing::Span::current(); @@ -532,9 +590,8 @@ impl Agent { } } } - // Yield the assistant's response with frontend tool requests filtered out - yield filtered_response.clone(); + yield AgentEvent::Message(filtered_response.clone()); tokio::task::yield_now().await; @@ -556,7 +613,7 @@ impl Agent { // execution is yeield back to this reply loop, and is of the same Message // type, so we can yield that back up to be handled while let Some(msg) = frontend_tool_stream.try_next().await? { - yield msg; + yield AgentEvent::Message(msg); } // Clone goose_mode once before the match to avoid move issues @@ -584,13 +641,23 @@ impl Agent { self.provider().await?).await; // Handle pre-approved and read-only tools in parallel - let mut tool_futures: Vec = Vec::new(); + let mut tool_futures: Vec<(String, ToolStream)> = Vec::new(); // Skip the confirmation for approved tools for request in &permission_check_result.approved { if let Ok(tool_call) = request.tool_call.clone() { - let tool_future = self.dispatch_tool_call(tool_call, request.id.clone()); - tool_futures.push(Box::pin(tool_future)); + let (req_id, tool_result) = self.dispatch_tool_call(tool_call, request.id.clone()).await; + + tool_futures.push((req_id, match tool_result { + Ok(result) => tool_stream( + result.notification_stream.unwrap_or_else(|| Box::new(stream::empty())), + result.result, + ), + Err(e) => tool_stream( + Box::new(stream::empty()), + futures::future::ready(Err(e)), + ), + })); } } @@ -618,7 +685,7 @@ impl Agent { // type, so we can yield the Message back up to be handled and grab any // confirmations or denials while let Some(msg) = tool_approval_stream.try_next().await? { - yield msg; + yield AgentEvent::Message(msg); } tool_futures = { @@ -628,16 +695,30 @@ impl Agent { futures_lock.drain(..).collect::>() }; - // Wait for all tool calls to complete - let results = futures::future::join_all(tool_futures).await; + let with_id = tool_futures + .into_iter() + .map(|(request_id, stream)| { + stream.map(move |item| (request_id.clone(), item)) + }) + .collect::>(); + + let mut combined = stream::select_all(with_id); + let mut all_install_successful = true; - for (request_id, output) in results.into_iter() { - if enable_extension_request_ids.contains(&request_id) && output.is_err(){ - all_install_successful = false; + while let Some((request_id, item)) = combined.next().await { + match item { + ToolStreamItem::Result(output) => { + if enable_extension_request_ids.contains(&request_id) && output.is_err(){ + all_install_successful = false; + } + let mut response = message_tool_response.lock().await; + *response = response.clone().with_tool_response(request_id, output); + }, + ToolStreamItem::Message(msg) => { + yield AgentEvent::McpNotification((request_id, msg)) + } } - let mut response = message_tool_response.lock().await; - *response = response.clone().with_tool_response(request_id, output); } // Update system prompt and tools if installations were successful @@ -647,7 +728,7 @@ impl Agent { } let final_message_tool_resp = message_tool_response.lock().await.clone(); - yield final_message_tool_resp.clone(); + yield AgentEvent::Message(final_message_tool_resp.clone()); messages.push(response); messages.push(final_message_tool_resp); @@ -656,15 +737,15 @@ impl Agent { // At this point, the last message should be a user message // because call to provider led to context length exceeded error // Immediately yield a special message and break - yield Message::assistant().with_context_length_exceeded( + yield AgentEvent::Message(Message::assistant().with_context_length_exceeded( "The context length of the model has been exceeded. Please start a new session and try again.", - ); + )); break; }, Err(e) => { // Create an error message & terminate the stream error!("Error: {}", e); - yield Message::assistant().with_text(format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.")); + yield AgentEvent::Message(Message::assistant().with_text(format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error."))); break; } } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 4b03a99a..69823c35 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -1,8 +1,7 @@ use anyhow::Result; use chrono::{DateTime, TimeZone, Utc}; -use futures::future; use futures::stream::{FuturesUnordered, StreamExt}; -use mcp_client::McpService; +use futures::{future, FutureExt}; use mcp_core::protocol::GetPromptResult; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -10,15 +9,22 @@ use std::sync::LazyLock; use std::time::Duration; use tokio::sync::Mutex; use tokio::task; -use tracing::{debug, error, warn}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{error, warn}; use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo}; +use super::tool_execution::ToolCallResult; use crate::agents::extension::Envs; use crate::config::{Config, ExtensionConfigManager}; use crate::prompt_template; use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}; +<<<<<<< HEAD use mcp_client::transport::{PendingRequests, SseTransport, StdioTransport, Transport}; use mcp_core::{prompt::Prompt, Content, Tool, ToolCall, ToolError, ToolResult}; +======= +use mcp_client::transport::{SseTransport, StdioTransport, Transport}; +use mcp_core::{prompt::Prompt, Content, Tool, ToolCall, ToolError}; +>>>>>>> 2f8f8e5767bb1fdc53dfaa4a492c9184f02c3721 use serde_json::Value; // By default, we set it to Jan 1, 2020 if the resource does not have a timestamp @@ -115,7 +121,8 @@ impl ExtensionManager { /// Add a new MCP extension based on the provided client type // TODO IMPORTANT need to ensure this times out if the extension command is broken! pub async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()> { - let sanitized_name = normalize(config.key().to_string()); + let config_name = config.key().to_string(); + let sanitized_name = normalize(config_name.clone()); /// Helper function to merge environment variables from direct envs and keychain-stored env_keys async fn merge_environments( @@ -185,6 +192,7 @@ impl ExtensionManager { let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?; let transport = SseTransport::new(uri, all_envs); let handle = transport.start().await?; +<<<<<<< HEAD let pending = handle.pending_requests(); let service = McpService::with_timeout( handle, @@ -194,6 +202,17 @@ impl ExtensionManager { ); self.pending_requests.insert(sanitized_name.clone(), pending); Box::new(McpClient::new(service)) +======= + Box::new( + McpClient::connect( + handle, + Duration::from_secs( + timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), + ), + ) + .await?, + ) +>>>>>>> 2f8f8e5767bb1fdc53dfaa4a492c9184f02c3721 } ExtensionConfig::Stdio { cmd, @@ -206,6 +225,7 @@ impl ExtensionManager { let all_envs = merge_environments(envs, env_keys, &sanitized_name).await?; let transport = StdioTransport::new(cmd, args.to_vec(), all_envs); let handle = transport.start().await?; +<<<<<<< HEAD let pending = handle.pending_requests(); let service = McpService::with_timeout( handle, @@ -215,6 +235,17 @@ impl ExtensionManager { ); self.pending_requests.insert(sanitized_name.clone(), pending); Box::new(McpClient::new(service)) +======= + Box::new( + McpClient::connect( + handle, + Duration::from_secs( + timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), + ), + ) + .await?, + ) +>>>>>>> 2f8f8e5767bb1fdc53dfaa4a492c9184f02c3721 } ExtensionConfig::Builtin { name, @@ -233,6 +264,7 @@ impl ExtensionManager { HashMap::new(), ); let handle = transport.start().await?; +<<<<<<< HEAD let pending = handle.pending_requests(); let service = McpService::with_timeout( handle, @@ -242,6 +274,17 @@ impl ExtensionManager { ); self.pending_requests.insert(sanitized_name.clone(), pending); Box::new(McpClient::new(service)) +======= + Box::new( + McpClient::connect( + handle, + Duration::from_secs( + timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT), + ), + ) + .await?, + ) +>>>>>>> 2f8f8e5767bb1fdc53dfaa4a492c9184f02c3721 } _ => unreachable!(), }; @@ -627,7 +670,7 @@ impl ExtensionManager { } } - pub async fn dispatch_tool_call(&self, tool_call: ToolCall) -> ToolResult> { + pub async fn dispatch_tool_call(&self, tool_call: ToolCall) -> Result { // Dispatch tool call based on the prefix naming convention let (client_name, client) = self .get_client_for_tool(&tool_call.name) @@ -638,22 +681,26 @@ impl ExtensionManager { .name .strip_prefix(client_name) .and_then(|s| s.strip_prefix("__")) - .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; + .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))? + .to_string(); - let client_guard = client.lock().await; + let arguments = tool_call.arguments.clone(); + let client = client.clone(); + let notifications_receiver = client.lock().await.subscribe().await; - let result = client_guard - .call_tool(tool_name, tool_call.clone().arguments) - .await - .map(|result| result.content) - .map_err(|e| ToolError::ExecutionError(e.to_string())); + let fut = async move { + let client_guard = client.lock().await; + client_guard + .call_tool(&tool_name, arguments) + .await + .map(|call| call.content) + .map_err(|e| ToolError::ExecutionError(e.to_string())) + }; - debug!( - "input" = serde_json::to_string(&tool_call).unwrap(), - "output" = serde_json::to_string(&result).unwrap(), - ); - - result + Ok(ToolCallResult { + result: Box::new(fut.boxed()), + notification_stream: Some(Box::new(ReceiverStream::new(notifications_receiver))), + }) } pub async fn list_prompts_from_extension( @@ -811,10 +858,11 @@ mod tests { use mcp_client::client::Error; use mcp_client::client::McpClientTrait; use mcp_core::protocol::{ - CallToolResult, GetPromptResult, InitializeResult, ListPromptsResult, ListResourcesResult, - ListToolsResult, ReadResourceResult, + CallToolResult, GetPromptResult, InitializeResult, JsonRpcMessage, ListPromptsResult, + ListResourcesResult, ListToolsResult, ReadResourceResult, }; use serde_json::json; + use tokio::sync::mpsc; struct MockClient {} @@ -867,6 +915,10 @@ mod tests { ) -> Result { Err(Error::NotInitialized) } + + async fn subscribe(&self) -> mpsc::Receiver { + mpsc::channel(1).1 + } } #[test] @@ -988,6 +1040,9 @@ mod tests { let result = extension_manager .dispatch_tool_call(invalid_tool_call) + .await + .unwrap() + .result .await; assert!(matches!( result.err().unwrap(), @@ -1004,6 +1059,11 @@ mod tests { let result = extension_manager .dispatch_tool_call(invalid_tool_call) .await; - assert!(matches!(result.err().unwrap(), ToolError::NotFound(_))); + if let Err(err) = result { + let tool_err = err.downcast_ref::().expect("Expected ToolError"); + assert!(matches!(tool_err, ToolError::NotFound(_))); + } else { + panic!("Expected ToolError::NotFound"); + } } } diff --git a/crates/goose/src/agents/large_response_handler.rs b/crates/goose/src/agents/large_response_handler.rs index 29141bc8..e4c0ab10 100644 --- a/crates/goose/src/agents/large_response_handler.rs +++ b/crates/goose/src/agents/large_response_handler.rs @@ -3,8 +3,7 @@ use mcp_core::{Content, ToolError}; use std::fs::File; use std::io::Write; -// Constant for the size threshold (20K characters) -const LARGE_TEXT_THRESHOLD: usize = 20_000; +const LARGE_TEXT_THRESHOLD: usize = 200_000; /// Process tool response and handle large text content pub fn process_tool_response( diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 54326285..24511ac6 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -13,7 +13,7 @@ mod tool_router_index_manager; pub(crate) mod tool_vectordb; mod types; -pub use agent::Agent; +pub use agent::{Agent, AgentEvent}; pub use extension::ExtensionConfig; pub use extension_manager::ExtensionManager; pub use prompt_manager::PromptManager; diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 0d7d8cbd..5b4b6d71 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -8,7 +8,8 @@ use crate::message::{Message, MessageContent, ToolRequest}; use crate::providers::base::{Provider, ProviderUsage}; use crate::providers::errors::ProviderError; use crate::providers::toolshim::{ - augment_message_with_tool_calls, modify_system_prompt_for_tool_json, OllamaInterpreter, + augment_message_with_tool_calls, convert_tool_messages_to_text, + modify_system_prompt_for_tool_json, OllamaInterpreter, }; use crate::session; use mcp_core::tool::Tool; @@ -110,8 +111,17 @@ impl Agent { ) -> Result<(Message, ProviderUsage), ProviderError> { let config = provider.get_model_config(); + // Convert tool messages to text if toolshim is enabled + let messages_for_provider = if config.toolshim { + convert_tool_messages_to_text(messages) + } else { + messages.to_vec() + }; + // Call the provider to get a response - let (mut response, usage) = provider.complete(system_prompt, messages, tools).await?; + let (mut response, usage) = provider + .complete(system_prompt, &messages_for_provider, tools) + .await?; // Store the model information in the global store crate::providers::base::set_current_model(&usage.model); diff --git a/crates/goose/src/agents/router_tool_selector.rs b/crates/goose/src/agents/router_tool_selector.rs index 62d28198..316197ea 100644 --- a/crates/goose/src/agents/router_tool_selector.rs +++ b/crates/goose/src/agents/router_tool_selector.rs @@ -39,13 +39,13 @@ impl VectorToolSelector { pub async fn new(provider: Arc, table_name: String) -> Result { let vector_db = ToolVectorDB::new(Some(table_name)).await?; - let embedding_provider = if env::var("EMBEDDING_MODEL_PROVIDER").is_ok() { + let embedding_provider = if env::var("GOOSE_EMBEDDING_MODEL_PROVIDER").is_ok() { // If env var is set, create a new provider for embeddings // Get embedding model and provider from environment variables - let embedding_model = env::var("EMBEDDING_MODEL") + let embedding_model = env::var("GOOSE_EMBEDDING_MODEL") .unwrap_or_else(|_| "text-embedding-3-small".to_string()); let embedding_provider_name = - env::var("EMBEDDING_MODEL_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + env::var("GOOSE_EMBEDDING_MODEL_PROVIDER").unwrap_or_else(|_| "openai".to_string()); // Create the provider using the factory let model_config = ModelConfig::new(embedding_model); diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index beffae66..446d1f58 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -1,23 +1,35 @@ use std::future::Future; -use std::pin::Pin; use std::sync::Arc; use async_stream::try_stream; -use futures::stream::BoxStream; -use futures::StreamExt; +use futures::stream::{self, BoxStream}; +use futures::{Stream, StreamExt}; +use mcp_core::protocol::JsonRpcMessage; use tokio::sync::Mutex; use crate::config::permission::PermissionLevel; use crate::config::PermissionManager; use crate::message::{Message, ToolRequest}; use crate::permission::Permission; -use mcp_core::{Content, ToolError}; +use mcp_core::{Content, ToolResult}; -// Type alias for ToolFutures - used in the agent loop to join all futures together -pub(crate) type ToolFuture<'a> = - Pin, ToolError>)> + Send + 'a>>; -pub(crate) type ToolFuturesVec<'a> = Arc>>>; +// ToolCallResult combines the result of a tool call with an optional notification stream that +// can be used to receive notifications from the tool. +pub struct ToolCallResult { + pub result: Box>> + Send + Unpin>, + pub notification_stream: Option + Send + Unpin>>, +} +impl From>> for ToolCallResult { + fn from(result: ToolResult>) -> Self { + Self { + result: Box::new(futures::future::ready(result)), + notification_stream: None, + } + } +} + +use super::agent::{tool_stream, ToolStream}; use crate::agents::Agent; pub const DECLINED_RESPONSE: &str = "The user has declined to run this tool. \ @@ -37,7 +49,7 @@ impl Agent { pub(crate) fn handle_approval_tool_requests<'a>( &'a self, tool_requests: &'a [ToolRequest], - tool_futures: ToolFuturesVec<'a>, + tool_futures: Arc>>, permission_manager: &'a mut PermissionManager, message_tool_response: Arc>, ) -> BoxStream<'a, anyhow::Result> { @@ -56,9 +68,19 @@ impl Agent { while let Some((req_id, confirmation)) = rx.recv().await { if req_id == request.id { if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow { - let tool_future = self.dispatch_tool_call(tool_call.clone(), request.id.clone()); + let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone()).await; let mut futures = tool_futures.lock().await; - futures.push(Box::pin(tool_future)); + + futures.push((req_id, match tool_result { + Ok(result) => tool_stream( + result.notification_stream.unwrap_or_else(|| Box::new(stream::empty())), + result.result, + ), + Err(e) => tool_stream( + Box::new(stream::empty()), + futures::future::ready(Err(e)), + ), + })); if confirmation.permission == Permission::AlwaysAllow { permission_manager.update_user_permission(&tool_call.name, PermissionLevel::AlwaysAllow); diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index c7062642..2059ab00 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -148,6 +148,12 @@ impl Usage { use async_trait::async_trait; +/// Trait for LeadWorkerProvider-specific functionality +pub trait LeadWorkerProviderTrait { + /// Get information about the lead and worker models for logging + fn get_model_info(&self) -> (String, String); +} + /// Base trait for AI providers (OpenAI, Anthropic, etc) #[async_trait] pub trait Provider: Send + Sync { @@ -195,6 +201,12 @@ pub trait Provider: Send + Sync { "This provider does not support embeddings".to_string(), )) } + + /// Check if this provider is a LeadWorkerProvider + /// This is used for logging model information at startup + fn as_lead_worker(&self) -> Option<&dyn LeadWorkerProviderTrait> { + None + } } #[cfg(test)] diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index 7a04407a..bccae364 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -17,6 +17,7 @@ use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::time::Duration; +use tokio::time::sleep; const DEFAULT_CLIENT_ID: &str = "databricks-cli"; const DEFAULT_REDIRECT_URL: &str = "http://localhost:8020"; @@ -24,6 +25,17 @@ const DEFAULT_REDIRECT_URL: &str = "http://localhost:8020"; // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess const DEFAULT_SCOPES: &[&str] = &["all-apis", "offline_access"]; +/// Default timeout for API requests in seconds +const DEFAULT_TIMEOUT_SECS: u64 = 600; +/// Default initial interval for retry (in milliseconds) +const DEFAULT_INITIAL_RETRY_INTERVAL_MS: u64 = 5000; +/// Default maximum number of retries +const DEFAULT_MAX_RETRIES: usize = 6; +/// Default retry backoff multiplier +const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0; +/// Default maximum interval for retry (in milliseconds) +const DEFAULT_MAX_RETRY_INTERVAL_MS: u64 = 320_000; + pub const DATABRICKS_DEFAULT_MODEL: &str = "databricks-claude-3-7-sonnet"; // Databricks can passthrough to a wide range of models, we only provide the default pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ @@ -36,6 +48,53 @@ pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ pub const DATABRICKS_DOC_URL: &str = "https://docs.databricks.com/en/generative-ai/external-models/index.html"; +/// Retry configuration for handling rate limit errors +#[derive(Debug, Clone)] +struct RetryConfig { + /// Maximum number of retry attempts + max_retries: usize, + /// Initial interval between retries in milliseconds + initial_interval_ms: u64, + /// Multiplier for backoff (exponential) + backoff_multiplier: f64, + /// Maximum interval between retries in milliseconds + max_interval_ms: u64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_retries: DEFAULT_MAX_RETRIES, + initial_interval_ms: DEFAULT_INITIAL_RETRY_INTERVAL_MS, + backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER, + max_interval_ms: DEFAULT_MAX_RETRY_INTERVAL_MS, + } + } +} + +impl RetryConfig { + /// Calculate the delay for a specific retry attempt (with jitter) + fn delay_for_attempt(&self, attempt: usize) -> Duration { + if attempt == 0 { + return Duration::from_millis(0); + } + + // Calculate exponential backoff + let exponent = (attempt - 1) as u32; + let base_delay_ms = (self.initial_interval_ms as f64 + * self.backoff_multiplier.powi(exponent as i32)) as u64; + + // Apply max limit + let capped_delay_ms = std::cmp::min(base_delay_ms, self.max_interval_ms); + + // Add jitter (+/-20% randomness) to avoid thundering herd problem + let jitter_factor = 0.8 + (rand::random::() * 0.4); // Between 0.8 and 1.2 + let jittered_delay_ms = (capped_delay_ms as f64 * jitter_factor) as u64; + + Duration::from_millis(jittered_delay_ms) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DatabricksAuth { Token(String), @@ -70,6 +129,8 @@ pub struct DatabricksProvider { auth: DatabricksAuth, model: ModelConfig, image_format: ImageFormat, + #[serde(skip)] + retry_config: RetryConfig, } impl Default for DatabricksProvider { @@ -100,9 +161,12 @@ impl DatabricksProvider { let host = host?; let client = Client::builder() - .timeout(Duration::from_secs(600)) + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) .build()?; + // Load optional retry configuration from environment + let retry_config = Self::load_retry_config(config); + // If we find a databricks token we prefer that if let Ok(api_key) = config.get_secret("DATABRICKS_TOKEN") { return Ok(Self { @@ -111,6 +175,7 @@ impl DatabricksProvider { auth: DatabricksAuth::token(api_key), model, image_format: ImageFormat::OpenAi, + retry_config, }); } @@ -121,9 +186,44 @@ impl DatabricksProvider { host, model, image_format: ImageFormat::OpenAi, + retry_config, }) } + /// Loads retry configuration from environment variables or uses defaults. + fn load_retry_config(config: &crate::config::Config) -> RetryConfig { + let max_retries = config + .get_param("DATABRICKS_MAX_RETRIES") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_MAX_RETRIES); + + let initial_interval_ms = config + .get_param("DATABRICKS_INITIAL_RETRY_INTERVAL_MS") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_INITIAL_RETRY_INTERVAL_MS); + + let backoff_multiplier = config + .get_param("DATABRICKS_BACKOFF_MULTIPLIER") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_BACKOFF_MULTIPLIER); + + let max_interval_ms = config + .get_param("DATABRICKS_MAX_RETRY_INTERVAL_MS") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL_MS); + + RetryConfig { + max_retries, + initial_interval_ms, + backoff_multiplier, + max_interval_ms, + } + } + /// Create a new DatabricksProvider with the specified host and token /// /// # Arguments @@ -145,6 +245,7 @@ impl DatabricksProvider { auth: DatabricksAuth::token(api_key), model, image_format: ImageFormat::OpenAi, + retry_config: RetryConfig::default(), }) } @@ -182,70 +283,148 @@ impl DatabricksProvider { ProviderError::RequestFailed(format!("Failed to construct endpoint URL: {e}")) })?; - let auth_header = self.ensure_auth_header().await?; - let response = self - .client - .post(url) - .header("Authorization", auth_header) - .json(&payload) - .send() - .await?; + // Initialize retry counter + let mut attempts = 0; + let mut last_error = None; - let status = response.status(); - let payload: Option = response.json().await.ok(); - - match status { - StatusCode::OK => payload.ok_or_else(|| ProviderError::RequestFailed("Response body is not valid JSON".to_string())), - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { - Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ - Status: {}. Response: {:?}", status, payload))) - } - StatusCode::BAD_REQUEST => { - // Databricks provides a generic 'error' but also includes 'external_model_message' which is provider specific - // We try to extract the error message from the payload and check for phrases that indicate context length exceeded - let payload_str = serde_json::to_string(&payload).unwrap_or_default().to_lowercase(); - let check_phrases = [ - "too long", - "context length", - "context_length_exceeded", - "reduce the length", - "token count", - "exceeds", - ]; - if check_phrases.iter().any(|c| payload_str.contains(c)) { - return Err(ProviderError::ContextLengthExceeded(payload_str)); - } - - let mut error_msg = "Unknown error".to_string(); - if let Some(payload) = &payload { - // try to convert message to string, if that fails use external_model_message - error_msg = payload - .get("message") - .and_then(|m| m.as_str()) - .or_else(|| { - payload.get("external_model_message") - .and_then(|ext| ext.get("message")) - .and_then(|m| m.as_str()) - }) - .unwrap_or("Unknown error").to_string(); - } - - tracing::debug!( - "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + loop { + // Check if we've exceeded max retries + if attempts > 0 && attempts > self.retry_config.max_retries { + let error_msg = format!( + "Exceeded maximum retry attempts ({}) for rate limiting (429)", + self.retry_config.max_retries ); - Err(ProviderError::RequestFailed(format!("Request failed with status: {}. Message: {}", status, error_msg))) + tracing::error!("{}", error_msg); + return Err(last_error.unwrap_or(ProviderError::RateLimitExceeded(error_msg))); } - StatusCode::TOO_MANY_REQUESTS => { - Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) - } - StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { - Err(ProviderError::ServerError(format!("{:?}", payload))) - } - _ => { - tracing::debug!( - "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) - ); - Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + + let auth_header = self.ensure_auth_header().await?; + let response = self + .client + .post(url.clone()) + .header("Authorization", auth_header) + .json(&payload) + .send() + .await?; + + let status = response.status(); + let payload: Option = response.json().await.ok(); + + match status { + StatusCode::OK => { + return payload.ok_or_else(|| { + ProviderError::RequestFailed("Response body is not valid JSON".to_string()) + }); + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + return Err(ProviderError::Authentication(format!( + "Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", + status, payload + ))); + } + StatusCode::BAD_REQUEST => { + // Databricks provides a generic 'error' but also includes 'external_model_message' which is provider specific + // We try to extract the error message from the payload and check for phrases that indicate context length exceeded + let payload_str = serde_json::to_string(&payload) + .unwrap_or_default() + .to_lowercase(); + let check_phrases = [ + "too long", + "context length", + "context_length_exceeded", + "reduce the length", + "token count", + "exceeds", + "exceed context limit", + "input length", + "max_tokens", + "decrease input length", + "context limit", + ]; + if check_phrases.iter().any(|c| payload_str.contains(c)) { + return Err(ProviderError::ContextLengthExceeded(payload_str)); + } + + let mut error_msg = "Unknown error".to_string(); + if let Some(payload) = &payload { + // try to convert message to string, if that fails use external_model_message + error_msg = payload + .get("message") + .and_then(|m| m.as_str()) + .or_else(|| { + payload + .get("external_model_message") + .and_then(|ext| ext.get("message")) + .and_then(|m| m.as_str()) + }) + .unwrap_or("Unknown error") + .to_string(); + } + + tracing::debug!( + "{}", + format!( + "Provider request failed with status: {}. Payload: {:?}", + status, payload + ) + ); + return Err(ProviderError::RequestFailed(format!( + "Request failed with status: {}. Message: {}", + status, error_msg + ))); + } + StatusCode::TOO_MANY_REQUESTS => { + attempts += 1; + let error_msg = format!( + "Rate limit exceeded (attempt {}/{}): {:?}", + attempts, self.retry_config.max_retries, payload + ); + tracing::warn!("{}. Retrying after backoff...", error_msg); + + // Store the error in case we need to return it after max retries + last_error = Some(ProviderError::RateLimitExceeded(error_msg)); + + // Calculate and apply the backoff delay + let delay = self.retry_config.delay_for_attempt(attempts); + tracing::info!("Backing off for {:?} before retry", delay); + sleep(delay).await; + + // Continue to the next retry attempt + continue; + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + attempts += 1; + let error_msg = format!( + "Server error (attempt {}/{}): {:?}", + attempts, self.retry_config.max_retries, payload + ); + tracing::warn!("{}. Retrying after backoff...", error_msg); + + // Store the error in case we need to return it after max retries + last_error = Some(ProviderError::ServerError(error_msg)); + + // Calculate and apply the backoff delay + let delay = self.retry_config.delay_for_attempt(attempts); + tracing::info!("Backing off for {:?} before retry", delay); + sleep(delay).await; + + // Continue to the next retry attempt + continue; + } + _ => { + tracing::debug!( + "{}", + format!( + "Provider request failed with status: {}. Payload: {:?}", + status, payload + ) + ); + return Err(ProviderError::RequestFailed(format!( + "Request failed with status: {}", + status + ))); + } } } } diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index da65c9ef..22bdaa95 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -10,14 +10,31 @@ use super::{ githubcopilot::GithubCopilotProvider, google::GoogleProvider, groq::GroqProvider, + lead_worker::LeadWorkerProvider, ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider, + snowflake::SnowflakeProvider, venice::VeniceProvider, }; use crate::model::ModelConfig; use anyhow::Result; +#[cfg(test)] +use super::errors::ProviderError; +#[cfg(test)] +use mcp_core::tool::Tool; + +fn default_lead_turns() -> usize { + 3 +} +fn default_failure_threshold() -> usize { + 2 +} +fn default_fallback_turns() -> usize { + 2 +} + pub fn providers() -> Vec { vec![ AnthropicProvider::metadata(), @@ -32,10 +49,67 @@ pub fn providers() -> Vec { OpenAiProvider::metadata(), OpenRouterProvider::metadata(), VeniceProvider::metadata(), + SnowflakeProvider::metadata(), ] } pub fn create(name: &str, model: ModelConfig) -> Result> { + let config = crate::config::Config::global(); + + // Check for lead model environment variables + if let Ok(lead_model_name) = config.get_param::("GOOSE_LEAD_MODEL") { + tracing::info!("Creating lead/worker provider from environment variables"); + + return create_lead_worker_from_env(name, &model, &lead_model_name); + } + + // Default: create regular provider + create_provider(name, model) +} + +/// Create a lead/worker provider from environment variables +fn create_lead_worker_from_env( + default_provider_name: &str, + default_model: &ModelConfig, + lead_model_name: &str, +) -> Result> { + let config = crate::config::Config::global(); + + // Get lead provider (optional, defaults to main provider) + let lead_provider_name = config + .get_param::("GOOSE_LEAD_PROVIDER") + .unwrap_or_else(|_| default_provider_name.to_string()); + + // Get configuration parameters with defaults + let lead_turns = config + .get_param::("GOOSE_LEAD_TURNS") + .unwrap_or(default_lead_turns()); + let failure_threshold = config + .get_param::("GOOSE_LEAD_FAILURE_THRESHOLD") + .unwrap_or(default_failure_threshold()); + let fallback_turns = config + .get_param::("GOOSE_LEAD_FALLBACK_TURNS") + .unwrap_or(default_fallback_turns()); + + // Create model configs + let lead_model_config = ModelConfig::new(lead_model_name.to_string()); + let worker_model_config = default_model.clone(); + + // Create the providers + let lead_provider = create_provider(&lead_provider_name, lead_model_config)?; + let worker_provider = create_provider(default_provider_name, worker_model_config)?; + + // Create the lead/worker provider with configured settings + Ok(Arc::new(LeadWorkerProvider::new_with_settings( + lead_provider, + worker_provider, + lead_turns, + failure_threshold, + fallback_turns, + ))) +} + +fn create_provider(name: &str, model: ModelConfig) -> Result> { // We use Arc instead of Box to be able to clone for multiple async tasks match name { "openai" => Ok(Arc::new(OpenAiProvider::from_env(model)?)), @@ -49,7 +123,220 @@ pub fn create(name: &str, model: ModelConfig) -> Result> { "gcp_vertex_ai" => Ok(Arc::new(GcpVertexAIProvider::from_env(model)?)), "google" => Ok(Arc::new(GoogleProvider::from_env(model)?)), "venice" => Ok(Arc::new(VeniceProvider::from_env(model)?)), + "snowflake" => Ok(Arc::new(SnowflakeProvider::from_env(model)?)), "github_copilot" => Ok(Arc::new(GithubCopilotProvider::from_env(model)?)), _ => Err(anyhow::anyhow!("Unknown provider: {}", name)), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::{Message, MessageContent}; + use crate::providers::base::{ProviderMetadata, ProviderUsage, Usage}; + use chrono::Utc; + use mcp_core::{content::TextContent, Role}; + use std::env; + + #[derive(Clone)] + struct MockTestProvider { + name: String, + model_config: ModelConfig, + } + + #[async_trait::async_trait] + impl Provider for MockTestProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "mock_test", + "Mock Test Provider", + "A mock provider for testing", + "mock-model", + vec!["mock-model"], + "", + vec![], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: format!( + "Response from {} with model {}", + self.name, self.model_config.model_name + ), + annotations: None, + })], + }, + ProviderUsage::new(self.model_config.model_name.clone(), Usage::default()), + )) + } + } + + #[test] + fn test_create_lead_worker_provider() { + // Save current env vars + let saved_lead = env::var("GOOSE_LEAD_MODEL").ok(); + let saved_provider = env::var("GOOSE_LEAD_PROVIDER").ok(); + let saved_turns = env::var("GOOSE_LEAD_TURNS").ok(); + + // Test with basic lead model configuration + env::set_var("GOOSE_LEAD_MODEL", "gpt-4o"); + + // This will try to create a lead/worker provider + let result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + + // The creation might succeed or fail depending on API keys, but we can verify the logic path + match result { + Ok(_) => { + // If it succeeds, it means we created a lead/worker provider successfully + // This would happen if API keys are available in the test environment + } + Err(error) => { + // If it fails, it should be due to missing API keys, confirming we tried to create providers + let error_msg = error.to_string(); + assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); + } + } + + // Test with different lead provider + env::set_var("GOOSE_LEAD_PROVIDER", "anthropic"); + env::set_var("GOOSE_LEAD_TURNS", "5"); + + let _result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + // Similar validation as above - will fail due to missing API keys but confirms the logic + + // Restore env vars + match saved_lead { + Some(val) => env::set_var("GOOSE_LEAD_MODEL", val), + None => env::remove_var("GOOSE_LEAD_MODEL"), + } + match saved_provider { + Some(val) => env::set_var("GOOSE_LEAD_PROVIDER", val), + None => env::remove_var("GOOSE_LEAD_PROVIDER"), + } + match saved_turns { + Some(val) => env::set_var("GOOSE_LEAD_TURNS", val), + None => env::remove_var("GOOSE_LEAD_TURNS"), + } + } + + #[test] + fn test_lead_model_env_vars_with_defaults() { + // Save current env vars + let saved_vars = [ + ("GOOSE_LEAD_MODEL", env::var("GOOSE_LEAD_MODEL").ok()), + ("GOOSE_LEAD_PROVIDER", env::var("GOOSE_LEAD_PROVIDER").ok()), + ("GOOSE_LEAD_TURNS", env::var("GOOSE_LEAD_TURNS").ok()), + ( + "GOOSE_LEAD_FAILURE_THRESHOLD", + env::var("GOOSE_LEAD_FAILURE_THRESHOLD").ok(), + ), + ( + "GOOSE_LEAD_FALLBACK_TURNS", + env::var("GOOSE_LEAD_FALLBACK_TURNS").ok(), + ), + ]; + + // Clear all lead env vars + for (key, _) in &saved_vars { + env::remove_var(key); + } + + // Set only the required lead model + env::set_var("GOOSE_LEAD_MODEL", "gpt-4o"); + + // This should use defaults for all other values + let result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + + // Should attempt to create lead/worker provider (will fail due to missing API keys but confirms logic) + match result { + Ok(_) => { + // Success means we have API keys and created the provider + } + Err(error) => { + // Should fail due to missing API keys, confirming we tried to create providers + let error_msg = error.to_string(); + assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); + } + } + + // Test with custom values + env::set_var("GOOSE_LEAD_TURNS", "7"); + env::set_var("GOOSE_LEAD_FAILURE_THRESHOLD", "4"); + env::set_var("GOOSE_LEAD_FALLBACK_TURNS", "3"); + + let _result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + // Should still attempt to create lead/worker provider with custom settings + + // Restore all env vars + for (key, value) in saved_vars { + match value { + Some(val) => env::set_var(key, val), + None => env::remove_var(key), + } + } + } + + #[test] + fn test_create_regular_provider_without_lead_config() { + // Save current env vars + let saved_lead = env::var("GOOSE_LEAD_MODEL").ok(); + let saved_provider = env::var("GOOSE_LEAD_PROVIDER").ok(); + let saved_turns = env::var("GOOSE_LEAD_TURNS").ok(); + let saved_threshold = env::var("GOOSE_LEAD_FAILURE_THRESHOLD").ok(); + let saved_fallback = env::var("GOOSE_LEAD_FALLBACK_TURNS").ok(); + + // Ensure all GOOSE_LEAD_* variables are not set + env::remove_var("GOOSE_LEAD_MODEL"); + env::remove_var("GOOSE_LEAD_PROVIDER"); + env::remove_var("GOOSE_LEAD_TURNS"); + env::remove_var("GOOSE_LEAD_FAILURE_THRESHOLD"); + env::remove_var("GOOSE_LEAD_FALLBACK_TURNS"); + + // This should try to create a regular provider + let result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + + // The creation might succeed or fail depending on API keys + match result { + Ok(_) => { + // If it succeeds, it means we created a regular provider successfully + // This would happen if API keys are available in the test environment + } + Err(error) => { + // If it fails, it should be due to missing API keys + let error_msg = error.to_string(); + assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); + } + } + + // Restore env vars + if let Some(val) = saved_lead { + env::set_var("GOOSE_LEAD_MODEL", val); + } + if let Some(val) = saved_provider { + env::set_var("GOOSE_LEAD_PROVIDER", val); + } + if let Some(val) = saved_turns { + env::set_var("GOOSE_LEAD_TURNS", val); + } + if let Some(val) = saved_threshold { + env::set_var("GOOSE_LEAD_FAILURE_THRESHOLD", val); + } + if let Some(val) = saved_fallback { + env::set_var("GOOSE_LEAD_FALLBACK_TURNS", val); + } + } +} diff --git a/crates/goose/src/providers/formats/gcpvertexai.rs b/crates/goose/src/providers/formats/gcpvertexai.rs index 5bc94841..d83d1939 100644 --- a/crates/goose/src/providers/formats/gcpvertexai.rs +++ b/crates/goose/src/providers/formats/gcpvertexai.rs @@ -98,6 +98,10 @@ pub enum GeminiVersion { Pro20Exp, /// Gemini 2.5 Pro Experimental version Pro25Exp, + /// Gemini 2.5 Flash Preview version + Flash25Preview, + /// Gemini 2.5 Pro Preview version + Pro25Preview, /// Generic Gemini model for custom or new versions Generic(String), } @@ -118,6 +122,8 @@ impl fmt::Display for GcpVertexAIModel { GeminiVersion::Flash20 => "gemini-2.0-flash-001", GeminiVersion::Pro20Exp => "gemini-2.0-pro-exp-02-05", GeminiVersion::Pro25Exp => "gemini-2.5-pro-exp-03-25", + GeminiVersion::Flash25Preview => "gemini-2.5-flash-preview-05-20", + GeminiVersion::Pro25Preview => "gemini-2.5-pro-preview-05-06", GeminiVersion::Generic(name) => name, }, }; @@ -154,6 +160,8 @@ impl TryFrom<&str> for GcpVertexAIModel { "gemini-2.0-flash-001" => Ok(Self::Gemini(GeminiVersion::Flash20)), "gemini-2.0-pro-exp-02-05" => Ok(Self::Gemini(GeminiVersion::Pro20Exp)), "gemini-2.5-pro-exp-03-25" => Ok(Self::Gemini(GeminiVersion::Pro25Exp)), + "gemini-2.5-flash-preview-05-20" => Ok(Self::Gemini(GeminiVersion::Flash25Preview)), + "gemini-2.5-pro-preview-05-06" => Ok(Self::Gemini(GeminiVersion::Pro25Preview)), // Generic models based on prefix matching _ if s.starts_with("claude-") => { Ok(Self::Claude(ClaudeVersion::Generic(s.to_string()))) @@ -349,6 +357,8 @@ mod tests { "gemini-2.0-flash-001", "gemini-2.0-pro-exp-02-05", "gemini-2.5-pro-exp-03-25", + "gemini-2.5-flash-preview-05-20", + "gemini-2.5-pro-preview-05-06", ]; for model_id in valid_models { @@ -372,6 +382,8 @@ mod tests { ("gemini-2.0-flash-001", GcpLocation::Iowa), ("gemini-2.0-pro-exp-02-05", GcpLocation::Iowa), ("gemini-2.5-pro-exp-03-25", GcpLocation::Iowa), + ("gemini-2.5-flash-preview-05-20", GcpLocation::Iowa), + ("gemini-2.5-pro-preview-05-06", GcpLocation::Iowa), ]; for (model_id, expected_location) in test_cases { diff --git a/crates/goose/src/providers/formats/mod.rs b/crates/goose/src/providers/formats/mod.rs index fc767e27..6f3df3d0 100644 --- a/crates/goose/src/providers/formats/mod.rs +++ b/crates/goose/src/providers/formats/mod.rs @@ -4,3 +4,4 @@ pub mod databricks; pub mod gcpvertexai; pub mod google; pub mod openai; +pub mod snowflake; diff --git a/crates/goose/src/providers/formats/snowflake.rs b/crates/goose/src/providers/formats/snowflake.rs new file mode 100644 index 00000000..c7cfe592 --- /dev/null +++ b/crates/goose/src/providers/formats/snowflake.rs @@ -0,0 +1,716 @@ +use crate::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::providers::base::Usage; +use crate::providers::errors::ProviderError; +use anyhow::{anyhow, Result}; +use mcp_core::content::Content; +use mcp_core::role::Role; +use mcp_core::tool::{Tool, ToolCall}; +use serde_json::{json, Value}; +use std::collections::HashSet; + +/// Convert internal Message format to Snowflake's API message specification +pub fn format_messages(messages: &[Message]) -> Vec { + let mut snowflake_messages = Vec::new(); + + // Convert messages to Snowflake format + for message in messages { + let role = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + + let mut text_content = String::new(); + + for msg_content in &message.content { + match msg_content { + MessageContent::Text(text) => { + if !text_content.is_empty() { + text_content.push('\n'); + } + text_content.push_str(&text.text); + } + MessageContent::ToolRequest(_tool_request) => { + // Skip tool requests in message formatting - tools are handled separately + // through the tools parameter in the API request + continue; + } + MessageContent::ToolResponse(tool_response) => { + if let Ok(result) = &tool_response.tool_result { + let text = result + .iter() + .filter_map(|c| match c { + Content::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n"); + + if !text_content.is_empty() { + text_content.push('\n'); + } + if !text.is_empty() { + text_content.push_str(&format!("Tool result: {}", text)); + } + } + } + MessageContent::ToolConfirmationRequest(_) => { + // Skip tool confirmation requests + } + MessageContent::ContextLengthExceeded(_) => { + // Skip + } + MessageContent::SummarizationRequested(_) => { + // Skip + } + MessageContent::Thinking(_thinking) => { + // Skip thinking for now + } + MessageContent::RedactedThinking(_redacted) => { + // Skip redacted thinking for now + } + MessageContent::Image(_) => continue, // Snowflake doesn't support image content yet + MessageContent::FrontendToolRequest(_tool_request) => { + // Skip frontend tool requests + } + } + } + + // Add message if it has text content + if !text_content.is_empty() { + snowflake_messages.push(json!({ + "role": role, + "content": text_content + })); + } + } + + // Only add default message if we truly have no messages at all + // This should be rare and only for edge cases + if snowflake_messages.is_empty() { + snowflake_messages.push(json!({ + "role": "user", + "content": "Continue the conversation" + })); + } + + snowflake_messages +} + +/// Convert internal Tool format to Snowflake's API tool specification +pub fn format_tools(tools: &[Tool]) -> Vec { + let mut unique_tools = HashSet::new(); + let mut tool_specs = Vec::new(); + + for tool in tools.iter() { + if unique_tools.insert(tool.name.clone()) { + let tool_spec = json!({ + "type": "generic", + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema + }); + + tool_specs.push(json!({"tool_spec": tool_spec})); + } + } + + tool_specs +} + +/// Convert system message to Snowflake's API system specification +pub fn format_system(system: &str) -> Value { + json!({ + "role": "system", + "content": system, + }) +} + +/// Convert Snowflake's streaming API response to internal Message format +pub fn parse_streaming_response(sse_data: &str) -> Result { + let mut message = Message::assistant(); + let mut accumulated_text = String::new(); + let mut tool_use_id: Option = None; + let mut tool_name: Option = None; + let mut tool_input = String::new(); + + // Parse each SSE event + for line in sse_data.lines() { + if !line.starts_with("data: ") { + continue; + } + + let json_str = &line[6..]; // Remove "data: " prefix + if json_str.trim().is_empty() || json_str.trim() == "[DONE]" { + continue; + } + + let event: Value = match serde_json::from_str(json_str) { + Ok(v) => v, + Err(_) => { + continue; + } + }; + + if let Some(choices) = event.get("choices").and_then(|c| c.as_array()) { + if let Some(choice) = choices.first() { + if let Some(delta) = choice.get("delta") { + match delta.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { + accumulated_text.push_str(content); + } + } + Some("tool_use") => { + if let Some(id) = delta.get("tool_use_id").and_then(|i| i.as_str()) { + tool_use_id = Some(id.to_string()); + } + if let Some(name) = delta.get("name").and_then(|n| n.as_str()) { + tool_name = Some(name.to_string()); + } + if let Some(input) = delta.get("input").and_then(|i| i.as_str()) { + tool_input.push_str(input); + } + } + _ => {} + } + } + } + } + } + + // Add accumulated text if any + if !accumulated_text.is_empty() { + message = message.with_text(accumulated_text); + } + + // Add tool use if complete + if let (Some(id), Some(name)) = (&tool_use_id, &tool_name) { + if !tool_input.is_empty() { + let input_value = serde_json::from_str::(&tool_input) + .unwrap_or_else(|_| Value::String(tool_input.clone())); + let tool_call = ToolCall::new(name, input_value); + message = message.with_tool_request(id, Ok(tool_call)); + } else if tool_name.is_some() { + // Tool with no input - use empty object + let tool_call = ToolCall::new(name, Value::Object(serde_json::Map::new())); + message = message.with_tool_request(id, Ok(tool_call)); + } + } + + Ok(message) +} + +/// Convert Snowflake's API response to internal Message format +pub fn response_to_message(response: Value) -> Result { + let mut message = Message::assistant(); + + let content_list = response.get("content_list").and_then(|cl| cl.as_array()); + + // Handle case where content_list is missing or empty + let content_list = match content_list { + Some(list) if !list.is_empty() => list, + _ => { + // If no content_list or empty, check if there's a direct content field + if let Some(direct_content) = response.get("content").and_then(|c| c.as_str()) { + if !direct_content.is_empty() { + message = message.with_text(direct_content.to_string()); + } + return Ok(message); + } else { + // Return empty assistant message for empty responses + return Ok(message); + } + } + }; + + // Process all content items in the list + for content in content_list { + match content.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = content.get("text").and_then(|t| t.as_str()) { + if !text.is_empty() { + message = message.with_text(text.to_string()); + } + } + } + Some("tool_use") => { + let id = content + .get("tool_use_id") + .and_then(|i| i.as_str()) + .ok_or_else(|| anyhow!("Missing tool_use id"))?; + let name = content + .get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow!("Missing tool_use name"))?; + + let input = content + .get("input") + .ok_or_else(|| anyhow!("Missing tool input"))? + .clone(); + + let tool_call = ToolCall::new(name, input); + message = message.with_tool_request(id, Ok(tool_call)); + } + Some("thinking") => { + let thinking = content + .get("thinking") + .and_then(|t| t.as_str()) + .ok_or_else(|| anyhow!("Missing thinking content"))?; + let signature = content + .get("signature") + .and_then(|s| s.as_str()) + .ok_or_else(|| anyhow!("Missing thinking signature"))?; + message = message.with_thinking(thinking, signature); + } + Some("redacted_thinking") => { + let data = content + .get("data") + .and_then(|d| d.as_str()) + .ok_or_else(|| anyhow!("Missing redacted_thinking data"))?; + message = message.with_redacted_thinking(data); + } + _ => { + // Ignore unrecognized content types + } + } + } + + Ok(message) +} + +/// Extract usage information from Snowflake's API response +pub fn get_usage(data: &Value) -> Result { + // Extract usage data if available + if let Some(usage) = data.get("usage") { + let input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + + let output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + + let total_tokens = match (input_tokens, output_tokens) { + (Some(input), Some(output)) => Some(input + output), + _ => None, + }; + + Ok(Usage::new(input_tokens, output_tokens, total_tokens)) + } else { + tracing::debug!( + "Failed to get usage data: {}", + ProviderError::UsageError("No usage data found in response".to_string()) + ); + // If no usage data, return None for all values + Ok(Usage::new(None, None, None)) + } +} + +/// Create a complete request payload for Snowflake's API +pub fn create_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> Result { + let mut snowflake_messages = format_messages(messages); + let system_spec = format_system(system); + + // Add system message to the beginning of the messages + snowflake_messages.insert(0, system_spec); + + // Check if we have any messages to send + if snowflake_messages.is_empty() { + return Err(anyhow!("No valid messages to send to Snowflake API")); + } + + // Detect description generation requests and exclude tools to prevent interference + // with normal tool execution flow + let is_description_request = + system.contains("Reply with only a description in four words or less"); + + let tool_specs = if is_description_request { + // For description generation, don't include any tools to avoid confusion + format_tools(&[]) + } else { + format_tools(tools) + }; + + let max_tokens = model_config.max_tokens.unwrap_or(4096); + let mut payload = json!({ + "model": model_config.model_name, + "messages": snowflake_messages, + "max_tokens": max_tokens, + }); + + // Add tools if present and not a description request + if !tool_specs.is_empty() { + if let Some(obj) = payload.as_object_mut() { + obj.insert("tools".to_string(), json!(tool_specs)); + } else { + return Err(anyhow!( + "Failed to create request payload: payload is not a JSON object" + )); + } + } + + Ok(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_text_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content_list": [{ + "type": "text", + "text": "Hello! How can I assist you today?" + }], + "model": "claude-3-5-sonnet", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 15 + } + }); + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "Hello! How can I assist you today?"); + } else { + panic!("Expected Text content"); + } + + assert_eq!(usage.input_tokens, Some(12)); + assert_eq!(usage.output_tokens, Some(15)); + assert_eq!(usage.total_tokens, Some(27)); // 12 + 15 + + Ok(()) + } + + #[test] + fn test_parse_tool_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content_list": [{ + "type": "tool_use", + "tool_use_id": "tool_1", + "name": "calculator", + "input": {"expression": "2 + 2"} + }], + "model": "claude-3-5-sonnet", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 15, + "output_tokens": 20 + } + }); + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + if let MessageContent::ToolRequest(tool_request) = &message.content[0] { + let tool_call = tool_request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "calculator"); + assert_eq!(tool_call.arguments, json!({"expression": "2 + 2"})); + } else { + panic!("Expected ToolRequest content"); + } + + assert_eq!(usage.input_tokens, Some(15)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.total_tokens, Some(35)); // 15 + 20 + + Ok(()) + } + + #[test] + fn test_message_to_snowflake_spec() { + let messages = vec![ + Message::user().with_text("Hello"), + Message::assistant().with_text("Hi there"), + Message::user().with_text("How are you?"), + ]; + + let spec = format_messages(&messages); + + assert_eq!(spec.len(), 3); + assert_eq!(spec[0]["role"], "user"); + assert_eq!(spec[0]["content"], "Hello"); + assert_eq!(spec[1]["role"], "assistant"); + assert_eq!(spec[1]["content"], "Hi there"); + assert_eq!(spec[2]["role"], "user"); + assert_eq!(spec[2]["content"], "How are you?"); + } + + #[test] + fn test_tools_to_snowflake_spec() { + let tools = vec![ + Tool::new( + "calculator", + "Calculate mathematical expressions", + json!({ + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "The mathematical expression to evaluate" + } + } + }), + None, + ), + Tool::new( + "weather", + "Get weather information", + json!({ + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The location to get weather for" + } + } + }), + None, + ), + ]; + + let spec = format_tools(&tools); + + assert_eq!(spec.len(), 2); + assert_eq!(spec[0]["tool_spec"]["name"], "calculator"); + assert_eq!( + spec[0]["tool_spec"]["description"], + "Calculate mathematical expressions" + ); + assert_eq!(spec[1]["tool_spec"]["name"], "weather"); + assert_eq!( + spec[1]["tool_spec"]["description"], + "Get weather information" + ); + } + + #[test] + fn test_system_to_snowflake_spec() { + let system = "You are a helpful assistant."; + let spec = format_system(system); + + assert_eq!(spec["role"], "system"); + assert_eq!(spec["content"], system); + } + + #[test] + fn test_parse_streaming_response() -> Result<()> { + let sse_data = r#"data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"text","content":"I","content_list":[{"type":"text","text":"I"}],"text":"I"}}],"usage":{}} + +data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"text","content":"'ll help you check Nvidia's current","content_list":[{"type":"text","text":"'ll help you check Nvidia's current"}],"text":"'ll help you check Nvidia's current"}}],"usage":{}} + +data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"tool_use","tool_use_id":"tooluse_FB_nOElDTAOKa-YnVWI5Uw","name":"get_stock_price","content_list":[{"tool_use_id":"tooluse_FB_nOElDTAOKa-YnVWI5Uw","name":"get_stock_price"}],"text":""}}],"usage":{}} + +data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","choices":[{"delta":{"type":"tool_use","input":"{\"symbol\":\"NVDA\"}","content_list":[{"input":"{\"symbol\":\"NVDA\"}"}],"text":""}}],"usage":{"prompt_tokens":397,"completion_tokens":65,"total_tokens":462}} +"#; + + let message = parse_streaming_response(sse_data)?; + + // Should have both text and tool request + assert_eq!(message.content.len(), 2); + + if let MessageContent::Text(text) = &message.content[0] { + assert!(text.text.contains("I'll help you check Nvidia's current")); + } else { + panic!("Expected Text content first"); + } + + if let MessageContent::ToolRequest(tool_request) = &message.content[1] { + let tool_call = tool_request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "get_stock_price"); + assert_eq!(tool_call.arguments, json!({"symbol": "NVDA"})); + assert_eq!(tool_request.id, "tooluse_FB_nOElDTAOKa-YnVWI5Uw"); + } else { + panic!("Expected ToolRequest content second"); + } + + Ok(()) + } + + #[test] + fn test_create_request_format() -> Result<()> { + use crate::model::ModelConfig; + + let model_config = ModelConfig::new("claude-3-5-sonnet".to_string()); + + let system = "You are a helpful assistant that can use tools to get information."; + let messages = vec![Message::user().with_text("What is the stock price of Nvidia?")]; + + let tools = vec![Tool::new( + "get_stock_price", + "Get stock price information", + json!({ + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "The symbol for the stock ticker, e.g. Snowflake = SNOW" + } + }, + "required": ["symbol"] + }), + None, + )]; + + let request = create_request(&model_config, system, &messages, &tools)?; + + // Check basic structure + assert_eq!(request["model"], "claude-3-5-sonnet"); + + let messages_array = request["messages"].as_array().unwrap(); + assert_eq!(messages_array.len(), 2); // system + user message + + // First message should be system with simple content + assert_eq!(messages_array[0]["role"], "system"); + assert_eq!( + messages_array[0]["content"], + "You are a helpful assistant that can use tools to get information." + ); + + // Second message should be user with simple content + assert_eq!(messages_array[1]["role"], "user"); + assert_eq!( + messages_array[1]["content"], + "What is the stock price of Nvidia?" + ); + + // Tools should have tool_spec wrapper + let tools_array = request["tools"].as_array().unwrap(); + assert_eq!(tools_array[0]["tool_spec"]["name"], "get_stock_price"); + + Ok(()) + } + + #[test] + fn test_parse_mixed_text_and_tool_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": "I'll help you with that calculation.", + "content_list": [ + { + "type": "text", + "text": "I'll help you with that calculation." + }, + { + "type": "tool_use", + "tool_use_id": "tool_1", + "name": "calculator", + "input": {"expression": "2 + 2"} + } + ], + "model": "claude-3-5-sonnet", + "usage": { + "input_tokens": 10, + "output_tokens": 15 + } + }); + + let message = response_to_message(response.clone())?; + + // Should have both text and tool request content + assert_eq!(message.content.len(), 2); + + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "I'll help you with that calculation."); + } else { + panic!("Expected Text content first"); + } + + if let MessageContent::ToolRequest(tool_request) = &message.content[1] { + let tool_call = tool_request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "calculator"); + assert_eq!(tool_request.id, "tool_1"); + } else { + panic!("Expected ToolRequest content second"); + } + + Ok(()) + } + + #[test] + fn test_empty_tools_array() { + let tools: Vec = vec![]; + let spec = format_tools(&tools); + assert_eq!(spec.len(), 0); + } + + #[test] + fn test_create_request_excludes_tools_for_description() -> Result<()> { + use crate::model::ModelConfig; + + let model_config = ModelConfig::new("claude-3-5-sonnet".to_string()); + let system = "Reply with only a description in four words or less"; + let messages = vec![Message::user().with_text("Test message")]; + let tools = vec![Tool::new( + "test_tool", + "Test tool", + json!({"type": "object", "properties": {}}), + None, + )]; + + let request = create_request(&model_config, system, &messages, &tools)?; + + // Should not include tools for description requests + assert!(request.get("tools").is_none()); + + Ok(()) + } + + #[test] + fn test_message_formatting_skips_tool_requests() { + use mcp_core::tool::ToolCall; + + // Create a conversation with text, tool requests, and tool responses + let tool_call = ToolCall::new("calculator", json!({"expression": "2 + 2"})); + + let messages = vec![ + Message::user().with_text("Calculate 2 + 2"), + Message::assistant() + .with_text("I'll help you calculate that.") + .with_tool_request("tool_1", Ok(tool_call)), + Message::user().with_text("Thanks!"), + ]; + + let spec = format_messages(&messages); + + // Should only have 3 messages - the tool request should be skipped + assert_eq!(spec.len(), 3); + assert_eq!(spec[0]["role"], "user"); + assert_eq!(spec[0]["content"], "Calculate 2 + 2"); + assert_eq!(spec[1]["role"], "assistant"); + assert_eq!(spec[1]["content"], "I'll help you calculate that."); + assert_eq!(spec[2]["role"], "user"); + assert_eq!(spec[2]["content"], "Thanks!"); + + // Verify no tool request content is in the message history + for message in &spec { + let content = message["content"].as_str().unwrap(); + assert!(!content.contains("Using tool:")); + assert!(!content.contains("calculator")); + } + } +} diff --git a/crates/goose/src/providers/gcpvertexai.rs b/crates/goose/src/providers/gcpvertexai.rs index afec862d..6385ec29 100644 --- a/crates/goose/src/providers/gcpvertexai.rs +++ b/crates/goose/src/providers/gcpvertexai.rs @@ -434,6 +434,9 @@ impl Provider for GcpVertexAIProvider { GcpVertexAIModel::Gemini(GeminiVersion::Pro15), GcpVertexAIModel::Gemini(GeminiVersion::Flash20), GcpVertexAIModel::Gemini(GeminiVersion::Pro20Exp), + GcpVertexAIModel::Gemini(GeminiVersion::Pro25Exp), + GcpVertexAIModel::Gemini(GeminiVersion::Flash25Preview), + GcpVertexAIModel::Gemini(GeminiVersion::Pro25Preview), ] .iter() .map(|model| model.to_string()) diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index 1f29a898..63a582aa 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -230,7 +230,7 @@ impl GithubCopilotProvider { async fn refresh_api_info(&self) -> Result { let config = Config::global(); - let token = match config.get_secret::("GITHUB_TOKEN") { + let token = match config.get_secret::("GITHUB_COPILOT_TOKEN") { Ok(token) => token, Err(err) => match err { ConfigError::NotFound(_) => { @@ -238,7 +238,7 @@ impl GithubCopilotProvider { .get_access_token() .await .context("unable to login into github")?; - config.set_secret("GITHUB_TOKEN", Value::String(token.clone()))?; + config.set_secret("GITHUB_COPILOT_TOKEN", Value::String(token.clone()))?; token } _ => return Err(err.into()), diff --git a/crates/goose/src/providers/lead_worker.rs b/crates/goose/src/providers/lead_worker.rs new file mode 100644 index 00000000..a242dcb9 --- /dev/null +++ b/crates/goose/src/providers/lead_worker.rs @@ -0,0 +1,637 @@ +use anyhow::Result; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::Mutex; + +use super::base::{LeadWorkerProviderTrait, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use crate::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use mcp_core::{tool::Tool, Content}; + +/// A provider that switches between a lead model and a worker model based on turn count +/// and can fallback to lead model on consecutive failures +pub struct LeadWorkerProvider { + lead_provider: Arc, + worker_provider: Arc, + lead_turns: usize, + turn_count: Arc>, + failure_count: Arc>, + max_failures_before_fallback: usize, + fallback_turns: usize, + in_fallback_mode: Arc>, + fallback_remaining: Arc>, +} + +impl LeadWorkerProvider { + /// Create a new LeadWorkerProvider + /// + /// # Arguments + /// * `lead_provider` - The provider to use for the initial turns + /// * `worker_provider` - The provider to use after lead_turns + /// * `lead_turns` - Number of turns to use the lead provider (default: 3) + pub fn new( + lead_provider: Arc, + worker_provider: Arc, + lead_turns: Option, + ) -> Self { + Self { + lead_provider, + worker_provider, + lead_turns: lead_turns.unwrap_or(3), + turn_count: Arc::new(Mutex::new(0)), + failure_count: Arc::new(Mutex::new(0)), + max_failures_before_fallback: 2, // Fallback after 2 consecutive failures + fallback_turns: 2, // Use lead model for 2 turns when in fallback mode + in_fallback_mode: Arc::new(Mutex::new(false)), + fallback_remaining: Arc::new(Mutex::new(0)), + } + } + + /// Create a new LeadWorkerProvider with custom settings + /// + /// # Arguments + /// * `lead_provider` - The provider to use for the initial turns + /// * `worker_provider` - The provider to use after lead_turns + /// * `lead_turns` - Number of turns to use the lead provider + /// * `failure_threshold` - Number of consecutive failures before fallback + /// * `fallback_turns` - Number of turns to use lead model in fallback mode + pub fn new_with_settings( + lead_provider: Arc, + worker_provider: Arc, + lead_turns: usize, + failure_threshold: usize, + fallback_turns: usize, + ) -> Self { + Self { + lead_provider, + worker_provider, + lead_turns, + turn_count: Arc::new(Mutex::new(0)), + failure_count: Arc::new(Mutex::new(0)), + max_failures_before_fallback: failure_threshold, + fallback_turns, + in_fallback_mode: Arc::new(Mutex::new(false)), + fallback_remaining: Arc::new(Mutex::new(0)), + } + } + + /// Reset the turn counter and failure tracking (useful for new conversations) + pub async fn reset_turn_count(&self) { + let mut count = self.turn_count.lock().await; + *count = 0; + let mut failures = self.failure_count.lock().await; + *failures = 0; + let mut fallback = self.in_fallback_mode.lock().await; + *fallback = false; + let mut remaining = self.fallback_remaining.lock().await; + *remaining = 0; + } + + /// Get the current turn count + pub async fn get_turn_count(&self) -> usize { + *self.turn_count.lock().await + } + + /// Get the current failure count + pub async fn get_failure_count(&self) -> usize { + *self.failure_count.lock().await + } + + /// Check if currently in fallback mode + pub async fn is_in_fallback_mode(&self) -> bool { + *self.in_fallback_mode.lock().await + } + + /// Get the currently active provider based on turn count and fallback state + async fn get_active_provider(&self) -> Arc { + let count = *self.turn_count.lock().await; + let in_fallback = *self.in_fallback_mode.lock().await; + + // Use lead provider if we're in initial turns OR in fallback mode + if count < self.lead_turns || in_fallback { + Arc::clone(&self.lead_provider) + } else { + Arc::clone(&self.worker_provider) + } + } + + /// Handle the result of a completion attempt and update failure tracking + async fn handle_completion_result( + &self, + result: &Result<(Message, ProviderUsage), ProviderError>, + ) { + match result { + Ok((message, _usage)) => { + // Check for task-level failures in the response + let has_task_failure = self.detect_task_failures(message).await; + + if has_task_failure { + // Task failure detected - increment failure count + let mut failures = self.failure_count.lock().await; + *failures += 1; + + let failure_count = *failures; + let turn_count = *self.turn_count.lock().await; + + tracing::warn!( + "Task failure detected in response (failure count: {})", + failure_count + ); + + // Check if we should trigger fallback + if turn_count >= self.lead_turns + && !*self.in_fallback_mode.lock().await + && failure_count >= self.max_failures_before_fallback + { + let mut in_fallback = self.in_fallback_mode.lock().await; + let mut fallback_remaining = self.fallback_remaining.lock().await; + + *in_fallback = true; + *fallback_remaining = self.fallback_turns; + *failures = 0; // Reset failure count when entering fallback + + tracing::warn!( + "🔄 SWITCHING TO LEAD MODEL: Entering fallback mode after {} consecutive task failures - using lead model for {} turns", + self.max_failures_before_fallback, + self.fallback_turns + ); + } + } else { + // Success - reset failure count and handle fallback mode + let mut failures = self.failure_count.lock().await; + *failures = 0; + + let mut in_fallback = self.in_fallback_mode.lock().await; + let mut fallback_remaining = self.fallback_remaining.lock().await; + + if *in_fallback { + *fallback_remaining -= 1; + if *fallback_remaining == 0 { + *in_fallback = false; + tracing::info!("✅ SWITCHING BACK TO WORKER MODEL: Exiting fallback mode - worker model resumed"); + } + } + } + + // Increment turn count on any completion (success or task failure) + let mut count = self.turn_count.lock().await; + *count += 1; + } + Err(_) => { + // Technical failure - just log and let it bubble up + // For technical failures (API/LLM issues), we don't want to second-guess + // the model choice - just let the default model handle it + tracing::warn!( + "Technical failure detected - API/LLM issue, will use default model" + ); + + // Don't increment turn count or failure tracking for technical failures + // as these are temporary infrastructure issues, not model capability issues + } + } + } + + /// Detect task-level failures in the model's response + async fn detect_task_failures(&self, message: &Message) -> bool { + let mut failure_indicators = 0; + + for content in &message.content { + match content { + MessageContent::ToolRequest(tool_request) => { + // Check if tool request itself failed (malformed, etc.) + if tool_request.tool_call.is_err() { + failure_indicators += 1; + tracing::debug!( + "Failed tool request detected: {:?}", + tool_request.tool_call + ); + } + } + MessageContent::ToolResponse(tool_response) => { + // Check if tool execution failed + if let Err(tool_error) = &tool_response.tool_result { + failure_indicators += 1; + tracing::debug!("Tool execution failure detected: {:?}", tool_error); + } else if let Ok(contents) = &tool_response.tool_result { + // Check tool output for error indicators + if self.contains_error_indicators(contents) { + failure_indicators += 1; + tracing::debug!("Tool output contains error indicators"); + } + } + } + MessageContent::Text(text_content) => { + // Check for user correction patterns or error acknowledgments + if self.contains_user_correction_patterns(&text_content.text) { + failure_indicators += 1; + tracing::debug!("User correction pattern detected in text"); + } + } + _ => {} + } + } + + // Consider it a failure if we have multiple failure indicators + failure_indicators >= 1 + } + + /// Check if tool output contains error indicators + fn contains_error_indicators(&self, contents: &[Content]) -> bool { + for content in contents { + if let Content::Text(text_content) = content { + let text_lower = text_content.text.to_lowercase(); + + // Common error patterns in tool outputs + if text_lower.contains("error:") + || text_lower.contains("failed:") + || text_lower.contains("exception:") + || text_lower.contains("traceback") + || text_lower.contains("syntax error") + || text_lower.contains("permission denied") + || text_lower.contains("file not found") + || text_lower.contains("command not found") + || text_lower.contains("compilation failed") + || text_lower.contains("test failed") + || text_lower.contains("assertion failed") + { + return true; + } + } + } + false + } + + /// Check for user correction patterns in text + fn contains_user_correction_patterns(&self, text: &str) -> bool { + let text_lower = text.to_lowercase(); + + // Patterns indicating user is correcting or expressing dissatisfaction + text_lower.contains("that's wrong") + || text_lower.contains("that's not right") + || text_lower.contains("that doesn't work") + || text_lower.contains("try again") + || text_lower.contains("let me correct") + || text_lower.contains("actually, ") + || text_lower.contains("no, that's") + || text_lower.contains("that's incorrect") + || text_lower.contains("fix this") + || text_lower.contains("this is broken") + || text_lower.contains("this doesn't") + || text_lower.starts_with("no,") + || text_lower.starts_with("wrong") + || text_lower.starts_with("incorrect") + } +} + +impl LeadWorkerProviderTrait for LeadWorkerProvider { + /// Get information about the lead and worker models for logging + fn get_model_info(&self) -> (String, String) { + let lead_model = self.lead_provider.get_model_config().model_name; + let worker_model = self.worker_provider.get_model_config().model_name; + (lead_model, worker_model) + } +} + +#[async_trait] +impl Provider for LeadWorkerProvider { + fn metadata() -> ProviderMetadata { + // This is a wrapper provider, so we return minimal metadata + ProviderMetadata::new( + "lead_worker", + "Lead/Worker Provider", + "A provider that switches between lead and worker models based on turn count", + "", // No default model as this is determined by the wrapped providers + vec![], // No known models as this depends on wrapped providers + "", // No doc link + vec![], // No config keys as configuration is done through wrapped providers + ) + } + + fn get_model_config(&self) -> ModelConfig { + // Return the lead provider's model config as the default + // In practice, this might need to be more sophisticated + self.lead_provider.get_model_config() + } + + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // Get the active provider + let provider = self.get_active_provider().await; + + // Log which provider is being used + let turn_count = *self.turn_count.lock().await; + let in_fallback = *self.in_fallback_mode.lock().await; + let fallback_remaining = *self.fallback_remaining.lock().await; + + let provider_type = if turn_count < self.lead_turns { + "lead (initial)" + } else if in_fallback { + "lead (fallback)" + } else { + "worker" + }; + + if in_fallback { + tracing::info!( + "🔄 Using {} provider for turn {} (FALLBACK MODE: {} turns remaining)", + provider_type, + turn_count + 1, + fallback_remaining + ); + } else { + tracing::info!( + "Using {} provider for turn {} (lead_turns: {})", + provider_type, + turn_count + 1, + self.lead_turns + ); + } + + // Make the completion request + let result = provider.complete(system, messages, tools).await; + + // For technical failures, try with default model (lead provider) instead + let final_result = match &result { + Err(_) => { + tracing::warn!("Technical failure with {} provider, retrying with default model (lead provider)", provider_type); + + // Try with lead provider as the default/fallback for technical failures + let default_result = self.lead_provider.complete(system, messages, tools).await; + + match &default_result { + Ok(_) => { + tracing::info!( + "✅ Default model (lead provider) succeeded after technical failure" + ); + default_result + } + Err(_) => { + tracing::error!("❌ Default model (lead provider) also failed - returning original error"); + result // Return the original error + } + } + } + Ok(_) => result, // Success with original provider + }; + + // Handle the result and update tracking (only for successful completions) + self.handle_completion_result(&final_result).await; + + final_result + } + + async fn fetch_supported_models_async(&self) -> Result>, ProviderError> { + // Combine models from both providers + let lead_models = self.lead_provider.fetch_supported_models_async().await?; + let worker_models = self.worker_provider.fetch_supported_models_async().await?; + + match (lead_models, worker_models) { + (Some(lead), Some(worker)) => { + let mut all_models = lead; + all_models.extend(worker); + all_models.sort(); + all_models.dedup(); + Ok(Some(all_models)) + } + (Some(models), None) | (None, Some(models)) => Ok(Some(models)), + (None, None) => Ok(None), + } + } + + fn supports_embeddings(&self) -> bool { + // Support embeddings if either provider supports them + self.lead_provider.supports_embeddings() || self.worker_provider.supports_embeddings() + } + + async fn create_embeddings(&self, texts: Vec) -> Result>, ProviderError> { + // Use the lead provider for embeddings if it supports them, otherwise use worker + if self.lead_provider.supports_embeddings() { + self.lead_provider.create_embeddings(texts).await + } else if self.worker_provider.supports_embeddings() { + self.worker_provider.create_embeddings(texts).await + } else { + Err(ProviderError::ExecutionError( + "Neither lead nor worker provider supports embeddings".to_string(), + )) + } + } + + /// Check if this provider is a LeadWorkerProvider + fn as_lead_worker(&self) -> Option<&dyn LeadWorkerProviderTrait> { + Some(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::MessageContent; + use crate::providers::base::{ProviderMetadata, ProviderUsage, Usage}; + use chrono::Utc; + use mcp_core::{content::TextContent, Role}; + + #[derive(Clone)] + struct MockProvider { + name: String, + model_config: ModelConfig, + } + + #[async_trait] + impl Provider for MockProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::empty() + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: format!("Response from {}", self.name), + annotations: None, + })], + }, + ProviderUsage::new(self.name.clone(), Usage::default()), + )) + } + } + + #[tokio::test] + async fn test_lead_worker_switching() { + let lead_provider = Arc::new(MockProvider { + name: "lead".to_string(), + model_config: ModelConfig::new("lead-model".to_string()), + }); + + let worker_provider = Arc::new(MockProvider { + name: "worker".to_string(), + model_config: ModelConfig::new("worker-model".to_string()), + }); + + let provider = LeadWorkerProvider::new(lead_provider, worker_provider, Some(3)); + + // First three turns should use lead provider + for i in 0..3 { + let (_message, usage) = provider.complete("system", &[], &[]).await.unwrap(); + assert_eq!(usage.model, "lead"); + assert_eq!(provider.get_turn_count().await, i + 1); + assert!(!provider.is_in_fallback_mode().await); + } + + // Subsequent turns should use worker provider + for i in 3..6 { + let (_message, usage) = provider.complete("system", &[], &[]).await.unwrap(); + assert_eq!(usage.model, "worker"); + assert_eq!(provider.get_turn_count().await, i + 1); + assert!(!provider.is_in_fallback_mode().await); + } + + // Reset and verify it goes back to lead + provider.reset_turn_count().await; + assert_eq!(provider.get_turn_count().await, 0); + assert_eq!(provider.get_failure_count().await, 0); + assert!(!provider.is_in_fallback_mode().await); + + let (_message, usage) = provider.complete("system", &[], &[]).await.unwrap(); + assert_eq!(usage.model, "lead"); + } + + #[tokio::test] + async fn test_technical_failure_retry() { + let lead_provider = Arc::new(MockFailureProvider { + name: "lead".to_string(), + model_config: ModelConfig::new("lead-model".to_string()), + should_fail: false, // Lead provider works + }); + + let worker_provider = Arc::new(MockFailureProvider { + name: "worker".to_string(), + model_config: ModelConfig::new("worker-model".to_string()), + should_fail: true, // Worker will fail + }); + + let provider = LeadWorkerProvider::new(lead_provider, worker_provider, Some(2)); + + // First two turns use lead (should succeed) + for _i in 0..2 { + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().1.model, "lead"); + assert!(!provider.is_in_fallback_mode().await); + } + + // Next turn uses worker (will fail, but should retry with lead and succeed) + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); // Should succeed because lead provider is used as fallback + assert_eq!(result.unwrap().1.model, "lead"); // Should be lead provider + assert_eq!(provider.get_failure_count().await, 0); // No failure tracking for technical failures + assert!(!provider.is_in_fallback_mode().await); // Not in fallback mode + + // Another turn - should still try worker first, then retry with lead + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); // Should succeed because lead provider is used as fallback + assert_eq!(result.unwrap().1.model, "lead"); // Should be lead provider + assert_eq!(provider.get_failure_count().await, 0); // Still no failure tracking + assert!(!provider.is_in_fallback_mode().await); // Still not in fallback mode + } + + #[tokio::test] + async fn test_fallback_on_task_failures() { + // Test that task failures (not technical failures) still trigger fallback mode + // This would need a different mock that simulates task failures in successful responses + // For now, we'll test the fallback mode functionality directly + let lead_provider = Arc::new(MockFailureProvider { + name: "lead".to_string(), + model_config: ModelConfig::new("lead-model".to_string()), + should_fail: false, + }); + + let worker_provider = Arc::new(MockFailureProvider { + name: "worker".to_string(), + model_config: ModelConfig::new("worker-model".to_string()), + should_fail: false, + }); + + let provider = LeadWorkerProvider::new(lead_provider, worker_provider, Some(2)); + + // Simulate being in fallback mode + { + let mut in_fallback = provider.in_fallback_mode.lock().await; + *in_fallback = true; + let mut fallback_remaining = provider.fallback_remaining.lock().await; + *fallback_remaining = 2; + let mut turn_count = provider.turn_count.lock().await; + *turn_count = 4; // Past initial lead turns + } + + // Should use lead provider in fallback mode + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().1.model, "lead"); + assert!(provider.is_in_fallback_mode().await); + + // One more fallback turn + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().1.model, "lead"); + assert!(!provider.is_in_fallback_mode().await); // Should exit fallback mode + } + + #[derive(Clone)] + struct MockFailureProvider { + name: String, + model_config: ModelConfig, + should_fail: bool, + } + + #[async_trait] + impl Provider for MockFailureProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::empty() + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + if self.should_fail { + Err(ProviderError::ExecutionError( + "Simulated failure".to_string(), + )) + } else { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: format!("Response from {}", self.name), + annotations: None, + })], + }, + ProviderUsage::new(self.name.clone(), Usage::default()), + )) + } + } + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index c91e43c6..2f6f1f87 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -13,10 +13,12 @@ pub mod gcpvertexai; pub mod githubcopilot; pub mod google; pub mod groq; +pub mod lead_worker; pub mod oauth; pub mod ollama; pub mod openai; pub mod openrouter; +pub mod snowflake; pub mod toolshim; pub mod utils; pub mod utils_universal_openai_stream; diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index 71553a4c..fd46fbcb 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -249,7 +249,7 @@ impl EmbeddingCapable for OpenAiProvider { } // Get embedding model from env var or use default - let embedding_model = std::env::var("EMBEDDING_MODEL") + let embedding_model = std::env::var("GOOSE_EMBEDDING_MODEL") .unwrap_or_else(|_| "text-embedding-3-small".to_string()); let request = EmbeddingRequest { diff --git a/crates/goose/src/providers/snowflake.rs b/crates/goose/src/providers/snowflake.rs new file mode 100644 index 00000000..32c1f2c6 --- /dev/null +++ b/crates/goose/src/providers/snowflake.rs @@ -0,0 +1,439 @@ +use anyhow::Result; +use async_trait::async_trait; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::time::Duration; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use super::formats::snowflake::{create_request, get_usage, response_to_message}; +use super::utils::{get_model, ImageFormat}; +use crate::config::ConfigError; +use crate::message::Message; +use crate::model::ModelConfig; +use mcp_core::tool::Tool; +use url::Url; + +pub const SNOWFLAKE_DEFAULT_MODEL: &str = "claude-3-7-sonnet"; +pub const SNOWFLAKE_KNOWN_MODELS: &[&str] = &["claude-3-7-sonnet", "claude-3-5-sonnet"]; + +pub const SNOWFLAKE_DOC_URL: &str = + "https://docs.snowflake.com/en/user-guide/snowflake-cortex/llm-functions#choosing-a-model"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SnowflakeAuth { + Token(String), +} + +impl SnowflakeAuth { + pub fn token(token: String) -> Self { + Self::Token(token) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct SnowflakeProvider { + #[serde(skip)] + client: Client, + host: String, + auth: SnowflakeAuth, + model: ModelConfig, + image_format: ImageFormat, +} + +impl Default for SnowflakeProvider { + fn default() -> Self { + let model = ModelConfig::new(SnowflakeProvider::metadata().default_model); + SnowflakeProvider::from_env(model).expect("Failed to initialize Snowflake provider") + } +} + +impl SnowflakeProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let mut host: Result = config.get_param("SNOWFLAKE_HOST"); + if host.is_err() { + host = config.get_secret("SNOWFLAKE_HOST") + } + if host.is_err() { + return Err(ConfigError::NotFound( + "Did not find SNOWFLAKE_HOST in either config file or keyring".to_string(), + ) + .into()); + } + + let mut host = host?; + + // Convert host to lowercase + host = host.to_lowercase(); + + // Ensure host ends with snowflakecomputing.com + if !host.ends_with("snowflakecomputing.com") { + host = format!("{}.snowflakecomputing.com", host); + } + + let mut token: Result = config.get_param("SNOWFLAKE_TOKEN"); + + if token.is_err() { + token = config.get_secret("SNOWFLAKE_TOKEN") + } + + if token.is_err() { + return Err(ConfigError::NotFound( + "Did not find SNOWFLAKE_TOKEN in either config file or keyring".to_string(), + ) + .into()); + } + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + // Use token-based authentication + let api_key = token?; + Ok(Self { + client, + host, + auth: SnowflakeAuth::token(api_key), + model, + image_format: ImageFormat::OpenAi, + }) + } + + async fn ensure_auth_header(&self) -> Result { + match &self.auth { + // https://docs.snowflake.com/en/developer-guide/snowflake-rest-api/authentication#using-a-programmatic-access-token-pat + SnowflakeAuth::Token(token) => Ok(format!("Bearer {}", token)), + } + } + + async fn post(&self, payload: Value) -> Result { + let base_url_str = + if !self.host.starts_with("https://") && !self.host.starts_with("http://") { + format!("https://{}", self.host) + } else { + self.host.clone() + }; + let base_url = Url::parse(&base_url_str) + .map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?; + let path = "api/v2/cortex/inference:complete"; + let url = base_url.join(path).map_err(|e| { + ProviderError::RequestFailed(format!("Failed to construct endpoint URL: {e}")) + })?; + + let auth_header = self.ensure_auth_header().await?; + let response = self + .client + .post(url) + .header("Authorization", auth_header) + .header("User-Agent", "Goose") + .json(&payload) + .send() + .await?; + + let status = response.status(); + + let payload_text: String = response.text().await.ok().unwrap_or_default(); + + if status == StatusCode::OK { + if let Ok(payload) = serde_json::from_str::(&payload_text) { + if payload.get("code").is_some() { + let code = payload + .get("code") + .and_then(|c| c.as_str()) + .unwrap_or("Unknown code"); + let message = payload + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown message"); + return Err(ProviderError::RequestFailed(format!( + "{} - {}", + code, message + ))); + } + } + } + + let lines = payload_text.lines().collect::>(); + + let mut text = String::new(); + let mut tool_name = String::new(); + let mut tool_input = String::new(); + let mut tool_use_id = String::new(); + for line in lines.iter() { + if line.is_empty() { + continue; + } + + let json_str = match line.strip_prefix("data: ") { + Some(s) => s, + None => continue, + }; + + if let Ok(json_line) = serde_json::from_str::(json_str) { + let choices = match json_line.get("choices").and_then(|c| c.as_array()) { + Some(choices) => choices, + None => { + continue; + } + }; + + let choice = match choices.first() { + Some(choice) => choice, + None => { + continue; + } + }; + + let delta = match choice.get("delta") { + Some(delta) => delta, + None => { + continue; + } + }; + + // Track if we found text in content_list to avoid duplication + let mut found_text_in_content_list = false; + + // Handle content_list array first + if let Some(content_list) = delta.get("content_list").and_then(|cl| cl.as_array()) { + for content_item in content_list { + match content_item.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text_content) = + content_item.get("text").and_then(|t| t.as_str()) + { + text.push_str(text_content); + found_text_in_content_list = true; + } + } + Some("tool_use") => { + if let Some(tool_id) = + content_item.get("tool_use_id").and_then(|id| id.as_str()) + { + tool_use_id.push_str(tool_id); + } + if let Some(name) = + content_item.get("name").and_then(|n| n.as_str()) + { + tool_name.push_str(name); + } + if let Some(input) = + content_item.get("input").and_then(|i| i.as_str()) + { + tool_input.push_str(input); + } + } + _ => { + // Handle content items without explicit type but with tool information + if let Some(name) = + content_item.get("name").and_then(|n| n.as_str()) + { + tool_name.push_str(name); + } + if let Some(tool_id) = + content_item.get("tool_use_id").and_then(|id| id.as_str()) + { + tool_use_id.push_str(tool_id); + } + if let Some(input) = + content_item.get("input").and_then(|i| i.as_str()) + { + tool_input.push_str(input); + } + } + } + } + } + + // Handle direct content field (for text) only if we didn't find text in content_list + if !found_text_in_content_list { + if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { + text.push_str(content); + } + } + } + } + + // Build the appropriate response structure + let mut content_list = Vec::new(); + + // Add text content if available + if !text.is_empty() { + content_list.push(json!({ + "type": "text", + "text": text + })); + } + + // Add tool use content only if we have complete tool information + if !tool_use_id.is_empty() && !tool_name.is_empty() { + // Parse tool input as JSON if it's not empty + let parsed_input = if tool_input.is_empty() { + json!({}) + } else { + serde_json::from_str::(&tool_input) + .unwrap_or_else(|_| json!({"raw_input": tool_input})) + }; + + content_list.push(json!({ + "type": "tool_use", + "tool_use_id": tool_use_id, + "name": tool_name, + "input": parsed_input + })); + } + + // Ensure we always have at least some content + if content_list.is_empty() { + content_list.push(json!({ + "type": "text", + "text": "" + })); + } + + let answer_payload = json!({ + "role": "assistant", + "content": text, + "content_list": content_list + }); + + match status { + StatusCode::OK => Ok(answer_payload), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + // Extract a clean error message from the response if available + let error_msg = payload_text + .lines() + .find(|line| line.contains("\"message\"")) + .and_then(|line| { + let json_str = line.strip_prefix("data: ").unwrap_or(line); + serde_json::from_str::(json_str).ok() + }) + .and_then(|json| { + json.get("message") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "Invalid credentials".to_string()); + + Err(ProviderError::Authentication(format!( + "Authentication failed. Please check your SNOWFLAKE_TOKEN and SNOWFLAKE_HOST configuration. Error: {}", + error_msg + ))) + } + StatusCode::BAD_REQUEST => { + // Snowflake provides a generic 'error' but also includes 'external_model_message' which is provider specific + // We try to extract the error message from the payload and check for phrases that indicate context length exceeded + let payload_str = payload_text.to_lowercase(); + let check_phrases = [ + "too long", + "context length", + "context_length_exceeded", + "reduce the length", + "token count", + "exceeds", + "exceed context limit", + "input length", + "max_tokens", + "decrease input length", + "context limit", + ]; + if check_phrases.iter().any(|c| payload_str.contains(c)) { + return Err(ProviderError::ContextLengthExceeded("Request exceeds maximum context length. Please reduce the number of messages or content size.".to_string())); + } + + // Try to parse a clean error message from the response + let error_msg = if let Ok(json) = serde_json::from_str::(&payload_text) { + json.get("message") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + json.get("external_model_message") + .and_then(|ext| ext.get("message")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "Bad request".to_string()) + } else { + "Bad request".to_string() + }; + + tracing::debug!( + "Provider request failed with status: {}. Response: {}", + status, + payload_text + ); + Err(ProviderError::RequestFailed(format!( + "Request failed: {}", + error_msg + ))) + } + StatusCode::TOO_MANY_REQUESTS => Err(ProviderError::RateLimitExceeded( + "Rate limit exceeded. Please try again later.".to_string(), + )), + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError( + "Snowflake service is temporarily unavailable. Please try again later." + .to_string(), + )) + } + _ => { + tracing::debug!( + "Provider request failed with status: {}. Response: {}", + status, + payload_text + ); + Err(ProviderError::RequestFailed(format!( + "Request failed with status: {}", + status + ))) + } + } + } +} + +#[async_trait] +impl Provider for SnowflakeProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "snowflake", + "Snowflake", + "Access several models using Snowflake Cortex services.", + SNOWFLAKE_DEFAULT_MODEL, + SNOWFLAKE_KNOWN_MODELS.to_vec(), + SNOWFLAKE_DOC_URL, + vec![ + ConfigKey::new("SNOWFLAKE_HOST", true, false, None), + ConfigKey::new("SNOWFLAKE_TOKEN", true, true, None), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request(&self.model, system, messages, tools)?; + + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + let model = get_model(&response); + super::utils::emit_debug_trace(&self.model, &payload, &response, &usage); + + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/toolshim.rs b/crates/goose/src/providers/toolshim.rs index eea5bd6e..2827caa7 100644 --- a/crates/goose/src/providers/toolshim.rs +++ b/crates/goose/src/providers/toolshim.rs @@ -38,6 +38,7 @@ use crate::model::ModelConfig; use crate::providers::formats::openai::create_request; use anyhow::Result; use mcp_core::tool::{Tool, ToolCall}; +use mcp_core::Content; use reqwest::Client; use serde_json::{json, Value}; use std::time::Duration; @@ -164,7 +165,10 @@ impl OllamaInterpreter { payload["stream"] = json!(false); // needed for the /api/chat endpoint to work payload["format"] = format_schema; - // tracing::warn!("payload: {}", serde_json::to_string_pretty(&payload).unwrap_or_default()); + tracing::info!( + "Tool interpreter payload: {}", + serde_json::to_string_pretty(&payload).unwrap_or_default() + ); let response = self.client.post(&url).json(&payload).send().await?; @@ -193,7 +197,10 @@ impl OllamaInterpreter { fn process_interpreter_response(response: &Value) -> Result, ProviderError> { let mut tool_calls = Vec::new(); - + tracing::info!( + "Tool interpreter response is {}", + serde_json::to_string_pretty(&response).unwrap_or_default() + ); // Extract tool_calls array from the response if response.get("message").is_some() && response["message"].get("content").is_some() { let content = response["message"]["content"].as_str().unwrap_or_default(); @@ -298,6 +305,72 @@ pub fn format_tool_info(tools: &[Tool]) -> String { tool_info } +/// Convert messages containing ToolRequest/ToolResponse to text messages for toolshim mode +/// This is necessary because some providers (like Bedrock) validate that tool_use/tool_result +/// blocks can only exist when tools are defined, but in toolshim mode we pass empty tools +pub fn convert_tool_messages_to_text(messages: &[Message]) -> Vec { + messages + .iter() + .map(|message| { + let mut new_content = Vec::new(); + let mut has_tool_content = false; + + for content in &message.content { + match content { + MessageContent::ToolRequest(req) => { + has_tool_content = true; + // Convert tool request to text format + let text = if let Ok(tool_call) = &req.tool_call { + format!( + "Using tool: {}\n{{\n \"name\": \"{}\",\n \"arguments\": {}\n}}", + tool_call.name, + tool_call.name, + serde_json::to_string_pretty(&tool_call.arguments) + .unwrap_or_default() + ) + } else { + "Tool request failed".to_string() + }; + new_content.push(MessageContent::text(text)); + } + MessageContent::ToolResponse(res) => { + has_tool_content = true; + // Convert tool response to text format + let text = match &res.tool_result { + Ok(contents) => { + let text_contents: Vec = contents + .iter() + .filter_map(|c| match c { + Content::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); + format!("Tool result:\n{}", text_contents.join("\n")) + } + Err(e) => format!("Tool error: {}", e), + }; + new_content.push(MessageContent::text(text)); + } + _ => { + // Keep other content types as-is + new_content.push(content.clone()); + } + } + } + + if has_tool_content { + Message { + role: message.role.clone(), + content: new_content, + created: message.created, + } + } else { + message.clone() + } + }) + .collect() +} + /// Modifies the system prompt to include tool usage instructions when tool interpretation is enabled pub fn modify_system_prompt_for_tool_json(system_prompt: &str, tools: &[Tool]) -> String { let tool_info = format_tool_info(tools); diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 8891dbd4..510ba000 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -28,7 +28,7 @@ fn default_version() -> String { /// /// # Example /// -/// ``` +/// /// use goose::recipe::Recipe; /// /// // Using the builder pattern @@ -52,7 +52,7 @@ fn default_version() -> String { /// author: None, /// parameters: None, /// }; -/// ``` +/// #[derive(Serialize, Deserialize, Debug)] pub struct Recipe { // Required fields @@ -166,7 +166,7 @@ impl Recipe { /// /// # Example /// - /// ``` + /// /// use goose::recipe::Recipe; /// /// let recipe = Recipe::builder() @@ -175,7 +175,7 @@ impl Recipe { /// .instructions("Act as a helpful assistant") /// .build() /// .expect("Failed to build Recipe: missing required fields"); - /// ``` + /// pub fn builder() -> RecipeBuilder { RecipeBuilder { version: default_version(), diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 32a1aea9..e6b0e356 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tokio_cron_scheduler::{job::JobId, Job, JobScheduler as TokioJobScheduler}; +use crate::agents::AgentEvent; use crate::agents::{Agent, SessionConfig}; use crate::config::{self, Config}; use crate::message::Message; @@ -20,6 +21,10 @@ use crate::recipe::Recipe; use crate::session; use crate::session::storage::SessionMetadata; +// Track running tasks with their abort handles +type RunningTasksMap = HashMap; +type JobsMap = HashMap; + pub fn get_default_scheduler_storage_path() -> Result { let strategy = choose_app_strategy(config::APP_STRATEGY.clone()) .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?; @@ -111,11 +116,15 @@ pub struct ScheduledJob { pub currently_running: bool, #[serde(default)] pub paused: bool, + #[serde(default)] + pub current_session_id: Option, + #[serde(default)] + pub process_start_time: Option>, } async fn persist_jobs_from_arc( storage_path: &Path, - jobs_arc: &Arc>>, + jobs_arc: &Arc>, ) -> Result<(), SchedulerError> { let jobs_guard = jobs_arc.lock().await; let list: Vec = jobs_guard.values().map(|(_, j)| j.clone()).collect(); @@ -129,8 +138,9 @@ async fn persist_jobs_from_arc( pub struct Scheduler { internal_scheduler: TokioJobScheduler, - jobs: Arc>>, + jobs: Arc>, storage_path: PathBuf, + running_tasks: Arc>, } impl Scheduler { @@ -140,11 +150,13 @@ impl Scheduler { .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; let jobs = Arc::new(Mutex::new(HashMap::new())); + let running_tasks = Arc::new(Mutex::new(HashMap::new())); let arc_self = Arc::new(Self { internal_scheduler, jobs, storage_path, + running_tasks, }); arc_self.load_jobs_from_storage().await?; @@ -208,17 +220,21 @@ impl Scheduler { let mut stored_job = original_job_spec.clone(); stored_job.source = destination_recipe_path.to_string_lossy().into_owned(); + stored_job.current_session_id = None; + stored_job.process_start_time = None; tracing::info!("Updated job source path to: {}", stored_job.source); let job_for_task = stored_job.clone(); let jobs_arc_for_task = self.jobs.clone(); let storage_path_for_task = self.storage_path.clone(); + let running_tasks_for_task = self.running_tasks.clone(); let cron_task = Job::new_async(&stored_job.cron, move |_uuid, _l| { let task_job_id = job_for_task.id.clone(); let current_jobs_arc = jobs_arc_for_task.clone(); let local_storage_path = storage_path_for_task.clone(); let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal + let running_tasks_arc = running_tasks_for_task.clone(); Box::pin(async move { // Check if the job is paused before executing @@ -243,6 +259,7 @@ impl Scheduler { if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) { current_job_in_map.last_run = Some(current_time); current_job_in_map.currently_running = true; + current_job_in_map.process_start_time = Some(current_time); needs_persist = true; } } @@ -258,14 +275,37 @@ impl Scheduler { ); } } - // Pass None for provider_override in normal execution - let result = run_scheduled_job_internal(job_to_execute, None).await; + + // Spawn the job execution as an abortable task + let job_task = tokio::spawn(run_scheduled_job_internal( + job_to_execute.clone(), + None, + Some(current_jobs_arc.clone()), + Some(task_job_id.clone()), + )); + + // Store the abort handle at the scheduler level + { + let mut running_tasks_guard = running_tasks_arc.lock().await; + running_tasks_guard.insert(task_job_id.clone(), job_task.abort_handle()); + } + + // Wait for the job to complete or be aborted + let result = job_task.await; + + // Remove the abort handle + { + let mut running_tasks_guard = running_tasks_arc.lock().await; + running_tasks_guard.remove(&task_job_id); + } // Update the job status after execution { let mut jobs_map_guard = current_jobs_arc.lock().await; if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) { current_job_in_map.currently_running = false; + current_job_in_map.current_session_id = None; + current_job_in_map.process_start_time = None; needs_persist = true; } } @@ -282,12 +322,27 @@ impl Scheduler { } } - if let Err(e) = result { - tracing::error!( - "Scheduled job '{}' execution failed: {}", - &e.job_id, - e.error - ); + match result { + Ok(Ok(_session_id)) => { + tracing::info!("Scheduled job '{}' completed successfully", &task_job_id); + } + Ok(Err(e)) => { + tracing::error!( + "Scheduled job '{}' execution failed: {}", + &e.job_id, + e.error + ); + } + Err(join_error) if join_error.is_cancelled() => { + tracing::info!("Scheduled job '{}' was cancelled/killed", &task_job_id); + } + Err(join_error) => { + tracing::error!( + "Scheduled job '{}' task failed: {}", + &task_job_id, + join_error + ); + } } }) }) @@ -328,12 +383,14 @@ impl Scheduler { let job_for_task = job_to_load.clone(); let jobs_arc_for_task = self.jobs.clone(); let storage_path_for_task = self.storage_path.clone(); + let running_tasks_for_task = self.running_tasks.clone(); let cron_task = Job::new_async(&job_to_load.cron, move |_uuid, _l| { let task_job_id = job_for_task.id.clone(); let current_jobs_arc = jobs_arc_for_task.clone(); let local_storage_path = storage_path_for_task.clone(); let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal + let running_tasks_arc = running_tasks_for_task.clone(); Box::pin(async move { // Check if the job is paused before executing @@ -358,6 +415,7 @@ impl Scheduler { if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) { stored_job.last_run = Some(current_time); stored_job.currently_running = true; + stored_job.process_start_time = Some(current_time); needs_persist = true; } } @@ -373,14 +431,37 @@ impl Scheduler { ); } } - // Pass None for provider_override in normal execution - let result = run_scheduled_job_internal(job_to_execute, None).await; + + // Spawn the job execution as an abortable task + let job_task = tokio::spawn(run_scheduled_job_internal( + job_to_execute, + None, + Some(current_jobs_arc.clone()), + Some(task_job_id.clone()), + )); + + // Store the abort handle at the scheduler level + { + let mut running_tasks_guard = running_tasks_arc.lock().await; + running_tasks_guard.insert(task_job_id.clone(), job_task.abort_handle()); + } + + // Wait for the job to complete or be aborted + let result = job_task.await; + + // Remove the abort handle + { + let mut running_tasks_guard = running_tasks_arc.lock().await; + running_tasks_guard.remove(&task_job_id); + } // Update the job status after execution { let mut jobs_map_guard = current_jobs_arc.lock().await; if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) { stored_job.currently_running = false; + stored_job.current_session_id = None; + stored_job.process_start_time = None; needs_persist = true; } } @@ -397,12 +478,30 @@ impl Scheduler { } } - if let Err(e) = result { - tracing::error!( - "Scheduled job '{}' execution failed: {}", - &e.job_id, - e.error - ); + match result { + Ok(Ok(_session_id)) => { + tracing::info!( + "Scheduled job '{}' completed successfully", + &task_job_id + ); + } + Ok(Err(e)) => { + tracing::error!( + "Scheduled job '{}' execution failed: {}", + &e.job_id, + e.error + ); + } + Err(join_error) if join_error.is_cancelled() => { + tracing::info!("Scheduled job '{}' was cancelled/killed", &task_job_id); + } + Err(join_error) => { + tracing::error!( + "Scheduled job '{}' task failed: {}", + &task_job_id, + join_error + ); + } } }) }) @@ -421,7 +520,7 @@ impl Scheduler { // Renamed and kept for direct use when a guard is already held (e.g. add/remove) async fn persist_jobs_to_storage_with_guard( &self, - jobs_guard: &tokio::sync::MutexGuard<'_, HashMap>, + jobs_guard: &tokio::sync::MutexGuard<'_, JobsMap>, ) -> Result<(), SchedulerError> { let list: Vec = jobs_guard.values().map(|(_, j)| j.clone()).collect(); if let Some(parent) = self.storage_path.parent() { @@ -523,14 +622,36 @@ impl Scheduler { } }; - // Pass None for provider_override in normal execution - let run_result = run_scheduled_job_internal(job_to_run.clone(), None).await; + // Spawn the job execution as an abortable task for run_now + let job_task = tokio::spawn(run_scheduled_job_internal( + job_to_run.clone(), + None, + Some(self.jobs.clone()), + Some(sched_id.to_string()), + )); + + // Store the abort handle for run_now jobs + { + let mut running_tasks_guard = self.running_tasks.lock().await; + running_tasks_guard.insert(sched_id.to_string(), job_task.abort_handle()); + } + + // Wait for the job to complete or be aborted + let run_result = job_task.await; + + // Remove the abort handle + { + let mut running_tasks_guard = self.running_tasks.lock().await; + running_tasks_guard.remove(sched_id); + } // Clear the currently_running flag after execution { let mut jobs_guard = self.jobs.lock().await; if let Some((_tokio_job_id, job_in_map)) = jobs_guard.get_mut(sched_id) { job_in_map.currently_running = false; + job_in_map.current_session_id = None; + job_in_map.process_start_time = None; job_in_map.last_run = Some(Utc::now()); } // MutexGuard is dropped here } @@ -539,12 +660,24 @@ impl Scheduler { self.persist_jobs().await?; match run_result { - Ok(session_id) => Ok(session_id), - Err(e) => Err(SchedulerError::AnyhowError(anyhow!( + Ok(Ok(session_id)) => Ok(session_id), + Ok(Err(e)) => Err(SchedulerError::AnyhowError(anyhow!( "Failed to execute job '{}' immediately: {}", sched_id, e.error ))), + Err(join_error) if join_error.is_cancelled() => { + tracing::info!("Run now job '{}' was cancelled/killed", sched_id); + Err(SchedulerError::AnyhowError(anyhow!( + "Job '{}' was successfully cancelled", + sched_id + ))) + } + Err(join_error) => Err(SchedulerError::AnyhowError(anyhow!( + "Failed to execute job '{}' immediately: {}", + sched_id, + join_error + ))), } } @@ -608,12 +741,14 @@ impl Scheduler { let job_for_task = job_def.clone(); let jobs_arc_for_task = self.jobs.clone(); let storage_path_for_task = self.storage_path.clone(); + let running_tasks_for_task = self.running_tasks.clone(); let cron_task = Job::new_async(&new_cron, move |_uuid, _l| { let task_job_id = job_for_task.id.clone(); let current_jobs_arc = jobs_arc_for_task.clone(); let local_storage_path = storage_path_for_task.clone(); let job_to_execute = job_for_task.clone(); + let running_tasks_arc = running_tasks_for_task.clone(); Box::pin(async move { // Check if the job is paused before executing @@ -641,6 +776,7 @@ impl Scheduler { { current_job_in_map.last_run = Some(current_time); current_job_in_map.currently_running = true; + current_job_in_map.process_start_time = Some(current_time); needs_persist = true; } } @@ -657,7 +793,29 @@ impl Scheduler { } } - let result = run_scheduled_job_internal(job_to_execute, None).await; + // Spawn the job execution as an abortable task + let job_task = tokio::spawn(run_scheduled_job_internal( + job_to_execute, + None, + Some(current_jobs_arc.clone()), + Some(task_job_id.clone()), + )); + + // Store the abort handle at the scheduler level + { + let mut running_tasks_guard = running_tasks_arc.lock().await; + running_tasks_guard + .insert(task_job_id.clone(), job_task.abort_handle()); + } + + // Wait for the job to complete or be aborted + let result = job_task.await; + + // Remove the abort handle + { + let mut running_tasks_guard = running_tasks_arc.lock().await; + running_tasks_guard.remove(&task_job_id); + } // Update the job status after execution { @@ -666,6 +824,8 @@ impl Scheduler { jobs_map_guard.get_mut(&task_job_id) { current_job_in_map.currently_running = false; + current_job_in_map.current_session_id = None; + current_job_in_map.process_start_time = None; needs_persist = true; } } @@ -682,12 +842,33 @@ impl Scheduler { } } - if let Err(e) = result { - tracing::error!( - "Scheduled job '{}' execution failed: {}", - &e.job_id, - e.error - ); + match result { + Ok(Ok(_session_id)) => { + tracing::info!( + "Scheduled job '{}' completed successfully", + &task_job_id + ); + } + Ok(Err(e)) => { + tracing::error!( + "Scheduled job '{}' execution failed: {}", + &e.job_id, + e.error + ); + } + Err(join_error) if join_error.is_cancelled() => { + tracing::info!( + "Scheduled job '{}' was cancelled/killed", + &task_job_id + ); + } + Err(join_error) => { + tracing::error!( + "Scheduled job '{}' task failed: {}", + &task_job_id, + join_error + ); + } } }) }) @@ -709,6 +890,70 @@ impl Scheduler { None => Err(SchedulerError::JobNotFound(sched_id.to_string())), } } + + pub async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> { + let mut jobs_guard = self.jobs.lock().await; + match jobs_guard.get_mut(sched_id) { + Some((_, job_def)) => { + if !job_def.currently_running { + return Err(SchedulerError::AnyhowError(anyhow!( + "Schedule '{}' is not currently running", + sched_id + ))); + } + + tracing::info!("Killing running job '{}'", sched_id); + + // Abort the running task if it exists + { + let mut running_tasks_guard = self.running_tasks.lock().await; + if let Some(abort_handle) = running_tasks_guard.remove(sched_id) { + abort_handle.abort(); + tracing::info!("Aborted running task for job '{}'", sched_id); + } else { + tracing::warn!( + "No abort handle found for job '{}' in running tasks map", + sched_id + ); + } + } + + // Mark the job as no longer running + job_def.currently_running = false; + job_def.current_session_id = None; + job_def.process_start_time = None; + + self.persist_jobs_to_storage_with_guard(&jobs_guard).await?; + + tracing::info!("Successfully killed job '{}'", sched_id); + Ok(()) + } + None => Err(SchedulerError::JobNotFound(sched_id.to_string())), + } + } + + pub async fn get_running_job_info( + &self, + sched_id: &str, + ) -> Result)>, SchedulerError> { + let jobs_guard = self.jobs.lock().await; + match jobs_guard.get(sched_id) { + Some((_, job_def)) => { + if job_def.currently_running { + if let (Some(session_id), Some(start_time)) = + (&job_def.current_session_id, &job_def.process_start_time) + { + Ok(Some((session_id.clone(), *start_time))) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + None => Err(SchedulerError::JobNotFound(sched_id.to_string())), + } + } } #[derive(Debug)] @@ -720,6 +965,8 @@ struct JobExecutionError { async fn run_scheduled_job_internal( job: ScheduledJob, provider_override: Option>, // New optional parameter + jobs_arc: Option>>, + job_id: Option, ) -> std::result::Result { tracing::info!("Executing job: {} (Source: {})", job.id, job.source); @@ -811,6 +1058,15 @@ async fn run_scheduled_job_internal( tracing::info!("Agent configured with provider for job '{}'", job.id); let session_id_for_return = session::generate_session_id(); + + // Update the job with the session ID if we have access to the jobs arc + if let (Some(jobs_arc), Some(job_id_str)) = (jobs_arc.as_ref(), job_id.as_ref()) { + let mut jobs_guard = jobs_arc.lock().await; + if let Some((_, job_def)) = jobs_guard.get_mut(job_id_str) { + job_def.current_session_id = Some(session_id_for_return.clone()); + } + } + let session_file_path = crate::session::storage::get_path( crate::session::storage::Identifier::Name(session_id_for_return.clone()), ); @@ -843,13 +1099,19 @@ async fn run_scheduled_job_internal( use futures::StreamExt; while let Some(message_result) = stream.next().await { + // Check if the task has been cancelled + tokio::task::yield_now().await; + match message_result { - Ok(msg) => { + Ok(AgentEvent::Message(msg)) => { if msg.role == mcp_core::role::Role::Assistant { tracing::info!("[Job {}] Assistant: {:?}", job.id, msg.content); } all_session_messages.push(msg); } + Ok(AgentEvent::McpNotification(_)) => { + // Handle notifications if needed + } Err(e) => { tracing::error!( "[Job {}] Error receiving message from agent: {}", @@ -1053,6 +1315,8 @@ mod tests { last_run: None, currently_running: false, paused: false, + current_session_id: None, + process_start_time: None, }; // Create the mock provider instance for the test @@ -1061,7 +1325,7 @@ mod tests { // Call run_scheduled_job_internal, passing the mock provider let created_session_id = - run_scheduled_job_internal(dummy_job.clone(), Some(mock_provider_instance)) + run_scheduled_job_internal(dummy_job.clone(), Some(mock_provider_instance), None, None) .await .expect("run_scheduled_job_internal failed"); diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index bb851ab4..8f474ed8 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::Result; use futures::StreamExt; -use goose::agents::Agent; +use goose::agents::{Agent, AgentEvent}; use goose::message::Message; use goose::model::ModelConfig; use goose::providers::base::Provider; @@ -132,7 +132,10 @@ async fn run_truncate_test( let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(response) => responses.push(response), + Ok(AgentEvent::Message(response)) => responses.push(response), + Ok(AgentEvent::McpNotification(n)) => { + println!("MCP Notification: {n:?}"); + } Err(e) => { println!("Error: {:?}", e); return Err(e); diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index d18d4226..1467577c 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -4,7 +4,7 @@ use goose::message::{Message, MessageContent}; use goose::providers::base::Provider; use goose::providers::errors::ProviderError; use goose::providers::{ - anthropic, azure, bedrock, databricks, google, groq, ollama, openai, openrouter, + anthropic, azure, bedrock, databricks, google, groq, ollama, openai, openrouter, snowflake, }; use mcp_core::content::Content; use mcp_core::tool::Tool; @@ -491,6 +491,17 @@ async fn test_google_provider() -> Result<()> { .await } +#[tokio::test] +async fn test_snowflake_provider() -> Result<()> { + test_provider( + "Snowflake", + &["SNOWFLAKE_HOST", "SNOWFLAKE_TOKEN"], + None, + snowflake::SnowflakeProvider::default, + ) + .await +} + // Print the final test report #[ctor::dtor] fn print_test_report() { diff --git a/crates/mcp-client/examples/clients.rs b/crates/mcp-client/examples/clients.rs index 4913b952..e36abeba 100644 --- a/crates/mcp-client/examples/clients.rs +++ b/crates/mcp-client/examples/clients.rs @@ -1,7 +1,6 @@ use mcp_client::{ client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}, transport::{SseTransport, StdioTransport, Transport}, - McpService, }; use rand::Rng; use rand::SeedableRng; @@ -20,18 +19,15 @@ async fn main() -> Result<(), Box> { let transport1 = StdioTransport::new("uvx", vec!["mcp-server-git".to_string()], HashMap::new()); let handle1 = transport1.start().await?; - let service1 = McpService::with_timeout(handle1, Duration::from_secs(30)); - let client1 = McpClient::new(service1); + let client1 = McpClient::connect(handle1, Duration::from_secs(30)).await?; let transport2 = StdioTransport::new("uvx", vec!["mcp-server-git".to_string()], HashMap::new()); let handle2 = transport2.start().await?; - let service2 = McpService::with_timeout(handle2, Duration::from_secs(30)); - let client2 = McpClient::new(service2); + let client2 = McpClient::connect(handle2, Duration::from_secs(30)).await?; let transport3 = SseTransport::new("http://localhost:8000/sse", HashMap::new()); let handle3 = transport3.start().await?; - let service3 = McpService::with_timeout(handle3, Duration::from_secs(10)); - let client3 = McpClient::new(service3); + let client3 = McpClient::connect(handle3, Duration::from_secs(10)).await?; // Initialize both clients let mut clients: Vec> = diff --git a/crates/mcp-client/examples/integration_test.rs b/crates/mcp-client/examples/integration_test.rs new file mode 100644 index 00000000..b16af1be --- /dev/null +++ b/crates/mcp-client/examples/integration_test.rs @@ -0,0 +1,122 @@ +use anyhow::Result; +use futures::lock::Mutex; +use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}; +use mcp_client::transport::{SseTransport, Transport}; +use mcp_client::StdioTransport; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env() + .add_directive("mcp_client=debug".parse().unwrap()) + .add_directive("eventsource_client=info".parse().unwrap()), + ) + .init(); + + test_transport(sse_transport().await?).await?; + test_transport(stdio_transport().await?).await?; + + Ok(()) +} + +async fn sse_transport() -> Result { + let port = "60053"; + + tokio::process::Command::new("npx") + .env("PORT", port) + .arg("@modelcontextprotocol/server-everything") + .arg("sse") + .spawn()?; + tokio::time::sleep(Duration::from_secs(1)).await; + + Ok(SseTransport::new( + format!("http://localhost:{}/sse", port), + HashMap::new(), + )) +} + +async fn stdio_transport() -> Result { + Ok(StdioTransport::new( + "npx", + vec!["@modelcontextprotocol/server-everything"] + .into_iter() + .map(|s| s.to_string()) + .collect(), + HashMap::new(), + )) +} + +async fn test_transport(transport: T) -> Result<()> +where + T: Transport + Send + 'static, +{ + // Start transport + let handle = transport.start().await?; + + // Create client + let mut client = McpClient::connect(handle, Duration::from_secs(10)).await?; + println!("Client created\n"); + + let mut receiver = client.subscribe().await; + let events = Arc::new(Mutex::new(Vec::new())); + let events_clone = events.clone(); + tokio::spawn(async move { + while let Some(event) = receiver.recv().await { + println!("Received event: {event:?}"); + events_clone.lock().await.push(event); + } + }); + + // Initialize + let server_info = client + .initialize( + ClientInfo { + name: "test-client".into(), + version: "1.0.0".into(), + }, + ClientCapabilities::default(), + ) + .await?; + println!("Connected to server: {server_info:?}\n"); + + // Sleep for 100ms to allow the server to start - surprisingly this is required! + tokio::time::sleep(Duration::from_millis(500)).await; + + // List tools + let tools = client.list_tools(None).await?; + println!("Available tools: {tools:#?}\n"); + + // Call tool + let tool_result = client + .call_tool("echo", serde_json::json!({ "message": "honk" })) + .await?; + println!("Tool result: {tool_result:#?}\n"); + + let collected_eventes_before = events.lock().await.len(); + let n_steps = 5; + let long_op = client + .call_tool( + "longRunningOperation", + serde_json::json!({ "duration": 3, "steps": n_steps }), + ) + .await?; + println!("Long op result: {long_op:#?}\n"); + let collected_events_after = events.lock().await.len(); + assert_eq!(collected_events_after - collected_eventes_before, n_steps); + + // List resources + let resources = client.list_resources(None).await?; + println!("Resources: {resources:#?}\n"); + + // Read resource + let resource = client.read_resource("test://static/resource/1").await?; + println!("Resource: {resource:#?}\n"); + + Ok(()) +} diff --git a/crates/mcp-client/examples/sse.rs b/crates/mcp-client/examples/sse.rs index 360a2bbc..6e97a0a6 100644 --- a/crates/mcp-client/examples/sse.rs +++ b/crates/mcp-client/examples/sse.rs @@ -1,7 +1,6 @@ use anyhow::Result; use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}; use mcp_client::transport::{SseTransport, Transport}; -use mcp_client::McpService; use std::collections::HashMap; use std::time::Duration; use tracing_subscriber::EnvFilter; @@ -23,11 +22,8 @@ async fn main() -> Result<()> { // Start transport let handle = transport.start().await?; - // Create the service with timeout middleware - let service = McpService::with_timeout(handle, Duration::from_secs(3)); - // Create client - let mut client = McpClient::new(service); + let mut client = McpClient::connect(handle, Duration::from_secs(3)).await?; println!("Client created\n"); // Initialize diff --git a/crates/mcp-client/examples/stdio.rs b/crates/mcp-client/examples/stdio.rs index e43f036c..98793597 100644 --- a/crates/mcp-client/examples/stdio.rs +++ b/crates/mcp-client/examples/stdio.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use anyhow::Result; use mcp_client::{ - ClientCapabilities, ClientInfo, Error as ClientError, McpClient, McpClientTrait, McpService, + ClientCapabilities, ClientInfo, Error as ClientError, McpClient, McpClientTrait, StdioTransport, Transport, }; use std::time::Duration; @@ -25,11 +25,8 @@ async fn main() -> Result<(), ClientError> { // 2) Start the transport to get a handle let transport_handle = transport.start().await?; - // 3) Create the service with timeout middleware - let service = McpService::with_timeout(transport_handle, Duration::from_secs(10)); - - // 4) Create the client with the middleware-wrapped service - let mut client = McpClient::new(service); + // 3) Create the client with the middleware-wrapped service + let mut client = McpClient::connect(transport_handle, Duration::from_secs(10)).await?; // Initialize let server_info = client diff --git a/crates/mcp-client/examples/stdio_integration.rs b/crates/mcp-client/examples/stdio_integration.rs index ffdcc10c..9b367d25 100644 --- a/crates/mcp-client/examples/stdio_integration.rs +++ b/crates/mcp-client/examples/stdio_integration.rs @@ -5,7 +5,6 @@ use mcp_client::client::{ ClientCapabilities, ClientInfo, Error as ClientError, McpClient, McpClientTrait, }; use mcp_client::transport::{StdioTransport, Transport}; -use mcp_client::McpService; use std::collections::HashMap; use std::time::Duration; use tracing_subscriber::EnvFilter; @@ -34,11 +33,8 @@ async fn main() -> Result<(), ClientError> { // Start the transport to get a handle let transport_handle = transport.start().await.unwrap(); - // Create the service with timeout middleware - let service = McpService::with_timeout(transport_handle, Duration::from_secs(10)); - // Create client - let mut client = McpClient::new(service); + let mut client = McpClient::connect(transport_handle, Duration::from_secs(10)).await?; // Initialize let server_info = client diff --git a/crates/mcp-client/src/client.rs b/crates/mcp-client/src/client.rs index cb8247b9..474592fd 100644 --- a/crates/mcp-client/src/client.rs +++ b/crates/mcp-client/src/client.rs @@ -4,11 +4,16 @@ use mcp_core::protocol::{ ListResourcesResult, ListToolsResult, ReadResourceResult, ServerCapabilities, METHOD_NOT_FOUND, }; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::sync::atomic::{AtomicU64, Ordering}; +use serde_json::{json, Value}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; use thiserror::Error; -use tokio::sync::Mutex; -use tower::{Service, ServiceExt}; // for Service::ready() +use tokio::sync::{mpsc, Mutex}; +use tower::{timeout::TimeoutLayer, Layer, Service, ServiceExt}; + +use crate::{McpService, TransportHandle}; pub type BoxError = Box; @@ -97,34 +102,67 @@ pub trait McpClientTrait: Send + Sync { async fn list_prompts(&self, next_cursor: Option) -> Result; async fn get_prompt(&self, name: &str, arguments: Value) -> Result; + + async fn subscribe(&self) -> mpsc::Receiver; } /// The MCP client is the interface for MCP operations. -pub struct McpClient +pub struct McpClient where - S: Service + Clone + Send + Sync + 'static, - S::Error: Into, - S::Future: Send, + T: TransportHandle + Send + Sync + 'static, { - service: Mutex, + service: Mutex>>, next_id: AtomicU64, server_capabilities: Option, server_info: Option, + notification_subscribers: Arc>>>, } -impl McpClient +impl McpClient where - S: Service + Clone + Send + Sync + 'static, - S::Error: Into, - S::Future: Send, + T: TransportHandle + Send + Sync + 'static, { - pub fn new(service: S) -> Self { - Self { - service: Mutex::new(service), + pub async fn connect(transport: T, timeout: std::time::Duration) -> Result { + let service = McpService::new(transport.clone()); + let service_ptr = service.clone(); + let notification_subscribers = + Arc::new(Mutex::new(Vec::>::new())); + let subscribers_ptr = notification_subscribers.clone(); + + tokio::spawn(async move { + loop { + match transport.receive().await { + Ok(message) => { + tracing::info!("Received message: {:?}", message); + match message { + JsonRpcMessage::Response(JsonRpcResponse { id: Some(id), .. }) => { + service_ptr.respond(&id.to_string(), Ok(message)).await; + } + _ => { + let mut subs = subscribers_ptr.lock().await; + subs.retain(|sub| sub.try_send(message.clone()).is_ok()); + } + } + } + Err(e) => { + tracing::error!("transport error: {:?}", e); + service_ptr.hangup().await; + subscribers_ptr.lock().await.clear(); + break; + } + } + } + }); + + let middleware = TimeoutLayer::new(timeout); + + Ok(Self { + service: Mutex::new(middleware.layer(service)), next_id: AtomicU64::new(1), server_capabilities: None, server_info: None, - } + notification_subscribers, + }) } /// Send a JSON-RPC request and check we don't get an error response. @@ -134,13 +172,18 @@ where { let mut service = self.service.lock().await; service.ready().await.map_err(|_| Error::NotReady)?; - let id = self.next_id.fetch_add(1, Ordering::SeqCst); + + let mut params = params.clone(); + params["_meta"] = json!({ + "progressToken": format!("prog-{}", id), + }); + let request = JsonRpcMessage::Request(JsonRpcRequest { jsonrpc: "2.0".to_string(), id: Some(id), method: method.to_string(), - params: Some(params.clone()), + params: Some(params), }); let response_msg = service @@ -154,7 +197,7 @@ where .unwrap_or("".to_string()), method: method.to_string(), // we don't need include params because it can be really large - source: Box::new(e.into()), + source: Box::::new(e.into()), })?; match response_msg { @@ -220,7 +263,7 @@ where .unwrap_or("".to_string()), method: method.to_string(), // we don't need include params because it can be really large - source: Box::new(e.into()), + source: Box::::new(e.into()), })?; Ok(()) @@ -233,11 +276,9 @@ where } #[async_trait::async_trait] -impl McpClientTrait for McpClient +impl McpClientTrait for McpClient where - S: Service + Clone + Send + Sync + 'static, - S::Error: Into, - S::Future: Send, + T: TransportHandle + Send + Sync + 'static, { async fn initialize( &mut self, @@ -388,4 +429,10 @@ where self.send_request("prompts/get", params).await } + + async fn subscribe(&self) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(16); + self.notification_subscribers.lock().await.push(tx); + rx + } } diff --git a/crates/mcp-client/src/service.rs b/crates/mcp-client/src/service.rs index 00aa95be..b2ea82cf 100644 --- a/crates/mcp-client/src/service.rs +++ b/crates/mcp-client/src/service.rs @@ -1,7 +1,9 @@ use futures::future::BoxFuture; -use mcp_core::protocol::JsonRpcMessage; +use mcp_core::protocol::{JsonRpcMessage, JsonRpcRequest}; +use std::collections::HashMap; use std::sync::Arc; use std::task::{Context, Poll}; +use tokio::sync::{oneshot, RwLock}; use tower::{timeout::Timeout, Service, ServiceBuilder}; use crate::transport::{Error, TransportHandle}; @@ -10,14 +12,24 @@ use crate::transport::{Error, TransportHandle}; #[derive(Clone)] pub struct McpService { inner: Arc, + pending_requests: Arc, } impl McpService { pub fn new(transport: T) -> Self { Self { inner: Arc::new(transport), + pending_requests: Arc::new(PendingRequests::default()), } } + + pub async fn respond(&self, id: &str, response: Result) { + self.pending_requests.respond(id, response).await + } + + pub async fn hangup(&self) { + self.pending_requests.broadcast_close().await + } } impl Service for McpService @@ -35,7 +47,31 @@ where fn call(&mut self, request: JsonRpcMessage) -> Self::Future { let transport = self.inner.clone(); - Box::pin(async move { transport.send(request).await }) + let pending_requests = self.pending_requests.clone(); + + Box::pin(async move { + match request { + JsonRpcMessage::Request(JsonRpcRequest { id: Some(id), .. }) => { + // Create a channel to receive the response + let (sender, receiver) = oneshot::channel(); + pending_requests.insert(id.to_string(), sender).await; + + transport.send(request).await?; + receiver.await.map_err(|_| Error::ChannelClosed)? + } + JsonRpcMessage::Request(_) => { + // Handle notifications without waiting for a response + transport.send(request).await?; + Ok(JsonRpcMessage::Nil) + } + JsonRpcMessage::Notification(_) => { + // Handle notifications without waiting for a response + transport.send(request).await?; + Ok(JsonRpcMessage::Nil) + } + _ => Err(Error::UnsupportedMessage), + } + }) } } @@ -50,3 +86,50 @@ where .service(McpService::new(transport)) } } + +// A data structure to store pending requests and their response channels +pub struct PendingRequests { + requests: RwLock>>>, +} + +impl Default for PendingRequests { + fn default() -> Self { + Self::new() + } +} + +impl PendingRequests { + pub fn new() -> Self { + Self { + requests: RwLock::new(HashMap::new()), + } + } + + pub async fn insert(&self, id: String, sender: oneshot::Sender>) { + self.requests.write().await.insert(id, sender); + } + + pub async fn respond(&self, id: &str, response: Result) { + if let Some(tx) = self.requests.write().await.remove(id) { + let _ = tx.send(response); + } + } + + pub async fn broadcast_close(&self) { + for (_, tx) in self.requests.write().await.drain() { + let _ = tx.send(Err(Error::ChannelClosed)); + } + } + + pub async fn clear(&self) { + self.requests.write().await.clear(); + } + + pub async fn len(&self) -> usize { + self.requests.read().await.len() + } + + pub async fn is_empty(&self) -> bool { + self.len().await == 0 + } +} diff --git a/crates/mcp-client/src/transport/mod.rs b/crates/mcp-client/src/transport/mod.rs index e2a66b26..28e6d929 100644 --- a/crates/mcp-client/src/transport/mod.rs +++ b/crates/mcp-client/src/transport/mod.rs @@ -1,8 +1,7 @@ use async_trait::async_trait; use mcp_core::protocol::JsonRpcMessage; -use std::collections::HashMap; use thiserror::Error; -use tokio::sync::{mpsc, oneshot, RwLock}; +use tokio::sync::{mpsc, oneshot}; pub type BoxError = Box; /// A generic error type for transport operations. @@ -57,74 +56,20 @@ pub trait Transport { #[async_trait] pub trait TransportHandle: Send + Sync + Clone + 'static { - async fn send(&self, message: JsonRpcMessage) -> Result; + async fn send(&self, message: JsonRpcMessage) -> Result<(), Error>; + async fn receive(&self) -> Result; } -// Helper function that contains the common send implementation -pub async fn send_message( - sender: &mpsc::Sender, +pub async fn serialize_and_send( + sender: &mpsc::Sender, message: JsonRpcMessage, -) -> Result { - match message { - JsonRpcMessage::Request(request) => { - let (respond_to, response) = oneshot::channel(); - let msg = TransportMessage { - message: JsonRpcMessage::Request(request), - response_tx: Some(respond_to), - }; - sender.send(msg).await.map_err(|_| Error::ChannelClosed)?; - Ok(response.await.map_err(|_| Error::ChannelClosed)??) +) -> Result<(), Error> { + match serde_json::to_string(&message).map_err(Error::Serialization) { + Ok(msg) => sender.send(msg).await.map_err(|_| Error::ChannelClosed), + Err(e) => { + tracing::error!(error = ?e, "Error serializing message"); + Err(e) } - JsonRpcMessage::Notification(notification) => { - let msg = TransportMessage { - message: JsonRpcMessage::Notification(notification), - response_tx: None, - }; - sender.send(msg).await.map_err(|_| Error::ChannelClosed)?; - Ok(JsonRpcMessage::Nil) - } - _ => Err(Error::UnsupportedMessage), - } -} - -// A data structure to store pending requests and their response channels -pub struct PendingRequests { - requests: RwLock>>>, -} - -impl Default for PendingRequests { - fn default() -> Self { - Self::new() - } -} - -impl PendingRequests { - pub fn new() -> Self { - Self { - requests: RwLock::new(HashMap::new()), - } - } - - pub async fn insert(&self, id: String, sender: oneshot::Sender>) { - self.requests.write().await.insert(id, sender); - } - - pub async fn respond(&self, id: &str, response: Result) { - if let Some(tx) = self.requests.write().await.remove(id) { - let _ = tx.send(response); - } - } - - pub async fn clear(&self) { - self.requests.write().await.clear(); - } - - pub async fn len(&self) -> usize { - self.requests.read().await.len() - } - - pub async fn is_empty(&self) -> bool { - self.len().await == 0 } } diff --git a/crates/mcp-client/src/transport/sse.rs b/crates/mcp-client/src/transport/sse.rs index 0e15f168..7a38aca9 100644 --- a/crates/mcp-client/src/transport/sse.rs +++ b/crates/mcp-client/src/transport/sse.rs @@ -1,17 +1,17 @@ -use crate::transport::{Error, PendingRequests, TransportMessage}; +use crate::transport::Error; use async_trait::async_trait; use eventsource_client::{Client, SSE}; use futures::TryStreamExt; -use mcp_core::protocol::{JsonRpcMessage, JsonRpcRequest}; +use mcp_core::protocol::JsonRpcMessage; use reqwest::Client as HttpClient; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::{mpsc, Mutex, RwLock}; use tokio::time::{timeout, Duration}; use tracing::warn; use url::Url; -use super::{send_message, Transport, TransportHandle}; +use super::{serialize_and_send, Transport, TransportHandle}; // Timeout for the endpoint discovery const ENDPOINT_TIMEOUT_SECS: u64 = 5; @@ -21,9 +21,9 @@ const ENDPOINT_TIMEOUT_SECS: u64 = 5; /// - Sends outgoing messages via HTTP POST (once the post endpoint is known). pub struct SseActor { /// Receives messages (requests/notifications) from the handle - receiver: mpsc::Receiver, - /// Map of request-id -> oneshot sender - pending_requests: Arc, + receiver: mpsc::Receiver, + /// Sends messages (responses) back to the handle + sender: mpsc::Sender, /// Base SSE URL sse_url: String, /// For sending HTTP POST requests @@ -34,14 +34,14 @@ pub struct SseActor { impl SseActor { pub fn new( - receiver: mpsc::Receiver, - pending_requests: Arc, + receiver: mpsc::Receiver, + sender: mpsc::Sender, sse_url: String, post_endpoint: Arc>>, ) -> Self { Self { receiver, - pending_requests, + sender, sse_url, post_endpoint, http_client: HttpClient::new(), @@ -54,15 +54,14 @@ impl SseActor { pub async fn run(self) { tokio::join!( Self::handle_incoming_messages( + self.sender, self.sse_url.clone(), - Arc::clone(&self.pending_requests), Arc::clone(&self.post_endpoint) ), Self::handle_outgoing_messages( self.receiver, self.http_client.clone(), Arc::clone(&self.post_endpoint), - Arc::clone(&self.pending_requests), ) ); } @@ -72,14 +71,13 @@ impl SseActor { /// - If a `message` event is received, parse it as `JsonRpcMessage` /// and respond to pending requests if it's a `Response`. async fn handle_incoming_messages( + sender: mpsc::Sender, sse_url: String, - pending_requests: Arc, post_endpoint: Arc>>, ) { let client = match eventsource_client::ClientBuilder::for_url(&sse_url) { Ok(builder) => builder.build(), Err(e) => { - pending_requests.clear().await; warn!("Failed to connect SSE client: {}", e); return; } @@ -105,84 +103,54 @@ impl SseActor { } // Now handle subsequent events - while let Ok(Some(event)) = stream.try_next().await { - match event { - SSE::Event(e) if e.event_type == "message" => { - // Attempt to parse the SSE data as a JsonRpcMessage - match serde_json::from_str::(&e.data) { - Ok(message) => { - match &message { - JsonRpcMessage::Response(response) => { - if let Some(id) = &response.id { - pending_requests - .respond(&id.to_string(), Ok(message)) - .await; - } + loop { + match stream.try_next().await { + Ok(Some(event)) => { + match event { + SSE::Event(e) if e.event_type == "message" => { + // Attempt to parse the SSE data as a JsonRpcMessage + match serde_json::from_str::(&e.data) { + Ok(message) => { + let _ = sender.send(message).await; } - JsonRpcMessage::Error(error) => { - if let Some(id) = &error.id { - pending_requests - .respond(&id.to_string(), Ok(message)) - .await; - } + Err(err) => { + warn!("Failed to parse SSE message: {err}"); } - _ => {} // TODO: Handle other variants (Request, etc.) } } - Err(err) => { - warn!("Failed to parse SSE message: {err}"); - } + _ => { /* ignore other events */ } } } - _ => { /* ignore other events */ } + Ok(None) => { + // Stream ended + tracing::info!("SSE stream ended."); + break; + } + Err(e) => { + warn!("Error reading SSE stream: {e}"); + break; + } } } - // SSE stream ended or errored; signal any pending requests - tracing::error!("SSE stream ended or encountered an error; clearing pending requests."); - pending_requests.clear().await; + tracing::error!("SSE stream ended or encountered an error."); } - /// Continuously receives messages from the `mpsc::Receiver`. - /// - If it's a request, store the oneshot in `pending_requests`. - /// - POST the message to the discovered endpoint (once known). async fn handle_outgoing_messages( - mut receiver: mpsc::Receiver, + mut receiver: mpsc::Receiver, http_client: HttpClient, post_endpoint: Arc>>, - pending_requests: Arc, ) { - while let Some(transport_msg) = receiver.recv().await { + while let Some(message_str) = receiver.recv().await { let post_url = match post_endpoint.read().await.as_ref() { Some(url) => url.clone(), None => { - if let Some(response_tx) = transport_msg.response_tx { - let _ = response_tx.send(Err(Error::NotConnected)); - } + // TODO: the endpoint isn't discovered yet. This shouldn't happen -- we only return the handle + // after the endpoint is set. continue; } }; - // Serialize the JSON-RPC message - let message_str = match serde_json::to_string(&transport_msg.message) { - Ok(s) => s, - Err(e) => { - if let Some(tx) = transport_msg.response_tx { - let _ = tx.send(Err(Error::Serialization(e))); - } - continue; - } - }; - - // If it's a request, store the channel so we can respond later - if let Some(response_tx) = transport_msg.response_tx { - if let JsonRpcMessage::Request(JsonRpcRequest { id: Some(id), .. }) = - &transport_msg.message - { - pending_requests.insert(id.to_string(), response_tx).await; - } - } - // Perform the HTTP POST match http_client .post(&post_url) @@ -209,33 +177,25 @@ impl SseActor { } } - // mpsc channel closed => no more outgoing messages - let pending = pending_requests.len().await; - if pending > 0 { - tracing::error!("SSE stream ended or encountered an error with {pending} unfulfilled pending requests."); - pending_requests.clear().await; - } else { - tracing::info!("SseActor shutdown cleanly. No pending requests."); - } + tracing::info!("SseActor shut down."); } } #[derive(Clone)] pub struct SseTransportHandle { - sender: mpsc::Sender, - pending_requests: Arc, + sender: mpsc::Sender, + receiver: Arc>>, } #[async_trait::async_trait] impl TransportHandle for SseTransportHandle { - async fn send(&self, message: JsonRpcMessage) -> Result { - send_message(&self.sender, message).await + async fn send(&self, message: JsonRpcMessage) -> Result<(), Error> { + serialize_and_send(&self.sender, message).await } -} -impl SseTransportHandle { - pub fn pending_requests(&self) -> Arc { - Arc::clone(&self.pending_requests) + async fn receive(&self) -> Result { + let mut receiver = self.receiver.lock().await; + receiver.recv().await.ok_or(Error::ChannelClosed) } } @@ -286,18 +246,13 @@ impl Transport for SseTransport { // Create a channel for outgoing TransportMessages let (tx, rx) = mpsc::channel(32); + let (otx, orx) = mpsc::channel(32); let post_endpoint: Arc>> = Arc::new(RwLock::new(None)); let post_endpoint_clone = Arc::clone(&post_endpoint); // Build the actor - let pending_requests = Arc::new(PendingRequests::new()); - let actor = SseActor::new( - rx, - pending_requests.clone(), - self.sse_url.clone(), - post_endpoint, - ); + let actor = SseActor::new(rx, otx, self.sse_url.clone(), post_endpoint); // Spawn the actor task tokio::spawn(actor.run()); @@ -309,7 +264,10 @@ impl Transport for SseTransport { ) .await { - Ok(_) => Ok(SseTransportHandle { sender: tx, pending_requests }), + Ok(_) => Ok(SseTransportHandle { + sender: tx, + receiver: Arc::new(Mutex::new(orx)), + }), Err(e) => Err(Error::SseConnection(e.to_string())), } } diff --git a/crates/mcp-client/src/transport/stdio.rs b/crates/mcp-client/src/transport/stdio.rs index 76a48487..de4378b3 100644 --- a/crates/mcp-client/src/transport/stdio.rs +++ b/crates/mcp-client/src/transport/stdio.rs @@ -14,7 +14,7 @@ use nix::sys::signal::{kill, Signal}; #[cfg(unix)] use nix::unistd::{getpgid, Pid}; -use super::{send_message, Error, PendingRequests, Transport, TransportHandle, TransportMessage}; +use super::{serialize_and_send, Error, Transport, TransportHandle}; // Global to track process groups we've created static PROCESS_GROUP: AtomicI32 = AtomicI32::new(-1); @@ -23,8 +23,8 @@ static PROCESS_GROUP: AtomicI32 = AtomicI32::new(-1); /// /// It uses channels for message passing and handles responses asynchronously through a background task. pub struct StdioActor { - receiver: Option>, - pending_requests: Arc, + receiver: Option>, + sender: Option>, process: Child, // we store the process to keep it alive error_sender: mpsc::Sender, stdin: Option, @@ -55,11 +55,11 @@ impl StdioActor { let stdout = self.stdout.take().expect("stdout should be available"); let stdin = self.stdin.take().expect("stdin should be available"); - let receiver = self.receiver.take().expect("receiver should be available"); + let msg_inbox = self.receiver.take().expect("receiver should be available"); + let msg_outbox = self.sender.take().expect("sender should be available"); - let incoming = Self::handle_incoming_messages(stdout, self.pending_requests.clone()); - let outgoing = - Self::handle_outgoing_messages(receiver, stdin, self.pending_requests.clone()); + let incoming = Self::handle_proc_output(stdout, msg_outbox); + let outgoing = Self::handle_proc_input(stdin, msg_inbox); // take ownership of futures for tokio::select pin!(incoming); @@ -96,12 +96,9 @@ impl StdioActor { .await; } } - - // Clean up regardless of which path we took - self.pending_requests.clear().await; } - async fn handle_incoming_messages(stdout: ChildStdout, pending_requests: Arc) { + async fn handle_proc_output(stdout: ChildStdout, sender: mpsc::Sender) { let mut reader = BufReader::new(stdout); let mut line = String::new(); loop { @@ -116,20 +113,12 @@ impl StdioActor { message = ?message, "Received incoming message" ); - - match &message { - JsonRpcMessage::Response(response) => { - if let Some(id) = &response.id { - pending_requests.respond(&id.to_string(), Ok(message)).await; - } - } - JsonRpcMessage::Error(error) => { - if let Some(id) = &error.id { - pending_requests.respond(&id.to_string(), Ok(message)).await; - } - } - _ => {} // TODO: Handle other variants (Request, etc.) - } + let _ = sender.send(message).await; + } else { + tracing::warn!( + message = ?line, + "Failed to parse incoming message" + ); } line.clear(); } @@ -141,44 +130,20 @@ impl StdioActor { } } - async fn handle_outgoing_messages( - mut receiver: mpsc::Receiver, - mut stdin: ChildStdin, - pending_requests: Arc, - ) { - while let Some(mut transport_msg) = receiver.recv().await { - let message_str = match serde_json::to_string(&transport_msg.message) { - Ok(s) => s, - Err(e) => { - if let Some(tx) = transport_msg.response_tx.take() { - let _ = tx.send(Err(Error::Serialization(e))); - } - continue; - } - }; - - tracing::debug!(message = ?transport_msg.message, "Sending outgoing message"); - - if let Some(response_tx) = transport_msg.response_tx.take() { - if let JsonRpcMessage::Request(request) = &transport_msg.message { - if let Some(id) = &request.id { - pending_requests.insert(id.to_string(), response_tx).await; - } - } - } + async fn handle_proc_input(mut stdin: ChildStdin, mut receiver: mpsc::Receiver) { + while let Some(message_str) = receiver.recv().await { + tracing::debug!(message = ?message_str, "Sending outgoing message"); if let Err(e) = stdin .write_all(format!("{}\n", message_str).as_bytes()) .await { tracing::error!(error = ?e, "Error writing message to child process"); - pending_requests.clear().await; break; } if let Err(e) = stdin.flush().await { tracing::error!(error = ?e, "Error flushing message to child process"); - pending_requests.clear().await; break; } } @@ -187,19 +152,25 @@ impl StdioActor { #[derive(Clone)] pub struct StdioTransportHandle { - sender: mpsc::Sender, + sender: mpsc::Sender, // to process + receiver: Arc>>, // from process error_receiver: Arc>>, pending_requests: Arc, } #[async_trait::async_trait] impl TransportHandle for StdioTransportHandle { - async fn send(&self, message: JsonRpcMessage) -> Result { - let result = send_message(&self.sender, message).await; + async fn send(&self, message: JsonRpcMessage) -> Result<(), Error> { + let result = serialize_and_send(&self.sender, message).await; // Check for any pending errors even if send is successful self.check_for_errors().await?; result } + + async fn receive(&self) -> Result { + let mut receiver = self.receiver.lock().await; + receiver.recv().await.ok_or(Error::ChannelClosed) + } } impl StdioTransportHandle { @@ -294,13 +265,14 @@ impl Transport for StdioTransport { async fn start(&self) -> Result { let (process, stdin, stdout, stderr) = self.spawn_process().await?; - let (message_tx, message_rx) = mpsc::channel(32); + let (outbox_tx, outbox_rx) = mpsc::channel(32); + let (inbox_tx, inbox_rx) = mpsc::channel(32); let (error_tx, error_rx) = mpsc::channel(1); let pending_requests = Arc::new(PendingRequests::new()); let actor = StdioActor { - receiver: Some(message_rx), - pending_requests: pending_requests.clone(), + receiver: Some(outbox_rx), // client to process + sender: Some(inbox_tx), // process to client process, error_sender: error_tx, stdin: Some(stdin), @@ -311,7 +283,8 @@ impl Transport for StdioTransport { tokio::spawn(actor.run()); let handle = StdioTransportHandle { - sender: message_tx, + sender: outbox_tx, // client to process + receiver: Arc::new(Mutex::new(inbox_rx)), // process to client error_receiver: Arc::new(Mutex::new(error_rx)), pending_requests, }; diff --git a/crates/mcp-server/src/lib.rs b/crates/mcp-server/src/lib.rs index 97594523..413d01d9 100644 --- a/crates/mcp-server/src/lib.rs +++ b/crates/mcp-server/src/lib.rs @@ -4,9 +4,13 @@ use std::{ }; use futures::{Future, Stream}; -use mcp_core::protocol::{JsonRpcError, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse}; +use mcp_core::protocol::{JsonRpcError, JsonRpcMessage, JsonRpcResponse}; use pin_project::pin_project; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use router::McpRequest; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}, + sync::mpsc, +}; use tower_service::Service; mod errors; @@ -123,7 +127,7 @@ pub struct Server { impl Server where - S: Service + Send, + S: Service + Send, S::Error: Into, S::Future: Send, { @@ -134,8 +138,8 @@ where // TODO transport trait instead of byte transport if we implement others pub async fn run(self, mut transport: ByteTransport) -> Result<(), ServerError> where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, { use futures::StreamExt; let mut service = self.service; @@ -160,7 +164,22 @@ where ); // Process the request using our service - let response = match service.call(request).await { + let (notify_tx, mut notify_rx) = mpsc::channel(256); + let mcp_request = McpRequest { + request, + notifier: notify_tx, + }; + + let transport_fut = tokio::spawn(async move { + while let Some(notification) = notify_rx.recv().await { + if transport.write_message(notification).await.is_err() { + break; + } + } + transport + }); + + let response = match service.call(mcp_request).await { Ok(resp) => resp, Err(e) => { let error_msg = e.into().to_string(); @@ -178,6 +197,16 @@ where } }; + transport = match transport_fut.await { + Ok(transport) => transport, + Err(e) => { + tracing::error!(error = %e, "Failed to spawn transport task"); + return Err(ServerError::Transport(TransportError::Io( + e.into(), + ))); + } + }; + // Serialize response for logging let response_json = serde_json::to_string(&response) .unwrap_or_else(|_| "Failed to serialize response".to_string()); @@ -247,7 +276,7 @@ where // Any router implements this pub trait BoundedService: Service< - JsonRpcRequest, + McpRequest, Response = JsonRpcResponse, Error = BoxError, Future = Pin> + Send>>, @@ -259,7 +288,7 @@ pub trait BoundedService: // Implement it for any type that meets the bounds impl BoundedService for T where T: Service< - JsonRpcRequest, + McpRequest, Response = JsonRpcResponse, Error = BoxError, Future = Pin> + Send>>, diff --git a/crates/mcp-server/src/main.rs b/crates/mcp-server/src/main.rs index ff2b7adf..f09032b0 100644 --- a/crates/mcp-server/src/main.rs +++ b/crates/mcp-server/src/main.rs @@ -2,12 +2,14 @@ use anyhow::Result; use mcp_core::content::Content; use mcp_core::handler::{PromptError, ResourceError}; use mcp_core::prompt::{Prompt, PromptArgument}; +use mcp_core::protocol::JsonRpcMessage; use mcp_core::tool::ToolAnnotations; use mcp_core::{handler::ToolError, protocol::ServerCapabilities, resource::Resource, tool::Tool}; use mcp_server::router::{CapabilitiesBuilder, RouterService}; use mcp_server::{ByteTransport, Router, Server}; use serde_json::Value; use std::{future::Future, pin::Pin, sync::Arc}; +use tokio::sync::mpsc; use tokio::{ io::{stdin, stdout}, sync::Mutex, @@ -124,6 +126,7 @@ impl Router for CounterRouter { &self, tool_name: &str, _arguments: Value, + _notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>> { let this = self.clone(); let tool_name = tool_name.to_string(); diff --git a/crates/mcp-server/src/router.rs b/crates/mcp-server/src/router.rs index 0931966f..6370bd1f 100644 --- a/crates/mcp-server/src/router.rs +++ b/crates/mcp-server/src/router.rs @@ -11,14 +11,15 @@ use mcp_core::{ handler::{PromptError, ResourceError, ToolError}, prompt::{Prompt, PromptMessage, PromptMessageRole}, protocol::{ - CallToolResult, GetPromptResult, Implementation, InitializeResult, JsonRpcRequest, - JsonRpcResponse, ListPromptsResult, ListResourcesResult, ListToolsResult, + CallToolResult, GetPromptResult, Implementation, InitializeResult, JsonRpcMessage, + JsonRpcRequest, JsonRpcResponse, ListPromptsResult, ListResourcesResult, ListToolsResult, PromptsCapability, ReadResourceResult, ResourcesCapability, ServerCapabilities, ToolsCapability, }, ResourceContents, }; use serde_json::Value; +use tokio::sync::mpsc; use tower_service::Service; use crate::{BoxError, RouterError}; @@ -91,6 +92,7 @@ pub trait Router: Send + Sync + 'static { &self, tool_name: &str, arguments: Value, + notifier: mpsc::Sender, ) -> Pin, ToolError>> + Send + 'static>>; fn list_resources(&self) -> Vec; fn read_resource( @@ -159,6 +161,7 @@ pub trait Router: Send + Sync + 'static { fn handle_tools_call( &self, req: JsonRpcRequest, + notifier: mpsc::Sender, ) -> impl Future> + Send { async move { let params = req @@ -172,7 +175,7 @@ pub trait Router: Send + Sync + 'static { let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); - let result = match self.call_tool(name, arguments).await { + let result = match self.call_tool(name, arguments, notifier).await { Ok(result) => CallToolResult { content: result, is_error: None, @@ -394,7 +397,12 @@ pub trait Router: Send + Sync + 'static { pub struct RouterService(pub T); -impl Service for RouterService +pub struct McpRequest { + pub request: JsonRpcRequest, + pub notifier: mpsc::Sender, +} + +impl Service for RouterService where T: Router + Clone + Send + Sync + 'static, { @@ -406,21 +414,21 @@ where Poll::Ready(Ok(())) } - fn call(&mut self, req: JsonRpcRequest) -> Self::Future { + fn call(&mut self, req: McpRequest) -> Self::Future { let this = self.0.clone(); Box::pin(async move { - let result = match req.method.as_str() { - "initialize" => this.handle_initialize(req).await, - "tools/list" => this.handle_tools_list(req).await, - "tools/call" => this.handle_tools_call(req).await, - "resources/list" => this.handle_resources_list(req).await, - "resources/read" => this.handle_resources_read(req).await, - "prompts/list" => this.handle_prompts_list(req).await, - "prompts/get" => this.handle_prompts_get(req).await, + let result = match req.request.method.as_str() { + "initialize" => this.handle_initialize(req.request).await, + "tools/list" => this.handle_tools_list(req.request).await, + "tools/call" => this.handle_tools_call(req.request, req.notifier).await, + "resources/list" => this.handle_resources_list(req.request).await, + "resources/read" => this.handle_resources_read(req.request).await, + "prompts/list" => this.handle_prompts_list(req.request).await, + "prompts/get" => this.handle_prompts_get(req.request).await, _ => { - let mut response = this.create_response(req.id); - response.error = Some(RouterError::MethodNotFound(req.method).into()); + let mut response = this.create_response(req.request.id); + response.error = Some(RouterError::MethodNotFound(req.request.method).into()); Ok(response) } }; diff --git a/documentation/blog/2025-06-02-goose-panther-mcp/automated-testing-graphic.png b/documentation/blog/2025-06-02-goose-panther-mcp/automated-testing-graphic.png new file mode 100644 index 00000000..c9a46879 Binary files /dev/null and b/documentation/blog/2025-06-02-goose-panther-mcp/automated-testing-graphic.png differ diff --git a/documentation/blog/2025-06-02-goose-panther-mcp/context-reuse-example.png b/documentation/blog/2025-06-02-goose-panther-mcp/context-reuse-example.png new file mode 100644 index 00000000..c81de28b Binary files /dev/null and b/documentation/blog/2025-06-02-goose-panther-mcp/context-reuse-example.png differ diff --git a/documentation/blog/2025-06-02-goose-panther-mcp/goose-panther-header.png b/documentation/blog/2025-06-02-goose-panther-mcp/goose-panther-header.png new file mode 100644 index 00000000..d8798d23 Binary files /dev/null and b/documentation/blog/2025-06-02-goose-panther-mcp/goose-panther-header.png differ diff --git a/documentation/blog/2025-06-02-goose-panther-mcp/goose-panther-mcp-interaction.png b/documentation/blog/2025-06-02-goose-panther-mcp/goose-panther-mcp-interaction.png new file mode 100644 index 00000000..e8bb23b6 Binary files /dev/null and b/documentation/blog/2025-06-02-goose-panther-mcp/goose-panther-mcp-interaction.png differ diff --git a/documentation/blog/2025-06-02-goose-panther-mcp/index.md b/documentation/blog/2025-06-02-goose-panther-mcp/index.md new file mode 100644 index 00000000..d257945e --- /dev/null +++ b/documentation/blog/2025-06-02-goose-panther-mcp/index.md @@ -0,0 +1,442 @@ +--- +title: "Democratizing Detection Engineering at Block: Taking Flight with Goose and Panther MCP" +description: "A comprehensive overview of how Block leverages Goose and Panther MCP to democratize and accelerate security detection engineering." +authors: + - tomasz + - glenn +--- + +![blog cover](goose-panther-header.png) + +Detection engineering stands at the forefront of cybersecurity, yet it’s often a tangled web of complexity. Traditional detection writing involves painstaking manual processes encompassing log format and schema comprehension, intricate query creation, threat modeling, and iterative manual detection testing and refinement, leading to time expenditure and reliance on specialized expertise. This can lead to gaps in threat coverage and an overwhelming number of alerts. At Block, we face the relentless challenge of evolving threats and intricate system complexities. To stay ahead, we've embraced AI-driven solutions, notably Goose, Block’s open-source AI agent, and Panther MCP, to allow the broader organization to contribute high-quality rules that are contextual to their area of expertise. This post delves into how we're transforming complicated detection workflows into streamlined, AI-powered, accessible processes for all stakeholders. + + + +## The Detection Engineering Challenge + +Historically, creating effective detections has been a niche skill, requiring deep technical knowledge and coding proficiency. This has created significant obstacles such as: + +* **Steep Learning Curve:** Crafting detections typically requires extensive technical expertise, often limiting participation. +* **Resources Constraints:** Even expert security teams often struggle with bandwidth, hindering their ability to develop and deploy new detections quickly. +* **Evolving Threat Landscape:** Advanced threats, particularly those from sophisticated nation-states actors, continuously evolve, outpacing traditional detection development processes. + +## Vision + +We envision a future where anyone at Block can effortlessly create and deploy security detections, revolutionizing our defenses through intelligent automation and empowering a democratized security posture. + +## Introducing Panther MCP + +### What is Panther MCP? + +[Panther MCP](https://github.com/panther-labs/mcp-panther) is an open-source model context protocol server born from the collaboration between [Panther](https://panther.com/) and Block to democratize security operations workflows. By tightly integrating with Goose as an extension, Panther MCP allows security teams at Block to translate natural language instructions into precise, executable SIEM detection logic, making threat detection contributions easier and faster than ever. + +This integration empowers analysts and engineers across Block to interact with Panther’s security analytics platform seamlessly. It shifts detection development from a coding-heavy process into an intuitive workflow accessible to everyone, regardless of technical background. Goose serves as an intermediary agent, coordinating calls to Panther MCP, reviewing the output, creating rule content, testing it, and making necessary edits for correctness or style. This AI-driven feedback loop saves countless hours of time. + +### Key Features + +Panther MCP offers dozens of tools that enhance and accelerate detection engineering workflows powered by Goose: + +1. **Natural Language to Detection Logic** + Engineers define detections using plain English prompts, which Panther MCP translates directly into Panther-compatible detection rules that can be checked into their [panther-analysis](https://github.com/panther-labs/panther-analysis) repository. +2. **Interactive Data Exploration and Usage** + Engineers can rapidly explore log sources and perform searches on data and previously generated alerts through quick, natural-language driven interactions. +3. **Unified Alert Triage and Response** + Enables AI-led alert triage with insights drawn from historical data and existing detections. + +## Accelerating Detection Creation with Goose + +Goose significantly accelerates security detection creation by using AI to automate traditionally manual tasks like log analysis and rule generation. This drastically reduces effort, improves the speed of developing and deploying threat coverage, and enhances agility against evolving threats. + +### Integrating Panther MCP as a Goose Extension + +Panther MCP functions as a Goose extension, seamlessly embedding its capabilities within the Goose environment through the following process: + +1. **Extension Registration:** Panther MCP is registered within Goose, making its suite of tools readily accessible via the Goose interface. +2. **API Connectivity:** The extension establishes a connection to Panther's backend API, enabling seamless context retrieval. +3. **Available Tools:** Panther MCP provides Goose with a range of tools designed for efficient detection creation, intuitive data interaction, and streamlined alert management. + +### Leveraging Enhanced Context with `.goosehints` + +The integration between Panther MCP and Goose is enhanced through the use of the [.goosehints](https://block.github.io/goose/docs/guides/using-goosehints/) file—a Goose feature that supplies additional context like rule examples and best practices. This enriched context enables Goose to generate more accurate and efficient detections, aligned with Block’s standards and requirements. + +Let's illustrate this with an example: creating a rule to detect users adding themselves to privileged Okta groups, a common privilege escalation technique. + +## Breaking Down the Barriers + +Traditionally, creating this detection would require: + +1. Deep knowledge of Okta and its log structure +2. Understanding of Panther’s detection framework +3. Python programming skills +4. Familiarity with different testing frameworks + +With Goose and Panther MCP, this becomes as simple as: + +> “Write a detection rule for users adding themselves to privileged Okta groups.” + +## The Intelligence Behind the Simplicity + +When a natural language request like "Write a detection rule for users adding themselves to privileged Okta groups" is received, Goose leverages a sophisticated, multi-stage process powered by Panther MCP to generate production-ready detection logic. This automated approach mirrors the workflow of an experienced detection engineer, encompassing threat research, relevant log identification, detection goal definition, logic outlining, sample log analysis, rule development, false positive consideration, severity/context assignment, thorough testing, refinement/optimization, and documentation. However, Goose executes these steps with the speed and scalability afforded by AI and automation. + +Goose first parses the natural language input to understand the core intent and requirements. It identifies key entities like "users", "privileged Okta groups", and the action "adding themselves". This understanding forms the basis for outlining the detection's objective, the necessary log source (`Okta.SystemLog`), and the fundamental logic: identifying events where the actor (user initiating the action) is the same as the target user (the user being added to the group), and the group being joined is designated as privileged. Goose also considers potential false positives (e.g., legitimate automated processes) and assigns a preliminary severity level based on the potential impact of the detected activity (privilege escalation). + +![Process overview diagram](process-overview-diagram.png) + +To ensure the generated logic is accurate and operates on valid data, Goose interacts with Panther MCP to retrieve the schema of the specified log source (`Okta.SystemLog`). This provides Goose with a structured understanding of the available fields and their data types within Okta logs. Furthermore, Goose utilizes Panther MCP's querying capabilities to fetch sample log events related to group membership changes. This step is crucial for: + +* **Identifying Common Event Patterns:** Analyzing real-world logs allows Goose to understand the typical structure and values associated with relevant events (e.g., `group.user_membership.add`). +* **Inferring Privileged Group Naming Conventions:** By examining historical data, Goose can identify patterns and keywords commonly used in the naming of privileged groups within the organization's Okta instance (e.g., "admin", "administrator", "security-admin"). +* **Discovering Edge Cases:** Examining diverse log samples helps uncover potential variations in event data or less common scenarios that the detection logic needs to accommodate. +* **Mapping Typical User Behavior:** Understanding baseline user behavior around group membership changes helps refine the detection logic and reduce the likelihood of false positives. + +The interaction with Panther MCP at this stage involves API calls to retrieve schema information and execute analytical queries, enabling Goose to ground its reasoning in actual log data. + +![Goose interacts with Panther MCP](goose-panther-mcp-interaction.png) + +Goose doesn't operate in isolation; it accesses a repository of existing Panther detection rules to identify similar logic or reusable components. This promotes consistency across the detection landscape, encourages the reuse of well-tested helper functions (like `okta_alert_context`), and ensures adherence to established rule standards within our security ecosystem. Learning from existing detections is a core component of Goose’s intelligence, allowing it to build upon prior knowledge and avoid reinventing the wheel. + +![Rule context reuse](context-reuse-example.png) + +Based on the understanding of the detection goal, the analysis of log data, and the knowledge gleaned from existing detections facilitated by Panther MCP, Goose generates the complete Panther detection rule in Python. This includes: + +* **Rule Function (`rule()`):** This function contains the core logic for evaluating each log event. In the example, it checks for the `group.user_membership.add` event type, verifies that the actor and target user IDs (or emails) are the same, and confirms that the target group's display name contains keywords indicative of a privileged group (defined in the `PRIVILEGED_GROUPS` set). +* **Metadata Functions (`title()`, `alert_context()`, `severity()`, `destinations()`):** These functions provide crucial context and operational information for triggered alerts. + +```python +from panther_okta_helpers import okta_alert_context + +# Define privileged Okta groups - customize this list based on your organization's needs +PRIVILEGED_GROUPS = { + "_group_admin", # Administrator roles + "admin", + "administrator", + "application-admin", + "aws_", # AWS roles can be privileged + "cicd_corp_system", # CI/CD admin access + "grc-okta", + "okta-administrators", + "okta_admin", + "okta_admin_svc_accounts", # Admin roles + "okta_resource-set_", # Resource sets are typically privileged + "security-admin", + "superadministrators", +} + +def rule(event): + """Determine if a user added themselves to a privileged group""" + # Only focus on group membership addition events + if event.get("eventType") != "group.user_membership.add": + return False + # Ensure both actor and target exist in the event + actor = event.get("actor", {}) + targets = event.get("target", []) + if not actor or len(targets) < 2: + return False + actor_id = actor.get("alternateId", "").lower() + actor_user_id = actor.get("id") + # Extract target user and group + target_user = targets[0] + target_group = targets[1] if len(targets) > 1 else {} + # The first target should be a user and the second should be a group + if target_user.get("type") != "User" or target_group.get("type") != "UserGroup": + return False + target_user_id = target_user.get("id") + target_user_email = target_user.get("alternateId", "").lower() + group_name = target_group.get("displayName", "").lower() + # Check if the actor added themselves to the group + is_self_add = (actor_user_id == target_user_id) or (actor_id == target_user_email) + # Check if the group is privileged + is_privileged_group = any(priv_group in group_name for priv_group in PRIVILEGED_GROUPS) + return is_self_add and is_privileged_group + +def title(event): + """Generate a descriptive title for the alert""" + actor = event.get("actor", {}) + targets = event.get("target", []) + actor_name = actor.get("displayName", "Unknown User") + actor_email = actor.get("alternateId", "unknown@example.com") + target_group = targets[1] if len(targets) > 1 else {} + group_name = target_group.get("displayName", "Unknown Group") + return (f"User [{actor_name} ({actor_email})] added themselves " + f"to privileged Okta group [{group_name}]") + +def alert_context(event): + """Return additional context for the alert""" + context = okta_alert_context(event) + # Add specific information about the privileged group + targets = event.get("target", []) + if len(targets) > 1: + target_group = targets[1] + context["privileged_group"] = { + "id": target_group.get("id", ""), + "name": target_group.get("displayName", ""), + } + return context + +def severity(event): + """Calculate severity based on group name - more sensitive groups get higher severity""" + targets = event.get("target", []) + if len(targets) <= 1: + return "Medium" + target_group = targets[1] + group_name = target_group.get("displayName", "").lower() + # Higher severity for direct admin groups + if any(name in group_name for name in ["admin", "administrator", "superadministrators"]): + return "Critical" + return "High" + +def destinations(_event): + """Send to staging destination for review""" + return ["staging_destination"] +``` + +Beyond the Python code, Goose also generates the corresponding YAML-based rule configuration file. This file contains essential metadata about the detection: + +```yaml +AnalysisType: rule +Description: Detects when a user adds themselves to a privileged Okta group, which could indicate privilege escalation attempts or unauthorized access. +DisplayName: "Users Adding Themselves to Privileged Okta Groups" +Enabled: true +DedupPeriodMinutes: 60 +LogTypes: + - Okta.SystemLog +RuleID: "goose.Okta.Self.Privileged.Group.Add" +Threshold: 1 +Filename: goose_okta_self_privileged_group_add.py +Reference: > + https://developer.okta.com/docs/reference/api/system-log/ + https://attack.mitre.org/techniques/T1078/004/ + https://attack.mitre.org/techniques/T1484/001/ +Runbook: > + 1. Verify if the user should have access to the privileged group they added themselves to + 2. If unauthorized, revoke the group membership immediately + 3. Check for other group membership changes made by the same user + 4. Review the authentication context and security context for suspicious indicators + 5. Interview the user to determine intent +Reports: + MITRE ATT&CK: + - TA0004:T1078.004 # Privileged Accounts: Cloud Accounts + - TA0004:T1484.001 # Domain Policy Modification: Group Policy Modification +Severity: High +Tags: + - author:tomasz + - coauthor:goose +Tests: + - Name: User adds themselves to privileged group + ExpectedResult: true + Log: + actor: + alternateId: jane.doe@company.com + displayName: Jane Doe + id: 00u1234abcd5678 + type: User + authenticationContext: + authenticationStep: 0 + externalSessionId: xyz1234 + client: + device: Computer + geographicalContext: + city: San Francisco + country: United States + geolocation: + lat: 37.7749 + lon: -122.4194 + postalCode: "94105" + state: California + ipAddress: 192.168.1.100 + userAgent: + browser: CHROME + os: Mac OS X + rawUserAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 + zone: "null" + debugContext: + debugData: + requestId: req123456 + requestUri: /api/v1/groups/00g123456/users/00u1234abcd5678 + url: /api/v1/groups/00g123456/users/00u1234abcd5678 + displayMessage: Add user to group membership + eventType: group.user_membership.add + legacyEventType: group.user_membership.add + outcome: + result: SUCCESS + published: "2023-07-15 14:25:30.811" + request: + ipChain: + - geographicalContext: + city: San Francisco + country: United States + geolocation: + lat: 37.7749 + lon: -122.4194 + postalCode: "94105" + state: California + ip: 192.168.1.100 + version: V4 + securityContext: + asNumber: 12345 + asOrg: Example ISP + domain: example.com + isProxy: false + isp: Example ISP + severity: INFO + target: + - alternateId: jane.doe@company.com + displayName: Jane Doe + id: 00u1234abcd5678 + type: User + - alternateId: unknown + displayName: okta_admin_person_role_super_admin + id: 00g5678abcd1234 + type: UserGroup + transaction: + detail: {} + id: transaction123 + type: WEB + uuid: event-uuid-123 + version: "0" + p_event_time: "2023-07-15 14:25:30.811" + p_parse_time: "2023-07-15 14:26:00.000" + p_log_type: "Okta.SystemLog" + - Name: User adds another user to privileged group + ExpectedResult: false + Log: + actor: + alternateId: admin@company.com + displayName: Admin User + id: 00u5678abcd1234 + type: User + authenticationContext: + authenticationStep: 0 + externalSessionId: xyz5678 + client: + device: Computer + geographicalContext: + city: San Francisco + country: United States + geolocation: + lat: 37.7749 + lon: -122.4194 + postalCode: "94105" + state: California + ipAddress: 192.168.1.100 + userAgent: + browser: CHROME + os: Mac OS X + rawUserAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 + zone: "null" + debugContext: + debugData: + requestId: req789012 + requestUri: /api/v1/groups/00g123456/users/00u9876fedc4321 + url: /api/v1/groups/00g123456/users/00u9876fedc4321 + displayMessage: Add user to group membership + eventType: group.user_membership.add + legacyEventType: group.user_membership.add + outcome: + result: SUCCESS + published: "2023-07-15 14:30:45.123" + request: + ipChain: + - geographicalContext: + city: San Francisco + country: United States + geolocation: + lat: 37.7749 + lon: -122.4194 + postalCode: "94105" + state: California + ip: 192.168.1.100 + version: V4 + securityContext: + asNumber: 12345 + asOrg: Example ISP + domain: example.com + isProxy: false + isp: Example ISP + severity: INFO + target: + - alternateId: user@company.com + displayName: Regular User + id: 00u9876fedc4321 + type: User + - alternateId: unknown + displayName: okta_admin_person_role_super_admin + id: 00g5678abcd1234 + type: UserGroup + transaction: + detail: {} + id: transaction456 + type: WEB + uuid: event-uuid-456 + version: "0" + p_event_time: "2023-07-15 14:30:45.123" + p_parse_time: "2023-07-15 14:31:00.000" + p_log_type: "Okta.SystemLog" +``` + +Every detection rule generated by Goose undergoes rigorous automated testing and validation. This includes: + +* **Unit Testing:** Using the test cases defined in the rule configuration, the Panther Analysis Tool is executed to verify that the rule logic correctly identifies true positives and avoids false negatives against simulated log data. +* **Linting:** Code linting tools (like Pylint) are automatically run to ensure the generated Python code adheres to established coding standards, including proper formatting, style conventions, and best practices. This contributes to code maintainability and reduces the risk of errors. + +![Automated testing graphic](automated-testing-graphic.png) +![Process improvement chart](process-improvement-chart.png) + +The seamless integration of Goose with Panther MCP automates these intricate steps, significantly reducing the time and specialized knowledge required to create and deploy security detections. This democratization empowers more individuals to contribute to Block's security posture, leading to more comprehensive threat coverage and a more resilient security environment. + +## Democratization in Practice + +A typical detection creation workflow now looks like: + +1. **Proposal:** A user describes a malicious behavior in natural language. +2. **Generation:** Goose transforms this description into detection logic with Panther MCP. +3. **Review:** The detection team reviews each detection against defined quality benchmarks. +4. **Deployment:** Approved detections are deployed to staging/production. + +## Early Impact & Lessons Learned + +### Expanding Collaboration to Enhance Coverage and Enable Self-Service + +* **Lowering the Technical Barrier:** Goose and Panther MCP empower subject matter experts (SMEs) to easily understand their logs in Panther, enabling a self-service model where teams can create their own detections without extensive security engineering expertise, thus distributing the workload. +* **Reduced Dependency on the Detection Team:** Panther MCP reduces security team dependency by enabling users to independently resolve inquiries autonomously. This includes threat intelligence teams assessing MITRE ATT&CK coverage, compliance teams identifying relevant detections, and helping service SMEs create their own detections. +* **Cross-Functional Detection Development:** Democratizing detection engineering allows specialized teams to create detections that security teams might miss, leading to a more diverse detection ecosystem covering niche use cases. This fosters two-way knowledge transfer, enhancing overall security awareness and capabilities. + +### Accelerating the Detection Development Lifecycle + +* **Contextual Understanding:** Detection engineering is becoming more efficient and consistent through tools that embed organizational context, provide guided best practices, understand existing log schemas and detections, and align with validation frameworks such as *pytest*. This approach enables broader participation and supports high-quality development across teams. +* **Streamlined Development Process:** Natural language interfaces are simplifying detection engineering by allowing users to interact with the system conversationally. This enables automated retrieval of example logs, analysis of log schemas, interpretation of detection goals or required changes, and generation of initial detection code—significantly accelerating development. +* **Automated Technical Steps:** Intelligent code generation incorporates error handling and best practices, while seamlessly generating test cases from data and producing comprehensive documentation—including descriptions, runbooks, and references. + +### Driving Consistency via Standardized Practices + +* **Code Style and Structure:** Newly created detections adhere to consistent stylistic patterns, utilizing dedicated functions for specific checks instead of overloaded `rule()` checks. Standardized formatting, including brackets for dynamic alert title text, enhances readability and consistency. +* **Code Reuse and Efficiency:** Promote code reuse and efficiency through global helpers/filters, explicit typing in function signatures, and detailed docstrings for better function understanding and LLM code generation. +* **Maintainability Improvements:** Detections are designed with a consistent structure and standardized patterns, making them easier to understand, maintain, and update. This uniformity ensures predictable behavior across the detection code base and simplifies bulk changes when needed. +* **Comprehensive Testing Requirements:** For our team, each detection is required to include at least two unit tests: one positive case that triggers the detection and one negative case that does not. Test names are descriptive and aligned with expected outcomes to enhance readability and maintainability. +* **Metadata and Documentation Standards:** Metadata and documentation standards are being strengthened through structured definitions within pytests, helping to codify detection ownership and context. This includes clearly defined author and coauthor tags (e.g., for Goose-generated content), environment references such as staging or production, and accurate mapping of alert destinations. +* **Structural Validation:** This supports compliance with organizational standards by enforcing filename conventions (e.g., prefixing, length, lowercase formatting), ensuring Python rules include all required functions, and verifying that YAML files contain the necessary fields for proper functionality and processing. +* **Security Framework Alignment:** Relevant rules are mapped to applicable MITRE ATT&CK techniques to highlight coverage gaps, inform detection development, prioritize research efforts, and establish a common language for discussing threats. + +### Best Practices and Safeguards + +* **Platform-Conformant Development:** Detections are developed in alignment with Panther’s recommended practices, such as using built-in event object methods like `event.deep_get()` and `event.deep_walk()` instead of importing them manually, ensuring consistency and maintainability within the platform. +* **Proactive Error Prevention:** We implement local validation checks through pre-commit and pre-push hooks to proactively catch and resolve errors before they reach upstream builds. These checks include validating alert destination names, verifying log types, and flagging grammatical issues to ensure quality and consistency. +* **Continuous Improvement:** Detection quality continuously improves by incorporating feedback, performance data, and analysis of detection trends. Panther MCP, along with other ticket tracking MCPs, provides insights from analyst feedback and alert dispositions, which facilitates automated adjustments, streamlines pull request development, and lowers operational overhead. + +## What’s Next? + +Block is dedicated to improving its security defenses and supporting its team by leveraging AI. We believe AI holds significant promise for the future of detection and response at Block and are committed to making security more accessible. + + + + + + + + + + + + + + diff --git a/documentation/blog/2025-06-02-goose-panther-mcp/process-improvement-chart.png b/documentation/blog/2025-06-02-goose-panther-mcp/process-improvement-chart.png new file mode 100644 index 00000000..b594a061 Binary files /dev/null and b/documentation/blog/2025-06-02-goose-panther-mcp/process-improvement-chart.png differ diff --git a/documentation/blog/2025-06-02-goose-panther-mcp/process-overview-diagram.png b/documentation/blog/2025-06-02-goose-panther-mcp/process-overview-diagram.png new file mode 100644 index 00000000..1ebdcb06 Binary files /dev/null and b/documentation/blog/2025-06-02-goose-panther-mcp/process-overview-diagram.png differ diff --git a/documentation/blog/README.md b/documentation/blog/README.md new file mode 100644 index 00000000..e45db056 --- /dev/null +++ b/documentation/blog/README.md @@ -0,0 +1,211 @@ +--- +unlisted: true +--- +# Writing Blog Posts for Goose + +This guide explains how to write and structure blog posts for the Goose documentation site. + +## Getting Started + +1. Clone the Goose repository: +```bash +git clone https://github.com/block/goose.git +cd goose +``` + +2. Install dependencies: +```bash +cd documentation +npm install +``` + +## Directory Structure + +Blog posts are organized by date using the following format: +``` +YYYY-MM-DD-post-title/ +├── index.md +└── images/ +``` + +Example: +``` +2025-05-22-llm-agent-readiness/ +├── index.md +└── llm-agent-test.png +``` + +## Frontmatter + +Each blog post must begin with YAML frontmatter that includes: + +```yaml +--- +title: Your Blog Post Title +description: A brief description of your post (1-2 sentences) +authors: + - your_author_id +--- +``` + +The `authors` field should match your ID in the `authors.yml` file. Multiple authors can be listed. [More info on authors](#author-information). + +## Header Image + +After the frontmatter, include a header image using Markdown: + +```markdown +![blog cover](your-image.png) +``` + +The header image should be: +- Relevant to the post content +- High quality (recommended dimensions: 1200 x 600 px) +- Stored in the post's directory +- Named descriptively + +## Content Structure + +### Introduction +Start with 1-2 paragraphs introducing the topic before the truncate tag. This will be what's shown on the blog index page. + +### Truncate Tag +Add the truncate tag after your introduction to create a "read more" break: + +```markdown + +``` + +### Headers +Use headers to organize your content hierarchically: +- `#` (H1) - Used only for the post title in frontmatter +- `##` (H2) - Main sections +- `###` (H3) - Subsections +- `####` (H4) - Minor sections (these will not show on the right nav bar) + +### Code Blocks +Use fenced code blocks with language specification: + +````markdown +```javascript +// Your code here +``` +```` + +### Images +Include additional images using Markdown: +```markdown +![descriptive alt text](image-name.png) +``` + +## Social Media Tags + +At the end of your post, include the following meta tags for social media sharing: + +```html + + + + + + + + + + + + +``` + +## Author Information + +To add yourself as an author: + +1. Edit `authors.yml` in the blog directory +2. Add your information following this format: + +```yaml +your_author_id: + name: Your Full Name + title: Your Title + image_url: https://avatars.githubusercontent.com/u/your_github_id?v=4 + url: https://your-website.com # Optional + page: true + socials: + linkedin: your_linkedin_username + github: your_github_username + x: your_twitter_handle + bluesky: your_bluesky_handle # Optional +``` + +## Best Practices + +1. **Writing Style** + - Use clear, concise language + - Break up long paragraphs + - Include code examples where relevant + - Use images to illustrate complex concepts + +2. **Technical Content** + - Include working code examples + - Explain prerequisites + - Link to relevant documentation + - Test code snippets before publishing + +3. **Formatting** + - Use consistent spacing + - Include alt text for images + - Break up content with subheadings + - Use lists and tables when appropriate + +4. **Review Process** + - Proofread for typos and grammar + - Verify all links work + - Check image paths + - Test code samples + - Validate frontmatter syntax + +## Previewing Your Blog Post + +To preview your blog post locally: + +1. Ensure you're in the documentation directory: +```bash +cd documentation +``` + +2. Start the development server: +```bash +npm start +``` + +3. Open your browser and visit: +``` +http://localhost:3000/goose/blog +``` + +The development server features: +- Hot reloading (changes appear immediately) +- Preview of the full site navigation +- Mobile responsive testing +- Social media preview testing + +If you make changes to your blog post while the server is running, the page will automatically refresh to show your updates. + +### Troubleshooting Preview + +If you encounter issues: + +1. Make sure all dependencies are installed: +```bash +npm install +``` + +2. Clear the cache and restart: +```bash +npm run clear +npm start +``` + +3. Verify your frontmatter syntax is correct (no tabs, proper indentation) +4. Check that all image paths are correct relative to your post's directory \ No newline at end of file diff --git a/documentation/blog/authors.yml b/documentation/blog/authors.yml index cb3561fb..b4526d0b 100644 --- a/documentation/blog/authors.yml +++ b/documentation/blog/authors.yml @@ -97,3 +97,23 @@ ian: github: iandouglas bluesky: iandouglas736.com x: iandouglas736 + +tomasz: + name: Tomasz Tchorz + title: Security Engineer + image_url: https://avatars.githubusercontent.com/u/75388736?v=4 + page: true + socials: + linkedin: tomasztchorz + github: tomala90 + +glenn: + name: Glenn Edwards + title: Detection Engineer + image_url: https://avatars.githubusercontent.com/u/1418309?v=4 + page: true + socials: + linkedin: glennpedwardsjr + github: hiddenillusion + x: hiddenillusion + bluesky: hiddenillusion diff --git a/documentation/docs/getting-started/installation.md b/documentation/docs/getting-started/installation.md index a4c5321f..25970aaa 100644 --- a/documentation/docs/getting-started/installation.md +++ b/documentation/docs/getting-started/installation.md @@ -96,7 +96,11 @@ import DesktopInstallButtons from '@site/src/components/DesktopInstallButtons'; wsl --install ``` - 2. Restart your computer if prompted. + 2. If prompted, restart your computer to complete the WSL installation. Once restarted, or if WSL is already installed, launch your Ubuntu shell by running: + + ```bash + wsl -d Ubuntu + ``` 3. Run the Goose installation script: ```bash @@ -165,6 +169,12 @@ Goose works with a set of [supported LLM providers][providers], and you'll need export OPENAI_API_KEY={your_api_key} ``` + Run `goose configure` again and proceed through the prompts. When you reach the step for entering the API key, Goose will detect that the key is already set as an environment variable and display a message like: + + ``` + ● OPENAI_API_KEY is set via environment variable + ``` + To make the changes persist in WSL across sessions, add the goose path and export commands to your `.bashrc` or `.bash_profile` file so you can load it later. ```bash diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 3b133eb9..717201fa 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -92,7 +92,7 @@ To configure your chosen provider or see available options, run `goose configure │ ○ OpenRouter └ ``` - 4. Enter your API key (and any other configuration details) when prompted + 4. Enter your API key (and any other configuration details) when prompted. ``` ┌ goose-configure @@ -106,6 +106,23 @@ To configure your chosen provider or see available options, run `goose configure ◆ Provider Anthropic requires ANTHROPIC_API_KEY, please enter a value │ └ + ``` + 5. Enter your desired `ANTHROPIC_HOST` or you can use the default one by hitting the `Enter` key. + + ``` + ◇ Enter new value for ANTHROPIC_HOST + │ https://api.anthropic.com (default) + ``` + 6. Enter the model you want to use or you can use the default one by hitting the `Enter` key. + ``` + │ + ◇ Model fetch complete + │ + ◇ Enter a model from that provider: + │ claude-3-5-sonnet-latest (default) + │ + ◓ Checking your configuration... + └ Configuration saved successfully ``` diff --git a/documentation/docs/guides/config-file.md b/documentation/docs/guides/config-file.md index 3eb4d788..f5f28bbd 100644 --- a/documentation/docs/guides/config-file.md +++ b/documentation/docs/guides/config-file.md @@ -29,6 +29,7 @@ The following settings can be configured at the root level of your config.yaml f | `GOOSE_TOOLSHIM_OLLAMA_MODEL` | Model for tool interpretation | Model name (e.g., "llama3.2") | System default | No | | `GOOSE_CLI_MIN_PRIORITY` | Tool output verbosity | Float between 0.0 and 1.0 | 0.0 | No | | `GOOSE_ALLOWLIST` | URL for allowed extensions | Valid URL | None | No | +| `GOOSE_RECIPE_GITHUB_REPO` | GitHub repository for recipes | Format: "org/repo" | None | No | ## Example Configuration @@ -49,6 +50,9 @@ GOOSE_MODE: "smart_approve" GOOSE_TOOLSHIM: true GOOSE_CLI_MIN_PRIORITY: 0.2 +# Recipe Configuration +GOOSE_RECIPE_GITHUB_REPO: "block/goose-recipes" + # Extensions Configuration extensions: developer: diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 7888b813..487661a2 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -9,6 +9,7 @@ Goose supports various environment variables that allow you to customize its beh ## Model Configuration These variables control the [language models](/docs/getting-started/providers) and their behavior. + ### Basic Provider Configuration These are the minimum required variables to get started with Goose. @@ -27,6 +28,7 @@ export GOOSE_PROVIDER="anthropic" export GOOSE_MODEL="claude-3.5-sonnet" export GOOSE_TEMPERATURE=0.7 ``` + ### Advanced Provider Configuration These variables are needed when using custom endpoints, enterprise deployments, or specific provider implementations. @@ -45,7 +47,34 @@ export GOOSE_PROVIDER__TYPE="anthropic" export GOOSE_PROVIDER__HOST="https://api.anthropic.com" export GOOSE_PROVIDER__API_KEY="your-api-key-here" ``` -## Planning Mode Configuration + +### Lead/Worker Model Configuration + +Configure a lead/worker model pattern where a powerful model handles initial planning and complex reasoning, then switches to a faster/cheaper model for execution. + +| Variable | Purpose | Values | Default | +|----------|---------|---------|---------| +| `GOOSE_LEAD_MODEL` | **Required to enable lead mode.** Specifies the lead model name | Model name (e.g., "gpt-4o", "claude-3.5-sonnet") | None | +| `GOOSE_LEAD_PROVIDER` | Provider for the lead model | [See available providers](/docs/getting-started/providers#available-providers) | Falls back to GOOSE_PROVIDER | +| `GOOSE_LEAD_TURNS` | Number of initial turns using the lead model | Integer | 3 | +| `GOOSE_LEAD_FAILURE_THRESHOLD` | Consecutive failures before fallback to lead model | Integer | 2 | +| `GOOSE_LEAD_FALLBACK_TURNS` | Number of turns to use lead model in fallback mode | Integer | 2 | + +**Examples** + +```bash +# Basic lead/worker setup +export GOOSE_LEAD_MODEL="o4" + +# Advanced lead/worker configuration +export GOOSE_LEAD_MODEL="claude4-opus" +export GOOSE_LEAD_PROVIDER="anthropic" +export GOOSE_LEAD_TURNS=5 +export GOOSE_LEAD_FAILURE_THRESHOLD=3 +export GOOSE_LEAD_FALLBACK_TURNS=2 +``` + +### Planning Mode Configuration These variables control Goose's [planning functionality](/docs/guides/creating-plans). @@ -62,6 +91,24 @@ export GOOSE_PLANNER_PROVIDER="openai" export GOOSE_PLANNER_MODEL="gpt-4" ``` +## Session Management + +These variables control how Goose manages conversation sessions and context. + +| Variable | Purpose | Values | Default | +|----------|---------|---------|---------| +| `GOOSE_CONTEXT_STRATEGY` | Controls how Goose handles context limit exceeded situations | "summarize", "truncate", "clear", "prompt" | "prompt" (interactive), "summarize" (headless) | + +**Examples** + +```bash +# Automatically summarize when context limit is reached +export GOOSE_CONTEXT_STRATEGY=summarize + +# Always prompt user to choose (default for interactive mode) +export GOOSE_CONTEXT_STRATEGY=prompt +``` + ## Tool Configuration These variables control how Goose handles [tool permissions](/docs/guides/tool-permissions) and their execution. diff --git a/documentation/docs/guides/goose-cli-commands.md b/documentation/docs/guides/goose-cli-commands.md index 96abdb5d..44c653ba 100644 --- a/documentation/docs/guides/goose-cli-commands.md +++ b/documentation/docs/guides/goose-cli-commands.md @@ -303,26 +303,29 @@ goose bench ...etc. ``` ### recipe -Used to validate a recipe file and get a link to share the recipe (aka "shared agent") with another Goose user. +Used to validate recipe files and manage recipe sharing. +**Usage:** ```bash goose recipe ``` +**Commands:** +- `validate `: Validate a recipe file +- `deeplink `: Generate a shareable link for a recipe file + **Options:** +- `--help, -h`: Print help information -- **`--help, -h`**: Print this message or the help for the subcommand - -**Usage:** - +**Examples:** ```bash # Validate a recipe file -goose recipe validate $FILE.yaml +goose recipe validate my-recipe.yaml -# Generate a deeplink for a recipe file -goose recipe deeplink $FILE.yaml +# Generate a shareable link +goose recipe deeplink my-recipe.yaml -# Print this message or the help for the given command +# Get help about recipe commands goose recipe help ``` diff --git a/documentation/docs/guides/managing-goose-sessions.md b/documentation/docs/guides/managing-goose-sessions.md index 02e88e4c..2e660507 100644 --- a/documentation/docs/guides/managing-goose-sessions.md +++ b/documentation/docs/guides/managing-goose-sessions.md @@ -261,4 +261,32 @@ Search allows you to find specific content within your current session. The sear Your specific terminal emulator may use a different keyboard shortcut. Check your terminal's documentation or settings for the search command. ::: + + +## Share Files in Session + + + + Share files with Goose in three ways: + + 1. **Drag and Drop**: Simply drag files from your computer's file explorer/finder and drop them anywhere in the chat window. The file paths will be automatically added to your message. + + 2. **File Browser**: Click the paperclip icon (📎) in the bottom left corner of the chat input to open your system's file browser and select files. + + 3. **Manual Path**: Type or paste the file path directly into the chat input. + + + You can reference files by their paths directly in your messages. Since you're already in a terminal, you can use standard shell commands to help with file paths: + + ```bash + # Reference a specific file + What does this code do? ./src/main.rs + + # Use tab completion + Can you explain the function in ./src/lib + + # Use shell expansion + Review these test files: ./tests/*.rs + ``` + \ No newline at end of file diff --git a/documentation/docs/guides/session-recipes.md b/documentation/docs/guides/session-recipes.md index ed6479a6..97533622 100644 --- a/documentation/docs/guides/session-recipes.md +++ b/documentation/docs/guides/session-recipes.md @@ -14,16 +14,11 @@ You can turn your current Goose session into a reusable recipe that includes the ## Create Recipe -:::tip Heads Up -You'll need to provide both instructions and activities for your Recipe. - -- **Instructions** provide the purpose. These get sent directly to the model and define how it behaves. Think of this as its internal mission statement. Make it clear, action-oriented, and scoped to the task at hand. - -- **Activities** are specific, example prompts that appear as clickable bubbles on a fresh session. They help others understand how to use the Recipe. -::: - + :::warning + You cannot create a recipe from an existing recipe session - the "Make Agent from this session" option will be disabled. + ::: 1. While in the session you want to save as a recipe, click the menu icon **⋮** in the top right corner 2. Select **Make Agent from this session** @@ -32,74 +27,64 @@ You'll need to provide both instructions and activities for your Recipe. - Provide a description - Some **activities** will be automatically generated. Add or remove as needed. - A set of **instructions** will also be automatically generated. Review and edit as needed. - 4. Copy the Recipe URL and use it however you like (e.g., share it with teammates, drop it in documentation, or keep it for yourself) + 4. Copy the recipe URL and use it however you like (e.g., share it with teammates, drop it in documentation, or keep it for yourself) + :::warning + You cannot create a recipe from an existing recipe session - the `/recipe` command will not work. + ::: - While in a session, run the following command: + ### Create a Recipe File + + Recipe files can be either JSON (.json) or YAML (.yaml) files. While in a [session](/docs/guides/managing-goose-sessions#start-session), run this command to generate a recipe.yaml file in your current directory: ```sh /recipe ``` - This will generate a `recipe.yaml` file in your current directory. - - Alternatively, you can provide a custom filename: + If you want to specify a different name, you can provide it as an argument: ```sh /recipe my-custom-recipe.yaml ```
- recipe.yaml - + recipe file structure + ```yaml # Required fields version: 1.0.0 title: $title description: $description - instructions: $instructions # instructions to be added to the system prompt + instructions: $instructions # Define the model's behavior # Optional fields - prompt: $prompt # if set, the initial prompt for the run/session - extensions: + prompt: $prompt # Initial message to start with + extensions: # Tools the recipe needs - $extensions - context: - - $context - activities: # example prompts to display in the Desktop app + activities: # Example prompts to display in the Desktop app - $activities - author: - contact: $contact - metadata: $metadata - parameters: # required if recipe uses {{ variables }} - - key: $param_key - input_type: $type # string, number, etc - requirement: $req # required, optional, or user_prompt - description: $description - default: $value # required for optional parameters ``` -
- You can then edit the recipe file to include the following key information: + ### Edit Recipe File - - `instructions`: Add or modify the system instructions - - `prompt`: Add the initial message or question to start a Goose session with - - `activities`: List the activities that can be performed, which are displayed as prompts in the Desktop app + Once the recipe file is created, you can open it and modify the value of any field. + ### Optional Parameters - #### Recipe Parameters - You may add parameters to a recipe, which will require users to fill in data when running the recipe. Parameters can be added to any part of the recipe (instructions, prompt, activities, etc). - To use parameters, edit your recipe file to include template variables using `{{ variable_name }}` syntax and define each of them in your yaml using `parameters`. + To use parameters: + 1. Add template variables using `{{ variable_name }}` syntax in your recipe content + 2. Define each parameter in the `parameters` section of your YAML file
Example recipe with parameters - - ```yaml title="code-review.yaml" + + ```yaml version: 1.0.0 title: "{{ project_name }} Code Review" # Wrap the value in quotes if it starts with template syntax to avoid YAML parsing errors description: Automated code review for {{ project_name }} with {{ language }} focus @@ -138,32 +123,25 @@ You'll need to provide both instructions and activities for your Recipe. requirement: user_prompt # If style_guide param value is not specified in the command, user will be prompted to provide a value, even in non-interactive mode ``` -
- When someone runs a recipe that contains template parameters, they will need to provide the parameters: + ### Validate Recipe - ```sh - goose run --recipe code-review.yaml \ - --params project_name=MyApp \ - --params language=Python \ - --params complexity_threshold=15 \ - --params test_coverage=80 \ - --params style_guide=PEP8 - ``` - - #### Validate the recipe - - [Exit the session](/docs/guides/managing-goose-sessions/#exit-session) and run: + [Exit the session](/docs/guides/managing-goose-sessions#exit-session) and run: ```sh goose recipe validate recipe.yaml ``` - #### Share the recipe +Validation ensures that: + - All required fields are present + - Parameters are properly formatted + - Referenced extensions exist and are valid + - The YAML/JSON syntax is correct - - To share with **CLI users**, send them the recipe yaml file - - To share with **Desktop users**, run the following command to create a deep link: + ### Share Your Recipe + + Now that your recipe is created, you can share it with CLI users by directly sending them the recipe file or converting it to a shareable deep link for Desktop users: ```sh goose recipe deeplink recipe.yaml @@ -178,76 +156,204 @@ You'll need to provide both instructions and activities for your Recipe. - To use a shared recipe, simply click the recipe link, or paste in a browser address bar. This will open Goose Desktop and start a new session with: + There are two ways to use a recipe in Goose Desktop: - - The recipe's defined instructions - - Suggested activities as clickable bubbles - - The same extensions and project context (if applicable) - - Each person using the recipe gets their own private session, so no data is shared between users, and nothing links back to your original session. + 1. **Direct Link** + - Click a recipe link shared with you + - The recipe will automatically open in Goose Desktop + 2. **Manual URL Entry** + - Copy a recipe URL + - Paste it into your browser's address bar + - You will see a prompt to "Open Goose" + - Goose Desktop will open with the recipe - You can start a session with a recipe file in the following ways: + ### Configure Recipe Location - - Run the recipe once and exit: + Recipes can be stored locally on your device or in a GitHub repository. Configure your recipe repository using either the `goose configure` command or [config file](/docs/guides/config-file#global-settings). - ```sh - goose run --recipe recipe.yaml - ``` + :::tip Repository Structure + - Each recipe should be in its own directory + - Directory name matches the recipe name you use in commands + - Recipe file can be either recipe.yaml or recipe.json + ::: - - Run the recipe and enter interactive mode: + + - ```sh - goose run --recipe recipe.yaml --interactive - ``` + Run the configure command: + ```sh + goose configure + ``` - - Run the recipe with parameters: + You'll see the following prompts: - ```sh - goose run --recipe recipe.yaml --interactive --params language=Spanish --params style=formal --params name=Alice - ``` + ```sh + ┌ goose-configure + │ + ◆ What would you like to configure? + │ ○ Configure Providers + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + // highlight-start + │ ● Goose Settings (Set the Goose Mode, Tool Output, Tool Permissions, Experiment, Goose recipe github repo and more) + // highlight-end + │ + ◇ What would you like to configure? + │ Goose Settings + │ + ◆ What setting would you like to configure? + │ ○ Goose Mode + │ ○ Tool Permission + │ ○ Tool Output + │ ○ Toggle Experiment + // highlight-start + │ ● Goose recipe github repo (Goose will pull recipes from this repo if not found locally.) + // highlight-end + └ + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Goose Settings + │ + ◇ What setting would you like to configure? + │ Goose recipe github repo + │ + ◆ Enter your Goose Recipe GitHub repo (owner/repo): eg: my_org/goose-recipes + // highlight-start + │ squareup/goose-recipes (default) + // highlight-end + └ + ``` - - Explain the recipe with description and parameters + - ```sh - goose run --recipe recipe.yaml --explain - ``` + - #### Discover recipes - When using recipe-related CLI commands, there are a few ways to specify which recipe to use: - ##### Option 1: Provide the full file path - Use the exact path to the recipe file: - - ```sh - goose run --recipe ~/my_recipe.yaml - goose recipe validate ~/my_recipe.yaml - goose recipe deeplink ~/my_recipe.yaml - ``` - ##### Option 2: Use the recipe name - If your recipe is named my_recipe, you can simply use the name: + Add to your config file: + ```yaml title="~/.config/goose/config.yaml" + GOOSE_RECIPE_GITHUB_REPO: "owner/repo" + ``` - ```sh - goose run --recipe my_recipe - goose recipe validate my_recipe - goose recipe deeplink my_recipe - ``` - When you use the recipe name, Goose will search for the file in the following order: - 1. Local search: - Goose will search for `my_recipe.yaml` or `my_recipe.json` in the current working directory - - 2. Remote search (GitHub): - - If the `GOOSE_RECIPE_GITHUB_REPO` environment variable is set or configured in the `Goose Settings` via `goose configure`, Goose will search the specified GitHub repo. (eg: my_org/goose-recipes). - - Goose will look for `my_recipe/recipe.yaml` or `my_recipe/recipe.json` within that GitHub repository. - + + + + ### Run a Recipe + + + + + **Basic Usage** - Run once and exit (see [run options](/docs/guides/goose-cli-commands#run-options) and [recipe commands](/docs/guides/goose-cli-commands#recipe) for more): + ```sh + # Using recipe file in current directory + goose run --recipe recipe.yaml + + # Using full path + goose run --recipe ./recipes/my-recipe.yaml + ``` + + **Preview Recipe** - Use the [`explain`](/docs/guides/goose-cli-commands#run-options) command to view details before running: + + **Interactive Mode** - Start an interactive session: + ```sh + goose run --recipe recipe.yaml --interactive + ``` + The interactive mode will prompt for required values: + ```sh + ◆ Enter value for required parameter 'language': + │ Python + │ + ◆ Enter value for required parameter 'style_guide': + │ PEP8 + ``` + + **With Parameters** - Supply parameter values when running recipes. See the [`run` command documentation](/docs/guides/goose-cli-commands#run-options) for detailed examples and options. + + Basic example: + ```sh + goose run --recipe recipe.yaml --params language=Python + ``` + + + + + + Once you've configured your GitHub repository, you can run recipes by name: + + **Basic Usage** - Run recipes from your configured repo using the recipe name that matches its directory (see [run options](/docs/guides/goose-cli-commands#run-options) and [recipe commands](/docs/guides/goose-cli-commands#recipe) for more): + + ```sh + goose run --recipe recipe-name + ``` + + For example, if your repository structure is: + ``` + my-repo/ + ├── code-review/ + │ └── recipe.yaml + └── setup-project/ + └── recipe.yaml + ``` + + You would run the following command to run the code review recipe: + ```sh + goose run --recipe code-review + ``` + + **Preview Recipe** - Use the [`explain`](/docs/guides/goose-cli-commands#run-options) command to view details before running: + + **Interactive Mode** - With parameter prompts: + ```sh + goose run --recipe code-review --interactive + ``` + The interactive mode will prompt for required values: + ```sh + ◆ Enter value for required parameter 'project_name': + │ MyProject + │ + ◆ Enter value for required parameter 'language': + │ Python + ``` + + **With Parameters** - Supply parameter values when running recipes. See the [`run` command documentation](/docs/guides/goose-cli-commands#run-options) for detailed examples and options. + + + + +
+:::note Privacy & Isolation +- Each person gets their own private session +- No data is shared between users +- Your session won't affect the original recipe creator's session +::: + +## Core Components + + A recipe needs these core components: + + - **Instructions**: Define the agent's behavior and capabilities + - Acts as the agent's mission statement + - Makes the agent ready for any relevant task + - Required if no prompt is provided + + - **Prompt** (Optional): Starts the conversation automatically + - Without a prompt, the agent waits for user input + - Useful for specific, immediate tasks + - Required if no instructions are provided + + - **Activities**: Example tasks that appear as clickable bubbles + - Help users understand what the recipe can do + - Make it easy to get started ## What's Included -A Recipe captures: +A recipe captures: - AI instructions (goal/purpose) - Suggested activities (examples for the user to click) @@ -263,27 +369,4 @@ To protect your privacy and system integrity, Goose excludes: - System-level Goose settings -This means others may need to supply their own credentials or memory context if the Recipe depends on those elements. - - -## Example Use Cases - -- 🔧 Share a debugging workflow with your team -- 📦 Save a repeatable project setup -- 📚 Onboard someone into a task without overwhelming them - - -## Tips for Great Recipes - -If you're sharing recipes with others, here are some tips: - -- Be specific and clear in the instructions, so users know what the recipe is meant to do. -- Keep the activity list focused. Remove anything that's too specific or out of scope. -- Test the link yourself before sharing to make sure everything loads as expected. -- Mention any setup steps that users might need to complete (e.g., obtaining an API key). - -## Troubleshooting - -- You can't create a Recipe from an existing Recipe session. The menu option will be disabled -- Make sure you're using the latest version of Goose if something isn't working -- Remember that credentials, memory, and certain local setups won't carry over +This means others may need to supply their own credentials or memory context if the recipe depends on those elements. \ No newline at end of file diff --git a/documentation/docs/guides/smart-context-management.md b/documentation/docs/guides/smart-context-management.md index 2421d929..81238bb1 100644 --- a/documentation/docs/guides/smart-context-management.md +++ b/documentation/docs/guides/smart-context-management.md @@ -58,10 +58,33 @@ You can proactively summarize your conversation before reaching context limits: The CLI offers three context management options: summarize, truncate, or clear your session. +### Default Context Strategy + +You can configure Goose to automatically handle context limits without prompting by setting the `GOOSE_CONTEXT_STRATEGY` environment variable: + +```bash +# Set default strategy (choose one) +export GOOSE_CONTEXT_STRATEGY=summarize # Automatically summarize (recommended) +export GOOSE_CONTEXT_STRATEGY=truncate # Automatically remove oldest messages +export GOOSE_CONTEXT_STRATEGY=clear # Automatically clear session +export GOOSE_CONTEXT_STRATEGY=prompt # Always prompt user (default) +``` + +Or configure it permanently: +```bash +goose configure set GOOSE_CONTEXT_STRATEGY summarize +``` + +**Default behavior:** +- **Interactive mode**: Prompts user to choose (equivalent to `prompt`) +- **Headless mode** (`goose run`): Automatically summarizes (equivalent to `summarize`) + -When you hit the context limit, you'll see this prompt to choose a management option, allowing you to continue your session: +When you hit the context limit, the behavior depends on your configuration: + +**With default settings (no `GOOSE_CONTEXT_STRATEGY` set)**, you'll see this prompt to choose a management option: ```sh ◇ The model's context length is maxed out. You will need to reduce the # msgs. Do you want to? @@ -76,6 +99,24 @@ final_summary: [A summary of your conversation will appear here] Context maxed out -------------------------------------------------- Goose summarized messages for you. +``` + +**With `GOOSE_CONTEXT_STRATEGY` configured**, Goose will automatically apply your chosen strategy: + +```sh +# Example with GOOSE_CONTEXT_STRATEGY=summarize +Context maxed out - automatically summarized messages. +-------------------------------------------------- +Goose automatically summarized messages for you. + +# Example with GOOSE_CONTEXT_STRATEGY=truncate +Context maxed out - automatically truncated messages. +-------------------------------------------------- +Goose tried its best to truncate messages for you. + +# Example with GOOSE_CONTEXT_STRATEGY=clear +Context maxed out - automatically cleared session. +-------------------------------------------------- ``` @@ -118,4 +159,4 @@ Key information has been preserved while reducing context length. This functionality is not available in the Goose CLI. - \ No newline at end of file + diff --git a/documentation/docs/guides/tool-router.md b/documentation/docs/guides/tool-router.md index 7744b57f..f30bd04b 100644 --- a/documentation/docs/guides/tool-router.md +++ b/documentation/docs/guides/tool-router.md @@ -1,3 +1,7 @@ +--- +draft: true +--- + # Tool Router (preview) ## Overview diff --git a/documentation/docs/guides/using-gooseignore.md b/documentation/docs/guides/using-gooseignore.md index 484adcfb..8c58de4a 100644 --- a/documentation/docs/guides/using-gooseignore.md +++ b/documentation/docs/guides/using-gooseignore.md @@ -23,6 +23,24 @@ Goose supports two types of `.gooseignore` files: You can use both global and local `.gooseignore` files simultaneously. When both exist, Goose will combine the restrictions from both files to determine which paths are restricted. ::: +## Automatic `.gitignore` fallback + +If no `.gooseignore` file is found in your current directory, Goose will automatically use your `.gitignore` file as a fallback. This means: + +1. **Priority Order**: Goose checks for ignore patterns in this order: + - Global `.gooseignore` (if exists) + - Local `.gooseignore` (if exists) + - Local `.gitignore` (if no local `.gooseignore` and `.gitignore` exists) + - Default patterns (if none of the above exist) + +2. **Seamless Integration**: Projects with existing `.gitignore` files get automatic protection without needing a separate `.gooseignore` file. + +3. **Override Capability**: Creating a local `.gooseignore` file will completely override `.gitignore` patterns for that directory. + +:::info Debug logging +When Goose uses `.gitignore` as a fallback, it will log a message to help you understand which ignore file is being used. +::: + ## Example `.gooseignore` file In your `.gooseignore` file, you can write patterns to match files you want Goose to ignore. Here are some common patterns: @@ -49,7 +67,7 @@ downloads/ # Ignore everything in the "downloads" directory ## Default patterns -By default, if you haven't created any `.gooseignore` files, Goose will not modify files matching these patterns: +By default, if you haven't created any `.gooseignore` files **and no `.gitignore` file exists**, Goose will not modify files matching these patterns: ```plaintext **/.env @@ -57,6 +75,8 @@ By default, if you haven't created any `.gooseignore` files, Goose will not modi **/secrets.* ``` +These default patterns only apply when neither `.gooseignore` nor `.gitignore` files are found in your project. + ## Common use cases Here are some typical scenarios where `.gooseignore` is helpful: @@ -65,4 +85,6 @@ Here are some typical scenarios where `.gooseignore` is helpful: - **Third-Party Code**: Keep Goose from changing external libraries or dependencies - **Important Configurations**: Protect critical configuration files from accidental modifications - **Version Control**: Prevent changes to version control files like `.git` directory +- **Existing Projects**: Most projects already have `.gitignore` files that work automatically as ignore patterns for Goose +- **Custom Restrictions**: Create `.gooseignore` when you need different patterns than your `.gitignore` (e.g., allowing Goose to read files that Git ignores) diff --git a/documentation/docs/tutorials/context7-mcp.mdx b/documentation/docs/tutorials/context7-mcp.mdx index ec2276d0..06d6a62a 100644 --- a/documentation/docs/tutorials/context7-mcp.mdx +++ b/documentation/docs/tutorials/context7-mcp.mdx @@ -10,7 +10,7 @@ import YouTubeShortEmbed from '@site/src/components/YouTubeShortEmbed'; import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions'; - + This tutorial covers how to add the [Context7 MCP Server](https://github.com/upstash/context7) as a Goose extension to pull up-to-date, version-specific code and docs so Goose can vibe code with real context, not hallucinated or outdated answers. diff --git a/documentation/docs/tutorials/google-drive-mcp.md b/documentation/docs/tutorials/google-drive-mcp.md index 45715329..6fa251fb 100644 --- a/documentation/docs/tutorials/google-drive-mcp.md +++ b/documentation/docs/tutorials/google-drive-mcp.md @@ -19,20 +19,24 @@ This tutorial covers how to add the [Google Drive MCP Server](https://github.com **Command** ```sh - GDRIVE_OAUTH_PATH=/Users//.config/gcp-oauth.keys.json \ - GDRIVE_CREDENTIALS_PATH=/Users//.config/.gdrive-server-credentials.json \ - npx -y @modelcontextprotocol/server-gdrive auth + GDRIVE_OAUTH_PATH=$USER_HOME/.config/gcp-oauth.keys.json \ + GDRIVE_CREDENTIALS_PATH=$USER_HOME/.config/.gdrive-server-credentials.json \ + npx -y @modelcontextprotocol/server-gdrive auth \ npx -y @modelcontextprotocol/server-gdrive ``` **Environment Variable** ``` - GDRIVE_CREDENTIALS_PATH: ~/.config/.gdrive-server-credentials.json - GDRIVE_OAUTH_PATH: ~/.config/gcp-oauth.keys.json + GDRIVE_CREDENTIALS_PATH: $USER_HOME/.config/.gdrive-server-credentials.json + GDRIVE_OAUTH_PATH: $USER_HOME/.config/gcp-oauth.keys.json ``` ::: +:::info +Note that you *must* use absolute paths in the environment variables. Make sure you replace `$USER_HOME` with your home directory. +::: + ## Configuration :::info @@ -72,10 +76,14 @@ To obtain your Google Drive server credentials and oauth keys, follow the steps To connect your Google account, run the following authentication command in your terminal: ```sh - GDRIVE_OAUTH_PATH=/Users//.config/gcp-oauth.keys.json \ - GDRIVE_CREDENTIALS_PATH=/Users//.config/.gdrive-server-credentials.json \ + GDRIVE_OAUTH_PATH=$USER_HOME/.config/gcp-oauth.keys.json \ + GDRIVE_CREDENTIALS_PATH=$USER_HOME/.config/.gdrive-server-credentials.json \ npx -y @modelcontextprotocol/server-gdrive auth ``` + :::info + Replace `$USER_HOME` with your home directory. + ::: + A browser window will open for authentication. Follow the prompts to connect your Google account and complete the OAuth process. At this stage, your environment variable `GDRIVE_CREDENTIALS_PATH` will be set with the saved credentials. :::tip @@ -87,13 +95,19 @@ You'll need to re-authenticate once a day when using the Google Drive extension. 1. [Launch the installer](goose://extension?cmd=npx&arg=-y&arg=%40modelcontextprotocol%2Fserver-gdrive&id=google-drive&name=Google%20Drive&description=Google%20Drive%20integration&env=GDRIVE_CREDENTIALS_PATH%3DPath%20to%20Google%20Drive%20credentials&env=GDRIVE_OAUTH_PATH%3DPath%20to%20OAuth%20token) 2. Press `Yes` to confirm the installation 3. For `GDRIVE_CREDENTIALS_PATH`, enter the following: + ```sh + $USER_HOME/.config/.gdrive-server-credentials.json ``` - ~/.config/.gdrive-server-credentials.json - ``` + :::info + Replace `$USER_HOME` with your home directory. You must specify an absolute path for this extension to work. + ::: 4. For `GDRIVE_OAUTH_PATH`, enter the following: + ```sh + $USER_HOME/.config/gcp-oauth.keys.json ``` - ~/.config/gcp-oauth.keys.json - ``` + :::info + Replace `$USER_HOME` with your home directory. You must specify an absolute path for this extension to work. + ::: 5. Click `Save Configuration` 6. Scroll to the top and click `Exit` from the upper left corner diff --git a/run_cross_local.md b/run_cross_local.md index b27b7f74..cfcde742 100644 --- a/run_cross_local.md +++ b/run_cross_local.md @@ -29,7 +29,7 @@ docker pull arm64v8/ubuntu 2. Run the container pwd is the directory which contains the binary built in the previous step on your host machine ```sh -docker run -v $(pwd):/app -it arm64v8/ubuntu /bin/bash +docker run --rm -v "$(pwd)":/app -it --platform linux/arm64 arm64v8/ubuntu /bin/bash ``` 3. Install dependencies in the container and set up api testing environment @@ -63,7 +63,7 @@ docker pull --platform linux/amd64 debian:latest 2. Run the container pwd is the directory contains the binary built in the previous step on your host machine ```sh -docker run --platform linux/amd64 -it -v "$(pwd)":/app debian:latest /bin/bash +docker run --rm -v "$(pwd)":/app -it --platform linux/amd64 ubuntu:latest /bin/bash ``` 3. Install dependencies in the container and set up api testing environment diff --git a/test_lead_worker.sh b/test_lead_worker.sh new file mode 100755 index 00000000..3d403b82 --- /dev/null +++ b/test_lead_worker.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Test script for lead/worker provider functionality + +# Set up test environment variables +export GOOSE_PROVIDER="openai" +export GOOSE_MODEL="gpt-4o-mini" +export OPENAI_API_KEY="test-key" + +# Test 1: Default behavior (no lead/worker) +echo "Test 1: Default behavior (no lead/worker)" +unset GOOSE_LEAD_MODEL +unset GOOSE_WORKER_MODEL +unset GOOSE_LEAD_TURNS + +# Test 2: Lead/worker with same provider +echo -e "\nTest 2: Lead/worker with same provider" +export GOOSE_LEAD_MODEL="gpt-4o" +export GOOSE_WORKER_MODEL="gpt-4o-mini" +export GOOSE_LEAD_TURNS="3" + +# Test 3: Lead/worker with default worker (uses main model) +echo -e "\nTest 3: Lead/worker with default worker" +export GOOSE_LEAD_MODEL="gpt-4o" +unset GOOSE_WORKER_MODEL +export GOOSE_LEAD_TURNS="5" + +echo -e "\nConfiguration examples:" +echo "- Default: Uses GOOSE_MODEL for all turns" +echo "- Lead/Worker: Set GOOSE_LEAD_MODEL to use a different model for initial turns" +echo "- GOOSE_LEAD_TURNS: Number of turns to use lead model (default: 5)" +echo "- GOOSE_WORKER_MODEL: Model to use after lead turns (default: GOOSE_MODEL)" \ No newline at end of file diff --git a/test_web.sh b/test_web.sh new file mode 100644 index 00000000..adfd4d95 --- /dev/null +++ b/test_web.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Test script for Goose Web Interface + +echo "Testing Goose Web Interface..." +echo "================================" + +# Start the web server in the background +echo "Starting web server on port 8080..." +./target/debug/goose web --port 8080 & +SERVER_PID=$! + +# Wait for server to start +sleep 2 + +# Test the health endpoint +echo -e "\nTesting health endpoint:" +curl -s http://localhost:8080/api/health | jq . + +# Open browser (optional) +# open http://localhost:8080 + +echo -e "\nWeb server is running at http://localhost:8080" +echo "Press Ctrl+C to stop the server" + +# Wait for user to stop +wait $SERVER_PID \ No newline at end of file diff --git a/ui/desktop/.env b/ui/desktop/.env index 68502576..e408e241 100644 --- a/ui/desktop/.env +++ b/ui/desktop/.env @@ -1,4 +1,4 @@ VITE_START_EMBEDDED_SERVER=yes GOOSE_PROVIDER__TYPE=openai GOOSE_PROVIDER__HOST=https://api.openai.com -GOOSE_PROVIDER__MODEL=gpt-4o \ No newline at end of file +GOOSE_PROVIDER__MODEL=gpt-4o diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1f4c197c..edeba03c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -589,6 +589,66 @@ } } }, + "/schedule/{id}/inspect": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "inspect_running_job", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to inspect", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Running job information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InspectJobResponse" + } + } + } + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/kill": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "kill_running_job", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Running job killed successfully" + } + } + } + }, "/schedule/{id}/pause": { "post": { "tags": [ @@ -1332,6 +1392,35 @@ } } }, + "InspectJobResponse": { + "type": "object", + "properties": { + "processStartTime": { + "type": "string", + "nullable": true + }, + "runningDurationSeconds": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "KillJobResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, "ListSchedulesResponse": { "type": "object", "required": [ @@ -1805,6 +1894,10 @@ "cron": { "type": "string" }, + "current_session_id": { + "type": "string", + "nullable": true + }, "currently_running": { "type": "boolean" }, @@ -1819,6 +1912,11 @@ "paused": { "type": "boolean" }, + "process_start_time": { + "type": "string", + "format": "date-time", + "nullable": true + }, "source": { "type": "string" } diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index d8c69bc3..b6f97c54 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -87,6 +87,7 @@ "postcss": "^8.4.47", "prettier": "^3.4.2", "tailwindcss": "^3.4.14", + "typescript": "~5.5.0", "vite": "^6.3.4" }, "engines": { @@ -287,13 +288,13 @@ } }, "node_modules/@ai-sdk/ui-utils": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.8.tgz", - "integrity": "sha512-nls/IJCY+ks3Uj6G/agNhXqQeLVqhNfoJbuNgCny+nX2veY5ADB91EcZUqVeQ/ionul2SeUswPY6Q/DxteY29Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.7", + "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "engines": { @@ -316,9 +317,9 @@ } }, "node_modules/@ai-sdk/ui-utils/node_modules/@ai-sdk/provider-utils": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.7.tgz", - "integrity": "sha512-kM0xS3GWg3aMChh9zfeM+80vEZfXzR3JEUBdycZLtbRZ2TRT8xOj3WodGHPb06sUK5yD7pAXC/P7ctsi2fvUGQ==", + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.3", @@ -404,23 +405,23 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", + "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", "dev": true, "license": "MIT", "engines": { @@ -428,22 +429,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -469,13 +470,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", + "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/parser": "^7.27.3", + "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -485,14 +486,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", - "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -512,28 +513,28 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -543,9 +544,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { @@ -553,27 +554,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -581,26 +582,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.4.tgz", + "integrity": "sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.4.tgz", + "integrity": "sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -610,13 +611,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -626,13 +627,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -642,42 +643,39 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.4.tgz", + "integrity": "sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -686,22 +684,22 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", + "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@electron-forge/cli": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.8.0.tgz", - "integrity": "sha512-XZ+Hg7pxeE9pgrahqcpMlND+VH0l0UTZLyO5wkI+YfanNyBQksB2mw24XeEtCA6x8F2IaEYdIGgijmPF6qpjzA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.8.1.tgz", + "integrity": "sha512-QI3EShutfq9Y+2TWWrPjm4JZM3eSAKzoQvRZdVhAfVpUbyJ8K23VqJShg3kGKlPf9BXHAGvE+8LyH5s2yDr1qA==", "dev": true, "funding": [ { @@ -715,9 +713,9 @@ ], "license": "MIT", "dependencies": { - "@electron-forge/core": "7.8.0", - "@electron-forge/core-utils": "7.8.0", - "@electron-forge/shared-types": "7.8.0", + "@electron-forge/core": "7.8.1", + "@electron-forge/core-utils": "7.8.1", + "@electron-forge/shared-types": "7.8.1", "@electron/get": "^3.0.0", "chalk": "^4.0.0", "commander": "^11.1.0", @@ -737,9 +735,9 @@ } }, "node_modules/@electron-forge/core": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.8.0.tgz", - "integrity": "sha512-7byf660ECZND+irOhGxvpmRXjk1bMrsTWh5J2AZMEvaXI8tub9OrZY9VSbi5fcDt0lpHPKmgVk7NRf/ZjJ+beQ==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.8.1.tgz", + "integrity": "sha512-jkh0QPW5p0zmruu1E8+2XNufc4UMxy13WLJcm7hn9jbaXKLkMbKuEvhrN1tH/9uGp1mhr/t8sC4N67gP+gS87w==", "dev": true, "funding": [ { @@ -753,17 +751,17 @@ ], "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.8.0", - "@electron-forge/maker-base": "7.8.0", - "@electron-forge/plugin-base": "7.8.0", - "@electron-forge/publisher-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0", - "@electron-forge/template-base": "7.8.0", - "@electron-forge/template-vite": "7.8.0", - "@electron-forge/template-vite-typescript": "7.8.0", - "@electron-forge/template-webpack": "7.8.0", - "@electron-forge/template-webpack-typescript": "7.8.0", - "@electron-forge/tracer": "7.8.0", + "@electron-forge/core-utils": "7.8.1", + "@electron-forge/maker-base": "7.8.1", + "@electron-forge/plugin-base": "7.8.1", + "@electron-forge/publisher-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1", + "@electron-forge/template-base": "7.8.1", + "@electron-forge/template-vite": "7.8.1", + "@electron-forge/template-vite-typescript": "7.8.1", + "@electron-forge/template-webpack": "7.8.1", + "@electron-forge/template-webpack-typescript": "7.8.1", + "@electron-forge/tracer": "7.8.1", "@electron/get": "^3.0.0", "@electron/packager": "^18.3.5", "@electron/rebuild": "^3.7.0", @@ -777,6 +775,7 @@ "global-dirs": "^3.0.0", "got": "^11.8.5", "interpret": "^3.1.1", + "jiti": "^2.4.2", "listr2": "^7.0.2", "lodash": "^4.17.20", "log-symbols": "^4.0.0", @@ -792,13 +791,13 @@ } }, "node_modules/@electron-forge/core-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.8.0.tgz", - "integrity": "sha512-ZioRzqkXVOGuwkfvXN/FPZxcssJ9AkOZx6RvxomQn90F77G2KfEbw4ZwAxVTQ+jWNUzydTic5qavWle++Y5IeA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.8.1.tgz", + "integrity": "sha512-mRoPLDNZgmjyOURE/K0D3Op53XGFmFRgfIvFC7c9S/BqsRpovVblrqI4XxPRdNmH9dvhd8On9gGz+XIYAKD3aQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0", + "@electron-forge/shared-types": "7.8.1", "@electron/rebuild": "^3.7.0", "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", @@ -813,13 +812,13 @@ } }, "node_modules/@electron-forge/maker-base": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.8.0.tgz", - "integrity": "sha512-yGRvz70w+NnKO7PhzNFRgYM+x6kxYFgpbChJIQBs3WChd9bGjL+MZLrwYqmxOFLpWNwRAJ6PEi4E/8U5GgV6AQ==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.8.1.tgz", + "integrity": "sha512-GUZqschGuEBzSzE0bMeDip65IDds48DZXzldlRwQ+85SYVA6RMU2AwDDqx3YiYsvP2OuxKruuqIJZtOF5ps4FQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0", + "@electron-forge/shared-types": "7.8.1", "fs-extra": "^10.0.0", "which": "^2.0.2" }, @@ -828,14 +827,14 @@ } }, "node_modules/@electron-forge/maker-deb": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-7.8.0.tgz", - "integrity": "sha512-9jjhLm/1IBIo0UuRdELgvBhUkNjK3tHNlUsrqeb8EJwWJZShbPwHYZJj+VbgjQfJFFzhHwBBDJViBXJ/4ePv+g==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-7.8.1.tgz", + "integrity": "sha512-tjjeesQtCP5Xht1X7gl4+K9bwoETPmQfBkOVAY/FZIxPj40uQh/hOUtLX2tYENNGNVZ1ryDYRs8TuPi+I41Vfw==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/maker-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0" + "@electron-forge/maker-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1" }, "engines": { "node": ">= 16.4.0" @@ -845,14 +844,14 @@ } }, "node_modules/@electron-forge/maker-rpm": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-rpm/-/maker-rpm-7.8.0.tgz", - "integrity": "sha512-oTH951NE39LOX2wYMg+C06vBZDWUP/0dsK01PlXEl5e5YfQM5Cifsk3E7BzE6BpZdWRJL3k/ETqpyYeIGNb1jw==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-rpm/-/maker-rpm-7.8.1.tgz", + "integrity": "sha512-TF6wylft3BHkw9zdHcxmjEPBZYgTIc0jE31skFnMEQ/aExbNRiNaCZvsXy+7ptTWZxhxUKRc9KHhLFRMCmOK8g==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/maker-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0" + "@electron-forge/maker-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1" }, "engines": { "node": ">= 16.4.0" @@ -862,14 +861,14 @@ } }, "node_modules/@electron-forge/maker-squirrel": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-squirrel/-/maker-squirrel-7.8.0.tgz", - "integrity": "sha512-On8WIyjNtNlWf8NJRRVToighGCCU+wcxytFM0F8Zx/pLszgc01bt7wIarOiAIzuIT9Z8vshAYA0iG1U099jfeA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-squirrel/-/maker-squirrel-7.8.1.tgz", + "integrity": "sha512-qT1PMvT7ALF0ONOkxlA0oc0PiFuKCAKgoMPoxYo9gGOqFvnAb+TBcnLxflQ4ashE/ZkrHpykr4LcDJxqythQTA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/maker-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0", + "@electron-forge/maker-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1", "fs-extra": "^10.0.0" }, "engines": { @@ -880,14 +879,14 @@ } }, "node_modules/@electron-forge/maker-zip": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.8.0.tgz", - "integrity": "sha512-7MLD7GkZdlGecC9GvgBu0sWYt48p3smYvr+YCwlpdH1CTeLmWhvCqeH33a2AB0XI5CY8U8jnkG2jgdTkzr/EQw==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.8.1.tgz", + "integrity": "sha512-unIxEoV1lnK4BLVqCy3L2y897fTyg8nKY1WT4rrpv0MUKnQG4qmigDfST5zZNNHHaulEn/ElAic2GEiP7d6bhQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/maker-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0", + "@electron-forge/maker-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1", "cross-zip": "^4.0.0", "fs-extra": "^10.0.0", "got": "^11.8.5" @@ -897,41 +896,41 @@ } }, "node_modules/@electron-forge/plugin-auto-unpack-natives": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/plugin-auto-unpack-natives/-/plugin-auto-unpack-natives-7.8.0.tgz", - "integrity": "sha512-JGal5ltZmbTQ5rNq67OgGC4MJ2zjjFW0fqykHy8X9J8cgaH7SRdKkT4yYZ8jH01IAF1J57FD2zIob1MvcBqjcg==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-auto-unpack-natives/-/plugin-auto-unpack-natives-7.8.1.tgz", + "integrity": "sha512-4URAgWX9qqqKe6Bfad0VmpFRrwINYMODfKGd2nFQrfHxmBtdpXnsWlLwVGE/wGssIQaTMI5bWQ6F2RNeXTgnhA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/plugin-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0" + "@electron-forge/plugin-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1" }, "engines": { "node": ">= 16.4.0" } }, "node_modules/@electron-forge/plugin-base": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.8.0.tgz", - "integrity": "sha512-rDeeChRWIp5rQVo3Uc1q0ncUvA+kWWURW7tMuQjPvy2qVSgX+jIf5krk+T1Dp06+D4YZzEIrkibRaamAaIcR1w==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.8.1.tgz", + "integrity": "sha512-iCZC2d7CbsZ9l6j5d+KPIiyQx0U1QBfWAbKnnQhWCSizjcrZ7A9V4sMFZeTO6+PVm48b/r9GFPm+slpgZtYQLg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0" + "@electron-forge/shared-types": "7.8.1" }, "engines": { "node": ">= 16.4.0" } }, "node_modules/@electron-forge/plugin-fuses": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/plugin-fuses/-/plugin-fuses-7.8.0.tgz", - "integrity": "sha512-ZxFtol3aHNY+oYrZWa7EDBLl4uk/+NlOCJmqC7C32R/3S/Kn2ebVRxpLwrFM12KtHeD+Z3gmZNBhwOe0TECgOA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-fuses/-/plugin-fuses-7.8.1.tgz", + "integrity": "sha512-dYTwvbV1HcDOIQ0wTybpdtPq6YoBYXIWBTb7DJuvFu/c/thj1eoEdnbwr8mT9hEivjlu5p4ls46n16P5EtZ0oA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/plugin-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0" + "@electron-forge/plugin-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1" }, "engines": { "node": ">= 16.4.0" @@ -941,16 +940,16 @@ } }, "node_modules/@electron-forge/plugin-vite": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/plugin-vite/-/plugin-vite-7.8.0.tgz", - "integrity": "sha512-qopX6DU51mUD4bnGYklo5nr0U+hmwATKQavUpncg1i+R0pyYSUrYSVYu2HVFNj8F9QXDyXhf1I2AwwZe9STYug==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-vite/-/plugin-vite-7.8.1.tgz", + "integrity": "sha512-NjaN25rO/kRaJn7xBvn9wlXroPcDYBAacrKRkSbOZIODlHgBda/pw46lk8lmSG5LTw7V0llvM4e01g9CP/8dEw==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.8.0", - "@electron-forge/plugin-base": "7.8.0", - "@electron-forge/shared-types": "7.8.0", - "@electron-forge/web-multi-logger": "7.8.0", + "@electron-forge/core-utils": "7.8.1", + "@electron-forge/plugin-base": "7.8.1", + "@electron-forge/shared-types": "7.8.1", + "@electron-forge/web-multi-logger": "7.8.1", "chalk": "^4.0.0", "debug": "^4.3.1", "fs-extra": "^10.0.0", @@ -961,26 +960,26 @@ } }, "node_modules/@electron-forge/publisher-base": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-7.8.0.tgz", - "integrity": "sha512-wrZyptJ0Uqvlh2wYzDZfIu2HgCQ+kdGiBlcucmLY4W+GUqf043O8cbYso3D9NXQxOow55QC/1saCQkgLphprPA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-7.8.1.tgz", + "integrity": "sha512-z2C+C4pcFxyCXIFwXGDcxhU8qtVUPZa3sPL6tH5RuMxJi77768chLw2quDWk2/dfupcSELXcOMYCs7aLysCzeQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0" + "@electron-forge/shared-types": "7.8.1" }, "engines": { "node": ">= 16.4.0" } }, "node_modules/@electron-forge/shared-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.8.0.tgz", - "integrity": "sha512-Ul+7HPvAZiAirqpZm0vc9YvlkAE+2bcrI10p3t50mEtuxn5VO/mB72NXiEKfWzHm8F31JySIe9bUV6s1MHQcCw==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.8.1.tgz", + "integrity": "sha512-guLyGjIISKQQRWHX+ugmcjIOjn2q/BEzCo3ioJXFowxiFwmZw/oCZ2KlPig/t6dMqgUrHTH5W/F0WKu0EY4M+Q==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/tracer": "7.8.0", + "@electron-forge/tracer": "7.8.1", "@electron/packager": "^18.3.5", "@electron/rebuild": "^3.7.0", "listr2": "^7.0.2" @@ -990,14 +989,14 @@ } }, "node_modules/@electron-forge/template-base": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.8.0.tgz", - "integrity": "sha512-hc8NwoDqEEmZFH/p0p3MK/7xygMmI+cm8Gavoj2Mr2xS7VUUu4r3b5PwIGKvkLfPG34uwsiVwtid2t1rWGF4UA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.8.1.tgz", + "integrity": "sha512-k8jEUr0zWFWb16ZGho+Es2OFeKkcbTgbC6mcH4eNyF/sumh/4XZMcwRtX1i7EiZAYiL9sVxyI6KVwGu254g+0g==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.8.0", - "@electron-forge/shared-types": "7.8.0", + "@electron-forge/core-utils": "7.8.1", + "@electron-forge/shared-types": "7.8.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "fs-extra": "^10.0.0", @@ -1008,14 +1007,14 @@ } }, "node_modules/@electron-forge/template-vite": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.8.0.tgz", - "integrity": "sha512-bf/jd8WzD0gU7Jet+WSi0Lm0SQmseb08WY27ZfJYEs2EVNMiwDfPicgQnOaqP++2yTrXhj1OY/rolZCP9CUyVw==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.8.1.tgz", + "integrity": "sha512-qzSlJaBYYqQAbBdLk4DqAE3HCNz4yXbpkb+VC74ddL4JGwPdPU57DjCthr6YetKJ2FsOVy9ipovA8HX5UbXpAg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0", - "@electron-forge/template-base": "7.8.0", + "@electron-forge/shared-types": "7.8.1", + "@electron-forge/template-base": "7.8.1", "fs-extra": "^10.0.0" }, "engines": { @@ -1023,14 +1022,14 @@ } }, "node_modules/@electron-forge/template-vite-typescript": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.8.0.tgz", - "integrity": "sha512-kW3CaVxKHUYuVfY+rT3iepeZ69frBRGh3YZOngLY2buCvGIqNEx+VCgrFBRDDbOKGmwQtwO1E9wp2rtC8q6Ztg==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.8.1.tgz", + "integrity": "sha512-CccQhwUjZcc6svzuOi3BtbDal591DzyX2J5GPa6mwVutDP8EMtqJL1VyOHdcWO/7XjI6GNAD0fiXySOJiUAECA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0", - "@electron-forge/template-base": "7.8.0", + "@electron-forge/shared-types": "7.8.1", + "@electron-forge/template-base": "7.8.1", "fs-extra": "^10.0.0" }, "engines": { @@ -1038,14 +1037,14 @@ } }, "node_modules/@electron-forge/template-webpack": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.8.0.tgz", - "integrity": "sha512-AdLGC6NVgrd7Q0SaaeiwJKmSBjN6C2EHxZgLMy1yxNSpazU9m3DtYQilDjXqmCWfxkeNzdke0NaeDvLgdJSw5A==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.8.1.tgz", + "integrity": "sha512-DA77o9kTCHrq+W211pyNP49DyAt0d1mzMp2gisyNz7a+iKvlv2DsMAeRieLoCQ44akb/z8ZsL0YLteSjKLy4AA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0", - "@electron-forge/template-base": "7.8.0", + "@electron-forge/shared-types": "7.8.1", + "@electron-forge/template-base": "7.8.1", "fs-extra": "^10.0.0" }, "engines": { @@ -1053,14 +1052,14 @@ } }, "node_modules/@electron-forge/template-webpack-typescript": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.8.0.tgz", - "integrity": "sha512-Pl8l+gv3HzqCfFIMLxlEsoAkNd0VEWeZZ675SYyqs0/kBQUifn0bKNhVE4gUZwKGgQCcG1Gvb23KdVGD3H3XmA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.8.1.tgz", + "integrity": "sha512-h922E+6zWwym1RT6WKD79BLTc4H8YxEMJ7wPWkBX59kw/exsTB/KFdiJq6r82ON5jSJ+Q8sDGqSmDWdyCfo+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.8.0", - "@electron-forge/template-base": "7.8.0", + "@electron-forge/shared-types": "7.8.1", + "@electron-forge/template-base": "7.8.1", "fs-extra": "^10.0.0" }, "engines": { @@ -1068,9 +1067,9 @@ } }, "node_modules/@electron-forge/tracer": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.8.0.tgz", - "integrity": "sha512-t4fIATZEX6/7PJNfyh6tLzKEsNMpO01Nz/rgHWBxeRvjCw5UNul9OOxoM7b43vfFAO9Jv++34oI3VJ09LeVQ2Q==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.8.1.tgz", + "integrity": "sha512-r2i7aHVp2fylGQSPDw3aTcdNfVX9cpL1iL2MKHrCRNwgrfR+nryGYg434T745GGm1rNQIv5Egdkh5G9xf00oWA==", "dev": true, "license": "MIT", "dependencies": { @@ -1081,9 +1080,9 @@ } }, "node_modules/@electron-forge/web-multi-logger": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@electron-forge/web-multi-logger/-/web-multi-logger-7.8.0.tgz", - "integrity": "sha512-2nUP7O9auXDsoa185AsZPlIbpargj1lNFweNH1Lch1MCwLlJOI9ZJHiCTAB4qviS4usRs00WeebWg/uN/zOWvA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@electron-forge/web-multi-logger/-/web-multi-logger-7.8.1.tgz", + "integrity": "sha512-Z8oU39sbrVDvyk0yILBqL0CFIysVlxkM5m4RWyeo+GLoc/t4LYAhGLSquFTOD1t20nzqZzgzG8M56zIgYuyX1w==", "dev": true, "license": "MIT", "dependencies": { @@ -1437,9 +1436,9 @@ } }, "node_modules/@electron/universal": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.2.tgz", - "integrity": "sha512-mqY1szx5/d5YLvfCDWWoJdkSIjIz+NdWN4pN0r78lYiE7De+slLpuF3lVxIT+hlJnwk5sH2wFRMl6/oUgUVO3A==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1497,9 +1496,9 @@ } }, "node_modules/@electron/windows-sign": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.1.tgz", - "integrity": "sha512-YfASnrhJ+ve6Q43ZiDwmpBgYgi2u0bYjeAVi2tDfN7YWAKO8X9EEOuPGtqbJpPLM6TfAHimghICjWe2eaJ8BAg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1661,9 +1660,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], @@ -1678,9 +1677,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ "arm" ], @@ -1695,9 +1694,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", "cpu": [ "arm64" ], @@ -1712,9 +1711,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", "cpu": [ "x64" ], @@ -1729,9 +1728,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], @@ -1746,9 +1745,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", "cpu": [ "x64" ], @@ -1763,9 +1762,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", "cpu": [ "arm64" ], @@ -1780,9 +1779,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", "cpu": [ "x64" ], @@ -1797,9 +1796,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", "cpu": [ "arm" ], @@ -1814,9 +1813,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", "cpu": [ "arm64" ], @@ -1831,9 +1830,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", "cpu": [ "ia32" ], @@ -1848,9 +1847,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", "cpu": [ "loong64" ], @@ -1865,9 +1864,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", "cpu": [ "mips64el" ], @@ -1882,9 +1881,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", "cpu": [ "ppc64" ], @@ -1899,9 +1898,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], @@ -1916,9 +1915,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], @@ -1933,9 +1932,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], @@ -1950,9 +1949,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", "cpu": [ "arm64" ], @@ -1967,9 +1966,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], @@ -1984,9 +1983,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", "cpu": [ "arm64" ], @@ -2001,9 +2000,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], @@ -2018,9 +2017,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", "cpu": [ "x64" ], @@ -2035,9 +2034,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], @@ -2052,9 +2051,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], @@ -2069,9 +2068,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], @@ -2086,9 +2085,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz", - "integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -2178,21 +2177,21 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", + "integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz", + "integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", + "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, @@ -2445,15 +2444,16 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.9.0.tgz", - "integrity": "sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", + "integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==", "dev": true, "license": "MIT", "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", @@ -2818,13 +2818,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", - "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.51.1" + "playwright": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -2852,12 +2852,12 @@ "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.3.tgz", - "integrity": "sha512-givBUIlhucV212j05wJCzXtcUtQnAwoUF9eAyUyOB2YwKHnWyme817trCtAzLjo0OndPr09kbkFe2onKRxLWdg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.1.3" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2875,20 +2875,20 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.4.tgz", - "integrity": "sha512-SGCxlSBaMvEzDROzyZjsVNzu9XY5E28B3k8jOENyrz6csOv/pG1eHyYfLJai1n9tRjwG61coXDhfpgtxKxUv5g==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz", + "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collapsible": "1.1.4", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -2906,17 +2906,17 @@ } }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.7.tgz", - "integrity": "sha512-7Gx1gcoltd0VxKoR8mc+TAVbzvChJyZryZsTam0UhoL92z0L+W8ovxvcgvd+nkz24y7Qc51JQKBAGe4+825tYw==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.7", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-slot": "1.2.0" + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2934,12 +2934,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.3.tgz", - "integrity": "sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2957,12 +2957,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.3.tgz", - "integrity": "sha512-yIrYZUc2e/JtRkDpuJCmaR6kj/jzekDfQLcPFdEWzSOygCPy8poR4YcszaHP5A7mh25ncofHEpeTwfhxEuBv8Q==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2980,14 +2980,15 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.4.tgz", - "integrity": "sha512-+kBesLBzwqyDiYCtYFK+6Ktf+N7+Y6QOTUueLGLIbLZ/YeyFW6bsBGDsN+5HxHpM55C90u5fxsg0ErxzXTcwKA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", "license": "MIT", "dependencies": { "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -3006,17 +3007,17 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.5.tgz", - "integrity": "sha512-B0gYIVxl77KYDR25AY9EGe/G//ef85RVBIxQvK+m5pxAC7XihAc/8leMHhDvjvhDu02SBSb6BuytlWr/G7F3+g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, @@ -3036,18 +3037,18 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.4.tgz", - "integrity": "sha512-u7LCw1EYInQtBNLGjm9nZ89S/4GcvX1UR5XbekEgnQae2Hkpq39ycJ1OhdeN1/JDfVNG91kWaWoest127TaEKQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", + "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -3066,15 +3067,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.3.tgz", - "integrity": "sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-slot": "1.2.0" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3122,17 +3123,17 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.7.tgz", - "integrity": "sha512-EwO3tyyqwGaLPg0P64jmIKJnBywD0yjiL1eRuMPyhUXPkWWpa5JPDS+oyeIWHy2JbhF+NUlfUPVq6vE7OqgZww==", + "version": "2.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.15.tgz", + "integrity": "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-menu": "2.1.7", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3150,23 +3151,23 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.7.tgz", - "integrity": "sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-slot": "1.2.0", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -3201,14 +3202,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz", - "integrity": "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, @@ -3228,18 +3229,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.7.tgz", - "integrity": "sha512-7/1LiuNZuCQE3IzdicGoHdQOHkS2Q08+7p8w6TXZ6ZjgAULaCI85ZY15yPl4o4FVgoKLRT43/rsfNVN8osClQQ==", + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.7", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3272,13 +3273,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.3.tgz", - "integrity": "sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { @@ -3297,17 +3298,17 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.3.tgz", - "integrity": "sha512-fVxaewKm9+oKL5q+E1+tIKNEkAeh8waJ+MsFNhLFAmpF8VG6nrNXYd2FFU8J7P3gIGNr023Sp+dD0xflqI84mA==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.7.tgz", + "integrity": "sha512-IXLKFnaYvFg/KkeV5QfOX7tRnwHXp127koOFUjLWMTrRv5Rny3DQcAtIFFeA/Cli4HHM8DuJCXAUsgnFVJndlw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-label": "2.1.3", - "@radix-ui/react-primitive": "2.0.3" + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3325,20 +3326,20 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.7.tgz", - "integrity": "sha512-HwM03kP8psrv21J1+9T/hhxi0f5rARVbqIZl9+IAq13l4j4fX+oGIuxisukZZmebO7J35w9gpoILvtG8bbph0w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz", + "integrity": "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.6", - "@radix-ui/react-popper": "1.2.3", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3383,12 +3384,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.3.tgz", - "integrity": "sha512-zwSQ1NzSKG95yA0tvBMgv6XPHoqapJCcg9nsUBaQQ66iRBhZNhlpaQG2ERYYX4O4stkYFK5rxj5NsWfO9CS+Hg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3406,26 +3407,26 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.7.tgz", - "integrity": "sha512-tBODsrk68rOi1/iQzbM54toFF+gSw/y+eQgttFflqlGekuSebNqvFNHjJgjqPhiMb4Fw9A0zNFly1QT6ZFdQ+Q==", + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.3", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-roving-focus": "1.1.3", - "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" @@ -3446,21 +3447,21 @@ } }, "node_modules/@radix-ui/react-menubar": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.7.tgz", - "integrity": "sha512-YB2zFhGdZ5SWEgRS+PgrF7EkwpsjEHntIFB/LRbT49LJdnIeK/xQQyuwLiRcOCgTDN+ALlPXQ08f0P0+TfR41g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.15.tgz", + "integrity": "sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.7", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-roving-focus": "1.1.3", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3478,25 +3479,89 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.6.tgz", - "integrity": "sha512-HJqyzqG74Lj7KV58rk73i/B1nnopVyCfUmKgeGWWrZZiCuMNcY0KKugTrmqMbIeMliUnkBUDKCy9J6Mzl6xeWw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz", + "integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.1.3" + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.7.tgz", + "integrity": "sha512-w1vm7AGI8tNXVovOK7TYQHrAGpRF7qQL+ENpT1a743De5Zmay2RbWGKAiYDKIyIuqptns+znCKwNztE2xl1n0Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.2.tgz", + "integrity": "sha512-F90uYnlBsLPU1UbSLciLsWQmk8+hdWa6SFw4GXaIdNWxFxI5ITKVdAG64f+Twaa9ic6xE7pqxPyUmodrGjT4pQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", @@ -3514,24 +3579,24 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.7.tgz", - "integrity": "sha512-I38OYWDmJF2kbO74LX8UsFydSHWOJuQ7LxPnTefjxxvdvPLempvAnmsyX9UsBlywcbSGpRH7oMLfkUf+ij4nrw==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.3", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-slot": "1.2.0", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -3551,16 +3616,16 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.3.tgz", - "integrity": "sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.3", + "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", @@ -3583,12 +3648,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.5.tgz", - "integrity": "sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -3607,9 +3672,9 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz", - "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -3631,12 +3696,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz", - "integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.0" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3654,13 +3719,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.3.tgz", - "integrity": "sha512-F56aZPGTPb4qJQ/vDjnAq63oTu/DRoIG/Asb5XKOWj8rpefNLtUllR969j5QDN2sRrTk9VXIqQDRj5VvAuquaw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", "license": "MIT", "dependencies": { "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.0.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3678,19 +3743,19 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.4.tgz", - "integrity": "sha512-oLz7ATfKgVTUbpr5OBu6Q7hQcnV22uPT306bmG0QwgnKqBStR98RfWfJGCfW/MmhL4ISmrmmBPBW+c77SDwV9g==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz", + "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-roving-focus": "1.1.3", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, @@ -3710,20 +3775,20 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz", - "integrity": "sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3741,9 +3806,9 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.4.tgz", - "integrity": "sha512-G9rdWTQjOR4sk76HwSdROhPU0jZWpfozn9skU1v4N0/g9k7TmswrJn8W8WMU+aYktnLLpk5LX6fofj2bGe5NFQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.1", @@ -3751,8 +3816,8 @@ "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, @@ -3772,30 +3837,30 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.7.tgz", - "integrity": "sha512-exzGIRtc7S8EIM2KjFg+7lJZsH7O7tpaBaJbBNVDnOZNhtoQ2iV+iSNfi2Wth0m6h3trJkMVvzAehB3c6xj/3Q==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.3", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.1.3", + "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -3815,12 +3880,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.3.tgz", - "integrity": "sha512-2omrWKJvxR0U/tkIXezcc1nFMwtLU0+b/rDK40gnzJqTLWQ/TD/D5IYVefp9sC3QWfeQbpSbEA6op9MQKyaALQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3838,19 +3903,19 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.4.tgz", - "integrity": "sha512-Vr/OgNejNJPAghIhjS7Mf/2F/EXGDT0qgtiHf2BHz71+KqgN+jndFLKq5xAB9JOGejGzejfJLIvT04Do+yzhcg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", + "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" @@ -3871,9 +3936,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", - "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -3889,16 +3954,16 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.4.tgz", - "integrity": "sha512-zGP6W8plLeogoeGMiTHJ/uvf+TE1C2chVsEwfP8YlvpQKJHktG+iCkUtCLGPAuDV8/qDSmIRPm4NggaTxFMVBQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz", + "integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, @@ -3918,19 +3983,19 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.4.tgz", - "integrity": "sha512-fuHMHWSf5SRhXke+DbHXj2wVMo+ghVH30vhX3XVacdXqDl+J4XWafMIGOOER861QpBx1jxgwKXL2dQnfrsd8MQ==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-roving-focus": "1.1.3", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3948,23 +4013,23 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.7.tgz", - "integrity": "sha512-0IWTbAUKvzdpOaWDMZisXZvScXzF0phaQjWspK8RUMEUxjLbli+886mB/kXTIC3F+t5vQ0n0vYn+dsX8s+WdfA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", + "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.6", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.1.3" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3982,14 +4047,14 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.3.tgz", - "integrity": "sha512-Za5HHd9nvsiZ2t3EI/dVd4Bm/JydK+D22uHKk46fPtvuPxVCJBUo5mQybN+g5sZe35y7I6GDTTfdkVv5SnxlFg==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.9.tgz", + "integrity": "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -4007,18 +4072,18 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.3.tgz", - "integrity": "sha512-khTzdGIxy8WurYUEUrapvj5aOev/tUA8TDEFi1D0Dn3yX+KR5AqjX0b7E5sL9ngRRpxDN2RRJdn5siasu5jtcg==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.10.tgz", + "integrity": "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-roving-focus": "1.1.3", - "@radix-ui/react-toggle": "1.1.3", - "@radix-ui/react-use-controllable-state": "1.1.1" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-toggle": "1.1.9", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -4036,18 +4101,18 @@ } }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.3.tgz", - "integrity": "sha512-yTZ8ooxlBqljSiruO6y6azKXSXYBpnzd23yohjyFesun4nm8yh+D91J1yCqhtnRtSjRWuAmr9vFgGxmGwLjTfg==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.10.tgz", + "integrity": "sha512-jiwQsduEL++M4YBIurjSa+voD86OIytCod0/dbIxFZDLD8NfO1//keXYMfsW8BPcfqwoNjt+y06XcJqAb4KR7A==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-roving-focus": "1.1.3", - "@radix-ui/react-separator": "1.1.3", - "@radix-ui/react-toggle-group": "1.1.3" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.10" }, "peerDependencies": { "@types/react": "*", @@ -4065,23 +4130,23 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.0.tgz", - "integrity": "sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.3", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-slot": "1.2.0", - "@radix-ui/react-use-controllable-state": "1.1.1", - "@radix-ui/react-visually-hidden": "1.1.3" + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4114,12 +4179,31 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz", - "integrity": "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4149,6 +4233,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -4216,12 +4318,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.3.tgz", - "integrity": "sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4270,10 +4372,17 @@ } } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", + "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", - "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz", + "integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==", "cpu": [ "arm" ], @@ -4285,9 +4394,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", - "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz", + "integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==", "cpu": [ "arm64" ], @@ -4299,9 +4408,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz", - "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", + "integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", "cpu": [ "arm64" ], @@ -4313,9 +4422,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz", - "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz", + "integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==", "cpu": [ "x64" ], @@ -4327,9 +4436,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz", - "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz", + "integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==", "cpu": [ "arm64" ], @@ -4341,9 +4450,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz", - "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz", + "integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==", "cpu": [ "x64" ], @@ -4355,9 +4464,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz", - "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz", + "integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==", "cpu": [ "arm" ], @@ -4369,9 +4478,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz", - "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz", + "integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==", "cpu": [ "arm" ], @@ -4383,9 +4492,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz", - "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz", + "integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==", "cpu": [ "arm64" ], @@ -4397,9 +4506,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz", - "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz", + "integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==", "cpu": [ "arm64" ], @@ -4411,9 +4520,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz", - "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz", + "integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==", "cpu": [ "loong64" ], @@ -4425,9 +4534,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz", - "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz", + "integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==", "cpu": [ "ppc64" ], @@ -4439,9 +4548,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz", - "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz", + "integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==", "cpu": [ "riscv64" ], @@ -4453,9 +4562,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz", - "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz", + "integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==", "cpu": [ "riscv64" ], @@ -4467,9 +4576,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz", - "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz", + "integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==", "cpu": [ "s390x" ], @@ -4481,9 +4590,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz", - "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz", + "integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==", "cpu": [ "x64" ], @@ -4495,9 +4604,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz", - "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz", + "integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==", "cpu": [ "x64" ], @@ -4509,9 +4618,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz", - "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz", + "integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==", "cpu": [ "arm64" ], @@ -4523,9 +4632,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz", - "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz", + "integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==", "cpu": [ "ia32" ], @@ -4537,9 +4646,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", - "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz", + "integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==", "cpu": [ "x64" ], @@ -4702,9 +4811,9 @@ } }, "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "version": "2.8.18", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", + "integrity": "sha512-nX3d0sxJW41CqQvfOzVG1NCTXfFDrDWIghCZncpHeWlVFd81zxB/DLhg7avFg6eHLCRX7ckBmoIIcqa++upvJA==", "dev": true, "license": "MIT", "dependencies": { @@ -4769,9 +4878,9 @@ } }, "node_modules/@types/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", - "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.2.tgz", + "integrity": "sha512-BtjL3ZwbCQriyb0DGw+Rt12qAXPiBTPs815lsUvtt1Grk0vLRMZNMUZ741d5rjk+UQOxfDiBZ3dxpX00vSkK3g==", "dev": true, "license": "MIT", "dependencies": { @@ -4845,9 +4954,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.16", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", - "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==", "license": "MIT" }, "node_modules/@types/mdast": { @@ -4873,9 +4982,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "version": "22.15.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.28.tgz", + "integrity": "sha512-I0okKVDmyKR281I0UIFV7EWAWRnR0gkuSKob5wVcByyyhr7Px/slhkQapcYX4u00ekzNWaS1gznKZnuzxwo4pw==", "dev": true, "license": "MIT", "dependencies": { @@ -4895,9 +5004,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, "license": "MIT" }, @@ -4909,9 +5018,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.20", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", - "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4919,9 +5028,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.6", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.6.tgz", - "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -5233,15 +5342,16 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.0.tgz", - "integrity": "sha512-x/EztcTKVj+TDeANY1WjNeYsvZjZdfWRMP/KXi5Yn8BoTzpa13ZltaQqKfvWYbX8CE10GOHHdC5v86jY9x8i/g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", + "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -5253,111 +5363,111 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", - "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.16.tgz", + "integrity": "sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.13", + "@babel/parser": "^7.27.2", + "@vue/shared": "3.5.16", "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.0" + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", - "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz", + "integrity": "sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-core": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-core": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", - "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.16.tgz", + "integrity": "sha512-rQR6VSFNpiinDy/DVUE0vHoIDUF++6p910cgcZoaAUm3POxgNOOdS/xgoll3rNdKYTYPnnbARDCZOyZ+QSe6Pw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/compiler-core": "3.5.13", - "@vue/compiler-dom": "3.5.13", - "@vue/compiler-ssr": "3.5.13", - "@vue/shared": "3.5.13", + "@babel/parser": "^7.27.2", + "@vue/compiler-core": "3.5.16", + "@vue/compiler-dom": "3.5.16", + "@vue/compiler-ssr": "3.5.16", + "@vue/shared": "3.5.16", "estree-walker": "^2.0.2", - "magic-string": "^0.30.11", - "postcss": "^8.4.48", - "source-map-js": "^1.2.0" + "magic-string": "^0.30.17", + "postcss": "^8.5.3", + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", - "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.16.tgz", + "integrity": "sha512-d2V7kfxbdsjrDSGlJE7my1ZzCXViEcqN6w14DOsDrUCHEA6vbnVCpRFfrc4ryCP/lCKzX2eS1YtnLE/BuC9f/A==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-dom": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-dom": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/reactivity": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", - "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.16.tgz", + "integrity": "sha512-FG5Q5ee/kxhIm1p2bykPpPwqiUBV3kFySsHEQha5BJvjXdZTUfmya7wP7zC39dFuZAcf/PD5S4Lni55vGLMhvA==", "license": "MIT", "peer": true, "dependencies": { - "@vue/shared": "3.5.13" + "@vue/shared": "3.5.16" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", - "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.16.tgz", + "integrity": "sha512-bw5Ykq6+JFHYxrQa7Tjr+VSzw7Dj4ldR/udyBZbq73fCdJmyy5MPIFR9IX/M5Qs+TtTjuyUTCnmK3lWWwpAcFQ==", "license": "MIT", "peer": true, "dependencies": { - "@vue/reactivity": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/reactivity": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", - "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.16.tgz", + "integrity": "sha512-T1qqYJsG2xMGhImRUV9y/RseB9d0eCYZQ4CWca9ztCuiPj/XWNNN+lkNBuzVbia5z4/cgxdL28NoQCvC0Xcfww==", "license": "MIT", "peer": true, "dependencies": { - "@vue/reactivity": "3.5.13", - "@vue/runtime-core": "3.5.13", - "@vue/shared": "3.5.13", + "@vue/reactivity": "3.5.16", + "@vue/runtime-core": "3.5.16", + "@vue/shared": "3.5.16", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", - "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.16.tgz", + "integrity": "sha512-BrX0qLiv/WugguGsnQUJiYOE0Fe5mZTwi6b7X/ybGB0vfrPH9z0gD/Y6WOR1sGCgX4gc25L1RYS5eYQKDMoNIg==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-ssr": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-ssr": "3.5.16", + "@vue/shared": "3.5.16" }, "peerDependencies": { - "vue": "3.5.13" + "vue": "3.5.16" } }, "node_modules/@vue/shared": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", - "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.16.tgz", + "integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==", "license": "MIT", "peer": true }, @@ -5615,9 +5725,9 @@ "license": "Python-2.0" }, "node_modules/aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -6052,9 +6162,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", "dev": true, "funding": [ { @@ -6072,10 +6182,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -6344,9 +6454,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001713", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz", - "integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==", + "version": "1.0.30001720", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", + "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", "dev": true, "funding": [ { @@ -6935,9 +7045,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7102,9 +7212,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7663,9 +7773,9 @@ } }, "node_modules/electron-log": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.3.3.tgz", - "integrity": "sha512-ZOnlgCVfhKC0Nef68L0wDhwhg8nh5QkpEOA+udjpBxcPfTHGgbZbfoCBS6hmAgVHTAWByHNPkHKpSbEOPGZcxA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.0.tgz", + "integrity": "sha512-AXI5OVppskrWxEAmCxuv8ovX+s2Br39CpCAgkGMNHQtjYT3IiVbSQTncEjFVGPgoH35ZygRm/mvUMBDWwhRxgg==", "license": "MIT", "engines": { "node": ">= 14" @@ -7696,9 +7806,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.137", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz", - "integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==", + "version": "1.5.161", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.161.tgz", + "integrity": "sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==", "dev": true, "license": "ISC" }, @@ -7785,9 +7895,9 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "20.17.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", - "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "version": "20.17.56", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.56.tgz", + "integrity": "sha512-HQk2cDZsA+HYGyqCfWbScO+OUI9RKEZr/sqiASBFpeYoN4Ro3PyaApDG5ipcLY//PvQPhK/a3VsFq2NrQ+Zz1A==", "dev": true, "license": "MIT", "dependencies": { @@ -7949,9 +8059,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "license": "MIT", "dependencies": { @@ -7959,18 +8069,18 @@ "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", @@ -7982,21 +8092,24 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", + "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -8005,7 +8118,7 @@ "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -8128,9 +8241,9 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8141,31 +8254,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/escalade": { @@ -8514,9 +8627,9 @@ "license": "MIT" }, "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, "license": "MIT", "dependencies": { @@ -8536,9 +8649,9 @@ } }, "node_modules/eventsource/node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.2.tgz", + "integrity": "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==", "dev": true, "license": "MIT", "engines": { @@ -9773,9 +9886,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "dev": true, "license": "BSD-2-Clause" }, @@ -10337,6 +10450,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -10845,9 +10971,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.1.tgz", - "integrity": "sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", "dev": true, "license": "MIT", "dependencies": { @@ -10994,9 +11120,9 @@ } }, "node_modules/lint-staged/node_modules/listr2": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.2.tgz", - "integrity": "sha512-vsBzcU4oE+v0lj4FhVLzr9dBTv4/fHIa57l+GCwovP8MoFNZJTOhGU8PXd4v2VJCbECAaijBiHntiekFMLvo0g==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12712,9 +12838,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "3.74.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", - "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", "dev": true, "license": "MIT", "dependencies": { @@ -13554,13 +13680,13 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", - "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.51.1" + "playwright-core": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -13573,9 +13699,9 @@ } }, "node_modules/playwright-core": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", - "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -13611,9 +13737,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", + "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", "funding": [ { "type": "opencollective", @@ -13630,7 +13756,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13881,9 +14007,9 @@ } }, "node_modules/property-information": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", - "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "license": "MIT", "funding": { "type": "github", @@ -13973,61 +14099,66 @@ } }, "node_modules/radix-ui": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.2.0.tgz", - "integrity": "sha512-05auM88p3yNwAarx3JQGnRHbtzDNATbMx6/Qkr2gXg5QNLPUjdeduJvlhhVzlGxfUMBnwzYmydUIzAdrOz3J5w==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.2.tgz", + "integrity": "sha512-fT/3YFPJzf2WUpqDoQi005GS8EpCi+53VhcLaHUj5fwkPYiZAjk1mSxFvbMA8Uq71L03n+WysuYC+mlKkXxt/Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-accessible-icon": "1.1.3", - "@radix-ui/react-accordion": "1.2.4", - "@radix-ui/react-alert-dialog": "1.1.7", - "@radix-ui/react-aspect-ratio": "1.1.3", - "@radix-ui/react-avatar": "1.1.4", - "@radix-ui/react-checkbox": "1.1.5", - "@radix-ui/react-collapsible": "1.1.4", - "@radix-ui/react-collection": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.11", + "@radix-ui/react-alert-dialog": "1.1.14", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.2", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-context-menu": "2.2.7", - "@radix-ui/react-dialog": "1.1.7", + "@radix-ui/react-context-menu": "2.2.15", + "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.6", - "@radix-ui/react-dropdown-menu": "2.1.7", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.3", - "@radix-ui/react-form": "0.1.3", - "@radix-ui/react-hover-card": "1.1.7", - "@radix-ui/react-label": "2.1.3", - "@radix-ui/react-menu": "2.1.7", - "@radix-ui/react-menubar": "1.1.7", - "@radix-ui/react-navigation-menu": "1.2.6", - "@radix-ui/react-popover": "1.1.7", - "@radix-ui/react-popper": "1.2.3", - "@radix-ui/react-portal": "1.1.5", - "@radix-ui/react-presence": "1.1.3", - "@radix-ui/react-primitive": "2.0.3", - "@radix-ui/react-progress": "1.1.3", - "@radix-ui/react-radio-group": "1.2.4", - "@radix-ui/react-roving-focus": "1.1.3", - "@radix-ui/react-scroll-area": "1.2.4", - "@radix-ui/react-select": "2.1.7", - "@radix-ui/react-separator": "1.1.3", - "@radix-ui/react-slider": "1.2.4", - "@radix-ui/react-slot": "1.2.0", - "@radix-ui/react-switch": "1.1.4", - "@radix-ui/react-tabs": "1.1.4", - "@radix-ui/react-toast": "1.2.7", - "@radix-ui/react-toggle": "1.1.3", - "@radix-ui/react-toggle-group": "1.1.3", - "@radix-ui/react-toolbar": "1.1.3", - "@radix-ui/react-tooltip": "1.2.0", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.7", + "@radix-ui/react-hover-card": "1.1.14", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-menubar": "1.1.15", + "@radix-ui/react-navigation-menu": "1.2.13", + "@radix-ui/react-one-time-password-field": "0.1.7", + "@radix-ui/react-password-toggle-field": "0.1.2", + "@radix-ui/react-popover": "1.1.14", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.7", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-scroll-area": "1.2.9", + "@radix-ui/react-select": "2.2.5", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.5", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.5", + "@radix-ui/react-tabs": "1.1.12", + "@radix-ui/react-toast": "1.2.14", + "@radix-ui/react-toggle": "1.1.9", + "@radix-ui/react-toggle-group": "1.1.10", + "@radix-ui/react-toolbar": "1.1.10", + "@radix-ui/react-tooltip": "1.2.7", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/react-visually-hidden": "1.1.3" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -14171,9 +14302,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", - "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz", + "integrity": "sha512-sGsQtcjMqdQyijAHytfGEELB8FufGbfXIsvUTe+NLx1GDRJCXtCFLBLUI1eyZCKXXvbEU2C6gai0PZKoIE9Vbg==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -14623,12 +14754,6 @@ "node": ">=6" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -14907,9 +15032,9 @@ } }, "node_modules/rollup": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz", - "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", + "integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", "dev": true, "license": "MIT", "dependencies": { @@ -14923,26 +15048,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.0", - "@rollup/rollup-android-arm64": "4.40.0", - "@rollup/rollup-darwin-arm64": "4.40.0", - "@rollup/rollup-darwin-x64": "4.40.0", - "@rollup/rollup-freebsd-arm64": "4.40.0", - "@rollup/rollup-freebsd-x64": "4.40.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", - "@rollup/rollup-linux-arm-musleabihf": "4.40.0", - "@rollup/rollup-linux-arm64-gnu": "4.40.0", - "@rollup/rollup-linux-arm64-musl": "4.40.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-musl": "4.40.0", - "@rollup/rollup-linux-s390x-gnu": "4.40.0", - "@rollup/rollup-linux-x64-gnu": "4.40.0", - "@rollup/rollup-linux-x64-musl": "4.40.0", - "@rollup/rollup-win32-arm64-msvc": "4.40.0", - "@rollup/rollup-win32-ia32-msvc": "4.40.0", - "@rollup/rollup-win32-x64-msvc": "4.40.0", + "@rollup/rollup-android-arm-eabi": "4.41.1", + "@rollup/rollup-android-arm64": "4.41.1", + "@rollup/rollup-darwin-arm64": "4.41.1", + "@rollup/rollup-darwin-x64": "4.41.1", + "@rollup/rollup-freebsd-arm64": "4.41.1", + "@rollup/rollup-freebsd-x64": "4.41.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", + "@rollup/rollup-linux-arm-musleabihf": "4.41.1", + "@rollup/rollup-linux-arm64-gnu": "4.41.1", + "@rollup/rollup-linux-arm64-musl": "4.41.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-musl": "4.41.1", + "@rollup/rollup-linux-s390x-gnu": "4.41.1", + "@rollup/rollup-linux-x64-gnu": "4.41.1", + "@rollup/rollup-linux-x64-musl": "4.41.1", + "@rollup/rollup-win32-arm64-msvc": "4.41.1", + "@rollup/rollup-win32-ia32-msvc": "4.41.1", + "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" } }, @@ -15093,9 +15218,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -15565,6 +15690,20 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -16020,9 +16159,9 @@ } }, "node_modules/svelte": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.27.0.tgz", - "integrity": "sha512-Uai13Ydt1ZE+bUHme6b9U38PCYVNCqBRoBMkUKbFbKiD7kHWjdUUrklYAQZJxyKK81qII4mrBwe/YmvEMSlC9w==", + "version": "5.33.10", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.10.tgz", + "integrity": "sha512-/yArPQIBoQS2p86LKnvJywOXkVHeEXnFgrDPSxkEfIAEkykopYuy2bF6UUqHG4IbZlJD6OurLxJT8Kn7kTk9WA==", "license": "MIT", "peer": true, "dependencies": { @@ -16334,9 +16473,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16351,9 +16490,9 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", + "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -16614,12 +16753,11 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16870,9 +17008,9 @@ } }, "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", - "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -17140,9 +17278,9 @@ } }, "node_modules/vite": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", - "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17215,9 +17353,9 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", + "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -17258,17 +17396,17 @@ } }, "node_modules/vue": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", - "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz", + "integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-dom": "3.5.13", - "@vue/compiler-sfc": "3.5.13", - "@vue/runtime-dom": "3.5.13", - "@vue/server-renderer": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-dom": "3.5.16", + "@vue/compiler-sfc": "3.5.16", + "@vue/runtime-dom": "3.5.16", + "@vue/server-renderer": "3.5.16", + "@vue/shared": "3.5.16" }, "peerDependencies": { "typescript": "*" @@ -17627,15 +17765,15 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } }, "node_modules/yargs": { @@ -17731,9 +17869,9 @@ "peer": true }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.25.42", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.42.tgz", + "integrity": "sha512-PcALTLskaucbeHc41tU/xfjfhcz8z0GdhhDcSgrCTmSazUuqnYqiXO63M0QUBVwpBlsLsNVn5qHSC5Dw3KZvaQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 339fdfe3..1724865d 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -26,7 +26,7 @@ "test-e2e:report": "playwright show-report", "test-e2e:single": "npm run generate-api && playwright test -g", "lint": "eslint \"src/**/*.{ts,tsx}\" --fix --no-warn-ignored", - "lint:check": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored", + "lint:check": "npm run typecheck && eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "prepare": "cd ../.. && husky install", @@ -67,12 +67,14 @@ "postcss": "^8.4.47", "prettier": "^3.4.2", "tailwindcss": "^3.4.14", + "typescript": "~5.5.0", "vite": "^6.3.4" }, "keywords": [], "license": "Apache-2.0", "lint-staged": { "src/**/*.{ts,tsx}": [ + "bash -c 'npm run typecheck'", "eslint --fix --max-warnings 0 --no-warn-ignored", "prettier --write" ], diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 78cd5ed5..d312764b 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { IpcRendererEvent } from 'electron'; -import { openSharedSessionFromDeepLink } from './sessionLinks'; +import { openSharedSessionFromDeepLink, type SessionLinksViewOptions } from './sessionLinks'; +import { type SharedSessionDetails } from './sharedSessions'; import { initializeSystem } from './utils/providerUtils'; import { ErrorUI } from './components/ErrorBoundary'; import { ConfirmationModal } from './components/ui/ConfirmationModal'; @@ -8,25 +9,25 @@ import { ToastContainer } from 'react-toastify'; import { toastService } from './toasts'; import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; +import { type ExtensionConfig } from './extensions'; +import { type Recipe } from './recipe'; import ChatView from './components/ChatView'; import SuspenseLoader from './suspense-loader'; -import { type SettingsViewOptions } from './components/settings/SettingsView'; -import SettingsViewV2 from './components/settings_v2/SettingsView'; -import MoreModelsView from './components/settings/models/MoreModelsView'; -import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView'; +import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; -import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage'; +import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import RecipeEditor from './components/RecipeEditor'; import { useChat } from './hooks/useChat'; import 'react-toastify/dist/ReactToastify.css'; import { useConfig, MalformedConfigError } from './components/ConfigContext'; -import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings_v2/extensions'; +import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; +import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions'; import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen'; -import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting'; +import PermissionSettingsView from './components/settings/permission/PermissionSetting'; import { type SessionDetails } from './sessions'; @@ -46,10 +47,28 @@ export type View = | 'recipeEditor' | 'permission'; -export type ViewOptions = - | SettingsViewOptions - | { resumedSession?: SessionDetails } - | Record; +export type ViewOptions = { + // Settings view options + extensionId?: string; + showEnvVars?: boolean; + deepLinkConfig?: ExtensionConfig; + + // Session view options + resumedSession?: SessionDetails; + sessionDetails?: SessionDetails; + error?: string; + shareToken?: string; + baseUrl?: string; + + // Recipe editor options + config?: unknown; + + // Permission view options + parentView?: View; + + // Generic options + [key: string]: unknown; +}; export type ViewConfig = { view: View; @@ -103,7 +122,7 @@ export default function App() { return `${cmd} ${args.join(' ')}`.trim(); } - function extractRemoteUrl(link: string): string { + function extractRemoteUrl(link: string): string | null { const url = new URL(link); return url.searchParams.get('url'); } @@ -164,7 +183,7 @@ export default function App() { if (provider && model) { setView('chat'); try { - await initializeSystem(provider, model, { + await initializeSystem(provider as string, model as string, { getExtensions, addExtension, }); @@ -218,12 +237,18 @@ export default function App() { }, []); useEffect(() => { - const handleOpenSharedSession = async (_event: IpcRendererEvent, link: string) => { + const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; window.electron.logInfo(`Opening shared session from deep link ${link}`); setIsLoadingSharedSession(true); setSharedSessionError(null); try { - await openSharedSessionFromDeepLink(link, setView); + await openSharedSessionFromDeepLink( + link, + (view: View, options?: SessionLinksViewOptions) => { + setView(view, options as ViewOptions); + } + ); } catch (error) { console.error('Unexpected error opening shared session:', error); setView('sessions'); @@ -260,7 +285,8 @@ export default function App() { useEffect(() => { console.log('Setting up fatal error handler'); - const handleFatalError = (_event: IpcRendererEvent, errorMessage: string) => { + const handleFatalError = (_event: IpcRendererEvent, ...args: unknown[]) => { + const errorMessage = args[0] as string; console.error('Encountered a fatal error: ', errorMessage); console.error('Current view:', view); console.error('Is loading session:', isLoadingSession); @@ -274,7 +300,8 @@ export default function App() { useEffect(() => { console.log('Setting up view change handler'); - const handleSetView = (_event: IpcRendererEvent, newView: View) => { + const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => { + const newView = args[0] as View; console.log(`Received view change request to: ${newView}`); setView(newView); }; @@ -289,7 +316,7 @@ export default function App() { }; setView(viewFromUrl, initialViewOptions); } else { - setView(viewFromUrl); + setView(viewFromUrl as View); } } window.electron.on('set-view', handleSetView); @@ -309,7 +336,8 @@ export default function App() { useEffect(() => { console.log('Setting up extension handler'); - const handleAddExtension = async (_event: IpcRendererEvent, link: string) => { + const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; try { console.log(`Received add-extension event with link: ${link}`); const command = extractCommand(link); @@ -382,7 +410,7 @@ export default function App() { }, [STRICT_ALLOWLIST]); useEffect(() => { - const handleFocusInput = (_event: IpcRendererEvent) => { + const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; if (inputField) { inputField.focus(); @@ -399,7 +427,9 @@ export default function App() { console.log(`Confirming installation of extension from: ${pendingLink}`); setModalVisible(false); try { - await addExtensionFromDeepLinkV2(pendingLink, addExtension, setView); + await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { + setView(view as View, options as ViewOptions); + }); console.log('Extension installation successful'); } catch (error) { console.error('Failed to add extension:', error); @@ -430,7 +460,7 @@ export default function App() { ); return ( - <> + @@ -464,7 +494,7 @@ export default function App() { setView('chat')} isOnboarding={true} /> )} {view === 'settings' && ( - { setView('chat'); }} @@ -472,21 +502,6 @@ export default function App() { viewOptions={viewOptions as SettingsViewOptions} /> )} - {view === 'moreModels' && ( - { - setView('settings'); - }} - setView={setView} - /> - )} - {view === 'configureProviders' && ( - { - setView('settings'); - }} - /> - )} {view === 'ConfigureProviders' && ( setView('chat')} isOnboarding={false} /> )} @@ -503,7 +518,9 @@ export default function App() { {view === 'schedules' && setView('chat')} />} {view === 'sharedSession' && ( setView('sessions')} @@ -513,7 +530,9 @@ export default function App() { try { await openSharedSessionFromDeepLink( `goose://sessions/${viewOptions.shareToken}`, - setView, + (view: View, options?: SessionLinksViewOptions) => { + setView(view, options as ViewOptions); + }, viewOptions.baseUrl ); } catch (error) { @@ -527,7 +546,7 @@ export default function App() { )} {view === 'recipeEditor' && ( )} {view === 'permission' && ( @@ -543,6 +562,6 @@ export default function App() { setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} /> )} - + ); } diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 79472945..7ed369e2 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; +import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -180,6 +180,20 @@ export const updateSchedule = (options: Op }); }; +export const inspectRunningJob = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/schedule/{id}/inspect', + ...options + }); +}; + +export const killRunningJob = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/schedule/{id}/kill', + ...options + }); +}; + export const pauseSchedule = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/schedule/{id}/pause', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a032086c..424a469b 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -172,6 +172,16 @@ export type ImageContent = { mimeType: string; }; +export type InspectJobResponse = { + processStartTime?: string | null; + runningDurationSeconds?: number | null; + sessionId?: string | null; +}; + +export type KillJobResponse = { + message: string; +}; + export type ListSchedulesResponse = { jobs: Array; }; @@ -304,10 +314,12 @@ export type RunNowResponse = { export type ScheduledJob = { cron: string; + current_session_id?: string | null; currently_running?: boolean; id: string; last_run?: string | null; paused?: boolean; + process_start_time?: string | null; source: string; }; @@ -1004,6 +1016,54 @@ export type UpdateScheduleResponses = { export type UpdateScheduleResponse = UpdateScheduleResponses[keyof UpdateScheduleResponses]; +export type InspectRunningJobData = { + body?: never; + path: { + /** + * ID of the schedule to inspect + */ + id: string; + }; + query?: never; + url: '/schedule/{id}/inspect'; +}; + +export type InspectRunningJobErrors = { + /** + * Scheduled job not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type InspectRunningJobResponses = { + /** + * Running job information + */ + 200: InspectJobResponse; +}; + +export type InspectRunningJobResponse = InspectRunningJobResponses[keyof InspectRunningJobResponses]; + +export type KillRunningJobData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/schedule/{id}/kill'; +}; + +export type KillRunningJobResponses = { + /** + * Running job killed successfully + */ + 200: unknown; +}; + export type PauseScheduleData = { body?: never; path: { diff --git a/ui/desktop/src/components/AgentHeader.tsx b/ui/desktop/src/components/AgentHeader.tsx index 35f33a6b..671c9141 100644 --- a/ui/desktop/src/components/AgentHeader.tsx +++ b/ui/desktop/src/components/AgentHeader.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - interface AgentHeaderProps { title: string; profileInfo?: string; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 8767f9a5..09cb2ade 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useState, useEffect, useMemo } from 'react'; import { Button } from './ui/button'; import type { View } from '../App'; import Stop from './ui/Stop'; @@ -94,11 +94,7 @@ export default function ChatInput({ // Set the image to loading state setPastedImages((prev) => - prev.map((img) => - img.id === imageId - ? { ...img, isLoading: true, error: undefined } - : img - ) + prev.map((img) => (img.id === imageId ? { ...img, isLoading: true, error: undefined } : img)) ); try { @@ -148,21 +144,22 @@ export default function ChatInput({ }, [droppedFiles, processedFilePaths, displayValue]); // Debounced function to update actual value - const debouncedSetValue = useCallback((val: string) => { - debounce((value: string) => { - setValue(value); - }, 150)(val); - }, []); + const debouncedSetValue = useMemo( + () => + debounce((value: string) => { + setValue(value); + }, 150), + [setValue] + ); // Debounced autosize function - const debouncedAutosize = useCallback( - (textArea: HTMLTextAreaElement) => { + const debouncedAutosize = useMemo( + () => debounce((element: HTMLTextAreaElement) => { element.style.height = '0px'; // Reset height const scrollHeight = element.scrollHeight; element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 150)(textArea); - }, + }, 150), [maxHeight] ); @@ -180,10 +177,10 @@ export default function ChatInput({ const handlePaste = async (evt: React.ClipboardEvent) => { const files = Array.from(evt.clipboardData.files || []); - const imageFiles = files.filter(file => file.type.startsWith('image/')); - + const imageFiles = files.filter((file) => file.type.startsWith('image/')); + if (imageFiles.length === 0) return; - + // Check if adding these images would exceed the limit if (pastedImages.length + imageFiles.length > MAX_IMAGES_PER_MESSAGE) { // Show error message to user @@ -193,20 +190,20 @@ export default function ChatInput({ id: `error-${Date.now()}`, dataUrl: '', isLoading: false, - error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed.` - } + error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed.`, + }, ]); - + // Remove the error message after 3 seconds setTimeout(() => { - setPastedImages((prev) => prev.filter(img => !img.id.startsWith('error-'))); + setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-'))); }, 3000); - + return; } - + evt.preventDefault(); - + for (const file of imageFiles) { // Check individual file size before processing if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) { @@ -217,18 +214,18 @@ export default function ChatInput({ id: errorId, dataUrl: '', isLoading: false, - error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.` - } + error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`, + }, ]); - + // Remove the error message after 3 seconds setTimeout(() => { - setPastedImages((prev) => prev.filter(img => img.id !== errorId)); + setPastedImages((prev) => prev.filter((img) => img.id !== errorId)); }, 3000); - + continue; } - + const reader = new FileReader(); reader.onload = async (e) => { const dataUrl = e.target?.result as string; @@ -366,7 +363,9 @@ export default function ChatInput({ LocalMessageStorage.addMessage(validPastedImageFilesPaths.join(' ')); } - handleSubmit(new CustomEvent('submit', { detail: { value: textToSend } })); + handleSubmit( + new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent + ); setDisplayValue(''); setValue(''); @@ -503,7 +502,7 @@ export default function ChatInput({ className="absolute -top-1 -right-1 bg-gray-700 hover:bg-red-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs leading-none opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10" aria-label="Remove image" > - + )} diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index ca0fc0b3..82649d15 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -34,6 +34,7 @@ import { ToolResponseMessageContent, ToolConfirmationRequestMessageContent, getTextContent, + TextContent, } from '../types/message'; export interface ChatType { @@ -81,7 +82,6 @@ export default function ChatView({ } function ChatContent({ - readyForAutoUserPrompt, chat, setChat, setView, @@ -102,7 +102,6 @@ function ChatContent({ const [droppedFiles, setDroppedFiles] = useState([]); const scrollRef = useRef(null); - const hasSentPromptRef = useRef(false); const { summaryContent, @@ -148,6 +147,7 @@ function ChatContent({ handleInputChange: _handleInputChange, handleSubmit: _submitMessage, updateMessageStreamBody, + notifications, } = useMessageStream({ api: getApiUrl('/reply'), initialMessages: chat.messages, @@ -244,12 +244,20 @@ function ChatContent({ // Create a new window for the recipe editor console.log('Opening recipe editor with config:', response.recipe); + const recipeConfig = { + id: response.recipe.title || 'untitled', + name: response.recipe.title || 'Untitled Recipe', + description: response.recipe.description || '', + instructions: response.recipe.instructions || '', + activities: response.recipe.activities || [], + prompt: response.recipe.prompt || '', + }; window.electron.createChatWindow( undefined, // query undefined, // dir undefined, // version undefined, // resumeSessionId - response.recipe, // recipe config + recipeConfig, // recipe config 'recipeEditor' // view type ); @@ -272,11 +280,8 @@ function ChatContent({ // Update chat messages when they change and save to sessionStorage useEffect(() => { - setChat((prevChat) => { - const updatedChat = { ...prevChat, messages }; - return updatedChat; - }); - }, [messages, setChat]); + setChat({ ...chat, messages }); + }, [messages, setChat, chat]); useEffect(() => { if (messages.length > 0) { @@ -284,13 +289,10 @@ function ChatContent({ } }, [messages]); - useEffect(() => { - const prompt = recipeConfig?.prompt; - if (prompt && !hasSentPromptRef.current && readyForAutoUserPrompt) { - append(prompt); - hasSentPromptRef.current = true; - } - }, [recipeConfig?.prompt, append, readyForAutoUserPrompt]); + // Pre-fill input with recipe prompt instead of auto-sending it + const initialPrompt = useMemo(() => { + return recipeConfig?.prompt || ''; + }, [recipeConfig?.prompt]); // Handle submit const handleSubmit = (e: React.FormEvent) => { @@ -353,10 +355,11 @@ function ChatContent({ // check if the last message is a real user's message if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) { // Get the text content from the last message before removing it - const textContent = lastMessage.content.find((c) => c.type === 'text')?.text || ''; + const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text'); + const textValue = textContent?.text || ''; // Set the text back to the input field - _setInput(textContent); + _setInput(textValue); // Remove the last user message if it's the most recent one if (messages.length > 1) { @@ -452,7 +455,8 @@ function ChatContent({ return filteredMessages .reduce((history, message) => { if (isUserMessage(message)) { - const text = message.content.find((c) => c.type === 'text')?.text?.trim(); + const textContent = message.content.find((c): c is TextContent => c.type === 'text'); + const text = textContent?.text?.trim(); if (text) { history.push(text); } @@ -467,7 +471,7 @@ function ChatContent({ const fetchSessionTokens = async () => { try { const sessionDetails = await fetchSessionDetails(chat.id); - setSessionTokenCount(sessionDetails.metadata.total_tokens); + setSessionTokenCount(sessionDetails.metadata.total_tokens || 0); } catch (err) { console.error('Error fetching session token count:', err); } @@ -492,6 +496,16 @@ function ChatContent({ const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); }; + + const toolCallNotifications = notifications.reduce((map, item) => { + const key = item.request_id; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(item); + return map; + }, new Map()); + return (
{/* Loader when generating recipe */} @@ -524,7 +538,7 @@ function ChatContent({ {messages.length === 0 ? ( ) : ( @@ -571,6 +585,7 @@ function ChatContent({ const updatedMessages = [...messages, newMessage]; setMessages(updatedMessages); }} + toolCallNotifications={toolCallNotifications} /> )} @@ -578,6 +593,7 @@ function ChatContent({
))} + {error && (
@@ -611,7 +627,7 @@ function ChatContent({ isLoading={isLoading} onStop={onStopGoose} commandHistory={commandHistory} - initialValue={_input} + initialValue={_input || initialPrompt} setView={setView} hasMessages={hasMessages} numTokens={sessionTokenCount} diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 76631ab6..be4fefc9 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -19,7 +19,7 @@ import type { ExtensionQuery, ExtensionConfig, } from '../api/types.gen'; -import { removeShims } from './settings_v2/extensions/utils'; +import { removeShims } from './settings/extensions/utils'; export type { ExtensionConfig } from '../api/types.gen'; @@ -74,7 +74,7 @@ export const ConfigProvider: React.FC = ({ children }) => { const reloadConfig = useCallback(async () => { const response = await readAllConfig(); - setConfig(response.data.config || {}); + setConfig(response.data?.config || {}); }, []); const upsert = useCallback( @@ -148,7 +148,7 @@ export const ConfigProvider: React.FC = ({ children }) => { return extensionsList; } - const extensionResponse: ExtensionResponse = result.data; + const extensionResponse: ExtensionResponse = result.data!; setExtensionsList(extensionResponse.extensions); return extensionResponse.extensions; } @@ -173,8 +173,8 @@ export const ConfigProvider: React.FC = ({ children }) => { async (forceRefresh = false): Promise => { if (forceRefresh || providersList.length === 0) { const response = await providers(); - setProvidersList(response.data); - return response.data; + setProvidersList(response.data || []); + return response.data || []; } return providersList; }, @@ -186,12 +186,12 @@ export const ConfigProvider: React.FC = ({ children }) => { (async () => { // Load config const configResponse = await readAllConfig(); - setConfig(configResponse.data.config || {}); + setConfig(configResponse.data?.config || {}); // Load providers try { const providersResponse = await providers(); - setProvidersList(providersResponse.data); + setProvidersList(providersResponse.data || []); } catch (error) { console.error('Failed to load providers:', error); } @@ -199,7 +199,7 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load extensions try { const extensionsResponse = await apiGetExtensions(); - setExtensionsList(extensionsResponse.data.extensions); + setExtensionsList(extensionsResponse.data?.extensions || []); } catch (error) { console.error('Failed to load extensions:', error); } diff --git a/ui/desktop/src/components/ErrorBoundary.tsx b/ui/desktop/src/components/ErrorBoundary.tsx index eec01f6f..4b80cd32 100644 --- a/ui/desktop/src/components/ErrorBoundary.tsx +++ b/ui/desktop/src/components/ErrorBoundary.tsx @@ -14,7 +14,7 @@ window.addEventListener('error', (event) => { ); }); -export function ErrorUI({ error }) { +export function ErrorUI({ error }: { error: Error }) { return (
@@ -51,7 +51,7 @@ export function ErrorUI({ error }) { export class ErrorBoundary extends React.Component< { children: React.ReactNode }, - { error: Error; hasError: boolean } + { error: Error | null; hasError: boolean } > { constructor(props: { children: React.ReactNode }) { super(props); @@ -69,7 +69,7 @@ export class ErrorBoundary extends React.Component< render() { if (this.state.hasError) { - return ; + return ; } return this.props.children; } diff --git a/ui/desktop/src/components/FlappyGoose.tsx b/ui/desktop/src/components/FlappyGoose.tsx index 3cd8b589..f9b8d54a 100644 --- a/ui/desktop/src/components/FlappyGoose.tsx +++ b/ui/desktop/src/components/FlappyGoose.tsx @@ -1,11 +1,5 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; -declare var requestAnimationFrame: (callback: FrameRequestCallback) => number; -declare class HTMLCanvasElement {} -declare class HTMLImageElement {} -declare class DOMHighResTimeStamp {} -declare class Image {} -declare type FrameRequestCallback = (time: DOMHighResTimeStamp) => void; import svg1 from '../images/loading-goose/1.svg'; import svg7 from '../images/loading-goose/7.svg'; @@ -20,9 +14,11 @@ interface FlappyGooseProps { } const FlappyGoose: React.FC = ({ onClose }) => { - const canvasRef = useRef(null); + // eslint-disable-next-line no-undef + const canvasRef = useRef(null); const [gameOver, setGameOver] = useState(false); const [displayScore, setDisplayScore] = useState(0); + // eslint-disable-next-line no-undef const gooseImages = useRef([]); const framesLoaded = useRef(0); const [imagesReady, setImagesReady] = useState(false); @@ -51,7 +47,7 @@ const FlappyGoose: React.FC = ({ onClose }) => { const OBSTACLE_WIDTH = 40; const FLAP_DURATION = 150; - const safeRequestAnimationFrame = useCallback((callback: FrameRequestCallback) => { + const safeRequestAnimationFrame = useCallback((callback: (time: number) => void) => { if (typeof window !== 'undefined' && typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(callback); } @@ -216,7 +212,8 @@ const FlappyGoose: React.FC = ({ onClose }) => { useEffect(() => { const frames = [svg1, svg7]; frames.forEach((src, index) => { - const img = new Image(); + // eslint-disable-next-line no-undef + const img = new Image() as HTMLImageElement; img.src = src; img.onload = () => { framesLoaded.current += 1; @@ -272,7 +269,9 @@ const FlappyGoose: React.FC = ({ onClose }) => { onClick={flap} > { + canvasRef.current = el; + }} style={{ border: '2px solid #333', borderRadius: '8px', diff --git a/ui/desktop/src/components/GooseLogo.tsx b/ui/desktop/src/components/GooseLogo.tsx index 1df25c62..bdce688e 100644 --- a/ui/desktop/src/components/GooseLogo.tsx +++ b/ui/desktop/src/components/GooseLogo.tsx @@ -1,7 +1,16 @@ -import React from 'react'; import { Goose, Rain } from './icons/Goose'; -export default function GooseLogo({ className = '', size = 'default', hover = true }) { +interface GooseLogoProps { + className?: string; + size?: 'default' | 'small'; + hover?: boolean; +} + +export default function GooseLogo({ + className = '', + size = 'default', + hover = true, +}: GooseLogoProps) { const sizes = { default: { frame: 'w-16 h-16', @@ -13,15 +22,18 @@ export default function GooseLogo({ className = '', size = 'default', hover = tr rain: 'w-[150px] h-[150px]', goose: 'w-8 h-8', }, - }; + } as const; + + const currentSize = sizes[size]; + return (
- +
); } diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 79f62b97..f001edcc 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import LinkPreview from './LinkPreview'; import ImagePreview from './ImagePreview'; import GooseResponseForm from './GooseResponseForm'; @@ -17,6 +17,7 @@ import { } from '../types/message'; import ToolCallConfirmation from './ToolCallConfirmation'; import MessageCopyLink from './MessageCopyLink'; +import { NotificationEvent } from '../hooks/useMessageStream'; interface GooseMessageProps { // messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests @@ -25,6 +26,7 @@ interface GooseMessageProps { message: Message; messages: Message[]; metadata?: string[]; + toolCallNotifications: Map; append: (value: string) => void; appendMessage: (message: Message) => void; } @@ -34,6 +36,7 @@ export default function GooseMessage({ message, metadata, messages, + toolCallNotifications, append, appendMessage, }: GooseMessageProps) { @@ -158,6 +161,7 @@ export default function GooseMessage({ } toolRequest={toolRequest} toolResponse={toolResponsesMap.get(toolRequest.id)} + notifications={toolCallNotifications.get(toolRequest.id)} />
))} @@ -190,7 +194,7 @@ export default function GooseMessage({ {/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */} {false && metadata && (
- +
)}
diff --git a/ui/desktop/src/components/GooseResponseForm.tsx b/ui/desktop/src/components/GooseResponseForm.tsx index 7c76d53e..621ae517 100644 --- a/ui/desktop/src/components/GooseResponseForm.tsx +++ b/ui/desktop/src/components/GooseResponseForm.tsx @@ -132,9 +132,14 @@ export default function GooseResponseForm({ return null; } - function isForm(f: DynamicForm) { + function isForm(f: DynamicForm | null): f is DynamicForm { return ( - f && f.title && f.description && f.fields && Array.isArray(f.fields) && f.fields.length > 0 + !!f && + !!f.title && + !!f.description && + !!f.fields && + Array.isArray(f.fields) && + f.fields.length > 0 ); } diff --git a/ui/desktop/src/components/GoosehintsModal.tsx b/ui/desktop/src/components/GoosehintsModal.tsx index 8f78e886..f7cab002 100644 --- a/ui/desktop/src/components/GoosehintsModal.tsx +++ b/ui/desktop/src/components/GoosehintsModal.tsx @@ -3,7 +3,7 @@ import { Card } from './ui/card'; import { Button } from './ui/button'; import { Check } from './icons'; -const Modal = ({ children }) => ( +const Modal = ({ children }: { children: React.ReactNode }) => (
@@ -48,13 +48,13 @@ const ModalHelpText = () => (
); -const ModalError = ({ error }) => ( +const ModalError = ({ error }: { error: Error }) => (
Error reading .goosehints file: {JSON.stringify(error)}
); -const ModalFileInfo = ({ filePath, found }) => ( +const ModalFileInfo = ({ filePath, found }: { filePath: string; found: boolean }) => (
{found ? (
@@ -66,7 +66,7 @@ const ModalFileInfo = ({ filePath, found }) => (
); -const ModalButtons = ({ onSubmit, onCancel }) => ( +const ModalButtons = ({ onSubmit, onCancel }: { onSubmit: () => void; onCancel: () => void }) => (
); -const getGoosehintsFile = async (filePath) => await window.electron.readFile(filePath); +const getGoosehintsFile = async (filePath: string) => await window.electron.readFile(filePath); type GoosehintsModalProps = { directory: string; @@ -96,9 +96,9 @@ type GoosehintsModalProps = { export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: GoosehintsModalProps) => { const goosehintsFilePath = `${directory}/.goosehints`; - const [goosehintsFile, setGoosehintsFile] = useState(null); + const [goosehintsFile, setGoosehintsFile] = useState(''); const [goosehintsFileFound, setGoosehintsFileFound] = useState(false); - const [goosehintsFileReadError, setGoosehintsFileReadError] = useState(null); + const [goosehintsFileReadError, setGoosehintsFileReadError] = useState(''); useEffect(() => { const fetchGoosehintsFile = async () => { @@ -106,7 +106,7 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi const { file, error, found } = await getGoosehintsFile(goosehintsFilePath); setGoosehintsFile(file); setGoosehintsFileFound(found); - setGoosehintsFileReadError(error); + setGoosehintsFileReadError(error || ''); } catch (error) { console.error('Error fetching .goosehints file:', error); } @@ -125,7 +125,7 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi
{goosehintsFileReadError ? ( - + ) : (
diff --git a/ui/desktop/src/components/ImagePreview.tsx b/ui/desktop/src/components/ImagePreview.tsx index 7e6d66f5..29e0aff3 100644 --- a/ui/desktop/src/components/ImagePreview.tsx +++ b/ui/desktop/src/components/ImagePreview.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; interface ImagePreviewProps { src: string; diff --git a/ui/desktop/src/components/LayingEggLoader.tsx b/ui/desktop/src/components/LayingEggLoader.tsx index 3d96947f..faa4f806 100644 --- a/ui/desktop/src/components/LayingEggLoader.tsx +++ b/ui/desktop/src/components/LayingEggLoader.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Geese } from './icons/Geese'; export default function LayingEggLoader() { diff --git a/ui/desktop/src/components/LinkPreview.tsx b/ui/desktop/src/components/LinkPreview.tsx index 71b33417..355d9154 100644 --- a/ui/desktop/src/components/LinkPreview.tsx +++ b/ui/desktop/src/components/LinkPreview.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card } from './ui/card'; interface Metadata { @@ -54,9 +54,9 @@ async function fetchMetadata(url: string): Promise { return { title: title || url, - description, + description: description || undefined, favicon, - image, + image: image || undefined, url, }; } catch (error) { @@ -85,10 +85,11 @@ export default function LinkPreview({ url }: LinkPreviewProps) { if (mounted) { setMetadata(data); } - } catch (error) { + } catch (err) { if (mounted) { - console.error('❌ Failed to fetch metadata:', error); - setError(error.message || 'Failed to fetch metadata'); + console.error('❌ Failed to fetch metadata:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch metadata'; + setError(errorMessage); } } finally { if (mounted) { diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index 38677d18..e3c623f2 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import GooseLogo from './GooseLogo'; const LoadingGoose = () => { diff --git a/ui/desktop/src/components/LoadingPlaceholder.tsx b/ui/desktop/src/components/LoadingPlaceholder.tsx index 2151fc25..248688cd 100644 --- a/ui/desktop/src/components/LoadingPlaceholder.tsx +++ b/ui/desktop/src/components/LoadingPlaceholder.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - export function LoadingPlaceholder() { return (
diff --git a/ui/desktop/src/components/ModelAndProviderContext.tsx b/ui/desktop/src/components/ModelAndProviderContext.tsx new file mode 100644 index 00000000..729467a6 --- /dev/null +++ b/ui/desktop/src/components/ModelAndProviderContext.tsx @@ -0,0 +1,190 @@ +import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { initializeAgent } from '../agent'; +import { toastError, toastSuccess } from '../toasts'; +import Model, { getProviderMetadata } from './settings/models/modelInterface'; +import { ProviderMetadata } from '../api'; +import { useConfig } from './ConfigContext'; + +// titles +export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup'; + +// errors +const CHANGE_MODEL_ERROR_TITLE = 'Change failed'; +const SWITCH_MODEL_AGENT_ERROR_MSG = + 'Failed to start agent with selected model -- please try again'; +const CONFIG_UPDATE_ERROR_MSG = 'Failed to update configuration settings -- please try again'; +export const UNKNOWN_PROVIDER_MSG = 'Unknown provider in config -- please inspect your config.yaml'; + +// success +const CHANGE_MODEL_TOAST_TITLE = 'Model changed'; +const SWITCH_MODEL_SUCCESS_MSG = 'Successfully switched models'; + +interface ModelAndProviderContextType { + currentModel: string | null; + currentProvider: string | null; + changeModel: (model: Model) => Promise; + getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>; + getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>; + getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>; + refreshCurrentModelAndProvider: () => Promise; +} + +interface ModelAndProviderProviderProps { + children: React.ReactNode; +} + +const ModelAndProviderContext = createContext(undefined); + +export const ModelAndProviderProvider: React.FC = ({ children }) => { + const [currentModel, setCurrentModel] = useState(null); + const [currentProvider, setCurrentProvider] = useState(null); + const { read, upsert, getProviders } = useConfig(); + + const changeModel = useCallback( + async (model: Model) => { + const modelName = model.name; + const providerName = model.provider; + try { + await initializeAgent({ + model: model.name, + provider: model.provider, + }); + } catch (error) { + console.error(`Failed to change model at agent step -- ${modelName} ${providerName}`); + toastError({ + title: CHANGE_MODEL_ERROR_TITLE, + msg: SWITCH_MODEL_AGENT_ERROR_MSG, + traceback: error instanceof Error ? error.message : String(error), + }); + // don't write to config + return; + } + + try { + await upsert('GOOSE_PROVIDER', providerName, false); + await upsert('GOOSE_MODEL', modelName, false); + + // Update local state + setCurrentProvider(providerName); + setCurrentModel(modelName); + } catch (error) { + console.error(`Failed to change model at config step -- ${modelName} ${providerName}}`); + toastError({ + title: CHANGE_MODEL_ERROR_TITLE, + msg: CONFIG_UPDATE_ERROR_MSG, + traceback: error instanceof Error ? error.message : String(error), + }); + // agent and config will be out of sync at this point + // TODO: reset agent to use current config settings + } finally { + // show toast + toastSuccess({ + title: CHANGE_MODEL_TOAST_TITLE, + msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model.alias ?? modelName} from ${model.subtext ?? providerName}`, + }); + } + }, + [upsert] + ); + + const getFallbackModelAndProvider = useCallback(async () => { + const provider = window.appConfig.get('GOOSE_DEFAULT_PROVIDER') as string; + const model = window.appConfig.get('GOOSE_DEFAULT_MODEL') as string; + if (provider && model) { + try { + await upsert('GOOSE_MODEL', model, false); + await upsert('GOOSE_PROVIDER', provider, false); + } catch (error) { + console.error('[getFallbackModelAndProvider] Failed to write to config', error); + } + } + return { model: model, provider: provider }; + }, [upsert]); + + const getCurrentModelAndProvider = useCallback(async () => { + let model: string; + let provider: string; + + // read from config + try { + model = (await read('GOOSE_MODEL', false)) as string; + provider = (await read('GOOSE_PROVIDER', false)) as string; + } catch (error) { + console.error(`Failed to read GOOSE_MODEL or GOOSE_PROVIDER from config`); + throw error; + } + if (!model || !provider) { + console.log('[getCurrentModelAndProvider] Checking app environment as fallback'); + return getFallbackModelAndProvider(); + } + return { model: model, provider: provider }; + }, [read, getFallbackModelAndProvider]); + + const getCurrentModelAndProviderForDisplay = useCallback(async () => { + const modelProvider = await getCurrentModelAndProvider(); + const gooseModel = modelProvider.model; + const gooseProvider = modelProvider.provider; + + // lookup display name + let metadata: ProviderMetadata; + + try { + metadata = await getProviderMetadata(String(gooseProvider), getProviders); + } catch (error) { + return { model: gooseModel, provider: gooseProvider }; + } + const providerDisplayName = metadata.display_name; + + return { model: gooseModel, provider: providerDisplayName }; + }, [getCurrentModelAndProvider, getProviders]); + + const refreshCurrentModelAndProvider = useCallback(async () => { + try { + const { model, provider } = await getCurrentModelAndProvider(); + setCurrentModel(model); + setCurrentProvider(provider); + } catch (error) { + console.error('Failed to refresh current model and provider:', error); + } + }, [getCurrentModelAndProvider]); + + // Load initial model and provider on mount + useEffect(() => { + refreshCurrentModelAndProvider(); + }, [refreshCurrentModelAndProvider]); + + const contextValue = useMemo( + () => ({ + currentModel, + currentProvider, + changeModel, + getCurrentModelAndProvider, + getFallbackModelAndProvider, + getCurrentModelAndProviderForDisplay, + refreshCurrentModelAndProvider, + }), + [ + currentModel, + currentProvider, + changeModel, + getCurrentModelAndProvider, + getFallbackModelAndProvider, + getCurrentModelAndProviderForDisplay, + refreshCurrentModelAndProvider, + ] + ); + + return ( + + {children} + + ); +}; + +export const useModelAndProvider = () => { + const context = useContext(ModelAndProviderContext); + if (context === undefined) { + throw new Error('useModelAndProvider must be used within a ModelAndProviderProvider'); + } + return context; +}; diff --git a/ui/desktop/src/components/ProviderGrid.tsx b/ui/desktop/src/components/ProviderGrid.tsx deleted file mode 100644 index 9ad66b9d..00000000 --- a/ui/desktop/src/components/ProviderGrid.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React from 'react'; -import { - supported_providers, - required_keys, - provider_aliases, -} from './settings/models/hardcoded_stuff'; -import { useActiveKeys } from './settings/api_keys/ActiveKeysContext'; -import { ProviderSetupModal } from './settings/ProviderSetupModal'; -import { useModel } from './settings/models/ModelContext'; -import { useRecentModels } from './settings/models/RecentModels'; -import { createSelectedModel } from './settings/models/utils'; -import { getDefaultModel } from './settings/models/hardcoded_stuff'; -import { initializeSystem } from '../utils/providerUtils'; -import { getApiUrl, getSecretKey } from '../config'; -import { getActiveProviders, isSecretKey } from './settings/api_keys/utils'; -import { BaseProviderGrid, getProviderDescription } from './settings/providers/BaseProviderGrid'; -import { toastError, toastSuccess } from '../toasts'; - -interface ProviderGridProps { - onSubmit?: () => void; -} - -export function ProviderGrid({ onSubmit }: ProviderGridProps) { - const { activeKeys, setActiveKeys } = useActiveKeys(); - const [selectedId, setSelectedId] = React.useState(null); - const [showSetupModal, setShowSetupModal] = React.useState(false); - const { switchModel } = useModel(); - const { addRecentModel } = useRecentModels(); - - const providers = React.useMemo(() => { - return supported_providers.map((providerName) => { - const alias = - provider_aliases.find((p) => p.provider === providerName)?.alias || - providerName.toLowerCase(); - const isConfigured = activeKeys.includes(providerName); - - return { - id: alias, - name: providerName, - isConfigured, - description: getProviderDescription(providerName), - }; - }); - }, [activeKeys]); - - const handleConfigure = async (provider) => { - const providerId = provider.id.toLowerCase(); - - const modelName = getDefaultModel(providerId); - const model = createSelectedModel(providerId, modelName); - - await initializeSystem(providerId, model.name); - - switchModel(model); - addRecentModel(model); - localStorage.setItem('GOOSE_PROVIDER', providerId); - - toastSuccess({ - title: provider.name, - msg: `Starting Goose with default model: ${getDefaultModel(provider.name.toLowerCase().replace(/ /g, '_'))}.`, - }); - - onSubmit?.(); - }; - - const handleAddKeys = (provider) => { - setSelectedId(provider.id); - setShowSetupModal(true); - }; - - const handleModalSubmit = async (configValues: { [key: string]: string }) => { - if (!selectedId) return; - - const provider = providers.find((p) => p.id === selectedId)?.name; - if (!provider) return; - - const requiredKeys = required_keys[provider]; - if (!requiredKeys || requiredKeys.length === 0) { - console.error(`No keys found for provider ${provider}`); - return; - } - - try { - // Delete existing keys if provider is already configured - const isUpdate = providers.find((p) => p.id === selectedId)?.isConfigured; - if (isUpdate) { - for (const keyName of requiredKeys) { - const isSecret = isSecretKey(keyName); - const deleteResponse = await fetch(getApiUrl('/configs/delete'), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: keyName, - isSecret, - }), - }); - - if (!deleteResponse.ok) { - const errorText = await deleteResponse.text(); - console.error('Delete response error:', errorText); - throw new Error(`Failed to delete old key: ${keyName}`); - } - } - } - - // Store new keys - for (const keyName of requiredKeys) { - const value = configValues[keyName]; - if (!value) { - console.error(`Missing value for required key: ${keyName}`); - continue; - } - - const isSecret = isSecretKey(keyName); - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: keyName, - value: value, - isSecret, - }), - }); - - if (!storeResponse.ok) { - const errorText = await storeResponse.text(); - console.error('Store response error:', errorText); - throw new Error(`Failed to store new key: ${keyName}`); - } - } - - toastSuccess({ - title: provider, - msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`, - }); - - const updatedKeys = await getActiveProviders(); - setActiveKeys(updatedKeys); - - setShowSetupModal(false); - setSelectedId(null); - } catch (error) { - console.error('Error handling modal submit:', error); - toastError({ - title: provider, - msg: `Failed to ${providers.find((p) => p.id === selectedId)?.isConfigured ? 'update' : 'add'} configuration`, - traceback: error.message, - }); - } - }; - - const handleSelect = (providerId: string) => { - setSelectedId(selectedId === providerId ? null : providerId); - }; - - // Add useEffect for Esc key handling - React.useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setSelectedId(null); - } - }; - window.addEventListener('keydown', handleEsc); - return () => { - window.removeEventListener('keydown', handleEsc); - }; - }, []); - - return ( -
- { - handleConfigure(provider); - }} - /> - - {showSetupModal && selectedId && ( -
- p.id === selectedId)?.name} - model="Example Model" - endpoint="Example Endpoint" - onSubmit={handleModalSubmit} - onCancel={() => { - setShowSetupModal(false); - setSelectedId(null); - }} - /> -
- )} -
- ); -} diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index efbb85d2..676f44bc 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -10,7 +10,7 @@ import { FixedExtensionEntry } from './ConfigContext'; import RecipeActivityEditor from './RecipeActivityEditor'; import RecipeInfoModal from './RecipeInfoModal'; import RecipeExpandableInfo from './RecipeExpandableInfo'; -// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList'; +import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal'; interface RecipeEditorProps { config?: Recipe; @@ -34,6 +34,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { const [extensionsLoaded, setExtensionsLoaded] = useState(false); const [copied, setCopied] = useState(false); const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false); + const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{ label: string; value: string; @@ -54,7 +55,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { } } // Fall back to config if available, using extension names - const exts = []; + const exts: string[] = []; return exts; }); // Section visibility state @@ -121,11 +122,14 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { if (!extension) return null; // Create a clean copy of the extension configuration - const cleanExtension = { ...extension }; - delete cleanExtension.enabled; + const { enabled: _enabled, ...cleanExtension } = extension; // Remove legacy envs which could potentially include secrets // env_keys will work but rely on the end user having setup those keys themselves - delete cleanExtension.envs; + if ('envs' in cleanExtension) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { envs: _envs, ...finalExtension } = cleanExtension as any; + return finalExtension; + } return cleanExtension; }) .filter(Boolean) as FullExtensionConfig[], @@ -328,6 +332,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
{/* Action Buttons */}
+
); } diff --git a/ui/desktop/src/components/Splash.tsx b/ui/desktop/src/components/Splash.tsx index 529dc343..ddad10d6 100644 --- a/ui/desktop/src/components/Splash.tsx +++ b/ui/desktop/src/components/Splash.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import SplashPills from './SplashPills'; import GooseLogo from './GooseLogo'; diff --git a/ui/desktop/src/components/SplashPills.tsx b/ui/desktop/src/components/SplashPills.tsx index 83bd9bf1..18506b75 100644 --- a/ui/desktop/src/components/SplashPills.tsx +++ b/ui/desktop/src/components/SplashPills.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import MarkdownContent from './MarkdownContent'; function truncateText(text: string, maxLength: number = 100): string { diff --git a/ui/desktop/src/components/ToolCallArguments.tsx b/ui/desktop/src/components/ToolCallArguments.tsx index 581f1580..b2e31a1f 100644 --- a/ui/desktop/src/components/ToolCallArguments.tsx +++ b/ui/desktop/src/components/ToolCallArguments.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import MarkdownContent from './MarkdownContent'; import Expand from './ui/Expand'; diff --git a/ui/desktop/src/components/ToolCallConfirmation.tsx b/ui/desktop/src/components/ToolCallConfirmation.tsx index 606923a3..cdffaed9 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { snakeToTitleCase } from '../utils'; -import PermissionModal from './settings_v2/permission/PermissionModal'; +import PermissionModal from './settings/permission/PermissionModal'; import { ChevronRight } from 'lucide-react'; import { confirmPermission } from '../api'; diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 721a39de..1bf602ef 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { Card } from './ui/card'; import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments'; import MarkdownContent from './MarkdownContent'; @@ -6,17 +6,20 @@ import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from ' import { snakeToTitleCase } from '../utils'; import Dot, { LoadingStatus } from './ui/Dot'; import Expand from './ui/Expand'; +import { NotificationEvent } from '../hooks/useMessageStream'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; toolRequest: ToolRequestMessageContent; toolResponse?: ToolResponseMessageContent; + notifications?: NotificationEvent[]; } export default function ToolCallWithResponse({ isCancelledMessage, toolRequest, toolResponse, + notifications, }: ToolCallWithResponseProps) { const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; if (!toolCall) { @@ -26,7 +29,7 @@ export default function ToolCallWithResponse({ return (
- +
); @@ -47,8 +50,9 @@ function ToolCallExpandable({ children, className = '', }: ToolCallExpandableProps) { - const [isExpanded, setIsExpanded] = React.useState(isStartExpanded); - const toggleExpand = () => setIsExpanded((prev) => !prev); + const [isExpandedState, setIsExpanded] = React.useState(null); + const isExpanded = isExpandedState === null ? isStartExpanded : isExpandedState; + const toggleExpand = () => setIsExpanded(!isExpanded); React.useEffect(() => { if (isForceExpand) setIsExpanded(true); }, [isForceExpand]); @@ -71,9 +75,42 @@ interface ToolCallViewProps { arguments: Record; }; toolResponse?: ToolResponseMessageContent; + notifications?: NotificationEvent[]; } -function ToolCallView({ isCancelledMessage, toolCall, toolResponse }: ToolCallViewProps) { +interface Progress { + progress: number; + progressToken: string; + total?: number; + message?: string; +} + +const logToString = (logMessage: NotificationEvent) => { + const params = logMessage.message.params; + + // Special case for the developer system shell logs + if ( + params && + params.data && + typeof params.data === 'object' && + 'output' in params.data && + 'stream' in params.data + ) { + return `[${params.data.stream}] ${params.data.output}`; + } + + return typeof params.data === 'string' ? params.data : JSON.stringify(params.data); +}; + +const notificationToProgress = (notification: NotificationEvent): Progress => + notification.message.params as unknown as Progress; + +function ToolCallView({ + isCancelledMessage, + toolCall, + toolResponse, + notifications, +}: ToolCallViewProps) { const responseStyle = localStorage.getItem('response_style'); const isExpandToolDetails = (() => { switch (responseStyle) { @@ -92,7 +129,7 @@ function ToolCallView({ isCancelledMessage, toolCall, toolResponse }: ToolCallVi const toolResults: { result: Content; isExpandToolResults: boolean }[] = loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value) - ? toolResponse.toolResult.value + ? toolResponse!.toolResult.value .filter((item) => { const audience = item.annotations?.audience as string[] | undefined; return !audience || audience.includes('user'); @@ -103,6 +140,29 @@ function ToolCallView({ isCancelledMessage, toolCall, toolResponse }: ToolCallVi })) : []; + const logs = notifications + ?.filter((notification) => notification.message.method === 'notifications/message') + .map(logToString); + + const progress = notifications + ?.filter((notification) => notification.message.method === 'notifications/progress') + .map(notificationToProgress) + .reduce((map, item) => { + const key = item.progressToken; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(item); + return map; + }, new Map()); + + const progressEntries = [...(progress?.values() || [])].map( + (entries) => entries.sort((a, b) => b.progress - a.progress)[0] + ); + + const isRenderingProgress = + loadingStatus === 'loading' && (progressEntries.length > 0 || (logs || []).length > 0); + const isShouldExpand = isExpandToolDetails || toolResults.some((v) => v.isExpandToolResults); // Function to create a compact representation of arguments @@ -136,7 +196,7 @@ function ToolCallView({ isCancelledMessage, toolCall, toolResponse }: ToolCallVi return ( @@ -156,6 +216,24 @@ function ToolCallView({ isCancelledMessage, toolCall, toolResponse }: ToolCallVi
)} + {logs && logs.length > 0 && ( +
+ +
+ )} + + {toolResults.length === 0 && + progressEntries.length > 0 && + progressEntries.map((entry, index) => ( +
+ +
+ ))} + {/* Tool Output */} {!isCancelledMessage && ( <> @@ -234,3 +312,76 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { ); } + +function ToolLogsView({ + logs, + working, + isStartExpanded, +}: { + logs: string[]; + working: boolean; + isStartExpanded?: boolean; +}) { + const boxRef = useRef(null); + + // Whenever logs update, jump to the newest entry + useEffect(() => { + if (boxRef.current) { + boxRef.current.scrollTop = boxRef.current.scrollHeight; + } + }, [logs]); + + return ( + + Logs + {working && ( +
+ +
+ )} + + } + isStartExpanded={isStartExpanded} + > +
+ {logs.map((log, i) => ( + + {log} + + ))} +
+
+ ); +} + +const ProgressBar = ({ progress, total, message }: Omit) => { + const isDeterminate = typeof total === 'number'; + const percent = isDeterminate ? Math.min((progress / total!) * 100, 100) : 0; + + return ( +
+ {message &&
{message}
} + +
+ {isDeterminate ? ( +
+ ) : ( +
+ )} +
+
+ ); +}; diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 4fad212f..a871b5b1 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useMemo } from 'react'; +import { useRef, useMemo } from 'react'; import LinkPreview from './LinkPreview'; import ImagePreview from './ImagePreview'; import { extractUrls } from '../utils/urlUtils'; diff --git a/ui/desktop/src/components/WelcomeGooseLogo.tsx b/ui/desktop/src/components/WelcomeGooseLogo.tsx index 9fb17eb8..b47ab9fa 100644 --- a/ui/desktop/src/components/WelcomeGooseLogo.tsx +++ b/ui/desktop/src/components/WelcomeGooseLogo.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Goose, Rain } from './icons/Goose'; export default function WelcomeGooseLogo({ className = '' }) { diff --git a/ui/desktop/src/components/WelcomeView.tsx b/ui/desktop/src/components/WelcomeView.tsx deleted file mode 100644 index 5003d740..00000000 --- a/ui/desktop/src/components/WelcomeView.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React from 'react'; -import { ProviderGrid } from './ProviderGrid'; -import { ScrollArea } from './ui/scroll-area'; -import { Button } from './ui/button'; -import WelcomeGooseLogo from './WelcomeGooseLogo'; -import MoreMenuLayout from './more_menu/MoreMenuLayout'; - -// Extending React CSSProperties to include custom webkit property -declare module 'react' { - interface CSSProperties { - WebkitAppRegion?: string; - } -} - -interface WelcomeScreenProps { - onSubmit?: () => void; -} - -export default function WelcomeScreen({ onSubmit }: WelcomeScreenProps) { - return ( -
- - - {/* Content area - explicitly set as non-draggable */} -
- -
- {/* Header Section */} -
-
-
- -
-
-

- Welcome to goose -

-

- Your intelligent AI assistant for seamless productivity and creativity. -

-
-
-
- - {/* ProviderGrid */} -
-

- Choose a Provider -

-

- Select an AI model provider to get started with goose. -

-

- Click on a provider to configure its API keys and start using goose. Your keys are - stored securely and encrypted locally. You can change your provider and select - specific models in the settings. -

- -
- - {/* Get started (now less prominent) */} -
-

- Not sure where to start?{' '} - -

-
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/alerts/AlertBox.tsx b/ui/desktop/src/components/alerts/AlertBox.tsx index fa89a5f0..c93f249f 100644 --- a/ui/desktop/src/components/alerts/AlertBox.tsx +++ b/ui/desktop/src/components/alerts/AlertBox.tsx @@ -33,10 +33,10 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => { className={cn( 'h-[2px] w-[2px] rounded-full', alert.type === AlertType.Info - ? i < Math.round((alert.progress.current / alert.progress.total) * 30) + ? i < Math.round((alert.progress!.current / alert.progress!.total) * 30) ? 'dark:bg-black bg-white' : 'dark:bg-black/20 bg-white/20' - : i < Math.round((alert.progress.current / alert.progress.total) * 30) + : i < Math.round((alert.progress!.current / alert.progress!.total) * 30) ? 'bg-white' : 'bg-white/20' )} @@ -46,18 +46,18 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
- {alert.progress.current >= 1000 - ? (alert.progress.current / 1000).toFixed(1) + 'k' - : alert.progress.current} + {alert.progress!.current >= 1000 + ? (alert.progress!.current / 1000).toFixed(1) + 'k' + : alert.progress!.current} - {Math.round((alert.progress.current / alert.progress.total) * 100)}% + {Math.round((alert.progress!.current / alert.progress!.total) * 100)}%
- {alert.progress.total >= 1000 - ? (alert.progress.total / 1000).toFixed(0) + 'k' - : alert.progress.total} + {alert.progress!.total >= 1000 + ? (alert.progress!.total / 1000).toFixed(0) + 'k' + : alert.progress!.total}
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenu.tsx b/ui/desktop/src/components/bottom_menu/BottomMenu.tsx index 43c4714a..587670ec 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenu.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenu.tsx @@ -1,13 +1,12 @@ import { useState, useEffect, useRef } from 'react'; -import { useModel } from '../settings/models/ModelContext'; import { AlertType, useAlerts } from '../alerts'; import { useToolCount } from '../alerts/useToolCount'; import BottomMenuAlertPopover from './BottomMenuAlertPopover'; import type { View, ViewOptions } from '../../App'; import { BottomMenuModeSelection } from './BottomMenuModeSelection'; -import ModelsBottomBar from '../settings_v2/models/bottom_bar/ModelsBottomBar'; +import ModelsBottomBar from '../settings/models/bottom_bar/ModelsBottomBar'; import { useConfig } from '../ConfigContext'; -import { getCurrentModelAndProvider } from '../settings_v2/models'; +import { useModelAndProvider } from '../ModelAndProviderContext'; import { Message } from '../../types/message'; import { ManualSummarizeButton } from '../context_management/ManualSummaryButton'; @@ -34,11 +33,11 @@ export default function BottomMenu({ setMessages: (messages: Message[]) => void; }) { const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); - const { currentModel } = useModel(); const { alerts, addAlert, clearAlerts } = useAlerts(); const dropdownRef = useRef(null); const toolCount = useToolCount(); const { getProviders, read } = useConfig(); + const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider(); const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); @@ -72,7 +71,7 @@ export default function BottomMenu({ setIsTokenLimitLoaded(false); // Get current model and provider first to avoid unnecessary provider fetches - const { model, provider } = await getCurrentModelAndProvider({ readFromConfig: read }); + const { model, provider } = await getCurrentModelAndProvider(); if (!model || !provider) { console.log('No model or provider found'); setIsTokenLimitLoaded(true); @@ -117,7 +116,7 @@ export default function BottomMenu({ useEffect(() => { loadProviderDetails(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentModel]); + }, [currentModel, currentProvider]); // Handle tool count alerts and token usage useEffect(() => { diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx index 092a6c0b..85242085 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { all_goose_modes, ModeSelectionItem } from '../settings_v2/mode/ModeSelectionItem'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem'; import { useConfig } from '../ConfigContext'; import { View, ViewOptions } from '../../App'; import { Orbit } from 'lucide-react'; diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index e4ccf29f..2c47e555 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -69,13 +69,13 @@ export const SearchBar: React.FC = ({ } }, [initialSearchTerm, caseSensitive, debouncedSearchRef]); - const [localSearchResults, setLocalSearchResults] = useState(null); + const [localSearchResults, setLocalSearchResults] = useState(undefined); // Sync external search results with local state useEffect(() => { // Only set results if we have a search term if (!searchTerm) { - setLocalSearchResults(null); + setLocalSearchResults(undefined); } else { setLocalSearchResults(searchResults); } @@ -168,7 +168,7 @@ export const SearchBar: React.FC = ({
{(() => { - return localSearchResults?.count > 0 && searchTerm + return localSearchResults?.count && localSearchResults.count > 0 && searchTerm ? `${localSearchResults.currentIndex}/${localSearchResults.count}` : null; })()} diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index 4f331dab..2570fc5d 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -231,23 +231,19 @@ export const SearchView: React.FC> = ({ highlighterRef.current = null; } - // Cancel any pending highlight operations - debouncedHighlight.cancel?.(); - // Clear search when closing onSearch?.('', false); - }, [debouncedHighlight, onSearch]); + }, [onSearch]); - // Clean up highlighter and debounced functions on unmount + // Clean up highlighter on unmount useEffect(() => { return () => { if (highlighterRef.current) { highlighterRef.current.destroy(); highlighterRef.current = null; } - debouncedHighlight.cancel?.(); }; - }, [debouncedHighlight]); + }, []); // Listen for keyboard events useEffect(() => { @@ -318,7 +314,7 @@ export const SearchView: React.FC> = ({
{ if (el) { - containerRef.current = el; + containerRef.current = el as SearchContainerElement; // Expose the highlighter instance containerRef.current._searchHighlighter = highlighterRef.current; } @@ -330,7 +326,7 @@ export const SearchView: React.FC> = ({ onSearch={handleSearch} onClose={handleCloseSearch} onNavigate={handleNavigate} - searchResults={searchResults || internalSearchResults} + searchResults={searchResults || internalSearchResults || undefined} inputRef={searchInputRef} initialSearchTerm={initialSearchTerm} /> diff --git a/ui/desktop/src/components/icons/ArrowDown.tsx b/ui/desktop/src/components/icons/ArrowDown.tsx index 9a72d8c4..a3f1cd5e 100644 --- a/ui/desktop/src/components/icons/ArrowDown.tsx +++ b/ui/desktop/src/components/icons/ArrowDown.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - export default function ArrowDown({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/ChatSmart.tsx b/ui/desktop/src/components/icons/ChatSmart.tsx index 9e901305..acdd377a 100644 --- a/ui/desktop/src/components/icons/ChatSmart.tsx +++ b/ui/desktop/src/components/icons/ChatSmart.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - export default function ChatSmart({ className = '' }) { return ( {} diff --git a/ui/desktop/src/components/more_menu/MoreMenu.tsx b/ui/desktop/src/components/more_menu/MoreMenu.tsx index 7424b066..a54ed552 100644 --- a/ui/desktop/src/components/more_menu/MoreMenu.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenu.tsx @@ -5,6 +5,15 @@ import { FolderOpen, Moon, Sliders, Sun } from 'lucide-react'; import { useConfig } from '../ConfigContext'; import { ViewOptions, View } from '../../App'; +interface RecipeConfig { + id: string; + name: string; + description: string; + instructions?: string; + activities?: string[]; + [key: string]: unknown; +} + interface MenuButtonProps { onClick: () => void; children: React.ReactNode; @@ -187,7 +196,7 @@ export default function MoreMenu({ setOpen(false); window.electron.createChatWindow( undefined, - window.appConfig.get('GOOSE_WORKING_DIR') + window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined ); }} subtitle="Start a new session in the current directory" @@ -244,7 +253,7 @@ export default function MoreMenu({ undefined, // dir undefined, // version undefined, // resumeSessionId - recipeConfig, // recipe config + recipeConfig as RecipeConfig, // recipe config 'recipeEditor' // view type ); }} diff --git a/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx b/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx index b030d65f..07479537 100644 --- a/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx @@ -44,7 +44,7 @@ export default function MoreMenuLayout({ >
- {window.appConfig.get('GOOSE_WORKING_DIR')} + {String(window.appConfig.get('GOOSE_WORKING_DIR'))}
@@ -54,7 +54,10 @@ export default function MoreMenuLayout({ - + {})} + setIsGoosehintsModalOpen={setIsGoosehintsModalOpen || (() => {})} + />
)}
diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx index 8aacc960..9f6f80b8 100644 --- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx @@ -1,9 +1,12 @@ -import React, { useState, useEffect, FormEvent } from 'react'; +import React, { useState, useEffect, FormEvent, useCallback } from 'react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; -import { Select } from '../ui/select'; +import { Select } from '../ui/Select'; import cronstrue from 'cronstrue'; +import * as yaml from 'yaml'; +import { Buffer } from 'buffer'; +import { Recipe } from '../../recipe'; type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly'; @@ -26,6 +29,39 @@ interface CreateScheduleModalProps { apiErrorExternally: string | null; } +// Interface for clean extension in YAML +interface CleanExtension { + name: string; + type: 'stdio' | 'sse' | 'builtin' | 'frontend'; + cmd?: string; + args?: string[]; + uri?: string; + display_name?: string; + tools?: unknown[]; + instructions?: string; + env_keys?: string[]; + timeout?: number; + description?: string; + bundled?: boolean; +} + +// Interface for clean recipe in YAML +interface CleanRecipe { + title: string; + description: string; + instructions: string; + prompt?: string; + activities?: string[]; + extensions?: CleanExtension[]; + goosehints?: string; + context?: string[]; + profile?: string; + author?: { + contact?: string; + metadata?: string; + }; +} + const frequencies: FrequencyOption[] = [ { value: 'once', label: 'Once' }, { value: 'hourly', label: 'Hourly' }, @@ -51,6 +87,151 @@ const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark const checkboxInputClassName = 'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2'; +type SourceType = 'file' | 'deeplink'; + +// Function to parse deep link and extract recipe config +function parseDeepLink(deepLink: string): Recipe | null { + try { + const url = new URL(deepLink); + if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) { + return null; + } + + const configParam = url.searchParams.get('config'); + if (!configParam) { + return null; + } + + const configJson = Buffer.from(configParam, 'base64').toString('utf-8'); + return JSON.parse(configJson) as Recipe; + } catch (error) { + console.error('Failed to parse deep link:', error); + return null; + } +} + +// Function to convert recipe to YAML +function recipeToYaml(recipe: Recipe): string { + // Create a clean recipe object for YAML conversion + const cleanRecipe: CleanRecipe = { + title: recipe.title, + description: recipe.description, + instructions: recipe.instructions, + }; + + if (recipe.prompt) { + cleanRecipe.prompt = recipe.prompt; + } + + if (recipe.activities && recipe.activities.length > 0) { + cleanRecipe.activities = recipe.activities; + } + + if (recipe.extensions && recipe.extensions.length > 0) { + cleanRecipe.extensions = recipe.extensions.map((ext) => { + const cleanExt: CleanExtension = { + name: ext.name, + type: 'builtin', // Default type, will be overridden below + }; + + // Handle different extension types using type assertions + if ('type' in ext && ext.type) { + cleanExt.type = ext.type as CleanExtension['type']; + + // Use type assertions to access properties safely + const extAny = ext as Record; + + if (ext.type === 'sse' && extAny.uri) { + cleanExt.uri = extAny.uri as string; + } else if (ext.type === 'stdio') { + if (extAny.cmd) { + cleanExt.cmd = extAny.cmd as string; + } + if (extAny.args) { + cleanExt.args = extAny.args as string[]; + } + } else if (ext.type === 'builtin' && extAny.display_name) { + cleanExt.display_name = extAny.display_name as string; + } + + // Handle frontend type separately to avoid TypeScript narrowing issues + if ((ext.type as string) === 'frontend') { + if (extAny.tools) { + cleanExt.tools = extAny.tools as unknown[]; + } + if (extAny.instructions) { + cleanExt.instructions = extAny.instructions as string; + } + } + } else { + // Fallback: try to infer type from available fields + const extAny = ext as Record; + + if (extAny.cmd) { + cleanExt.type = 'stdio'; + cleanExt.cmd = extAny.cmd as string; + if (extAny.args) { + cleanExt.args = extAny.args as string[]; + } + } else if (extAny.command) { + // Handle legacy 'command' field by converting to 'cmd' + cleanExt.type = 'stdio'; + cleanExt.cmd = extAny.command as string; + } else if (extAny.uri) { + cleanExt.type = 'sse'; + cleanExt.uri = extAny.uri as string; + } else if (extAny.tools) { + cleanExt.type = 'frontend'; + cleanExt.tools = extAny.tools as unknown[]; + if (extAny.instructions) { + cleanExt.instructions = extAny.instructions as string; + } + } else { + // Default to builtin if we can't determine type + cleanExt.type = 'builtin'; + } + } + + // Add common optional fields + if (ext.env_keys && ext.env_keys.length > 0) { + cleanExt.env_keys = ext.env_keys; + } + + if ('timeout' in ext && ext.timeout) { + cleanExt.timeout = ext.timeout as number; + } + + if ('description' in ext && ext.description) { + cleanExt.description = ext.description as string; + } + + if ('bundled' in ext && ext.bundled !== undefined) { + cleanExt.bundled = ext.bundled as boolean; + } + + return cleanExt; + }); + } + + if (recipe.goosehints) { + cleanRecipe.goosehints = recipe.goosehints; + } + + if (recipe.context && recipe.context.length > 0) { + cleanRecipe.context = recipe.context; + } + + if (recipe.profile) { + cleanRecipe.profile = recipe.profile; + } + + if (recipe.author) { + cleanRecipe.author = recipe.author; + } + + return yaml.stringify(cleanRecipe); +} + export const CreateScheduleModal: React.FC = ({ isOpen, onClose, @@ -59,7 +240,10 @@ export const CreateScheduleModal: React.FC = ({ apiErrorExternally, }) => { const [scheduleId, setScheduleId] = useState(''); + const [sourceType, setSourceType] = useState('file'); const [recipeSourcePath, setRecipeSourcePath] = useState(''); + const [deepLinkInput, setDeepLinkInput] = useState(''); + const [parsedRecipe, setParsedRecipe] = useState(null); const [frequency, setFrequency] = useState('daily'); const [selectedDate, setSelectedDate] = useState( () => new Date().toISOString().split('T')[0] @@ -72,9 +256,54 @@ export const CreateScheduleModal: React.FC = ({ const [readableCronExpression, setReadableCronExpression] = useState(''); const [internalValidationError, setInternalValidationError] = useState(null); + const handleDeepLinkChange = useCallback( + (value: string) => { + setDeepLinkInput(value); + setInternalValidationError(null); + + if (value.trim()) { + const recipe = parseDeepLink(value.trim()); + if (recipe) { + setParsedRecipe(recipe); + // Auto-populate schedule ID from recipe title if available + if (recipe.title && !scheduleId) { + const cleanId = recipe.title + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + setScheduleId(cleanId); + } + } else { + setParsedRecipe(null); + setInternalValidationError( + 'Invalid deep link format. Please use a goose://bot or goose://recipe link.' + ); + } + } else { + setParsedRecipe(null); + } + }, + [scheduleId] + ); + + useEffect(() => { + // Check for pending deep link when modal opens + if (isOpen) { + const pendingDeepLink = localStorage.getItem('pendingScheduleDeepLink'); + if (pendingDeepLink) { + localStorage.removeItem('pendingScheduleDeepLink'); + setSourceType('deeplink'); + handleDeepLinkChange(pendingDeepLink); + } + } + }, [isOpen, handleDeepLinkChange]); + const resetForm = () => { setScheduleId(''); + setSourceType('file'); setRecipeSourcePath(''); + setDeepLinkInput(''); + setParsedRecipe(null); setFrequency('daily'); setSelectedDate(new Date().toISOString().split('T')[0]); setSelectedTime('09:00'); @@ -195,10 +424,48 @@ export const CreateScheduleModal: React.FC = ({ setInternalValidationError('Schedule ID is required.'); return; } - if (!recipeSourcePath) { - setInternalValidationError('Recipe source file is required.'); - return; + + let finalRecipeSource = ''; + + if (sourceType === 'file') { + if (!recipeSourcePath) { + setInternalValidationError('Recipe source file is required.'); + return; + } + finalRecipeSource = recipeSourcePath; + } else if (sourceType === 'deeplink') { + if (!deepLinkInput.trim()) { + setInternalValidationError('Deep link is required.'); + return; + } + if (!parsedRecipe) { + setInternalValidationError('Invalid deep link. Please check the format.'); + return; + } + + try { + // Convert recipe to YAML and save to a temporary file + const yamlContent = recipeToYaml(parsedRecipe); + console.log('Generated YAML content:', yamlContent); // Debug log + const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`; + const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.'; + const tempFilePath = `${tempDir}/${tempFileName}`; + + // Write the YAML file + const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent); + if (!writeSuccess) { + setInternalValidationError('Failed to create temporary recipe file.'); + return; + } + + finalRecipeSource = tempFilePath; + } catch (error) { + console.error('Failed to convert recipe to YAML:', error); + setInternalValidationError('Failed to process the recipe from deep link.'); + return; + } } + if ( !derivedCronExpression || derivedCronExpression.includes('Invalid') || @@ -216,7 +483,7 @@ export const CreateScheduleModal: React.FC = ({ const newSchedulePayload: NewSchedulePayload = { id: scheduleId.trim(), - recipe_source: recipeSourcePath, + recipe_source: finalRecipeSource, cron: derivedCronExpression, }; @@ -232,7 +499,7 @@ export const CreateScheduleModal: React.FC = ({ return (
- +

Create New Schedule @@ -268,22 +535,79 @@ export const CreateScheduleModal: React.FC = ({ required />

+
- - - {recipeSourcePath && ( -

- Selected: {recipeSourcePath} -

- )} + +
+
+ + +
+ + {sourceType === 'file' && ( +
+ + {recipeSourcePath && ( +

+ Selected: {recipeSourcePath} +

+ )} +
+ )} + + {sourceType === 'deeplink' && ( +
+ handleDeepLinkChange(e.target.value)} + placeholder="Paste goose://bot or goose://recipe link here..." + /> + {parsedRecipe && ( +
+

+ ✓ Recipe parsed successfully +

+

+ Title: {parsedRecipe.title} +

+

+ Description: {parsedRecipe.description} +

+
+ )} +
+ )} +
+
); -}; \ No newline at end of file +}; diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index 3b4bf70c..155fef21 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -5,11 +5,21 @@ import BackButton from '../ui/BackButton'; import { Card } from '../ui/card'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { fetchSessionDetails, SessionDetails } from '../../sessions'; -import { getScheduleSessions, runScheduleNow, pauseSchedule, unpauseSchedule, updateSchedule, listSchedules, ScheduledJob } from '../../schedule'; +import { + getScheduleSessions, + runScheduleNow, + pauseSchedule, + unpauseSchedule, + updateSchedule, + listSchedules, + killRunningJob, + inspectRunningJob, + ScheduledJob, +} from '../../schedule'; import SessionHistoryView from '../sessions/SessionHistoryView'; import { EditScheduleModal } from './EditScheduleModal'; import { toastError, toastSuccess } from '../../toasts'; -import { Loader2, Pause, Play, Edit } from 'lucide-react'; +import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react'; import cronstrue from 'cronstrue'; interface ScheduleSessionMeta { @@ -40,9 +50,14 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN const [scheduleDetails, setScheduleDetails] = useState(null); const [isLoadingSchedule, setIsLoadingSchedule] = useState(false); const [scheduleError, setScheduleError] = useState(null); - + // Individual loading states for each action to prevent double-clicks const [pauseUnpauseLoading, setPauseUnpauseLoading] = useState(false); + const [killJobLoading, setKillJobLoading] = useState(false); + const [inspectJobLoading, setInspectJobLoading] = useState(false); + + // Track if we explicitly killed a job to distinguish from natural completion + const [jobWasKilled, setJobWasKilled] = useState(false); const [selectedSessionDetails, setSelectedSessionDetails] = useState(null); const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false); @@ -68,25 +83,34 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN } }, []); - const fetchScheduleDetails = useCallback(async (sId: string) => { - if (!sId) return; - setIsLoadingSchedule(true); - setScheduleError(null); - try { - const allSchedules = await listSchedules(); - const schedule = allSchedules.find((s) => s.id === sId); - if (schedule) { - setScheduleDetails(schedule); - } else { - setScheduleError('Schedule not found'); + const fetchScheduleDetails = useCallback( + async (sId: string) => { + if (!sId) return; + setIsLoadingSchedule(true); + setScheduleError(null); + try { + const allSchedules = await listSchedules(); + const schedule = allSchedules.find((s) => s.id === sId); + if (schedule) { + // Only reset runNowLoading if we explicitly killed the job + // This prevents interfering with natural job completion + if (!schedule.currently_running && runNowLoading && jobWasKilled) { + setRunNowLoading(false); + setJobWasKilled(false); // Reset the flag + } + setScheduleDetails(schedule); + } else { + setScheduleError('Schedule not found'); + } + } catch (err) { + console.error('Failed to fetch schedule details:', err); + setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details'); + } finally { + setIsLoadingSchedule(false); } - } catch (err) { - console.error('Failed to fetch schedule details:', err); - setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details'); - } finally { - setIsLoadingSchedule(false); - } - }, []); + }, + [runNowLoading, jobWasKilled] + ); const getReadableCron = (cronString: string) => { try { @@ -108,6 +132,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN setSelectedSessionDetails(null); setScheduleDetails(null); setScheduleError(null); + setJobWasKilled(false); // Reset kill flag when changing schedules } }, [scheduleId, fetchScheduleSessions, fetchScheduleDetails, selectedSessionDetails]); @@ -115,11 +140,18 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN if (!scheduleId) return; setRunNowLoading(true); try { - const newSessionId = await runScheduleNow(scheduleId); // MODIFIED - toastSuccess({ - title: 'Schedule Triggered', - msg: `Successfully triggered schedule. New session ID: ${newSessionId}`, - }); + const newSessionId = await runScheduleNow(scheduleId); + if (newSessionId === 'CANCELLED') { + toastSuccess({ + title: 'Job Cancelled', + msg: 'The job was cancelled while starting up.', + }); + } else { + toastSuccess({ + title: 'Schedule Triggered', + msg: `Successfully triggered schedule. New session ID: ${newSessionId}`, + }); + } setTimeout(() => { if (scheduleId) { fetchScheduleSessions(scheduleId); @@ -183,9 +215,60 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN setEditApiError(null); }; + const handleKillRunningJob = async () => { + if (!scheduleId) return; + setKillJobLoading(true); + try { + const result = await killRunningJob(scheduleId); + toastSuccess({ + title: 'Job Killed', + msg: result.message, + }); + // Mark that we explicitly killed this job + setJobWasKilled(true); + // Clear the runNowLoading state immediately when job is killed + setRunNowLoading(false); + fetchScheduleDetails(scheduleId); + } catch (err) { + console.error('Failed to kill running job:', err); + const errorMsg = err instanceof Error ? err.message : 'Failed to kill running job'; + toastError({ title: 'Kill Job Error', msg: errorMsg }); + } finally { + setKillJobLoading(false); + } + }; + + const handleInspectRunningJob = async () => { + if (!scheduleId) return; + setInspectJobLoading(true); + try { + const result = await inspectRunningJob(scheduleId); + if (result.sessionId) { + const duration = result.runningDurationSeconds + ? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s` + : 'Unknown'; + toastSuccess({ + title: 'Job Inspection', + msg: `Session: ${result.sessionId}\nRunning for: ${duration}`, + }); + } else { + toastSuccess({ + title: 'Job Inspection', + msg: 'No detailed information available for this job', + }); + } + } catch (err) { + console.error('Failed to inspect running job:', err); + const errorMsg = err instanceof Error ? err.message : 'Failed to inspect running job'; + toastError({ title: 'Inspect Job Error', msg: errorMsg }); + } finally { + setInspectJobLoading(false); + } + }; + const handleEditScheduleSubmit = async (cron: string) => { if (!scheduleId) return; - + setIsEditSubmitting(true); setEditApiError(null); try { @@ -226,6 +309,18 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN }; }, [scheduleId, fetchScheduleDetails]); + // Monitor schedule state changes and reset loading states appropriately + useEffect(() => { + if (scheduleDetails) { + // Only reset runNowLoading if we explicitly killed the job + // This prevents interfering with natural job completion + if (!scheduleDetails.currently_running && runNowLoading && jobWasKilled) { + setRunNowLoading(false); + setJobWasKilled(false); // Reset the flag + } + } + }, [scheduleDetails, runNowLoading, jobWasKilled]); + const loadAndShowSessionDetails = async (sessionId: string) => { setIsLoadingSessionDetails(true); setSessionDetailsError(null); @@ -364,6 +459,18 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN ? new Date(scheduleDetails.last_run).toLocaleString() : 'Never'}

+ {scheduleDetails.currently_running && scheduleDetails.current_session_id && ( +

+ Current Session:{' '} + {scheduleDetails.current_session_id} +

+ )} + {scheduleDetails.currently_running && scheduleDetails.process_start_time && ( +

+ Process Started:{' '} + {new Date(scheduleDetails.process_start_time).toLocaleString()} +

+ )}
)} @@ -379,7 +486,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN > {runNowLoading ? 'Triggering...' : 'Run Schedule Now'} - + {scheduleDetails && !scheduleDetails.currently_running && ( <> )} + + {scheduleDetails && scheduleDetails.currently_running && ( + <> + + + + )}
- + {scheduleDetails?.currently_running && (

Cannot trigger or modify a schedule while it's already running.

)} - + {scheduleDetails?.paused && (

- This schedule is paused and will not run automatically. Use "Run Schedule Now" to trigger it manually or unpause to resume automatic execution. + This schedule is paused and will not run automatically. Use "Run Schedule Now" to + trigger it manually or unpause to resume automatic execution.

)} diff --git a/ui/desktop/src/components/schedule/ScheduleFromRecipeModal.tsx b/ui/desktop/src/components/schedule/ScheduleFromRecipeModal.tsx new file mode 100644 index 00000000..d1f7f0ed --- /dev/null +++ b/ui/desktop/src/components/schedule/ScheduleFromRecipeModal.tsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect } from 'react'; +import { Card } from '../ui/card'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Recipe } from '../../recipe'; +import { generateDeepLink } from '../ui/DeepLinkModal'; +import Copy from '../icons/Copy'; +import { Check } from 'lucide-react'; + +interface ScheduleFromRecipeModalProps { + isOpen: boolean; + onClose: () => void; + recipe: Recipe; + onCreateSchedule: (deepLink: string) => void; +} + +export const ScheduleFromRecipeModal: React.FC = ({ + isOpen, + onClose, + recipe, + onCreateSchedule, +}) => { + const [copied, setCopied] = useState(false); + const [deepLink, setDeepLink] = useState(''); + + useEffect(() => { + if (isOpen && recipe) { + // Convert Recipe to the format expected by generateDeepLink + const recipeConfig = { + id: recipe.title?.toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'recipe', + title: recipe.title, + description: recipe.description, + instructions: recipe.instructions, + activities: recipe.activities || [], + prompt: recipe.prompt, + extensions: recipe.extensions, + goosehints: recipe.goosehints, + context: recipe.context, + profile: recipe.profile, + author: recipe.author, + }; + const link = generateDeepLink(recipeConfig); + setDeepLink(link); + } + }, [isOpen, recipe]); + + const handleCopy = () => { + navigator.clipboard + .writeText(deepLink) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch((err) => { + console.error('Failed to copy the text:', err); + }); + }; + + const handleCreateSchedule = () => { + onCreateSchedule(deepLink); + onClose(); + }; + + const handleClose = () => { + setCopied(false); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+ +
+

+ Create Schedule from Recipe +

+

+ Create a scheduled task using this recipe configuration. +

+
+ +
+
+

+ Recipe Details: +

+
+

+ {recipe.title} +

+

+ {recipe.description} +

+
+
+ +
+ +
+ + +
+

+ This link contains your recipe configuration and can be used to create a schedule. +

+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx index 18dd5fe6..c3e20e3e 100644 --- a/ui/desktop/src/components/schedule/SchedulesView.tsx +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -1,12 +1,22 @@ import React, { useState, useEffect } from 'react'; -import { listSchedules, createSchedule, deleteSchedule, pauseSchedule, unpauseSchedule, updateSchedule, ScheduledJob } from '../../schedule'; +import { + listSchedules, + createSchedule, + deleteSchedule, + pauseSchedule, + unpauseSchedule, + updateSchedule, + killRunningJob, + inspectRunningJob, + ScheduledJob, +} from '../../schedule'; import BackButton from '../ui/BackButton'; import { ScrollArea } from '../ui/scroll-area'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { TrashIcon } from '../icons/TrashIcon'; -import { Plus, RefreshCw, Pause, Play, Edit } from 'lucide-react'; +import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye } from 'lucide-react'; import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal'; import { EditScheduleModal } from './EditScheduleModal'; import ScheduleDetailView from './ScheduleDetailView'; @@ -27,10 +37,12 @@ const SchedulesView: React.FC = ({ onClose }) => { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); - + // Individual loading states for each action to prevent double-clicks const [pausingScheduleIds, setPausingScheduleIds] = useState>(new Set()); const [deletingScheduleIds, setDeletingScheduleIds] = useState>(new Set()); + const [killingScheduleIds, setKillingScheduleIds] = useState>(new Set()); + const [inspectingScheduleIds, setInspectingScheduleIds] = useState>(new Set()); const [viewingScheduleId, setViewingScheduleId] = useState(null); @@ -55,6 +67,14 @@ const SchedulesView: React.FC = ({ onClose }) => { useEffect(() => { if (viewingScheduleId === null) { fetchSchedules(); + + // Check for pending deep link from recipe editor + const pendingDeepLink = localStorage.getItem('pendingScheduleDeepLink'); + if (pendingDeepLink) { + localStorage.removeItem('pendingScheduleDeepLink'); + setIsCreateModalOpen(true); + // The CreateScheduleModal will handle the deep link + } } }, [viewingScheduleId]); @@ -125,7 +145,7 @@ const SchedulesView: React.FC = ({ onClose }) => { const handleEditScheduleSubmit = async (cron: string) => { if (!editingSchedule) return; - + setIsSubmitting(true); setSubmitApiError(null); try { @@ -153,10 +173,10 @@ const SchedulesView: React.FC = ({ onClose }) => { const handleDeleteSchedule = async (idToDelete: string) => { if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return; - + // Immediately add to deleting set to disable button - setDeletingScheduleIds(prev => new Set(prev).add(idToDelete)); - + setDeletingScheduleIds((prev) => new Set(prev).add(idToDelete)); + if (viewingScheduleId === idToDelete) { setViewingScheduleId(null); } @@ -171,7 +191,7 @@ const SchedulesView: React.FC = ({ onClose }) => { ); } finally { // Remove from deleting set - setDeletingScheduleIds(prev => { + setDeletingScheduleIds((prev) => { const newSet = new Set(prev); newSet.delete(idToDelete); return newSet; @@ -181,8 +201,8 @@ const SchedulesView: React.FC = ({ onClose }) => { const handlePauseSchedule = async (idToPause: string) => { // Immediately add to pausing set to disable button - setPausingScheduleIds(prev => new Set(prev).add(idToPause)); - + setPausingScheduleIds((prev) => new Set(prev).add(idToPause)); + setApiError(null); try { await pauseSchedule(idToPause); @@ -193,7 +213,8 @@ const SchedulesView: React.FC = ({ onClose }) => { await fetchSchedules(); } catch (error) { console.error(`Failed to pause schedule "${idToPause}":`, error); - const errorMsg = error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`; + const errorMsg = + error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`; setApiError(errorMsg); toastError({ title: 'Pause Schedule Error', @@ -201,7 +222,7 @@ const SchedulesView: React.FC = ({ onClose }) => { }); } finally { // Remove from pausing set - setPausingScheduleIds(prev => { + setPausingScheduleIds((prev) => { const newSet = new Set(prev); newSet.delete(idToPause); return newSet; @@ -211,8 +232,8 @@ const SchedulesView: React.FC = ({ onClose }) => { const handleUnpauseSchedule = async (idToUnpause: string) => { // Immediately add to pausing set to disable button - setPausingScheduleIds(prev => new Set(prev).add(idToUnpause)); - + setPausingScheduleIds((prev) => new Set(prev).add(idToUnpause)); + setApiError(null); try { await unpauseSchedule(idToUnpause); @@ -223,7 +244,8 @@ const SchedulesView: React.FC = ({ onClose }) => { await fetchSchedules(); } catch (error) { console.error(`Failed to unpause schedule "${idToUnpause}":`, error); - const errorMsg = error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`; + const errorMsg = + error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`; setApiError(errorMsg); toastError({ title: 'Unpause Schedule Error', @@ -231,7 +253,7 @@ const SchedulesView: React.FC = ({ onClose }) => { }); } finally { // Remove from pausing set - setPausingScheduleIds(prev => { + setPausingScheduleIds((prev) => { const newSet = new Set(prev); newSet.delete(idToUnpause); return newSet; @@ -239,6 +261,77 @@ const SchedulesView: React.FC = ({ onClose }) => { } }; + const handleKillRunningJob = async (scheduleId: string) => { + // Immediately add to killing set to disable button + setKillingScheduleIds((prev) => new Set(prev).add(scheduleId)); + + setApiError(null); + try { + const result = await killRunningJob(scheduleId); + toastSuccess({ + title: 'Job Killed', + msg: result.message, + }); + await fetchSchedules(); + } catch (error) { + console.error(`Failed to kill running job "${scheduleId}":`, error); + const errorMsg = + error instanceof Error ? error.message : `Unknown error killing job "${scheduleId}".`; + setApiError(errorMsg); + toastError({ + title: 'Kill Job Error', + msg: errorMsg, + }); + } finally { + // Remove from killing set + setKillingScheduleIds((prev) => { + const newSet = new Set(prev); + newSet.delete(scheduleId); + return newSet; + }); + } + }; + + const handleInspectRunningJob = async (scheduleId: string) => { + // Immediately add to inspecting set to disable button + setInspectingScheduleIds((prev) => new Set(prev).add(scheduleId)); + + setApiError(null); + try { + const result = await inspectRunningJob(scheduleId); + if (result.sessionId) { + const duration = result.runningDurationSeconds + ? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s` + : 'Unknown'; + toastSuccess({ + title: 'Job Inspection', + msg: `Session: ${result.sessionId}\nRunning for: ${duration}`, + }); + } else { + toastSuccess({ + title: 'Job Inspection', + msg: 'No detailed information available for this job', + }); + } + } catch (error) { + console.error(`Failed to inspect running job "${scheduleId}":`, error); + const errorMsg = + error instanceof Error ? error.message : `Unknown error inspecting job "${scheduleId}".`; + setApiError(errorMsg); + toastError({ + title: 'Inspect Job Error', + msg: errorMsg, + }); + } finally { + // Remove from inspecting set + setInspectingScheduleIds((prev) => { + const newSet = new Set(prev); + newSet.delete(scheduleId); + return newSet; + }); + } + }; + const handleNavigateToScheduleDetail = (scheduleId: string) => { setViewingScheduleId(scheduleId); }; @@ -372,7 +465,11 @@ const SchedulesView: React.FC = ({ onClose }) => { }} className="text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-blue-100/50 dark:hover:bg-blue-900/30" title={`Edit schedule ${job.id}`} - disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id) || isSubmitting} + disabled={ + pausingScheduleIds.has(job.id) || + deletingScheduleIds.has(job.id) || + isSubmitting + } > @@ -392,10 +489,54 @@ const SchedulesView: React.FC = ({ onClose }) => { ? 'text-green-500 dark:text-green-400 hover:text-green-600 dark:hover:text-green-300 hover:bg-green-100/50 dark:hover:bg-green-900/30' : 'text-orange-500 dark:text-orange-400 hover:text-orange-600 dark:hover:text-orange-300 hover:bg-orange-100/50 dark:hover:bg-orange-900/30' }`} - title={job.paused ? `Unpause schedule ${job.id}` : `Pause schedule ${job.id}`} - disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)} + title={ + job.paused + ? `Unpause schedule ${job.id}` + : `Pause schedule ${job.id}` + } + disabled={ + pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id) + } > - {job.paused ? : } + {job.paused ? ( + + ) : ( + + )} + + + )} + {job.currently_running && ( + <> + + )} @@ -408,7 +549,12 @@ const SchedulesView: React.FC = ({ onClose }) => { }} className="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-900/30" title={`Delete schedule ${job.id}`} - disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)} + disabled={ + pausingScheduleIds.has(job.id) || + deletingScheduleIds.has(job.id) || + killingScheduleIds.has(job.id) || + inspectingScheduleIds.has(job.id) + } > diff --git a/ui/desktop/src/components/sessions/SessionViewComponents.tsx b/ui/desktop/src/components/sessions/SessionViewComponents.tsx index 3c0f4dce..c7d76c5f 100644 --- a/ui/desktop/src/components/sessions/SessionViewComponents.tsx +++ b/ui/desktop/src/components/sessions/SessionViewComponents.tsx @@ -7,7 +7,11 @@ import { ScrollArea } from '../ui/scroll-area'; import MarkdownContent from '../MarkdownContent'; import ToolCallWithResponse from '../ToolCallWithResponse'; import ImagePreview from '../ImagePreview'; -import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message'; +import { + ToolRequestMessageContent, + ToolResponseMessageContent, + TextContent, +} from '../../types/message'; import { type Message } from '../../types/message'; import { formatMessageTimestamp } from '../../utils/timeUtils'; import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils'; @@ -109,7 +113,7 @@ export const SessionMessages: React.FC = ({ .map((message, index) => { // Extract text content from the message let textContent = message.content - .filter((c) => c.type === 'text') + .filter((c): c is TextContent => c.type === 'text') .map((c) => c.text) .join('\n'); diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index 0cd09550..739523d7 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -30,10 +30,11 @@ const SessionsView: React.FC = ({ setView }) => { // Keep the selected session null if there's an error setSelectedSession(null); + const errorMessage = err instanceof Error ? err.message : String(err); toastError({ title: 'Failed to load session. The file may be corrupted.', msg: 'Please try again later.', - traceback: err, + traceback: errorMessage, }); } finally { setIsLoadingSession(false); diff --git a/ui/desktop/src/components/sessions/SharedSessionView.tsx b/ui/desktop/src/components/sessions/SharedSessionView.tsx index 7fa04bff..d13753ec 100644 --- a/ui/desktop/src/components/sessions/SharedSessionView.tsx +++ b/ui/desktop/src/components/sessions/SharedSessionView.tsx @@ -33,13 +33,13 @@ const SharedSessionView: React.FC = ({
- {formatMessageTimestamp(session.messages[0]?.created)} + {session ? formatMessageTimestamp(session.messages[0]?.created) : 'Unknown'} - {session.message_count} + {session ? session.message_count : 0} - {session.total_tokens !== null && ( + {session && session.total_tokens !== null && ( {session.total_tokens.toLocaleString()} @@ -49,7 +49,7 @@ const SharedSessionView: React.FC = ({
- {session.working_dir} + {session ? session.working_dir : 'Unknown'}
diff --git a/ui/desktop/src/components/settings/OllamaBattleGame.tsx b/ui/desktop/src/components/settings/OllamaBattleGame.tsx deleted file mode 100644 index c3b98f21..00000000 --- a/ui/desktop/src/components/settings/OllamaBattleGame.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; - -// Import actual PNG images -import llamaSprite from '../../assets/battle-game/llama.png'; -import gooseSprite from '../../assets/battle-game/goose.png'; -import battleBackground from '../../assets/battle-game/background.png'; -import battleMusic from '../../assets/battle-game/battle.mp3'; - -interface BattleState { - currentStep: number; - gooseHp: number; - llamaHp: number; - message: string; - animation: string | null; - lastChoice?: string; - showHostInput?: boolean; - processingAction?: boolean; -} - -interface OllamaBattleGameProps { - onComplete: (configValues: { [key: string]: string }) => void; - requiredKeys: string[]; -} - -export function OllamaBattleGame({ onComplete, _requiredKeys }: OllamaBattleGameProps) { - // Use Audio element type for audioRef - const audioRef = useRef<{ play: () => Promise; pause: () => void; volume: number } | null>( - null - ); - const [isMuted, setIsMuted] = useState(false); - - const [battleState, setBattleState] = useState({ - currentStep: 0, - gooseHp: 100, - llamaHp: 100, - message: 'A wild Ollama appeared!', - animation: null, - processingAction: false, - }); - - const [configValues, setConfigValues] = useState<{ [key: string]: string }>({}); - - // Initialize audio when component mounts - useEffect(() => { - if (typeof window !== 'undefined') { - audioRef.current = new window.Audio(battleMusic); - audioRef.current.loop = true; - audioRef.current.volume = 0.2; - audioRef.current.play().catch((e) => console.log('Audio autoplay prevented:', e)); - } - - return () => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } - }; - }, []); - - const toggleMute = () => { - if (audioRef.current) { - if (isMuted) { - audioRef.current.volume = 0.2; - } else { - audioRef.current.volume = 0; - } - setIsMuted(!isMuted); - } - }; - - const battleSteps = [ - { - message: 'A wild Ollama appeared!', - action: null, - animation: 'appear', - }, - { - message: 'What will GOOSE do?', - action: 'choice', - choices: ['Pacify', 'HONK!'], - animation: 'attack', - followUpMessages: ["It's not very effective...", 'But OLLAMA is confused!'], - }, - { - message: 'OLLAMA used YAML Confusion!', - action: null, - animation: 'counter', - followUpMessages: ['OLLAMA hurt itself in confusion!', 'GOOSE maintained composure!'], - }, - { - message: 'What will GOOSE do?', - action: 'final_choice', - choices: (previousChoice: string) => [ - previousChoice === 'Pacify' ? 'HONK!' : 'Pacify', - 'Configure Host', - ], - animation: 'attack', - }, - { - message: 'OLLAMA used Docker Dependency!', - action: null, - animation: 'counter', - followUpMessages: ["It's not very effective...", 'GOOSE knows containerization!'], - }, - { - message: 'What will GOOSE do?', - action: 'host_choice', - choices: ['Configure Host'], - animation: 'finish', - }, - { - message: '', // Will be set dynamically based on choice - action: 'host_input', - prompt: 'Enter your Ollama host address:', - configKey: 'OLLAMA_HOST', - animation: 'finish', - followUpMessages: [ - "It's super effective!", - 'OLLAMA has been configured!', - 'OLLAMA joined your team!', - ], - }, - { - message: 'Configuration complete!\nOLLAMA will remember this friendship!', - action: 'complete', - }, - ]; - - const animateHit = (isLlama: boolean) => { - const element = document.querySelector(isLlama ? '.llama-sprite' : '.goose-sprite'); - if (element) { - element.classList.add('hit-flash'); - setTimeout(() => { - element.classList.remove('hit-flash'); - }, 500); - } - }; - - useEffect(() => { - // Add CSS for the hit animation and defeat animation - const style = document.createElement('style'); - style.textContent = ` - @keyframes hitFlash { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } - } - .hit-flash { - animation: hitFlash 0.5s; - } - @keyframes defeat { - 0% { transform: translateY(0); opacity: 1; } - 20% { transform: translateY(-30px); opacity: 1; } - 100% { transform: translateY(500px); opacity: 0; } - } - .defeated { - animation: defeat 1.3s cubic-bezier(.36,.07,.19,.97) both; - } - `; - document.head.appendChild(style); - return () => { - document.head.removeChild(style); - }; - }, []); - - const handleAction = async (value: string) => { - const currentStep = - battleState.currentStep < battleSteps.length ? battleSteps[battleState.currentStep] : null; - - if (!currentStep) return; - - // Handle host input - if (currentStep.action === 'host_input' && value) { - setConfigValues((prev) => ({ - ...prev, - [currentStep.configKey]: value, - })); - return; - } - - // Handle host submit - if (currentStep.action === 'host_input' && !value) { - setBattleState((prev) => ({ - ...prev, - processingAction: true, - llamaHp: 0, - message: "It's super effective!", - })); - animateHit(true); - - // Add defeat class to llama sprite and health bar - const llamaContainer = document.querySelector('.llama-container'); - if (llamaContainer) { - await new Promise((resolve) => setTimeout(resolve, 500)); - llamaContainer.classList.add('defeated'); - } - - // Show victory messages with delays - if (currentStep.followUpMessages) { - for (const msg of currentStep.followUpMessages) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - setBattleState((prev) => ({ ...prev, message: msg })); - } - } - - await new Promise((resolve) => setTimeout(resolve, 1000)); - onComplete(configValues); - return; - } - - // Handle continue button for messages - if (!currentStep.action) { - setBattleState((prev) => ({ - ...prev, - currentStep: prev.currentStep + 1, - message: battleSteps[prev.currentStep + 1]?.message || prev.message, - processingAction: false, - })); - return; - } - - // Handle choices (Pacify/HONK/Configure Host) - if ( - (currentStep.action === 'choice' || - currentStep.action === 'final_choice' || - currentStep.action === 'host_choice') && - value - ) { - // Set processing flag to hide buttons - setBattleState((prev) => ({ - ...prev, - processingAction: true, - })); - - if (value === 'Configure Host') { - setBattleState((prev) => ({ - ...prev, - message: 'GOOSE used Configure Host!', - showHostInput: true, - currentStep: battleSteps.findIndex((step) => step.action === 'host_input'), - processingAction: false, - })); - return; - } - - // Handle Pacify or HONK attacks - setBattleState((prev) => ({ - ...prev, - lastChoice: value, - llamaHp: Math.max(0, prev.llamaHp - 25), - message: `GOOSE used ${value}!`, - })); - animateHit(true); - - // Show follow-up messages - if (currentStep.followUpMessages) { - for (const msg of currentStep.followUpMessages) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - setBattleState((prev) => ({ ...prev, message: msg })); - } - } - - // Proceed to counter-attack - await new Promise((resolve) => setTimeout(resolve, 1000)); - const isFirstCycle = currentStep.action === 'choice'; - const nextStep = battleSteps[battleState.currentStep + 1]; - setBattleState((prev) => ({ - ...prev, - gooseHp: Math.max(0, prev.gooseHp - 25), - message: isFirstCycle ? 'OLLAMA used YAML Confusion!' : 'OLLAMA used Docker Dependency!', - currentStep: prev.currentStep + 1, - processingAction: false, - })); - animateHit(false); - - // Show counter-attack messages - if (nextStep?.followUpMessages) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - for (const msg of nextStep.followUpMessages) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - setBattleState((prev) => ({ ...prev, message: msg })); - } - } - - return; - } - - // Check for battle completion - if (battleState.currentStep === battleSteps.length - 2) { - onComplete(configValues); - } - }; - - return ( -
- {/* Battle Scene */} -
- {/* Llama sprite */} -
-
-
- OLLAMA - Lv.1 -
-
-
-
50 - ? '#10B981' - : battleState.llamaHp > 20 - ? '#F59E0B' - : '#EF4444', - }} - /> -
- - {Math.floor(battleState.llamaHp)}/100 - -
-
- Llama -
- - {/* Goose sprite */} -
- Goose -
-
- GOOSE - Lv.99 -
-
-
-
50 - ? '#10B981' - : battleState.gooseHp > 20 - ? '#F59E0B' - : '#EF4444', - }} - /> -
- - {Math.floor(battleState.gooseHp)}/100 - -
-
-
-
- - {/* Dialog Box */} -
-
-
- -
-

- {battleState.message} -

- - {battleState.currentStep < battleSteps.length && ( -
- {/* Show battle choices */} - {(battleSteps[battleState.currentStep].action === 'choice' || - battleSteps[battleState.currentStep].action === 'final_choice' || - battleSteps[battleState.currentStep].action === 'host_choice') && - !battleState.showHostInput && - !battleState.processingAction && ( -
- {(typeof battleSteps[battleState.currentStep].choices === 'function' - ? battleSteps[battleState.currentStep].choices(battleState.lastChoice || '') - : battleSteps[battleState.currentStep].choices - )?.map((choice: string) => ( - - ))} -
- )} - - {/* Show host input when needed */} - {battleState.showHostInput && !battleState.processingAction && ( -
-

- Enter your Ollama host address: -

-
- handleAction(e.target.value)} - /> - -
-
- )} - - {/* Continue button for messages */} - {!battleSteps[battleState.currentStep].action && !battleState.processingAction && ( - - )} -
- )} -
- - {/* Black corners for that classic Pokemon feel */} -
-
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/ProviderSetupModal.tsx b/ui/desktop/src/components/settings/ProviderSetupModal.tsx deleted file mode 100644 index ad643e92..00000000 --- a/ui/desktop/src/components/settings/ProviderSetupModal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import { Card } from '../ui/card'; -import { Lock } from 'lucide-react'; -import { Input } from '../ui/input'; -import { Button } from '../ui/button'; -import { required_keys, default_key_value } from './models/hardcoded_stuff'; -import { isSecretKey } from './api_keys/utils'; -import { OllamaBattleGame } from './OllamaBattleGame'; - -interface ProviderSetupModalProps { - provider: string; - _model: string; - _endpoint: string; - title?: string; - onSubmit: (configValues: { [key: string]: string }) => void; - onCancel: () => void; - forceBattle?: boolean; -} - -export function ProviderSetupModal({ - provider, - _model, - _endpoint, - title, - onSubmit, - onCancel, - forceBattle = false, -}: ProviderSetupModalProps) { - const [configValues, setConfigValues] = React.useState<{ [key: string]: string }>( - default_key_value - ); - const requiredKeys = required_keys[provider] || ['API Key']; - const headerText = title || `Setup ${provider}`; - - const shouldShowBattle = React.useMemo(() => { - if (forceBattle) return true; - if (provider.toLowerCase() !== 'ollama') return false; - - const now = new Date(); - return now.getMinutes() === 0; - }, [provider, forceBattle]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSubmit(configValues); - }; - - return ( -
- -
- {/* Header */} -
-

{headerText}

-
- - {provider.toLowerCase() === 'ollama' && shouldShowBattle ? ( - - ) : ( -
-
- {requiredKeys.map((keyName) => ( -
- - setConfigValues((prev) => ({ - ...prev, - [keyName]: e.target.value, - })) - } - placeholder={keyName} - className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900" - required - /> -
- ))} -
{ - if (provider.toLowerCase() === 'ollama') { - onCancel(); - onSubmit({ forceBattle: 'true' }); - } - }} - > - - {`Your configuration values will be stored securely in the keychain and used only for making requests to ${provider}`} -
-
- - {/* Actions */} -
- - -
-
- )} -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index b2220db3..54d95b92 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -1,58 +1,19 @@ -import React, { useState, useEffect } from 'react'; -import { IpcRendererEvent } from 'electron'; import { ScrollArea } from '../ui/scroll-area'; -import { Settings as SettingsType } from './types'; -import { - FullExtensionConfig, - addExtension, - removeExtension, - BUILT_IN_EXTENSIONS, -} from '../../extensions'; -import { ConfigureExtensionModal } from './extensions/ConfigureExtensionModal'; -import { ManualExtensionModal } from './extensions/ManualExtensionModal'; -import { ConfigureBuiltInExtensionModal } from './extensions/ConfigureBuiltInExtensionModal'; import BackButton from '../ui/BackButton'; -import { RecentModelsRadio } from './models/RecentModels'; -import { ExtensionItem } from './extensions/ExtensionItem'; -import { View, ViewOptions } from '../../App'; -import { ModeSelection } from './basic/ModeSelection'; -import SessionSharingSection from './session/SessionSharingSection'; -import { toastSuccess } from '../../toasts'; +import type { View, ViewOptions } from '../../App'; +import ExtensionsSection from './extensions/ExtensionsSection'; +import ModelsSection from './models/ModelsSection'; +import { ModeSection } from './mode/ModeSection'; +import { ToolSelectionStrategySection } from './tool_selection_strategy/ToolSelectionStrategySection'; +import SessionSharingSection from './sessions/SessionSharingSection'; +import { ResponseStylesSection } from './response_styles/ResponseStylesSection'; +import AppSettingsSection from './app/AppSettingsSection'; +import { ExtensionConfig } from '../../api'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; -const EXTENSIONS_DESCRIPTION = - 'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.'; - -const EXTENSIONS_SITE_LINK = 'https://block.github.io/goose/v1/extensions/'; - -const DEFAULT_SETTINGS: SettingsType = { - models: [ - { - id: 'gpt4', - name: 'GPT 4.0', - description: 'Standard config', - enabled: false, - }, - { - id: 'gpt4lite', - name: 'GPT 4.0 lite', - description: 'Standard config', - enabled: false, - }, - { - id: 'claude', - name: 'Claude', - description: 'Standard config', - enabled: true, - }, - ], - // @ts-expect-error "we actually do always have all the properties required for builtins, but tsc cannot tell for some reason" - extensions: BUILT_IN_EXTENSIONS, -}; - export type SettingsViewOptions = { - extensionId: string; - showEnvVars: boolean; + deepLinkConfig?: ExtensionConfig; + showEnvVars?: boolean; }; export default function SettingsView({ @@ -64,133 +25,8 @@ export default function SettingsView({ setView: (view: View, viewOptions?: ViewOptions) => void; viewOptions: SettingsViewOptions; }) { - const [settings, setSettings] = React.useState(() => { - const saved = localStorage.getItem('user_settings'); - window.electron.logInfo('Settings: ' + saved); - let currentSettings = saved ? JSON.parse(saved) : DEFAULT_SETTINGS; - - // Ensure built-in extensions are included if not already present - BUILT_IN_EXTENSIONS.forEach((builtIn) => { - const exists = currentSettings.extensions.some( - (ext: FullExtensionConfig) => ext.id === builtIn.id - ); - if (!exists) { - currentSettings.extensions.push(builtIn); - } - }); - - return currentSettings; - }); - - const [extensionBeingConfigured, setExtensionBeingConfigured] = - useState(null); - - const [isManualModalOpen, setIsManualModalOpen] = useState(false); - - // Persist settings changes - useEffect(() => { - localStorage.setItem('user_settings', JSON.stringify(settings)); - }, [settings]); - - // Listen for settings updates from extension storage - useEffect(() => { - const handleSettingsUpdate = (_event: IpcRendererEvent) => { - const saved = localStorage.getItem('user_settings'); - if (saved) { - let currentSettings = JSON.parse(saved); - setSettings(currentSettings); - } - }; - - window.electron.on('settings-updated', handleSettingsUpdate); - return () => { - window.electron.off('settings-updated', handleSettingsUpdate); - }; - }, []); - - // Handle URL parameters for auto-opening extension configuration - useEffect(() => { - const extensionId = viewOptions.extensionId; - const showEnvVars = viewOptions.showEnvVars; - - if (extensionId && showEnvVars === true) { - // Find the extension in settings - const extension = settings.extensions.find((ext) => ext.id === extensionId); - if (extension) { - // Auto-open the configuration modal - setExtensionBeingConfigured(extension); - // Scroll to extensions section - const element = document.getElementById('extensions'); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } - } - } - // We only run this once on load - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings.extensions]); - - const handleExtensionToggle = async (extensionId: string) => { - // Find the extension to get its current state - const extension = settings.extensions.find((ext) => ext.id === extensionId); - - if (!extension) return; - - const newEnabled = !extension.enabled; - - const originalSettings = settings; - - // Optimistically update local component state - setSettings((prev) => ({ - ...prev, - extensions: prev.extensions.map((ext) => - ext.id === extensionId ? { ...ext, enabled: newEnabled } : ext - ), - })); - - let response: Response; - - if (newEnabled) { - response = await addExtension(extension); - } else { - response = await removeExtension(extension.name); - } - - if (!response.ok) { - setSettings(originalSettings); - } - }; - - const handleExtensionRemove = async () => { - if (!extensionBeingConfigured) return; - - const response = await removeExtension(extensionBeingConfigured.name, true); - - if (response.ok) { - toastSuccess({ - title: extensionBeingConfigured.name, - msg: `Successfully removed extension`, - }); - - // Remove from localstorage - setSettings((prev) => ({ - ...prev, - extensions: prev.extensions.filter((ext) => ext.id !== extensionBeingConfigured.id), - })); - setExtensionBeingConfigured(null); - } - }; - - const handleExtensionConfigSubmit = () => { - setExtensionBeingConfigured(null); - }; - - const isBuiltIn = (extensionId: string) => { - return BUILT_IN_EXTENSIONS.some((builtIn) => builtIn.id === extensionId); - }; - return ( -
+
@@ -201,114 +37,29 @@ export default function SettingsView({
{/* Content Area */} -
+
- {/*Models Section*/} -
- -
-
-
-

Extensions

- - Browse - -
- -
-

{EXTENSIONS_DESCRIPTION}

- - {settings.extensions.length === 0 ? ( -

No Extensions Added

- ) : ( -
- {settings.extensions.map((ext) => ( - setExtensionBeingConfigured(extension)} - /> - ))} - -
- )} -
-
- -
-
-

Mode Selection

-
- -
-

- Configure how Goose interacts with tools and extensions -

- - -
-
-
- -
+ {/* Models Section */} + + {/* Extensions Section */} + + {/* Goose Modes */} + + {/*Session sharing*/} + + {/* Response Styles */} + + {/* Tool Selection Strategy */} + + {/* App Settings */} +
- - {extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id) ? ( - { - setExtensionBeingConfigured(null); - }} - extension={extensionBeingConfigured} - onSubmit={handleExtensionConfigSubmit} - /> - ) : ( - { - setExtensionBeingConfigured(null); - }} - extension={extensionBeingConfigured} - onSubmit={handleExtensionConfigSubmit} - onRemove={handleExtensionRemove} - /> - )} - - setIsManualModalOpen(false)} - onSubmit={async (extension) => { - const response = await addExtension(extension); - - if (response.ok) { - setSettings((prev) => ({ - ...prev, - extensions: [...prev.extensions, extension], - })); - setIsManualModalOpen(false); - } else { - // TODO - Anything for the UI state beyond validation? - } - }} - />
); } diff --git a/ui/desktop/src/components/settings/api_keys/ActiveKeysContext.tsx b/ui/desktop/src/components/settings/api_keys/ActiveKeysContext.tsx deleted file mode 100644 index 52f7b6ae..00000000 --- a/ui/desktop/src/components/settings/api_keys/ActiveKeysContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react'; -import { getActiveProviders } from './utils'; -import SuspenseLoader from '../../../suspense-loader'; - -// Create a context for active keys -const ActiveKeysContext = createContext< - | { - activeKeys: string[]; - setActiveKeys: (keys: string[]) => void; - } - | undefined ->(undefined); - -export const ActiveKeysProvider = ({ children }: { children: ReactNode }) => { - const [activeKeys, setActiveKeys] = useState([]); // Start with an empty list - const [isLoading, setIsLoading] = useState(true); // Track loading state - - // Fetch active keys from the backend - useEffect(() => { - const fetchActiveProviders = async () => { - try { - const providers = await getActiveProviders(); // Fetch the active providers - setActiveKeys(providers); // Update state with fetched providers - } catch (error) { - console.error('Error fetching active providers:', error); - } finally { - setIsLoading(false); // Ensure loading is marked as complete - } - }; - - fetchActiveProviders(); // Call the async function - }, []); - - // Provide active keys and ability to update them - return ( - - {!isLoading ? children : } - - ); -}; - -// Custom hook to access active keys -export const useActiveKeys = () => { - const context = useContext(ActiveKeysContext); - if (!context) { - throw new Error('useActiveKeys must be used within an ActiveKeysProvider'); - } - return context; -}; diff --git a/ui/desktop/src/components/settings/api_keys/types.ts b/ui/desktop/src/components/settings/api_keys/types.ts deleted file mode 100644 index 9d8b2245..00000000 --- a/ui/desktop/src/components/settings/api_keys/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface ProviderResponse { - supported: boolean; - name?: string; - description?: string; - models?: string[]; - config_status: Record; -} - -export interface ConfigDetails { - key: string; - is_set: boolean; - location?: string; -} diff --git a/ui/desktop/src/components/settings/api_keys/utils.tsx b/ui/desktop/src/components/settings/api_keys/utils.tsx deleted file mode 100644 index 994a547b..00000000 --- a/ui/desktop/src/components/settings/api_keys/utils.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { ProviderResponse, ConfigDetails } from './types'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { default_key_value, required_keys } from '../models/hardcoded_stuff'; // e.g. { OPENAI_HOST: '', OLLAMA_HOST: '' } - -// Backend API response types -interface ProviderMetadata { - description: string; - models: string[]; -} - -interface ProviderDetails { - name: string; - metadata: ProviderMetadata; - is_configured: boolean; -} - -export function isSecretKey(keyName: string): boolean { - // Endpoints and hosts should not be stored as secrets - const nonSecretKeys = [ - 'ANTHROPIC_HOST', - 'DATABRICKS_HOST', - 'OLLAMA_HOST', - 'OPENAI_HOST', - 'OPENAI_BASE_PATH', - 'AZURE_OPENAI_ENDPOINT', - 'AZURE_OPENAI_DEPLOYMENT_NAME', - 'AZURE_OPENAI_API_VERSION', - 'GCP_PROJECT_ID', - 'GCP_LOCATION', - ]; - return !nonSecretKeys.includes(keyName); -} - -export async function getActiveProviders(): Promise { - try { - const configSettings = await getConfigSettings(); - const activeProviders = Object.values(configSettings) - .filter((provider) => { - const providerName = provider.name; - const configStatus = provider.config_status ?? {}; - - // Skip if provider isn't in required_keys - if (!required_keys[providerName]) return false; - - // Get all required keys for this provider - const providerRequiredKeys = required_keys[providerName]; - - // Special case: If a provider has exactly one required key and that key - // has a default value, check if it's explicitly set - if (providerRequiredKeys.length === 1 && providerRequiredKeys[0] in default_key_value) { - const key = providerRequiredKeys[0]; - // Only consider active if the key is explicitly set - return configStatus[key]?.is_set === true; - } - - // For providers with multiple keys or keys without defaults: - // Check if all required keys without defaults are set - const requiredNonDefaultKeys = providerRequiredKeys.filter( - (key) => !(key in default_key_value) - ); - - // If there are no non-default keys, this provider needs at least one key explicitly set - if (requiredNonDefaultKeys.length === 0) { - return providerRequiredKeys.some((key) => configStatus[key]?.is_set === true); - } - - // Otherwise, all non-default keys must be set - return requiredNonDefaultKeys.every((key) => configStatus[key]?.is_set === true); - }) - .map((provider) => provider.name || 'Unknown Provider'); - - console.log('[GET ACTIVE PROVIDERS]:', activeProviders); - return activeProviders; - } catch (error) { - console.error('Failed to get active providers:', error); - return []; - } -} - -export async function getConfigSettings(): Promise> { - // Fetch provider config status - const response = await fetch(getApiUrl('/config/providers'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - }); - - if (!response.ok) { - throw new Error('Failed to fetch provider configuration status'); - } - - const providers: ProviderDetails[] = await response.json(); - - // Convert the response to the expected format - const data: Record = {}; - providers.forEach((provider) => { - const providerRequiredKeys = required_keys[provider.name] || []; - - data[provider.name] = { - name: provider.name, - supported: true, - description: provider.metadata.description, - models: provider.metadata.models, - config_status: providerRequiredKeys.reduce>((acc, key) => { - acc[key] = { - key, - is_set: provider.is_configured, - location: provider.is_configured ? 'config' : undefined, - }; - return acc; - }, {}), - }; - }); - - return data; -} diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx new file mode 100644 index 00000000..317b9e2b --- /dev/null +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from 'react'; +import { Switch } from '../../ui/switch'; + +export default function AppSettingsSection() { + const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); + const [dockIconEnabled, setDockIconEnabled] = useState(true); + const [isMacOS, setIsMacOS] = useState(false); + const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); + + // Check if running on macOS + useEffect(() => { + setIsMacOS(window.electron.platform === 'darwin'); + }, []); + + // Load menu bar and dock icon states + useEffect(() => { + window.electron.getMenuBarIconState().then((enabled) => { + setMenuBarIconEnabled(enabled); + }); + + if (isMacOS) { + window.electron.getDockIconState().then((enabled) => { + setDockIconEnabled(enabled); + }); + } + }, [isMacOS]); + + const handleMenuBarIconToggle = async () => { + const newState = !menuBarIconEnabled; + // If we're turning off the menu bar icon and the dock icon is hidden, + // we need to show the dock icon to maintain accessibility + if (!newState && !dockIconEnabled && isMacOS) { + const success = await window.electron.setDockIcon(true); + if (success) { + setDockIconEnabled(true); + } + } + const success = await window.electron.setMenuBarIcon(newState); + if (success) { + setMenuBarIconEnabled(newState); + } + }; + + const handleDockIconToggle = async () => { + const newState = !dockIconEnabled; + // If we're turning off the dock icon and the menu bar icon is hidden, + // we need to show the menu bar icon to maintain accessibility + if (!newState && !menuBarIconEnabled) { + const success = await window.electron.setMenuBarIcon(true); + if (success) { + setMenuBarIconEnabled(true); + } + } + + // Disable the switch to prevent rapid toggling + setIsDockSwitchDisabled(true); + setTimeout(() => { + setIsDockSwitchDisabled(false); + }, 1000); + + // Set the dock icon state + const success = await window.electron.setDockIcon(newState); + if (success) { + setDockIconEnabled(newState); + } + }; + + return ( +
+
+

App Settings

+
+
+

Configure Goose app

+
+
+
+

Menu Bar Icon

+

+ Show Goose in the menu bar +

+
+
+ +
+
+ + {isMacOS && ( +
+
+

Dock Icon

+

Show Goose in the dock

+
+
+ +
+
+ )} +
+
+
+ ); +} diff --git a/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx b/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx deleted file mode 100644 index f0fbe20d..00000000 --- a/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Card } from '../../ui/card'; -import { Button } from '../../ui/button'; -import { GooseMode, ModeSelectionItem } from './ModeSelectionItem'; - -interface ConfigureApproveModeProps { - onClose: () => void; - handleModeChange: (newMode: string) => void; - currentMode: string | null; -} - -export function ConfigureApproveMode({ - onClose, - handleModeChange, - currentMode, -}: ConfigureApproveModeProps) { - const approveModes: GooseMode[] = [ - { - key: 'approve', - label: 'Manual Approval', - description: 'All tools, extensions and file modifications will require human approval', - }, - { - key: 'smart_approve', - label: 'Smart Approval', - description: 'Intelligently determine which actions need approval based on risk level ', - }, - ]; - - const [isSubmitting, setIsSubmitting] = useState(false); - const [approveMode, setApproveMode] = useState(currentMode); - - useEffect(() => { - setApproveMode(currentMode); - }, [currentMode]); - - const handleModeSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - setIsSubmitting(true); - try { - handleModeChange(approveMode); - onClose(); - } catch (error) { - console.error('Error configuring goose mode:', error); - } finally { - setIsSubmitting(false); - } - }; - - return ( -
- -
-
- {/* Header */} -
-

- Configure Approve Mode -

-
- -
-

- Approve requests can either be given to all tool requests or determine which actions - may need integration -

-
- {approveModes.map((mode) => ( - { - setApproveMode(newMode); - }} - /> - ))} -
-
-
-
- - {/* Actions */} -
- - -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/basic/ModeSelection.tsx b/ui/desktop/src/components/settings/basic/ModeSelection.tsx deleted file mode 100644 index 96307496..00000000 --- a/ui/desktop/src/components/settings/basic/ModeSelection.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { all_goose_modes, filterGooseModes, ModeSelectionItem } from './ModeSelectionItem'; -import { useConfig } from '../../ConfigContext'; - -export const ModeSelection = () => { - const [currentMode, setCurrentMode] = useState('auto'); - const [previousApproveModel, setPreviousApproveModel] = useState(''); - const { read, upsert } = useConfig(); - - const handleModeChange = async (newMode: string) => { - try { - await upsert('GOOSE_MODE', newMode, false); - // Only track the previous approve if current mode is approve related but new mode is not. - if (currentMode.includes('approve') && !newMode.includes('approve')) { - setPreviousApproveModel(currentMode); - } - setCurrentMode(newMode); - } catch (error) { - console.error('Error updating goose mode:', error); - throw new Error(`Failed to store new goose mode: ${newMode}`); - } - }; - - const fetchCurrentMode = useCallback(async () => { - try { - const mode = (await read('GOOSE_MODE', false)) as string; - if (mode) { - setCurrentMode(mode); - } - } catch (error) { - console.error('Error fetching current mode:', error); - } - }, [read]); - - useEffect(() => { - fetchCurrentMode(); - }, [fetchCurrentMode]); - - return ( -
-
- {filterGooseModes(currentMode, all_goose_modes, previousApproveModel).map((mode) => ( - - ))} -
-
- ); -}; diff --git a/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx b/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx deleted file mode 100644 index f025b80a..00000000 --- a/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Gear } from '../../icons'; -import { ConfigureApproveMode } from './ConfigureApproveMode'; - -export interface GooseMode { - key: string; - label: string; - description: string; -} - -export const all_goose_modes: GooseMode[] = [ - { - key: 'auto', - label: 'Autonomous', - description: 'Full file modification capabilities, edit, create, and delete files freely.', - }, - { - key: 'approve', - label: 'Manual Approval', - description: 'All tools, extensions and file modifications will require human approval', - }, - { - key: 'smart_approve', - label: 'Smart Approval', - description: 'Intelligently determine which actions need approval based on risk level ', - }, - { - key: 'chat', - label: 'Chat Only', - description: 'Engage with the selected provider without using tools or extensions.', - }, -]; - -export function filterGooseModes( - currentMode: string, - modes: GooseMode[], - previousApproveMode: string -) { - return modes.filter((mode) => { - const approveList = ['approve', 'smart_approve']; - const nonApproveList = ['auto', 'chat']; - // Always keep 'auto' and 'chat' - if (nonApproveList.includes(mode.key)) { - return true; - } - // If current mode is non approve mode, we display write approve by default. - if (nonApproveList.includes(currentMode) && !previousApproveMode) { - return mode.key === 'smart_approve'; - } - - // Always include the current and previou approve mode - if (mode.key === currentMode) { - return true; - } - - // Current mode and previous approve mode cannot exist at the same time. - if (approveList.includes(currentMode) && approveList.includes(previousApproveMode)) { - return false; - } - - if (mode.key === previousApproveMode) { - return true; - } - - return false; - }); -} - -interface ModeSelectionItemProps { - currentMode: string; - mode: GooseMode; - showDescription: boolean; - isApproveModeConfigure: boolean; - handleModeChange: (newMode: string) => void; -} - -export function ModeSelectionItem({ - currentMode, - mode, - showDescription, - isApproveModeConfigure, - handleModeChange, -}: ModeSelectionItemProps) { - const [checked, setChecked] = useState(currentMode == mode.key); - const [isDislogOpen, setIsDislogOpen] = useState(false); - - useEffect(() => { - setChecked(currentMode === mode.key); - }, [currentMode, mode.key]); - - return ( -
-
handleModeChange(mode.key)}> -
-
-

{mode.label}

- {showDescription && ( -

{mode.description}

- )} -
-
-
- {!isApproveModeConfigure && (mode.key == 'approve' || mode.key == 'smart_approve') && ( - - )} - handleModeChange(mode.key)} - className="peer sr-only" - /> -
-
-
-
-
- {isDislogOpen ? ( - { - setIsDislogOpen(false); - }} - handleModeChange={handleModeChange} - currentMode={currentMode} - /> - ) : null} -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx deleted file mode 100644 index d41b0640..00000000 --- a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React from 'react'; -import { Card } from '../../ui/card'; -import { Button } from '../../ui/button'; -import { Input } from '../../ui/input'; -import { FullExtensionConfig } from '../../../extensions'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { addExtension } from '../../../extensions'; -import { toastError, toastSuccess } from '../../../toasts'; - -interface ConfigureExtensionModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: () => void; - extension: FullExtensionConfig | null; -} - -export function ConfigureBuiltInExtensionModal({ - isOpen, - onClose, - onSubmit, - extension, -}: ConfigureExtensionModalProps) { - const [envValues, setEnvValues] = React.useState>({}); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - // Reset form when dialog closes or extension changes - React.useEffect(() => { - if (!isOpen || !extension) { - setEnvValues({}); - } - }, [isOpen, extension]); - - const handleExtensionConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!extension) return; - - setIsSubmitting(true); - try { - // First store all environment variables - if (extension.env_keys?.length > 0) { - for (const envKey of extension.env_keys) { - const value = envValues[envKey]; - if (!value) continue; - - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: envKey, - value: value.trim(), - isSecret: true, - }), - }); - - if (!storeResponse.ok) { - throw new Error(`Failed to store environment variable: ${envKey}`); - } - } - } - - const response = await addExtension(extension); - - if (!response.ok) { - throw new Error('Failed to add system configuration'); - } - - toastSuccess({ - title: extension.name, - msg: `Successfully configured extension`, - }); - onSubmit(); - onClose(); - } catch (error) { - console.error('Error configuring extension:', error); - toastError({ - title: extension.name, - msg: `Failed to configure the extension`, - traceback: error.message, - }); - } finally { - setIsSubmitting(false); - } - }; - - if (!extension || !isOpen) return null; - - return ( -
- -
- {/* Header */} -
-

- Configure {extension.name} -

-
- - {/* Form */} -
-
- {extension.env_keys?.length > 0 ? ( - <> -

- Please provide the required environment variables for this extension: -

-
- {extension.env_keys?.map((envVarName) => ( -
- - - setEnvValues((prev) => ({ - ...prev, - [envVarName]: e.target.value, - })) - } - className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder:text-gray-500" - required - /> -
- ))} -
- - ) : ( -

- This extension doesn't require any environment variables. -

- )} -
- - {/* Actions */} -
- - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx deleted file mode 100644 index 82dcf8ba..00000000 --- a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react'; -import { Card } from '../../ui/card'; -import { Button } from '../../ui/button'; -import { Input } from '../../ui/input'; -import { FullExtensionConfig } from '../../../extensions'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { addExtension } from '../../../extensions'; -import { toastError, toastSuccess } from '../../../toasts'; - -interface ConfigureExtensionModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: () => void; - onRemove: () => void; - extension: FullExtensionConfig | null; -} - -export function ConfigureExtensionModal({ - isOpen, - onClose, - onSubmit, - onRemove, - extension, -}: ConfigureExtensionModalProps) { - const [envValues, setEnvValues] = React.useState>({}); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - // Reset form when dialog closes or extension changes - React.useEffect(() => { - if (!isOpen || !extension) { - setEnvValues({}); - } - }, [isOpen, extension]); - - const handleExtensionConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!extension) return; - - setIsSubmitting(true); - try { - // First store all environment variables - if (extension.env_keys?.length > 0) { - for (const envKey of extension.env_keys) { - const value = envValues[envKey]; - if (!value) continue; - - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: envKey, - value: value.trim(), - isSecret: true, - }), - }); - - if (!storeResponse.ok) { - throw new Error(`Failed to store environment variable: ${envKey}`); - } - } - } - - const response = await addExtension(extension); - - if (!response.ok) { - throw new Error('Failed to add system configuration'); - } - - toastSuccess({ - title: extension.name, - msg: `Successfully configured extension`, - }); - onSubmit(); - onClose(); - } catch (error) { - console.error('Error configuring extension:', error); - toastError({ - title: extension.name, - msg: `Failed to configure extension`, - traceback: error.message, - }); - } finally { - setIsSubmitting(false); - } - }; - - if (!extension || !isOpen) return null; - - return ( -
- -
- {/* Header */} -
-

- Configure {extension.name} -

-
- - {/* Form */} -
-
- {extension.env_keys?.length > 0 ? ( - <> -

- Please provide the required environment variables for this extension: -

-
- {extension.env_keys?.map((envVarName) => ( -
- - - setEnvValues((prev) => ({ - ...prev, - [envVarName]: e.target.value, - })) - } - className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder:text-gray-500" - required - /> -
- ))} -
- - ) : ( -

- This extension doesn't require any environment variables. -

- )} -
- - {/* Actions */} -
- - - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/extensions/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/ExtensionItem.tsx deleted file mode 100644 index 29b701f7..00000000 --- a/ui/desktop/src/components/settings/extensions/ExtensionItem.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { FullExtensionConfig } from '../../../extensions'; -import { Gear } from '../../icons'; - -type ExtensionItemProps = FullExtensionConfig & { - onToggle: (id: string) => void; - onConfigure: (extension: FullExtensionConfig) => void; - canConfigure?: boolean; // Added optional prop here -}; - -export const ExtensionItem: React.FC = (props) => { - const { id, name, description, enabled, onToggle, onConfigure, canConfigure } = props; - - return ( -
-
-
-
-

{name}

-
-

{description}

-
-
- {canConfigure && ( // Conditionally render the gear icon - - )} - -
-
-
- ); -}; diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx similarity index 98% rename from ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx rename to ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index 99e28a9f..c2e41f1b 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -250,7 +250,10 @@ export default function ExtensionsSection({ {deepLinkConfigStateVar && showEnvVarsStateVar && ( void; - onSubmit: (extension: FullExtensionConfig) => void; -} - -export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtensionModalProps) { - const [formData, setFormData] = useState< - Partial & { commandInput?: string } - >({ - type: 'stdio', - enabled: true, - args: [], - commandInput: '', - timeout: DEFAULT_EXTENSION_TIMEOUT, - }); - const [envKey, setEnvKey] = useState(''); - const [envValue, setEnvValue] = useState(''); - const [envVars, setEnvVars] = useState>([]); - - const typeOptions = [ - { value: 'stdio', label: 'Standard IO' }, - { value: 'sse', label: 'Server-Sent Events' }, - { value: 'builtin', label: 'Built-in' }, - ]; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!formData.id || !formData.name || !formData.description) { - toastError({ title: 'Please fill in all required fields' }); - return; - } - - if (formData.type === 'stdio' && !formData.commandInput) { - toastError({ title: 'Command is required for stdio type' }); - return; - } - - if (formData.type === 'sse' && !formData.uri) { - toastError({ title: 'URI is required for SSE type' }); - return; - } - - if (formData.type === 'builtin' && !formData.name) { - toastError({ title: 'Name is required for builtin type' }); - return; - } - - try { - // Store environment variables as secrets - for (const envVar of envVars) { - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: envVar.key, - value: envVar.value.trim(), - isSecret: true, - }), - }); - - if (!storeResponse.ok) { - throw new Error(`Failed to store environment variable: ${envVar.key}`); - } - } - - // Parse command input into cmd and args - let cmd = ''; - let args: string[] = []; - if (formData.type === 'stdio' && formData.commandInput) { - const parts = formData.commandInput.trim().split(/\s+/); - [cmd, ...args] = parts; - } - - const extension: FullExtensionConfig = { - ...formData, - type: formData.type!, - enabled: true, - env_keys: envVars.map((v) => v.key), - ...(formData.type === 'stdio' && { cmd, args }), - } as FullExtensionConfig; - - onSubmit(extension); - resetForm(); - } catch (error) { - console.error('Error configuring extension:', error); - toastError({ title: 'Failed to configure extension', traceback: error.message }); - } - }; - - const resetForm = () => { - setFormData({ - type: 'stdio', - enabled: true, - args: [], - commandInput: '', - }); - setEnvVars([]); - setEnvKey(''); - setEnvValue(''); - }; - - const handleAddEnvVar = () => { - if (envKey && !envVars.some((v) => v.key === envKey)) { - setEnvVars([...envVars, { key: envKey, value: envValue }]); - setEnvKey(''); - setEnvValue(''); - } - }; - - const handleRemoveEnvVar = (key: string) => { - setEnvVars(envVars.filter((v) => v.key !== key)); - }; - - if (!isOpen) return null; - - return ( -
- -
-
-

Add custom extension

-
- -
-
-
- - setFormData({ ...formData, id: e.target.value })} - className="w-full" - required - /> -
- -
- - setFormData({ ...formData, name: e.target.value })} - className="w-full" - required - /> -
- -
- - setFormData({ ...formData, description: e.target.value })} - className="w-full" - required - /> -
- - {formData.type === 'stdio' && ( -
- - setFormData({ ...formData, commandInput: e.target.value })} - placeholder="e.g. goosed mcp example" - className="w-full" - required - /> -
- )} - - {formData.type === 'sse' && ( -
- - setFormData({ ...formData, uri: e.target.value })} - className="w-full" - required - /> -
- )} - -
- -
- setEnvKey(e.target.value)} - placeholder="Environment variable name" - className="flex-1" - /> - setEnvValue(e.target.value)} - placeholder="Value" - className="flex-1" - /> - - -
- {envVars.length > 0 && ( -
- {envVars.map((envVar) => ( -
-
-
- {envVar.key} - - = {envVar.value} - -
-
-
- -
-
- ))} -
- )} -
- -
- - setFormData({ ...formData, timeout: parseInt(e.target.value) })} - className="w-full" - required - /> -
-
-
- - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings_v2/extensions/agent-api.ts b/ui/desktop/src/components/settings/extensions/agent-api.ts similarity index 90% rename from ui/desktop/src/components/settings_v2/extensions/agent-api.ts rename to ui/desktop/src/components/settings/extensions/agent-api.ts index 243a91c2..52fd7744 100644 --- a/ui/desktop/src/components/settings_v2/extensions/agent-api.ts +++ b/ui/desktop/src/components/settings/extensions/agent-api.ts @@ -29,11 +29,11 @@ export async function extensionApiCall( }; // for adding the payload is an extensionConfig, for removing payload is just the name - const extensionName = isActivating ? payload.name : payload; + const extensionName = isActivating ? (payload as ExtensionConfig).name : (payload as string); let toastId; // Step 1: Show loading toast (only for activation of stdio) - if (isActivating && (payload as ExtensionConfig) && payload.type == 'stdio') { + if (isActivating && typeof payload === 'object' && payload.type === 'stdio') { toastId = toastService.loading({ title: extensionName, msg: `${action.verb} ${extensionName} extension...`, @@ -77,11 +77,13 @@ export async function extensionApiCall( } catch (error) { // Final catch-all error handler toastService.dismiss(toastId); - const msg = error.length < 70 ? error : `Failed to ${action.presentTense} extension`; + const errorMessage = error instanceof Error ? error.message : String(error); + const msg = + errorMessage.length < 70 ? errorMessage : `Failed to ${action.presentTense} extension`; toastService.error({ title: extensionName, msg: msg, - traceback: error, + traceback: errorMessage, }); console.error(`Error in extensionApiCall for ${extensionName}:`, error); throw error; @@ -95,7 +97,7 @@ function handleErrorResponse( response: Response, extensionName: string, action: { type: string; verb: string }, - toastId: string + toastId: string | number | undefined ): never { const errorMsg = `Server returned ${response.status}: ${response.statusText}`; console.error(errorMsg); @@ -150,7 +152,7 @@ export async function addToAgent( return await extensionApiCall('/extensions/add', extension, options); } catch (error) { // Check if this is a 428 error and make the message more descriptive - if (error.message && error.message.includes('428')) { + if (error instanceof Error && error.message && error.message.includes('428')) { const enhancedError = new Error( 'Failed to add extension. Goose Agent was still starting up. Please try again.' ); diff --git a/ui/desktop/src/components/settings_v2/extensions/bundled-extensions.json b/ui/desktop/src/components/settings/extensions/bundled-extensions.json similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/bundled-extensions.json rename to ui/desktop/src/components/settings/extensions/bundled-extensions.json diff --git a/ui/desktop/src/components/settings_v2/extensions/bundled-extensions.ts b/ui/desktop/src/components/settings/extensions/bundled-extensions.ts similarity index 95% rename from ui/desktop/src/components/settings_v2/extensions/bundled-extensions.ts rename to ui/desktop/src/components/settings/extensions/bundled-extensions.ts index aa560995..b84bd778 100644 --- a/ui/desktop/src/components/settings_v2/extensions/bundled-extensions.ts +++ b/ui/desktop/src/components/settings/extensions/bundled-extensions.ts @@ -63,10 +63,10 @@ export async function syncBundledExtensions( description: bundledExt.description, type: bundledExt.type, timeout: bundledExt.timeout, - cmd: bundledExt.cmd, - args: bundledExt.args, + cmd: bundledExt.cmd || '', + args: bundledExt.args || [], envs: bundledExt.envs, - env_keys: bundledExt.env_keys, + env_keys: bundledExt.env_keys || [], bundled: true, }; break; @@ -76,7 +76,7 @@ export async function syncBundledExtensions( description: bundledExt.description, type: bundledExt.type, timeout: bundledExt.timeout, - uri: bundledExt.uri, + uri: bundledExt.uri || '', bundled: true, }; } diff --git a/ui/desktop/src/components/settings_v2/extensions/deeplink.ts b/ui/desktop/src/components/settings/extensions/deeplink.ts similarity index 96% rename from ui/desktop/src/components/settings_v2/extensions/deeplink.ts rename to ui/desktop/src/components/settings/extensions/deeplink.ts index 41914236..5c647d07 100644 --- a/ui/desktop/src/components/settings_v2/extensions/deeplink.ts +++ b/ui/desktop/src/components/settings/extensions/deeplink.ts @@ -122,8 +122,8 @@ export async function addExtensionFromDeepLink( const remoteUrl = parsedUrl.searchParams.get('url'); const config = remoteUrl - ? getSseConfig(remoteUrl, name, description, timeout) - : getStdioConfig(cmd!, parsedUrl, name, description, timeout); + ? getSseConfig(remoteUrl, name, description || '', timeout) + : getStdioConfig(cmd!, parsedUrl, name, description || '', timeout); // Check if extension requires env vars and go to settings if so if (config.envs && Object.keys(config.envs).length > 0) { diff --git a/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts similarity index 97% rename from ui/desktop/src/components/settings_v2/extensions/extension-manager.ts rename to ui/desktop/src/components/settings/extensions/extension-manager.ts index bd0f4113..9c1a3459 100644 --- a/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -25,7 +25,7 @@ async function retryWithBackoff(fn: () => Promise, options: RetryOptions = const { retries = 3, delayMs = 1000, backoffFactor = 1.5, shouldRetry = () => true } = options; let attempt = 0; - let lastError: ExtensionError; + let lastError: ExtensionError = new Error('Unknown error'); while (attempt <= retries) { try { @@ -100,7 +100,7 @@ export async function addToAgentOnStartup({ retries: 3, delayMs: 1000, shouldRetry: (error: ExtensionError) => - error.message && + !!error.message && (error.message.includes('428') || error.message.includes('Precondition Required') || error.message.includes('Agent is not initialized')), @@ -110,7 +110,7 @@ export async function addToAgentOnStartup({ toastService.error({ title: extensionConfig.name, msg: 'Extension failed to start and will be disabled.', - traceback: finalError as Error, + traceback: finalError instanceof Error ? finalError.message : String(finalError), }); try { diff --git a/ui/desktop/src/components/settings_v2/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/index.ts rename to ui/desktop/src/components/settings/extensions/index.ts diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx b/ui/desktop/src/components/settings/extensions/modal/EnvVarsSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx rename to ui/desktop/src/components/settings/extensions/modal/EnvVarsSection.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx similarity index 98% rename from ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx rename to ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx index 36fa09cc..9acb085f 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx @@ -1,5 +1,4 @@ import { Input } from '../../../ui/input'; -import React from 'react'; interface ExtensionConfigFieldsProps { type: 'stdio' | 'sse' | 'builtin'; diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx similarity index 95% rename from ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx rename to ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx index 0b426c70..e779505a 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx @@ -44,7 +44,8 @@ export default function ExtensionInfoFields({ option.value === selectedProvider) || null} - onChange={(option: { value: string | null }) => { - setSelectedProvider(option?.value || null); - setModelName(''); // Clear model name when provider changes - setFilteredModels([]); - }} - placeholder="Select provider" - isClearable - styles={createDarkSelectStyles('200px')} - theme={darkSelectTheme} - /> -
- setModelName(e.target.value)} - onBlur={handleBlur} - /> - {showSuggestions && ( -
- {filteredModels.map((model) => ( -
handleSelectSuggestion(model)} - > - {model.name} -
- ))} -
- )} -
- - -
- ); -} diff --git a/ui/desktop/src/components/settings/models/GooseModels.tsx b/ui/desktop/src/components/settings/models/GooseModels.tsx deleted file mode 100644 index 4badf402..00000000 --- a/ui/desktop/src/components/settings/models/GooseModels.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Model } from './ModelContext'; - -// TODO: move into backends / fetch dynamically -// this is used by ModelContext -export const gooseModels: Model[] = [ - { id: 1, name: 'gpt-4o-mini', provider: 'OpenAI' }, - { id: 2, name: 'gpt-4o', provider: 'OpenAI' }, - { id: 3, name: 'gpt-4-turbo', provider: 'OpenAI' }, - { id: 5, name: 'o1', provider: 'OpenAI' }, - { id: 7, name: 'claude-3-5-sonnet-latest', provider: 'Anthropic' }, - { id: 8, name: 'claude-3-5-haiku-latest', provider: 'Anthropic' }, - { id: 9, name: 'claude-3-opus-latest', provider: 'Anthropic' }, - { id: 10, name: 'gemini-1.5-pro', provider: 'Google' }, - { id: 11, name: 'gemini-1.5-flash', provider: 'Google' }, - { id: 12, name: 'gemini-2.0-flash', provider: 'Google' }, - { id: 13, name: 'gemini-2.0-flash-lite-preview-02-05', provider: 'Google' }, - { id: 14, name: 'gemini-2.0-flash-thinking-exp-01-21', provider: 'Google' }, - { id: 15, name: 'gemini-2.0-pro-exp-02-05', provider: 'Google' }, - { id: 16, name: 'gemini-2.5-pro-exp-03-25', provider: 'Google' }, - { id: 17, name: 'llama-3.3-70b-versatile', provider: 'Groq' }, - { id: 18, name: 'qwen2.5', provider: 'Ollama' }, - { id: 19, name: 'anthropic/claude-3.5-sonnet', provider: 'OpenRouter' }, - { id: 20, name: 'gpt-4o', provider: 'Azure OpenAI' }, - { id: 21, name: 'claude-3-7-sonnet@20250219', provider: 'GCP Vertex AI' }, - { id: 22, name: 'claude-3-5-sonnet-v2@20241022', provider: 'GCP Vertex AI' }, - { id: 23, name: 'claude-3-5-sonnet@20240620', provider: 'GCP Vertex AI' }, - { id: 24, name: 'claude-3-5-haiku@20241022', provider: 'GCP Vertex AI' }, - { id: 25, name: 'claude-sonnet-4@20250514', provider: 'GCP Vertex AI' }, - { id: 26, name: 'gemini-2.0-pro-exp-02-05', provider: 'GCP Vertex AI' }, - { id: 27, name: 'gemini-2.0-flash-001', provider: 'GCP Vertex AI' }, - { id: 28, name: 'gemini-1.5-pro-002', provider: 'GCP Vertex AI' }, - { id: 29, name: 'gemini-2.5-pro-exp-03-25', provider: 'GCP Vertex AI' }, -]; diff --git a/ui/desktop/src/components/settings/models/ModelContext.tsx b/ui/desktop/src/components/settings/models/ModelContext.tsx deleted file mode 100644 index 560e80ef..00000000 --- a/ui/desktop/src/components/settings/models/ModelContext.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { createContext, useContext, useState, ReactNode } from 'react'; -import { GOOSE_MODEL, GOOSE_PROVIDER } from '../../../env_vars'; -import { gooseModels } from './GooseModels'; // Assuming hardcoded models are here - -// TODO: API keys -export interface Model { - id?: number; // Make `id` optional to allow user-defined models - name: string; - provider: string; - lastUsed?: string; - alias?: string; // optional model display name - subtext?: string; // goes below model name if not the provider -} - -interface ModelContextValue { - currentModel: Model | null; - setCurrentModel: (model: Model) => void; - switchModel: (model: Model) => void; // Add the reusable switch function -} - -const ModelContext = createContext(undefined); - -export const ModelProvider = ({ children }: { children: ReactNode }) => { - const [currentModel, setCurrentModel] = useState( - JSON.parse(localStorage.getItem(GOOSE_MODEL) || 'null') - ); - - const updateModel = (model: Model) => { - setCurrentModel(model); - localStorage.setItem(GOOSE_PROVIDER, model.provider.toLowerCase()); - localStorage.setItem(GOOSE_MODEL, JSON.stringify(model)); - }; - - const switchModel = (model: Model) => { - const newModel = model.id - ? gooseModels.find((m) => m.id === model.id) || model - : { id: Date.now(), ...model }; // Assign unique ID for user-defined models - updateModel(newModel); - }; - - return ( - - {children} - - ); -}; - -export const useModel = () => { - const context = useContext(ModelContext); - if (!context) throw new Error('useModel must be used within a ModelProvider'); - return context; -}; diff --git a/ui/desktop/src/components/settings/models/ModelRadioList.tsx b/ui/desktop/src/components/settings/models/ModelRadioList.tsx deleted file mode 100644 index 7fd4eac8..00000000 --- a/ui/desktop/src/components/settings/models/ModelRadioList.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useRecentModels } from './RecentModels'; -import { useModel, Model } from './ModelContext'; -import { useHandleModelSelection } from './utils'; -import type { View } from '@/src/App'; - -interface ModelRadioListProps { - renderItem: (props: { - model: Model; - isSelected: boolean; - onSelect: () => void; - }) => React.ReactNode; - className?: string; -} - -export function SeeMoreModelsButtons({ setView }: { setView: (view: View) => void }) { - return ( -
-

Models

- -
- ); -} - -export function ModelRadioList({ renderItem, className = '' }: ModelRadioListProps) { - const { recentModels } = useRecentModels(); - const { currentModel } = useModel(); - const handleModelSelection = useHandleModelSelection(); - const [selectedModel, setSelectedModel] = useState(null); - - useEffect(() => { - if (currentModel) { - setSelectedModel(currentModel.name); - } - }, [currentModel]); - - const handleRadioChange = async (model: Model) => { - if (selectedModel === model.name) { - console.log(`Model "${model.name}" is already active.`); - return; - } - - setSelectedModel(model.name); - await handleModelSelection(model, 'ModelList'); - }; - - return ( -
- {recentModels.map((model) => - renderItem({ - model, - isSelected: selectedModel === model.name, - onSelect: () => handleRadioChange(model), - }) - )} -
- ); -} diff --git a/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx b/ui/desktop/src/components/settings/models/ModelsSection.tsx similarity index 97% rename from ui/desktop/src/components/settings_v2/models/ModelsSection.tsx rename to ui/desktop/src/components/settings/models/ModelsSection.tsx index b9aef231..9b380c13 100644 --- a/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx +++ b/ui/desktop/src/components/settings/models/ModelsSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import type { View } from '../../../App'; import ModelSettingsButtons from './subcomponents/ModelSettingsButtons'; import { useConfig } from '../../ConfigContext'; diff --git a/ui/desktop/src/components/settings/models/MoreModelsView.tsx b/ui/desktop/src/components/settings/models/MoreModelsView.tsx deleted file mode 100644 index 0ca8bc0c..00000000 --- a/ui/desktop/src/components/settings/models/MoreModelsView.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import { RecentModels } from './RecentModels'; -import { ProviderButtons } from './ProviderButtons'; -import BackButton from '../../ui/BackButton'; -import { SearchBar } from './Search'; -import { AddModelInline } from './AddModelInline'; -import { ScrollArea } from '../../ui/scroll-area'; -import type { View } from '../../../App'; -import MoreMenuLayout from '../../more_menu/MoreMenuLayout'; - -export default function MoreModelsView({ - onClose, - setView, -}: { - onClose: () => void; - setView: (view: View) => void; -}) { - return ( -
- - - -
- -

Browse models

-
- - {/* Content Area */} -
-
-
-

Models

- -
- -
- {/* Search Section */} -
-

Search Models

- -
- - {/* Add Model Section */} -
-

Add Model

- -
- - {/* Provider Section */} -
-

Browse by Provider

-
- -
-
- - {/* Recent Models Section */} -
-
-

Recently used

-
-
- -
-
-
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/models/ProviderButtons.tsx b/ui/desktop/src/components/settings/models/ProviderButtons.tsx deleted file mode 100644 index 66257cf5..00000000 --- a/ui/desktop/src/components/settings/models/ProviderButtons.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Button } from '../../ui/button'; -import { Switch } from '../../ui/switch'; -import { useActiveKeys } from '../api_keys/ActiveKeysContext'; -import { model_docs_link } from './hardcoded_stuff'; -import { gooseModels } from './GooseModels'; -import { useModel } from './ModelContext'; -import { useHandleModelSelection } from './utils'; - -// Create a mapping from provider name to href -const providerLinks = model_docs_link.reduce((acc, { name, href }) => { - acc[name] = href; - return acc; -}, {}); - -export function ProviderButtons() { - const { activeKeys } = useActiveKeys(); - const [selectedProvider, setSelectedProvider] = useState(null); - const { currentModel } = useModel(); - const handleModelSelection = useHandleModelSelection(); - - // Handle Escape key press - useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setSelectedProvider(null); - } - }; - window.addEventListener('keydown', handleEsc); - return () => window.removeEventListener('keydown', handleEsc); - }, []); - - // Filter models by provider - const providerModels = selectedProvider - ? gooseModels.filter((model) => model.provider === selectedProvider) - : []; - - return ( -
-
-
- {activeKeys.map((provider) => ( - - ))} -
-
- - {/* Models List */} - {selectedProvider && ( -
-
- {providerModels.map((model) => ( -
- {model.name} - handleModelSelection(model, 'ProviderButtons')} - /> -
- ))} -
- - - Browse more {selectedProvider} models - -
- )} -
- ); -} diff --git a/ui/desktop/src/components/settings/models/RecentModels.tsx b/ui/desktop/src/components/settings/models/RecentModels.tsx deleted file mode 100644 index ecfb7095..00000000 --- a/ui/desktop/src/components/settings/models/RecentModels.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Clock } from 'lucide-react'; -import { Model } from './ModelContext'; -import { ModelRadioList, SeeMoreModelsButtons } from './ModelRadioList'; -import { useModel } from './ModelContext'; -import { useHandleModelSelection } from './utils'; -import type { View } from '../../../App'; - -const MAX_RECENT_MODELS = 3; - -export function useRecentModels() { - const [recentModels, setRecentModels] = useState([]); - - useEffect(() => { - const storedModels = localStorage.getItem('recentModels'); - if (storedModels) { - setRecentModels(JSON.parse(storedModels)); - } - }, []); - - const addRecentModel = (model: Model) => { - const modelWithTimestamp = { ...model, lastUsed: new Date().toISOString() }; // Add lastUsed field - setRecentModels((prevModels) => { - const updatedModels = [ - modelWithTimestamp, - ...prevModels.filter((m) => m.name !== model.name), - ].slice(0, MAX_RECENT_MODELS); - - localStorage.setItem('recentModels', JSON.stringify(updatedModels)); - return updatedModels; - }); - }; - - return { recentModels, addRecentModel }; -} - -function getRelativeTimeString(date: string | Date): string { - const now = new Date(); - const then = new Date(date); - const diffInSeconds = Math.floor((now.getTime() - then.getTime()) / 1000); - - if (diffInSeconds < 60) { - return 'Just now'; - } - - const diffInMinutes = Math.floor(diffInSeconds / 60); - if (diffInMinutes < 60) { - return `${diffInMinutes}m ago`; - } - - const diffInHours = Math.floor(diffInMinutes / 60); - if (diffInHours < 24) { - return `${diffInHours}h ago`; - } - - const diffInDays = Math.floor(diffInHours / 24); - if (diffInDays < 7) { - return `${diffInDays}d ago`; - } - - if (diffInDays < 30) { - const weeks = Math.floor(diffInDays / 7); - return `${weeks}w ago`; - } - - const months = Math.floor(diffInDays / 30); - if (months < 12) { - return `${months}mo ago`; - } - - const years = Math.floor(months / 12); - return `${years}y ago`; -} - -export function RecentModels() { - const { recentModels } = useRecentModels(); - const { currentModel } = useModel(); - const handleModelSelection = useHandleModelSelection(); - const [selectedModel, setSelectedModel] = useState(null); - - useEffect(() => { - if (currentModel) { - setSelectedModel(currentModel.name); - } - }, [currentModel]); - - const handleRadioChange = async (model: Model) => { - if (selectedModel === model.name) { - console.log(`Model "${model.name}" is already active.`); - return; - } - - setSelectedModel(model.name); - await handleModelSelection(model, 'RecentModels'); - }; - - return ( -
- {recentModels.map((model) => ( - - ))} -
- ); -} - -export function RecentModelsRadio({ setView }: { setView: (view: View) => void }) { - return ( -
- -
-
- ( - - )} - /> -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/models/Search.tsx b/ui/desktop/src/components/settings/models/Search.tsx deleted file mode 100644 index 5f743937..00000000 --- a/ui/desktop/src/components/settings/models/Search.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Search } from 'lucide-react'; -import { Switch } from '../../ui/switch'; -import { gooseModels } from './GooseModels'; -import { useModel } from './ModelContext'; -import { useHandleModelSelection } from './utils'; -import { useActiveKeys } from '../api_keys/ActiveKeysContext'; - -// TODO: dark mode (p1) -// FIXME: arrow keys do not work to select a model (p1) -export function SearchBar() { - const [search, setSearch] = useState(''); - const [focusedIndex, setFocusedIndex] = useState(-1); - const [showResults, setShowResults] = useState(false); - const resultsRef = useRef<(HTMLDivElement | null)[]>([]); - const searchBarRef = useRef(null); - - const { currentModel } = useModel(); // Access global state - const handleModelSelection = useHandleModelSelection(); - - // search results filtering - // results set will only include models that have a configured provider - const { activeKeys } = useActiveKeys(); // Access active keys from context - - const model_options = gooseModels.filter((model) => activeKeys.includes(model.provider)); - - const filteredModels = model_options - .filter((model) => model.name.toLowerCase().includes(search.toLowerCase())) - .slice(0, 5); - - useEffect(() => { - setFocusedIndex(-1); - }, [search]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) { - setShowResults(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setFocusedIndex((prev) => (prev < filteredModels.length - 1 ? prev + 1 : prev)); - setShowResults(true); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - setShowResults(true); - } else if (e.key === 'Enter' && focusedIndex >= 0) { - e.preventDefault(); - const selectedModel = filteredModels[focusedIndex]; - handleModelSelection(selectedModel, 'SearchBar'); - } else if (e.key === 'Escape') { - e.preventDefault(); - setShowResults(false); - } - }; - - useEffect(() => { - if (focusedIndex >= 0 && focusedIndex < resultsRef.current.length) { - resultsRef.current[focusedIndex]?.scrollIntoView({ - block: 'nearest', - }); - } - }, [focusedIndex]); - - return ( -
- - { - setSearch(e.target.value); - setShowResults(true); - }} - onKeyDown={handleKeyDown} - onFocus={() => setShowResults(true)} - className="w-full pl-9 py-2 text-black dark:text-white bg-bgApp border border-borderSubtle rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - {showResults && search && ( -
- {filteredModels.length > 0 ? ( - filteredModels.map((model, index) => ( -
(resultsRef.current[index] = el)} - className={`p-2 flex justify-between items-center hover:bg-bgSubtle/50 dark:hover:bg-gray-700 cursor-pointer ${ - model.id === currentModel?.id ? 'bg-bgSubtle/50 dark:bg-gray-700' : '' - }`} - > -
- {model.name} - - {model.provider} - -
- handleModelSelection(model, 'SearchBar')} - /> -
- )) - ) : ( -
No models found
- )} -
- )} -
- ); -} diff --git a/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx similarity index 84% rename from ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx rename to ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx index 21bb47fa..2f37ce87 100644 --- a/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx @@ -1,7 +1,6 @@ import { Sliders } from 'lucide-react'; import React, { useEffect, useState, useRef } from 'react'; -import { useConfig } from '../../../ConfigContext'; -import { getCurrentModelAndProviderForDisplay } from '../index'; +import { useModelAndProvider } from '../../../ModelAndProviderContext'; import { AddModelModal } from '../subcomponents/AddModelModal'; import { View } from '../../../../App'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../../../ui/Tooltip'; @@ -11,10 +10,10 @@ interface ModelsBottomBarProps { setView: (view: View) => void; } export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBarProps) { - const { read, getProviders } = useConfig(); + const { currentModel, currentProvider, getCurrentModelAndProviderForDisplay } = + useModelAndProvider(); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); - const [provider, setProvider] = useState(null); - const [model, setModel] = useState(''); + const [displayProvider, setDisplayProvider] = useState(null); const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false); const menuRef = useRef(null); const [isModelTruncated, setIsModelTruncated] = useState(false); @@ -22,16 +21,15 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa const modelRef = useRef(null); const [isTooltipOpen, setIsTooltipOpen] = useState(false); + // Update display provider when current provider changes useEffect(() => { - (async () => { - const modelProvider = await getCurrentModelAndProviderForDisplay({ - readFromConfig: read, - getProviders, - }); - setProvider(modelProvider.provider as string | null); - setModel(modelProvider.model as string); - })(); - }); + if (currentProvider) { + (async () => { + const modelProvider = await getCurrentModelAndProviderForDisplay(); + setDisplayProvider(modelProvider.provider); + })(); + } + }, [currentProvider, getCurrentModelAndProviderForDisplay]); useEffect(() => { const checkTruncation = () => { @@ -42,7 +40,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa checkTruncation(); window.addEventListener('resize', checkTruncation); return () => window.removeEventListener('resize', checkTruncation); - }, [model]); + }, [currentModel]); useEffect(() => { setIsTooltipOpen(false); @@ -81,12 +79,12 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa ref={modelRef} className="truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0 block" > - {model || 'Select Model'} + {currentModel || 'Select Model'} {isModelTruncated && ( - {model || 'Select Model'} + {currentModel || 'Select Model'} )} @@ -99,7 +97,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
Current:
- {model} -- {provider} + {currentModel} -- {displayProvider}
void; }; export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { - const { getProviders, upsert } = useConfig(); - const { switchModel } = useModel(); - const [providerOptions, setProviderOptions] = useState([]); - const [modelOptions, setModelOptions] = useState([]); + const { getProviders } = useConfig(); + const { changeModel } = useModelAndProvider(); + const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]); + const [modelOptions, setModelOptions] = useState< + { options: { value: string; label: string; provider: string }[] }[] + >([]); const [provider, setProvider] = useState(null); const [model, setModel] = useState(''); const [isCustomModel, setIsCustomModel] = useState(false); @@ -80,18 +91,12 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { const isFormValid = validateForm(); if (isFormValid) { - const providerMetaData = await getProviderMetadata(provider, getProviders); + const providerMetaData = await getProviderMetadata(provider || '', getProviders); const providerDisplayName = providerMetaData.display_name; const modelObj = { name: model, provider: provider, subtext: providerDisplayName } as Model; - await changeModel({ - model: modelObj, - writeToConfig: upsert, - }); - - // Update the model context - switchModel(modelObj); + await changeModel(modelObj); onClose(); } @@ -122,7 +127,9 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { ]); // Format model options by provider - const formattedModelOptions = []; + const formattedModelOptions: { + options: { value: string; label: string; provider: string }[]; + }[] = []; activeProviders.forEach(({ metadata, name }) => { if (metadata.known_models && metadata.known_models.length > 0) { formattedModelOptions.push({ @@ -158,7 +165,8 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { : []; // Handle model selection change - const handleModelChange = (selectedOption) => { + const handleModelChange = (newValue: unknown) => { + const selectedOption = newValue as { value: string; label: string; provider: string } | null; if (selectedOption?.value === 'custom') { setIsCustomModel(true); setModel(''); @@ -169,7 +177,8 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { }; // Store the original model options in state, initialized from modelOptions - const [originalModelOptions, setOriginalModelOptions] = useState(modelOptions); + const [originalModelOptions, setOriginalModelOptions] = + useState<{ options: { value: string; label: string; provider: string }[] }[]>(modelOptions); const handleInputChange = (inputValue: string) => { if (!provider) return; @@ -221,8 +230,8 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { } > @@ -252,7 +261,8 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { -
- {urlError &&

{urlError}

} -
- )} -
-
- - ); -} diff --git a/ui/desktop/src/components/settings_v2/sessions/SessionSharingSection.tsx b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx similarity index 93% rename from ui/desktop/src/components/settings_v2/sessions/SessionSharingSection.tsx rename to ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx index 916d0a56..1ee306ca 100644 --- a/ui/desktop/src/components/settings_v2/sessions/SessionSharingSection.tsx +++ b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx @@ -10,12 +10,14 @@ export default function SessionSharingSection() { // If env is set, force sharing enabled and set the baseUrl accordingly. const [sessionSharingConfig, setSessionSharingConfig] = useState({ enabled: envBaseUrlShare ? true : false, - baseUrl: envBaseUrlShare || '', + baseUrl: typeof envBaseUrlShare === 'string' ? envBaseUrlShare : '', }); const [urlError, setUrlError] = useState(''); // isUrlConfigured is true if the user has configured a baseUrl and it is valid. const isUrlConfigured = - !envBaseUrlShare && sessionSharingConfig.enabled && isValidUrl(sessionSharingConfig.baseUrl); + !envBaseUrlShare && + sessionSharingConfig.enabled && + isValidUrl(String(sessionSharingConfig.baseUrl)); // Only load saved config from localStorage if the env variable is not provided. useEffect(() => { @@ -23,7 +25,7 @@ export default function SessionSharingSection() { // If env variable is set, save the forced configuration to localStorage const forcedConfig = { enabled: true, - baseUrl: envBaseUrlShare, + baseUrl: typeof envBaseUrlShare === 'string' ? envBaseUrlShare : '', }; localStorage.setItem('session_sharing_config', JSON.stringify(forcedConfig)); } else { @@ -113,7 +115,7 @@ export default function SessionSharingSection() { ) : ( @@ -139,7 +141,7 @@ export default function SessionSharingSection() { placeholder="https://example.com/api" value={sessionSharingConfig.baseUrl} disabled={!!envBaseUrlShare} - onChange={envBaseUrlShare ? undefined : handleBaseUrlChange} + {...(envBaseUrlShare ? {} : { onChange: handleBaseUrlChange })} />
{urlError &&

{urlError}

} diff --git a/ui/desktop/src/components/settings_v2/tool_selection_strategy/ToolSelectionStrategySection.tsx b/ui/desktop/src/components/settings/tool_selection_strategy/ToolSelectionStrategySection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/tool_selection_strategy/ToolSelectionStrategySection.tsx rename to ui/desktop/src/components/settings/tool_selection_strategy/ToolSelectionStrategySection.tsx diff --git a/ui/desktop/src/components/settings/types.ts b/ui/desktop/src/components/settings/types.ts deleted file mode 100644 index 140c233e..00000000 --- a/ui/desktop/src/components/settings/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FullExtensionConfig } from '../../extensions'; - -export interface Model { - id: string; - name: string; - description: string; - enabled: boolean; -} - -export interface Settings { - models: Model[]; - extensions: FullExtensionConfig[]; -} diff --git a/ui/desktop/src/components/settings_v2/SettingsView.tsx b/ui/desktop/src/components/settings_v2/SettingsView.tsx deleted file mode 100644 index b6a5bd7d..00000000 --- a/ui/desktop/src/components/settings_v2/SettingsView.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { ScrollArea } from '../ui/scroll-area'; -import BackButton from '../ui/BackButton'; -import type { View, ViewOptions } from '../../App'; -import ExtensionsSection from './extensions/ExtensionsSection'; -import ModelsSection from './models/ModelsSection'; -import { ModeSection } from './mode/ModeSection'; -import { ToolSelectionStrategySection } from './tool_selection_strategy/ToolSelectionStrategySection'; -import SessionSharingSection from './sessions/SessionSharingSection'; -import { ResponseStylesSection } from './response_styles/ResponseStylesSection'; -import { ExtensionConfig } from '../../api'; -import MoreMenuLayout from '../more_menu/MoreMenuLayout'; - -export type SettingsViewOptions = { - deepLinkConfig?: ExtensionConfig; - showEnvVars?: boolean; -}; - -export default function SettingsView({ - onClose, - setView, - viewOptions, -}: { - onClose: () => void; - setView: (view: View, viewOptions?: ViewOptions) => void; - viewOptions: SettingsViewOptions; -}) { - return ( -
- - - -
-
- onClose()} /> -

Settings

-
- - {/* Content Area */} -
-
- {/* Models Section */} - - {/* Extensions Section */} - - {/* Goose Modes */} - - {/*Session sharing*/} - - {/* Response Styles */} - - {/* Tool Selection Strategy */} - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/handlers/DefaultSubmitHandler.tsx b/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/handlers/DefaultSubmitHandler.tsx deleted file mode 100644 index 59fea347..00000000 --- a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/handlers/DefaultSubmitHandler.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Standalone function to submit provider configuration - * Useful for components that don't want to use the hook - */ -export const DefaultSubmitHandler = async (upsertFn, provider, configValues) => { - const parameters = provider.metadata.config_keys || []; - - const upsertPromises = parameters.map((parameter) => { - // Skip parameters that don't have a value and aren't required - if (!configValues[parameter.name] && !parameter.required) { - return Promise.resolve(); - } - - // For required parameters with no value, use the default if available - const value = - configValues[parameter.name] !== undefined ? configValues[parameter.name] : parameter.default; - - // Skip if there's still no value - if (value === undefined || value === null) { - return Promise.resolve(); - } - - // Create the provider-specific config key - const configKey = `${parameter.name}`; - - // Explicitly define is_secret as a boolean (true/false) - const isSecret = parameter.secret === true; - - // Pass the is_secret flag from the parameter definition - return upsertFn(configKey, value, isSecret); - }); - - // Wait for all upsert operations to complete - return Promise.all(upsertPromises); -}; diff --git a/ui/desktop/src/components/ui/Box.tsx b/ui/desktop/src/components/ui/Box.tsx index ced0140c..a512ff56 100644 --- a/ui/desktop/src/components/ui/Box.tsx +++ b/ui/desktop/src/components/ui/Box.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - export default function Box({ size }: { size: number }) { return ( ); diff --git a/ui/desktop/src/components/ui/Stop.tsx b/ui/desktop/src/components/ui/Stop.tsx index 10503eba..61d5fec2 100644 --- a/ui/desktop/src/components/ui/Stop.tsx +++ b/ui/desktop/src/components/ui/Stop.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - interface StopProps { size?: number; } diff --git a/ui/desktop/src/components/ui/VertDots.tsx b/ui/desktop/src/components/ui/VertDots.tsx index b4b18589..3631a75f 100644 --- a/ui/desktop/src/components/ui/VertDots.tsx +++ b/ui/desktop/src/components/ui/VertDots.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - export default function VertDots({ size }: { size: number }) { return ( diff --git a/ui/desktop/src/components/ui/icons.tsx b/ui/desktop/src/components/ui/icons.tsx index 00d3ae16..1fb76a88 100644 --- a/ui/desktop/src/components/ui/icons.tsx +++ b/ui/desktop/src/components/ui/icons.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - export const BotIcon = () => { return ( { - const baseUrl = window.appConfig.get('GOOSE_API_HOST') + ':' + window.appConfig.get('GOOSE_PORT'); + const baseUrl = + String(window.appConfig.get('GOOSE_API_HOST') || '') + + ':' + + String(window.appConfig.get('GOOSE_PORT') || ''); const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; return `${baseUrl}${cleanEndpoint}`; }; export const getSecretKey = (): string => { - return window.appConfig.get('secretKey'); + return String(window.appConfig.get('secretKey') || ''); }; diff --git a/ui/desktop/src/extensions.tsx b/ui/desktop/src/extensions.tsx index d86369a0..774e61c3 100644 --- a/ui/desktop/src/extensions.tsx +++ b/ui/desktop/src/extensions.tsx @@ -151,7 +151,7 @@ export async function addExtension( toastError({ title: extension.name, msg: 'Failed to add extension', - traceback: error.message, + traceback: error instanceof Error ? error.message : String(error), toastOptions: { autoClose: false }, }); throw error; @@ -193,7 +193,7 @@ export async function removeExtension(name: string, silent: boolean = false): Pr toastError({ title: name, msg: 'Error removing extension', - traceback: error.message, + traceback: error instanceof Error ? error.message : String(error), toastOptions: { autoClose: false }, }); throw error; @@ -243,7 +243,6 @@ export async function loadAndAddStoredExtensions() { } else { console.log('Saving default builtin extensions to localStorage'); // TODO - Revisit - // @ts-expect-error "we actually do always have all the properties required for builtins, but tsc cannot tell for some reason" BUILT_IN_EXTENSIONS.forEach(async (extension: FullExtensionConfig) => { storeExtensionConfig(extension); if (extension.enabled) { diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 1424e0d6..1b4c1666 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -1,13 +1,11 @@ -import { spawn } from 'child_process'; +import { spawn, ChildProcess } from 'child_process'; import { createServer } from 'net'; import os from 'node:os'; import path from 'node:path'; import { getBinaryPath } from './utils/binaryPath'; import log from './utils/logger'; -import { ChildProcessByStdio } from 'node:child_process'; -import { Readable, Buffer } from 'node:stream'; import { App } from 'electron'; -import type { ProcessEnv } from 'node:process'; +import { Buffer } from 'node:buffer'; // Find an available port to start goosed on export const findAvailablePort = (): Promise => { @@ -52,7 +50,8 @@ const checkServerStatus = async ( return false; }; -interface GooseProcessEnv extends ProcessEnv { +interface GooseProcessEnv { + [key: string]: string | undefined; HOME: string; USERPROFILE: string; APPDATA: string; @@ -66,22 +65,59 @@ export const startGoosed = async ( app: App, dir: string | null = null, env: Partial = {} -): Promise<[number, string, ChildProcessByStdio]> => { +): Promise<[number, string, ChildProcess]> => { // we default to running goosed in home dir - if not specified const homeDir = os.homedir(); const isWindows = process.platform === 'win32'; - // Ensure dir is properly normalized for the platform + // Ensure dir is properly normalized for the platform and validate it if (!dir) { dir = homeDir; } - dir = path.normalize(dir); + + // Sanitize and validate the directory path + dir = path.resolve(path.normalize(dir)); + + // Security check: Ensure the directory path doesn't contain suspicious characters + if (dir.includes('..') || dir.includes(';') || dir.includes('|') || dir.includes('&')) { + throw new Error(`Invalid directory path: ${dir}`); + } // Get the goosed binary path using the shared utility let goosedPath = getBinaryPath(app, 'goosed'); + + // Security validation: Ensure the binary path is safe + const resolvedGoosedPath = path.resolve(goosedPath); + + // Validate that the binary path doesn't contain suspicious characters or sequences + if ( + resolvedGoosedPath.includes('..') || + resolvedGoosedPath.includes(';') || + resolvedGoosedPath.includes('|') || + resolvedGoosedPath.includes('&') || + resolvedGoosedPath.includes('`') || + resolvedGoosedPath.includes('$') + ) { + throw new Error(`Invalid binary path detected: ${resolvedGoosedPath}`); + } + + // Ensure the binary path is within expected application directories + const appPath = app.getAppPath(); + const resourcesPath = process.resourcesPath; + const currentWorkingDir = process.cwd(); + + const isValidPath = + resolvedGoosedPath.startsWith(path.resolve(appPath)) || + resolvedGoosedPath.startsWith(path.resolve(resourcesPath)) || + resolvedGoosedPath.startsWith(path.resolve(currentWorkingDir)); + + if (!isValidPath) { + throw new Error(`Binary path is outside of allowed directories: ${resolvedGoosedPath}`); + } + const port = await findAvailablePort(); - log.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${dir}`); + log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`); // Define additional environment variables const additionalEnv: GooseProcessEnv = { @@ -94,7 +130,7 @@ export const startGoosed = async ( // Set LOCAL_APPDATA for Windows LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), // Set PATH to include the binary directory - PATH: `${path.dirname(goosedPath)}${path.delimiter}${process.env.PATH || ''}`, + PATH: `${path.dirname(resolvedGoosedPath)}${path.delimiter}${process.env.PATH || ''}`, // start with the port specified GOOSE_PORT: String(port), GOOSE_SERVER__SECRET_KEY: process.env.GOOSE_SERVER__SECRET_KEY, @@ -116,20 +152,25 @@ export const startGoosed = async ( log.info(`Environment PATH: ${processEnv.PATH}`); // Ensure proper executable path on Windows - if (isWindows && !goosedPath.toLowerCase().endsWith('.exe')) { - goosedPath += '.exe'; + if (isWindows && !resolvedGoosedPath.toLowerCase().endsWith('.exe')) { + goosedPath = resolvedGoosedPath + '.exe'; + } else { + goosedPath = resolvedGoosedPath; } log.info(`Binary path resolved to: ${goosedPath}`); - // Verify binary exists + // Verify binary exists and is a regular file try { // eslint-disable-next-line @typescript-eslint/no-var-requires const fs = require('fs'); const stats = fs.statSync(goosedPath); - log.info(`Binary exists: ${stats.isFile()}`); + if (!stats.isFile()) { + throw new Error(`Path is not a regular file: ${goosedPath}`); + } + log.info(`Binary exists and is a regular file: ${stats.isFile()}`); } catch (error) { - log.error(`Binary not found at ${goosedPath}:`, error); - throw new Error(`Binary not found at ${goosedPath}`); + log.error(`Binary not found or invalid at ${goosedPath}:`, error); + throw new Error(`Binary not found or invalid at ${goosedPath}`); } const spawnOptions = { @@ -140,26 +181,43 @@ export const startGoosed = async ( windowsHide: true, // Run detached on Windows only to avoid terminal windows detached: isWindows, - // Never use shell to avoid terminal windows + // Never use shell to avoid command injection - this is critical for security shell: false, }; - // Log spawn options for debugging - log.info('Spawn options:', JSON.stringify(spawnOptions, null, 2)); + // Log spawn options for debugging (excluding sensitive env vars) + const safeSpawnOptions = { + ...spawnOptions, + env: Object.keys(spawnOptions.env || {}).reduce( + (acc, key) => { + if (key.includes('SECRET') || key.includes('PASSWORD') || key.includes('TOKEN')) { + acc[key] = '[REDACTED]'; + } else { + acc[key] = spawnOptions.env![key] || ''; + } + return acc; + }, + {} as Record + ), + }; + log.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2)); - // Spawn the goosed process - const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions); + // Security: Use only hardcoded, safe arguments + const safeArgs = ['agent']; // Only allow the 'agent' argument + + // Spawn the goosed process with validated inputs + const goosedProcess: ChildProcess = spawn(goosedPath, safeArgs, spawnOptions); // Only unref on Windows to allow it to run independently of the parent - if (isWindows) { + if (isWindows && goosedProcess.unref) { goosedProcess.unref(); } - goosedProcess.stdout.on('data', (data: Buffer) => { + goosedProcess.stdout?.on('data', (data: Buffer) => { log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`); }); - goosedProcess.stderr.on('data', (data: Buffer) => { + goosedProcess.stderr?.on('data', (data: Buffer) => { log.error(`goosed stderr for port ${port} and dir ${dir}: ${data.toString()}`); }); @@ -180,9 +238,14 @@ export const startGoosed = async ( try { if (isWindows) { // On Windows, use taskkill to forcefully terminate the process tree - spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']); + // Security: Validate PID is numeric and use safe arguments + const pid = goosedProcess.pid?.toString() || '0'; + if (!/^\d+$/.test(pid)) { + throw new Error(`Invalid PID: ${pid}`); + } + spawn('taskkill', ['/pid', pid, '/T', '/F'], { shell: false }); } else { - goosedProcess.kill(); + goosedProcess.kill?.(); } } catch (error) { log.error('Error while terminating goosed process:', error); @@ -197,9 +260,15 @@ export const startGoosed = async ( try { if (isWindows) { // On Windows, use taskkill to forcefully terminate the process tree - spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']); + // Security: Validate PID is numeric and use safe arguments + const pid = goosedProcess.pid?.toString() || '0'; + if (!/^\d+$/.test(pid)) { + log.error(`Invalid PID for termination: ${pid}`); + return; + } + spawn('taskkill', ['/pid', pid, '/T', '/F'], { shell: false }); } else { - goosedProcess.kill(); + goosedProcess.kill?.(); } } catch (error) { log.error('Error while terminating goosed process:', error); diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index e7bb9718..87d311ac 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -6,11 +6,25 @@ import { Message, createUserMessage, hasCompletedToolCalls } from '../types/mess // Ensure TextDecoder is available in the global scope const TextDecoder = globalThis.TextDecoder; +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export interface NotificationEvent { + type: 'Notification'; + request_id: string; + message: { + method: string; + params: { + [key: string]: JsonValue; + }; + }; +} + // Event types for SSE stream type MessageEvent = | { type: 'Message'; message: Message } | { type: 'Error'; error: string } - | { type: 'Finish'; reason: string }; + | { type: 'Finish'; reason: string } + | NotificationEvent; export interface UseMessageStreamOptions { /** @@ -124,6 +138,8 @@ export interface UseMessageStreamHelpers { /** Modify body (session id and/or work dir mid-stream) **/ updateMessageStreamBody?: (newBody: object) => void; + + notifications: NotificationEvent[]; } /** @@ -151,6 +167,8 @@ export function useMessageStream({ fallbackData: initialMessages, }); + const [notifications, setNotifications] = useState([]); + // expose a way to update the body so we can update the session id when CLE occurs const updateMessageStreamBody = useCallback((newBody: object) => { extraMetadataRef.current.body = { @@ -247,6 +265,14 @@ export function useMessageStream({ break; } + case 'Notification': { + const newNotification = { + ...parsedEvent, + }; + setNotifications((prev) => [...prev, newNotification]); + break; + } + case 'Error': throw new Error(parsedEvent.error); @@ -516,5 +542,6 @@ export function useMessageStream({ isLoading: isLoading || false, addToolResult, updateMessageStreamBody, + notifications, }; } diff --git a/ui/desktop/src/json.d.ts b/ui/desktop/src/json.d.ts index d60d46d6..5bb9ca45 100644 --- a/ui/desktop/src/json.d.ts +++ b/ui/desktop/src/json.d.ts @@ -2,3 +2,45 @@ declare module '*.json' { const value: Record; export default value; } + +declare module '*.png' { + const value: string; + export default value; +} + +declare module '*.jpg' { + const value: string; + export default value; +} + +declare module '*.jpeg' { + const value: string; + export default value; +} + +declare module '*.gif' { + const value: string; + export default value; +} + +declare module '*.svg' { + const value: string; + export default value; +} + +declare module '*.mp3' { + const value: string; + export default value; +} + +declare module '*.mp4' { + const value: string; + export default value; +} + +// Extend CSS properties to include Electron-specific properties +declare namespace React { + interface CSSProperties { + WebkitAppRegion?: 'drag' | 'no-drag'; + } +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index ea34c3e7..7696f6f7 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -12,6 +12,7 @@ import { App, globalShortcut, } from 'electron'; +import type { OpenDialogReturnValue } from 'electron'; import { Buffer } from 'node:buffer'; import fs from 'node:fs/promises'; import started from 'electron-squirrel-startup'; @@ -90,7 +91,7 @@ async function ensureTempDirExists(): Promise { } } } catch (error) { - if (error.code === 'ENOENT') { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { // Directory doesn't exist, create it await fs.mkdir(gooseTempDir, { recursive: true }); } else { @@ -121,7 +122,7 @@ if (process.platform === 'win32') { if (!gotTheLock) { app.quit(); } else { - app.on('second-instance', (event, commandLine) => { + app.on('second-instance', (_event, commandLine) => { const protocolUrl = commandLine.find((arg) => arg.startsWith('goose://')); if (protocolUrl) { const parsedUrl = new URL(protocolUrl); @@ -142,7 +143,7 @@ if (process.platform === 'win32') { } } - createChat(app, undefined, openDir, undefined, undefined, recipeConfig); + createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig); }); return; // Skip the rest of the handler } @@ -173,7 +174,7 @@ if (process.platform === 'win32') { } let firstOpenWindow: BrowserWindow; -let pendingDeepLink = null; +let pendingDeepLink: string | null = null; async function handleProtocolUrl(url: string) { if (!url) return; @@ -185,9 +186,13 @@ async function handleProtocolUrl(url: string) { const openDir = recentDirs.length > 0 ? recentDirs[0] : null; if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { - // For bot/recipe URLs, skip existing window processing - // and let processProtocolUrl handle it entirely - processProtocolUrl(parsedUrl, null); + // For bot/recipe URLs, get existing window or create new one + const existingWindows = BrowserWindow.getAllWindows(); + const targetWindow = + existingWindows.length > 0 + ? existingWindows[0] + : await createChat(app, undefined, openDir || undefined); + processProtocolUrl(parsedUrl, targetWindow); } else { // For other URL types, reuse existing window if available const existingWindows = BrowserWindow.getAllWindows(); @@ -198,7 +203,7 @@ async function handleProtocolUrl(url: string) { } firstOpenWindow.focus(); } else { - firstOpenWindow = await createChat(app, undefined, openDir); + firstOpenWindow = await createChat(app, undefined, openDir || undefined); } if (firstOpenWindow) { @@ -233,12 +238,12 @@ function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { } } // Create a new window and ignore the passed-in window - createChat(app, undefined, openDir, undefined, undefined, recipeConfig); + createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig); } pendingDeepLink = null; } -app.on('open-url', async (event, url) => { +app.on('open-url', async (_event, url) => { if (process.platform !== 'win32') { const parsedUrl = new URL(url); const recentDirs = loadRecentDirs(); @@ -257,7 +262,7 @@ app.on('open-url', async (event, url) => { } // Create a new window directly - await createChat(app, undefined, openDir, undefined, undefined, recipeConfig); + await createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig); return; // Skip the rest of the handler } @@ -270,7 +275,7 @@ app.on('open-url', async (event, url) => { if (firstOpenWindow.isMinimized()) firstOpenWindow.restore(); firstOpenWindow.focus(); } else { - firstOpenWindow = await createChat(app, undefined, openDir); + firstOpenWindow = await createChat(app, undefined, openDir || undefined); } if (parsedUrl.hostname === 'extension') { @@ -368,7 +373,7 @@ const createChat = async ( app: App, query?: string, dir?: string, - version?: string, + _version?: string, resumeSessionId?: string, recipeConfig?: RecipeConfig, // Bot configuration viewType?: string // View type @@ -376,7 +381,7 @@ const createChat = async ( // Initialize variables for process and configuration let port = 0; let working_dir = ''; - let goosedProcess = null; + let goosedProcess: import('child_process').ChildProcess | null = null; if (viewType === 'recipeEditor') { // For recipeEditor, get the port from existing windows' config @@ -403,7 +408,10 @@ const createChat = async ( // Apply current environment settings before creating chat updateEnvironmentVariables(envToggles); // Start new Goosed process for regular windows - [port, working_dir, goosedProcess] = await startGoosed(app, dir); + const [newPort, newWorkingDir, newGoosedProcess] = await startGoosed(app, dir); + port = newPort; + working_dir = newWorkingDir; + goosedProcess = newGoosedProcess; } const mainWindow = new BrowserWindow({ @@ -446,7 +454,7 @@ const createChat = async ( // // TODO: Load language codes from a setting if we ever have i18n/l10n mainWindow.webContents.session.setSpellCheckerLanguages(['en-US', 'en-GB']); - mainWindow.webContents.on('context-menu', (event, params) => { + mainWindow.webContents.on('context-menu', (_event, params) => { const menu = new Menu(); // Add each spelling suggestion @@ -564,7 +572,7 @@ const createChat = async ( // Handle window closure mainWindow.on('closed', () => { windowMap.delete(windowId); - if (goosedProcess) { + if (goosedProcess && typeof goosedProcess === 'object' && 'kill' in goosedProcess) { goosedProcess.kill(); } }); @@ -574,7 +582,17 @@ const createChat = async ( // Track tray instance let tray: Tray | null = null; +const destroyTray = () => { + if (tray) { + tray.destroy(); + tray = null; + } +}; + const createTray = () => { + // If tray already exists, destroy it first + destroyTray(); + const isDev = process.env.NODE_ENV === 'development'; let iconPath: string; @@ -608,7 +626,7 @@ const showWindow = async () => { log.info('No windows are open, creating a new one...'); const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; - await createChat(app, undefined, openDir); + await createChat(app, undefined, openDir || undefined); return; } @@ -647,16 +665,18 @@ const buildRecentFilesMenu = () => { })); }; -const openDirectoryDialog = async (replaceWindow: boolean = false) => { - const result = await dialog.showOpenDialog({ +const openDirectoryDialog = async ( + replaceWindow: boolean = false +): Promise => { + const result = (await dialog.showOpenDialog({ properties: ['openFile', 'openDirectory'], - }); + })) as unknown as OpenDialogReturnValue; if (!result.canceled && result.filePaths.length > 0) { addRecentDir(result.filePaths[0]); const currentWindow = BrowserWindow.getFocusedWindow(); await createChat(app, undefined, result.filePaths[0]); - if (replaceWindow) { + if (replaceWindow && currentWindow) { currentWindow.close(); } } @@ -701,11 +721,78 @@ ipcMain.handle('directory-chooser', (_event, replace: boolean = false) => { return openDirectoryDialog(replace); }); +// Handle menu bar icon visibility +ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => { + try { + const settings = loadSettings(); + settings.showMenuBarIcon = show; + saveSettings(settings); + + if (show) { + createTray(); + } else { + destroyTray(); + } + return true; + } catch (error) { + console.error('Error setting menu bar icon:', error); + return false; + } +}); + +ipcMain.handle('get-menu-bar-icon-state', () => { + try { + const settings = loadSettings(); + return settings.showMenuBarIcon ?? true; + } catch (error) { + console.error('Error getting menu bar icon state:', error); + return true; + } +}); + +// Handle dock icon visibility (macOS only) +ipcMain.handle('set-dock-icon', async (_event, show: boolean) => { + try { + if (process.platform !== 'darwin') return false; + + const settings = loadSettings(); + settings.showDockIcon = show; + saveSettings(settings); + + if (show) { + await app.dock.show(); + } else { + // Only hide the dock if we have a menu bar icon to maintain accessibility + if (settings.showMenuBarIcon) { + app.dock.hide(); + setTimeout(() => { + focusWindow(); + }, 50); + } + } + return true; + } catch (error) { + console.error('Error setting dock icon:', error); + return false; + } +}); + +ipcMain.handle('get-dock-icon-state', () => { + try { + if (process.platform !== 'darwin') return true; + const settings = loadSettings(); + return settings.showDockIcon ?? true; + } catch (error) { + console.error('Error getting dock icon state:', error); + return true; + } +}); + // Add file/directory selection handler ipcMain.handle('select-file-or-directory', async () => { - const result = await dialog.showOpenDialog({ + const result = (await dialog.showOpenDialog({ properties: process.platform === 'darwin' ? ['openFile', 'openDirectory'] : ['openFile'], - }); + })) as unknown as OpenDialogReturnValue; if (!result.canceled && result.filePaths.length > 0) { return result.filePaths[0]; @@ -714,7 +801,7 @@ ipcMain.handle('select-file-or-directory', async () => { }); // IPC handler to save data URL to a temporary file -ipcMain.handle('save-data-url-to-temp', async (event, dataUrl: string, uniqueId: string) => { +ipcMain.handle('save-data-url-to-temp', async (_event, dataUrl: string, uniqueId: string) => { console.log(`[Main] Received save-data-url-to-temp for ID: ${uniqueId}`); try { // Input validation for uniqueId - only allow alphanumeric characters and hyphens @@ -772,12 +859,12 @@ ipcMain.handle('save-data-url-to-temp', async (event, dataUrl: string, uniqueId: return { id: uniqueId, filePath: filePath }; } catch (error) { console.error(`[Main] Failed to save image to temp for ID ${uniqueId}:`, error); - return { id: uniqueId, error: error.message || 'Failed to save image' }; + return { id: uniqueId, error: error instanceof Error ? error.message : 'Failed to save image' }; } }); // IPC handler to serve temporary image files -ipcMain.handle('get-temp-image', async (event, filePath: string) => { +ipcMain.handle('get-temp-image', async (_event, filePath: string) => { console.log(`[Main] Received get-temp-image for path: ${filePath}`); // Input validation @@ -823,7 +910,7 @@ ipcMain.handle('get-temp-image', async (event, filePath: string) => { // If realpath fails, use the original path validation console.log( `[Main] realpath failed for ${filePath}, using original path validation:`, - realpathError.message + realpathError instanceof Error ? realpathError.message : String(realpathError) ); } @@ -849,7 +936,7 @@ ipcMain.handle('get-temp-image', async (event, filePath: string) => { return null; } }); -ipcMain.on('delete-temp-file', async (event, filePath: string) => { +ipcMain.on('delete-temp-file', async (_event, filePath: string) => { console.log(`[Main] Received delete-temp-file for path: ${filePath}`); // Input validation @@ -894,14 +981,14 @@ ipcMain.on('delete-temp-file', async (event, filePath: string) => { // If realpath fails, use the original path validation console.log( `[Main] realpath failed for ${filePath}, using original path validation:`, - realpathError.message + realpathError instanceof Error ? realpathError.message : String(realpathError) ); } await fs.unlink(actualPath); console.log(`[Main] Deleted temp file: ${filePath}`); } catch (error) { - if (error.code !== 'ENOENT') { + if (error && typeof error === 'object' && 'code' in error && error.code !== 'ENOENT') { // ENOENT means file doesn't exist, which is fine console.error(`[Main] Failed to delete temp file: ${filePath}`, error); } else { @@ -1051,15 +1138,17 @@ const registerGlobalHotkey = (accelerator: string) => { globalShortcut.unregisterAll(); try { - const ret = globalShortcut.register(accelerator, () => { + globalShortcut.register(accelerator, () => { focusWindow(); }); - if (!ret) { + // Check if the shortcut was registered successfully + if (globalShortcut.isRegistered(accelerator)) { + return true; + } else { console.error('Failed to register global hotkey'); return false; } - return true; } catch (e) { console.error('Error registering global hotkey:', e); return false; @@ -1072,35 +1161,34 @@ app.whenReady().then(async () => { callback({ responseHeaders: { ...details.responseHeaders, - 'Content-Security-Policy': [ + 'Content-Security-Policy': "default-src 'self';" + - // Allow inline styles since we use them in our React components - "style-src 'self' 'unsafe-inline';" + - // Scripts only from our app - "script-src 'self';" + - // Images from our app and data: URLs (for base64 images) - "img-src 'self' data: https:;" + - // Connect to our local API and specific external services - "connect-src 'self' http://127.0.0.1:*" + - // Don't allow any plugins - "object-src 'none';" + - // Don't allow any frames - "frame-src 'none';" + - // Font sources - "font-src 'self';" + - // Media sources - "media-src 'none';" + - // Form actions - "form-action 'none';" + - // Base URI restriction - "base-uri 'self';" + - // Manifest files - "manifest-src 'self';" + - // Worker sources - "worker-src 'self';" + - // Upgrade insecure requests - 'upgrade-insecure-requests;', - ], + // Allow inline styles since we use them in our React components + "style-src 'self' 'unsafe-inline';" + + // Scripts only from our app + "script-src 'self';" + + // Images from our app and data: URLs (for base64 images) + "img-src 'self' data: https:;" + + // Connect to our local API and specific external services + "connect-src 'self' http://127.0.0.1:*" + + // Don't allow any plugins + "object-src 'none';" + + // Don't allow any frames + "frame-src 'none';" + + // Font sources + "font-src 'self';" + + // Media sources + "media-src 'none';" + + // Form actions + "form-action 'none';" + + // Base URI restriction + "base-uri 'self';" + + // Manifest files + "manifest-src 'self';" + + // Worker sources + "worker-src 'self';" + + // Upgrade insecure requests + 'upgrade-insecure-requests;', }, }); }); @@ -1122,10 +1210,20 @@ app.whenReady().then(async () => { }, 5000); } + // Create tray if enabled in settings + const settings = loadSettings(); + if (settings.showMenuBarIcon) { + createTray(); + } + + // Handle dock icon visibility (macOS only) + if (process.platform === 'darwin' && !settings.showDockIcon && settings.showMenuBarIcon) { + app.dock.hide(); + } + // Parse command line arguments const { dirPath } = parseArgs(); - createTray(); createNewWindow(app, dirPath); // Get the existing menu @@ -1184,7 +1282,7 @@ app.whenReady().then(async () => { }, { label: 'Use Selection for Find', - accelerator: process.platform === 'darwin' ? 'Command+E' : null, + accelerator: process.platform === 'darwin' ? 'Command+E' : undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('use-selection-find'); @@ -1213,7 +1311,8 @@ app.whenReady().then(async () => { submenu: Menu.buildFromTemplate( createEnvironmentMenu(envToggles, (newToggles) => { envToggles = newToggles; - saveSettings({ envToggles: newToggles }); + const currentSettings = loadSettings(); + saveSettings({ ...currentSettings, envToggles: newToggles }); updateEnvironmentVariables(newToggles); }) ), @@ -1594,7 +1693,7 @@ app.on('will-quit', async () => { console.error('[Main] Error while cleaning up temp directory contents:', err); } } catch (error) { - if (error.code === 'ENOENT') { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { console.log('[Main] Temp directory did not exist during "will-quit", no cleanup needed.'); } else { console.error( @@ -1607,7 +1706,7 @@ app.on('will-quit', async () => { // Quit when all windows are closed, except on macOS or if we have a tray icon. // Add confirmation dialog when quitting with Cmd+Q (skip in dev mode) -app.on('before-quit', (event) => { +app.on('before-quit', async (event) => { // Skip confirmation dialog in development mode if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { return; // Allow normal quit behavior in dev mode @@ -1617,24 +1716,26 @@ app.on('before-quit', (event) => { event.preventDefault(); // Show confirmation dialog - dialog - .showMessageBox({ + try { + const result = (await dialog.showMessageBox({ type: 'question', buttons: ['Quit', 'Cancel'], defaultId: 1, // Default to Cancel title: 'Confirm Quit', message: 'Are you sure you want to quit Goose?', detail: 'Any unsaved changes may be lost.', - }) - .then(({ response }) => { - if (response === 0) { - // User clicked "Quit" - // Set a flag to avoid showing the dialog again - app.removeAllListeners('before-quit'); - // Actually quit the app - app.quit(); - } - }); + })) as unknown as { response: number }; + + if (result.response === 0) { + // User clicked "Quit" + // Set a flag to avoid showing the dialog again + app.removeAllListeners('before-quit'); + // Actually quit the app + app.quit(); + } + } catch (error) { + console.error('Error showing quit dialog:', error); + } }); app.on('window-all-closed', () => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 9f667aad..13674f88 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -58,6 +58,10 @@ type ElectronAPI = { writeFile: (directory: string, content: string) => Promise; getAllowedExtensions: () => Promise; getPathForFile: (file: File) => string; + setMenuBarIcon: (show: boolean) => Promise; + getMenuBarIconState: () => Promise; + setDockIcon: (show: boolean) => Promise; + getDockIconState: () => Promise; on: ( channel: string, callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void @@ -117,6 +121,10 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke('write-file', filePath, content), getPathForFile: (file: File) => webUtils.getPathForFile(file), getAllowedExtensions: () => ipcRenderer.invoke('get-allowed-extensions'), + setMenuBarIcon: (show: boolean) => ipcRenderer.invoke('set-menu-bar-icon', show), + getMenuBarIconState: () => ipcRenderer.invoke('get-menu-bar-icon-state'), + setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show), + getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'), on: ( channel: string, callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index 17e51e30..aa185c61 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -15,6 +15,8 @@ export interface Recipe { extensions?: FullExtensionConfig[]; goosehints?: string; context?: string[]; + profile?: string; + mcps?: number; } export interface CreateRecipeRequest { diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index d08734f7..71a90179 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -1,9 +1,7 @@ import React, { Suspense, lazy } from 'react'; import ReactDOM from 'react-dom/client'; -import { ModelProvider } from './components/settings/models/ModelContext'; import { ConfigProvider } from './components/ConfigContext'; import { ErrorBoundary } from './components/ErrorBoundary'; -import { ActiveKeysProvider } from './components/settings/api_keys/ActiveKeysContext'; import { patchConsoleLogging } from './utils'; import SuspenseLoader from './suspense-loader'; @@ -15,13 +13,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - + + + diff --git a/ui/desktop/src/schedule.ts b/ui/desktop/src/schedule.ts index 4c300166..c60c68a5 100644 --- a/ui/desktop/src/schedule.ts +++ b/ui/desktop/src/schedule.ts @@ -7,6 +7,8 @@ import { updateSchedule as apiUpdateSchedule, sessionsHandler as apiGetScheduleSessions, runNowHandler as apiRunScheduleNow, + killRunningJob as apiKillRunningJob, + inspectRunningJob as apiInspectRunningJob, } from './api'; export interface ScheduledJob { @@ -16,6 +18,8 @@ export interface ScheduledJob { last_run?: string | null; currently_running?: boolean; paused?: boolean; + current_session_id?: string | null; + process_start_time?: string | null; } export interface ScheduleSession { @@ -151,3 +155,47 @@ export async function updateSchedule(scheduleId: string, cron: string): Promise< throw error; } } + +export interface KillJobResponse { + message: string; +} + +export interface InspectJobResponse { + sessionId?: string | null; + processStartTime?: string | null; + runningDurationSeconds?: number | null; +} + +export async function killRunningJob(scheduleId: string): Promise { + try { + const response = await apiKillRunningJob({ + path: { id: scheduleId }, + }); + + if (response && response.data) { + return response.data as KillJobResponse; + } + console.error('Unexpected response format from apiKillRunningJob', response); + throw new Error('Failed to kill running job: Unexpected response format'); + } catch (error) { + console.error(`Error killing running job ${scheduleId}:`, error); + throw error; + } +} + +export async function inspectRunningJob(scheduleId: string): Promise { + try { + const response = await apiInspectRunningJob({ + path: { id: scheduleId }, + }); + + if (response && response.data) { + return response.data as InspectJobResponse; + } + console.error('Unexpected response format from apiInspectRunningJob', response); + throw new Error('Failed to inspect running job: Unexpected response format'); + } catch (error) { + console.error(`Error inspecting running job ${scheduleId}:`, error); + throw error; + } +} diff --git a/ui/desktop/src/sessionLinks.ts b/ui/desktop/src/sessionLinks.ts index 43d72bd9..09a3f8f8 100644 --- a/ui/desktop/src/sessionLinks.ts +++ b/ui/desktop/src/sessionLinks.ts @@ -1,7 +1,7 @@ import { fetchSharedSessionDetails, SharedSessionDetails } from './sharedSessions'; import { type View } from './App'; -interface SessionLinksViewOptions { +export interface SessionLinksViewOptions { sessionDetails?: SharedSessionDetails | null; error?: string; shareToken?: string; @@ -27,7 +27,7 @@ export async function openSharedSessionFromDeepLink( } // Extract the share token from the URL - const shareToken = url.replace('goose://sessions/', ''); + const shareToken: string = url.replace('goose://sessions/', ''); if (!shareToken || shareToken.trim() === '') { throw new Error('Invalid URL: Missing share token'); @@ -58,7 +58,7 @@ export async function openSharedSessionFromDeepLink( } // Fetch the shared session details - const sessionDetails = await fetchSharedSessionDetails(baseUrl, shareToken); + const sessionDetails = await fetchSharedSessionDetails(baseUrl!, shareToken); // Navigate to the shared session view setView('sharedSession', { diff --git a/ui/desktop/src/suspense-loader.tsx b/ui/desktop/src/suspense-loader.tsx index 4530cbc9..56e21e01 100644 --- a/ui/desktop/src/suspense-loader.tsx +++ b/ui/desktop/src/suspense-loader.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import GooseLogo from './components/GooseLogo'; export default function SuspenseLoader() { diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index fe8672aa..9ac086c5 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -1,5 +1,4 @@ import { toast, ToastOptions } from 'react-toastify'; -import React from 'react'; import { Button } from './components/ui/button'; export interface ToastServiceOptions { diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index a3ec14ec..4e52b6cf 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -218,7 +218,7 @@ export function getToolResponses(message: Message): ToolResponseMessageContent[] export function getToolConfirmationContent( message: Message -): ToolConfirmationRequestMessageContent { +): ToolConfirmationRequestMessageContent | undefined { return message.content.find( (content): content is ToolConfirmationRequestMessageContent => content.type === 'toolConfirmationRequest' diff --git a/ui/desktop/src/utils/binaryPath.ts b/ui/desktop/src/utils/binaryPath.ts index 9704a677..f5a4a5d4 100644 --- a/ui/desktop/src/utils/binaryPath.ts +++ b/ui/desktop/src/utils/binaryPath.ts @@ -4,6 +4,24 @@ import Electron from 'electron'; import log from './logger'; export const getBinaryPath = (app: Electron.App, binaryName: string): string => { + // Security validation: Ensure binaryName doesn't contain suspicious characters + if ( + !binaryName || + typeof binaryName !== 'string' || + binaryName.includes('..') || + binaryName.includes('/') || + binaryName.includes('\\') || + binaryName.includes(';') || + binaryName.includes('|') || + binaryName.includes('&') || + binaryName.includes('`') || + binaryName.includes('$') || + binaryName.length > 50 + ) { + // Reasonable length limit + throw new Error(`Invalid binary name: ${binaryName}`); + } + const isWindows = process.platform === 'win32'; const possiblePaths: string[] = []; @@ -16,8 +34,28 @@ export const getBinaryPath = (app: Electron.App, binaryName: string): string => for (const binPath of possiblePaths) { try { - if (fs.existsSync(binPath)) { - return binPath; + // Security: Resolve the path and validate it's within expected directories + const resolvedPath = path.resolve(binPath); + + // Ensure the resolved path doesn't contain suspicious sequences + if ( + resolvedPath.includes('..') || + resolvedPath.includes(';') || + resolvedPath.includes('|') || + resolvedPath.includes('&') + ) { + log.error(`Suspicious path detected, skipping: ${resolvedPath}`); + continue; + } + + if (fs.existsSync(resolvedPath)) { + // Additional security check: ensure it's a regular file + const stats = fs.statSync(resolvedPath); + if (stats.isFile()) { + return resolvedPath; + } else { + log.error(`Path exists but is not a regular file: ${resolvedPath}`); + } } } catch (error) { log.error(`Error checking path ${binPath}:`, error); diff --git a/ui/desktop/src/utils/deleteAllKeys.tsx b/ui/desktop/src/utils/deleteAllKeys.tsx deleted file mode 100644 index 3972e9cc..00000000 --- a/ui/desktop/src/utils/deleteAllKeys.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { getApiUrl, getSecretKey } from '../config'; -import { required_keys } from '../components/settings/models/hardcoded_stuff'; - -export async function DeleteProviderKeysFromKeychain() { - for (const [_provider, keys] of Object.entries(required_keys)) { - for (const keyName of keys) { - try { - const deleteResponse = await fetch(getApiUrl('/configs/delete'), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: keyName, - is_secret: true, // get rid of keychain keys only - }), - }); - - if (!deleteResponse.ok) { - const errorText = await deleteResponse.text(); - console.error('Delete response error:', errorText); - throw new Error('Failed to delete key: ' + keyName); - } else { - console.log('Successfully deleted key:', keyName); - } - } catch (error) { - console.error('Error deleting key:', keyName, error); - } - } - } -} diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 58749974..a26f9f1a 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -5,8 +5,8 @@ import { initializeBundledExtensions, syncBundledExtensions, addToAgentOnStartup, -} from '../components/settings_v2/extensions'; -import { extractExtensionConfig } from '../components/settings_v2/extensions/utils'; +} from '../components/settings/extensions'; +import { extractExtensionConfig } from '../components/settings/extensions/utils'; import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext'; // TODO: remove when removing migration logic import { toastService } from '../toasts'; @@ -139,7 +139,7 @@ export const initializeSystem = async ( // Get recipeConfig directly here const recipeConfig = window.appConfig?.get?.('recipeConfig'); - const botPrompt = recipeConfig?.instructions; + const botPrompt = (recipeConfig as { instructions?: string })?.instructions; // Extend the system prompt with desktop-specific information const response = await fetch(getApiUrl('/agent/prompt'), { method: 'POST', diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index b6e2d531..1dddb275 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -1,4 +1,4 @@ -import { app } from 'electron'; +import { app, MenuItem } from 'electron'; import fs from 'fs'; import path from 'path'; @@ -10,6 +10,8 @@ export interface EnvToggles { export interface Settings { envToggles: EnvToggles; + showMenuBarIcon: boolean; + showDockIcon: boolean; } // Constants @@ -20,6 +22,8 @@ const defaultSettings: Settings = { GOOSE_SERVER__MEMORY: false, GOOSE_SERVER__COMPUTER_CONTROLLER: false, }, + showMenuBarIcon: true, + showDockIcon: true, }; // Settings management @@ -66,9 +70,9 @@ export function createEnvironmentMenu( return [ { label: 'Enable Memory Mode', - type: 'checkbox', + type: 'checkbox' as const, checked: envToggles.GOOSE_SERVER__MEMORY, - click: (menuItem: { checked: boolean }) => { + click: (menuItem: MenuItem) => { const newToggles = { ...envToggles, GOOSE_SERVER__MEMORY: menuItem.checked, @@ -78,9 +82,9 @@ export function createEnvironmentMenu( }, { label: 'Enable Computer Controller Mode', - type: 'checkbox', + type: 'checkbox' as const, checked: envToggles.GOOSE_SERVER__COMPUTER_CONTROLLER, - click: (menuItem: { checked: boolean }) => { + click: (menuItem: MenuItem) => { const newToggles = { ...envToggles, GOOSE_SERVER__COMPUTER_CONTROLLER: menuItem.checked, diff --git a/ui/desktop/src/utils/urlUtils.ts b/ui/desktop/src/utils/urlUtils.ts index a58952c5..a33ad5f3 100644 --- a/ui/desktop/src/utils/urlUtils.ts +++ b/ui/desktop/src/utils/urlUtils.ts @@ -53,7 +53,7 @@ export function extractUrls(content: string, previousUrls: string[] = []): strin const normalizedCurrentUrls = eligibleUrls.map(normalizeUrl); // Filter out duplicates from previous URLs - const uniqueUrls = eligibleUrls.filter((url, index) => { + const uniqueUrls = eligibleUrls.filter((_url, index) => { const normalized = normalizedCurrentUrls[index]; const isDuplicate = normalizedPreviousUrls.some( (prevUrl) => normalizeUrl(prevUrl) === normalized diff --git a/ui/desktop/tailwind.config.ts b/ui/desktop/tailwind.config.ts index 6955a3d4..1a62cc50 100644 --- a/ui/desktop/tailwind.config.ts +++ b/ui/desktop/tailwind.config.ts @@ -44,10 +44,16 @@ export default { '0%': { transform: 'rotate(0deg)' }, '100%': { transform: 'rotate(360deg)' }, }, + indeterminate: { + '0%': { left: '-40%', width: '40%' }, + '50%': { left: '20%', width: '60%' }, + '100%': { left: '100%', width: '80%' }, + }, }, animation: { 'shimmer-pulse': 'shimmer 4s ease-in-out infinite', 'gradient-loader': 'loader 750ms ease-in-out infinite', + indeterminate: 'indeterminate 1.5s infinite linear', }, colors: { bgApp: 'var(--background-app)', diff --git a/ui/desktop/tests/e2e/app.spec.ts b/ui/desktop/tests/e2e/app.spec.ts index b33f265f..1c7ba1ce 100644 --- a/ui/desktop/tests/e2e/app.spec.ts +++ b/ui/desktop/tests/e2e/app.spec.ts @@ -179,7 +179,13 @@ test.describe('Goose App', () => { // Get the main window once for all tests mainWindow = await electronApp.firstWindow(); await mainWindow.waitForLoadState('domcontentloaded'); - await mainWindow.waitForLoadState('networkidle'); + + // Try to wait for networkidle, but don't fail if it times out due to MCP activity + try { + await mainWindow.waitForLoadState('networkidle', { timeout: 10000 }); + } catch (error) { + console.log('NetworkIdle timeout (likely due to MCP activity), continuing with test...'); + } // Wait for React app to be ready by checking for the root element to have content await mainWindow.waitForFunction(() => { @@ -417,7 +423,12 @@ test.describe('Goose App', () => { try { // Reload the page to ensure settings are fresh await mainWindow.reload(); - await mainWindow.waitForLoadState('networkidle'); + // Try to wait for networkidle, but don't fail if it times out due to MCP activity + try { + await mainWindow.waitForLoadState('networkidle', { timeout: 10000 }); + } catch (error) { + console.log('NetworkIdle timeout (likely due to MCP activity), continuing with test...'); + } await mainWindow.waitForLoadState('domcontentloaded'); // Wait for React app to be ready @@ -687,9 +698,18 @@ test.describe('Goose App', () => { }, initialMessages, { timeout: 30000 }); // Get the latest response - const response = await mainWindow.locator('[data-testid="message-container"]').last(); + const response = await mainWindow.waitForSelector('.goose-message-tool', { timeout: 5000 }); expect(await response.isVisible()).toBe(true); + // Click the Output dropdown to reveal the actual quote + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-quote-response-debug.png` }); + const element = await mainWindow.$('.goose-message-tool'); + const html = await element.innerHTML(); + console.log('HTML content:', html); + // Click the Runningquote dropdown to reveal the actual quote + const runningQuoteButton = await mainWindow.waitForSelector('div.goose-message-tool svg.rotate-90', { timeout: 5000 }); + await runningQuoteButton.click(); + // Click the Output dropdown to reveal the actual quote const outputButton = await mainWindow.waitForSelector('button:has-text("Output")', { timeout: 5000 }); await outputButton.click();