diff --git a/.conductor/setup.sh b/.conductor/setup.sh new file mode 100755 index 0000000000..5e2f68bd81 --- /dev/null +++ b/.conductor/setup.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +# Conductor workspace setup script +# This script creates symlinks for .env and all node_modules directories + +LOG_FILE="$PWD/.conductor-setup.log" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$timestamp] $1" | tee -a "$LOG_FILE" +} + +log "==========================================" +log "Conductor Setup Script Started" +log "==========================================" +log "CONDUCTOR_ROOT_PATH: $CONDUCTOR_ROOT_PATH" +log "Current working directory: $PWD" +log "" + +# Check if CONDUCTOR_ROOT_PATH is set +if [ -z "$CONDUCTOR_ROOT_PATH" ]; then + log "ERROR: CONDUCTOR_ROOT_PATH is not set!" + exit 1 +fi + +# Symlink .env file +log "--- Symlinking .env file ---" +if [ -f "$CONDUCTOR_ROOT_PATH/.env" ]; then + ln -sf "$CONDUCTOR_ROOT_PATH/.env" .env + if [ -L ".env" ]; then + log "SUCCESS: .env symlinked -> $(readlink .env)" + else + log "ERROR: Failed to create .env symlink" + fi +else + log "WARNING: $CONDUCTOR_ROOT_PATH/.env does not exist, skipping" +fi + +log "" +log "--- Finding node_modules directories ---" + +# Find all node_modules directories (excluding .pnpm internal and .next build cache) +# NODE_MODULES_DIRS=$(find "$CONDUCTOR_ROOT_PATH" -maxdepth 3 -name "node_modules" -type d 2>/dev/null | grep -v ".pnpm" | grep -v ".next") + +# log "Found node_modules directories:" +# echo "$NODE_MODULES_DIRS" >> "$LOG_FILE" + +# log "" +# log "--- Creating node_modules symlinks ---" + +# # Counter for statistics +# total=0 +# success=0 +# failed=0 + +# for dir in $NODE_MODULES_DIRS; do +# total=$((total + 1)) + +# # Get relative path by removing CONDUCTOR_ROOT_PATH prefix +# rel_path="${dir#$CONDUCTOR_ROOT_PATH/}" +# parent_dir=$(dirname "$rel_path") + +# log "Processing: $rel_path" +# log " Source: $dir" +# log " Parent dir: $parent_dir" + +# # Create parent directory if needed +# if [ "$parent_dir" != "." ]; then +# if [ ! -d "$parent_dir" ]; then +# mkdir -p "$parent_dir" +# log " Created parent directory: $parent_dir" +# fi +# fi + +# # Create symlink +# ln -sf "$dir" "$rel_path" + +# # Verify symlink was created +# if [ -L "$rel_path" ]; then +# log " SUCCESS: $rel_path -> $(readlink "$rel_path")" +# success=$((success + 1)) +# else +# log " ERROR: Failed to create symlink for $rel_path" +# failed=$((failed + 1)) +# fi + +# log "" +# done + +log "==========================================" +log "Setup Complete" +log "==========================================" +log "Total node_modules: $total" +log "Successful symlinks: $success" +log "Failed symlinks: $failed" +log "" + +# List created symlinks for verification +log "--- Verification: Listing symlinks in workspace ---" +find . -maxdepth 1 -type l -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE" +find ./packages -maxdepth 2 -type l -name "node_modules" -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE" +find ./apps -maxdepth 2 -type l -name "node_modules" -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE" +find ./e2e -maxdepth 2 -type l -name "node_modules" -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE" + +log "" +log "Log file saved to: $LOG_FILE" +log "Setup script finished." diff --git a/.github/actions/desktop-build-setup/action.yml b/.github/actions/desktop-build-setup/action.yml new file mode 100644 index 0000000000..37c312e4d5 --- /dev/null +++ b/.github/actions/desktop-build-setup/action.yml @@ -0,0 +1,29 @@ +name: Desktop Build Setup +description: Setup Node.js, pnpm and install dependencies for desktop build + +inputs: + node-version: + description: Node.js version + required: true + +runs: + using: composite + steps: + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + package-manager-cache: false + + - name: Install dependencies + shell: bash + run: pnpm install --node-linker=hoisted + + - name: Install deps on Desktop + shell: bash + run: npm run install-isolated --prefix=./apps/desktop diff --git a/.github/actions/desktop-upload-artifacts/action.yml b/.github/actions/desktop-upload-artifacts/action.yml new file mode 100644 index 0000000000..f953f556b4 --- /dev/null +++ b/.github/actions/desktop-upload-artifacts/action.yml @@ -0,0 +1,46 @@ +name: Desktop Upload Artifacts +description: Rename macOS yml for multi-arch and upload build artifacts + +inputs: + artifact-name: + description: Name for the uploaded artifact + required: true + retention-days: + description: Number of days to retain artifacts + required: false + default: '5' + +runs: + using: composite + steps: + - name: Rename macOS latest-mac.yml for multi-architecture support + if: runner.os == 'macOS' + shell: bash + run: | + cd apps/desktop/release + if [ -f "latest-mac.yml" ]; then + SYSTEM_ARCH=$(uname -m) + if [[ "$SYSTEM_ARCH" == "arm64" ]]; then + ARCH_SUFFIX="arm64" + else + ARCH_SUFFIX="x64" + fi + mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml" + echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml" + fi + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.artifact-name }} + path: | + apps/desktop/release/latest* + apps/desktop/release/*.dmg* + apps/desktop/release/*.zip* + apps/desktop/release/*.exe* + apps/desktop/release/*.AppImage + apps/desktop/release/*.deb* + apps/desktop/release/*.snap* + apps/desktop/release/*.rpm* + apps/desktop/release/*.tar.gz* + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index a23aae2ba6..36477c9bb3 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -1,22 +1,59 @@ name: Release Desktop Beta +# ============================================ +# Beta/Nightly 频道发版工作流 +# ============================================ +# 触发条件: 发布包含 pre-release 标识的 release +# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1, v2.0.0-nightly.xxx +# +# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理 +# ============================================ + on: release: - types: [published] # 发布 release 时触发构建 + types: [published] -# 确保同一时间只运行一个相同的 workflow,取消正在进行的旧的运行 concurrency: group: ${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true -# Add default permissions permissions: read-all +env: + NODE_VERSION: '24.11.1' + jobs: + # ============================================ + # 检查是否为 Beta/Nightly 版本 (排除 Stable) + # ============================================ + check-beta: + name: Check if Beta/Nightly Release + runs-on: ubuntu-latest + outputs: + is_beta: ${{ steps.check.outputs.is_beta }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Check release tag + id: check + run: | + version="${{ github.event.release.tag_name }}" + version="${version#v}" + echo "version=${version}" >> $GITHUB_OUTPUT + + # Beta/Nightly 版本包含 beta/alpha/rc/nightly + if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]]; then + echo "is_beta=true" >> $GITHUB_OUTPUT + echo "✅ Beta/Nightly release detected: $version" + else + echo "is_beta=false" >> $GITHUB_OUTPUT + echo "⏭️ Skipping: $version is a stable release (handled by release-desktop-stable.yml)" + fi + test: name: Code quality check - # 添加 PR label 触发条件,只有添加了 trigger:build-desktop 标签的 PR 才会触发构建 - runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查 + needs: [check-beta] + if: needs.check-beta.outputs.is_beta == 'true' + runs-on: ubuntu-latest steps: - name: Checkout base uses: actions/checkout@v6 @@ -26,7 +63,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24.11.1 + node-version: ${{ env.NODE_VERSION }} package-manager-cache: false - name: Install bun @@ -40,44 +77,9 @@ jobs: - name: Lint run: bun run lint - version: - name: Determine version - runs-on: ubuntu-latest - outputs: - # 输出版本信息,供后续 job 使用 - version: ${{ steps.set_version.outputs.version }} - is_pr_build: ${{ steps.set_version.outputs.is_pr_build }} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24.11.1 - package-manager-cache: false - - # 主要逻辑:确定构建版本号 - - name: Set version - id: set_version - run: | - # 从 apps/desktop/package.json 读取基础版本号 - base_version=$(node -p "require('./apps/desktop/package.json').version") - - # Release 事件直接使用 release tag 作为版本号,去掉可能的 v 前缀 - version="${{ github.event.release.tag_name }}" - version="${version#v}" - echo "version=${version}" >> $GITHUB_OUTPUT - echo "📦 Release Version: ${version}" - - # 输出版本信息总结,方便在 GitHub Actions 界面查看 - - name: Version Summary - run: | - echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}" - build: - needs: [version, test] + needs: [check-beta] + if: needs.check-beta.outputs.is_beta == 'true' name: Build Desktop App runs-on: ${{ matrix.os }} strategy: @@ -88,117 +90,76 @@ jobs: with: fetch-depth: 0 - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup build environment + uses: ./.github/actions/desktop-build-setup with: - run_install: false + node-version: ${{ env.NODE_VERSION }} - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24.11.1 - package-manager-cache: false - - # node-linker=hoisted 模式将可以确保 asar 压缩可用 - - name: Install dependencies - run: pnpm install --node-linker=hoisted - - - name: Install deps on Desktop - run: npm run install-isolated --prefix=./apps/desktop - - # 设置 package.json 的版本号 - name: Set package version - run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} beta + run: npm run workflow:set-desktop-version ${{ needs.check-beta.outputs.version }} beta - # macOS 构建处理 + # macOS 构建 - name: Build artifact on macOS if: runner.os == 'macOS' run: npm run desktop:build env: + UPDATE_CHANNEL: beta APP_URL: http://localhost:3015 - DATABASE_URL: "postgresql://postgres@localhost:5432/postgres" - # 默认添加一个加密 SECRET - KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=" - # macOS 签名和公证配置 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # allow provisionally CSC_FOR_PULL_REQUEST: true APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }} NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }} - # Windows 平台构建处理 + # Windows 构建 - name: Build artifact on Windows if: runner.os == 'Windows' run: npm run desktop:build env: + UPDATE_CHANNEL: beta APP_URL: http://localhost:3015 - DATABASE_URL: "postgresql://postgres@localhost:5432/postgres" - KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=" + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }} NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }} - - # 将 TEMP 和 TMP 目录设置到 C 盘 TEMP: C:\temp TMP: C:\temp - # Linux 平台构建处理 + # Linux 构建 - name: Build artifact on Linux if: runner.os == 'Linux' run: npm run desktop:build env: + UPDATE_CHANNEL: beta APP_URL: http://localhost:3015 - DATABASE_URL: "postgresql://postgres@localhost:5432/postgres" - KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=" + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }} NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }} - # 处理 macOS latest-mac.yml 重命名 (避免多架构覆盖) - - name: Rename macOS latest-mac.yml for multi-architecture support - if: runner.os == 'macOS' - run: | - cd apps/desktop/release - if [ -f "latest-mac.yml" ]; then - # 使用系统架构检测,与 electron-builder 输出保持一致 - SYSTEM_ARCH=$(uname -m) - if [[ "$SYSTEM_ARCH" == "arm64" ]]; then - ARCH_SUFFIX="arm64" - else - ARCH_SUFFIX="x64" - fi - - mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml" - echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml (detected: $SYSTEM_ARCH)" - ls -la latest-mac-*.yml - else - echo "⚠️ latest-mac.yml not found, skipping rename" - ls -la latest*.yml || echo "No latest*.yml files found" - fi - - # 上传构建产物 (工作流处理重命名,不依赖 electron-builder 钩子) - - name: Upload artifact - uses: actions/upload-artifact@v6 + - name: Upload artifacts + uses: ./.github/actions/desktop-upload-artifacts with: - name: release-${{ matrix.os }} - path: | - apps/desktop/release/latest* - apps/desktop/release/*.dmg* - apps/desktop/release/*.zip* - apps/desktop/release/*.exe* - apps/desktop/release/*.AppImage - apps/desktop/release/*.deb* - apps/desktop/release/*.snap* - apps/desktop/release/*.rpm* - apps/desktop/release/*.tar.gz* - retention-days: 5 + artifact-name: release-${{ matrix.os }} + + # 汇总门禁: test/build 完成后决定是否继续 + gate: + needs: [check-beta, test, build] + if: ${{ needs.check-beta.outputs.is_beta == 'true' && needs.test.result == 'success' && needs.build.result == 'success' }} + name: Gate for publish + runs-on: ubuntu-latest + steps: + - name: Gate passed + run: echo "Gate passed" # 合并 macOS 多架构 latest-mac.yml 文件 merge-mac-files: - needs: [build, version] + needs: [gate] name: Merge macOS Release Files runs-on: ubuntu-latest permissions: @@ -210,7 +171,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24.11.1 + node-version: ${{ env.NODE_VERSION }} package-manager-cache: false - name: Install bun diff --git a/.github/workflows/release-desktop-stable.yml b/.github/workflows/release-desktop-stable.yml new file mode 100644 index 0000000000..f093991951 --- /dev/null +++ b/.github/workflows/release-desktop-stable.yml @@ -0,0 +1,461 @@ +name: Release Desktop Stable + +# ============================================ +# Stable 频道发版工作流 +# ============================================ +# 触发条件: 发布不含 pre-release 标识的 release (如 v2.0.0) +# +# 与 Beta 的区别: +# 1. 仅响应 stable 版本 tag (不含 beta/alpha/rc/nightly) +# 2. 使用 STABLE 专用的 Umami 配置 +# 3. 额外上传到 S3 更新服务器 +# 4. 构建时注入 UPDATE_SERVER_URL 让客户端从 S3 检查更新 +# +# 需要配置的 Secrets (S3 相关, 统一 UPDATE_ 前缀): +# - UPDATE_AWS_ACCESS_KEY_ID +# - UPDATE_AWS_SECRET_ACCESS_KEY +# - UPDATE_S3_BUCKET (S3 存储桶名称) +# - UPDATE_S3_REGION (可选, 默认 us-east-1) +# - UPDATE_S3_ENDPOINT (可选, 用于 R2/MinIO 等 S3 兼容服务) +# - UPDATE_SERVER_URL (客户端检查更新的 URL) +# ============================================ + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version to build (e.g., 2.0.0)' + required: true + type: string + build_mac: + description: 'Build macOS (ARM64)' + required: false + type: boolean + default: true + build_windows: + description: 'Build Windows' + required: false + type: boolean + default: true + build_linux: + description: 'Build Linux' + required: false + type: boolean + default: true + skip_s3_upload: + description: 'Skip S3 upload (for testing)' + required: false + type: boolean + default: true + skip_github_release: + description: 'Skip GitHub release upload (for testing)' + required: false + type: boolean + default: true + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +permissions: read-all + +env: + NODE_VERSION: '24.11.1' + +jobs: + # ============================================ + # 检查版本信息 + # ============================================ + check-stable: + name: Check Release Version + runs-on: ubuntu-latest + outputs: + is_stable: ${{ steps.check.outputs.is_stable }} + version: ${{ steps.check.outputs.version }} + is_manual: ${{ steps.check.outputs.is_manual }} + release_notes: ${{ steps.check.outputs.release_notes }} + steps: + - name: Check release info + id: check + run: | + # 判断触发方式 + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # 手动触发: 使用输入的版本号 + version="${{ inputs.version }}" + echo "is_manual=true" >> $GITHUB_OUTPUT + echo "is_stable=true" >> $GITHUB_OUTPUT + echo "version=${version}" >> $GITHUB_OUTPUT + echo "release_notes=" >> $GITHUB_OUTPUT + echo "🔧 Manual trigger: version=${version}" + else + # Release 触发: 从 tag 提取版本号 + version="${{ github.event.release.tag_name }}" + version="${version#v}" + echo "is_manual=false" >> $GITHUB_OUTPUT + echo "version=${version}" >> $GITHUB_OUTPUT + release_body="${{ github.event.release.body }}" + { + echo "release_notes<> $GITHUB_OUTPUT + + # 检查是否为 stable 版本 (不含 beta/alpha/rc/nightly) + if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]]; then + echo "is_stable=false" >> $GITHUB_OUTPUT + echo "⏭️ Skipping: $version is not a stable release" + else + echo "is_stable=true" >> $GITHUB_OUTPUT + echo "✅ Stable release detected: $version" + fi + fi + + # ============================================ + # 配置构建矩阵 (检查自托管 Runner) + # ============================================ + configure-build: + needs: [check-stable] + if: needs.check-stable.outputs.is_stable == 'true' + name: Configure Build Matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Generate Matrix + id: set-matrix + run: | + # 基础矩阵 + static_matrix='[]' + + # Windows + if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_windows }}" == "true" ]]; then + static_matrix=$(echo "$static_matrix" | jq -c '. + [{"os": "windows-2025", "name": "windows-2025"}]') + fi + + # Linux + if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_linux }}" == "true" ]]; then + static_matrix=$(echo "$static_matrix" | jq -c '. + [{"os": "ubuntu-latest", "name": "ubuntu-latest"}]') + fi + + # macOS (ARM64) + # 使用 GitHub Hosted Runner + if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_mac }}" == "true" ]]; then + echo "Using GitHub-Hosted Runner for macOS ARM64" + arm_entry='{"os": "macos-14", "name": "macos-arm64"}' + static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$arm_entry" '. + [$entry]') + fi + + # 输出 + echo "matrix={\"include\":$static_matrix}" >> $GITHUB_OUTPUT + + # ============================================ + # 多平台构建 + # ============================================ + build: + needs: [check-stable, configure-build] + if: needs.check-stable.outputs.is_stable == 'true' + name: Build Desktop App + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.configure-build.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup build environment + uses: ./.github/actions/desktop-build-setup + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Set package version + run: npm run workflow:set-desktop-version ${{ needs.check-stable.outputs.version }} stable + + # macOS 构建 + - name: Build artifact on macOS + if: runner.os == 'macOS' + run: npm run desktop:build + env: + UPDATE_CHANNEL: stable + UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }} + RELEASE_NOTES: ${{ needs.check-stable.outputs.release_notes }} + APP_URL: http://localhost:3015 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + CSC_FOR_PULL_REQUEST: true + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_STABLE_DESKTOP_BASE_URL }} + + # Windows 构建 + - name: Build artifact on Windows + if: runner.os == 'Windows' + run: npm run desktop:build + env: + UPDATE_CHANNEL: stable + UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }} + RELEASE_NOTES: ${{ needs.check-stable.outputs.release_notes }} + APP_URL: http://localhost:3015 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' + NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_STABLE_DESKTOP_BASE_URL }} + TEMP: C:\temp + TMP: C:\temp + + # Linux 构建 + - name: Build artifact on Linux + if: runner.os == 'Linux' + run: | + npm run desktop:build + tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C out . + env: + UPDATE_CHANNEL: stable + UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }} + RELEASE_NOTES: ${{ needs.check-stable.outputs.release_notes }} + APP_URL: http://localhost:3015 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' + NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_STABLE_DESKTOP_BASE_URL }} + + - name: Upload artifacts + uses: ./.github/actions/desktop-upload-artifacts + with: + artifact-name: release-${{ matrix.name }} + + # ============================================ + # 合并 macOS 多架构文件 + # ============================================ + merge-mac-files: + needs: [build, check-stable] + name: Merge macOS Release Files + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + package-manager-cache: false + + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Download artifacts + uses: actions/download-artifact@v7 + with: + path: release + pattern: release-* + merge-multiple: true + + - name: List downloaded artifacts + run: ls -R release + + - name: Install yaml only for merge step + run: | + cd scripts/electronWorkflow + if [ ! -f package.json ]; then + echo '{"name":"merge-mac-release","private":true}' > package.json + fi + bun add --no-save yaml@2.8.1 + + - name: Merge latest-mac.yml files + run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js + + - name: Upload artifacts with merged macOS files + uses: actions/upload-artifact@v6 + with: + name: merged-release + path: release/ + retention-days: 1 + + # ============================================ + # 发布到 GitHub Releases + # ============================================ + publish-github: + needs: [merge-mac-files, check-stable] + name: Publish to GitHub Release + runs-on: ubuntu-latest + # 手动触发时可选择跳过 + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip_github_release) }} + permissions: + contents: write + steps: + - name: Download merged artifacts + uses: actions/download-artifact@v7 + with: + name: merged-release + path: release + + - name: List final artifacts + run: ls -R release + + - name: Upload to Release + uses: softprops/action-gh-release@v1 + with: + # 手动触发时使用输入的版本号创建 tag + tag_name: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', needs.check-stable.outputs.version) || github.event.release.tag_name }} + # 手动触发时创建为 draft + draft: ${{ github.event_name == 'workflow_dispatch' }} + files: | + release/stable* + release/latest* + release/*.dmg* + release/*.zip* + release/*.exe* + release/*.AppImage + release/*.deb* + release/*.snap* + release/*.rpm* + release/*.tar.gz* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ============================================ + # 发布到 S3 更新服务器 + # ============================================ + # S3 目录结构: + # s3://bucket/ + # stable/ + # stable-mac.yml ← electron-updater 检查更新 (stable channel) + # stable.yml ← Windows (stable channel) + # stable-linux.yml ← Linux (stable channel) + # latest-mac.yml ← fallback for GitHub provider + # {version}/ ← 版本目录 + # *.dmg, *.zip, *.exe, ... + # ============================================ + publish-s3: + needs: [merge-mac-files, check-stable] + name: Publish to S3 + runs-on: ubuntu-latest + # 手动触发时可选择跳过 + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip_s3_upload) }} + steps: + - name: Download merged artifacts + uses: actions/download-artifact@v7 + with: + name: merged-release + path: release + + - name: List artifacts to upload + run: | + echo "📦 Artifacts to upload to S3:" + ls -lah release/ + echo "" + echo "📋 Version: ${{ needs.check-stable.outputs.version }}" + + - name: Upload to S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.UPDATE_S3_REGION || 'us-east-1' }} + S3_BUCKET: ${{ secrets.UPDATE_S3_BUCKET }} + S3_ENDPOINT: ${{ secrets.UPDATE_S3_ENDPOINT }} + VERSION: ${{ needs.check-stable.outputs.version }} + run: | + if [ -z "$S3_BUCKET" ]; then + echo "⚠️ UPDATE_S3_BUCKET is not configured, skipping S3 upload" + echo "" + echo "To enable S3 upload, configure the following secrets:" + echo " - UPDATE_AWS_ACCESS_KEY_ID" + echo " - UPDATE_AWS_SECRET_ACCESS_KEY" + echo " - UPDATE_S3_BUCKET" + echo " - UPDATE_S3_REGION (optional, defaults to us-east-1)" + echo " - UPDATE_S3_ENDPOINT (optional, for S3-compatible services)" + exit 0 + fi + + # 构建端点参数 + ENDPOINT_ARG="" + if [ -n "$S3_ENDPOINT" ]; then + ENDPOINT_ARG="--endpoint-url $S3_ENDPOINT" + echo "📡 Using custom S3 endpoint: $S3_ENDPOINT" + fi + + echo "🚀 Uploading to S3 bucket: $S3_BUCKET" + echo "📁 Target path: s3://$S3_BUCKET/stable/" + echo "" + + # 1. 上传安装包到版本目录 + echo "📦 Uploading release files to s3://$S3_BUCKET/stable/$VERSION/" + for file in release/*.dmg release/*.zip release/*.exe release/*.AppImage release/*.deb release/*.rpm release/*.snap release/*.tar.gz; do + if [ -f "$file" ]; then + filename=$(basename "$file") + echo " ↗️ $filename" + aws s3 cp "$file" "s3://$S3_BUCKET/stable/$VERSION/$filename" $ENDPOINT_ARG + fi + done + + # 2. 创建 stable*.yml (从 latest*.yml 复制,并修改 URL 加上版本目录前缀) + # electron-updater 在 channel=stable 时会找 stable-mac.yml + # S3 目录结构: stable/{version}/xxx.dmg,所以 URL 需要加上 {version}/ 前缀 + echo "" + echo "📋 Creating stable*.yml files from latest*.yml..." + for yml in release/latest*.yml; do + if [ -f "$yml" ]; then + stable_name=$(basename "$yml" | sed 's/latest/stable/') + # 复制并修改 URL: 给所有 url 字段加上版本目录前缀 + # url: xxx.dmg -> url: {VERSION}/xxx.dmg + sed "s|url: |url: $VERSION/|g" "$yml" > "release/$stable_name" + echo " 📄 Created $stable_name from $(basename $yml) with URL prefix: $VERSION/" + fi + done + + # 3. 创建 renderer manifest (用于验证 renderer tar 完整性) + echo "" + echo "📋 Creating renderer manifest..." + RENDERER_TAR="release/lobehub-renderer.tar.gz" + if [ -f "$RENDERER_TAR" ]; then + RENDERER_SHA512=$(shasum -a 512 "$RENDERER_TAR" | awk '{print $1}' | xxd -r -p | base64) + RENDERER_SIZE=$(stat -f%z "$RENDERER_TAR" 2>/dev/null || stat -c%s "$RENDERER_TAR") + RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") + echo "version: $VERSION" > "release/stable-renderer.yml" + echo "files:" >> "release/stable-renderer.yml" + echo " - url: $VERSION/lobehub-renderer.tar.gz" >> "release/stable-renderer.yml" + echo " sha512: $RENDERER_SHA512" >> "release/stable-renderer.yml" + echo " size: $RENDERER_SIZE" >> "release/stable-renderer.yml" + echo "path: $VERSION/lobehub-renderer.tar.gz" >> "release/stable-renderer.yml" + echo "sha512: $RENDERER_SHA512" >> "release/stable-renderer.yml" + echo "releaseDate: '$RELEASE_DATE'" >> "release/stable-renderer.yml" + echo " 📄 Created stable-renderer.yml with SHA512 checksum" + else + echo " ⚠️ Renderer tar not found, skipping manifest creation" + fi + + # 4. 上传 manifest 到根目录和版本目录 + # 根目录: electron-updater 需要,会被每次发版覆盖 + # 版本目录: 作为存档保留 + echo "" + echo "📋 Uploading manifest files..." + for yml in release/stable*.yml release/latest*.yml; do + if [ -f "$yml" ]; then + filename=$(basename "$yml") + echo " ↗️ $filename -> s3://$S3_BUCKET/stable/$filename" + aws s3 cp "$yml" "s3://$S3_BUCKET/stable/$filename" $ENDPOINT_ARG + echo " ↗️ $filename -> s3://$S3_BUCKET/stable/$VERSION/$filename (archive)" + aws s3 cp "$yml" "s3://$S3_BUCKET/stable/$VERSION/$filename" $ENDPOINT_ARG + fi + done + + echo "" + echo "✅ S3 upload completed!" + echo "" + echo "📋 Files in s3://$S3_BUCKET/stable/:" + aws s3 ls "s3://$S3_BUCKET/stable/" $ENDPOINT_ARG || true + echo "" + echo "📋 Files in s3://$S3_BUCKET/stable/$VERSION/:" + aws s3 ls "s3://$S3_BUCKET/stable/$VERSION/" $ENDPOINT_ARG || true diff --git a/apps/desktop/dev-app-update.yml b/apps/desktop/dev-app-update.yml index 3deac76f44..0d3c70a026 100644 --- a/apps/desktop/dev-app-update.yml +++ b/apps/desktop/dev-app-update.yml @@ -1,6 +1,16 @@ +# 开发环境更新配置 +# 可选择 GitHub 或 Generic provider 进行测试 + +# 方式1: GitHub Provider (默认) provider: github owner: lobehub repo: lobe-chat updaterCacheDirName: electron-app-updater allowPrerelease: true channel: nightly + +# 方式2: Generic Provider (测试自定义服务器) +# 取消下面的注释,注释掉上面的 GitHub 配置 +# provider: generic +# url: http://localhost:8080 +# updaterCacheDirName: electron-app-updater diff --git a/apps/desktop/electron-builder.mjs b/apps/desktop/electron-builder.mjs index 0d5c7e62ed..eb382b4d3b 100644 --- a/apps/desktop/electron-builder.mjs +++ b/apps/desktop/electron-builder.mjs @@ -10,19 +10,47 @@ dotenv.config(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const packageJSON = JSON.parse( - await fs.readFile(path.join(__dirname, 'package.json'), 'utf8') -); +const packageJSON = JSON.parse(await fs.readFile(path.join(__dirname, 'package.json'), 'utf8')); const channel = process.env.UPDATE_CHANNEL; const arch = os.arch(); const hasAppleCertificate = Boolean(process.env.CSC_LINK); +// 自定义更新服务器 URL (用于 stable 频道) +const updateServerUrl = process.env.UPDATE_SERVER_URL; + console.log(`🚄 Build Version ${packageJSON.version}, Channel: ${channel}`); console.log(`🏗️ Building for architecture: ${arch}`); const isNightly = channel === 'nightly'; const isBeta = packageJSON.name.includes('beta'); +const isStable = !isNightly && !isBeta; + +// 根据 channel 配置不同的 publish provider +// - Stable + UPDATE_SERVER_URL: 使用 generic (自定义 HTTP 服务器) +// - Beta/Nightly: 仅使用 GitHub +const getPublishConfig = () => { + const githubProvider = { + owner: 'lobehub', + provider: 'github', + repo: 'lobe-chat', + }; + + // Stable channel: 使用自定义服务器 (generic provider) + if (isStable && updateServerUrl) { + console.log(`📦 Stable channel: Using generic provider (${updateServerUrl})`); + const genericProvider = { + provider: 'generic', + url: updateServerUrl, + }; + // 同时发布到自定义服务器和 GitHub (GitHub 作为备用/镜像) + return [genericProvider, githubProvider]; + } + + // Beta/Nightly channel: 仅使用 GitHub + console.log(`📦 ${channel || 'default'} channel: Using GitHub provider`); + return [githubProvider]; +}; // Keep only these Electron Framework localization folders (*.lproj) // (aligned with previous Electron Forge build config) @@ -221,13 +249,15 @@ const config = { schemes: [protocolScheme], }, ], - publish: [ - { - owner: 'lobehub', - provider: 'github', - repo: 'lobe-chat', - }, - ], + publish: getPublishConfig(), + + // Release notes 配置 + // 可以通过环境变量 RELEASE_NOTES 传入,或从文件读取 + // 这会被写入 latest-mac.yml / latest.yml 中,供 generic provider 使用 + releaseInfo: { + releaseNotes: process.env.RELEASE_NOTES || undefined, + }, + win: { executableName: 'LobeHub', }, diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index d376d62652..f440a40206 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -21,11 +21,12 @@ export default defineConfig({ }, sourcemap: isDev ? 'inline' : false, }, - // 这里是关键:在构建时进行文本替换 + define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), - 'process.env.OFFICIAL_CLOUD_SERVER': JSON.stringify(process.env.OFFICIAL_CLOUD_SERVER), 'process.env.UPDATE_CHANNEL': JSON.stringify(process.env.UPDATE_CHANNEL), + + 'process.env.UPDATE_SERVER_URL': JSON.stringify(process.env.UPDATE_SERVER_URL), }, resolve: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b33a901ae0..d5a0394a97 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -36,7 +36,8 @@ "stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix", "test": "vitest --run", "type-check": "tsgo --noEmit -p tsconfig.json", - "typecheck": "tsgo --noEmit -p tsconfig.json" + "typecheck": "tsgo --noEmit -p tsconfig.json", + "update-server": "sh scripts/update-test/run-test.sh" }, "dependencies": { "electron-updater": "^6.6.2", diff --git a/apps/desktop/scripts/update-test/README.md b/apps/desktop/scripts/update-test/README.md new file mode 100644 index 0000000000..f757ad965c --- /dev/null +++ b/apps/desktop/scripts/update-test/README.md @@ -0,0 +1,222 @@ +# 本地更新测试指南 + +本目录包含用于在本地测试 Desktop 应用更新功能的工具和脚本。 + +## 目录结构 + +``` +scripts/update-test/ +├── README.md # 本文档 +├── setup.sh # 一键设置脚本 +├── start-server.sh # 启动本地更新服务器 +├── stop-server.sh # 停止本地更新服务器 +├── generate-manifest.sh # 生成 manifest 和目录结构 +├── dev-app-update.local.yml # 本地测试用的更新配置模板 +└── server/ # 本地服务器文件目录 (自动生成) + ├── stable/ # stable 渠道 + │ ├── latest-mac.yml + │ └── {version}/ + │ ├── xxx.dmg + │ └── xxx.zip + ├── beta/ # beta 渠道 + │ └── ... + └── nightly/ # nightly 渠道 + └── ... +``` + +## 快速开始 + +### 1. 首次设置 + +```bash +cd apps/desktop/scripts/update-test +chmod +x *.sh +./setup.sh +``` + +### 2. 构建测试包 + +```bash +# 回到 desktop 目录 +cd ../.. + +# 构建未签名的本地测试包 +bun run build +bun run build-local +``` + +### 3. 生成更新文件 + +```bash +cd scripts/update-test + +# 从 release 目录自动检测并生成 (默认 stable 渠道) +./generate-manifest.sh --from-release + +# 指定版本号 (用于模拟更新) +./generate-manifest.sh --from-release -v 0.0.1 + +# 指定渠道 +./generate-manifest.sh --from-release -c beta -v 2.1.0-beta.1 +``` + +### 4. 启动本地服务器 + +```bash +./start-server.sh +# 服务器默认在 http://localhost:8787 启动 +``` + +### 5. 配置应用使用本地服务器 + +```bash +# 复制本地测试配置到 desktop 根目录 +cp dev-app-update.local.yml ../../dev-app-update.yml + +# 或者直接编辑 dev-app-update.yml,确保 URL 指向正确的渠道: +# url: http://localhost:8787/stable +``` + +### 6. 运行应用测试 + +```bash +cd ../.. +bun run dev +``` + +### 7. 测试完成后 + +```bash +cd scripts/update-test +./stop-server.sh + +# 恢复默认的 dev-app-update.yml(可选) +cd ../.. +git checkout dev-app-update.yml +``` + +--- + +## generate-manifest.sh 用法 + +```bash +用法: ./generate-manifest.sh [选项] + +选项: + -v, --version VERSION 指定版本号 (例如: 2.0.1) + -c, --channel CHANNEL 指定渠道 (stable|beta|nightly, 默认: stable) + -d, --dmg FILE 指定 DMG 文件名 + -z, --zip FILE 指定 ZIP 文件名 + -n, --notes TEXT 指定 release notes + -f, --from-release 从 release 目录自动复制文件 + -h, --help 显示帮助信息 + +示例: + ./generate-manifest.sh --from-release + ./generate-manifest.sh -v 2.0.1 -c stable --from-release + ./generate-manifest.sh -v 2.1.0-beta.1 -c beta --from-release +``` + +--- + +## 详细说明 + +### 关于 macOS 签名验证 + +本地测试的包未经签名和公证,macOS 会阻止运行。解决方法: + +#### 方法 1:临时禁用 Gatekeeper(推荐) + +```bash +# 禁用 +sudo spctl --master-disable + +# 测试完成后务必重新启用! +sudo spctl --master-enable +``` + +#### 方法 2:手动移除隔离属性 + +```bash +# 对下载的 DMG 或解压后的 .app 执行 +xattr -cr /path/to/YourApp.app +``` + +#### 方法 3:系统偏好设置 + +1. 打开「系统偏好设置」→「安全性与隐私」→「通用」 +2. 点击「仍要打开」允许未签名的应用 + +### 自定义 Release Notes + +编辑 `server/{channel}/latest-mac.yml` 中的 `releaseNotes` 字段: + +```yaml +releaseNotes: | + ## 🎉 v2.0.1 测试版本 + + ### ✨ 新功能 + - 功能 A + - 功能 B + + ### 🐛 修复 + - 修复问题 X +``` + +### 测试不同场景 + +| 场景 | 操作 | +| ------------ | ----------------------------------------------------- | +| 有新版本可用 | 设置 manifest 中的 `version` 大于当前应用版本 (0.0.0) | +| 无新版本 | 设置 `version` 小于或等于当前版本 | +| 下载失败 | 删除 server/{channel}/{version}/ 中的 DMG 文件 | +| 网络错误 | 停止本地服务器 | +| 测试不同渠道 | 修改 dev-app-update.yml 中的 URL 指向不同渠道 | + +### 环境变量 + +也可以通过环境变量指定更新服务器: + +```bash +UPDATE_SERVER_URL=http://localhost:8787/stable bun run dev +``` + +--- + +## 故障排除 + +### 1. 服务器启动失败 + +```bash +# 检查端口是否被占用 +lsof -i :8787 + +# 使用其他端口 +PORT=9000 ./start-server.sh +``` + +### 2. 更新检测不到 + +- 确认 `dev-app-update.yml` 中的 URL 包含渠道路径 (如 `/stable`) +- 确认 manifest 中的版本号大于当前版本 (0.0.0) +- 查看日志:`tail -f ~/Library/Logs/lobehub-desktop-dev/main.log` + +### 3. 请求了错误的 yml 文件 + +- 如果请求的是 `stable-mac.yml` 而不是 `latest-mac.yml`,说明代码中设置了 channel +- 确保在 dev 模式下运行,代码不会设置 `autoUpdater.channel` + +### 4. 下载后无法安装 + +- 确认已禁用 Gatekeeper 或移除隔离属性 +- 确认 DMG 文件完整 + +--- + +## 注意事项 + +⚠️ **安全提醒**: + +1. 测试完成后务必重新启用 Gatekeeper +2. 这些脚本仅用于本地开发测试 +3. 不要将未签名的包分发给其他用户 diff --git a/apps/desktop/scripts/update-test/dev-app-update.local.yml b/apps/desktop/scripts/update-test/dev-app-update.local.yml new file mode 100644 index 0000000000..e01f6645d7 --- /dev/null +++ b/apps/desktop/scripts/update-test/dev-app-update.local.yml @@ -0,0 +1,18 @@ +# 本地更新测试配置 +# 将此文件复制到 apps/desktop/dev-app-update.yml 以使用本地服务器测试 +# +# 使用方法: +# cp scripts/update-test/dev-app-update.local.yml dev-app-update.yml +# +# 恢复默认配置: +# git checkout dev-app-update.yml + +provider: generic +# URL 格式: http://localhost:8787/{channel} +# 可选渠道: stable, beta, nightly +url: http://localhost:8787/stable +updaterCacheDirName: lobehub-desktop-local-test + +# 设置 channel 为 stable 以匹配生产环境行为 +# stable channel 会找 stable-mac.yml +channel: stable diff --git a/apps/desktop/scripts/update-test/generate-manifest.sh b/apps/desktop/scripts/update-test/generate-manifest.sh new file mode 100755 index 0000000000..3dca01e12d --- /dev/null +++ b/apps/desktop/scripts/update-test/generate-manifest.sh @@ -0,0 +1,277 @@ +#!/bin/bash + +# ============================================ +# 生成更新 manifest 文件 ({channel}-mac.yml) +# +# 目录结构: +# server/ +# {channel}/ +# {channel}-mac.yml (e.g., stable-mac.yml) +# {version}/ +# xxx.dmg +# xxx.zip +# ============================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_DIR="$SCRIPT_DIR/server" +DESKTOP_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +RELEASE_DIR="$DESKTOP_DIR/release" + +# 默认值 +VERSION="" +CHANNEL="stable" +DMG_FILE="" +ZIP_FILE="" +RELEASE_NOTES="" +FROM_RELEASE=false + +# 帮助信息 +show_help() { + echo "用法: $0 [选项]" + echo "" + echo "选项:" + echo " -v, --version VERSION 指定版本号 (例如: 2.0.1)" + echo " -c, --channel CHANNEL 指定渠道 (stable|beta|nightly, 默认: stable)" + echo " -d, --dmg FILE 指定 DMG 文件名" + echo " -z, --zip FILE 指定 ZIP 文件名" + echo " -n, --notes TEXT 指定 release notes" + echo " -f, --from-release 从 release 目录自动复制文件" + echo " -h, --help 显示帮助信息" + echo "" + echo "示例:" + echo " $0 --from-release" + echo " $0 -v 2.0.1 -c stable -d LobeHub-2.0.1-arm64.dmg" + echo " $0 -v 2.1.0-beta.1 -c beta --from-release" + echo "" + echo "生成的目录结构:" + echo " server/" + echo " {channel}/" + echo " {channel}-mac.yml (e.g., stable-mac.yml)" + echo " {version}/" + echo " xxx.dmg" + echo " xxx.zip" + echo "" +} + +# 计算 SHA512 +calc_sha512() { + local file="$1" + if [ -f "$file" ]; then + shasum -a 512 "$file" | awk '{print $1}' | xxd -r -p | base64 + else + echo "placeholder-sha512-file-not-found" + fi +} + +# 获取文件大小 +get_file_size() { + local file="$1" + if [ -f "$file" ]; then + stat -f%z "$file" 2>/dev/null || stat --printf="%s" "$file" 2>/dev/null || echo "0" + else + echo "0" + fi +} + +# 解析参数 +FROM_RELEASE=false +while [[ $# -gt 0 ]]; do + case $1 in + -v|--version) + VERSION="$2" + shift 2 + ;; + -c|--channel) + CHANNEL="$2" + shift 2 + ;; + -d|--dmg) + DMG_FILE="$2" + shift 2 + ;; + -z|--zip) + ZIP_FILE="$2" + shift 2 + ;; + -n|--notes) + RELEASE_NOTES="$2" + shift 2 + ;; + -f|--from-release) + FROM_RELEASE=true + shift + ;; + -h|--help) + show_help + exit 0 + ;; + *) + echo "未知参数: $1" + show_help + exit 1 + ;; + esac +done + +echo "🔧 生成更新 manifest 文件..." +echo " 渠道: $CHANNEL" +echo "" + +# 渠道目录 +CHANNEL_DIR="$SERVER_DIR/$CHANNEL" + +# 自动从 release 目录检测和复制 +if [ "$FROM_RELEASE" = true ]; then + echo "📂 从 release 目录检测文件..." + + if [ ! -d "$RELEASE_DIR" ]; then + echo "❌ release 目录不存在: $RELEASE_DIR" + echo " 请先运行构建命令" + exit 1 + fi + + # 查找 DMG 文件 + DMG_PATH=$(find "$RELEASE_DIR" -maxdepth 1 -name "*.dmg" -type f | head -1) + if [ -n "$DMG_PATH" ]; then + DMG_FILE=$(basename "$DMG_PATH") + echo " 找到 DMG: $DMG_FILE" + fi + + # 查找 ZIP 文件 + ZIP_PATH=$(find "$RELEASE_DIR" -maxdepth 1 -name "*-mac.zip" -type f | head -1) + if [ -n "$ZIP_PATH" ]; then + ZIP_FILE=$(basename "$ZIP_PATH") + echo " 找到 ZIP: $ZIP_FILE" + fi + + # 从文件名提取版本号 + # 文件名格式: lobehub-desktop-dev-0.0.0-arm64.dmg + # 版本号格式: x.y.z 或 x.y.z-beta.1 等 + if [ -z "$VERSION" ] && [ -n "$DMG_FILE" ]; then + # 先尝试匹配带预发布标签的版本 (如 2.0.0-beta.1) + VERSION=$(echo "$DMG_FILE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|nightly)\.[0-9]+' | head -1) + # 如果没有预发布标签,只匹配基本版本号 (如 2.0.0) + if [ -z "$VERSION" ]; then + VERSION=$(echo "$DMG_FILE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + fi + fi +fi + +# 设置默认版本号 +if [ -z "$VERSION" ]; then + VERSION="0.0.1" + echo "⚠️ 未指定版本号,使用默认值: $VERSION" +fi + +# 版本目录 +VERSION_DIR="$CHANNEL_DIR/$VERSION" + +# 创建目录结构 +echo "" +echo "📁 创建目录结构..." +mkdir -p "$VERSION_DIR" +echo " $CHANNEL_DIR/" +echo " $VERSION/" + +# 复制文件到版本目录 +if [ "$FROM_RELEASE" = true ]; then + if [ -n "$DMG_PATH" ] && [ -f "$DMG_PATH" ]; then + echo " 复制 $DMG_FILE -> $VERSION/" + cp "$DMG_PATH" "$VERSION_DIR/" + fi + + if [ -n "$ZIP_PATH" ] && [ -f "$ZIP_PATH" ]; then + echo " 复制 $ZIP_FILE -> $VERSION/" + cp "$ZIP_PATH" "$VERSION_DIR/" + fi +fi + +# 设置默认 release notes +if [ -z "$RELEASE_NOTES" ]; then + RELEASE_NOTES="## 🎉 v$VERSION 本地测试版本 + +这是一个用于本地测试更新功能的模拟版本。 + +### ✨ 新功能 +- 测试自动更新功能 +- 验证更新流程 + +### 🐛 修复 +- 本地测试环境配置" +fi + +# 生成 {channel}-mac.yml (e.g., stable-mac.yml) +MANIFEST_FILE="$CHANNEL-mac.yml" +echo "" +echo "📝 生成 $CHANNEL/$MANIFEST_FILE..." + +DMG_SHA512="" +DMG_SIZE="0" +ZIP_SHA512="" +ZIP_SIZE="0" + +if [ -n "$DMG_FILE" ] && [ -f "$VERSION_DIR/$DMG_FILE" ]; then + echo " 计算 DMG SHA512..." + DMG_SHA512=$(calc_sha512 "$VERSION_DIR/$DMG_FILE") + DMG_SIZE=$(get_file_size "$VERSION_DIR/$DMG_FILE") +fi + +if [ -n "$ZIP_FILE" ] && [ -f "$VERSION_DIR/$ZIP_FILE" ]; then + echo " 计算 ZIP SHA512..." + ZIP_SHA512=$(calc_sha512 "$VERSION_DIR/$ZIP_FILE") + ZIP_SIZE=$(get_file_size "$VERSION_DIR/$ZIP_FILE") +fi + +# 写入 manifest 文件 (放在渠道目录下) +cat > "$CHANNEL_DIR/$MANIFEST_FILE" << EOF +version: $VERSION +files: +EOF + +if [ -n "$DMG_FILE" ]; then +cat >> "$CHANNEL_DIR/$MANIFEST_FILE" << EOF + - url: $VERSION/$DMG_FILE + sha512: ${DMG_SHA512:-placeholder} + size: $DMG_SIZE +EOF +fi + +if [ -n "$ZIP_FILE" ]; then +cat >> "$CHANNEL_DIR/$MANIFEST_FILE" << EOF + - url: $VERSION/$ZIP_FILE + sha512: ${ZIP_SHA512:-placeholder} + size: $ZIP_SIZE +EOF +fi + +cat >> "$CHANNEL_DIR/$MANIFEST_FILE" << EOF +path: $VERSION/${DMG_FILE:-LobeHub-$VERSION-arm64.dmg} +sha512: ${DMG_SHA512:-placeholder} +releaseDate: '$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")' +releaseNotes: | +$(echo "$RELEASE_NOTES" | sed 's/^/ /') +EOF + +echo "✅ 已生成 $CHANNEL_DIR/$MANIFEST_FILE" + +# 显示生成的文件内容 +echo "" +echo "📋 文件内容:" +echo "----------------------------------------" +cat "$CHANNEL_DIR/$MANIFEST_FILE" +echo "----------------------------------------" + +# 显示目录结构 +echo "" +echo "📁 目录结构:" +find "$CHANNEL_DIR" -type f | sed "s|$SERVER_DIR/||" | sort + +echo "" +echo "✅ 完成!" +echo "" +echo "下一步:" +echo " 1. 启动服务器: ./start-server.sh" +echo " 2. 确认 dev-app-update.yml 的 URL 为: http://localhost:8787/$CHANNEL" +echo " 3. 运行应用: cd ../.. && bun run dev" diff --git a/apps/desktop/scripts/update-test/run-test.sh b/apps/desktop/scripts/update-test/run-test.sh new file mode 100755 index 0000000000..74a081961a --- /dev/null +++ b/apps/desktop/scripts/update-test/run-test.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +# ============================================ +# 一键启动本地更新测试 +# ============================================ +# +# 此脚本会: +# 1. 设置测试环境 +# 2. 从 release 目录复制文件 +# 3. 生成 manifest +# 4. 启动本地服务器 +# 5. 配置应用使用本地服务器 +# 6. 启动应用 +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" + +echo "============================================" +echo "🧪 本地更新测试 - 一键启动" +echo "============================================" +echo "" + +# 检查 macOS Gatekeeper 状态 +check_gatekeeper() { + if command -v spctl &> /dev/null; then + STATUS=$(spctl --status 2>&1 || true) + if [[ "$STATUS" == *"enabled"* ]]; then + echo "⚠️ 警告: macOS Gatekeeper 已启用" + echo "" + echo " 未签名的应用可能无法安装。你可以:" + echo " 1. 临时禁用: sudo spctl --master-disable" + echo " 2. 或者在安装后手动允许应用" + echo "" + read -p "是否继续?[y/N] " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + else + echo "✅ Gatekeeper 已禁用,可以安装未签名应用" + fi + fi +} + +# 步骤 1: 设置 +echo "📦 步骤 1/5: 设置测试环境..." +cd "$SCRIPT_DIR" +chmod +x *.sh +mkdir -p server + +# 步骤 2: 检查 release 目录 +echo "" +echo "📂 步骤 2/5: 检查构建产物..." +if [ ! -d "$DESKTOP_DIR/release" ] || [ -z "$(ls -A "$DESKTOP_DIR/release"/*.dmg 2>/dev/null)" ]; then + echo "❌ 未找到构建产物" + echo "" + echo "请先构建应用:" + echo " cd $DESKTOP_DIR" + echo " npm run build-local" + echo "" + exit 1 +fi + +# 步骤 3: 生成 manifest +echo "" +echo "📝 步骤 3/5: 生成 manifest 文件..." +./generate-manifest.sh --from-release + +# 步骤 4: 启动服务器 +echo "" +echo "🚀 步骤 4/5: 启动本地服务器..." +./start-server.sh + +# 步骤 5: 配置并启动应用 +echo "" +echo "⚙️ 步骤 5/5: 配置应用..." +cp "$SCRIPT_DIR/dev-app-update.local.yml" "$DESKTOP_DIR/dev-app-update.yml" +echo "✅ 已更新 dev-app-update.yml" + +# 检查 Gatekeeper +echo "" +check_gatekeeper + +echo "" +echo "============================================" +echo "✅ 准备完成!" +echo "============================================" +echo "" +echo "现在可以运行应用进行测试:" +echo "" +echo " cd $DESKTOP_DIR" +echo " npm run dev" +echo "" +echo "或者直接运行:" +read -p "是否现在启动应用?[Y/n] " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Nn]$ ]]; then + echo "" + echo "🚀 启动应用..." + cd "$DESKTOP_DIR" + npm run dev +fi diff --git a/apps/desktop/scripts/update-test/setup.sh b/apps/desktop/scripts/update-test/setup.sh new file mode 100755 index 0000000000..511be66067 --- /dev/null +++ b/apps/desktop/scripts/update-test/setup.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# ============================================ +# 本地更新测试 - 一键设置脚本 +# ============================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_DIR="$SCRIPT_DIR/server" + +echo "🚀 设置本地更新测试环境..." + +# 创建服务器目录 +mkdir -p "$SERVER_DIR" +echo "✅ 创建服务器目录: $SERVER_DIR" + +# 设置脚本执行权限 +chmod +x "$SCRIPT_DIR"/*.sh +echo "✅ 设置脚本执行权限" + +# 检查是否安装了 serve +if ! command -v npx &> /dev/null; then + echo "❌ 需要安装 Node.js 和 npm" + exit 1 +fi + +# 创建示例 latest-mac.yml +cat > "$SERVER_DIR/latest-mac.yml" << 'EOF' +version: 99.0.0 +files: + - url: LobeHub-99.0.0-arm64.dmg + sha512: placeholder-sha512-will-be-replaced + size: 100000000 + - url: LobeHub-99.0.0-arm64-mac.zip + sha512: placeholder-sha512-will-be-replaced + size: 100000000 +path: LobeHub-99.0.0-arm64.dmg +sha512: placeholder-sha512-will-be-replaced +releaseDate: '2026-01-15T10:00:00.000Z' +releaseNotes: | + ## 🎉 v99.0.0 本地测试版本 + + 这是一个用于本地测试更新功能的模拟版本。 + + ### ✨ 新功能 + - 测试功能 A + - 测试功能 B + + ### 🐛 修复 + - 修复测试问题 X +EOF +echo "✅ 创建示例 latest-mac.yml" + +# 创建 Windows 版本的 manifest (可选) +cat > "$SERVER_DIR/latest.yml" << 'EOF' +version: 99.0.0 +files: + - url: LobeHub-99.0.0-setup.exe + sha512: placeholder-sha512-will-be-replaced + size: 100000000 +path: LobeHub-99.0.0-setup.exe +sha512: placeholder-sha512-will-be-replaced +releaseDate: '2026-01-15T10:00:00.000Z' +releaseNotes: | + ## 🎉 v99.0.0 本地测试版本 + + 这是一个用于本地测试更新功能的模拟版本。 +EOF +echo "✅ 创建示例 latest.yml (Windows)" + +# 创建本地测试用的 dev-app-update.yml +cat > "$SCRIPT_DIR/dev-app-update.local.yml" << 'EOF' +# 本地更新测试配置 +# 将此文件复制到 apps/desktop/dev-app-update.yml 以使用本地服务器测试 + +provider: generic +url: http://localhost:8787 +updaterCacheDirName: lobehub-desktop-local-test +EOF +echo "✅ 创建本地测试配置文件" + +echo "" +echo "============================================" +echo "✅ 设置完成!" +echo "============================================" +echo "" +echo "下一步操作:" +echo "" +echo "1. 构建测试包:" +echo " cd $(dirname "$SCRIPT_DIR")" +echo " npm run build-local" +echo "" +echo "2. 复制构建产物到服务器目录:" +echo " cp release/*.dmg scripts/update-test/server/" +echo " cp release/*.zip scripts/update-test/server/" +echo "" +echo "3. 更新 manifest 文件(可选):" +echo " cd scripts/update-test" +echo " ./generate-manifest.sh" +echo "" +echo "4. 启动本地服务器:" +echo " ./start-server.sh" +echo "" +echo "5. 配置应用使用本地服务器:" +echo " cp dev-app-update.local.yml ../../dev-app-update.yml" +echo "" +echo "6. 运行应用:" +echo " cd ../.." +echo " npm run dev" +echo "" diff --git a/apps/desktop/scripts/update-test/start-server.sh b/apps/desktop/scripts/update-test/start-server.sh new file mode 100755 index 0000000000..c38acf520e --- /dev/null +++ b/apps/desktop/scripts/update-test/start-server.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# ============================================ +# 启动本地更新服务器 +# ============================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_DIR="$SCRIPT_DIR/server" +PID_FILE="$SCRIPT_DIR/.server.pid" +LOG_FILE="$SCRIPT_DIR/.server.log" +PORT="${PORT:-8787}" + +# 检查服务器目录 +if [ ! -d "$SERVER_DIR" ]; then + echo "❌ 服务器目录不存在,请先运行 ./setup.sh" + exit 1 +fi + +# 检查是否已经在运行 +if [ -f "$PID_FILE" ]; then + OLD_PID=$(cat "$PID_FILE") + if ps -p "$OLD_PID" > /dev/null 2>&1; then + echo "⚠️ 服务器已经在运行 (PID: $OLD_PID)" + echo " 地址: http://localhost:$PORT" + echo "" + echo " 如需重启,请先运行 ./stop-server.sh" + exit 0 + else + rm -f "$PID_FILE" + fi +fi + +echo "🚀 启动本地更新服务器..." +echo " 目录: $SERVER_DIR" +echo " 端口: $PORT" +echo "" + +# 列出服务器目录中的文件 +echo "📦 可用文件:" +ls -la "$SERVER_DIR" | grep -v "^d" | grep -v "^total" | awk '{print " " $NF}' +echo "" + +# 启动服务器 (后台运行) +cd "$SERVER_DIR" +nohup npx serve -p "$PORT" --cors -n > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +echo "$SERVER_PID" > "$PID_FILE" + +# 等待服务器启动 +sleep 2 + +# 检查是否启动成功 +if ps -p "$SERVER_PID" > /dev/null 2>&1; then + echo "✅ 服务器已启动!" + echo "" + echo " 地址: http://localhost:$PORT" + echo " PID: $SERVER_PID" + echo " 日志: $LOG_FILE" + echo "" + echo "📋 测试 URL:" + echo " latest-mac.yml: http://localhost:$PORT/latest-mac.yml" + echo " latest.yml: http://localhost:$PORT/latest.yml" + echo "" + echo "🛑 停止服务器: ./stop-server.sh" +else + echo "❌ 服务器启动失败" + echo " 查看日志: cat $LOG_FILE" + rm -f "$PID_FILE" + exit 1 +fi diff --git a/apps/desktop/scripts/update-test/stop-server.sh b/apps/desktop/scripts/update-test/stop-server.sh new file mode 100755 index 0000000000..0aa875aa19 --- /dev/null +++ b/apps/desktop/scripts/update-test/stop-server.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# ============================================ +# 停止本地更新服务器 +# ============================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PID_FILE="$SCRIPT_DIR/.server.pid" +LOG_FILE="$SCRIPT_DIR/.server.log" + +if [ ! -f "$PID_FILE" ]; then + echo "ℹ️ 服务器未运行 (找不到 PID 文件)" + exit 0 +fi + +PID=$(cat "$PID_FILE") + +if ps -p "$PID" > /dev/null 2>&1; then + echo "🛑 停止服务器 (PID: $PID)..." + kill "$PID" + sleep 1 + + # 强制终止(如果还在运行) + if ps -p "$PID" > /dev/null 2>&1; then + kill -9 "$PID" 2>/dev/null + fi + + echo "✅ 服务器已停止" +else + echo "ℹ️ 服务器进程已不存在" +fi + +rm -f "$PID_FILE" diff --git a/apps/desktop/src/main/core/infrastructure/UpdaterManager.ts b/apps/desktop/src/main/core/infrastructure/UpdaterManager.ts index b913518bc3..6213ed5e5d 100644 --- a/apps/desktop/src/main/core/infrastructure/UpdaterManager.ts +++ b/apps/desktop/src/main/core/infrastructure/UpdaterManager.ts @@ -2,11 +2,21 @@ import log from 'electron-log'; import { autoUpdater } from 'electron-updater'; import { isDev, isWindows } from '@/const/env'; -import { UPDATE_CHANNEL as channel, updaterConfig } from '@/modules/updater/configs'; +import { getDesktopEnv } from '@/env'; +import { + UPDATE_SERVER_URL, + UPDATE_CHANNEL as channel, + githubConfig, + isStableChannel, + updaterConfig, +} from '@/modules/updater/configs'; import { createLogger } from '@/utils/logger'; import type { App as AppCore } from '../App'; +// Allow forcing dev update config via env (for testing updates in packaged app) +const FORCE_DEV_UPDATE_CONFIG = getDesktopEnv().FORCE_DEV_UPDATE_CONFIG; + // Create logger const logger = createLogger('core:UpdaterManager'); @@ -16,6 +26,7 @@ export class UpdaterManager { private downloading: boolean = false; private updateAvailable: boolean = false; private isManualCheck: boolean = false; + private usingFallbackProvider: boolean = false; constructor(app: AppCore) { this.app = app; @@ -42,16 +53,26 @@ export class UpdaterManager { // Configure autoUpdater autoUpdater.autoDownload = false; // Set to false, we'll control downloads manually autoUpdater.autoInstallOnAppQuit = false; - - autoUpdater.channel = channel; - autoUpdater.allowPrerelease = channel !== 'stable'; autoUpdater.allowDowngrade = false; - // Enable test mode in development environment - if (isDev) { - logger.info(`Running in dev mode, forcing update check, channel: ${autoUpdater.channel}`); - // Allow testing updates in development environment + // Enable test mode in development environment or when forced via env + // IMPORTANT: This must be set BEFORE channel configuration so that + // dev-app-update.yml takes precedence over programmatic configuration + const useDevConfig = isDev || FORCE_DEV_UPDATE_CONFIG; + if (useDevConfig) { + // In dev mode, use dev-app-update.yml for all configuration including channel + // Don't set channel here - let dev-app-update.yml control it (defaults to "latest") autoUpdater.forceDevUpdateConfig = true; + logger.info( + `Using dev update config (isDev=${isDev}, FORCE_DEV_UPDATE_CONFIG=${FORCE_DEV_UPDATE_CONFIG})`, + ); + logger.info('Dev mode: Using dev-app-update.yml for update configuration'); + } else { + // Only configure channel and update provider programmatically in production + // Note: channel is configured in configureUpdateProvider based on provider type + autoUpdater.allowPrerelease = channel !== 'stable'; + logger.info(`Production mode: channel=${channel}, allowPrerelease=${channel !== 'stable'}`); + this.configureUpdateProvider(); } // Register events @@ -291,6 +312,79 @@ export class UpdaterManager { }, 300); }; + /** + * Configure update provider based on channel + * - Stable channel + UPDATE_SERVER_URL: Use generic HTTP provider (S3) as primary, channel=stable + * - Other channels (beta/nightly) or no S3: Use GitHub provider, channel=latest + * + * Important: S3 has stable-mac.yml, GitHub has latest-mac.yml + */ + private configureUpdateProvider() { + if (isStableChannel && UPDATE_SERVER_URL && !this.usingFallbackProvider) { + // Stable channel uses custom update server (generic HTTP) as primary + // S3 has stable-mac.yml, so we set channel to 'stable' + autoUpdater.channel = 'stable'; + logger.info(`Configuring generic provider for stable channel (primary)`); + logger.info(`Update server URL: ${UPDATE_SERVER_URL}`); + logger.info(`Channel set to: stable (will look for stable-mac.yml)`); + + autoUpdater.setFeedURL({ + provider: 'generic', + url: UPDATE_SERVER_URL, + }); + } else { + // Beta/nightly channels use GitHub, or fallback to GitHub if UPDATE_SERVER_URL not configured + // GitHub releases have latest-mac.yml, so we use default channel (latest) + autoUpdater.channel = 'latest'; + const reason = this.usingFallbackProvider ? '(fallback from S3)' : ''; + logger.info(`Configuring GitHub provider for ${channel} channel ${reason}`); + logger.info(`Channel set to: latest (will look for latest-mac.yml)`); + + autoUpdater.setFeedURL({ + owner: githubConfig.owner, + provider: 'github', + repo: githubConfig.repo, + }); + + logger.info(`GitHub update URL configured: ${githubConfig.owner}/${githubConfig.repo}`); + } + } + + /** + * Switch to fallback provider (GitHub) and retry update check + * Called when primary provider (S3) fails + */ + private switchToFallbackAndRetry = async () => { + // Only fallback if we're on stable channel with S3 configured and haven't already fallen back + if (!isStableChannel || !UPDATE_SERVER_URL || this.usingFallbackProvider) { + return false; + } + + logger.info('Primary update server (S3) failed, switching to GitHub fallback...'); + this.usingFallbackProvider = true; + this.configureUpdateProvider(); + + // Retry update check with fallback provider + try { + await autoUpdater.checkForUpdates(); + return true; + } catch (error) { + logger.error('Fallback provider (GitHub) also failed:', error); + return false; + } + }; + + /** + * Reset to primary provider for next update check + */ + private resetToPrimaryProvider = () => { + if (this.usingFallbackProvider) { + logger.info('Resetting to primary update provider (S3)'); + this.usingFallbackProvider = false; + this.configureUpdateProvider(); + } + }; + private registerEvents() { logger.debug('Registering updater events'); @@ -302,6 +396,9 @@ export class UpdaterManager { logger.info(`Update available: ${info.version}`); this.updateAvailable = true; + // Reset to primary provider for next check cycle + this.resetToPrimaryProvider(); + if (this.isManualCheck) { this.mainWindow.broadcast('manualUpdateAvailable', info); } else { @@ -313,13 +410,27 @@ export class UpdaterManager { autoUpdater.on('update-not-available', (info) => { logger.info(`Update not available. Current: ${info.version}`); + + // Reset to primary provider for next check cycle + this.resetToPrimaryProvider(); + if (this.isManualCheck) { this.mainWindow.broadcast('manualUpdateNotAvailable', info); } }); - autoUpdater.on('error', (err) => { + autoUpdater.on('error', async (err) => { logger.error('Error in auto-updater:', err); + + // Try fallback to GitHub if S3 failed + if (!this.usingFallbackProvider && isStableChannel && UPDATE_SERVER_URL) { + logger.info('Attempting fallback to GitHub provider...'); + const fallbackSucceeded = await this.switchToFallbackAndRetry(); + if (fallbackSucceeded) { + return; // Fallback initiated, don't report error yet + } + } + if (this.isManualCheck) { this.mainWindow.broadcast('updateError', err.message); } diff --git a/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts b/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts index b04cbf7dac..e18a9ca739 100644 --- a/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts +++ b/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts @@ -36,6 +36,7 @@ vi.mock('electron-updater', () => ({ logger: null as any, on: vi.fn(), quitAndInstall: vi.fn(), + setFeedURL: vi.fn(), }, })); @@ -62,6 +63,12 @@ vi.mock('@/utils/logger', () => ({ // Mock updater configs vi.mock('@/modules/updater/configs', () => ({ UPDATE_CHANNEL: 'stable', + UPDATE_SERVER_URL: 'https://mock.update.server', + githubConfig: { + owner: 'lobehub', + repo: 'lobe-chat', + }, + isStableChannel: true, updaterConfig: { app: { autoCheckUpdate: false, @@ -73,6 +80,13 @@ vi.mock('@/modules/updater/configs', () => ({ }, })); +// Mock env +vi.mock('@/env', () => ({ + getDesktopEnv: () => ({ + FORCE_DEV_UPDATE_CONFIG: false, + }), +})); + // Mock isDev vi.mock('@/const/env', () => ({ isDev: false, @@ -454,9 +468,11 @@ describe('UpdaterManager', () => { vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any); await updaterManager.checkForUpdates({ manual: true }); + vi.mocked(autoUpdater.checkForUpdates).mockRejectedValueOnce(new Error('Fallback failed')); + const error = new Error('Update error'); const handler = registeredEvents.get('error'); - handler?.(error); + await handler?.(error); expect(mockBroadcast).toHaveBeenCalledWith('updateError', 'Update error'); }); diff --git a/apps/desktop/src/main/env.ts b/apps/desktop/src/main/env.ts index a9314fdead..bd894b267a 100644 --- a/apps/desktop/src/main/env.ts +++ b/apps/desktop/src/main/env.ts @@ -59,29 +59,37 @@ const envNumber = (defaultValue: number) => */ export const getDesktopEnv = memoize(() => createEnv({ + client: {}, + clientPrefix: 'PUBLIC_', + emptyStringAsUndefined: true, + isServer: true, + runtimeEnv: process.env, server: { DEBUG_VERBOSE: envBoolean(false), + // escape hatch: allow testing static renderer in dev via env + DESKTOP_RENDERER_STATIC: envBoolean(false), + + // Force use dev-app-update.yml even in packaged app (for testing updates) + FORCE_DEV_UPDATE_CONFIG: envBoolean(false), + + // mcp client + MCP_TOOL_TIMEOUT: envNumber(60_000), + // keep optional to preserve existing behavior: // - unset NODE_ENV should behave like "not production" in logger runtime paths NODE_ENV: z.enum(['development', 'production', 'test']).optional(), - // escape hatch: allow testing static renderer in dev via env - DESKTOP_RENDERER_STATIC: envBoolean(false), - - // updater - UPDATE_CHANNEL: z.string().optional(), - - // mcp client - MCP_TOOL_TIMEOUT: envNumber(60_000), - // cloud server url (can be overridden for selfhost/dev) OFFICIAL_CLOUD_SERVER: z.string().optional().default('https://app.lobehub.com'), + + // updater + // process.env.xxx will replace in build stage + UPDATE_CHANNEL: z.string().optional().default(process.env.UPDATE_CHANNEL), + + // Custom update server URL (for stable channel) + // e.g., https://releases.lobehub.com/stable or https://your-bucket.s3.amazonaws.com/releases + UPDATE_SERVER_URL: z.string().optional().default(process.env.UPDATE_SERVER_URL), }, - clientPrefix: 'PUBLIC_', - client: {}, - runtimeEnv: process.env, - emptyStringAsUndefined: true, - isServer: true, }), ); diff --git a/apps/desktop/src/main/modules/updater/configs.ts b/apps/desktop/src/main/modules/updater/configs.ts index ff0e9acbd4..4e468271e5 100644 --- a/apps/desktop/src/main/modules/updater/configs.ts +++ b/apps/desktop/src/main/modules/updater/configs.ts @@ -2,7 +2,20 @@ import { isDev } from '@/const/env'; import { getDesktopEnv } from '@/env'; // 更新频道(stable, beta, alpha 等) -export const UPDATE_CHANNEL = getDesktopEnv().UPDATE_CHANNEL; +export const UPDATE_CHANNEL = getDesktopEnv().UPDATE_CHANNEL || 'stable'; + +// 判断是否为 stable 频道 +export const isStableChannel = UPDATE_CHANNEL === 'stable' || !UPDATE_CHANNEL; + +// 自定义更新服务器 URL (用于 stable 频道) +// e.g., https://releases.lobehub.com/stable +export const UPDATE_SERVER_URL = getDesktopEnv().UPDATE_SERVER_URL; + +// GitHub 配置 (用于 beta/nightly 频道,或作为 fallback) +export const githubConfig = { + owner: 'lobehub', + repo: 'lobe-chat', +}; export const updaterConfig = { // 应用更新配置 diff --git a/conductor.json b/conductor.json new file mode 100644 index 0000000000..27ff13586e --- /dev/null +++ b/conductor.json @@ -0,0 +1,5 @@ +{ + "scripts": { + "setup": "bash \"$CONDUCTOR_ROOT_PATH/.conductor/setup.sh\"" + } +} diff --git a/locales/en-US/subscription.json b/locales/en-US/subscription.json index 1e75db2518..ac3e19262e 100644 --- a/locales/en-US/subscription.json +++ b/locales/en-US/subscription.json @@ -54,11 +54,11 @@ "funds.packages.expiresIn": "Expires in {{days}} days", "funds.packages.expiresToday": "Expires today", "funds.packages.expiringSoon": "Expiring soon", + "funds.packages.gift": "Gift", + "funds.packages.giftedOn": "Gifted on {{date}}", "funds.packages.noPackages": "No credit packages", "funds.packages.purchaseFirst": "Purchase your first credit package", "funds.packages.purchasedOn": "Purchased on {{date}}", - "funds.packages.gift": "Gift", - "funds.packages.giftedOn": "Gifted on {{date}}", "funds.packages.sort.amountAsc": "Amount: Low to High", "funds.packages.sort.amountDesc": "Amount: High to Low", "funds.packages.sort.balanceAsc": "Balance: Low to High", diff --git a/locales/zh-CN/subscription.json b/locales/zh-CN/subscription.json index 407615d6ca..83f7222cf5 100644 --- a/locales/zh-CN/subscription.json +++ b/locales/zh-CN/subscription.json @@ -54,11 +54,11 @@ "funds.packages.expiresIn": "{{days}} 天后过期", "funds.packages.expiresToday": "今日过期", "funds.packages.expiringSoon": "即将过期", + "funds.packages.gift": "赠送", + "funds.packages.giftedOn": "赠送于 {{date}}", "funds.packages.noPackages": "暂无积分包", "funds.packages.purchaseFirst": "购买您的第一个积分包", "funds.packages.purchasedOn": "购买于 {{date}}", - "funds.packages.gift": "赠送", - "funds.packages.giftedOn": "赠送于 {{date}}", "funds.packages.sort.amountAsc": "金额:从低到高", "funds.packages.sort.amountDesc": "金额:从高到低", "funds.packages.sort.balanceAsc": "余额:从低到高", diff --git a/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx b/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx index 4f7f4c7751..8a6cf616de 100644 --- a/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx +++ b/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow } from '@lobehub/ui'; +import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow, TooltipGroup } from '@lobehub/ui'; import { Button } from 'antd'; import { createStaticStyles } from 'antd-style'; import { Maximize2, Minimize2, NotebookText, PencilLine } from 'lucide-react'; @@ -85,19 +85,21 @@ const DocumentCard = memo(({ document }) => {
{document.title}
- - - - + + + + + + {/* Content */} diff --git a/packages/electron-client-ipc/src/useWatchBroadcast.ts b/packages/electron-client-ipc/src/useWatchBroadcast.ts index 403d3cb6b5..d95705c15d 100644 --- a/packages/electron-client-ipc/src/useWatchBroadcast.ts +++ b/packages/electron-client-ipc/src/useWatchBroadcast.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useLayoutEffect, useRef } from 'react'; import { MainBroadcastEventKey, MainBroadcastParams } from './events'; @@ -21,11 +21,17 @@ export const useWatchBroadcast = ( event: T, handler: (data: MainBroadcastParams) => void, ) => { + const handlerRef = useRef(handler); + + useLayoutEffect(() => { + handlerRef.current = handler; + }, [handler]); + useEffect(() => { if (!window.electron) return; - const listener = (e: any, data: MainBroadcastParams) => { - handler(data); + const listener = (_e: any, data: MainBroadcastParams) => { + handlerRef.current(data); }; window.electron.ipcRenderer.on(event, listener); @@ -33,5 +39,5 @@ export const useWatchBroadcast = ( return () => { window.electron.ipcRenderer.removeListener(event, listener); }; - }, []); + }, [event]); }; diff --git a/packages/model-bank/src/aiModels/qiniu.ts b/packages/model-bank/src/aiModels/qiniu.ts index 75eea849e4..86ba18514a 100644 --- a/packages/model-bank/src/aiModels/qiniu.ts +++ b/packages/model-bank/src/aiModels/qiniu.ts @@ -21,7 +21,7 @@ const qiniuChatModels: AIChatModelCard[] = [ }, contextWindowTokens: 65_536, description: - "DeepSeek R1 is DeepSeek’s latest open model with very strong reasoning, matching OpenAI’s o1 on math, programming, and reasoning tasks.", + 'DeepSeek R1 is DeepSeek’s latest open model with very strong reasoning, matching OpenAI’s o1 on math, programming, and reasoning tasks.', displayName: 'DeepSeek R1', enabled: true, id: 'deepseek-r1', @@ -34,7 +34,7 @@ const qiniuChatModels: AIChatModelCard[] = [ search: true, }, contextWindowTokens: 204_800, - description: + description: 'MiniMax-M2.1 is a lightweight, cutting-edge large language model optimized for coding, proxy workflows, and modern application development, providing cleaner, more concise output and faster perceptual response times.', displayName: 'MiniMax M2.1', enabled: true, @@ -89,7 +89,7 @@ const qiniuChatModels: AIChatModelCard[] = [ displayName: 'LongCat Flash Chat', enabled: true, id: 'meituan/longcat-flash-chat', - maxOutput: 65536, + maxOutput: 65_536, pricing: { currency: 'CNY', units: [ @@ -111,8 +111,8 @@ const qiniuChatModels: AIChatModelCard[] = [ search: true, }, contextWindowTokens: 200_000, - description: - 'GLM-4.7 is Zhipu\'s latest flagship model, offering improved general capabilities, simpler and more natural replies, and a more immersive writing experience.', + description: + "GLM-4.7 is Zhipu's latest flagship model, offering improved general capabilities, simpler and more natural replies, and a more immersive writing experience.", displayName: 'GLM-4.7', enabled: true, id: 'z-ai/glm-4.7', @@ -138,7 +138,7 @@ const qiniuChatModels: AIChatModelCard[] = [ search: true, }, contextWindowTokens: 200_000, - description: + description: 'The flagship model of Zhipu, GLM-4.6, surpasses its predecessor in all aspects of advanced coding, long text processing, reasoning, and intelligent agent capabilities.', displayName: 'GLM-4.6', enabled: true, diff --git a/packages/observability-otel/src/node.ts b/packages/observability-otel/src/node.ts index a76e2e923d..2662231297 100644 --- a/packages/observability-otel/src/node.ts +++ b/packages/observability-otel/src/node.ts @@ -5,7 +5,7 @@ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; -import { resourceFromAttributes, DetectedResourceAttributes } from '@opentelemetry/resources'; +import { DetectedResourceAttributes, resourceFromAttributes } from '@opentelemetry/resources'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; @@ -16,48 +16,42 @@ export function attributesForVercel(): DetectedResourceAttributes { // Vercel. // https://vercel.com/docs/projects/environment-variables/system-environment-variables // Vercel Env set as top level attribute for simplicity. One of 'production', 'preview' or 'development'. - env: process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV, + 'env': process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV, - "vercel.branch_host": - process.env.VERCEL_BRANCH_URL || - process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL || - undefined, - "vercel.deployment_id": process.env.VERCEL_DEPLOYMENT_ID || undefined, - "vercel.host": - process.env.VERCEL_URL || - process.env.NEXT_PUBLIC_VERCEL_URL || - undefined, - "vercel.project_id": process.env.VERCEL_PROJECT_ID || undefined, - "vercel.region": process.env.VERCEL_REGION, - "vercel.runtime": process.env.NEXT_RUNTIME || "nodejs", - "vercel.sha": - process.env.VERCEL_GIT_COMMIT_SHA || - process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, + 'vercel.branch_host': + process.env.VERCEL_BRANCH_URL || process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL || undefined, + 'vercel.deployment_id': process.env.VERCEL_DEPLOYMENT_ID || undefined, + 'vercel.host': process.env.VERCEL_URL || process.env.NEXT_PUBLIC_VERCEL_URL || undefined, + 'vercel.project_id': process.env.VERCEL_PROJECT_ID || undefined, + 'vercel.region': process.env.VERCEL_REGION, + 'vercel.runtime': process.env.NEXT_RUNTIME || 'nodejs', + 'vercel.sha': + process.env.VERCEL_GIT_COMMIT_SHA || process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, 'service.version': process.env.VERCEL_DEPLOYMENT_ID, - } + }; } export function attributesForNodejs(): DetectedResourceAttributes { return { // Node. - "node.ci": process.env.CI ? true : undefined, - "node.env": process.env.NODE_ENV, - } + 'node.ci': process.env.CI ? true : undefined, + 'node.env': process.env.NODE_ENV, + }; } export function attributesForEnv(): DetectedResourceAttributes { return { ...attributesForVercel(), ...attributesForNodejs(), - } + }; } export function attributesCommon(): DetectedResourceAttributes { return { [ATTR_SERVICE_NAME]: 'lobe-chat', ...attributesForEnv(), - } + }; } function debugLogLevelFromString(level?: string | null): DiagLogLevel | undefined { @@ -69,26 +63,38 @@ function debugLogLevelFromString(level?: string | null): DiagLogLevel | undefine } switch (level.toLowerCase()) { - case 'none': + case 'none': { return DiagLogLevel.NONE; - case 'error': + } + case 'error': { return DiagLogLevel.ERROR; - case 'warn': + } + case 'warn': { return DiagLogLevel.WARN; - case 'info': + } + case 'info': { return DiagLogLevel.INFO; - case 'debug': + } + case 'debug': { return DiagLogLevel.DEBUG; - case 'verbose': + } + case 'verbose': { return DiagLogLevel.VERBOSE; - case 'all': + } + case 'all': { return DiagLogLevel.ALL; - default: + } + default: { return undefined; + } } } -export function register(options?: { debug?: true | DiagLogLevel; name?: string; version?: string }) { +export function register(options?: { + debug?: true | DiagLogLevel; + name?: string; + version?: string; +}) { const attributes = attributesCommon(); if (typeof options?.name !== 'undefined') { @@ -102,11 +108,7 @@ export function register(options?: { debug?: true | DiagLogLevel; name?: string; diag.setLogger( new DiagConsoleLogger(), - !!levelFromEnv - ? levelFromEnv - : options?.debug === true - ? DiagLogLevel.DEBUG - : options?.debug, + !!levelFromEnv ? levelFromEnv : options?.debug === true ? DiagLogLevel.DEBUG : options?.debug, ); } diff --git a/scripts/electronWorkflow/mergeMacReleaseFiles.js b/scripts/electronWorkflow/mergeMacReleaseFiles.js index 7ce3b11cf7..af2e85d67d 100644 --- a/scripts/electronWorkflow/mergeMacReleaseFiles.js +++ b/scripts/electronWorkflow/mergeMacReleaseFiles.js @@ -4,7 +4,9 @@ import path from 'node:path'; import YAML from 'yaml'; // 配置 -const FILE_NAME = 'latest-mac.yml'; +// Support both stable-mac.yml (stable channel) and latest-mac.yml (fallback) +const STABLE_outputFileName = 'stable-mac.yml'; +const LATEST_outputFileName = 'latest-mac.yml'; const RELEASE_DIR = path.resolve('release'); /** @@ -85,11 +87,23 @@ async function main() { const releaseFiles = fs.readdirSync(RELEASE_DIR); console.log(`📂 Files in release directory: ${releaseFiles.join(', ')}`); - // 2. 查找所有 latest-mac*.yml 文件 - const macYmlFiles = releaseFiles.filter( + // 2. 查找所有 stable-mac*.yml 和 latest-mac*.yml 文件 + // Prioritize stable-mac*.yml, fallback to latest-mac*.yml + const stableMacYmlFiles = releaseFiles.filter( + (f) => f.startsWith('stable-mac') && f.endsWith('.yml'), + ); + const latestMacYmlFiles = releaseFiles.filter( (f) => f.startsWith('latest-mac') && f.endsWith('.yml'), ); - console.log(`🔍 Found macOS YAML files: ${macYmlFiles.join(', ')}`); + + // Use stable files if available, otherwise use latest + const macYmlFiles = stableMacYmlFiles.length > 0 ? stableMacYmlFiles : latestMacYmlFiles; + const outputFileName = + stableMacYmlFiles.length > 0 ? STABLE_outputFileName : LATEST_outputFileName; + + console.log(`🔍 Found stable macOS YAML files: ${stableMacYmlFiles.join(', ') || 'none'}`); + console.log(`🔍 Found latest macOS YAML files: ${latestMacYmlFiles.join(', ') || 'none'}`); + console.log(`🔍 Using files: ${macYmlFiles.join(', ')} -> ${outputFileName}`); if (macYmlFiles.length === 0) { console.log('⚠️ No macOS YAML files found, skipping merge'); @@ -115,7 +129,7 @@ async function main() { } else if (platform === 'both') { console.log(`✅ Found already merged file: ${fileName}`); // 如果已经是合并后的文件,直接复制为最终文件 - writeLocalFile(path.join(RELEASE_DIR, FILE_NAME), content); + writeLocalFile(path.join(RELEASE_DIR, outputFileName), content); return; } else { console.log(`⚠️ Unknown platform type: ${platform} in ${fileName}`); @@ -136,13 +150,13 @@ async function main() { if (x64Files.length === 0) { console.log('⚠️ No x64 files found, using ARM64 only'); - writeLocalFile(path.join(RELEASE_DIR, FILE_NAME), arm64Files[0].content); + writeLocalFile(path.join(RELEASE_DIR, outputFileName), arm64Files[0].content); return; } if (arm64Files.length === 0) { console.log('⚠️ No ARM64 files found, using x64 only'); - writeLocalFile(path.join(RELEASE_DIR, FILE_NAME), x64Files[0].content); + writeLocalFile(path.join(RELEASE_DIR, outputFileName), x64Files[0].content); return; } @@ -154,7 +168,7 @@ async function main() { const mergedContent = mergeYamlFiles(x64File.yaml, arm64File.yaml); // 6. 保存合并后的文件 - const mergedFilePath = path.join(RELEASE_DIR, FILE_NAME); + const mergedFilePath = path.join(RELEASE_DIR, outputFileName); writeLocalFile(mergedFilePath, mergedContent); // 7. 验证合并结果 diff --git a/src/app/(backend)/api/version/route.ts b/src/app/(backend)/api/version/route.ts new file mode 100644 index 0000000000..a1afbe2f59 --- /dev/null +++ b/src/app/(backend)/api/version/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; + +import pkg from '../../../../../package.json'; + +export interface VersionResponseData { + version: string; +} + +export async function GET() { + return NextResponse.json({ + version: pkg.version, + } satisfies VersionResponseData); +} diff --git a/src/app/[variants]/(desktop)/desktop-onboarding/_layout/index.tsx b/src/app/[variants]/(desktop)/desktop-onboarding/_layout/index.tsx index 8a8206082a..4aa75b51b9 100644 --- a/src/app/[variants]/(desktop)/desktop-onboarding/_layout/index.tsx +++ b/src/app/[variants]/(desktop)/desktop-onboarding/_layout/index.tsx @@ -1,11 +1,12 @@ 'use client'; +import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge'; import { Center, Flexbox, Text } from '@lobehub/ui'; import { Divider } from 'antd'; import { cx } from 'antd-style'; import type { FC, PropsWithChildren } from 'react'; -import { SimpleTitleBar, TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar'; +import SimpleTitleBar from '@/features/Electron/titlebar/SimpleTitleBar'; import LangButton from '@/features/User/UserPanel/LangButton'; import ThemeButton from '@/features/User/UserPanel/ThemeButton'; import { useIsDark } from '@/hooks/useIsDark'; diff --git a/src/app/[variants]/(main)/_layout/index.tsx b/src/app/[variants]/(main)/_layout/index.tsx index 3c6eda60b6..99eee68545 100644 --- a/src/app/[variants]/(main)/_layout/index.tsx +++ b/src/app/[variants]/(main)/_layout/index.tsx @@ -1,5 +1,6 @@ 'use client'; +import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge'; import { Flexbox } from '@lobehub/ui'; import { cx } from 'antd-style'; import dynamic from 'next/dynamic'; @@ -12,7 +13,7 @@ import Loading from '@/components/Loading/BrandTextLoading'; import { isDesktop } from '@/const/version'; import { BANNER_HEIGHT } from '@/features/AlertBanner/CloudBanner'; import DesktopNavigationBridge from '@/features/DesktopNavigationBridge'; -import TitleBar, { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar'; +import TitleBar from '@/features/Electron/titlebar/TitleBar'; import HotkeyHelperPanel from '@/features/HotkeyHelperPanel'; import NavPanel from '@/features/NavPanel'; import { useFeedbackModal } from '@/hooks/useFeedbackModal'; diff --git a/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobScheduleConfig.tsx b/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobScheduleConfig.tsx index 211ed2848c..aec3045185 100644 --- a/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobScheduleConfig.tsx +++ b/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobScheduleConfig.tsx @@ -187,7 +187,6 @@ const CronJobScheduleConfig = memo( style={{ maxWidth: 300, minWidth: 200 }} value={timezone} /> - {/* Max Executions */} diff --git a/src/app/[variants]/(main)/agent/cron/[cronId]/index.tsx b/src/app/[variants]/(main)/agent/cron/[cronId]/index.tsx index a8c2578eb9..6bf9c4187c 100644 --- a/src/app/[variants]/(main)/agent/cron/[cronId]/index.tsx +++ b/src/app/[variants]/(main)/agent/cron/[cronId]/index.tsx @@ -168,11 +168,11 @@ const CronJobDetailPage = memo(() => { (current) => current ? { - ...current, - ...payload, - executionConditions: payload.executionConditions ?? null, - ...(updatedAt ? { updatedAt } : null), - } + ...current, + ...payload, + executionConditions: payload.executionConditions ?? null, + ...(updatedAt ? { updatedAt } : null), + } : current, false, ); diff --git a/src/app/[variants]/(main)/agent/features/Conversation/ThreadHydration.tsx b/src/app/[variants]/(main)/agent/features/Conversation/ThreadHydration.tsx index 093c686cde..759d3a013f 100644 --- a/src/app/[variants]/(main)/agent/features/Conversation/ThreadHydration.tsx +++ b/src/app/[variants]/(main)/agent/features/Conversation/ThreadHydration.tsx @@ -32,7 +32,9 @@ const ThreadHydration = memo(() => { // should open portal automatically when portalThread is set useEffect(() => { if (!!portalThread && !useChatStore.getState().showPortal) { - useChatStore.getState().pushPortalView({ threadId: portalThread, type: PortalViewType.Thread }); + useChatStore + .getState() + .pushPortalView({ threadId: portalThread, type: PortalViewType.Thread }); } }, [portalThread]); diff --git a/src/app/[variants]/(main)/group/features/Conversation/ThreadHydration.tsx b/src/app/[variants]/(main)/group/features/Conversation/ThreadHydration.tsx index 093c686cde..759d3a013f 100644 --- a/src/app/[variants]/(main)/group/features/Conversation/ThreadHydration.tsx +++ b/src/app/[variants]/(main)/group/features/Conversation/ThreadHydration.tsx @@ -32,7 +32,9 @@ const ThreadHydration = memo(() => { // should open portal automatically when portalThread is set useEffect(() => { if (!!portalThread && !useChatStore.getState().showPortal) { - useChatStore.getState().pushPortalView({ threadId: portalThread, type: PortalViewType.Thread }); + useChatStore + .getState() + .pushPortalView({ threadId: portalThread, type: PortalViewType.Thread }); } }, [portalThread]); diff --git a/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx b/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx index c789e008c7..196e6cf2bc 100644 --- a/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx +++ b/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx @@ -186,9 +186,9 @@ const Checker = memo( style={ pass ? { - borderColor: cssVar.colorSuccess, - color: cssVar.colorSuccess, - } + borderColor: cssVar.colorSuccess, + color: cssVar.colorSuccess, + } : undefined } > diff --git a/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx b/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx index 681c0201d1..0023af5dd7 100644 --- a/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx +++ b/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx @@ -259,10 +259,7 @@ export const mobileRoutes: RouteConfig[] = [ { children: [ { - element: dynamicElement( - () => import('../../share/t/[id]'), - 'Mobile > Share > Topic', - ), + element: dynamicElement(() => import('../../share/t/[id]'), 'Mobile > Share > Topic'), path: ':id', }, ], diff --git a/src/app/[variants]/router/desktopRouter.config.tsx b/src/app/[variants]/router/desktopRouter.config.tsx index 38397bdb80..7bc50886fe 100644 --- a/src/app/[variants]/router/desktopRouter.config.tsx +++ b/src/app/[variants]/router/desktopRouter.config.tsx @@ -403,10 +403,7 @@ export const desktopRoutes: RouteConfig[] = [ { children: [ { - element: dynamicElement( - () => import('../share/t/[id]'), - 'Desktop > Share > Topic', - ), + element: dynamicElement(() => import('../share/t/[id]'), 'Desktop > Share > Topic'), path: ':id', }, ], diff --git a/src/components/FunctionModal/createModalHooks.ts b/src/components/FunctionModal/createModalHooks.ts deleted file mode 100644 index 18abc7cc62..0000000000 --- a/src/components/FunctionModal/createModalHooks.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { App } from 'antd'; -import { type ModalFuncProps } from 'antd/es/modal/interface'; -import { type MutableRefObject, type ReactNode, type RefObject, useRef } from 'react'; - -import { closeIcon, styles } from './style'; - -interface CreateModalProps extends ModalFuncProps { - content: ReactNode; -} - -interface ModalInstance { - destroy: (...args: any[]) => void; -} - -type PropsFunc = ( - instance: MutableRefObject, - props?: T, -) => CreateModalProps; - -const createModal = (params: CreateModalProps | PropsFunc) => { - const useModal = () => { - const { modal } = App.useApp(); - const instanceRef = useRef(null); - - const open = (outProps?: T) => { - const props = - typeof params === 'function' - ? params(instanceRef as RefObject, outProps) - : params; - - instanceRef.current = modal.confirm({ - className: styles.content, - closable: true, - closeIcon, - footer: false, - icon: null, - wrapClassName: styles.wrap, - ...props, - }); - }; - - return { open }; - }; - - return useModal; -}; - -export { createModal }; diff --git a/src/components/FunctionModal/index.ts b/src/components/FunctionModal/index.ts deleted file mode 100644 index 757279fd96..0000000000 --- a/src/components/FunctionModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './createModalHooks'; diff --git a/src/components/FunctionModal/style.tsx b/src/components/FunctionModal/style.tsx deleted file mode 100644 index 66ff7bd085..0000000000 --- a/src/components/FunctionModal/style.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Icon } from '@lobehub/ui'; -import { createStaticStyles , responsive } from 'antd-style'; -import { XIcon } from 'lucide-react'; - -const prefixCls = 'ant'; - -export const styles = createStaticStyles(({ css, cssVar }) => { - return { - content: css` - .${prefixCls}-modal-container { - overflow: hidden; - - width: min(90vw, 450px); - padding: 0; - border: 1px solid ${cssVar.colorSplit}; - border-radius: ${cssVar.borderRadiusLG}; - - background: ${cssVar.colorBgLayout}; - - ${responsive.sm} { - width: unset; - } - } - .${prefixCls}-modal-confirm-title { - display: block; - padding-block: 16px 0; - padding-inline: 16px; - } - .${prefixCls}-modal-confirm-btns { - margin-block-start: 0; - padding: 16px; - } - - .${prefixCls}-modal-confirm-paragraph { - max-width: 100%; - } - `, - wrap: css` - overflow: hidden auto; - `, - }; -}); - -export const closeIcon = ; diff --git a/src/components/HtmlPreview/PreviewDrawer.tsx b/src/components/HtmlPreview/PreviewDrawer.tsx index 70dad5c3a6..7fd571e238 100644 --- a/src/components/HtmlPreview/PreviewDrawer.tsx +++ b/src/components/HtmlPreview/PreviewDrawer.tsx @@ -1,3 +1,4 @@ +import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge'; import { exportFile } from '@lobechat/utils/client'; import { Block, Button, Flexbox, Highlighter, Segmented } from '@lobehub/ui'; import { Drawer } from 'antd'; @@ -7,7 +8,6 @@ import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isDesktop } from '@/const/version'; -import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar'; const styles = createStaticStyles(({ css }) => ({ container: css` diff --git a/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx b/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx index 4ac2e18512..6ac25934b0 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx @@ -25,10 +25,20 @@ const GroupItem = memo( toggleMessageEditing(item.id, true); }} > - + ) : ( - + ); }, isEqual, diff --git a/src/features/Conversation/Messages/Tool/Tool/index.tsx b/src/features/Conversation/Messages/Tool/Tool/index.tsx index a4207677d6..5adf8d9c16 100644 --- a/src/features/Conversation/Messages/Tool/Tool/index.tsx +++ b/src/features/Conversation/Messages/Tool/Tool/index.tsx @@ -33,7 +33,16 @@ export interface InspectorProps { * Tool message component - adapts Tool message data to use AssistantGroup/Tool components */ const Tool = memo( - ({ arguments: requestArgs, apiName, disableEditing, messageId, toolCallId, index, identifier, type }) => { + ({ + arguments: requestArgs, + apiName, + disableEditing, + messageId, + toolCallId, + index, + identifier, + type, + }) => { const [showDebug, setShowDebug] = useState(false); const [showPluginRender, setShowPluginRender] = useState(false); const [expand, setExpand] = useState(true); diff --git a/src/features/ElectronTitlebar/Connection/index.tsx b/src/features/Electron/connection/Connection.tsx similarity index 100% rename from src/features/ElectronTitlebar/Connection/index.tsx rename to src/features/Electron/connection/Connection.tsx diff --git a/src/features/ElectronTitlebar/Connection/ConnectionMode.tsx b/src/features/Electron/connection/ConnectionMode.tsx similarity index 100% rename from src/features/ElectronTitlebar/Connection/ConnectionMode.tsx rename to src/features/Electron/connection/ConnectionMode.tsx diff --git a/src/features/ElectronTitlebar/Connection/Option.tsx b/src/features/Electron/connection/Option.tsx similarity index 100% rename from src/features/ElectronTitlebar/Connection/Option.tsx rename to src/features/Electron/connection/Option.tsx diff --git a/src/features/ElectronTitlebar/Connection/RemoteStatus.tsx b/src/features/Electron/connection/RemoteStatus.tsx similarity index 100% rename from src/features/ElectronTitlebar/Connection/RemoteStatus.tsx rename to src/features/Electron/connection/RemoteStatus.tsx diff --git a/src/features/ElectronTitlebar/Connection/Waiting.tsx b/src/features/Electron/connection/Waiting.tsx similarity index 100% rename from src/features/ElectronTitlebar/Connection/Waiting.tsx rename to src/features/Electron/connection/Waiting.tsx diff --git a/src/features/ElectronTitlebar/Connection/WaitingAnim.tsx b/src/features/Electron/connection/WaitingAnim.tsx similarity index 100% rename from src/features/ElectronTitlebar/Connection/WaitingAnim.tsx rename to src/features/Electron/connection/WaitingAnim.tsx diff --git a/src/features/ElectronTitlebar/helpers/routeMetadata.ts b/src/features/Electron/navigation/routeMetadata.ts similarity index 100% rename from src/features/ElectronTitlebar/helpers/routeMetadata.ts rename to src/features/Electron/navigation/routeMetadata.ts diff --git a/src/features/ElectronTitlebar/hooks/useNavigationHistory.ts b/src/features/Electron/navigation/useNavigationHistory.ts similarity index 98% rename from src/features/ElectronTitlebar/hooks/useNavigationHistory.ts rename to src/features/Electron/navigation/useNavigationHistory.ts index 4de6bd39cb..10392f89c2 100644 --- a/src/features/ElectronTitlebar/hooks/useNavigationHistory.ts +++ b/src/features/Electron/navigation/useNavigationHistory.ts @@ -7,7 +7,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useElectronStore } from '@/store/electron'; -import { getRouteMetadata } from '../helpers/routeMetadata'; +import { getRouteMetadata } from './routeMetadata'; /** * Hook to manage navigation history in Electron desktop app diff --git a/src/features/ElectronTitlebar/hooks/useWatchThemeUpdate.ts b/src/features/Electron/system/useWatchThemeUpdate.ts similarity index 100% rename from src/features/ElectronTitlebar/hooks/useWatchThemeUpdate.ts rename to src/features/Electron/system/useWatchThemeUpdate.ts diff --git a/src/features/ElectronTitlebar/NavigationBar/index.tsx b/src/features/Electron/titlebar/NavigationBar.tsx similarity index 97% rename from src/features/ElectronTitlebar/NavigationBar/index.tsx rename to src/features/Electron/titlebar/NavigationBar.tsx index 04fc396101..5ea6ac908e 100644 --- a/src/features/ElectronTitlebar/NavigationBar/index.tsx +++ b/src/features/Electron/titlebar/NavigationBar.tsx @@ -10,7 +10,7 @@ import { systemStatusSelectors } from '@/store/global/selectors'; import { electronStylish } from '@/styles/electron'; import { isMacOS } from '@/utils/platform'; -import { useNavigationHistory } from '../hooks/useNavigationHistory'; +import { useNavigationHistory } from '../navigation/useNavigationHistory'; import RecentlyViewed from './RecentlyViewed'; const isMac = isMacOS(); diff --git a/src/features/ElectronTitlebar/NavigationBar/RecentlyViewed.tsx b/src/features/Electron/titlebar/RecentlyViewed.tsx similarity index 98% rename from src/features/ElectronTitlebar/NavigationBar/RecentlyViewed.tsx rename to src/features/Electron/titlebar/RecentlyViewed.tsx index 7a015a63b4..b86e4021e6 100644 --- a/src/features/ElectronTitlebar/NavigationBar/RecentlyViewed.tsx +++ b/src/features/Electron/titlebar/RecentlyViewed.tsx @@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom'; import { useElectronStore } from '@/store/electron'; import type { HistoryEntry } from '@/store/electron/actions/navigationHistory'; -import { getRouteIcon } from '../helpers/routeMetadata'; +import { getRouteIcon } from '../navigation/routeMetadata'; const styles = createStaticStyles(({ css, cssVar }) => ({ container: css` diff --git a/src/features/ElectronTitlebar/SimpleTitleBar.tsx b/src/features/Electron/titlebar/SimpleTitleBar.tsx similarity index 100% rename from src/features/ElectronTitlebar/SimpleTitleBar.tsx rename to src/features/Electron/titlebar/SimpleTitleBar.tsx diff --git a/src/features/ElectronTitlebar/index.tsx b/src/features/Electron/titlebar/TitleBar.tsx similarity index 67% rename from src/features/ElectronTitlebar/index.tsx rename to src/features/Electron/titlebar/TitleBar.tsx index db59e445f9..829a66201f 100644 --- a/src/features/ElectronTitlebar/index.tsx +++ b/src/features/Electron/titlebar/TitleBar.tsx @@ -1,18 +1,19 @@ import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge'; +import { useWatchBroadcast } from '@lobechat/electron-client-ipc'; import { Flexbox } from '@lobehub/ui'; import { Divider } from 'antd'; -import { memo, useMemo } from 'react'; +import { memo, useMemo, useRef } from 'react'; import { useElectronStore } from '@/store/electron'; import { electronStylish } from '@/styles/electron'; import { isMacOS } from '@/utils/platform'; -import Connection from './Connection'; +import Connection from '../connection/Connection'; +import { useWatchThemeUpdate } from '../system/useWatchThemeUpdate'; +import { useUpdateModal } from '../updater/UpdateModal'; +import { UpdateNotification } from '../updater/UpdateNotification'; import NavigationBar from './NavigationBar'; -import { UpdateModal } from './UpdateModal'; -import { UpdateNotification } from './UpdateNotification'; import WinControl from './WinControl'; -import { useWatchThemeUpdate } from './hooks/useWatchThemeUpdate'; const isMac = isMacOS(); @@ -25,6 +26,19 @@ const TitleBar = memo(() => { initElectronAppState(); useWatchThemeUpdate(); + const { open: openUpdateModal } = useUpdateModal(); + const updateModalOpenRef = useRef(false); + + useWatchBroadcast('manualUpdateCheckStart', () => { + if (updateModalOpenRef.current) return; + updateModalOpenRef.current = true; + openUpdateModal({ + onAfterClose: () => { + updateModalOpenRef.current = false; + }, + }); + }); + const showWinControl = isAppStateInit && !isMac; const padding = useMemo(() => { @@ -59,12 +73,8 @@ const TitleBar = memo(() => { )} - ); }); export default TitleBar; - -export { default as SimpleTitleBar } from './SimpleTitleBar'; -export { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge'; diff --git a/src/features/Electron/titlebar/WinControl.tsx b/src/features/Electron/titlebar/WinControl.tsx new file mode 100644 index 0000000000..1421181b99 --- /dev/null +++ b/src/features/Electron/titlebar/WinControl.tsx @@ -0,0 +1,5 @@ +const WinControl = () => { + return
; +}; + +export default WinControl; diff --git a/src/features/Electron/updater/UpdateModal.tsx b/src/features/Electron/updater/UpdateModal.tsx new file mode 100644 index 0000000000..43d496581c --- /dev/null +++ b/src/features/Electron/updater/UpdateModal.tsx @@ -0,0 +1,299 @@ +import { + type ProgressInfo, + type UpdateInfo, + useWatchBroadcast, +} from '@lobechat/electron-client-ipc'; +import { Button, Flexbox, type ModalInstance, createModal } from '@lobehub/ui'; +import { App, Progress, Spin } from 'antd'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { autoUpdateService } from '@/services/electron/autoUpdate'; +import { formatSpeed } from '@/utils/format'; + +type UpdateStage = 'checking' | 'available' | 'latest' | 'downloading' | 'downloaded'; + +interface ModalUpdateOptions { + closable?: boolean; + keyboard?: boolean; + maskClosable?: boolean; + title?: React.ReactNode; +} + +interface UpdateModalContentProps { + onClose: () => void; + setModalProps: (props: ModalUpdateOptions) => void; +} + +const UpdateModalContent = memo(({ onClose, setModalProps }) => { + const { t } = useTranslation(['electron', 'common']); + const { modal } = App.useApp(); + const errorHandledRef = useRef(false); + const isClosingRef = useRef(false); + + const [stage, setStage] = useState('checking'); + const [updateAvailableInfo, setUpdateAvailableInfo] = useState(null); + const [downloadedInfo, setDownloadedInfo] = useState(null); + const [progress, setProgress] = useState(null); + const [latestVersionInfo, setLatestVersionInfo] = useState(null); + + useEffect(() => { + const isDownloading = stage === 'downloading'; + const modalTitle = (() => { + switch (stage) { + case 'checking': { + return t('updater.checkingUpdate'); + } + case 'available': { + return t('updater.newVersionAvailable'); + } + case 'downloading': { + return t('updater.downloadingUpdate'); + } + case 'downloaded': { + return t('updater.updateReady'); + } + case 'latest': { + return t('updater.isLatestVersion'); + } + default: { + return ''; + } + } + })(); + + setModalProps({ + closable: !isDownloading, + keyboard: !isDownloading, + maskClosable: !isDownloading, + title: modalTitle, + }); + }, [setModalProps, stage, t]); + + useWatchBroadcast('manualUpdateAvailable', (info: UpdateInfo) => { + if (isClosingRef.current) return; + setStage('available'); + setUpdateAvailableInfo(info); + setDownloadedInfo(null); + setLatestVersionInfo(null); + }); + + useWatchBroadcast('manualUpdateNotAvailable', (info: UpdateInfo) => { + if (isClosingRef.current) return; + setStage('latest'); + setLatestVersionInfo(info); + setUpdateAvailableInfo(null); + setDownloadedInfo(null); + setProgress(null); + }); + + useWatchBroadcast('updateDownloadStart', () => { + if (isClosingRef.current) return; + setStage('downloading'); + setProgress({ bytesPerSecond: 0, percent: 0, total: 0, transferred: 0 }); + setUpdateAvailableInfo(null); + setLatestVersionInfo(null); + }); + + useWatchBroadcast('updateDownloadProgress', (progressInfo: ProgressInfo) => { + if (isClosingRef.current) return; + setProgress(progressInfo); + }); + + useWatchBroadcast('updateDownloaded', (info: UpdateInfo) => { + if (isClosingRef.current) return; + setStage('downloaded'); + setDownloadedInfo(info); + setProgress(null); + setUpdateAvailableInfo(null); + setLatestVersionInfo(null); + }); + + useWatchBroadcast('updateError', (message: string) => { + if (isClosingRef.current || errorHandledRef.current) return; + errorHandledRef.current = true; + isClosingRef.current = true; + onClose(); + modal.error({ content: message, title: t('updater.updateError') }); + }); + + const closeModal = () => { + if (isClosingRef.current) return; + errorHandledRef.current = true; + isClosingRef.current = true; + onClose(); + }; + + const handleDownload = () => { + if (!updateAvailableInfo) return; + autoUpdateService.downloadUpdate(); + }; + + const handleInstallNow = () => { + autoUpdateService.installNow(); + closeModal(); + }; + + const handleInstallLater = () => { + autoUpdateService.installLater(); + closeModal(); + }; + + const renderReleaseNotes = (notes?: UpdateInfo['releaseNotes']) => { + if (!notes) return null; + return ( +
+ ); + }; + + const renderBody = () => { + switch (stage) { + case 'checking': { + return ( + +
+ {t('updater.checkingUpdateDesc')} +
+
+ ); + } + case 'available': { + return ( + <> +

+ {t('updater.newVersionAvailableDesc', { version: updateAvailableInfo?.version })} +

+ {renderReleaseNotes(updateAvailableInfo?.releaseNotes)} + + ); + } + case 'downloading': { + const percent = progress ? Math.round(progress.percent) : 0; + return ( +
+ +
+ {t('updater.downloadingUpdateDesc', { percent })} + {progress && progress.bytesPerSecond > 0 && ( + {formatSpeed(progress.bytesPerSecond)} + )} +
+
+ ); + } + case 'downloaded': { + return ( + <> +

{t('updater.updateReadyDesc', { version: downloadedInfo?.version })}

+ {renderReleaseNotes(downloadedInfo?.releaseNotes)} + + ); + } + case 'latest': { + return

{t('updater.isLatestVersionDesc', { version: latestVersionInfo?.version })}

; + } + default: { + return null; + } + } + }; + + const renderActions = () => { + if (stage === 'downloading') return null; + + let actions: React.ReactNode[] = []; + + if (stage === 'checking') { + actions = [ + , + ]; + } + + if (stage === 'available') { + actions = [ + , + , + ]; + } + + if (stage === 'downloaded') { + actions = [ + , + , + ]; + } + + if (stage === 'latest') { + actions = [ + , + ]; + } + + if (actions.length === 0) return null; + + return ( + + {actions} + + ); + }; + + return ( + +
{renderBody()}
+ {renderActions()} +
+ ); +}); + +UpdateModalContent.displayName = 'UpdateModalContent'; + +interface UpdateModalOpenProps { + onAfterClose?: () => void; +} + +export const useUpdateModal = () => { + const instanceRef = useRef(null); + + const open = useCallback((props?: UpdateModalOpenProps) => { + const setModalProps = (nextProps: ModalUpdateOptions) => { + instanceRef.current?.update?.(nextProps); + }; + + const handleClose = () => { + instanceRef.current?.close(); + }; + + instanceRef.current = createModal({ + afterClose: props?.onAfterClose, + children: , + footer: null, + keyboard: true, + maskClosable: true, + title: '', + }); + }, []); + + return { open }; +}; diff --git a/src/features/ElectronTitlebar/UpdateNotification.tsx b/src/features/Electron/updater/UpdateNotification.tsx similarity index 100% rename from src/features/ElectronTitlebar/UpdateNotification.tsx rename to src/features/Electron/updater/UpdateNotification.tsx diff --git a/src/features/ElectronTitlebar/UpdateModal.tsx b/src/features/ElectronTitlebar/UpdateModal.tsx deleted file mode 100644 index 088ae72145..0000000000 --- a/src/features/ElectronTitlebar/UpdateModal.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { - type ProgressInfo, - type UpdateInfo, - useWatchBroadcast, -} from '@lobechat/electron-client-ipc'; -import { Button } from '@lobehub/ui'; -import { App, Modal, Progress, Spin } from 'antd'; -import React, { memo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { autoUpdateService } from '@/services/electron/autoUpdate'; -import { formatSpeed } from '@/utils/format'; - -export const UpdateModal = memo(() => { - const { t } = useTranslation(['electron', 'common']); - - const [isChecking, setIsChecking] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); - // 仅用于手动触发的更新流程(用户从设置页点“检查更新”) - const manualFlowRef = useRef(false); - const [updateAvailableInfo, setUpdateAvailableInfo] = useState(null); - const [downloadedInfo, setDownloadedInfo] = useState(null); - const [progress, setProgress] = useState(null); - const [latestVersionInfo, setLatestVersionInfo] = useState(null); // State for latest version modal - const { modal } = App.useApp(); - // --- Event Listeners --- - - useWatchBroadcast('manualUpdateCheckStart', () => { - console.log('[Manual Update] Check Start'); - manualFlowRef.current = true; - - setIsChecking(true); - setUpdateAvailableInfo(null); - setDownloadedInfo(null); - setProgress(null); - setLatestVersionInfo(null); // Reset latest version info - // Optional: Show a brief notification that check has started - // notification.info({ message: t('updater.checking') }); - }); - - useWatchBroadcast('manualUpdateAvailable', (info: UpdateInfo) => { - console.log('[Manual Update] Available:', info); - // Only react if it's part of a manual check flow (i.e., isChecking was true) - // No need to check isChecking here as this event is specific - setIsChecking(false); - setUpdateAvailableInfo(info); - }); - - useWatchBroadcast('manualUpdateNotAvailable', (info) => { - console.log('[Manual Update] Not Available:', info); - // Only react if it's part of a manual check flow - // No need to check isChecking here as this event is specific - setIsChecking(false); - manualFlowRef.current = false; - - setLatestVersionInfo(info); // Set info for the modal - // notification.success({ - // description: t('updater.isLatestVersionDesc', { version: info.version }), - // message: t('updater.isLatestVersion'), - // }); - }); - - useWatchBroadcast('updateError', (message: string) => { - console.log('[Manual Update] Error:', message); - // Only react if it's part of a manual check/download flow - if (isChecking || isDownloading) { - setIsChecking(false); - setIsDownloading(false); - // Show error modal or notification - modal.error({ content: message, title: t('updater.updateError') }); - setLatestVersionInfo(null); // Ensure other modals are closed on error - setUpdateAvailableInfo(null); - setDownloadedInfo(null); - manualFlowRef.current = false; - } - }); - - useWatchBroadcast('updateDownloadStart', () => { - console.log('[Manual Update] Download Start'); - // This event implies a manual download was triggered (likely from the 'updateAvailable' modal) - manualFlowRef.current = true; - - setIsDownloading(true); - setUpdateAvailableInfo(null); // Hide the 'download' button modal - setProgress({ bytesPerSecond: 0, percent: 0, total: 0, transferred: 0 }); // Reset progress - setLatestVersionInfo(null); // Ensure other modals are closed - // Optional: Show notification that download started - // notification.info({ message: t('updater.downloadingUpdate') }); - }); - - useWatchBroadcast('updateDownloadProgress', (progressInfo: ProgressInfo) => { - console.log('[Manual Update] Progress:', progressInfo); - // Only update progress if we are in the manual download state - setProgress(progressInfo); - }); - - useWatchBroadcast('updateDownloaded', (info: UpdateInfo) => { - console.log('[Manual Update] Downloaded:', info); - // 仅在手动流程里展示阻塞式的“更新就绪”弹窗 - if (manualFlowRef.current) { - setIsChecking(false); - setIsDownloading(false); - setDownloadedInfo(info); - setProgress(null); // Clear progress - setLatestVersionInfo(null); // Ensure other modals are closed - setUpdateAvailableInfo(null); - } - }); - - // --- Render Logic --- - - const handleDownload = () => { - if (!updateAvailableInfo) return; - // No need to set states here, 'updateDownloadStart' will handle it - autoUpdateService.downloadUpdate(); - }; - - const handleInstallNow = () => { - setDownloadedInfo(null); // Close modal immediately - autoUpdateService.installNow(); - manualFlowRef.current = false; - }; - - const handleInstallLater = () => { - // No need to set state here, 'updateWillInstallLater' handles it - autoUpdateService.installLater(); - setDownloadedInfo(null); // Close the modal after clicking - manualFlowRef.current = false; - }; - - const closeAvailableModal = () => setUpdateAvailableInfo(null); - const closeDownloadedModal = () => setDownloadedInfo(null); - const closeLatestVersionModal = () => setLatestVersionInfo(null); - - const handleCancelCheck = () => { - setIsChecking(false); - setUpdateAvailableInfo(null); - setDownloadedInfo(null); - setProgress(null); - setLatestVersionInfo(null); - manualFlowRef.current = false; - }; - - const renderCheckingModal = () => ( - - {t('cancel', { ns: 'common' })} - , - ]} - onCancel={handleCancelCheck} - open={isChecking} - title={t('updater.checkingUpdate')} - > - -
- {t('updater.checkingUpdateDesc')} -
-
-
- ); - - const renderAvailableModal = () => ( - - {t('cancel', { ns: 'common' })} - , - , - ]} - onCancel={closeAvailableModal} - open={!!updateAvailableInfo} - title={t('updater.newVersionAvailable')} - > -

{t('updater.newVersionAvailableDesc', { version: updateAvailableInfo?.version })}

- {updateAvailableInfo?.releaseNotes && ( -
- )} - - ); - - const renderDownloadingModal = () => { - const percent = progress ? Math.round(progress.percent) : 0; - return ( - -
- -
- {t('updater.downloadingUpdateDesc', { percent })} - {progress && progress.bytesPerSecond > 0 && ( - {formatSpeed(progress.bytesPerSecond)} - )} -
-
-
- ); - }; - - const renderDownloadedModal = () => ( - - {t('updater.installLater')} - , - , - ]} - onCancel={closeDownloadedModal} // Allow closing if they don't want to decide now - open={!!downloadedInfo} - title={t('updater.updateReady')} - > -

{t('updater.updateReadyDesc', { version: downloadedInfo?.version })}

- {downloadedInfo?.releaseNotes && ( -
- )} - - ); - - // New modal for "latest version" - const renderLatestVersionModal = () => ( - - {t('ok', { ns: 'common' })} - , - ]} - onCancel={closeLatestVersionModal} - open={!!latestVersionInfo} - title={t('updater.isLatestVersion')} - > -

