mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-18 22:54:24 +01:00
feat: goose windows (#880)
Co-authored-by: Ryan Versaw <ryan@versaw.com>
This commit is contained in:
14
.github/workflows/build-cli.yml
vendored
14
.github/workflows/build-cli.yml
vendored
@@ -15,7 +15,7 @@ on:
|
|||||||
operating-systems:
|
operating-systems:
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: '["ubuntu-latest","macos-latest"]'
|
default: '["ubuntu-latest","macos-latest","windows-latest"]'
|
||||||
architectures:
|
architectures:
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
@@ -37,6 +37,8 @@ jobs:
|
|||||||
target-suffix: unknown-linux-gnu
|
target-suffix: unknown-linux-gnu
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
target-suffix: apple-darwin
|
target-suffix: apple-darwin
|
||||||
|
- os: windows-latest
|
||||||
|
target-suffix: pc-windows-gnu
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -60,12 +62,20 @@ jobs:
|
|||||||
- name: Build CLI
|
- name: Build CLI
|
||||||
env:
|
env:
|
||||||
CROSS_NO_WARNINGS: 0
|
CROSS_NO_WARNINGS: 0
|
||||||
|
RUST_LOG: debug
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
CROSS_VERBOSE: 1
|
||||||
run: |
|
run: |
|
||||||
export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}"
|
export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}"
|
||||||
rustup target add "${TARGET}"
|
rustup target add "${TARGET}"
|
||||||
|
echo "Building for target: ${TARGET}"
|
||||||
|
echo "Rust toolchain info:"
|
||||||
|
rustup show
|
||||||
|
echo "Cross version:"
|
||||||
|
cross --version
|
||||||
|
|
||||||
# 'cross' is used to cross-compile for different architectures (see Cross.toml)
|
# 'cross' is used to cross-compile for different architectures (see Cross.toml)
|
||||||
cross build --release --target ${TARGET} -p goose-cli
|
cross build --release --target ${TARGET} -p goose-cli -vv
|
||||||
|
|
||||||
# tar the goose binary as goose-<TARGET>.tar.bz2
|
# tar the goose binary as goose-<TARGET>.tar.bz2
|
||||||
cd target/${TARGET}/release
|
cd target/${TARGET}/release
|
||||||
|
|||||||
157
.github/workflows/bundle-desktop-windows.yml
vendored
Normal file
157
.github/workflows/bundle-desktop-windows.yml
vendored
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
name: "Bundle Desktop (Windows)"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
signing:
|
||||||
|
description: 'Whether to sign the Windows executable'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
secrets:
|
||||||
|
WINDOWS_CERTIFICATE:
|
||||||
|
required: false
|
||||||
|
WINDOWS_CERTIFICATE_PASSWORD:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-desktop-windows:
|
||||||
|
name: Build Desktop (Windows)
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# 1) Check out source
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# 2) Set up Rust
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
# 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
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
|
||||||
|
# 4) Cache dependencies (optional, can add more paths if needed)
|
||||||
|
- name: Cache node_modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
ui/desktop/node_modules
|
||||||
|
key: ${{ runner.os }}-build-desktop-windows-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-build-desktop-windows-
|
||||||
|
|
||||||
|
# 5) Install top-level dependencies if a package.json is in root
|
||||||
|
- name: Install top-level deps
|
||||||
|
run: |
|
||||||
|
if (Test-Path package.json) {
|
||||||
|
npm install
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
- 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\
|
||||||
|
|
||||||
|
# Copy MinGW DLLs - try both possible locations
|
||||||
|
$mingwPaths = @(
|
||||||
|
"C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin",
|
||||||
|
"C:\tools\mingw64\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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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\
|
||||||
|
}
|
||||||
|
|
||||||
|
# 8) Install & build UI desktop
|
||||||
|
- name: Build desktop UI with npm
|
||||||
|
run: |
|
||||||
|
cd ui\desktop
|
||||||
|
npm install
|
||||||
|
npm run bundle:windows
|
||||||
|
|
||||||
|
# 9) 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\
|
||||||
|
}
|
||||||
|
|
||||||
|
# 10) 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
|
||||||
|
|
||||||
|
# 11) Upload the final Windows build
|
||||||
|
- name: Upload Windows build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-windows-dist
|
||||||
|
path: ui/desktop/out/Goose-win32-x64/
|
||||||
18
.github/workflows/bundle-desktop.yml
vendored
18
.github/workflows/bundle-desktop.yml
vendored
@@ -201,21 +201,5 @@ jobs:
|
|||||||
echo "App did not stay open. Possible crash or startup error."
|
echo "App did not stay open. Possible crash or startup error."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
LOGFILE="$HOME/Library/Application Support/Goose/logs/main.log"
|
|
||||||
# Print the log and verify "ChatWindow loaded" is in the logs
|
|
||||||
if [ -f "$LOGFILE" ]; then
|
|
||||||
echo "===== Log file contents ====="
|
|
||||||
cat "$LOGFILE"
|
|
||||||
echo "============================="
|
|
||||||
if grep -F "ChatWindow loaded" "$LOGFILE"; then
|
|
||||||
echo "Confirmed: 'ChatWindow loaded' found in logs!"
|
|
||||||
else
|
|
||||||
echo "Did not find 'ChatWindow loaded' in logs. Failing..."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "No log file found at $LOGFILE. Exiting with failure."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Kill the app to clean up
|
# Kill the app to clean up
|
||||||
pkill -f "Goose.app/Contents/MacOS/Goose"
|
pkill -f "Goose.app/Contents/MacOS/Goose"
|
||||||
|
|||||||
22
.github/workflows/pr-comment-bundle-desktop.yml
vendored
22
.github/workflows/pr-comment-bundle-desktop.yml
vendored
@@ -51,10 +51,21 @@ jobs:
|
|||||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
bundle-desktop-windows:
|
||||||
|
# Only run this if ".bundle" command is detected.
|
||||||
|
needs: [trigger-on-command]
|
||||||
|
if: ${{ needs.trigger-on-command.outputs.continue == 'true' }}
|
||||||
|
uses: ./.github/workflows/bundle-desktop-windows.yml
|
||||||
|
with:
|
||||||
|
signing: true
|
||||||
|
secrets:
|
||||||
|
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||||
|
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
pr-comment:
|
pr-comment:
|
||||||
name: PR Comment with Desktop App
|
name: PR Comment with Desktop App
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [trigger-on-command, bundle-desktop]
|
needs: [trigger-on-command, bundle-desktop, bundle-desktop-windows]
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
@@ -71,10 +82,15 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
### Desktop App for this PR
|
### Desktop App for this PR
|
||||||
|
|
||||||
The following build is available for testing:
|
The following builds are available for testing:
|
||||||
|
|
||||||
- [📱 macOS Desktop App (arm64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/Goose-darwin-arm64.zip)
|
- [📱 macOS Desktop App (arm64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/Goose-darwin-arm64.zip)
|
||||||
|
- [🪟 Windows Desktop App (x64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/desktop-windows-dist.zip)
|
||||||
|
|
||||||
|
**macOS Instructions:**
|
||||||
After downloading, unzip the file and drag the Goose.app to your Applications folder. The app is signed and notarized for macOS.
|
After downloading, unzip the file and drag the Goose.app to your Applications folder. The app is signed and notarized for macOS.
|
||||||
|
|
||||||
This link is provided by nightly.link and will work even if you're not logged into GitHub.
|
**Windows Instructions:**
|
||||||
|
After downloading, unzip the file and run Goose.exe. The app is signed for Windows.
|
||||||
|
|
||||||
|
These links are provided by nightly.link and will work even if you're not logged into GitHub.
|
||||||
|
|||||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
path: download_cli.sh
|
path: download_cli.sh
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# 3) Bundle Desktop App (macOS only)
|
# 3) Bundle Desktop App (macOS)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
bundle-desktop:
|
bundle-desktop:
|
||||||
uses: ./.github/workflows/bundle-desktop.yml
|
uses: ./.github/workflows/bundle-desktop.yml
|
||||||
@@ -46,6 +46,19 @@ jobs:
|
|||||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
# # ------------------------------------------------------------
|
||||||
|
# # 4) Bundle Desktop App (Windows)
|
||||||
|
# # ------------------------------------------------------------
|
||||||
|
# bundle-desktop-windows:
|
||||||
|
# uses: ./.github/workflows/bundle-desktop-windows.yml
|
||||||
|
# # Signing is disabled by default until we have a certificate
|
||||||
|
# with:
|
||||||
|
# signing: false
|
||||||
|
# # Uncomment and configure these when we have a certificate:
|
||||||
|
# # secrets:
|
||||||
|
# # WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||||
|
# # WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
# ------------------------------------
|
# ------------------------------------
|
||||||
# 4) Create/Update GitHub Release
|
# 4) Create/Update GitHub Release
|
||||||
# ------------------------------------
|
# ------------------------------------
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ pre-build = [
|
|||||||
libxcb1-dev:arm64
|
libxcb1-dev:arm64
|
||||||
"""
|
"""
|
||||||
]
|
]
|
||||||
env = { PKG_CONFIG_PATH = "/usr/lib/aarch64-linux-gnu/pkgconfig" }
|
|
||||||
|
|
||||||
[target.x86_64-unknown-linux-gnu]
|
[target.x86_64-unknown-linux-gnu]
|
||||||
xargo = false
|
xargo = false
|
||||||
@@ -27,3 +26,9 @@ pre-build = [
|
|||||||
libxcb1-dev \
|
libxcb1-dev \
|
||||||
"""
|
"""
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[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" }
|
||||||
|
|||||||
63
Justfile
63
Justfile
@@ -10,6 +10,31 @@ release-binary:
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
@just copy-binary
|
@just copy-binary
|
||||||
|
|
||||||
|
# Build Windows executable
|
||||||
|
release-windows:
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
if [ "$(uname)" = "Darwin" ] || [ "$(uname)" = "Linux" ]; then
|
||||||
|
echo "Building Windows executable using Docker..."
|
||||||
|
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 && \
|
||||||
|
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/"
|
||||||
|
else
|
||||||
|
echo "Building Windows executable using Docker through PowerShell..."
|
||||||
|
powershell.exe -Command "docker volume create goose-windows-cache; 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 && 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/'"
|
||||||
|
fi
|
||||||
|
echo "Windows executable and required DLLs created at ./target/x86_64-pc-windows-gnu/release/"
|
||||||
|
|
||||||
# Copy binary command
|
# Copy binary command
|
||||||
copy-binary:
|
copy-binary:
|
||||||
@if [ -f ./target/release/goosed ]; then \
|
@if [ -f ./target/release/goosed ]; then \
|
||||||
@@ -20,12 +45,30 @@ copy-binary:
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Copy Windows binary command
|
||||||
|
copy-binary-windows:
|
||||||
|
@powershell.exe -Command "if (Test-Path ./target/x86_64-pc-windows-gnu/release/goosed.exe) { \
|
||||||
|
Write-Host 'Copying Windows binary and DLLs to ui/desktop/src/bin...'; \
|
||||||
|
Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goosed.exe' -Destination './ui/desktop/src/bin/' -Force; \
|
||||||
|
Copy-Item -Path './target/x86_64-pc-windows-gnu/release/*.dll' -Destination './ui/desktop/src/bin/' -Force; \
|
||||||
|
} else { \
|
||||||
|
Write-Host 'Windows binary not found.' -ForegroundColor Red; \
|
||||||
|
exit 1; \
|
||||||
|
}"
|
||||||
|
|
||||||
# Run UI with latest
|
# Run UI with latest
|
||||||
run-ui:
|
run-ui:
|
||||||
@just release-binary
|
@just release-binary
|
||||||
@echo "Running UI..."
|
@echo "Running UI..."
|
||||||
cd ui/desktop && npm install && npm run start-gui
|
cd ui/desktop && npm install && npm run start-gui
|
||||||
|
|
||||||
|
# Run UI with latest (Windows version)
|
||||||
|
run-ui-windows:
|
||||||
|
@just release-windows
|
||||||
|
@powershell.exe -Command "Write-Host 'Copying Windows binary...'"
|
||||||
|
@just copy-binary-windows
|
||||||
|
@powershell.exe -Command "Write-Host 'Running UI...'; Set-Location ui/desktop; npm install; npm run start-gui"
|
||||||
|
|
||||||
# Run Docusaurus server for documentation
|
# Run Docusaurus server for documentation
|
||||||
run-docs:
|
run-docs:
|
||||||
@echo "Running docs server..."
|
@echo "Running docs server..."
|
||||||
@@ -41,6 +84,26 @@ make-ui:
|
|||||||
@just release-binary
|
@just release-binary
|
||||||
cd ui/desktop && npm run bundle:default
|
cd ui/desktop && npm run bundle:default
|
||||||
|
|
||||||
|
# make GUI with latest Windows binary
|
||||||
|
make-ui-windows:
|
||||||
|
@just release-windows
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
if [ -f "./target/x86_64-pc-windows-gnu/release/goosed.exe" ]; then \
|
||||||
|
echo "Copying Windows binary and DLLs to ui/desktop/src/bin..."; \
|
||||||
|
mkdir -p ./ui/desktop/src/bin; \
|
||||||
|
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/; \
|
||||||
|
echo "Building Windows package..."; \
|
||||||
|
cd ui/desktop && \
|
||||||
|
npm run bundle:windows && \
|
||||||
|
mkdir -p out/Goose-win32-x64/resources/bin && \
|
||||||
|
cp -f src/bin/goosed.exe out/Goose-win32-x64/resources/bin/ && \
|
||||||
|
cp -f src/bin/*.dll out/Goose-win32-x64/resources/bin/; \
|
||||||
|
else \
|
||||||
|
echo "Windows binary not found."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Setup langfuse server
|
# Setup langfuse server
|
||||||
langfuse-server:
|
langfuse-server:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ tracing = "0.1"
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] }
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
|
winapi = { version = "0.3", features = ["wincred"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
winapi = { version = "0.3", features = ["wincred"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -62,12 +62,29 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some(ConfigError::KeyringError(msg)) => {
|
Some(ConfigError::KeyringError(msg)) => {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
println!(
|
println!(
|
||||||
"\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.",
|
"\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.",
|
||||||
style("Error").red().italic(),
|
style("Error").red().italic(),
|
||||||
msg,
|
msg,
|
||||||
style("goose configure").cyan()
|
style("goose configure").cyan()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
println!(
|
||||||
|
"\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.",
|
||||||
|
style("Error").red().italic(),
|
||||||
|
msg,
|
||||||
|
style("goose configure").cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
println!(
|
||||||
|
"\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.",
|
||||||
|
style("Error").red().italic(),
|
||||||
|
msg,
|
||||||
|
style("goose configure").cyan()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(ConfigError::DeserializeError(msg)) => {
|
Some(ConfigError::DeserializeError(msg)) => {
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer;
|
|||||||
/// Returns the directory where log files should be stored.
|
/// Returns the directory where log files should be stored.
|
||||||
/// Creates the directory structure if it doesn't exist.
|
/// Creates the directory structure if it doesn't exist.
|
||||||
fn get_log_directory() -> Result<PathBuf> {
|
fn get_log_directory() -> Result<PathBuf> {
|
||||||
let home = std::env::var("HOME").context("HOME environment variable not set")?;
|
let home = if cfg!(windows) {
|
||||||
|
std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")?
|
||||||
|
} else {
|
||||||
|
std::env::var("HOME").context("HOME environment variable not set")?
|
||||||
|
};
|
||||||
|
|
||||||
let base_log_dir = PathBuf::from(home)
|
let base_log_dir = PathBuf::from(home)
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("goose")
|
.join("goose")
|
||||||
@@ -114,7 +119,11 @@ mod tests {
|
|||||||
|
|
||||||
fn setup_temp_home() -> TempDir {
|
fn setup_temp_home() -> TempDir {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
env::set_var("HOME", temp_dir.path());
|
if cfg!(windows) {
|
||||||
|
env::set_var("USERPROFILE", temp_dir.path());
|
||||||
|
} else {
|
||||||
|
env::set_var("HOME", temp_dir.path());
|
||||||
|
}
|
||||||
temp_dir
|
temp_dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ use indoc::{formatdoc, indoc};
|
|||||||
use reqwest::{Client, Url};
|
use reqwest::{Client, Url};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap, fs, future::Future, os::unix::fs::PermissionsExt, path::PathBuf,
|
collections::HashMap, fs, future::Future, path::PathBuf, pin::Pin, sync::Arc, sync::Mutex,
|
||||||
pin::Pin, sync::Arc, sync::Mutex,
|
|
||||||
};
|
};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
@@ -18,6 +17,9 @@ use mcp_core::{
|
|||||||
use mcp_server::router::CapabilitiesBuilder;
|
use mcp_server::router::CapabilitiesBuilder;
|
||||||
use mcp_server::Router;
|
use mcp_server::Router;
|
||||||
|
|
||||||
|
mod platform;
|
||||||
|
use platform::{create_system_automation, SystemAutomation};
|
||||||
|
|
||||||
/// An extension designed for non-developers to help them with common tasks like
|
/// An extension designed for non-developers to help them with common tasks like
|
||||||
/// web scraping, data processing, and automation.
|
/// web scraping, data processing, and automation.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -27,6 +29,7 @@ pub struct ComputerControllerRouter {
|
|||||||
active_resources: Arc<Mutex<HashMap<String, Resource>>>,
|
active_resources: Arc<Mutex<HashMap<String, Resource>>>,
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
instructions: String,
|
instructions: String,
|
||||||
|
system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ComputerControllerRouter {
|
impl Default for ComputerControllerRouter {
|
||||||
@@ -86,9 +89,19 @@ impl ComputerControllerRouter {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let computer_control_tool = Tool::new(
|
let computer_control_desc = match std::env::consts::OS {
|
||||||
"computer_control",
|
"windows" => indoc! {r#"
|
||||||
indoc! {r#"
|
Control the computer using Windows system automation.
|
||||||
|
|
||||||
|
Features available:
|
||||||
|
- PowerShell automation for system control
|
||||||
|
- UI automation through PowerShell
|
||||||
|
- File and system management
|
||||||
|
- Windows-specific features and settings
|
||||||
|
|
||||||
|
Can be combined with screenshot tool for visual task assistance.
|
||||||
|
"#},
|
||||||
|
_ => indoc! {r#"
|
||||||
Control the computer using AppleScript (macOS only). Automate applications and system features.
|
Control the computer using AppleScript (macOS only). Automate applications and system features.
|
||||||
|
|
||||||
Key capabilities:
|
Key capabilities:
|
||||||
@@ -104,14 +117,19 @@ impl ComputerControllerRouter {
|
|||||||
- Data: Interact with spreadsheets and documents
|
- Data: Interact with spreadsheets and documents
|
||||||
|
|
||||||
Can be combined with screenshot tool for visual task assistance.
|
Can be combined with screenshot tool for visual task assistance.
|
||||||
"#},
|
"#},
|
||||||
|
};
|
||||||
|
|
||||||
|
let computer_control_tool = Tool::new(
|
||||||
|
"computer_control",
|
||||||
|
computer_control_desc.to_string(),
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["script"],
|
"required": ["script"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"script": {
|
"script": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The AppleScript content to execute"
|
"description": "The automation script content (PowerShell for Windows, AppleScript for macOS)"
|
||||||
},
|
},
|
||||||
"save_output": {
|
"save_output": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@@ -122,9 +140,18 @@ impl ComputerControllerRouter {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let quick_script_tool = Tool::new(
|
let quick_script_desc = match std::env::consts::OS {
|
||||||
"automation_script",
|
"windows" => indoc! {r#"
|
||||||
indoc! {r#"
|
Create and run small PowerShell or Batch scripts for automation tasks.
|
||||||
|
PowerShell is recommended for most tasks.
|
||||||
|
|
||||||
|
The script is saved to a temporary file and executed.
|
||||||
|
Some examples:
|
||||||
|
- Sort unique lines: Get-Content file.txt | Sort-Object -Unique
|
||||||
|
- Extract CSV column: Import-Csv file.csv | Select-Object -ExpandProperty Column2
|
||||||
|
- Find text: Select-String -Pattern "pattern" -Path file.txt
|
||||||
|
"#},
|
||||||
|
_ => indoc! {r#"
|
||||||
Create and run small scripts for automation tasks.
|
Create and run small scripts for automation tasks.
|
||||||
Supports Shell and Ruby (on macOS).
|
Supports Shell and Ruby (on macOS).
|
||||||
|
|
||||||
@@ -135,14 +162,19 @@ impl ComputerControllerRouter {
|
|||||||
- create a sorted list of unique lines: sort file.txt | uniq
|
- create a sorted list of unique lines: sort file.txt | uniq
|
||||||
- extract 2nd column in csv: awk -F "," '{ print $2}'
|
- extract 2nd column in csv: awk -F "," '{ print $2}'
|
||||||
- pattern matching: grep pattern file.txt
|
- pattern matching: grep pattern file.txt
|
||||||
"#},
|
"#},
|
||||||
|
};
|
||||||
|
|
||||||
|
let quick_script_tool = Tool::new(
|
||||||
|
"automation_script",
|
||||||
|
quick_script_desc.to_string(),
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["language", "script"],
|
"required": ["language", "script"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"language": {
|
"language": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["shell", "ruby"],
|
"enum": ["shell", "ruby", "powershell", "batch"],
|
||||||
"description": "The scripting language to use"
|
"description": "The scripting language to use"
|
||||||
},
|
},
|
||||||
"script": {
|
"script": {
|
||||||
@@ -186,9 +218,10 @@ impl ComputerControllerRouter {
|
|||||||
|
|
||||||
// Create cache directory in user's home directory
|
// Create cache directory in user's home directory
|
||||||
let cache_dir = dirs::cache_dir()
|
let cache_dir = dirs::cache_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
.unwrap_or_else(|| create_system_automation().get_temp_path())
|
||||||
.join("goose")
|
.join("goose")
|
||||||
.join("computer_controller");
|
.join("computer_controller");
|
||||||
|
|
||||||
fs::create_dir_all(&cache_dir).unwrap_or_else(|_| {
|
fs::create_dir_all(&cache_dir).unwrap_or_else(|_| {
|
||||||
println!(
|
println!(
|
||||||
"Warning: Failed to create cache directory at {:?}",
|
"Warning: Failed to create cache directory at {:?}",
|
||||||
@@ -196,8 +229,41 @@ impl ComputerControllerRouter {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let macos_browser_instructions = if std::env::consts::OS == "macos" {
|
let system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>> =
|
||||||
indoc! {r#"
|
Arc::new(create_system_automation());
|
||||||
|
|
||||||
|
let os_specific_instructions = match std::env::consts::OS {
|
||||||
|
"windows" => indoc! {r#"
|
||||||
|
Here are some extra tools:
|
||||||
|
automation_script
|
||||||
|
- Create and run PowerShell or Batch scripts
|
||||||
|
- PowerShell is recommended for most tasks
|
||||||
|
- Scripts can save their output to files
|
||||||
|
- Windows-specific features:
|
||||||
|
- PowerShell for system automation and UI control
|
||||||
|
- Windows Management Instrumentation (WMI)
|
||||||
|
- Registry access and system settings
|
||||||
|
- Use the screenshot tool if needed to help with tasks
|
||||||
|
|
||||||
|
computer_control
|
||||||
|
- System automation using PowerShell
|
||||||
|
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||||
|
"#},
|
||||||
|
_ => indoc! {r#"
|
||||||
|
Here are some extra tools:
|
||||||
|
automation_script
|
||||||
|
- Create and run Shell and Ruby scripts
|
||||||
|
- Shell (bash) is recommended for most tasks
|
||||||
|
- Scripts can save their output to files
|
||||||
|
- macOS-specific features:
|
||||||
|
- AppleScript for system and UI control
|
||||||
|
- Integration with macOS apps and services
|
||||||
|
- Use the screenshot tool if needed to help with tasks
|
||||||
|
|
||||||
|
computer_control
|
||||||
|
- System automation using AppleScript
|
||||||
|
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||||
|
|
||||||
When you need to interact with websites or web applications, consider using the computer_control tool with AppleScript, which can automate Safari or other browsers to:
|
When you need to interact with websites or web applications, consider using the computer_control tool with AppleScript, which can automate Safari or other browsers to:
|
||||||
- Open specific URLs
|
- Open specific URLs
|
||||||
- Fill in forms
|
- Fill in forms
|
||||||
@@ -205,47 +271,25 @@ impl ComputerControllerRouter {
|
|||||||
- Extract content
|
- Extract content
|
||||||
- Handle web-based workflows
|
- Handle web-based workflows
|
||||||
This is often more reliable than web scraping for modern web applications.
|
This is often more reliable than web scraping for modern web applications.
|
||||||
"#}
|
"#},
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let instructions = formatdoc! {r#"
|
let instructions = formatdoc! {r#"
|
||||||
You are a helpful assistant to a power user who is not a professional developer, but you may use devleopment tools to help assist them.
|
You are a helpful assistant to a power user who is not a professional developer, but you may use development tools to help assist them.
|
||||||
The user may not know how to break down tasks, so you will need to ensure that you do, and run things in batches as needed.
|
The user may not know how to break down tasks, so you will need to ensure that you do, and run things in batches as needed.
|
||||||
The ComputerControllerExtension helps you with common tasks like web scraping,
|
The ComputerControllerExtension helps you with common tasks like web scraping,
|
||||||
data processing, and automation and computer control without requiring programming expertise,
|
data processing, and automation without requiring programming expertise.
|
||||||
supplementing the Developer Extension.
|
|
||||||
|
|
||||||
You can use scripting as needed to work with text files of data, such as csvs, json, or text files etc.
|
You can use scripting as needed to work with text files of data, such as csvs, json, or text files etc.
|
||||||
Using the developer extension is allowed for more sophisticated tasks or instructed to (js or py can be helpful for more complex tasks if tools are available).
|
Using the developer extension is allowed for more sophisticated tasks or instructed to (js or py can be helpful for more complex tasks if tools are available).
|
||||||
|
|
||||||
{macos_instructions}
|
Accessing web sites, even apis, may be common (you can use scripting to do this) without troubling them too much (they won't know what limits are).
|
||||||
|
Try to do your best to find ways to complete a task without too many questions or offering options unless it is really unclear, find a way if you can.
|
||||||
Accessing web sites, even apis, may be common (you can use bash scripting to do this) without troubling them too much (they won't know what limits are).
|
|
||||||
Try to do your best to find ways to complete a task without too many quesitons or offering options unless it is really unclear, find a way if you can.
|
|
||||||
You can also guide them steps if they can help out as you go along.
|
You can also guide them steps if they can help out as you go along.
|
||||||
|
|
||||||
There is already a screenshot tool available you can use if needed to see what is on screen.
|
There is already a screenshot tool available you can use if needed to see what is on screen.
|
||||||
|
|
||||||
Here are some extra tools:
|
{os_instructions}
|
||||||
automation_script
|
|
||||||
- Create and run simple automation scripts
|
|
||||||
- Supports Shell (such as bash), AppleScript (on macos), Ruby (on macos)
|
|
||||||
- Scripts can save their output to files
|
|
||||||
- on macos, can use applescript to interact with the desktop, eg calendars, notes and more, anything apple script can do for apps that support it:
|
|
||||||
AppleScript is a powerful scripting language designed for automating tasks on macOS such as: Integration with Other Scripts
|
|
||||||
Execute shell scripts, Ruby scripts, or other automation scripts.
|
|
||||||
Combine workflows across scripting languages.
|
|
||||||
Complex Workflows
|
|
||||||
Automate multi-step tasks involving multiple apps or system features.
|
|
||||||
Create scheduled tasks using Calendar or other scheduling apps.
|
|
||||||
|
|
||||||
- use the screenshot tool if needed to help with tasks
|
|
||||||
|
|
||||||
computer_control
|
|
||||||
- Control the computer using AppleScript (macOS only)
|
|
||||||
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
|
||||||
|
|
||||||
web_search
|
web_search
|
||||||
- Search the web using DuckDuckGo's API for general topics or keywords
|
- Search the web using DuckDuckGo's API for general topics or keywords
|
||||||
@@ -262,7 +306,7 @@ impl ComputerControllerRouter {
|
|||||||
- Cache directory: {cache_dir}
|
- Cache directory: {cache_dir}
|
||||||
- File organization and cleanup
|
- File organization and cleanup
|
||||||
"#,
|
"#,
|
||||||
macos_instructions = macos_browser_instructions,
|
os_instructions = os_specific_instructions,
|
||||||
cache_dir = cache_dir.display()
|
cache_dir = cache_dir.display()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,6 +322,7 @@ impl ComputerControllerRouter {
|
|||||||
active_resources: Arc::new(Mutex::new(HashMap::new())),
|
active_resources: Arc::new(Mutex::new(HashMap::new())),
|
||||||
http_client: Client::builder().user_agent("Goose/1.0").build().unwrap(),
|
http_client: Client::builder().user_agent("Goose/1.0").build().unwrap(),
|
||||||
instructions: instructions.clone(),
|
instructions: instructions.clone(),
|
||||||
|
system_automation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +363,7 @@ impl ComputerControllerRouter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement web_scrape tool functionality
|
// Implement web_search tool functionality
|
||||||
async fn web_search(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
async fn web_search(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||||
let query = params
|
let query = params
|
||||||
.get("query")
|
.get("query")
|
||||||
@@ -452,22 +497,18 @@ impl ComputerControllerRouter {
|
|||||||
ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e))
|
ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let (shell, shell_arg) = self.system_automation.get_shell_command();
|
||||||
|
|
||||||
let command = match language {
|
let command = match language {
|
||||||
"shell" => {
|
"shell" | "batch" => {
|
||||||
let script_path = script_dir.path().join("script.sh");
|
let script_path = script_dir.path().join(format!(
|
||||||
|
"script.{}",
|
||||||
|
if cfg!(windows) { "bat" } else { "sh" }
|
||||||
|
));
|
||||||
fs::write(&script_path, script).map_err(|e| {
|
fs::write(&script_path, script).map_err(|e| {
|
||||||
ToolError::ExecutionError(format!("Failed to write script: {}", e))
|
ToolError::ExecutionError(format!("Failed to write script: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).map_err(
|
|
||||||
|e| {
|
|
||||||
ToolError::ExecutionError(format!(
|
|
||||||
"Failed to set script permissions: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
script_path.display().to_string()
|
script_path.display().to_string()
|
||||||
}
|
}
|
||||||
"ruby" => {
|
"ruby" => {
|
||||||
@@ -478,12 +519,23 @@ impl ComputerControllerRouter {
|
|||||||
|
|
||||||
format!("ruby {}", script_path.display())
|
format!("ruby {}", script_path.display())
|
||||||
}
|
}
|
||||||
|
"powershell" => {
|
||||||
|
let script_path = script_dir.path().join("script.ps1");
|
||||||
|
fs::write(&script_path, script).map_err(|e| {
|
||||||
|
ToolError::ExecutionError(format!("Failed to write script: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"powershell -NoProfile -NonInteractive -File {}",
|
||||||
|
script_path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
_ => unreachable!(), // Prevented by enum in tool definition
|
_ => unreachable!(), // Prevented by enum in tool definition
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run the script
|
// Run the script
|
||||||
let output = Command::new("bash")
|
let output = Command::new(shell)
|
||||||
.arg("-c")
|
.arg(shell_arg)
|
||||||
.arg(&command)
|
.arg(&command)
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
@@ -515,14 +567,8 @@ impl ComputerControllerRouter {
|
|||||||
Ok(vec![Content::text(result)])
|
Ok(vec![Content::text(result)])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement computer control (AppleScript) functionality
|
// Implement computer control functionality
|
||||||
async fn computer_control(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
async fn computer_control(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||||
if std::env::consts::OS != "macos" {
|
|
||||||
return Err(ToolError::ExecutionError(
|
|
||||||
"Computer control (AppleScript) is only supported on macOS".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let script = params
|
let script = params
|
||||||
.get("script")
|
.get("script")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -533,44 +579,18 @@ impl ComputerControllerRouter {
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Create a temporary directory for the script
|
// Use platform-specific automation
|
||||||
let script_dir = tempfile::tempdir().map_err(|e| {
|
let output = self
|
||||||
ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e))
|
.system_automation
|
||||||
})?;
|
.execute_system_script(script)
|
||||||
|
.map_err(|e| ToolError::ExecutionError(format!("Failed to execute script: {}", e)))?;
|
||||||
|
|
||||||
let script_path = script_dir.path().join("script.scpt");
|
let mut result = format!("Script completed successfully.\n\nOutput:\n{}", output);
|
||||||
fs::write(&script_path, script)
|
|
||||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write script: {}", e)))?;
|
|
||||||
|
|
||||||
let command = format!("osascript {}", script_path.display());
|
|
||||||
|
|
||||||
// Run the script
|
|
||||||
let output = Command::new("bash")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(&command)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to run AppleScript: {}", e)))?;
|
|
||||||
|
|
||||||
let output_str = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
||||||
let error_str = String::from_utf8_lossy(&output.stderr).into_owned();
|
|
||||||
|
|
||||||
let mut result = if output.status.success() {
|
|
||||||
format!(
|
|
||||||
"AppleScript completed successfully.\n\nOutput:\n{}",
|
|
||||||
output_str
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"AppleScript failed with error code {}.\n\nError:\n{}\nOutput:\n{}",
|
|
||||||
output.status, error_str, output_str
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save output if requested
|
// Save output if requested
|
||||||
if save_output && !output_str.is_empty() {
|
if save_output && !output.is_empty() {
|
||||||
let cache_path = self
|
let cache_path = self
|
||||||
.save_to_cache(output_str.as_bytes(), "applescript_output", "txt")
|
.save_to_cache(output.as_bytes(), "automation_output", "txt")
|
||||||
.await?;
|
.await?;
|
||||||
result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display()));
|
result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display()));
|
||||||
|
|
||||||
|
|||||||
25
crates/goose-mcp/src/computercontroller/platform/macos.rs
Normal file
25
crates/goose-mcp/src/computercontroller/platform/macos.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use super::SystemAutomation;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub struct MacOSAutomation;
|
||||||
|
|
||||||
|
// MacOSAutomation is Send + Sync because it contains no shared state
|
||||||
|
unsafe impl Send for MacOSAutomation {}
|
||||||
|
unsafe impl Sync for MacOSAutomation {}
|
||||||
|
|
||||||
|
impl SystemAutomation for MacOSAutomation {
|
||||||
|
fn execute_system_script(&self, script: &str) -> std::io::Result<String> {
|
||||||
|
let output = Command::new("osascript").arg("-e").arg(script).output()?;
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_shell_command(&self) -> (&'static str, &'static str) {
|
||||||
|
("bash", "-c")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_temp_path(&self) -> PathBuf {
|
||||||
|
PathBuf::from("/tmp")
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/goose-mcp/src/computercontroller/platform/mod.rs
Normal file
29
crates/goose-mcp/src/computercontroller/platform/mod.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
mod macos;
|
||||||
|
mod windows;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub use self::windows::WindowsAutomation;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub use self::macos::MacOSAutomation;
|
||||||
|
|
||||||
|
pub trait SystemAutomation: Send + Sync {
|
||||||
|
fn execute_system_script(&self, script: &str) -> std::io::Result<String>;
|
||||||
|
fn get_shell_command(&self) -> (&'static str, &'static str); // (shell, arg)
|
||||||
|
fn get_temp_path(&self) -> std::path::PathBuf;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_system_automation() -> Box<dyn SystemAutomation + Send + Sync> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
Box::new(WindowsAutomation)
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
Box::new(MacOSAutomation)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
unimplemented!("Unsupported operating system")
|
||||||
|
}
|
||||||
|
}
|
||||||
32
crates/goose-mcp/src/computercontroller/platform/windows.rs
Normal file
32
crates/goose-mcp/src/computercontroller/platform/windows.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use super::SystemAutomation;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub struct WindowsAutomation;
|
||||||
|
|
||||||
|
// WindowsAutomation is Send + Sync because it contains no shared state
|
||||||
|
unsafe impl Send for WindowsAutomation {}
|
||||||
|
unsafe impl Sync for WindowsAutomation {}
|
||||||
|
|
||||||
|
impl SystemAutomation for WindowsAutomation {
|
||||||
|
fn execute_system_script(&self, script: &str) -> std::io::Result<String> {
|
||||||
|
let output = Command::new("powershell")
|
||||||
|
.arg("-NoProfile")
|
||||||
|
.arg("-NonInteractive")
|
||||||
|
.arg("-Command")
|
||||||
|
.arg(script)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_shell_command(&self) -> (&'static str, &'static str) {
|
||||||
|
("powershell", "-Command")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_temp_path(&self) -> PathBuf {
|
||||||
|
std::env::var("TEMP")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(r"C:\Windows\Temp"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ pub fn get_language_identifier(path: &Path) -> &'static str {
|
|||||||
Some("toml") => "toml",
|
Some("toml") => "toml",
|
||||||
Some("yaml") | Some("yml") => "yaml",
|
Some("yaml") | Some("yml") => "yaml",
|
||||||
Some("sh") => "bash",
|
Some("sh") => "bash",
|
||||||
|
Some("ps1") => "powershell",
|
||||||
|
Some("bat") | Some("cmd") => "batch",
|
||||||
|
Some("vbs") => "vbscript",
|
||||||
Some("go") => "go",
|
Some("go") => "go",
|
||||||
Some("md") => "markdown",
|
Some("md") => "markdown",
|
||||||
Some("html") => "html",
|
Some("html") => "html",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod lang;
|
mod lang;
|
||||||
|
mod shell;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -31,6 +32,11 @@ use std::process::Stdio;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use xcap::{Monitor, Window};
|
use xcap::{Monitor, Window};
|
||||||
|
|
||||||
|
use self::shell::{
|
||||||
|
expand_path, format_command_for_platform, get_shell_config, is_absolute_path,
|
||||||
|
normalize_line_endings,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct DeveloperRouter {
|
pub struct DeveloperRouter {
|
||||||
tools: Vec<Tool>,
|
tools: Vec<Tool>,
|
||||||
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
|
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
|
||||||
@@ -48,9 +54,30 @@ impl DeveloperRouter {
|
|||||||
// TODO consider rust native search tools, we could use
|
// TODO consider rust native search tools, we could use
|
||||||
// https://docs.rs/ignore/latest/ignore/
|
// https://docs.rs/ignore/latest/ignore/
|
||||||
|
|
||||||
let bash_tool = Tool::new(
|
// Get OS-specific shell tool description
|
||||||
"shell".to_string(),
|
let shell_tool_desc = match std::env::consts::OS {
|
||||||
indoc! {r#"
|
"windows" => indoc! {r#"
|
||||||
|
Execute a command in the shell.
|
||||||
|
|
||||||
|
This will return the output and error concatenated into a single string, as
|
||||||
|
you would see from running on the command line. There will also be an indication
|
||||||
|
of if the command succeeded or failed.
|
||||||
|
|
||||||
|
Avoid commands that produce a large amount of output, and consider piping those outputs to files.
|
||||||
|
|
||||||
|
**Important**: For searching files and code:
|
||||||
|
|
||||||
|
Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast:
|
||||||
|
- To locate a file by name: `rg --files | rg example.py`
|
||||||
|
- To locate content inside files: `rg 'class Example'`
|
||||||
|
|
||||||
|
Alternative Windows commands (if ripgrep is not installed):
|
||||||
|
- To locate a file by name: `dir /s /b example.py`
|
||||||
|
- To locate content inside files: `findstr /s /i "class Example" *.py`
|
||||||
|
|
||||||
|
Note: Alternative commands may show ignored/hidden files that should be excluded.
|
||||||
|
"#},
|
||||||
|
_ => indoc! {r#"
|
||||||
Execute a command in the shell.
|
Execute a command in the shell.
|
||||||
|
|
||||||
This will return the output and error concatenated into a single string, as
|
This will return the output and error concatenated into a single string, as
|
||||||
@@ -64,8 +91,13 @@ impl DeveloperRouter {
|
|||||||
**Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions
|
**Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions
|
||||||
may show ignored or hidden files. For example *do not* use `find` or `ls -r`
|
may show ignored or hidden files. For example *do not* use `find` or `ls -r`
|
||||||
- To locate a file by name: `rg --files | rg example.py`
|
- To locate a file by name: `rg --files | rg example.py`
|
||||||
- To locate consent inside files: `rg 'class Example'`
|
- To locate content inside files: `rg 'class Example'`
|
||||||
"#}.to_string(),
|
"#},
|
||||||
|
};
|
||||||
|
|
||||||
|
let bash_tool = Tool::new(
|
||||||
|
"shell".to_string(),
|
||||||
|
shell_tool_desc.to_string(),
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["command"],
|
"required": ["command"],
|
||||||
@@ -157,9 +189,31 @@ impl DeveloperRouter {
|
|||||||
|
|
||||||
// Get base instructions and working directory
|
// Get base instructions and working directory
|
||||||
let cwd = std::env::current_dir().expect("should have a current working dir");
|
let cwd = std::env::current_dir().expect("should have a current working dir");
|
||||||
let base_instructions = formatdoc! {r#"
|
let os = std::env::consts::OS;
|
||||||
The developer extension gives you the capabilities to edit code files and run shell commands,
|
|
||||||
and can be used to solve a wide range of problems.
|
let base_instructions = match os {
|
||||||
|
"windows" => formatdoc! {r#"
|
||||||
|
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||||
|
and can be used to solve a wide range of problems.
|
||||||
|
|
||||||
|
You can use the shell tool to run Windows commands (PowerShell or CMD).
|
||||||
|
When using paths, you can use either backslashes or forward slashes.
|
||||||
|
|
||||||
|
Use the shell tool as needed to locate files or interact with the project.
|
||||||
|
|
||||||
|
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
|
||||||
|
prompted to, but you can mention they are available if they are relevant.
|
||||||
|
|
||||||
|
operating system: {os}
|
||||||
|
current directory: {cwd}
|
||||||
|
|
||||||
|
"#,
|
||||||
|
os=os,
|
||||||
|
cwd=cwd.to_string_lossy(),
|
||||||
|
},
|
||||||
|
_ => formatdoc! {r#"
|
||||||
|
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||||
|
and can be used to solve a wide range of problems.
|
||||||
|
|
||||||
You can use the shell tool to run any command that would work on the relevant operating system.
|
You can use the shell tool to run any command that would work on the relevant operating system.
|
||||||
Use the shell tool as needed to locate files or interact with the project.
|
Use the shell tool as needed to locate files or interact with the project.
|
||||||
@@ -170,9 +224,10 @@ impl DeveloperRouter {
|
|||||||
operating system: {os}
|
operating system: {os}
|
||||||
current directory: {cwd}
|
current directory: {cwd}
|
||||||
|
|
||||||
"#,
|
"#,
|
||||||
os=std::env::consts::OS,
|
os=os,
|
||||||
cwd=cwd.to_string_lossy(),
|
cwd=cwd.to_string_lossy(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for global hints in ~/.config/goose/.goosehints
|
// Check for global hints in ~/.config/goose/.goosehints
|
||||||
@@ -223,15 +278,15 @@ impl DeveloperRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to resolve a path relative to cwd
|
// Helper method to resolve a path relative to cwd with platform-specific handling
|
||||||
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, ToolError> {
|
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, ToolError> {
|
||||||
let cwd = std::env::current_dir().expect("should have a current working dir");
|
let cwd = std::env::current_dir().expect("should have a current working dir");
|
||||||
let expanded = shellexpand::tilde(path_str);
|
let expanded = expand_path(path_str);
|
||||||
let path = Path::new(expanded.as_ref());
|
let path = Path::new(&expanded);
|
||||||
|
|
||||||
let suggestion = cwd.join(path);
|
let suggestion = cwd.join(path);
|
||||||
|
|
||||||
match path.is_absolute() {
|
match is_absolute_path(&expanded) {
|
||||||
true => Ok(path.to_path_buf()),
|
true => Ok(path.to_path_buf()),
|
||||||
false => Err(ToolError::InvalidParameters(format!(
|
false => Err(ToolError::InvalidParameters(format!(
|
||||||
"The path {} is not an absolute path, did you possibly mean {}?",
|
"The path {} is not an absolute path, did you possibly mean {}?",
|
||||||
@@ -241,7 +296,7 @@ impl DeveloperRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement bash tool functionality
|
// Shell command execution with platform-specific handling
|
||||||
async fn bash(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
async fn bash(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||||
let command =
|
let command =
|
||||||
params
|
params
|
||||||
@@ -251,19 +306,17 @@ impl DeveloperRouter {
|
|||||||
"The command string is required".to_string(),
|
"The command string is required".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
// TODO consider command suggestions and safety rails
|
// Get platform-specific shell configuration
|
||||||
|
let shell_config = get_shell_config();
|
||||||
|
let cmd_with_redirect = format_command_for_platform(command);
|
||||||
|
|
||||||
// TODO be more careful about backgrounding, revisit interleave
|
// Execute the command using platform-specific shell
|
||||||
// Redirect stderr to stdout to interleave outputs
|
let child = Command::new(&shell_config.executable)
|
||||||
let cmd_with_redirect = format!("{} 2>&1", command);
|
.stdout(Stdio::piped())
|
||||||
|
|
||||||
// Execute the command
|
|
||||||
let child = Command::new("bash")
|
|
||||||
.stdout(Stdio::piped()) // These two pipes required to capture output later.
|
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.kill_on_drop(true) // Critical so that the command is killed when the agent.reply stream is interrupted.
|
.kill_on_drop(true)
|
||||||
.arg("-c")
|
.arg(&shell_config.arg)
|
||||||
.arg(cmd_with_redirect)
|
.arg(cmd_with_redirect)
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||||
@@ -417,12 +470,15 @@ impl DeveloperRouter {
|
|||||||
path: &PathBuf,
|
path: &PathBuf,
|
||||||
file_text: &str,
|
file_text: &str,
|
||||||
) -> Result<Vec<Content>, ToolError> {
|
) -> Result<Vec<Content>, ToolError> {
|
||||||
|
// Normalize line endings based on platform
|
||||||
|
let normalized_text = normalize_line_endings(file_text);
|
||||||
|
|
||||||
// Write to the file
|
// Write to the file
|
||||||
std::fs::write(path, file_text)
|
std::fs::write(path, normalized_text)
|
||||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||||
|
|
||||||
// Try to detect the language from the file extension
|
// Try to detect the language from the file extension
|
||||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
let language = lang::get_language_identifier(path);
|
||||||
|
|
||||||
// The assistant output does not show the file again because the content is already in the tool request
|
// The assistant output does not show the file again because the content is already in the tool request
|
||||||
// but we do show it to the user here
|
// but we do show it to the user here
|
||||||
@@ -478,13 +534,14 @@ impl DeveloperRouter {
|
|||||||
// Save history for undo
|
// Save history for undo
|
||||||
self.save_file_history(path)?;
|
self.save_file_history(path)?;
|
||||||
|
|
||||||
// Replace and write back
|
// Replace and write back with platform-specific line endings
|
||||||
let new_content = content.replace(old_str, new_str);
|
let new_content = content.replace(old_str, new_str);
|
||||||
std::fs::write(path, &new_content)
|
let normalized_content = normalize_line_endings(&new_content);
|
||||||
|
std::fs::write(path, &normalized_content)
|
||||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||||
|
|
||||||
// Try to detect the language from the file extension
|
// Try to detect the language from the file extension
|
||||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
let language = lang::get_language_identifier(path);
|
||||||
|
|
||||||
// Show a snippet of the changed content with context
|
// Show a snippet of the changed content with context
|
||||||
const SNIPPET_LINES: usize = 4;
|
const SNIPPET_LINES: usize = 4;
|
||||||
@@ -811,65 +868,28 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_text_editor_size_limits() {
|
#[cfg(windows)]
|
||||||
// Create temp directory first so it stays in scope for the whole test
|
async fn test_windows_specific_commands() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
std::env::set_current_dir(&temp_dir).unwrap();
|
|
||||||
|
|
||||||
// Get router after setting current directory
|
|
||||||
let router = get_router().await;
|
let router = get_router().await;
|
||||||
|
|
||||||
// Test file size limit
|
// Test PowerShell command
|
||||||
{
|
let result = router
|
||||||
let large_file_path = temp_dir.path().join("large.txt");
|
.call_tool(
|
||||||
let large_file_str = large_file_path.to_str().unwrap();
|
"shell",
|
||||||
|
json!({
|
||||||
|
"command": "Get-ChildItem"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Create a file larger than 2MB
|
// Test Windows path handling
|
||||||
let content = "x".repeat(3 * 1024 * 1024); // 3MB
|
let result = router.resolve_path("C:\\Windows\\System32");
|
||||||
std::fs::write(&large_file_path, content).unwrap();
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let result = router
|
// Test UNC path handling
|
||||||
.call_tool(
|
let result = router.resolve_path("\\\\server\\share");
|
||||||
"text_editor",
|
assert!(result.is_ok());
|
||||||
json!({
|
|
||||||
"command": "view",
|
|
||||||
"path": large_file_str
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
let err = result.err().unwrap();
|
|
||||||
assert!(matches!(err, ToolError::ExecutionError(_)));
|
|
||||||
assert!(err.to_string().contains("too large"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test character count limit
|
|
||||||
{
|
|
||||||
let many_chars_path = temp_dir.path().join("many_chars.txt");
|
|
||||||
let many_chars_str = many_chars_path.to_str().unwrap();
|
|
||||||
|
|
||||||
// Create a file with more than 400K characters but less than 400KB
|
|
||||||
let content = "x".repeat(405_000);
|
|
||||||
std::fs::write(&many_chars_path, content).unwrap();
|
|
||||||
|
|
||||||
let result = router
|
|
||||||
.call_tool(
|
|
||||||
"text_editor",
|
|
||||||
json!({
|
|
||||||
"command": "view",
|
|
||||||
"path": many_chars_str
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
let err = result.err().unwrap();
|
|
||||||
assert!(matches!(err, ToolError::ExecutionError(_)));
|
|
||||||
assert!(err.to_string().contains("too many characters"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let temp_dir drop naturally at end of scope
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
72
crates/goose-mcp/src/developer/shell.rs
Normal file
72
crates/goose-mcp/src/developer/shell.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ShellConfig {
|
||||||
|
pub executable: String,
|
||||||
|
pub arg: String,
|
||||||
|
pub redirect_syntax: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ShellConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
if cfg!(windows) {
|
||||||
|
// Use cmd.exe for simpler command execution
|
||||||
|
Self {
|
||||||
|
executable: "cmd.exe".to_string(),
|
||||||
|
arg: "/C".to_string(),
|
||||||
|
redirect_syntax: "2>&1".to_string(), // cmd.exe also supports this syntax
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self {
|
||||||
|
executable: "bash".to_string(),
|
||||||
|
arg: "-c".to_string(),
|
||||||
|
redirect_syntax: "2>&1".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_shell_config() -> ShellConfig {
|
||||||
|
ShellConfig::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_command_for_platform(command: &str) -> String {
|
||||||
|
let config = get_shell_config();
|
||||||
|
// For all shells, no braces needed
|
||||||
|
format!("{} {}", command, config.redirect_syntax)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expand_path(path_str: &str) -> String {
|
||||||
|
if cfg!(windows) {
|
||||||
|
// Expand Windows environment variables (%VAR%)
|
||||||
|
let with_userprofile = path_str.replace(
|
||||||
|
"%USERPROFILE%",
|
||||||
|
&env::var("USERPROFILE").unwrap_or_default(),
|
||||||
|
);
|
||||||
|
// Add more Windows environment variables as needed
|
||||||
|
with_userprofile.replace("%APPDATA%", &env::var("APPDATA").unwrap_or_default())
|
||||||
|
} else {
|
||||||
|
// Unix-style expansion
|
||||||
|
shellexpand::tilde(path_str).into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_absolute_path(path_str: &str) -> bool {
|
||||||
|
if cfg!(windows) {
|
||||||
|
// Check for Windows absolute paths (drive letters and UNC)
|
||||||
|
path_str.contains(":\\") || path_str.starts_with("\\\\")
|
||||||
|
} else {
|
||||||
|
// Unix absolute paths start with /
|
||||||
|
path_str.starts_with('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_line_endings(text: &str) -> String {
|
||||||
|
if cfg!(windows) {
|
||||||
|
// Ensure CRLF line endings on Windows
|
||||||
|
text.replace("\r\n", "\n").replace("\n", "\r\n")
|
||||||
|
} else {
|
||||||
|
// Ensure LF line endings on Unix
|
||||||
|
text.replace("\r\n", "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer;
|
|||||||
/// Returns the directory where log files should be stored.
|
/// Returns the directory where log files should be stored.
|
||||||
/// Creates the directory structure if it doesn't exist.
|
/// Creates the directory structure if it doesn't exist.
|
||||||
fn get_log_directory() -> Result<PathBuf> {
|
fn get_log_directory() -> Result<PathBuf> {
|
||||||
let home = std::env::var("HOME").context("HOME environment variable not set")?;
|
let home = if cfg!(windows) {
|
||||||
|
std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")?
|
||||||
|
} else {
|
||||||
|
std::env::var("HOME").context("HOME environment variable not set")?
|
||||||
|
};
|
||||||
|
|
||||||
let base_log_dir = PathBuf::from(home)
|
let base_log_dir = PathBuf::from(home)
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("goose")
|
.join("goose")
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
|
|||||||
aws-smithy-types = "1.2.12"
|
aws-smithy-types = "1.2.12"
|
||||||
aws-sdk-bedrockruntime = "1.72.0"
|
aws-sdk-bedrockruntime = "1.72.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
winapi = { version = "0.3", features = ["wincred"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = "0.5"
|
criterion = "0.5"
|
||||||
tempfile = "3.15.0"
|
tempfile = "3.15.0"
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ struct TokenCache {
|
|||||||
|
|
||||||
fn get_base_path() -> PathBuf {
|
fn get_base_path() -> PathBuf {
|
||||||
const BASE_PATH: &str = ".config/goose/databricks/oauth";
|
const BASE_PATH: &str = ".config/goose/databricks/oauth";
|
||||||
let home_dir = std::env::var("HOME").expect("HOME environment variable not set");
|
let home_dir = if cfg!(windows) {
|
||||||
|
std::env::var("USERPROFILE").expect("USERPROFILE environment variable not set")
|
||||||
|
} else {
|
||||||
|
std::env::var("HOME").expect("HOME environment variable not set")
|
||||||
|
};
|
||||||
PathBuf::from(home_dir).join(BASE_PATH)
|
PathBuf::from(home_dir).join(BASE_PATH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,15 +196,24 @@ impl StdioTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_process(&self) -> Result<(Child, ChildStdin, ChildStdout, ChildStderr), Error> {
|
async fn spawn_process(&self) -> Result<(Child, ChildStdin, ChildStdout, ChildStderr), Error> {
|
||||||
let mut process = Command::new(&self.command)
|
let mut command = Command::new(&self.command);
|
||||||
|
command
|
||||||
.envs(&self.env)
|
.envs(&self.env)
|
||||||
.args(&self.args)
|
.args(&self.args)
|
||||||
.stdin(std::process::Stdio::piped())
|
.stdin(std::process::Stdio::piped())
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true);
|
||||||
// 0 sets the process group ID equal to the process ID
|
|
||||||
.process_group(0) // don't inherit signal handling from parent process
|
// Set process group only on Unix systems
|
||||||
|
#[cfg(unix)]
|
||||||
|
command.process_group(0); // don't inherit signal handling from parent process
|
||||||
|
|
||||||
|
// Hide console window on Windows
|
||||||
|
#[cfg(windows)]
|
||||||
|
command.creation_flags(0x08000000); // CREATE_NO_WINDOW flag
|
||||||
|
|
||||||
|
let mut process = command
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| Error::StdioProcessError(e.to_string()))?;
|
.map_err(|e| Error::StdioProcessError(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
3
ui/desktop/.gitignore
vendored
3
ui/desktop/.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.vite/
|
.vite/
|
||||||
out
|
out
|
||||||
src/bin/goosed
|
src/bin/goosed
|
||||||
|
/src/bin/goosed.exe
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ let cfg = {
|
|||||||
asar: true,
|
asar: true,
|
||||||
extraResource: ['src/bin', 'src/images'],
|
extraResource: ['src/bin', 'src/images'],
|
||||||
icon: 'src/images/icon',
|
icon: 'src/images/icon',
|
||||||
|
// Windows specific configuration
|
||||||
|
win32: {
|
||||||
|
icon: 'src/images/icon.ico',
|
||||||
|
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
|
||||||
|
certificatePassword: process.env.WINDOWS_CERTIFICATE_PASSWORD,
|
||||||
|
rfc3161TimeStampServer: 'http://timestamp.digicert.com',
|
||||||
|
signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256'
|
||||||
|
},
|
||||||
|
// Protocol registration
|
||||||
|
protocols: [
|
||||||
|
{
|
||||||
|
name: "GooseProtocol",
|
||||||
|
schemes: ["goose"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// macOS specific configuration
|
||||||
osxSign: {
|
osxSign: {
|
||||||
entitlements: 'entitlements.plist',
|
entitlements: 'entitlements.plist',
|
||||||
'entitlements-inherit': 'entitlements.plist',
|
'entitlements-inherit': 'entitlements.plist',
|
||||||
@@ -34,13 +50,14 @@ module.exports = {
|
|||||||
packagerConfig: cfg,
|
packagerConfig: cfg,
|
||||||
rebuildConfig: {},
|
rebuildConfig: {},
|
||||||
makers: [
|
makers: [
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-squirrel',
|
|
||||||
config: {},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-zip',
|
name: '@electron-forge/maker-zip',
|
||||||
platforms: ['darwin'],
|
platforms: ['darwin', 'win32'],
|
||||||
|
config: {
|
||||||
|
options: {
|
||||||
|
icon: 'src/images/icon.ico'
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-deb',
|
name: '@electron-forge/maker-deb',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "goose-app",
|
"name": "goose-app",
|
||||||
"productName": "Goose",
|
"productName": "Goose",
|
||||||
"version": "1.0.5",
|
"version": "1.0.51",
|
||||||
"description": "Goose App",
|
"description": "Goose App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"bundle:default": "npm run make && cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip",
|
"bundle:default": "npm run make && cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip",
|
||||||
|
"bundle:windows": "npm run make -- --platform=win32 --arch=x64 && node scripts/copy-windows-dlls.js",
|
||||||
"debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app",
|
"debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app",
|
||||||
"test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 12 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi",
|
"test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 12 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi",
|
||||||
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||||
|
|||||||
84
ui/desktop/scripts/copy-windows-dlls.js
Normal file
84
ui/desktop/scripts/copy-windows-dlls.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
// Required DLLs that must be present
|
||||||
|
const REQUIRED_DLLS = [
|
||||||
|
'libstdc++-6.dll',
|
||||||
|
'libgcc_s_seh-1.dll',
|
||||||
|
'libwinpthread-1.dll'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Source and target directories
|
||||||
|
const sourceDir = path.join(__dirname, '../src/bin');
|
||||||
|
const targetDir = path.join(__dirname, '../out/Goose-win32-x64/resources/bin');
|
||||||
|
|
||||||
|
function ensureDirectoryExists(dir) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
console.log(`Created directory: ${dir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDLLs() {
|
||||||
|
// Ensure target directory exists
|
||||||
|
ensureDirectoryExists(targetDir);
|
||||||
|
|
||||||
|
// Get list of DLLs in source directory
|
||||||
|
const sourceDLLs = fs.readdirSync(sourceDir)
|
||||||
|
.filter(file => file.toLowerCase().endsWith('.dll'));
|
||||||
|
|
||||||
|
console.log('Found DLLs in source directory:', sourceDLLs);
|
||||||
|
|
||||||
|
// Check for missing required DLLs
|
||||||
|
const missingDLLs = REQUIRED_DLLS.filter(dll =>
|
||||||
|
!sourceDLLs.includes(dll)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingDLLs.length > 0) {
|
||||||
|
console.error('Missing required DLLs:', missingDLLs);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy all DLLs and the executable to target directory
|
||||||
|
sourceDLLs.forEach(dll => {
|
||||||
|
const sourcePath = path.join(sourceDir, dll);
|
||||||
|
const targetPath = path.join(targetDir, dll);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
console.log(`Copied ${dll} to ${targetDir}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error copying ${dll}:`, err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy the executable
|
||||||
|
const exeName = 'goosed.exe';
|
||||||
|
const sourceExe = path.join(sourceDir, exeName);
|
||||||
|
const targetExe = path.join(targetDir, exeName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(sourceExe)) {
|
||||||
|
fs.copyFileSync(sourceExe, targetExe);
|
||||||
|
console.log(`Copied ${exeName} to ${targetDir}`);
|
||||||
|
} else {
|
||||||
|
console.error(`${exeName} not found in source directory`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error copying ${exeName}:`, err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All files copied successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
try {
|
||||||
|
copyDLLs();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error during copy process:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import WingToWing, { Working } from './components/WingToWing';
|
|||||||
import { askAi } from './utils/askAI';
|
import { askAi } from './utils/askAI';
|
||||||
import { getStoredModel, Provider } from './utils/providerUtils';
|
import { getStoredModel, Provider } from './utils/providerUtils';
|
||||||
import { ChatLayout } from './components/chat_window/ChatLayout';
|
import { ChatLayout } from './components/chat_window/ChatLayout';
|
||||||
import { ChatRoutes } from './components/chat_window/ChatRoutes';
|
|
||||||
import { WelcomeScreen } from './components/welcome_screen/WelcomeScreen';
|
import { WelcomeScreen } from './components/welcome_screen/WelcomeScreen';
|
||||||
import { getStoredProvider, initializeSystem } from './utils/providerUtils';
|
import { getStoredProvider, initializeSystem } from './utils/providerUtils';
|
||||||
import { useModel } from './components/settings/models/ModelContext';
|
import { useModel } from './components/settings/models/ModelContext';
|
||||||
@@ -22,6 +21,9 @@ import { useRecentModels } from './components/settings/models/RecentModels';
|
|||||||
import { createSelectedModel } from './components/settings/models/utils';
|
import { createSelectedModel } from './components/settings/models/utils';
|
||||||
import { getDefaultModel } from './components/settings/models/hardcoded_stuff';
|
import { getDefaultModel } from './components/settings/models/hardcoded_stuff';
|
||||||
import Splash from './components/Splash';
|
import Splash from './components/Splash';
|
||||||
|
import Settings from './components/settings/Settings';
|
||||||
|
import MoreModelsSettings from './components/settings/models/MoreModels';
|
||||||
|
import ConfigureProviders from './components/settings/providers/ConfigureProviders';
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,13 +35,19 @@ export interface Chat {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type View = 'welcome' | 'chat' | 'settings' | 'moreModels' | 'configureProviders';
|
||||||
|
|
||||||
|
// This component is our main chat content.
|
||||||
|
// We'll move the majority of chat logic here, minus the 'view' state.
|
||||||
export function ChatContent({
|
export function ChatContent({
|
||||||
chats,
|
chats,
|
||||||
setChats,
|
setChats,
|
||||||
selectedChatId,
|
selectedChatId,
|
||||||
|
setSelectedChatId,
|
||||||
initialQuery,
|
initialQuery,
|
||||||
setProgressMessage,
|
setProgressMessage,
|
||||||
setWorking,
|
setWorking,
|
||||||
|
setView,
|
||||||
}: {
|
}: {
|
||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
|
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
|
||||||
@@ -48,6 +56,7 @@ export function ChatContent({
|
|||||||
initialQuery: string | null;
|
initialQuery: string | null;
|
||||||
setProgressMessage: React.Dispatch<React.SetStateAction<string>>;
|
setProgressMessage: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setWorking: React.Dispatch<React.SetStateAction<Working>>;
|
setWorking: React.Dispatch<React.SetStateAction<Working>>;
|
||||||
|
setView: (view: View) => void;
|
||||||
}) {
|
}) {
|
||||||
const chat = chats.find((c: Chat) => c.id === selectedChatId);
|
const chat = chats.find((c: Chat) => c.id === selectedChatId);
|
||||||
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
|
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
|
||||||
@@ -95,7 +104,6 @@ export function ChatContent({
|
|||||||
window.electron.logInfo('last interaction:' + lastInteractionTime);
|
window.electron.logInfo('last interaction:' + lastInteractionTime);
|
||||||
if (timeSinceLastInteraction > 60000) {
|
if (timeSinceLastInteraction > 60000) {
|
||||||
// 60000ms = 1 minute
|
// 60000ms = 1 minute
|
||||||
|
|
||||||
window.electron.showNotification({
|
window.electron.showNotification({
|
||||||
title: 'Goose finished the task.',
|
title: 'Goose finished the task.',
|
||||||
body: 'Click here to expand.',
|
body: 'Click here to expand.',
|
||||||
@@ -133,7 +141,7 @@ export function ChatContent({
|
|||||||
setLastInteractionTime(Date.now());
|
setLastInteractionTime(Date.now());
|
||||||
append({
|
append({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: content,
|
content,
|
||||||
});
|
});
|
||||||
if (scrollRef.current?.scrollToBottom) {
|
if (scrollRef.current?.scrollToBottom) {
|
||||||
scrollRef.current.scrollToBottom();
|
scrollRef.current.scrollToBottom();
|
||||||
@@ -194,7 +202,8 @@ export function ChatContent({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-screen items-center justify-center">
|
<div className="flex flex-col w-full h-screen items-center justify-center">
|
||||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle border-b border-borderSubtle">
|
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle border-b border-borderSubtle">
|
||||||
<MoreMenu />
|
{/* Pass setView to MoreMenu so it can switch to settings or other views */}
|
||||||
|
<MoreMenu setView={setView} />
|
||||||
</div>
|
</div>
|
||||||
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
|
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
@@ -215,12 +224,6 @@ export function ChatContent({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* {isLoading && (
|
|
||||||
<div className="flex items-center justify-center p-4">
|
|
||||||
<div onClick={() => setShowGame(true)} style={{ cursor: 'pointer' }}>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex flex-col items-center justify-center p-4">
|
<div className="flex flex-col items-center justify-center p-4">
|
||||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
||||||
@@ -258,7 +261,7 @@ export function ChatContent({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onStop={onStopGoose}
|
onStop={onStopGoose}
|
||||||
/>
|
/>
|
||||||
<BottomMenu hasMessages={hasMessages} />
|
<BottomMenu hasMessages={hasMessages} setView={setView} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -268,98 +271,59 @@ export function ChatContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatWindow() {
|
export default function ChatWindow() {
|
||||||
|
// We'll add a state controlling which "view" is active.
|
||||||
|
const [view, setView] = useState<View>('welcome');
|
||||||
|
|
||||||
// Shared function to create a chat window
|
// Shared function to create a chat window
|
||||||
const openNewChatWindow = () => {
|
const openNewChatWindow = () => {
|
||||||
window.electron.createChatWindow();
|
window.electron.createChatWindow();
|
||||||
};
|
};
|
||||||
const { switchModel, currentModel } = useModel(); // Access switchModel via useModel
|
const { switchModel } = useModel();
|
||||||
const { addRecentModel } = useRecentModels(); // Access addRecentModel from useRecentModels
|
const { addRecentModel } = useRecentModels();
|
||||||
|
|
||||||
// Add keyboard shortcut handler
|
// This will store chat data for the "chat" view.
|
||||||
|
const [chats, setChats] = useState<Chat[]>(() => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Chat 1',
|
||||||
|
messages: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [selectedChatId, setSelectedChatId] = useState(1);
|
||||||
|
|
||||||
|
// Additional states
|
||||||
|
const [mode, setMode] = useState<'expanded' | 'compact'>('expanded');
|
||||||
|
const [working, setWorking] = useState<Working>(Working.Idle);
|
||||||
|
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||||
|
const [initialQuery, setInitialQuery] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Keyboard shortcut handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
// Check for Command+N (Mac) or Control+N (Windows/Linux)
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === 'n') {
|
if ((event.metaKey || event.ctrlKey) && event.key === 'n') {
|
||||||
event.preventDefault(); // Prevent default browser behavior
|
event.preventDefault();
|
||||||
openNewChatWindow();
|
openNewChatWindow();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add event listener
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Get initial query and history from URL parameters
|
// Attempt to detect config for a stored provider
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
|
||||||
const initialQuery = searchParams.get('initialQuery');
|
|
||||||
const historyParam = searchParams.get('history');
|
|
||||||
const initialHistory = historyParam ? JSON.parse(decodeURIComponent(historyParam)) : [];
|
|
||||||
|
|
||||||
const [chats, setChats] = useState<Chat[]>(() => {
|
|
||||||
const firstChat = {
|
|
||||||
id: 1,
|
|
||||||
title: initialQuery || 'Chat 1',
|
|
||||||
messages: initialHistory.length > 0 ? initialHistory : [],
|
|
||||||
};
|
|
||||||
return [firstChat];
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedChatId, setSelectedChatId] = useState(1);
|
|
||||||
const [mode, setMode] = useState<'expanded' | 'compact'>(initialQuery ? 'compact' : 'expanded');
|
|
||||||
const [working, setWorking] = useState<Working>(Working.Idle);
|
|
||||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string | Provider | null>(null);
|
|
||||||
const [showWelcomeModal, setShowWelcomeModal] = useState(true);
|
|
||||||
|
|
||||||
// Add this useEffect to track changes and update welcome state
|
|
||||||
const toggleMode = () => {
|
|
||||||
const newMode = mode === 'expanded' ? 'compact' : 'expanded';
|
|
||||||
console.log(`Toggle to ${newMode}`);
|
|
||||||
setMode(newMode);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.electron.logInfo('ChatWindow loaded');
|
|
||||||
|
|
||||||
// Fix the handleSubmit function syntax
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setShowWelcomeModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if we already have a provider set
|
|
||||||
const config = window.electron.getConfig();
|
const config = window.electron.getConfig();
|
||||||
const storedProvider = getStoredProvider(config);
|
const storedProvider = getStoredProvider(config);
|
||||||
|
|
||||||
if (storedProvider) {
|
if (storedProvider) {
|
||||||
setShowWelcomeModal(false);
|
setView('chat');
|
||||||
} else {
|
} else {
|
||||||
setShowWelcomeModal(true);
|
setView('welcome');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const storeSecret = async (key: string, value: string) => {
|
// Initialize system if we have a stored provider
|
||||||
const response = await fetch(getApiUrl('/configs/store'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Secret-Key': getSecretKey(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ key, value }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to store secret: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize system on load if we have a stored provider
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setupStoredProvider = async () => {
|
const setupStoredProvider = async () => {
|
||||||
const config = window.electron.getConfig();
|
const config = window.electron.getConfig();
|
||||||
@@ -378,19 +342,10 @@ export default function ChatWindow() {
|
|||||||
await initializeSystem(storedProvider, storedModel);
|
await initializeSystem(storedProvider, storedModel);
|
||||||
|
|
||||||
if (!storedModel) {
|
if (!storedModel) {
|
||||||
// get the default model
|
|
||||||
const modelName = getDefaultModel(storedProvider.toLowerCase());
|
const modelName = getDefaultModel(storedProvider.toLowerCase());
|
||||||
|
|
||||||
// create model object
|
|
||||||
const model = createSelectedModel(storedProvider.toLowerCase(), modelName);
|
const model = createSelectedModel(storedProvider.toLowerCase(), modelName);
|
||||||
|
|
||||||
// Call the context's switchModel to track the set model state in the front end
|
|
||||||
switchModel(model);
|
switchModel(model);
|
||||||
|
|
||||||
// Keep track of the recently used models
|
|
||||||
addRecentModel(model);
|
addRecentModel(model);
|
||||||
|
|
||||||
console.log('set up provider with default model', storedProvider, modelName);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize with stored provider:', error);
|
console.error('Failed to initialize with stored provider:', error);
|
||||||
@@ -401,24 +356,58 @@ export default function ChatWindow() {
|
|||||||
setupStoredProvider();
|
setupStoredProvider();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Render WelcomeScreen at root level if showing
|
// Render everything inside ChatLayout now
|
||||||
if (showWelcomeModal) {
|
// We'll switch views inside the ChatLayout children.
|
||||||
return <WelcomeScreen onSubmit={handleSubmit} />;
|
|
||||||
}
|
// If we want to skip showing ChatLayout for the welcome screen, we can do so.
|
||||||
|
// But let's do exactly what's requested: put all view options under ChatLayout.
|
||||||
|
|
||||||
// Only render ChatLayout if not showing welcome screen
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<ChatLayout mode={mode}>
|
||||||
<ChatLayout mode={mode}>
|
{/* Conditionally render based on `view` */}
|
||||||
<ChatRoutes
|
{view === 'welcome' && (
|
||||||
|
<WelcomeScreen
|
||||||
|
onSubmit={() => {
|
||||||
|
setView('chat');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === 'settings' && (
|
||||||
|
<Settings
|
||||||
|
onClose={() => {
|
||||||
|
setView('chat');
|
||||||
|
}}
|
||||||
|
setView={setView}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === 'moreModels' && (
|
||||||
|
<MoreModelsSettings
|
||||||
|
onClose={() => {
|
||||||
|
setView('settings');
|
||||||
|
}}
|
||||||
|
setView={setView}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === 'configureProviders' && (
|
||||||
|
<ConfigureProviders
|
||||||
|
onClose={() => {
|
||||||
|
setView('settings');
|
||||||
|
}}
|
||||||
|
setView={setView}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === 'chat' && (
|
||||||
|
<ChatContent
|
||||||
chats={chats}
|
chats={chats}
|
||||||
setChats={setChats}
|
setChats={setChats}
|
||||||
selectedChatId={selectedChatId}
|
selectedChatId={selectedChatId}
|
||||||
setSelectedChatId={setSelectedChatId}
|
setSelectedChatId={setSelectedChatId}
|
||||||
|
initialQuery={initialQuery}
|
||||||
setProgressMessage={setProgressMessage}
|
setProgressMessage={setProgressMessage}
|
||||||
setWorking={setWorking}
|
setWorking={setWorking}
|
||||||
|
setView={setView}
|
||||||
/>
|
/>
|
||||||
</ChatLayout>
|
)}
|
||||||
</div>
|
</ChatLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
ui/desktop/src/bin/libgcc_s_seh-1.dll
Executable file
BIN
ui/desktop/src/bin/libgcc_s_seh-1.dll
Executable file
Binary file not shown.
BIN
ui/desktop/src/bin/libstdc++-6.dll
Executable file
BIN
ui/desktop/src/bin/libstdc++-6.dll
Executable file
Binary file not shown.
BIN
ui/desktop/src/bin/libwinpthread-1.dll
Executable file
BIN
ui/desktop/src/bin/libwinpthread-1.dll
Executable file
Binary file not shown.
20
ui/desktop/src/bin/npx.cmd
Normal file
20
ui/desktop/src/bin/npx.cmd
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
:: Created by npm, please don't edit manually.
|
||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
SETLOCAL
|
||||||
|
|
||||||
|
SET "NODE_EXE=%~dp0\node.exe"
|
||||||
|
IF NOT EXIST "%NODE_EXE%" (
|
||||||
|
SET "NODE_EXE=node"
|
||||||
|
)
|
||||||
|
|
||||||
|
SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
|
||||||
|
SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
||||||
|
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO (
|
||||||
|
SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js"
|
||||||
|
)
|
||||||
|
IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" (
|
||||||
|
SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%"
|
||||||
|
)
|
||||||
|
|
||||||
|
"%NODE_EXE%" "%NPX_CLI_JS%" %*
|
||||||
BIN
ui/desktop/src/bin/uv.exe
Normal file
BIN
ui/desktop/src/bin/uv.exe
Normal file
Binary file not shown.
BIN
ui/desktop/src/bin/uvx.exe
Normal file
BIN
ui/desktop/src/bin/uvx.exe
Normal file
Binary file not shown.
@@ -3,14 +3,21 @@ import { useModel } from './settings/models/ModelContext';
|
|||||||
import { useRecentModels } from './settings/models/RecentModels'; // Hook for recent models
|
import { useRecentModels } from './settings/models/RecentModels'; // Hook for recent models
|
||||||
import { Sliders } from 'lucide-react';
|
import { Sliders } from 'lucide-react';
|
||||||
import { ModelRadioList } from './settings/models/ModelRadioList';
|
import { ModelRadioList } from './settings/models/ModelRadioList';
|
||||||
import { useNavigate } from 'react-router-dom';
|
// Remove react-router-dom usage
|
||||||
|
// import { useNavigate } from 'react-router-dom';
|
||||||
import { Document, ChevronUp, ChevronDown } from './icons';
|
import { Document, ChevronUp, ChevronDown } from './icons';
|
||||||
|
import type { View } from '../ChatWindow';
|
||||||
|
|
||||||
export default function BottomMenu({ hasMessages }) {
|
export default function BottomMenu({
|
||||||
|
hasMessages,
|
||||||
|
setView,
|
||||||
|
}: {
|
||||||
|
hasMessages: boolean;
|
||||||
|
setView?: (view: View) => void;
|
||||||
|
}) {
|
||||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||||
const { currentModel } = useModel();
|
const { currentModel } = useModel();
|
||||||
const { recentModels } = useRecentModels(); // Get recent models
|
const { recentModels } = useRecentModels(); // Get recent models
|
||||||
const navigate = useNavigate();
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Add effect to handle clicks outside
|
// Add effect to handle clicks outside
|
||||||
@@ -126,7 +133,8 @@ export default function BottomMenu({ hasMessages }) {
|
|||||||
border-t border-borderSubtle mt-2"
|
border-t border-borderSubtle mt-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsModelMenuOpen(false);
|
setIsModelMenuOpen(false);
|
||||||
navigate('/settings');
|
// Instead of navigate('/settings'), call setView('settings').
|
||||||
|
setView?.('settings');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-sm">Tools and Settings</span>
|
<span className="text-sm">Tools and Settings</span>
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '@radix-u
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { FaMoon, FaSun } from 'react-icons/fa';
|
import { FaMoon, FaSun } from 'react-icons/fa';
|
||||||
import VertDots from './ui/VertDots';
|
import VertDots from './ui/VertDots';
|
||||||
import { useNavigate } from 'react-router-dom';
|
// Removed react-router-dom import
|
||||||
|
// import { useNavigate } from 'react-router-dom';
|
||||||
import { More } from './icons';
|
import { More } from './icons';
|
||||||
import { Settings, Grid, MessageSquare } from 'lucide-react';
|
import { Settings, Grid, MessageSquare } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import type { View } from '../../ChatWindow';
|
||||||
|
|
||||||
interface VersionInfo {
|
interface VersionInfo {
|
||||||
current_version: string;
|
current_version: string;
|
||||||
available_versions: string[];
|
available_versions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MoreMenu() {
|
// Accept setView as a prop from the parent (e.g. ChatContent)
|
||||||
const navigate = useNavigate();
|
export default function MoreMenu({ setView }: { setView?: (view: View) => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [versions, setVersions] = useState<VersionInfo | null>(null);
|
const [versions, setVersions] = useState<VersionInfo | null>(null);
|
||||||
const [showVersions, setShowVersions] = useState(false);
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
@@ -229,7 +231,8 @@ export default function MoreMenu() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
navigate('/settings');
|
// Instead of navigate('/settings'), call setView to switch.
|
||||||
|
setView?.('settings');
|
||||||
}}
|
}}
|
||||||
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
|
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
|
||||||
>
|
>
|
||||||
@@ -264,12 +267,16 @@ export default function MoreMenu() {
|
|||||||
>
|
>
|
||||||
Reset Provider
|
Reset Provider
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Provider keys settings */}
|
{/* Provider keys settings */}
|
||||||
{process.env.NODE_ENV === 'development' && (
|
{process.env.NODE_ENV === 'development' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
navigate('/keys');
|
// Instead of navigate('/keys'), we might do setView('someKeysView') or open new window.
|
||||||
|
// For now, just do nothing or set to some placeholder.
|
||||||
|
// setView?.('keys');
|
||||||
|
window.electron.createChatWindow();
|
||||||
}}
|
}}
|
||||||
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
|
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
|
||||||
import { ChatContent } from '../../ChatWindow';
|
|
||||||
import Settings from '../settings/Settings';
|
|
||||||
import MoreModelsSettings from '../settings/models/MoreModels';
|
|
||||||
import ConfigureProviders from '../settings/providers/ConfigureProviders';
|
|
||||||
import { WelcomeScreen } from '../welcome_screen/WelcomeScreen';
|
|
||||||
|
|
||||||
export const ChatRoutes = ({
|
|
||||||
chats,
|
|
||||||
setChats,
|
|
||||||
selectedChatId,
|
|
||||||
setSelectedChatId,
|
|
||||||
setProgressMessage,
|
|
||||||
setWorking,
|
|
||||||
}) => (
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/chat/:id"
|
|
||||||
element={
|
|
||||||
<ChatContent
|
|
||||||
chats={chats}
|
|
||||||
setChats={setChats}
|
|
||||||
selectedChatId={selectedChatId}
|
|
||||||
setSelectedChatId={setSelectedChatId}
|
|
||||||
initialQuery={null}
|
|
||||||
setProgressMessage={setProgressMessage}
|
|
||||||
setWorking={setWorking}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route path="/settings/more-models" element={<MoreModelsSettings />} />
|
|
||||||
<Route path="/settings/configure-providers" element={<ConfigureProviders />} />
|
|
||||||
<Route path="/welcome" element={<WelcomeScreen />} />
|
|
||||||
<Route path="*" element={<Navigate to="/chat/1" replace />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Settings as SettingsType } from './types';
|
import { Settings as SettingsType } from './types';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +14,7 @@ import { ConfigureBuiltInExtensionModal } from './extensions/ConfigureBuiltInExt
|
|||||||
import BackButton from '../ui/BackButton';
|
import BackButton from '../ui/BackButton';
|
||||||
import { RecentModelsRadio } from './models/RecentModels';
|
import { RecentModelsRadio } from './models/RecentModels';
|
||||||
import { ExtensionItem } from './extensions/ExtensionItem';
|
import { ExtensionItem } from './extensions/ExtensionItem';
|
||||||
|
import type { View } from '../../ChatWindow';
|
||||||
|
|
||||||
const EXTENSIONS_DESCRIPTION =
|
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.';
|
'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.';
|
||||||
@@ -46,9 +46,18 @@ const DEFAULT_SETTINGS: SettingsType = {
|
|||||||
extensions: BUILT_IN_EXTENSIONS,
|
extensions: BUILT_IN_EXTENSIONS,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Settings() {
|
// We'll accept two props:
|
||||||
const navigate = useNavigate();
|
// onClose: to go back to chat
|
||||||
const location = useLocation();
|
// setView: to switch to moreModels, configureProviders, etc.
|
||||||
|
export default function Settings({
|
||||||
|
onClose,
|
||||||
|
setView,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
setView: (view: View) => void;
|
||||||
|
}) {
|
||||||
|
// We'll read query params from window.location instead of react-router's useLocation
|
||||||
|
const [searchParams] = useState(() => new URLSearchParams(window.location.search));
|
||||||
|
|
||||||
const [settings, setSettings] = React.useState<SettingsType>(() => {
|
const [settings, setSettings] = React.useState<SettingsType>(() => {
|
||||||
const saved = localStorage.getItem('user_settings');
|
const saved = localStorage.getItem('user_settings');
|
||||||
@@ -96,9 +105,8 @@ export default function Settings() {
|
|||||||
|
|
||||||
// Handle URL parameters for auto-opening extension configuration
|
// Handle URL parameters for auto-opening extension configuration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const extensionId = searchParams.get('extensionId');
|
||||||
const extensionId = params.get('extensionId');
|
const showEnvVars = searchParams.get('showEnvVars');
|
||||||
const showEnvVars = params.get('showEnvVars');
|
|
||||||
|
|
||||||
if (extensionId && showEnvVars === 'true') {
|
if (extensionId && showEnvVars === 'true') {
|
||||||
// Find the extension in settings
|
// Find the extension in settings
|
||||||
@@ -113,7 +121,9 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [location.search, settings.extensions]);
|
// We only run this once on load
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings.extensions]);
|
||||||
|
|
||||||
const handleExtensionToggle = async (extensionId: string) => {
|
const handleExtensionToggle = async (extensionId: string) => {
|
||||||
// Find the extension to get its current state
|
// Find the extension to get its current state
|
||||||
@@ -160,28 +170,11 @@ export default function Settings() {
|
|||||||
extensions: prev.extensions.filter((ext) => ext.id !== extensionBeingConfigured.id),
|
extensions: prev.extensions.filter((ext) => ext.id !== extensionBeingConfigured.id),
|
||||||
}));
|
}));
|
||||||
setExtensionBeingConfigured(null);
|
setExtensionBeingConfigured(null);
|
||||||
navigate('/settings', { replace: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNavClick = (section: string, e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
|
|
||||||
const element = document.getElementById(section.toLowerCase());
|
|
||||||
|
|
||||||
if (scrollArea && element) {
|
|
||||||
const topPos = element.offsetTop;
|
|
||||||
scrollArea.scrollTo({
|
|
||||||
top: topPos,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExtensionConfigSubmit = () => {
|
const handleExtensionConfigSubmit = () => {
|
||||||
setExtensionBeingConfigured(null);
|
setExtensionBeingConfigured(null);
|
||||||
// Clear the URL parameters after configuration
|
|
||||||
navigate('/settings', { replace: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBuiltIn = (extensionId: string) => {
|
const isBuiltIn = (extensionId: string) => {
|
||||||
@@ -197,7 +190,8 @@ export default function Settings() {
|
|||||||
<div className="px-8 pt-6 pb-4">
|
<div className="px-8 pt-6 pb-4">
|
||||||
<BackButton
|
<BackButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/chat/1', { replace: true });
|
// Instead of navigate('/chat/1', { replace: true });
|
||||||
|
onClose();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Settings</h1>
|
<h1 className="text-3xl font-medium text-textStandard mt-1">Settings</h1>
|
||||||
@@ -210,7 +204,10 @@ export default function Settings() {
|
|||||||
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
||||||
<h2 className="text-xl font-medium text-textStandard">Models</h2>
|
<h2 className="text-xl font-medium text-textStandard">Models</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings/more-models')}
|
onClick={() => {
|
||||||
|
// Instead of navigate('/settings/more-models'):
|
||||||
|
setView('moreModels');
|
||||||
|
}}
|
||||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||||
>
|
>
|
||||||
Browse
|
Browse
|
||||||
@@ -230,7 +227,6 @@ export default function Settings() {
|
|||||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||||
title="Add Manually"
|
title="Add Manually"
|
||||||
>
|
>
|
||||||
{/* <Plus className="h-4 w-4" /> */}
|
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -255,7 +251,7 @@ export default function Settings() {
|
|||||||
<ExtensionItem
|
<ExtensionItem
|
||||||
key={ext.id}
|
key={ext.id}
|
||||||
{...ext}
|
{...ext}
|
||||||
canConfigure={true} // Ensure gear icon always appears
|
canConfigure={true}
|
||||||
onToggle={handleExtensionToggle}
|
onToggle={handleExtensionToggle}
|
||||||
onConfigure={(extension) => setExtensionBeingConfigured(extension)}
|
onConfigure={(extension) => setExtensionBeingConfigured(extension)}
|
||||||
/>
|
/>
|
||||||
@@ -273,7 +269,6 @@ export default function Settings() {
|
|||||||
isOpen={!!extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id)}
|
isOpen={!!extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id)}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setExtensionBeingConfigured(null);
|
setExtensionBeingConfigured(null);
|
||||||
navigate('/settings', { replace: true });
|
|
||||||
}}
|
}}
|
||||||
extension={extensionBeingConfigured}
|
extension={extensionBeingConfigured}
|
||||||
onSubmit={handleExtensionConfigSubmit}
|
onSubmit={handleExtensionConfigSubmit}
|
||||||
@@ -283,8 +278,6 @@ export default function Settings() {
|
|||||||
isOpen={!!extensionBeingConfigured}
|
isOpen={!!extensionBeingConfigured}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setExtensionBeingConfigured(null);
|
setExtensionBeingConfigured(null);
|
||||||
// Clear URL parameters when closing manually
|
|
||||||
navigate('/settings', { replace: true });
|
|
||||||
}}
|
}}
|
||||||
extension={extensionBeingConfigured}
|
extension={extensionBeingConfigured}
|
||||||
onSubmit={handleExtensionConfigSubmit}
|
onSubmit={handleExtensionConfigSubmit}
|
||||||
|
|||||||
@@ -6,25 +6,27 @@ import BackButton from '../../ui/BackButton';
|
|||||||
import { SearchBar } from './Search';
|
import { SearchBar } from './Search';
|
||||||
import { useModel } from './ModelContext';
|
import { useModel } from './ModelContext';
|
||||||
import { AddModelInline } from './AddModelInline';
|
import { AddModelInline } from './AddModelInline';
|
||||||
import { useNavigate } from 'react-router-dom';
|
// Removed react-router-dom usage
|
||||||
|
// import { useNavigate } from 'react-router-dom';
|
||||||
import { ScrollArea } from '../../ui/scroll-area';
|
import { ScrollArea } from '../../ui/scroll-area';
|
||||||
|
import type { View } from '../../../ChatWindow';
|
||||||
|
|
||||||
export default function MoreModelsPage() {
|
export default function MoreModelsPage({
|
||||||
|
onClose,
|
||||||
|
setView,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
setView: (view: View) => void;
|
||||||
|
}) {
|
||||||
const { currentModel } = useModel();
|
const { currentModel } = useModel();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full">
|
<div className="h-screen w-full">
|
||||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||||
|
|
||||||
<ScrollArea className="h-full w-full">
|
<ScrollArea className="h-full w-full">
|
||||||
{/*
|
|
||||||
Instead of forcing one row, allow the layout
|
|
||||||
to stack vertically on small screens:
|
|
||||||
*/}
|
|
||||||
|
|
||||||
<div className="px-8 pt-6 pb-4">
|
<div className="px-8 pt-6 pb-4">
|
||||||
<BackButton />
|
<BackButton onClick={onClose} />
|
||||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Browse models</h1>
|
<h1 className="text-3xl font-medium text-textStandard mt-1">Browse models</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ export default function MoreModelsPage() {
|
|||||||
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
||||||
<h2 className="text-xl font-medium text-textStandard">Models</h2>
|
<h2 className="text-xl font-medium text-textStandard">Models</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings/configure-providers')}
|
onClick={() => setView('configureProviders')}
|
||||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||||
>
|
>
|
||||||
Configure
|
Configure
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ import React from 'react';
|
|||||||
import { ScrollArea } from '../../ui/scroll-area';
|
import { ScrollArea } from '../../ui/scroll-area';
|
||||||
import BackButton from '../../ui/BackButton';
|
import BackButton from '../../ui/BackButton';
|
||||||
import { ConfigureProvidersGrid } from './ConfigureProvidersGrid';
|
import { ConfigureProvidersGrid } from './ConfigureProvidersGrid';
|
||||||
|
import type { View } from '../../../ChatWindow';
|
||||||
|
|
||||||
export default function ConfigureProviders() {
|
export default function ConfigureProviders({
|
||||||
|
onClose,
|
||||||
|
setView,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
setView?: (view: View) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full">
|
<div className="h-screen w-full">
|
||||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||||
|
|
||||||
<ScrollArea className="h-full w-full">
|
<ScrollArea className="h-full w-full">
|
||||||
<div className="px-8 pt-6 pb-4">
|
<div className="px-8 pt-6 pb-4">
|
||||||
<BackButton />
|
<BackButton onClick={onClose} />
|
||||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Configure</h1>
|
<h1 className="text-3xl font-medium text-textStandard mt-1">Configure</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { createServer } from 'net';
|
import { createServer } from 'net';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { getBinaryPath } from './utils/binaryPath';
|
import { getBinaryPath } from './utils/binaryPath';
|
||||||
import log from './utils/logger';
|
import log from './utils/logger';
|
||||||
import { ChildProcessByStdio } from 'node:child_process';
|
import { ChildProcessByStdio } from 'node:child_process';
|
||||||
@@ -8,7 +9,7 @@ import { Readable } from 'node:stream';
|
|||||||
|
|
||||||
// Find an available port to start goosed on
|
// Find an available port to start goosed on
|
||||||
export const findAvailablePort = (): Promise<number> => {
|
export const findAvailablePort = (): Promise<number> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, _reject) => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
server.listen(0, '127.0.0.1', () => {
|
||||||
@@ -56,16 +57,18 @@ export const startGoosed = async (
|
|||||||
): Promise<[number, string, ChildProcessByStdio<null, Readable, Readable>]> => {
|
): Promise<[number, string, ChildProcessByStdio<null, Readable, Readable>]> => {
|
||||||
// we default to running goosed in home dir - if not specified
|
// we default to running goosed in home dir - if not specified
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
// Ensure dir is properly normalized for the platform
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
dir = homeDir;
|
dir = homeDir;
|
||||||
}
|
}
|
||||||
|
dir = path.normalize(dir);
|
||||||
|
|
||||||
// Get the goosed binary path using the shared utility
|
// Get the goosed binary path using the shared utility
|
||||||
const goosedPath = getBinaryPath(app, 'goosed');
|
let goosedPath = getBinaryPath(app, 'goosed');
|
||||||
const port = await findAvailablePort();
|
const port = await findAvailablePort();
|
||||||
|
|
||||||
// in case we want it
|
|
||||||
//const isPackaged = app.isPackaged;
|
|
||||||
log.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${dir}`);
|
log.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${dir}`);
|
||||||
|
|
||||||
// Define additional environment variables
|
// Define additional environment variables
|
||||||
@@ -74,12 +77,15 @@ export const startGoosed = async (
|
|||||||
HOME: homeDir,
|
HOME: homeDir,
|
||||||
// Set USERPROFILE for Windows
|
// Set USERPROFILE for Windows
|
||||||
USERPROFILE: homeDir,
|
USERPROFILE: homeDir,
|
||||||
|
// Set APPDATA for Windows
|
||||||
|
APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
||||||
|
// 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}`,
|
||||||
// start with the port specified
|
// start with the port specified
|
||||||
GOOSE_PORT: String(port),
|
GOOSE_PORT: String(port),
|
||||||
|
|
||||||
GOOSE_SERVER__SECRET_KEY: process.env.GOOSE_SERVER__SECRET_KEY,
|
GOOSE_SERVER__SECRET_KEY: process.env.GOOSE_SERVER__SECRET_KEY,
|
||||||
|
|
||||||
// Add any additional environment variables passed in
|
// Add any additional environment variables passed in
|
||||||
...env,
|
...env,
|
||||||
};
|
};
|
||||||
@@ -87,12 +93,54 @@ export const startGoosed = async (
|
|||||||
// Merge parent environment with additional environment variables
|
// Merge parent environment with additional environment variables
|
||||||
const processEnv = { ...process.env, ...additionalEnv };
|
const processEnv = { ...process.env, ...additionalEnv };
|
||||||
|
|
||||||
// Spawn the goosed process with the user's home directory as cwd
|
// Add detailed logging for troubleshooting
|
||||||
const goosedProcess = spawn(goosedPath, ['agent'], {
|
log.info(`Process platform: ${process.platform}`);
|
||||||
|
log.info(`Process cwd: ${process.cwd()}`);
|
||||||
|
log.info(`Target working directory: ${dir}`);
|
||||||
|
log.info(`Environment HOME: ${processEnv.HOME}`);
|
||||||
|
log.info(`Environment USERPROFILE: ${processEnv.USERPROFILE}`);
|
||||||
|
log.info(`Environment APPDATA: ${processEnv.APPDATA}`);
|
||||||
|
log.info(`Environment LOCALAPPDATA: ${processEnv.LOCALAPPDATA}`);
|
||||||
|
log.info(`Environment PATH: ${processEnv.PATH}`);
|
||||||
|
|
||||||
|
// Ensure proper executable path on Windows
|
||||||
|
if (isWindows && !goosedPath.toLowerCase().endsWith('.exe')) {
|
||||||
|
goosedPath += '.exe';
|
||||||
|
}
|
||||||
|
log.info(`Binary path resolved to: ${goosedPath}`);
|
||||||
|
|
||||||
|
// Verify binary exists
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const stats = fs.statSync(goosedPath);
|
||||||
|
log.info(`Binary exists: ${stats.isFile()}`);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Binary not found at ${goosedPath}:`, error);
|
||||||
|
throw new Error(`Binary not found at ${goosedPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnOptions = {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
env: processEnv,
|
env: processEnv,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
// Hide terminal window on Windows
|
||||||
|
windowsHide: true,
|
||||||
|
// Run detached on Windows only to avoid terminal windows
|
||||||
|
detached: isWindows,
|
||||||
|
// Never use shell to avoid terminal windows
|
||||||
|
shell: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log spawn options for debugging
|
||||||
|
log.info('Spawn options:', JSON.stringify(spawnOptions, null, 2));
|
||||||
|
|
||||||
|
// Spawn the goosed process
|
||||||
|
const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
|
||||||
|
|
||||||
|
// Only unref on Windows to allow it to run independently of the parent
|
||||||
|
if (isWindows) {
|
||||||
|
goosedProcess.unref();
|
||||||
|
}
|
||||||
|
|
||||||
goosedProcess.stdout.on('data', (data) => {
|
goosedProcess.stdout.on('data', (data) => {
|
||||||
log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
|
log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
|
||||||
@@ -116,7 +164,16 @@ export const startGoosed = async (
|
|||||||
log.info(`Goosed isReady ${isReady}`);
|
log.info(`Goosed isReady ${isReady}`);
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
log.error(`Goosed server failed to start on port ${port}`);
|
log.error(`Goosed server failed to start on port ${port}`);
|
||||||
goosedProcess.kill();
|
try {
|
||||||
|
if (isWindows) {
|
||||||
|
// On Windows, use taskkill to forcefully terminate the process tree
|
||||||
|
spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']);
|
||||||
|
} else {
|
||||||
|
goosedProcess.kill();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error while terminating goosed process:', error);
|
||||||
|
}
|
||||||
throw new Error(`Goosed server failed to start on port ${port}`);
|
throw new Error(`Goosed server failed to start on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +181,16 @@ export const startGoosed = async (
|
|||||||
// TODO will need to do it at tab level next
|
// TODO will need to do it at tab level next
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
log.info('App quitting, terminating goosed server');
|
log.info('App quitting, terminating goosed server');
|
||||||
goosedProcess.kill();
|
try {
|
||||||
|
if (isWindows) {
|
||||||
|
// On Windows, use taskkill to forcefully terminate the process tree
|
||||||
|
spawn('taskkill', ['/pid', goosedProcess.pid.toString(), '/T', '/F']);
|
||||||
|
} else {
|
||||||
|
goosedProcess.kill();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error while terminating goosed process:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info(`Goosed server successfully started on port ${port}`);
|
log.info(`Goosed server successfully started on port ${port}`);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'electron';
|
} from 'electron';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { handleSquirrelEvent } from './setup-events';
|
||||||
import { startGoosed } from './goosed';
|
import { startGoosed } from './goosed';
|
||||||
import { getBinaryPath } from './utils/binaryPath';
|
import { getBinaryPath } from './utils/binaryPath';
|
||||||
import { loadShellEnv } from './utils/loadEnv';
|
import { loadShellEnv } from './utils/loadEnv';
|
||||||
@@ -26,25 +27,112 @@ import {
|
|||||||
saveSettings,
|
saveSettings,
|
||||||
updateEnvironmentVariables,
|
updateEnvironmentVariables,
|
||||||
} from './utils/settings';
|
} from './utils/settings';
|
||||||
const { exec } = require('child_process');
|
import * as crypto from 'crypto';
|
||||||
|
import * as electron from 'electron';
|
||||||
|
import { exec as execCallback } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const exec = promisify(execCallback);
|
||||||
|
|
||||||
|
// Handle Squirrel events for Windows installer
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
console.log('Windows detected, command line args:', process.argv);
|
||||||
|
|
||||||
|
if (handleSquirrelEvent()) {
|
||||||
|
// squirrel event handled and app will exit in 1000ms, so don't do anything else
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the protocol on Windows during first launch
|
||||||
|
if (process.argv.length >= 2) {
|
||||||
|
const url = process.argv[1];
|
||||||
|
console.log('Checking URL from command line:', url);
|
||||||
|
if (url.startsWith('goose://')) {
|
||||||
|
console.log('Found goose:// URL in command line args');
|
||||||
|
app.emit('open-url', { preventDefault: () => {} }, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure single instance lock
|
||||||
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!gotTheLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', (event, commandLine, _workingDirectory) => {
|
||||||
|
// Someone tried to run a second instance
|
||||||
|
console.log('Second instance detected with args:', commandLine);
|
||||||
|
|
||||||
|
// Get existing window or create new one
|
||||||
|
const existingWindows = BrowserWindow.getAllWindows();
|
||||||
|
if (existingWindows.length > 0) {
|
||||||
|
const window = existingWindows[0];
|
||||||
|
if (window.isMinimized()) window.restore();
|
||||||
|
window.focus();
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Protocol handling for Windows
|
||||||
|
const url = commandLine[commandLine.length - 1];
|
||||||
|
console.log('Checking last arg for protocol:', url);
|
||||||
|
if (url.startsWith('goose://')) {
|
||||||
|
console.log('Found goose:// URL in second instance');
|
||||||
|
// Send the URL to the window
|
||||||
|
if (!window.webContents.isLoading()) {
|
||||||
|
window.webContents.send('add-extension', url);
|
||||||
|
} else {
|
||||||
|
window.webContents.once('did-finish-load', () => {
|
||||||
|
window.webContents.send('add-extension', url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||||
if (started) app.quit();
|
if (started) app.quit();
|
||||||
|
|
||||||
|
// Register protocol handler
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const success = app.setAsDefaultProtocolClient('goose', process.execPath, ['--']);
|
||||||
|
console.log('Registering protocol handler for Windows:', success ? 'success' : 'failed');
|
||||||
|
} else {
|
||||||
|
const success = app.setAsDefaultProtocolClient('goose');
|
||||||
|
console.log('Registering protocol handler:', success ? 'success' : 'failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log if we're the default protocol handler
|
||||||
|
console.log('Is default protocol handler:', app.isDefaultProtocolClient('goose'));
|
||||||
|
|
||||||
// Triggered when the user opens "goose://..." links
|
// Triggered when the user opens "goose://..." links
|
||||||
app.on('open-url', async (event, url) => {
|
app.on('open-url', async (event, url) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log('open-url:', url);
|
console.log('open-url:', url);
|
||||||
|
|
||||||
const recentDirs = loadRecentDirs();
|
// Get existing window or create new one
|
||||||
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
let targetWindow: BrowserWindow;
|
||||||
|
const existingWindows = BrowserWindow.getAllWindows();
|
||||||
|
|
||||||
// Create the new Chat window
|
if (existingWindows.length > 0) {
|
||||||
const newWindow = await createChat(app, undefined, openDir);
|
targetWindow = existingWindows[0];
|
||||||
|
if (targetWindow.isMinimized()) targetWindow.restore();
|
||||||
|
targetWindow.focus();
|
||||||
|
} else {
|
||||||
|
const recentDirs = loadRecentDirs();
|
||||||
|
const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
|
||||||
|
targetWindow = await createChat(app, undefined, openDir);
|
||||||
|
}
|
||||||
|
|
||||||
newWindow.webContents.once('did-finish-load', () => {
|
// Wait for window to be ready before sending the extension URL
|
||||||
newWindow.webContents.send('add-extension', url);
|
if (!targetWindow.webContents.isLoading()) {
|
||||||
});
|
targetWindow.webContents.send('add-extension', url);
|
||||||
|
} else {
|
||||||
|
targetWindow.webContents.once('did-finish-load', () => {
|
||||||
|
targetWindow.webContents.send('add-extension', url);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||||
@@ -77,8 +165,7 @@ const getGooseProvider = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateSecretKey = () => {
|
const generateSecretKey = () => {
|
||||||
const crypto = require('crypto');
|
const key = crypto.randomBytes(32).toString('hex');
|
||||||
let key = crypto.randomBytes(32).toString('hex');
|
|
||||||
process.env.GOOSE_SERVER__SECRET_KEY = key;
|
process.env.GOOSE_SERVER__SECRET_KEY = key;
|
||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
@@ -98,7 +185,7 @@ const createLauncher = () => {
|
|||||||
const launcherWindow = new BrowserWindow({
|
const launcherWindow = new BrowserWindow({
|
||||||
width: 600,
|
width: 600,
|
||||||
height: 60,
|
height: 60,
|
||||||
frame: false,
|
frame: process.platform === 'darwin' ? false : true,
|
||||||
transparent: false,
|
transparent: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.ts'),
|
preload: path.join(__dirname, 'preload.ts'),
|
||||||
@@ -110,8 +197,7 @@ const createLauncher = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Center on screen
|
// Center on screen
|
||||||
const { screen } = require('electron');
|
const primaryDisplay = electron.screen.getPrimaryDisplay();
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
|
||||||
const { width, height } = primaryDisplay.workAreaSize;
|
const { width, height } = primaryDisplay.workAreaSize;
|
||||||
const windowBounds = launcherWindow.getBounds();
|
const windowBounds = launcherWindow.getBounds();
|
||||||
|
|
||||||
@@ -141,18 +227,16 @@ let windowCounter = 0;
|
|||||||
const windowMap = new Map<number, BrowserWindow>();
|
const windowMap = new Map<number, BrowserWindow>();
|
||||||
|
|
||||||
const createChat = async (app, query?: string, dir?: string, version?: string) => {
|
const createChat = async (app, query?: string, dir?: string, version?: string) => {
|
||||||
const env = version ? { GOOSE_AGENT_VERSION: version } : {};
|
|
||||||
|
|
||||||
// Apply current environment settings before creating chat
|
// Apply current environment settings before creating chat
|
||||||
updateEnvironmentVariables(envToggles);
|
updateEnvironmentVariables(envToggles);
|
||||||
|
|
||||||
const [port, working_dir, goosedProcess] = await startGoosed(app, dir);
|
const [port, working_dir, goosedProcess] = await startGoosed(app, dir);
|
||||||
|
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
|
||||||
trafficLightPosition: { x: 16, y: 10 },
|
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 10 } : undefined,
|
||||||
vibrancy: 'window',
|
vibrancy: process.platform === 'darwin' ? 'window' : undefined,
|
||||||
frame: false,
|
frame: process.platform === 'darwin' ? false : true,
|
||||||
width: 750,
|
width: 750,
|
||||||
height: 800,
|
height: 800,
|
||||||
minWidth: 650,
|
minWidth: 650,
|
||||||
@@ -186,8 +270,7 @@ const createChat = async (app, query?: string, dir?: string, version?: string) =
|
|||||||
|
|
||||||
// Load the index.html of the app.
|
// Load the index.html of the app.
|
||||||
const queryParam = query ? `?initialQuery=${encodeURIComponent(query)}` : '';
|
const queryParam = query ? `?initialQuery=${encodeURIComponent(query)}` : '';
|
||||||
const { screen } = require('electron');
|
const primaryDisplay = electron.screen.getPrimaryDisplay();
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
|
||||||
const { width } = primaryDisplay.workAreaSize;
|
const { width } = primaryDisplay.workAreaSize;
|
||||||
|
|
||||||
// Increment window counter to track number of windows
|
// Increment window counter to track number of windows
|
||||||
@@ -357,7 +440,7 @@ ipcMain.handle('select-file-or-directory', async () => {
|
|||||||
|
|
||||||
ipcMain.handle('check-ollama', async () => {
|
ipcMain.handle('check-ollama', async () => {
|
||||||
try {
|
try {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
// Run `ps` and filter for "ollama"
|
// Run `ps` and filter for "ollama"
|
||||||
exec('ps aux | grep -iw "[o]llama"', (error, stdout, stderr) => {
|
exec('ps aux | grep -iw "[o]llama"', (error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
29
ui/desktop/src/preload.js
Normal file
29
ui/desktop/src/preload.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('appConfig', {
|
||||||
|
get: (key) => config[key],
|
||||||
|
getAll: () => config,
|
||||||
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
|
getConfig: () => config,
|
||||||
|
hideWindow: () => ipcRenderer.send('hide-window'),
|
||||||
|
directoryChooser: (replace) => ipcRenderer.send('directory-chooser', replace),
|
||||||
|
createChatWindow: (query, dir, version) => ipcRenderer.send('create-chat-window', query, dir, version),
|
||||||
|
logInfo: (txt) => ipcRenderer.send('logInfo', txt),
|
||||||
|
showNotification: (data) => ipcRenderer.send('notify', data),
|
||||||
|
createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query),
|
||||||
|
openInChrome: (url) => ipcRenderer.send('open-in-chrome', url),
|
||||||
|
fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url),
|
||||||
|
reloadApp: () => ipcRenderer.send('reload-app'),
|
||||||
|
checkForOllama: () => ipcRenderer.invoke('check-ollama'),
|
||||||
|
selectFileOrDirectory: () => ipcRenderer.invoke('select-file-or-directory'),
|
||||||
|
startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'),
|
||||||
|
stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'),
|
||||||
|
getBinaryPath: (binaryName) => ipcRenderer.invoke('get-binary-path', binaryName),
|
||||||
|
on: (channel, callback) => ipcRenderer.on(channel, callback),
|
||||||
|
off: (channel, callback) => ipcRenderer.off(channel, callback),
|
||||||
|
send: (key) => ipcRenderer.send(key)
|
||||||
|
});
|
||||||
71
ui/desktop/src/setup-events.ts
Normal file
71
ui/desktop/src/setup-events.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { spawn as spawnProcess } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
export function handleSquirrelEvent(): boolean {
|
||||||
|
if (process.argv.length === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appFolder = path.resolve(process.execPath, '..');
|
||||||
|
const rootAtomFolder = path.resolve(appFolder, '..');
|
||||||
|
const updateDotExe = path.resolve(path.join(rootAtomFolder, 'Update.exe'));
|
||||||
|
const exeName = path.basename(process.execPath);
|
||||||
|
|
||||||
|
const spawnUpdate = function (args: string[]) {
|
||||||
|
try {
|
||||||
|
return spawnProcess(updateDotExe, args, { detached: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to spawn update process:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const squirrelEvent = process.argv[1];
|
||||||
|
switch (squirrelEvent) {
|
||||||
|
case '--squirrel-install':
|
||||||
|
case '--squirrel-updated': {
|
||||||
|
// Register protocol handler
|
||||||
|
spawnUpdate(['--createShortcut', exeName]);
|
||||||
|
|
||||||
|
// Register protocol
|
||||||
|
const regCommand = `Windows Registry Editor Version 5.00
|
||||||
|
|
||||||
|
[HKEY_CLASSES_ROOT\\goose]
|
||||||
|
@="URL:Goose Protocol"
|
||||||
|
"URL Protocol"=""
|
||||||
|
|
||||||
|
[HKEY_CLASSES_ROOT\\goose\\DefaultIcon]
|
||||||
|
@="\\"${process.execPath.replace(/\\/g, '\\\\')},1\\""
|
||||||
|
|
||||||
|
[HKEY_CLASSES_ROOT\\goose\\shell]
|
||||||
|
|
||||||
|
[HKEY_CLASSES_ROOT\\goose\\shell\\open]
|
||||||
|
|
||||||
|
[HKEY_CLASSES_ROOT\\goose\\shell\\open\\command]
|
||||||
|
@="\\"${process.execPath.replace(/\\/g, '\\\\')}\\" \\"%1\\""`;
|
||||||
|
|
||||||
|
fs.writeFileSync('goose-protocol.reg', regCommand);
|
||||||
|
spawnProcess('regedit.exe', ['/s', 'goose-protocol.reg']);
|
||||||
|
|
||||||
|
setTimeout(() => app.quit(), 1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '--squirrel-uninstall': {
|
||||||
|
// Remove protocol handler
|
||||||
|
spawnUpdate(['--removeShortcut', exeName]);
|
||||||
|
setTimeout(() => app.quit(), 1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '--squirrel-obsolete': {
|
||||||
|
app.quit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,55 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
import Electron from 'electron';
|
import Electron from 'electron';
|
||||||
|
import log from './logger';
|
||||||
|
|
||||||
export const getBinaryPath = (app: Electron.App, binaryName: string): string => {
|
export const getBinaryPath = (app: Electron.App, binaryName: string): string => {
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const isPackaged = app.isPackaged;
|
const isPackaged = app.isPackaged;
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
// On Windows, use .cmd for npx and .exe for uvx
|
||||||
|
const executableName = isWindows
|
||||||
|
? binaryName === 'npx'
|
||||||
|
? 'npx.cmd'
|
||||||
|
: `${binaryName}.exe`
|
||||||
|
: binaryName;
|
||||||
|
|
||||||
|
// List of possible paths to check
|
||||||
|
const possiblePaths = [];
|
||||||
|
|
||||||
if (isDev && !isPackaged) {
|
if (isDev && !isPackaged) {
|
||||||
// In development, use the absolute path from the project root
|
// In development, check multiple possible locations
|
||||||
return path.join(
|
possiblePaths.push(
|
||||||
process.cwd(),
|
path.join(process.cwd(), 'src', 'bin', executableName),
|
||||||
'src',
|
path.join(process.cwd(), 'bin', executableName),
|
||||||
'bin',
|
path.join(process.cwd(), '..', '..', 'target', 'release', executableName)
|
||||||
process.platform === 'win32' ? `${binaryName}.exe` : binaryName
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// In production, use the path relative to the app resources
|
// In production, check resources paths
|
||||||
return path.join(
|
possiblePaths.push(
|
||||||
process.resourcesPath,
|
path.join(process.resourcesPath, 'bin', executableName),
|
||||||
'bin',
|
path.join(app.getAppPath(), 'resources', 'bin', executableName)
|
||||||
process.platform === 'win32' ? `${binaryName}.exe` : binaryName
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log all paths we're checking
|
||||||
|
log.info('Checking binary paths:', possiblePaths);
|
||||||
|
|
||||||
|
// Try each path and return the first one that exists
|
||||||
|
for (const binPath of possiblePaths) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(binPath)) {
|
||||||
|
log.info(`Found binary at: ${binPath}`);
|
||||||
|
return binPath;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Error checking path ${binPath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, we couldn't find the binary
|
||||||
|
const error = `Could not find ${binaryName} binary in any of the expected locations: ${possiblePaths.join(', ')}`;
|
||||||
|
log.error(error);
|
||||||
|
throw new Error(error);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user