From 959c210e869803545d451c3019e178966188ef17 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 15 Jan 2026 17:26:19 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(desktop):=20add=20local=20upda?= =?UTF-8?q?te=20testing=20scripts=20and=20stable=20channel=20API=20version?= =?UTF-8?q?=20check=20(#11474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: stable updater * ✨ feat: add local update testing scripts and configuration - Introduced scripts for local update testing, including setup, server management, and manifest generation. - Added `dev-app-update.local.yml` for local server configuration. - Implemented `generate-manifest.sh` to create update manifests. - Created `run-test.sh` for streamlined testing process. - Updated `README.md` with instructions for local testing setup and usage. - Enhanced `UpdaterManager` to allow forced use of dev update configuration in packaged apps. Signed-off-by: Innei * 🐛 fix(desktop): update UpdaterManager test mocks for new exports Add missing mock exports for @/modules/updater/configs: - isStableChannel - githubConfig - UPDATE_SERVER_URL Add mock for @/env with getDesktopEnv Add setFeedURL method to autoUpdater mock * ✨ feat: add Conductor setup scripts and configuration * ✨ feat: enhance update modal functionality and refactor modal hooks - Added `useUpdateModal` for managing update modal state and behavior. - Refactored `UpdateModal` to utilize new modal management approach. - Improved `useWatchBroadcast` integration for handling update events. - Removed deprecated `createModalHooks` and related components from `FunctionModal`. - Updated `AddFilesToKnowledgeBase` and `CreateNew` modals to use new modal context for closing behavior. This refactor streamlines modal management and enhances the user experience during update processes. Signed-off-by: Innei * update flow (#11513) * ci: simplify desktop release workflow and add renderer tarball * 👷 ci: fix s3 upload credentials for desktop release * 🐛 fix(ci): use compact jq output for GitHub Actions matrix Add -c flag to jq commands to produce single-line JSON output, fixing "Invalid format" error when setting GITHUB_OUTPUT. * 🐛 fix(ci): add administration permission to detect self-hosted runner The /actions/runners API requires administration:read permission to list repository runners. * 🔧 refactor(ci): use workflow input for self-hosted runner selection Replace API-based runner detection with workflow input parameter since GITHUB_TOKEN lacks permission to call /actions/runners API. - Add `use_self_hosted_mac` input (default: true) - Release events always use self-hosted runner - Manual dispatch can toggle via input * feat(updater): add stable channel support with fallback mechanism - Configure electron-builder to generate stable-mac.yml for stable channel - Update CI workflow to handle both stable and latest manifest files - Implement fallback to GitHub provider when primary S3 provider fails - Reset to primary provider after successful update check * 🐛 fix(updater): remove invalid channel config from electron-builder - Remove unsupported 'channel' property from electron-builder config - Create stable*.yml files from latest*.yml in workflow instead - This ensures electron-updater finds correct manifest for stable channel * 🐛 fix(updater): use correct channel based on provider type - S3 provider: channel='stable' → looks for stable-mac.yml - GitHub provider: channel='latest' → looks for latest-mac.yml This fixes the 404 error when falling back to GitHub releases, which only have latest-mac.yml files. * refactor(env): remove unused OFFICIAL_CLOUD_SERVER and update env defaults Update environment variable handling by removing unused OFFICIAL_CLOUD_SERVER and setting defaults for UPDATE_CHANNEL and UPDATE_SERVER_URL from process.env during build stage. * 🐛 fix(ci): add version prefix to stable manifest URLs for S3 S3 directory structure: stable/{version}/xxx.dmg So stable-mac.yml URLs need version prefix: url: LobeHub-2.1.0-arm64.dmg → url: 2.1.1/LobeHub-2.1.0-arm64.dmg * ✨ feat(ci): add renderer tar manifest for integrity verification Creates stable-renderer.yml with SHA512 checksum for lobehub-renderer.tar.gz This allows the desktop app to verify renderer tarball integrity before extraction. * 🐛 fix(ci): fix YAML syntax error in renderer manifest generation * ✨ feat(ci): archive manifest files in version directory * refactor(ci): update desktop release workflows to streamline build process - Removed unnecessary dependencies in the build job for the desktop beta workflow. - Introduced a new gate job to conditionally proceed with publishing based on the success of previous jobs. - Updated macOS file merging to depend on the new gate job instead of the build job. - Simplified macOS runner selection logic in the stable workflow by using GitHub-hosted runners exclusively. Signed-off-by: Innei * refactor(electron): reorganize titlebar components and update imports - Moved titlebar components to a new directory structure for better organization. - Updated import paths for `SimpleTitleBar`, `TitleBar`, and related constants. - Introduced new components for connection management and navigation within the titlebar. - Added constants for title bar height to maintain consistency across components. This refactor enhances the maintainability of the titlebar code and improves the overall structure of the Electron application. Signed-off-by: Innei * feat(ci): add release notes handling to desktop stable workflow - Enhanced the desktop stable release workflow to include release notes. - Updated output variables to capture release notes from the GitHub event. - Adjusted environment variables in subsequent jobs to utilize the new release notes data. This addition improves the clarity and documentation of releases by ensuring that release notes are included in the workflow process. Signed-off-by: Innei * 🐛 fix: call onClose after knowledge base modal closes * 🧪 test: fix UpdaterManager update channel mocks --------- Signed-off-by: Innei --- .conductor/setup.sh | 107 ++++ .../actions/desktop-build-setup/action.yml | 29 ++ .../desktop-upload-artifacts/action.yml | 46 ++ .github/workflows/release-desktop-beta.yml | 191 +++----- .github/workflows/release-desktop-stable.yml | 461 ++++++++++++++++++ apps/desktop/dev-app-update.yml | 10 + apps/desktop/electron-builder.mjs | 50 +- apps/desktop/electron.vite.config.ts | 5 +- apps/desktop/package.json | 3 +- apps/desktop/scripts/update-test/README.md | 222 +++++++++ .../update-test/dev-app-update.local.yml | 18 + .../scripts/update-test/generate-manifest.sh | 277 +++++++++++ apps/desktop/scripts/update-test/run-test.sh | 105 ++++ apps/desktop/scripts/update-test/setup.sh | 111 +++++ .../scripts/update-test/start-server.sh | 70 +++ .../scripts/update-test/stop-server.sh | 33 ++ .../core/infrastructure/UpdaterManager.ts | 129 ++++- .../__tests__/UpdaterManager.test.ts | 18 +- apps/desktop/src/main/env.ts | 36 +- .../src/main/modules/updater/configs.ts | 15 +- conductor.json | 5 + locales/en-US/subscription.json | 4 +- locales/zh-CN/subscription.json | 4 +- .../Render/CreateDocument/DocumentCard.tsx | 30 +- .../src/useWatchBroadcast.ts | 14 +- packages/model-bank/src/aiModels/qiniu.ts | 12 +- packages/observability-otel/src/node.ts | 76 +-- .../electronWorkflow/mergeMacReleaseFiles.js | 30 +- src/app/(backend)/api/version/route.ts | 13 + .../desktop-onboarding/_layout/index.tsx | 3 +- src/app/[variants]/(main)/_layout/index.tsx | 3 +- .../features/CronJobScheduleConfig.tsx | 1 - .../(main)/agent/cron/[cronId]/index.tsx | 10 +- .../features/Conversation/ThreadHydration.tsx | 4 +- .../features/Conversation/ThreadHydration.tsx | 4 +- .../features/ProviderConfig/Checker.tsx | 6 +- .../(mobile)/router/mobileRouter.config.tsx | 5 +- .../router/desktopRouter.config.tsx | 5 +- .../FunctionModal/createModalHooks.ts | 48 -- src/components/FunctionModal/index.ts | 1 - src/components/FunctionModal/style.tsx | 44 -- src/components/HtmlPreview/PreviewDrawer.tsx | 2 +- .../AssistantGroup/components/GroupItem.tsx | 14 +- .../Conversation/Messages/Tool/Tool/index.tsx | 11 +- .../connection/Connection.tsx} | 0 .../connection}/ConnectionMode.tsx | 0 .../connection}/Option.tsx | 0 .../connection}/RemoteStatus.tsx | 0 .../connection}/Waiting.tsx | 0 .../connection}/WaitingAnim.tsx | 0 .../navigation}/routeMetadata.ts | 0 .../navigation}/useNavigationHistory.ts | 2 +- .../system}/useWatchThemeUpdate.ts | 0 .../titlebar/NavigationBar.tsx} | 2 +- .../titlebar}/RecentlyViewed.tsx | 2 +- .../titlebar}/SimpleTitleBar.tsx | 0 .../titlebar/TitleBar.tsx} | 28 +- src/features/Electron/titlebar/WinControl.tsx | 5 + src/features/Electron/updater/UpdateModal.tsx | 299 ++++++++++++ .../updater}/UpdateNotification.tsx | 0 src/features/ElectronTitlebar/UpdateModal.tsx | 274 ----------- .../ElectronTitlebar/WinControl/index.tsx | 90 ---- .../AddFilesToKnowledgeBase/index.test.tsx | 24 + .../AddFilesToKnowledgeBase/index.tsx | 45 +- src/features/LibraryModal/CreateNew/index.tsx | 40 +- src/features/PluginDevModal/index.tsx | 2 +- src/layout/GlobalProvider/AppTheme.tsx | 2 +- src/store/aiInfra/slices/aiProvider/action.ts | 33 +- src/store/chat/slices/portal/action.test.ts | 2 - src/store/chat/slices/portal/action.ts | 61 +-- src/store/chat/slices/thread/action.test.ts | 5 +- src/store/chat/slices/thread/action.ts | 7 +- 72 files changed, 2375 insertions(+), 833 deletions(-) create mode 100755 .conductor/setup.sh create mode 100644 .github/actions/desktop-build-setup/action.yml create mode 100644 .github/actions/desktop-upload-artifacts/action.yml create mode 100644 .github/workflows/release-desktop-stable.yml create mode 100644 apps/desktop/scripts/update-test/README.md create mode 100644 apps/desktop/scripts/update-test/dev-app-update.local.yml create mode 100755 apps/desktop/scripts/update-test/generate-manifest.sh create mode 100755 apps/desktop/scripts/update-test/run-test.sh create mode 100755 apps/desktop/scripts/update-test/setup.sh create mode 100755 apps/desktop/scripts/update-test/start-server.sh create mode 100755 apps/desktop/scripts/update-test/stop-server.sh create mode 100644 conductor.json create mode 100644 src/app/(backend)/api/version/route.ts delete mode 100644 src/components/FunctionModal/createModalHooks.ts delete mode 100644 src/components/FunctionModal/index.ts delete mode 100644 src/components/FunctionModal/style.tsx rename src/features/{ElectronTitlebar/Connection/index.tsx => Electron/connection/Connection.tsx} (100%) rename src/features/{ElectronTitlebar/Connection => Electron/connection}/ConnectionMode.tsx (100%) rename src/features/{ElectronTitlebar/Connection => Electron/connection}/Option.tsx (100%) rename src/features/{ElectronTitlebar/Connection => Electron/connection}/RemoteStatus.tsx (100%) rename src/features/{ElectronTitlebar/Connection => Electron/connection}/Waiting.tsx (100%) rename src/features/{ElectronTitlebar/Connection => Electron/connection}/WaitingAnim.tsx (100%) rename src/features/{ElectronTitlebar/helpers => Electron/navigation}/routeMetadata.ts (100%) rename src/features/{ElectronTitlebar/hooks => Electron/navigation}/useNavigationHistory.ts (98%) rename src/features/{ElectronTitlebar/hooks => Electron/system}/useWatchThemeUpdate.ts (100%) rename src/features/{ElectronTitlebar/NavigationBar/index.tsx => Electron/titlebar/NavigationBar.tsx} (97%) rename src/features/{ElectronTitlebar/NavigationBar => Electron/titlebar}/RecentlyViewed.tsx (98%) rename src/features/{ElectronTitlebar => Electron/titlebar}/SimpleTitleBar.tsx (100%) rename src/features/{ElectronTitlebar/index.tsx => Electron/titlebar/TitleBar.tsx} (67%) create mode 100644 src/features/Electron/titlebar/WinControl.tsx create mode 100644 src/features/Electron/updater/UpdateModal.tsx rename src/features/{ElectronTitlebar => Electron/updater}/UpdateNotification.tsx (100%) delete mode 100644 src/features/ElectronTitlebar/UpdateModal.tsx delete mode 100644 src/features/ElectronTitlebar/WinControl/index.tsx create mode 100644 src/features/LibraryModal/AddFilesToKnowledgeBase/index.test.tsx 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';