{t('updater.isLatestVersionDesc', { version: latestVersionInfo?.version })}

-
- ); - - return ( - <> - {renderCheckingModal()} - {renderAvailableModal()} - {renderDownloadingModal()} - {renderDownloadedModal()} - {renderLatestVersionModal()} - {/* Error state is handled by Modal.error currently */} - - ); -}); diff --git a/src/features/ElectronTitlebar/WinControl/index.tsx b/src/features/ElectronTitlebar/WinControl/index.tsx deleted file mode 100644 index f8c1cc07f8..0000000000 --- a/src/features/ElectronTitlebar/WinControl/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -// const useStyles = createStyles(({ css, cx, token }) => { -// const icon = css` -// display: flex; -// align-items: center; -// justify-content: center; -// -// width: ${TITLE_BAR_HEIGHT * 1.2}px; -// min-height: ${TITLE_BAR_HEIGHT}px; -// -// color: ${token.colorTextSecondary}; -// -// transition: all ease-in-out 100ms; -// -// -webkit-app-region: no-drag; -// -// &:hover { -// color: ${token.colorText}; -// background: ${token.colorFillTertiary}; -// } -// -// &:active { -// color: ${token.colorText}; -// background: ${token.colorFillSecondary}; -// } -// `; -// return { -// close: cx( -// icon, -// css` -// padding-inline-end: 2px; -// -// &:hover { -// color: ${token.colorTextLightSolid}; -// -// /* win11 的色值,亮暗色均不变 */ -// background: #d33328; -// } -// -// &:active { -// color: ${token.colorTextLightSolid}; -// -// /* win11 的色值 */ -// background: #8b2b25; -// } -// `, -// ), -// container: css` -// cursor: pointer; -// display: flex; -// `, -// icon, -// }; -// }); - -const WinControl = () => { - return
; - - // const { styles } = useStyles(); - // - // return ( - //
- //
{ - // electronSystemService.minimizeWindow(); - // }} - // > - // - //
- //
{ - // electronSystemService.maximizeWindow(); - // }} - // > - // - //
- //
{ - // electronSystemService.closeWindow(); - // }} - // > - // - //
- //
- // ); -}; - -export default WinControl; diff --git a/src/features/LibraryModal/AddFilesToKnowledgeBase/index.test.tsx b/src/features/LibraryModal/AddFilesToKnowledgeBase/index.test.tsx new file mode 100644 index 0000000000..7d9600bd7e --- /dev/null +++ b/src/features/LibraryModal/AddFilesToKnowledgeBase/index.test.tsx @@ -0,0 +1,24 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useAddFilesToKnowledgeBaseModal } from './index'; + +const mockCreateModal = vi.hoisted(() => vi.fn()); + +vi.mock('@lobehub/ui', () => ({ + Flexbox: () => null, + Icon: () => null, + createModal: mockCreateModal, + useModalContext: () => ({ close: vi.fn() }), +})); + +describe('useAddFilesToKnowledgeBaseModal', () => { + it('should forward onClose to createModal afterClose', () => { + const onClose = vi.fn(); + const { result } = renderHook(() => useAddFilesToKnowledgeBaseModal()); + + result.current.open({ fileIds: ['file-1'], onClose }); + + expect(mockCreateModal).toHaveBeenCalledWith(expect.objectContaining({ afterClose: onClose })); + }); +}); diff --git a/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx b/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx index 20f75a9ccf..a9291fc5b3 100644 --- a/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx +++ b/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx @@ -1,10 +1,8 @@ -import { Flexbox, Icon } from '@lobehub/ui'; +import { Flexbox, Icon, createModal, useModalContext } from '@lobehub/ui'; import { BookUp2Icon } from 'lucide-react'; -import { Suspense, memo } from 'react'; +import { Suspense, memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { createModal } from '@/components/FunctionModal'; - import SelectForm from './SelectForm'; interface AddFilesToKnowledgeBaseModalProps { @@ -16,12 +14,11 @@ interface AddFilesToKnowledgeBaseModalProps { interface ModalContentProps { fileIds: string[]; knowledgeBaseId?: string; - onClose?: () => void; } -const ModalContent = memo(({ fileIds, knowledgeBaseId, onClose }) => { +const ModalContent = memo(({ fileIds, knowledgeBaseId }) => { const { t } = useTranslation('knowledgeBase'); - + const { close } = useModalContext(); return ( <> @@ -29,7 +26,7 @@ const ModalContent = memo(({ fileIds, knowledgeBaseId, onClos {t('addToKnowledgeBase.title')} - + ); @@ -37,19 +34,19 @@ const ModalContent = memo(({ fileIds, knowledgeBaseId, onClos ModalContent.displayName = 'AddFilesToKnowledgeBaseModalContent'; -export const useAddFilesToKnowledgeBaseModal = createModal( - (instance, params) => ({ - content: ( - }> - { - instance.current?.destroy(); - params?.onClose?.(); - }} - /> - - ), - }), -); +export const useAddFilesToKnowledgeBaseModal = () => { + const open = useCallback((params?: AddFilesToKnowledgeBaseModalProps) => { + createModal({ + afterClose: params?.onClose, + children: ( + }> + + + ), + footer: null, + title: null, + }); + }, []); + + return { open }; +}; diff --git a/src/features/LibraryModal/CreateNew/index.tsx b/src/features/LibraryModal/CreateNew/index.tsx index 671f8e9322..b2ae6ad5dc 100644 --- a/src/features/LibraryModal/CreateNew/index.tsx +++ b/src/features/LibraryModal/CreateNew/index.tsx @@ -1,41 +1,37 @@ -import { Flexbox } from '@lobehub/ui'; -import { Suspense, memo } from 'react'; - -import { createModal } from '@/components/FunctionModal'; +import { Flexbox, createModal, useModalContext } from '@lobehub/ui'; +import { Suspense, memo, useCallback } from 'react'; import CreateForm from './CreateForm'; interface ModalContentProps { - onClose?: () => void; onSuccess?: (id: string) => void; } -const ModalContent = memo(({ onClose, onSuccess }) => { +const ModalContent = memo(({ onSuccess }) => { + const { close } = useModalContext(); + return ( - + ); }); ModalContent.displayName = 'KnowledgeBaseCreateModalContent'; -// eslint-disable-next-line unused-imports/no-unused-vars -export const useCreateNewModal = createModal<{ onSuccess?: (id: string) => void }>( - (instance, props) => { - return { - content: ( +export const useCreateNewModal = () => { + const open = useCallback((props?: { onSuccess?: (id: string) => void }) => { + createModal({ + children: ( }> - { - instance.current?.destroy(); - }} - onSuccess={props?.onSuccess} - /> + ), focusTriggerAfterClose: true, - footer: false, - }; - }, -); + footer: null, + title: null, + }); + }, []); + + return { open }; +}; diff --git a/src/features/PluginDevModal/index.tsx b/src/features/PluginDevModal/index.tsx index f6b46f6fa1..47951a52fc 100644 --- a/src/features/PluginDevModal/index.tsx +++ b/src/features/PluginDevModal/index.tsx @@ -1,3 +1,4 @@ +import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge'; import { Alert, Button, Drawer, Flexbox, Icon, Segmented, Tag } from '@lobehub/ui'; import { App, Form, Popconfirm } from 'antd'; import { useResponsive } from 'antd-style'; @@ -7,7 +8,6 @@ import { Trans, useTranslation } from 'react-i18next'; import { WIKI_PLUGIN_GUIDE } from '@/const/url'; import { isDesktop } from '@/const/version'; -import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar'; import { type LobeToolCustomPlugin } from '@/types/tool/plugin'; import MCPManifestForm from './MCPManifestForm'; diff --git a/src/layout/GlobalProvider/AppTheme.tsx b/src/layout/GlobalProvider/AppTheme.tsx index 14a6bfe65c..e0e64624b6 100644 --- a/src/layout/GlobalProvider/AppTheme.tsx +++ b/src/layout/GlobalProvider/AppTheme.tsx @@ -1,5 +1,6 @@ 'use client'; +import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge'; import { ConfigProvider, FontLoader, @@ -19,7 +20,6 @@ import { type ReactNode, memo, useEffect, useMemo, useState } from 'react'; import AntdStaticMethods from '@/components/AntdStaticMethods'; import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/theme'; import { isDesktop } from '@/const/version'; -import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar'; import { useIsDark } from '@/hooks/useIsDark'; import { getUILocaleAndResources } from '@/libs/getUILocaleAndResources'; import { useGlobalStore } from '@/store/global'; diff --git a/src/store/aiInfra/slices/aiProvider/action.ts b/src/store/aiInfra/slices/aiProvider/action.ts index 10b86f7573..b969747450 100644 --- a/src/store/aiInfra/slices/aiProvider/action.ts +++ b/src/store/aiInfra/slices/aiProvider/action.ts @@ -29,7 +29,6 @@ import { type UpdateAiProviderParams, } from '@/types/aiProvider'; - export type ProviderModelListItem = { abilities: ModelAbilities; approximatePricePerImage?: number; @@ -77,10 +76,10 @@ export const normalizeImageModel = async ( const fallbackParametersPromise = model.parameters ? Promise.resolve(model.parameters) : getModelPropertyWithFallback( - model.id, - 'parameters', - model.providerId, - ); + model.id, + 'parameters', + model.providerId, + ); const modelWithPricing = model as AIImageModelCard; const fallbackPricingPromise = modelWithPricing.pricing @@ -321,23 +320,23 @@ export const createAiProviderSlice: StateCreator< aiProviderDetailMap: currentDetailConfig && Object.keys(detailUpdates).length > 0 ? { - ...state.aiProviderDetailMap, - [id]: { - ...currentDetailConfig, - ...detailUpdates, - }, - } + ...state.aiProviderDetailMap, + [id]: { + ...currentDetailConfig, + ...detailUpdates, + }, + } : state.aiProviderDetailMap, // Update runtime config for selectors aiProviderRuntimeConfig: currentRuntimeConfig && Object.keys(updates).length > 0 ? { - ...state.aiProviderRuntimeConfig, - [id]: { - ...currentRuntimeConfig, - ...updates, - }, - } + ...state.aiProviderRuntimeConfig, + [id]: { + ...currentRuntimeConfig, + ...updates, + }, + } : state.aiProviderRuntimeConfig, }; }, diff --git a/src/store/chat/slices/portal/action.test.ts b/src/store/chat/slices/portal/action.test.ts index abfd0ea147..214acf7cc7 100644 --- a/src/store/chat/slices/portal/action.test.ts +++ b/src/store/chat/slices/portal/action.test.ts @@ -203,7 +203,6 @@ describe('chatDockSlice', () => { }); }); - describe('closeToolUI', () => { it('should pop ToolUI view from stack', () => { const { result } = renderHook(() => useChatStore()); @@ -267,5 +266,4 @@ describe('chatDockSlice', () => { expect(result.current.showPortal).toBe(true); }); }); - }); diff --git a/src/store/chat/slices/portal/action.ts b/src/store/chat/slices/portal/action.ts index 30c74c13ec..bc94d28892 100644 --- a/src/store/chat/slices/portal/action.ts +++ b/src/store/chat/slices/portal/action.ts @@ -42,71 +42,57 @@ export const chatPortalSlice: StateCreator< [], ChatPortalAction > = (set, get) => ({ - - clearPortalStack: () => { set({ portalStack: [], showPortal: false }, false, 'clearPortalStack'); }, - -closeArtifact: () => { + closeArtifact: () => { const { portalStack } = get(); if (getCurrentViewType(portalStack) === PortalViewType.Artifact) { get().popPortalView(); } }, - -closeDocument: () => { + closeDocument: () => { const { portalStack } = get(); if (getCurrentViewType(portalStack) === PortalViewType.Document) { get().popPortalView(); } }, - -closeFilePreview: () => { + closeFilePreview: () => { const { portalStack } = get(); if (getCurrentViewType(portalStack) === PortalViewType.FilePreview) { get().popPortalView(); } }, - -closeMessageDetail: () => { + closeMessageDetail: () => { const { portalStack } = get(); if (getCurrentViewType(portalStack) === PortalViewType.MessageDetail) { get().popPortalView(); } }, - -closeNotebook: () => { + closeNotebook: () => { const { portalStack } = get(); if (getCurrentViewType(portalStack) === PortalViewType.Notebook) { get().popPortalView(); } }, - - - -closeToolUI: () => { + closeToolUI: () => { const { portalStack } = get(); if (getCurrentViewType(portalStack) === PortalViewType.ToolUI) { get().popPortalView(); } }, - - -goBack: () => { + goBack: () => { get().popPortalView(); }, - - -goHome: () => { + goHome: () => { set( { portalStack: [{ type: PortalViewType.Home }], @@ -117,45 +103,32 @@ goHome: () => { ); }, - - -// ============== Convenience Methods (using stack operations) ============== -openArtifact: (artifact) => { + // ============== Convenience Methods (using stack operations) ============== + openArtifact: (artifact) => { get().pushPortalView({ artifact, type: PortalViewType.Artifact }); }, - - - -openDocument: (documentId) => { + openDocument: (documentId) => { get().pushPortalView({ documentId, type: PortalViewType.Document }); }, - - - -openFilePreview: (file) => { + openFilePreview: (file) => { get().pushPortalView({ file, type: PortalViewType.FilePreview }); }, - - -openMessageDetail: (messageId) => { + openMessageDetail: (messageId) => { get().pushPortalView({ messageId, type: PortalViewType.MessageDetail }); }, - -openNotebook: () => { + openNotebook: () => { get().pushPortalView({ type: PortalViewType.Notebook }); }, - -openToolUI: (messageId, identifier) => { + openToolUI: (messageId, identifier) => { get().pushPortalView({ identifier, messageId, type: PortalViewType.ToolUI }); }, - -popPortalView: () => { + popPortalView: () => { const { portalStack } = get(); if (portalStack.length <= 1) { @@ -167,7 +140,7 @@ popPortalView: () => { }, // ============== Core Stack Operations ============== -pushPortalView: (view) => { + pushPortalView: (view) => { const { portalStack } = get(); const top = portalStack.at(-1); diff --git a/src/store/chat/slices/thread/action.test.ts b/src/store/chat/slices/thread/action.test.ts index a9f676db5c..3e47fd5706 100644 --- a/src/store/chat/slices/thread/action.test.ts +++ b/src/store/chat/slices/thread/action.test.ts @@ -150,7 +150,10 @@ describe('thread action', () => { expect(result.current.threadStartMessageId).toBe('message-id'); expect(result.current.portalThreadId).toBeUndefined(); expect(result.current.startToForkThread).toBe(true); - expect(pushPortalViewSpy).toHaveBeenCalledWith({ type: 'thread', startMessageId: 'message-id' }); + expect(pushPortalViewSpy).toHaveBeenCalledWith({ + type: 'thread', + startMessageId: 'message-id', + }); }); }); diff --git a/src/store/chat/slices/thread/action.ts b/src/store/chat/slices/thread/action.ts index 64d8c08698..9d2af644f1 100644 --- a/src/store/chat/slices/thread/action.ts +++ b/src/store/chat/slices/thread/action.ts @@ -2,7 +2,12 @@ // Disable the auto sort key eslint rule to make the code more logic and readable import { LOADING_FLAT } from '@lobechat/const'; import { chainSummaryTitle } from '@lobechat/prompts'; -import { type CreateMessageParams, type IThreadType, type ThreadItem, type UIChatMessage } from '@lobechat/types'; +import { + type CreateMessageParams, + type IThreadType, + type ThreadItem, + type UIChatMessage, +} from '@lobechat/types'; import isEqual from 'fast-deep-equal'; import type { SWRResponse } from 'swr'; import { type StateCreator } from 'zustand/vanilla';