diff --git a/.github/workflows/pre-build-view.yml b/.github/workflows/pre-build-view.yml new file mode 100644 index 00000000..16bd870a --- /dev/null +++ b/.github/workflows/pre-build-view.yml @@ -0,0 +1,408 @@ +name: Pre-Build-View + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + arch: arm64 + artifact_name: macos-arm64 + - os: macos-15-intel + arch: x64 + artifact_name: macos-intel + - os: windows-latest + arch: x64 + artifact_name: windows-latest + - os: ubuntu-latest + arch: x64 + artifact_name: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + clean: true + + # Clean node_modules on self-hosted runner to ensure fresh install + - name: Clean node_modules (self-hosted) + if: contains(matrix.os, 'self-hosted') + run: | + rm -rf node_modules + rm -rf release + rm -rf dist out build .vite + rm -rf node_modules/.cache || true + + # Clean build outputs on GitHub-hosted runners to avoid stale artifacts in current job + - name: Clean build outputs (non-Windows) + if: "!contains(matrix.os, 'self-hosted') && runner.os != 'Windows'" + run: | + rm -rf release dist out .vite + rm -rf node_modules/.cache || true + + - name: Clean build outputs (Windows) + if: "!contains(matrix.os, 'self-hosted') && runner.os == 'Windows'" + shell: pwsh + run: | + Remove-Item -Recurse -Force release, dist, out, .vite -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force node_modules/.cache -ErrorAction SilentlyContinue + + - name: Setup Node.js + if: "!contains(matrix.os, 'self-hosted')" + uses: actions/setup-node@v6 + with: + node-version: 20 + + - name: Setup Python + if: "!contains(matrix.os, 'self-hosted')" + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Point electron-builder publish target to private repo + shell: bash + run: | + node -e "const fs=require('fs'); const path='electron-builder.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); if (Array.isArray(config.publish)) { config.publish=config.publish.map((entry) => entry && entry.provider === 'github' ? { ...entry, repo: 'eigent-private-lab' } : entry); } fs.writeFileSync(path, JSON.stringify(config, null, 2) + '\n');" + + - name: Install Python Dependencies + run: | + python3 -m pip install --upgrade pip + pip3 install uv + + - name: Install bun + run: npm install -g bun + + - name: Install Dependencies + run: npm install + + # Verify Electron installation on macOS + - name: Verify Electron Installation (macOS) + if: runner.os == 'macOS' + run: | + echo "Checking Electron installation..." + ls -la node_modules/electron/dist/ || echo "Electron dist not found" + if [ -d "node_modules/electron/dist/Electron.app" ]; then + echo "✅ Electron.app found" + ls -la "node_modules/electron/dist/Electron.app/Contents/Frameworks/" | head -5 + else + echo "❌ Electron.app NOT found - this will cause build failure" + exit 1 + fi + + # Install libfuse2 for Linux AppImage builds + - name: Install libfuse2 (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libfuse2 + + # Install LLVM 20 for macOS Intel - llvmlite 0.46.0 only supports LLVM 20 (not 21) + - name: Install LLVM 20 (macOS Intel) + if: runner.os == 'macOS' && matrix.arch == 'x64' + run: | + brew install llvm@20 + echo "LLVM_DIR=$(brew --prefix llvm@20)/lib/cmake/llvm" >> $GITHUB_ENV + echo "CMAKE_PREFIX_PATH=$(brew --prefix llvm@20)/lib/cmake/llvm" >> $GITHUB_ENV + + # Prebuild separately on macOS so signing/package issues are isolated + - name: Build Release Files (macOS prebuild) + if: runner.os == 'macOS' + timeout-minutes: 45 + run: | + npm run prebuild + env: + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + VITE_PROXY_URL: ${{ secrets.VITE_PROXY_URL }} + VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} + VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} + VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + USE_NPM_INSTALL_BUN: 'true' + + # Step for macOS builds with signing + - name: Build Release Files (macOS with signing) + if: runner.os == 'macOS' + timeout-minutes: 90 + run: | + # Set file descriptor limit to system maximum (hard limit) to prevent EMFILE during signing + HARD=$(ulimit -Hn 2>/dev/null) + if [ -n "$HARD" ] && [ "$HARD" != "unlimited" ]; then + ulimit -n "$HARD" 2>/dev/null || true + fi + ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true + echo "File descriptor limit: $(ulimit -n) (hard: $(ulimit -Hn 2>/dev/null || echo 'N/A'))" + + set +e + npx electron-builder --mac dmg --${{ matrix.arch }} --publish never + BUILD_EXIT=$? + + if [ $BUILD_EXIT -ne 0 ]; then + echo "First attempt failed with exit code $BUILD_EXIT" + echo "Retrying once in 5 seconds..." + sleep 5 + npx electron-builder --mac dmg --${{ matrix.arch }} --publish never + BUILD_EXIT=$? + fi + + exit $BUILD_EXIT + env: + CSC_LINK: ${{ secrets.CERT_P12 }} + CSC_KEY_PASSWORD: ${{ secrets.CERT_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + VITE_PROXY_URL: ${{ secrets.VITE_PROXY_URL }} + VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} + VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} + VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + USE_NPM_INSTALL_BUN: 'true' + + # Step for Windows builds without signing + - name: Build Release Files (Windows without signing) + if: runner.os == 'Windows' + timeout-minutes: 90 + run: | + npm run prebuild + npx electron-builder --win --${{ matrix.arch }} --publish never + env: + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + VITE_PROXY_URL: ${{ secrets.VITE_PROXY_URL }} + VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} + VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} + VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + USE_NPM_INSTALL_BUN: 'true' + + # Step for Linux builds + - name: Build Release Files (Linux) + if: runner.os == 'Linux' + timeout-minutes: 90 + run: | + npm run prebuild + npx electron-builder --linux --${{ matrix.arch }} --publish never + env: + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + VITE_PROXY_URL: ${{ secrets.VITE_PROXY_URL }} + VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} + VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} + VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + USE_NPM_INSTALL_BUN: 'true' + + # Verify built app contains Electron Framework + - name: Verify Built App (macOS) + if: runner.os == 'macOS' + run: | + echo "Checking built app..." + APP_PATH=$(find release -name "*.app" -type d | head -1) + if [ -n "$APP_PATH" ]; then + echo "Found app at: $APP_PATH" + FRAMEWORKS_PATH="$APP_PATH/Contents/Frameworks" + if [ -d "$FRAMEWORKS_PATH/Electron Framework.framework" ]; then + echo "✅ Electron Framework found" + ls -la "$FRAMEWORKS_PATH/" | head -10 + else + echo "❌ Electron Framework NOT found in built app!" + echo "Contents of Frameworks directory:" + ls -la "$FRAMEWORKS_PATH/" 2>/dev/null || echo "Frameworks directory does not exist" + exit 1 + fi + else + echo "No .app found in release directory" + ls -la release/ + fi + + - name: Upload Artifact (macOS - dmg only) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v6 + with: + name: release-${{ matrix.artifact_name }}-${{ matrix.arch }} + path: | + release/*.dmg + retention-days: 5 + + - name: Upload Artifact (Windows - exe only) + if: runner.os == 'Windows' + uses: actions/upload-artifact@v6 + with: + name: release-${{ matrix.artifact_name }}-${{ matrix.arch }} + path: | + release/*.exe + retention-days: 5 + + - name: Upload Artifact (Linux - AppImage only) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v6 + with: + name: release-${{ matrix.artifact_name }}-${{ matrix.arch }} + path: | + release/*.AppImage + retention-days: 5 + + merge-release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Create directories + run: | + mkdir -p release/mac-arm64 release/mac-intel release/win-x64 release/linux-x64 + + - name: Download mac-arm64 artifact + uses: actions/download-artifact@v7 + with: + name: release-macos-arm64-arm64 + path: temp-mac-arm64 + + - name: Download mac-intel artifact + uses: actions/download-artifact@v7 + with: + name: release-macos-intel-x64 + path: temp-mac-intel + + - name: Download win-x64 artifact + uses: actions/download-artifact@v7 + with: + name: release-windows-latest-x64 + path: temp-win-x64 + + - name: Download linux-x64 artifact + uses: actions/download-artifact@v7 + with: + name: release-ubuntu-latest-x64 + path: temp-linux-x64 + + # Move only dmg files for macOS, exe files for Windows, and AppImage for Linux + - name: Move files to clean folders + shell: bash + run: | + # mac-arm64 - only move dmg files + if [ -d "temp-mac-arm64/release" ]; then + find temp-mac-arm64/release -name "*.dmg" -exec mv {} release/mac-arm64/ \; || true + else + find temp-mac-arm64 -name "*.dmg" -exec mv {} release/mac-arm64/ \; || true + fi + + # mac-intel - only move dmg files + if [ -d "temp-mac-intel/release" ]; then + find temp-mac-intel/release -name "*.dmg" -exec mv {} release/mac-intel/ \; || true + else + find temp-mac-intel -name "*.dmg" -exec mv {} release/mac-intel/ \; || true + fi + + # win-x64 - only move exe files + if [ -d "temp-win-x64/release" ]; then + find temp-win-x64/release -name "*.exe" -exec mv {} release/win-x64/ \; || true + else + find temp-win-x64 -name "*.exe" -exec mv {} release/win-x64/ \; || true + fi + + # linux-x64 - only move AppImage files + if [ -d "temp-linux-x64/release" ]; then + find temp-linux-x64/release -name "*.AppImage" -exec mv {} release/linux-x64/ \; || true + else + find temp-linux-x64 -name "*.AppImage" -exec mv {} release/linux-x64/ \; || true + fi + + # Extract version for test builds + - name: Extract version + id: version + run: | + # Create a version using timestamp for test builds + VERSION="test-$(date +%Y%m%d-%H%M%S)" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version for test build: $VERSION" + + # Configure AWS credentials (skipped when AWS secrets are not configured) + - name: Configure AWS credentials + id: aws-check + if: env.AWS_ACCESS_KEY_ID != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + # Upload to S3 - test directory + - name: Upload to S3 (test build) + if: steps.aws-check.outcome == 'success' + run: | + VERSION="${{ steps.version.outputs.version }}" + BUCKET="${{ secrets.AWS_S3_BUCKET }}" + + echo "Uploading test build $VERSION to S3 bucket: $BUCKET" + + declare -a targets=( + "release/mac-arm64 mac-arm64" + "release/mac-intel mac-intel" + "release/win-x64 win-x64" + "release/linux-x64 linux-x64" + ) + for t in "${targets[@]}"; do + src_dir="${t%% *}" + path_suffix="${t##* }" + if [ -d "$src_dir" ] && [ "$(ls -A "$src_dir")" ]; then + echo "Uploading $path_suffix files..." + aws s3 sync "$src_dir/" "s3://$BUCKET/pre-releases/test-builds/$VERSION/$path_suffix/" \ + --content-type "binary/octet-stream" \ + --metadata-directive REPLACE \ + --cache-control "public, max-age=300" + fi + done + + echo "Test build $VERSION uploaded successfully" + + # Generate download URLs + - name: Generate download URLs + if: steps.aws-check.outcome == 'success' + run: | + VERSION="${{ steps.version.outputs.version }}" + BUCKET="${{ secrets.AWS_S3_BUCKET }}" + REGION="${{ secrets.AWS_REGION }}" + + # Determine S3 endpoint based on region + if [[ "$REGION" == cn-* ]]; then + ENDPOINT="s3.$REGION.amazonaws.com.cn" + else + ENDPOINT="s3.$REGION.amazonaws.com" + fi + + echo "================================" + echo "Test Build Download URLs" + echo "Build ID: $VERSION" + echo "================================" + echo "" + + # Check which platforms exist and show URLs + if [ -d "release/mac-arm64" ] && [ "$(ls -A release/mac-arm64)" ]; then + echo "macOS (ARM64): https://$BUCKET.$ENDPOINT/pre-releases/test-builds/$VERSION/mac-arm64/" + fi + + if [ -d "release/mac-intel" ] && [ "$(ls -A release/mac-intel)" ]; then + echo "macOS (Intel): https://$BUCKET.$ENDPOINT/pre-releases/test-builds/$VERSION/mac-intel/" + fi + + if [ -d "release/win-x64" ] && [ "$(ls -A release/win-x64)" ]; then + echo "Windows (x64): https://$BUCKET.$ENDPOINT/pre-releases/test-builds/$VERSION/win-x64/" + fi + + if [ -d "release/linux-x64" ] && [ "$(ls -A release/linux-x64)" ]; then + echo "Linux (x64): https://$BUCKET.$ENDPOINT/pre-releases/test-builds/$VERSION/linux-x64/" + fi + + echo "" + echo "⚠️ Note: Test builds are stored in 'pre-releases/test-builds/' directory" + echo "⚠️ Remember to delete old test builds to save storage costs" + echo "" + echo "To delete this test build:" + echo "aws s3 rm s3://$BUCKET/pre-releases/test-builds/$VERSION/ --recursive --region $REGION" + echo "================================" diff --git a/.github/workflows/pre-build.yml b/.github/workflows/pre-build.yml new file mode 100644 index 00000000..85d4077b --- /dev/null +++ b/.github/workflows/pre-build.yml @@ -0,0 +1,551 @@ +name: Pre-Build + +on: + push: + tags: + - 'pre-v*' + paths-ignore: + - '**.md' + - '**.spec.js' + - '.idea' + - '.vscode' + - '.dockerignore' + - 'Dockerfile' + - '.gitignore' + - '.github/**' + - '!.github/workflows/pre-build.yml' + +permissions: + contents: write + +jobs: + build: + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + + strategy: + matrix: + include: + - os: macos-latest + arch: arm64 + artifact_name: macos-arm64 + - os: macos-15-intel + arch: x64 + artifact_name: macos-intel + - os: windows-latest + arch: x64 + artifact_name: windows-latest + - os: ubuntu-latest + arch: x64 + artifact_name: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + clean: true + + # Clean node_modules on self-hosted runner to ensure fresh install + - name: Clean node_modules (self-hosted) + if: contains(matrix.os, 'self-hosted') + run: | + rm -rf node_modules + rm -rf release + + - name: Setup Node.js + if: "!contains(matrix.os, 'self-hosted')" + uses: actions/setup-node@v6 + with: + node-version: 20 + + - name: Setup Python + if: "!contains(matrix.os, 'self-hosted')" + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Point electron-builder publish target to private repo + shell: bash + run: | + node -e "const fs=require('fs'); const path='electron-builder.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); if (Array.isArray(config.publish)) { config.publish=config.publish.map((entry) => entry && entry.provider === 'github' ? { ...entry, repo: 'eigent-private-lab' } : entry); } fs.writeFileSync(path, JSON.stringify(config, null, 2) + '\n');" + + - name: Install Python Dependencies + run: | + python3 -m pip install --upgrade pip + pip3 install uv + + - name: Install bun + run: npm install -g bun + + - name: Install Dependencies + run: npm install + + # Verify Electron installation on macOS + - name: Verify Electron Installation (macOS) + if: runner.os == 'macOS' + run: | + echo "Checking Electron installation..." + ls -la node_modules/electron/dist/ || echo "Electron dist not found" + if [ -d "node_modules/electron/dist/Electron.app" ]; then + echo "✅ Electron.app found" + ls -la "node_modules/electron/dist/Electron.app/Contents/Frameworks/" | head -5 + else + echo "❌ Electron.app NOT found - this will cause build failure" + exit 1 + fi + + # Install libfuse2 for Linux AppImage builds + - name: Install libfuse2 (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libfuse2 + + # Install LLVM 20 for macOS Intel - llvmlite 0.46.0 only supports LLVM 20 (not 21) + - name: Install LLVM 20 (macOS Intel) + if: runner.os == 'macOS' && matrix.arch == 'x64' + run: | + brew install llvm@20 + echo "LLVM_DIR=$(brew --prefix llvm@20)/lib/cmake/llvm" >> $GITHUB_ENV + echo "CMAKE_PREFIX_PATH=$(brew --prefix llvm@20)/lib/cmake/llvm" >> $GITHUB_ENV + + # Step for macOS builds with signing + - name: Build Release Files (macOS with signing) + if: runner.os == 'macOS' + timeout-minutes: 90 + run: | + # Set file descriptor limit to system maximum (hard limit) to prevent EMFILE during signing + HARD=$(ulimit -Hn 2>/dev/null) + if [ -n "$HARD" ] && [ "$HARD" != "unlimited" ]; then + ulimit -n "$HARD" 2>/dev/null || true + fi + ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true + echo "File descriptor limit: $(ulimit -n) (hard: $(ulimit -Hn 2>/dev/null || echo 'N/A'))" + npm run build -- --${{ matrix.arch }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.CERT_P12 }} + CSC_KEY_PASSWORD: ${{ secrets.CERT_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + VITE_PROXY_URL: ${{ secrets.VITE_PROXY_URL }} + VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} + VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} + VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + USE_NPM_INSTALL_BUN: 'true' + + # Step for Windows builds without signing + - name: Build Release Files (Windows without signing) + if: runner.os == 'Windows' + timeout-minutes: 90 + run: npm run build -- --${{ matrix.arch }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + VITE_PROXY_URL: ${{ secrets.VITE_PROXY_URL }} + VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} + VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} + VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + USE_NPM_INSTALL_BUN: 'true' + + # Step for Linux builds + - name: Build Release Files (Linux) + if: runner.os == 'Linux' + timeout-minutes: 90 + run: npm run build:linux + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + VITE_PROXY_URL: ${{ secrets.VITE_PROXY_URL }} + VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} + VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} + VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + USE_NPM_INSTALL_BUN: 'true' + + # Verify built app contains Electron Framework + - name: Verify Built App (macOS) + if: runner.os == 'macOS' + run: | + echo "Checking built app..." + APP_PATH=$(find release -name "*.app" -type d | head -1) + if [ -n "$APP_PATH" ]; then + echo "Found app at: $APP_PATH" + FRAMEWORKS_PATH="$APP_PATH/Contents/Frameworks" + if [ -d "$FRAMEWORKS_PATH/Electron Framework.framework" ]; then + echo "✅ Electron Framework found" + ls -la "$FRAMEWORKS_PATH/" | head -10 + else + echo "❌ Electron Framework NOT found in built app!" + echo "Contents of Frameworks directory:" + ls -la "$FRAMEWORKS_PATH/" 2>/dev/null || echo "Frameworks directory does not exist" + exit 1 + fi + else + echo "No .app found in release directory" + ls -la release/ + fi + + - name: Upload Artifact (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v6 + with: + name: release-${{ matrix.artifact_name }}-${{ matrix.arch }} + path: | + release/*.dmg + release/*.dmg.blockmap + release/*.zip + release/*.zip.blockmap + release/latest*.yml + retention-days: 5 + + - name: Upload Artifact (Windows) + if: runner.os == 'Windows' + uses: actions/upload-artifact@v6 + with: + name: release-${{ matrix.artifact_name }}-${{ matrix.arch }} + path: | + release/*.exe + release/*.exe.blockmap + release/latest*.yml + retention-days: 5 + + - name: Upload Artifact (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v6 + with: + name: release-${{ matrix.artifact_name }}-${{ matrix.arch }} + path: | + release/*.AppImage + release/latest*.yml + retention-days: 5 + merge-release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Create directories + run: | + mkdir -p release/mac-arm64 release/mac-intel release/win-x64 release/linux-x64 + + - name: Download mac-arm64 artifact + uses: actions/download-artifact@v7 + with: + name: release-macos-arm64-arm64 + path: temp-mac-arm64 + + - name: Download mac-intel artifact + uses: actions/download-artifact@v7 + with: + name: release-macos-intel-x64 + path: temp-mac-intel + + - name: Download win-x64 artifact + uses: actions/download-artifact@v7 + with: + name: release-windows-latest-x64 + path: temp-win-x64 + + - name: Download linux-x64 artifact + uses: actions/download-artifact@v7 + with: + name: release-ubuntu-latest-x64 + path: temp-linux-x64 + + # Move release files for each platform + - name: Move files to clean folders + shell: bash + run: | + # mac-arm64 - move dmg, zip, blockmap, and yml files + if [ -d "temp-mac-arm64/release" ]; then + find temp-mac-arm64/release \( -name "*.dmg" -o -name "*.dmg.blockmap" -o -name "*.zip" -o -name "*.zip.blockmap" -o -name "latest*.yml" \) -exec mv {} release/mac-arm64/ \; || true + else + find temp-mac-arm64 \( -name "*.dmg" -o -name "*.dmg.blockmap" -o -name "*.zip" -o -name "*.zip.blockmap" -o -name "latest*.yml" \) -exec mv {} release/mac-arm64/ \; || true + fi + + # mac-intel - move dmg, zip, blockmap, and yml files + if [ -d "temp-mac-intel/release" ]; then + find temp-mac-intel/release \( -name "*.dmg" -o -name "*.dmg.blockmap" -o -name "*.zip" -o -name "*.zip.blockmap" -o -name "latest*.yml" \) -exec mv {} release/mac-intel/ \; || true + else + find temp-mac-intel \( -name "*.dmg" -o -name "*.dmg.blockmap" -o -name "*.zip" -o -name "*.zip.blockmap" -o -name "latest*.yml" \) -exec mv {} release/mac-intel/ \; || true + fi + + # win-x64 - move exe, blockmap, and yml files + if [ -d "temp-win-x64/release" ]; then + find temp-win-x64/release \( -name "*.exe" -o -name "*.exe.blockmap" -o -name "latest*.yml" \) -exec mv {} release/win-x64/ \; || true + else + find temp-win-x64 \( -name "*.exe" -o -name "*.exe.blockmap" -o -name "latest*.yml" \) -exec mv {} release/win-x64/ \; || true + fi + + # linux-x64 - move AppImage and yml files + if [ -d "temp-linux-x64/release" ]; then + find temp-linux-x64/release \( -name "*.AppImage" -o -name "latest*.yml" \) -exec mv {} release/linux-x64/ \; || true + else + find temp-linux-x64 \( -name "*.AppImage" -o -name "latest*.yml" \) -exec mv {} release/linux-x64/ \; || true + fi + + # Create GitHub Release + - name: Prepare GitHub Release assets + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + # GitHub release assets must have unique filenames. + # Both mac folders contain latest-mac.yml, so stage assets with + # channel-specific manifest names for macOS and keep one compatibility file. + rm -rf gh-release-assets + mkdir -p gh-release-assets + + copy_file() { + local src_file="$1" + local dst_name="$2" + [ -f "$src_file" ] || return 0 + + if [ -e "gh-release-assets/$dst_name" ]; then + echo "Duplicate release asset name detected: $dst_name" + echo " existing: gh-release-assets/$dst_name" + echo " incoming: $src_file" + exit 1 + fi + + cp -f "$src_file" "gh-release-assets/$dst_name" + } + + copy_assets() { + local src_dir="$1" + local skip_name="${2:-}" + [ -d "$src_dir" ] || return 0 + + while IFS= read -r -d '' file; do + local name + name="$(basename "$file")" + + if [ -n "$skip_name" ] && [ "$name" = "$skip_name" ]; then + continue + fi + + copy_file "$file" "$name" + done < <(find "$src_dir" -maxdepth 1 -type f -print0) + } + + # Stage all normal artifacts (exclude duplicate mac manifest names first). + copy_assets "release/mac-arm64" "latest-mac.yml" + copy_assets "release/mac-intel" "latest-mac.yml" + copy_assets "release/win-x64" + copy_assets "release/linux-x64" + + # macOS updater channels configured in electron/main/update.ts: + # arm64 -> latest-arm64-mac.yml, x64 -> latest-x64-mac.yml + copy_file "release/mac-arm64/latest-mac.yml" "latest-arm64-mac.yml" + copy_file "release/mac-intel/latest-mac.yml" "latest-x64-mac.yml" + + # Compatibility manifest for clients still using default latest-mac.yml. + copy_file "release/mac-intel/latest-mac.yml" "latest-mac.yml" + + echo "Prepared GitHub release assets:" + ls -1 gh-release-assets + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + prerelease: true + files: | + gh-release-assets/* + + # Extract version from pre-release tag (e.g., pre-v0.0.85 -> 0.0.85) + - name: Extract version + if: startsWith(github.ref, 'refs/tags/') + id: version + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#pre-v} + VERSION=${VERSION#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + # Configure AWS credentials (skipped when AWS secrets not configured) + - name: Configure AWS credentials + id: aws-check + if: env.AWS_ACCESS_KEY_ID != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + # Upload to S3 - versioned directory (immutable; all files get long cache) + - name: Upload to S3 (versioned) + if: steps.aws-check.outcome == 'success' + run: | + VERSION="${{ steps.version.outputs.version }}" + BUCKET="${{ secrets.AWS_S3_BUCKET }}" + + echo "Uploading version $VERSION to S3 bucket: $BUCKET" + + declare -a targets=( + "release/mac-arm64 mac-arm64" + "release/mac-intel mac-intel" + "release/win-x64 win-x64" + "release/linux-x64 linux-x64" + ) + for t in "${targets[@]}"; do + src_dir="${t%% *}" + path_suffix="${t##* }" + if [ -d "$src_dir" ] && [ "$(ls -A "$src_dir")" ]; then + echo "Uploading $path_suffix files..." + aws s3 sync "$src_dir/" "s3://$BUCKET/pre-releases/v$VERSION/$path_suffix/" \ + --content-type "binary/octet-stream" \ + --metadata-directive REPLACE \ + --cache-control "public, max-age=31536000, immutable" + fi + done + + echo "Version $VERSION uploaded successfully" + + # Upload to S3 - latest directory + - name: Upload to S3 (latest) + if: steps.aws-check.outcome == 'success' + run: | + BUCKET="${{ secrets.AWS_S3_BUCKET }}" + + echo "Uploading latest release to S3 bucket: $BUCKET" + + # Upload macOS ARM64 files to latest + if [ -d "release/mac-arm64" ] && [ "$(ls -A release/mac-arm64)" ]; then + echo "Uploading latest macOS ARM64 files..." + aws s3 sync release/mac-arm64/ "s3://$BUCKET/pre-releases/latest/mac-arm64/" \ + --delete \ + --content-type "binary/octet-stream" \ + --metadata-directive REPLACE \ + --cache-control "public, max-age=300" \ + --exclude "*.yml" \ + --exclude "*.blockmap" + + aws s3 sync release/mac-arm64/ "s3://$BUCKET/pre-releases/latest/mac-arm64/" \ + --delete \ + --exclude "*" \ + --include "*.yml" \ + --include "*.blockmap" \ + --cache-control "public, max-age=300" + fi + + # Upload macOS Intel files to latest (if exists) + if [ -d "release/mac-intel" ] && [ "$(ls -A release/mac-intel)" ]; then + echo "Uploading latest macOS Intel files..." + aws s3 sync release/mac-intel/ "s3://$BUCKET/pre-releases/latest/mac-intel/" \ + --delete \ + --content-type "binary/octet-stream" \ + --metadata-directive REPLACE \ + --cache-control "public, max-age=300" \ + --exclude "*.yml" \ + --exclude "*.blockmap" + + aws s3 sync release/mac-intel/ "s3://$BUCKET/pre-releases/latest/mac-intel/" \ + --delete \ + --exclude "*" \ + --include "*.yml" \ + --include "*.blockmap" \ + --cache-control "public, max-age=300" + fi + + # Upload Windows files to latest + if [ -d "release/win-x64" ] && [ "$(ls -A release/win-x64)" ]; then + echo "Uploading latest Windows files..." + aws s3 sync release/win-x64/ "s3://$BUCKET/pre-releases/latest/win-x64/" \ + --delete \ + --content-type "binary/octet-stream" \ + --metadata-directive REPLACE \ + --cache-control "public, max-age=300" \ + --exclude "*.yml" \ + --exclude "*.blockmap" + + aws s3 sync release/win-x64/ "s3://$BUCKET/pre-releases/latest/win-x64/" \ + --delete \ + --exclude "*" \ + --include "*.yml" \ + --include "*.blockmap" \ + --cache-control "public, max-age=300" + fi + + # Upload Linux files to latest + if [ -d "release/linux-x64" ] && [ "$(ls -A release/linux-x64)" ]; then + echo "Uploading latest Linux files..." + aws s3 sync release/linux-x64/ "s3://$BUCKET/pre-releases/latest/linux-x64/" \ + --delete \ + --content-type "binary/octet-stream" \ + --metadata-directive REPLACE \ + --cache-control "public, max-age=300" \ + --exclude "*.yml" \ + --exclude "*.blockmap" + + aws s3 sync release/linux-x64/ "s3://$BUCKET/pre-releases/latest/linux-x64/" \ + --delete \ + --exclude "*" \ + --include "*.yml" \ + --include "*.blockmap" \ + --cache-control "public, max-age=300" + fi + + echo "Latest release uploaded successfully" + + # Generate download URLs + - name: Generate download URLs + if: steps.aws-check.outcome == 'success' + run: | + VERSION="${{ steps.version.outputs.version }}" + BUCKET="${{ secrets.AWS_S3_BUCKET }}" + REGION="${{ secrets.AWS_REGION }}" + + # Determine S3 endpoint based on region + if [[ "$REGION" == cn-* ]]; then + ENDPOINT="s3.$REGION.amazonaws.com.cn" + else + ENDPOINT="s3.$REGION.amazonaws.com" + fi + + echo "================================" + echo "Download URLs for version v$VERSION" + echo "================================" + echo "" + echo "Versioned URLs:" + + # Check which platforms exist and show URLs + if [ -d "release/mac-arm64" ] && [ "$(ls -A release/mac-arm64)" ]; then + echo "macOS (ARM64): https://$BUCKET.$ENDPOINT/pre-releases/v$VERSION/mac-arm64/" + fi + + if [ -d "release/mac-intel" ] && [ "$(ls -A release/mac-intel)" ]; then + echo "macOS (Intel): https://$BUCKET.$ENDPOINT/pre-releases/v$VERSION/mac-intel/" + fi + + if [ -d "release/win-x64" ] && [ "$(ls -A release/win-x64)" ]; then + echo "Windows (x64): https://$BUCKET.$ENDPOINT/pre-releases/v$VERSION/win-x64/" + fi + + if [ -d "release/linux-x64" ] && [ "$(ls -A release/linux-x64)" ]; then + echo "Linux (x64): https://$BUCKET.$ENDPOINT/pre-releases/v$VERSION/linux-x64/" + fi + + echo "" + echo "Latest URLs:" + + if [ -d "release/mac-arm64" ] && [ "$(ls -A release/mac-arm64)" ]; then + echo "macOS (ARM64): https://$BUCKET.$ENDPOINT/pre-releases/latest/mac-arm64/" + fi + + if [ -d "release/mac-intel" ] && [ "$(ls -A release/mac-intel)" ]; then + echo "macOS (Intel): https://$BUCKET.$ENDPOINT/pre-releases/latest/mac-intel/" + fi + + if [ -d "release/win-x64" ] && [ "$(ls -A release/win-x64)" ]; then + echo "Windows (x64): https://$BUCKET.$ENDPOINT/pre-releases/latest/win-x64/" + fi + + if [ -d "release/linux-x64" ] && [ "$(ls -A release/linux-x64)" ]; then + echo "Linux (x64): https://$BUCKET.$ENDPOINT/pre-releases/latest/linux-x64/" + fi + + echo "================================" diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py index 94ca0d91..53658a73 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -22,6 +22,7 @@ PLATFORM_ALIAS_MAPPING: Final[dict[str, str]] = { "grok": "openai-compatible-model", "ernie": "qianfan", "llama.cpp": "openai-compatible-model", + "orcarouter": "openai-compatible-model", } # Bedrock Converse requires a region during model initialization. diff --git a/docs/core/models/byok.md b/docs/core/models/byok.md index f83a9600..5adf6731 100644 --- a/docs/core/models/byok.md +++ b/docs/core/models/byok.md @@ -74,6 +74,7 @@ Eigent supports the following BYOK providers: | **Anthropic** | `https://api.anthropic.com/` | [Anthropic API Docs](https://docs.anthropic.com/en/api/getting-started) | | **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai/` | [Gemini API Docs](https://ai.google.dev/gemini-api/docs) | | **OpenRouter** | `https://openrouter.ai/api/v1` | [OpenRouter Docs](https://openrouter.ai/docs) | +| **OrcaRouter** | `https://api.orcarouter.ai/v1` | [OrcaRouter Docs](https://docs.orcarouter.ai/) | | **Qwen (Alibaba)** | `https://dashscope.aliyuncs.com/compatible-mode/v1` | [Qwen API Docs](https://help.aliyun.com/zh/dashscope/developer-reference/api-details) | | **DeepSeek** | `https://api.deepseek.com` | [DeepSeek API Docs](https://platform.deepseek.com/api-docs) | | **Minimax** | `https://api.minimax.io/v1` | [Minimax API Docs](https://platform.minimaxi.com/document/Announcement) | diff --git a/src/assets/model/orcarouter.svg b/src/assets/model/orcarouter.svg new file mode 100644 index 00000000..b5651c94 --- /dev/null +++ b/src/assets/model/orcarouter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/llm.ts b/src/lib/llm.ts index e06105bb..4d785f4b 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -42,6 +42,17 @@ export const INIT_PROVODERS: Provider[] = [ is_valid: false, model_type: '', }, + { + id: 'orcarouter', + name: 'OrcaRouter', + apiKey: '', + apiHost: 'https://api.orcarouter.ai/v1', + description: 'OrcaRouter model configuration.', + is_valid: false, + model_type: '', + modelsEndpoint: '/models', + websiteUrl: 'https://www.orcarouter.ai', + }, { id: 'openrouter', name: 'OpenRouter', diff --git a/src/lib/providerModels.ts b/src/lib/providerModels.ts new file mode 100644 index 00000000..46ee7a19 --- /dev/null +++ b/src/lib/providerModels.ts @@ -0,0 +1,155 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Fetch + parse helper for cloud providers that expose an OpenAI-compatible + * `/v1/models` listing endpoint (e.g. OrcaRouter). Returns chat-capable + * models grouped by their `/` prefix so the UI can render + * provider tabs. + */ + +/** Single model entry as returned by an OpenAI-compatible /v1/models call. */ +type RawModel = { + id: string; + architecture?: { + input_modalities?: string[] | null; + output_modalities?: string[] | null; + }; + context_length?: number; + max_completion_tokens?: number; +}; + +export type ProviderModelInfo = { + id: string; + contextLength?: number; + maxCompletionTokens?: number; +}; + +export type ProviderModelGroup = { + provider: string; + models: ProviderModelInfo[]; +}; + +/** + * Decide whether a model is chat-capable enough to surface in the dropdown. + * Keeps models that explicitly emit text, plus models that omit the + * architecture field entirely (some upstream listings — e.g. deepseek-reasoner + * — leave it null even though they are usable for chat). + * + * Filters out: TTS / image-only / video-only outputs. + */ +function isChatCapable(model: RawModel): boolean { + const arch = model.architecture; + if (!arch) return true; + const out = arch.output_modalities; + if (out == null) return true; + return out.includes('text'); +} + +/** Split `anthropic/claude-opus-4.6` into `["anthropic", "claude-opus-4.6"]`. */ +function splitProviderPrefix(id: string): [string, string] { + const idx = id.indexOf('/'); + if (idx <= 0) return ['', id]; + return [id.slice(0, idx), id.slice(idx + 1)]; +} + +/** + * Hit `${apiHost}${modelsEndpoint}` with a Bearer token and return chat-capable + * models grouped by provider prefix, sorted alphabetically by provider, with + * models within each group sorted alphabetically by id. + * + * Throws on network failure or non-2xx response with a user-readable message. + */ +export async function fetchProviderModels( + apiHost: string, + modelsEndpoint: string, + apiKey: string +): Promise { + if (!apiKey) { + throw new Error('API key is required to fetch model list.'); + } + const trimmedHost = apiHost.replace(/\/+$/, ''); + const url = `${trimmedHost}${modelsEndpoint}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch models: ${response.status} ${response.statusText}` + ); + } + const payload = await response.json(); + const data: RawModel[] = Array.isArray(payload?.data) ? payload.data : []; + + const grouped = new Map(); + for (const model of data) { + if (!model?.id || !isChatCapable(model)) continue; + const [provider] = splitProviderPrefix(model.id); + const bucket = provider || 'other'; + const info: ProviderModelInfo = { + id: model.id, + contextLength: model.context_length, + maxCompletionTokens: model.max_completion_tokens, + }; + const arr = grouped.get(bucket); + if (arr) arr.push(info); + else grouped.set(bucket, [info]); + } + + const groups: ProviderModelGroup[] = Array.from(grouped.entries()) + .map(([provider, models]) => ({ + provider, + models: models.sort((a, b) => a.id.localeCompare(b.id)), + })) + .sort((a, b) => a.provider.localeCompare(b.provider)); + + return groups; +} + +/** localStorage cache helpers — keyed per provider id to keep entries small. */ +const CACHE_KEY_PREFIX = 'eigent-provider-models-v1:'; + +export function loadCachedModels( + providerId: string +): ProviderModelGroup[] | null { + try { + const raw = localStorage.getItem(CACHE_KEY_PREFIX + providerId); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return parsed as ProviderModelGroup[]; + } catch { + return null; + } +} + +export function saveCachedModels( + providerId: string, + groups: ProviderModelGroup[] +): void { + try { + localStorage.setItem( + CACHE_KEY_PREFIX + providerId, + JSON.stringify(groups) + ); + } catch { + // localStorage may be unavailable (quota / private mode); silently ignore. + } +} diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index c5759d4a..33ec8b21 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -82,6 +82,7 @@ import moonshotImage from '@/assets/model/moonshot.svg'; import ollamaImage from '@/assets/model/ollama.svg'; import openaiImage from '@/assets/model/openai.svg'; import openrouterImage from '@/assets/model/openrouter.svg'; +import orcarouterImage from '@/assets/model/orcarouter.svg'; import qwenImage from '@/assets/model/qwen.svg'; import sglangImage from '@/assets/model/sglang.svg'; import vllmImage from '@/assets/model/vllm.svg'; @@ -104,6 +105,13 @@ import { toEndpointBaseUrl, VLLM_PROVIDER_ID, } from './localModels'; +import { ProviderModelCombobox } from './components/ProviderModelCombobox'; +import { + fetchProviderModels, + loadCachedModels, + saveCachedModels, + type ProviderModelGroup, +} from '@/lib/providerModels'; // Sidebar tab types type SidebarTab = @@ -212,6 +220,72 @@ export default function SettingModels() { const [ollamaEndpointAutoFixedOnce, setOllamaEndpointAutoFixedOnce] = useState(false); + // Per-cloud-provider model list state: { groups, loading, error } keyed by + // provider id. Populated for providers whose `INIT_PROVODERS` entry declares + // a `modelsEndpoint` (today: only OrcaRouter). + const [cloudModelsState, setCloudModelsState] = useState< + Record< + string, + { groups: ProviderModelGroup[]; loading: boolean; error: string | null } + > + >(() => { + const initial: Record< + string, + { groups: ProviderModelGroup[]; loading: boolean; error: string | null } + > = {}; + for (const p of INIT_PROVODERS) { + if (!p.modelsEndpoint) continue; + const cached = loadCachedModels(p.id); + if (cached) { + initial[p.id] = { groups: cached, loading: false, error: null }; + } + } + return initial; + }); + + const fetchCloudProviderModels = useCallback( + async (idx: number) => { + const item = items[idx]; + if (!item?.modelsEndpoint) return; + const apiKey = form[idx]?.apiKey; + const apiHost = form[idx]?.apiHost || item.apiHost; + if (!apiKey) return; + setCloudModelsState((prev) => ({ + ...prev, + [item.id]: { + groups: prev[item.id]?.groups || [], + loading: true, + error: null, + }, + })); + try { + const groups = await fetchProviderModels( + apiHost, + item.modelsEndpoint, + apiKey + ); + setCloudModelsState((prev) => ({ + ...prev, + [item.id]: { groups, loading: false, error: null }, + })); + saveCachedModels(item.id, groups); + } catch (err: any) { + setCloudModelsState((prev) => ({ + ...prev, + [item.id]: { + groups: prev[item.id]?.groups || [], + loading: false, + error: + typeof err?.message === 'string' + ? err.message + : 'Failed to fetch models.', + }, + })); + } + }, + [items, form] + ); + // Generic model fetcher driven by LOCAL_MODEL_OPTIONS config. // Only fetches for providers that define fetchPath and parseModels. const fetchModelsForPlatform = useCallback( @@ -1140,6 +1214,7 @@ export default function SettingModels() { anthropic: anthropicImage, gemini: geminiImage, openrouter: openrouterImage, + orcarouter: orcarouterImage, 'tongyi-qianwen': qwenImage, deepseek: deepseekImage, ernie: ernieImage, @@ -1495,6 +1570,19 @@ export default function SettingModels() {
{item.description} + {item.websiteUrl ? ( + <> + {' '} + + Visit {item.name} + + + ) : null}
@@ -1552,28 +1640,63 @@ export default function SettingModels() { }} /> {/* Model Type Setting */} - { - const v = e.target.value; - setForm((f) => - f.map((fi, i) => (i === idx ? { ...fi, model_type: v } : fi)) - ); - setErrors((errs) => - errs.map((er, i) => - i === idx ? { ...er, model_type: '' } : er - ) - ); - }} - /> + {item.modelsEndpoint ? ( + { + setForm((f) => + f.map((fi, i) => + i === idx ? { ...fi, model_type: v } : fi + ) + ); + setErrors((errs) => + errs.map((er, i) => + i === idx ? { ...er, model_type: '' } : er + ) + ); + }} + groups={cloudModelsState[item.id]?.groups || []} + loading={cloudModelsState[item.id]?.loading || false} + error={ + cloudModelsState[item.id]?.error ?? + errors[idx]?.model_type ?? + null + } + disabled={!form[idx].apiKey} + disabledReason="Enter API Key first." + onRefresh={() => void fetchCloudProviderModels(idx)} + triggerPlaceholder={`${t('setting.enter-your-model-type')} ${ + item.name + } ${t('setting.model-type')}`} + /> + ) : ( + { + const v = e.target.value; + setForm((f) => + f.map((fi, i) => + i === idx ? { ...fi, model_type: v } : fi + ) + ); + setErrors((errs) => + errs.map((er, i) => + i === idx ? { ...er, model_type: '' } : er + ) + ); + }} + /> + )} {/* externalConfig render */} {item.externalConfig && form[idx].externalConfig && diff --git a/src/pages/Agents/components/ProviderModelCombobox.tsx b/src/pages/Agents/components/ProviderModelCombobox.tsx new file mode 100644 index 00000000..c714b2aa --- /dev/null +++ b/src/pages/Agents/components/ProviderModelCombobox.tsx @@ -0,0 +1,275 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { useEffect, useMemo, useState } from 'react'; +import { ChevronDown, Loader2, RotateCcw } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import type { ProviderModelGroup } from '@/lib/providerModels'; + +type Props = { + /** Stable id used for "selected" comparison and aria-label scoping. */ + providerName: string; + /** Localized field title shown above the trigger (e.g. "Model Type Setting"). */ + title: string; + /** Currently saved model id. May be empty or a value not in `groups`. */ + value: string; + onChange: (value: string) => void; + groups: ProviderModelGroup[]; + loading: boolean; + error: string | null; + /** Disable everything when the user hasn't filled in an API key yet. */ + disabled: boolean; + /** Reason to show inside the popover when disabled (e.g. "Enter API Key first"). */ + disabledReason?: string; + onRefresh: () => void; + triggerPlaceholder?: string; +}; + +/** Split `anthropic/claude-opus-4.6` into `["anthropic", "claude-opus-4.6"]`. */ +function splitPrefix(id: string): [string, string] { + const idx = id.indexOf('/'); + if (idx <= 0) return ['', id]; + return [id.slice(0, idx), id.slice(idx + 1)]; +} + +export function ProviderModelCombobox({ + providerName, + title, + value, + onChange, + groups, + loading, + error, + disabled, + disabledReason, + onRefresh, + triggerPlaceholder, +}: Props) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + + // Default the active left-column entry to the provider of the saved value, + // falling back to the first provider with at least one model. + const initialActiveProvider = useMemo(() => { + if (value) { + const [prefix] = splitPrefix(value); + if (prefix && groups.some((g) => g.provider === prefix)) return prefix; + } + const first = groups.find((g) => g.models.length > 0); + return first?.provider ?? ''; + }, [value, groups]); + + const [activeProvider, setActiveProvider] = useState( + initialActiveProvider + ); + + // Keep activeProvider sane if `groups` changes (e.g. after a refresh). + useEffect(() => { + if (!activeProvider && initialActiveProvider) { + setActiveProvider(initialActiveProvider); + } else if ( + activeProvider && + groups.length > 0 && + !groups.some((g) => g.provider === activeProvider) + ) { + setActiveProvider(initialActiveProvider); + } + }, [groups, activeProvider, initialActiveProvider]); + + // Saved value not present in any group — surface a one-row "Current" section. + const orphanValue = useMemo(() => { + if (!value) return null; + const known = groups.some((g) => g.models.some((m) => m.id === value)); + return known ? null : value; + }, [value, groups]); + + // Models for the right column: active provider's models filtered by query. + const activeModels = useMemo(() => { + const group = groups.find((g) => g.provider === activeProvider); + if (!group) return []; + const q = query.trim().toLowerCase(); + if (!q) return group.models; + return group.models.filter((m) => m.id.toLowerCase().includes(q)); + }, [groups, activeProvider, query]); + + const hasAnyModels = groups.some((g) => g.models.length > 0); + + return ( +
+ {title ? ( +
+ {title} +
+ ) : null} + +
+ + + + + + + + + {!hasAnyModels && !orphanValue ? ( +
+ {loading + ? 'Loading...' + : disabled + ? disabledReason ?? 'Enter API Key first.' + : 'Click the refresh button to load models.'} +
+ ) : ( +
+ {/* Left column: provider list */} +
+ {orphanValue ? ( + + ) : null} + {groups.map((g) => ( + + ))} +
+ + {/* Right column: models for active provider */} + + {activeProvider === '__orphan__' && orphanValue ? ( + { + onChange(orphanValue); + setOpen(false); + }} + > + {orphanValue} + + ) : activeModels.length > 0 ? ( + activeModels.map((m) => { + const [, modelName] = splitPrefix(m.id); + return ( + { + onChange(m.id); + setOpen(false); + }} + className={cn( + value === m.id && 'bg-button-transparent-fill-hover' + )} + > + {modelName} + + ); + }) + ) : ( + + {query.trim() ? 'No matches.' : 'No models.'} + + )} + +
+ )} +
+
+
+ + +
+ + {error ? ( +
{error}
+ ) : null} +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 82eeea59..1c20bc04 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,6 +37,19 @@ export type Provider = { model_type?: string; prefer?: boolean; azure_deployment?: string; + /** + * If set, the provider exposes an OpenAI-compatible `/v1/models` listing + * endpoint. Value is the path relative to `apiHost` (e.g. `/v1/models`). + * Cards with this field render a searchable model dropdown grouped by + * provider prefix instead of a free-form text input. + */ + modelsEndpoint?: string; + /** + * Optional marketing / docs website. When set, the card renders a + * clickable link below the description (opened in the user's default + * external browser via Electron's `setWindowOpenHandler`). + */ + websiteUrl?: string; }; export type Model = {