mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-22 19:47:28 +00:00
Merge branch 'main' into feat/free-trial-usage-limit-banner
This commit is contained in:
commit
07249c9545
10 changed files with 1561 additions and 22 deletions
408
.github/workflows/pre-build-view.yml
vendored
Normal file
408
.github/workflows/pre-build-view.yml
vendored
Normal file
|
|
@ -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 "================================"
|
||||
551
.github/workflows/pre-build.yml
vendored
Normal file
551
.github/workflows/pre-build.yml
vendored
Normal file
|
|
@ -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 "================================"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
1
src/assets/model/orcarouter.svg
Normal file
1
src/assets/model/orcarouter.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 105 KiB |
|
|
@ -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',
|
||||
|
|
|
|||
155
src/lib/providerModels.ts
Normal file
155
src/lib/providerModels.ts
Normal file
|
|
@ -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 `<provider>/<model>` 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<ProviderModelGroup[]> {
|
||||
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<string, ProviderModelInfo[]>();
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
<div className="text-body-sm text-text-label">
|
||||
{item.description}
|
||||
{item.websiteUrl ? (
|
||||
<>
|
||||
{' '}
|
||||
<a
|
||||
href={item.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-information hover:underline"
|
||||
>
|
||||
Visit {item.name}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-4 px-6">
|
||||
|
|
@ -1552,28 +1640,63 @@ export default function SettingModels() {
|
|||
}}
|
||||
/>
|
||||
{/* Model Type Setting */}
|
||||
<Input
|
||||
id={`modelType-${item.id}`}
|
||||
size="default"
|
||||
title={t('setting.model-type-setting')}
|
||||
state={errors[idx]?.model_type ? 'error' : 'default'}
|
||||
note={errors[idx]?.model_type ?? undefined}
|
||||
placeholder={`${t('setting.enter-your-model-type')} ${
|
||||
item.name
|
||||
} ${t('setting.model-type')}`}
|
||||
value={form[idx].model_type}
|
||||
onChange={(e) => {
|
||||
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 ? (
|
||||
<ProviderModelCombobox
|
||||
providerName={item.name}
|
||||
title={t('setting.model-type-setting')}
|
||||
value={form[idx].model_type || ''}
|
||||
onChange={(v) => {
|
||||
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')}`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`modelType-${item.id}`}
|
||||
size="default"
|
||||
title={t('setting.model-type-setting')}
|
||||
state={errors[idx]?.model_type ? 'error' : 'default'}
|
||||
note={errors[idx]?.model_type ?? undefined}
|
||||
placeholder={`${t('setting.enter-your-model-type')} ${
|
||||
item.name
|
||||
} ${t('setting.model-type')}`}
|
||||
value={form[idx].model_type}
|
||||
onChange={(e) => {
|
||||
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 &&
|
||||
|
|
|
|||
275
src/pages/Agents/components/ProviderModelCombobox.tsx
Normal file
275
src/pages/Agents/components/ProviderModelCombobox.tsx
Normal file
|
|
@ -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<string>(
|
||||
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 (
|
||||
<div className="flex w-full flex-col">
|
||||
{title ? (
|
||||
<div className="mb-1.5 flex items-center gap-1 text-body-sm font-bold text-text-heading">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label={`${providerName} model type`}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border px-3 text-body-sm transition-colors',
|
||||
'border-input-border-default bg-input-bg-default text-text-heading',
|
||||
'hover:border-input-border-hover focus:border-input-border-focus focus:outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
error && 'border-input-border-cuation'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
!value && 'text-input-label-default'
|
||||
)}
|
||||
>
|
||||
{value || triggerPlaceholder || 'Select model'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search model..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
|
||||
{!hasAnyModels && !orphanValue ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-text-label">
|
||||
{loading
|
||||
? 'Loading...'
|
||||
: disabled
|
||||
? disabledReason ?? 'Enter API Key first.'
|
||||
: 'Click the refresh button to load models.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-80">
|
||||
{/* Left column: provider list */}
|
||||
<div className="w-[120px] flex-shrink-0 overflow-y-auto border-r border-border-secondary py-1">
|
||||
{orphanValue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveProvider('__orphan__')}
|
||||
className={cn(
|
||||
'flex w-full items-center px-3 py-1.5 text-left text-xs text-text-label transition-colors',
|
||||
activeProvider === '__orphan__'
|
||||
? 'bg-button-transparent-fill-hover text-text-heading'
|
||||
: 'hover:bg-button-transparent-fill-hover'
|
||||
)}
|
||||
>
|
||||
Current
|
||||
</button>
|
||||
) : null}
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.provider}
|
||||
type="button"
|
||||
onClick={() => setActiveProvider(g.provider)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-3 py-1.5 text-left text-xs transition-colors',
|
||||
activeProvider === g.provider
|
||||
? 'bg-button-transparent-fill-hover text-text-heading'
|
||||
: 'text-text-label hover:bg-button-transparent-fill-hover'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{g.provider}</span>
|
||||
<span className="ml-2 flex-shrink-0 text-text-label opacity-60">
|
||||
{g.models.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right column: models for active provider */}
|
||||
<CommandList className="max-h-80 flex-1">
|
||||
{activeProvider === '__orphan__' && orphanValue ? (
|
||||
<CommandItem
|
||||
value={orphanValue}
|
||||
onSelect={() => {
|
||||
onChange(orphanValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{orphanValue}</span>
|
||||
</CommandItem>
|
||||
) : activeModels.length > 0 ? (
|
||||
activeModels.map((m) => {
|
||||
const [, modelName] = splitPrefix(m.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
onSelect={() => {
|
||||
onChange(m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
value === m.id && 'bg-button-transparent-fill-hover'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{modelName}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CommandEmpty>
|
||||
{query.trim() ? 'No matches.' : 'No models.'}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={disabled || loading}
|
||||
aria-label={`Refresh ${providerName} models`}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-1.5 text-xs text-text-cuation">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue