feat(desktop): add local update testing scripts and stable channel API version check (#11474)

* 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 <tukon479@gmail.com>

* 🐛 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 <tukon479@gmail.com>

* 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 <tukon479@gmail.com>

* 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 <tukon479@gmail.com>

* 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 <tukon479@gmail.com>

* 🐛 fix: call onClose after knowledge base modal closes

* 🧪 test: fix UpdaterManager update channel mocks

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-01-15 17:26:19 +08:00 committed by GitHub
parent 41801649b6
commit 959c210e86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 2375 additions and 833 deletions

107
.conductor/setup.sh Executable file
View file

@ -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."

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -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<<EOF"
printf '%s\n' "$release_body"
echo "EOF"
} >> $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

View file

@ -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

View file

@ -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',
},

View file

@ -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: {

View file

@ -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",

View file

@ -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. 不要将未签名的包分发给其他用户

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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 ""

View file

@ -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

View file

@ -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"

View file

@ -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);
}

View file

@ -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');
});

View file

@ -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,
}),
);

View file

@ -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 = {
// 应用更新配置

5
conductor.json Normal file
View file

@ -0,0 +1,5 @@
{
"scripts": {
"setup": "bash \"$CONDUCTOR_ROOT_PATH/.conductor/setup.sh\""
}
}

View file

@ -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",

View file

@ -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": "余额:从低到高",

View file

@ -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,6 +85,7 @@ const DocumentCard = memo<DocumentCardProps>(({ document }) => {
<Flexbox flex={1}>
<div className={styles.title}>{document.title}</div>
</Flexbox>
<TooltipGroup>
<Flexbox gap={4} horizontal>
<CopyButton
content={document.content}
@ -98,6 +99,7 @@ const DocumentCard = memo<DocumentCardProps>(({ document }) => {
title={t('builtins.lobe-notebook.actions.edit')}
/>
</Flexbox>
</TooltipGroup>
</Flexbox>
{/* Content */}
<ScrollShadow className={styles.content} offset={12} size={12} style={{ maxHeight: 400 }}>

View file

@ -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 = <T extends MainBroadcastEventKey>(
event: T,
handler: (data: MainBroadcastParams<T>) => void,
) => {
const handlerRef = useRef<typeof handler>(handler);
useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
if (!window.electron) return;
const listener = (e: any, data: MainBroadcastParams<T>) => {
handler(data);
const listener = (_e: any, data: MainBroadcastParams<T>) => {
handlerRef.current(data);
};
window.electron.ipcRenderer.on(event, listener);
@ -33,5 +39,5 @@ export const useWatchBroadcast = <T extends MainBroadcastEventKey>(
return () => {
window.electron.ipcRenderer.removeListener(event, listener);
};
}, []);
}, [event]);
};

View file

@ -21,7 +21,7 @@ const qiniuChatModels: AIChatModelCard[] = [
},
contextWindowTokens: 65_536,
description:
"DeepSeek R1 is DeepSeeks latest open model with very strong reasoning, matching OpenAIs o1 on math, programming, and reasoning tasks.",
'DeepSeek R1 is DeepSeeks latest open model with very strong reasoning, matching OpenAIs o1 on math, programming, and reasoning tasks.',
displayName: 'DeepSeek R1',
enabled: true,
id: 'deepseek-r1',
@ -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: [
@ -112,7 +112,7 @@ const qiniuChatModels: AIChatModelCard[] = [
},
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.',
"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',

View file

@ -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,
);
}

View file

@ -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. 验证合并结果

View file

@ -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);
}

View file

@ -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';

View file

@ -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';

View file

@ -187,7 +187,6 @@ const CronJobScheduleConfig = memo<CronJobScheduleConfigProps>(
style={{ maxWidth: 300, minWidth: 200 }}
value={timezone}
/>
</Flexbox>
{/* Max Executions */}

View file

@ -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]);

View file

@ -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]);

View file

@ -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',
},
],

View file

@ -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',
},
],

View file

@ -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<T = undefined> = (
instance: MutableRefObject<ModalInstance | undefined>,
props?: T,
) => CreateModalProps;
const createModal = <T>(params: CreateModalProps | PropsFunc<T>) => {
const useModal = () => {
const { modal } = App.useApp();
const instanceRef = useRef<ModalInstance>(null);
const open = (outProps?: T) => {
const props =
typeof params === 'function'
? params(instanceRef as RefObject<ModalInstance>, 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 };

View file

@ -1 +0,0 @@
export * from './createModalHooks';

View file

@ -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 = <Icon icon={XIcon} size={20} />;

View file

@ -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`

View file

@ -25,10 +25,20 @@ const GroupItem = memo<GroupItemProps>(
toggleMessageEditing(item.id, true);
}}
>
<ContentBlock {...item} assistantId={assistantId} disableEditing={disableEditing} error={error} />
<ContentBlock
{...item}
assistantId={assistantId}
disableEditing={disableEditing}
error={error}
/>
</Flexbox>
) : (
<ContentBlock {...item} assistantId={assistantId} disableEditing={disableEditing} error={error} />
<ContentBlock
{...item}
assistantId={assistantId}
disableEditing={disableEditing}
error={error}
/>
);
},
isEqual,

View file

@ -33,7 +33,16 @@ export interface InspectorProps {
* Tool message component - adapts Tool message data to use AssistantGroup/Tool components
*/
const Tool = memo<InspectorProps>(
({ 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);

View file

@ -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

View file

@ -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();

View file

@ -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`

View file

@ -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(() => {
</>
)}
</Flexbox>
<UpdateModal />
</Flexbox>
);
});
export default TitleBar;
export { default as SimpleTitleBar } from './SimpleTitleBar';
export { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';

View file

@ -0,0 +1,5 @@
const WinControl = () => {
return <div style={{ width: 132 }} />;
};
export default WinControl;

View file

@ -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<UpdateModalContentProps>(({ onClose, setModalProps }) => {
const { t } = useTranslation(['electron', 'common']);
const { modal } = App.useApp();
const errorHandledRef = useRef(false);
const isClosingRef = useRef(false);
const [stage, setStage] = useState<UpdateStage>('checking');
const [updateAvailableInfo, setUpdateAvailableInfo] = useState<UpdateInfo | null>(null);
const [downloadedInfo, setDownloadedInfo] = useState<UpdateInfo | null>(null);
const [progress, setProgress] = useState<ProgressInfo | null>(null);
const [latestVersionInfo, setLatestVersionInfo] = useState<UpdateInfo | null>(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 (
<div
dangerouslySetInnerHTML={{ __html: notes as string }}
style={{
borderRadius: 4,
marginTop: 8,
maxHeight: 300,
overflow: 'auto',
padding: '8px 12px',
}}
/>
);
};
const renderBody = () => {
switch (stage) {
case 'checking': {
return (
<Spin spinning>
<div style={{ padding: '20px', textAlign: 'center' }}>
{t('updater.checkingUpdateDesc')}
</div>
</Spin>
);
}
case 'available': {
return (
<>
<h4>
{t('updater.newVersionAvailableDesc', { version: updateAvailableInfo?.version })}
</h4>
{renderReleaseNotes(updateAvailableInfo?.releaseNotes)}
</>
);
}
case 'downloading': {
const percent = progress ? Math.round(progress.percent) : 0;
return (
<div style={{ padding: '20px 0' }}>
<Progress percent={percent} status="active" />
<div style={{ fontSize: 12, marginTop: 8, textAlign: 'center' }}>
{t('updater.downloadingUpdateDesc', { percent })}
{progress && progress.bytesPerSecond > 0 && (
<span>{formatSpeed(progress.bytesPerSecond)}</span>
)}
</div>
</div>
);
}
case 'downloaded': {
return (
<>
<h4>{t('updater.updateReadyDesc', { version: downloadedInfo?.version })}</h4>
{renderReleaseNotes(downloadedInfo?.releaseNotes)}
</>
);
}
case 'latest': {
return <p>{t('updater.isLatestVersionDesc', { version: latestVersionInfo?.version })}</p>;
}
default: {
return null;
}
}
};
const renderActions = () => {
if (stage === 'downloading') return null;
let actions: React.ReactNode[] = [];
if (stage === 'checking') {
actions = [
<Button key="cancel" onClick={closeModal}>
{t('cancel', { ns: 'common' })}
</Button>,
];
}
if (stage === 'available') {
actions = [
<Button key="cancel" onClick={closeModal}>
{t('cancel', { ns: 'common' })}
</Button>,
<Button key="download" onClick={handleDownload} type="primary">
{t('updater.downloadNewVersion')}
</Button>,
];
}
if (stage === 'downloaded') {
actions = [
<Button key="later" onClick={handleInstallLater}>
{t('updater.installLater')}
</Button>,
<Button key="now" onClick={handleInstallNow} type="primary">
{t('updater.restartAndInstall')}
</Button>,
];
}
if (stage === 'latest') {
actions = [
<Button key="ok" onClick={closeModal} type="primary">
{t('ok', { ns: 'common' })}
</Button>,
];
}
if (actions.length === 0) return null;
return (
<Flexbox gap={8} horizontal justify="end">
{actions}
</Flexbox>
);
};
return (
<Flexbox gap={16} style={{ padding: 16 }}>
<div>{renderBody()}</div>
{renderActions()}
</Flexbox>
);
});
UpdateModalContent.displayName = 'UpdateModalContent';
interface UpdateModalOpenProps {
onAfterClose?: () => void;
}
export const useUpdateModal = () => {
const instanceRef = useRef<ModalInstance | null>(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: <UpdateModalContent onClose={handleClose} setModalProps={setModalProps} />,
footer: null,
keyboard: true,
maskClosable: true,
title: '',
});
}, []);
return { open };
};

View file

@ -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<UpdateInfo | null>(null);
const [downloadedInfo, setDownloadedInfo] = useState<UpdateInfo | null>(null);
const [progress, setProgress] = useState<ProgressInfo | null>(null);
const [latestVersionInfo, setLatestVersionInfo] = useState<UpdateInfo | null>(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 = () => (
<Modal
closable
footer={[
<Button key="cancel" onClick={handleCancelCheck}>
{t('cancel', { ns: 'common' })}
</Button>,
]}
onCancel={handleCancelCheck}
open={isChecking}
title={t('updater.checkingUpdate')}
>
<Spin spinning={true}>
<div style={{ padding: '20px', textAlign: 'center' }}>
{t('updater.checkingUpdateDesc')}
</div>
</Spin>
</Modal>
);
const renderAvailableModal = () => (
<Modal
footer={[
<Button key="cancel" onClick={closeAvailableModal}>
{t('cancel', { ns: 'common' })}
</Button>,
<Button key="download" onClick={handleDownload} type="primary">
{t('updater.downloadNewVersion')}
</Button>,
]}
onCancel={closeAvailableModal}
open={!!updateAvailableInfo}
title={t('updater.newVersionAvailable')}
>
<h4>{t('updater.newVersionAvailableDesc', { version: updateAvailableInfo?.version })}</h4>
{updateAvailableInfo?.releaseNotes && (
<div
dangerouslySetInnerHTML={{ __html: updateAvailableInfo.releaseNotes as string }}
style={{
// background:theme
borderRadius: 4,
marginTop: 8,
maxHeight: 300,
overflow: 'auto',
padding: '8px 12px',
}}
/>
)}
</Modal>
);
const renderDownloadingModal = () => {
const percent = progress ? Math.round(progress.percent) : 0;
return (
<Modal
closable={false}
footer={null}
maskClosable={false}
open={isDownloading && !downloadedInfo}
title={t('updater.downloadingUpdate')}
>
<div style={{ padding: '20px 0' }}>
<Progress percent={percent} status="active" />
<div style={{ fontSize: 12, marginTop: 8, textAlign: 'center' }}>
{t('updater.downloadingUpdateDesc', { percent })}
{progress && progress.bytesPerSecond > 0 && (
<span>{formatSpeed(progress.bytesPerSecond)}</span>
)}
</div>
</div>
</Modal>
);
};
const renderDownloadedModal = () => (
<Modal
footer={[
<Button key="later" onClick={handleInstallLater}>
{t('updater.installLater')}
</Button>,
<Button key="now" onClick={handleInstallNow} type="primary">
{t('updater.restartAndInstall')}
</Button>,
]}
onCancel={closeDownloadedModal} // Allow closing if they don't want to decide now
open={!!downloadedInfo}
title={t('updater.updateReady')}
>
<h4>{t('updater.updateReadyDesc', { version: downloadedInfo?.version })}</h4>
{downloadedInfo?.releaseNotes && (
<div
dangerouslySetInnerHTML={{ __html: downloadedInfo.releaseNotes as string }}
style={{
borderRadius: 4,
marginTop: 8,
maxHeight: 300,
overflow: 'auto',
padding: '8px 12px',
}}
/>
)}
</Modal>
);
// New modal for "latest version"
const renderLatestVersionModal = () => (
<Modal
footer={[
<Button key="ok" onClick={closeLatestVersionModal} type="primary">
{t('ok', { ns: 'common' })}
</Button>,
]}
onCancel={closeLatestVersionModal}
open={!!latestVersionInfo}
title={t('updater.isLatestVersion')}
>
<p>{t('updater.isLatestVersionDesc', { version: latestVersionInfo?.version })}</p>
</Modal>
);
return (
<>
{renderCheckingModal()}
{renderAvailableModal()}
{renderDownloadingModal()}
{renderDownloadedModal()}
{renderLatestVersionModal()}
{/* Error state is handled by Modal.error currently */}
</>
);
});

View file

@ -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 <div style={{ width: 132 }} />;
// const { styles } = useStyles();
//
// return (
// <div className={styles.container}>
// <div
// className={styles.icon}
// onClick={() => {
// electronSystemService.minimizeWindow();
// }}
// >
// <Minus absoluteStrokeWidth size={14} strokeWidth={1.2} />
// </div>
// <div
// className={styles.icon}
// onClick={() => {
// electronSystemService.maximizeWindow();
// }}
// >
// <Square absoluteStrokeWidth size={10} strokeWidth={1.2} />
// </div>
// <div
// className={styles.close}
// onClick={() => {
// electronSystemService.closeWindow();
// }}
// >
// <XIcon absoluteStrokeWidth size={14} strokeWidth={1.2} />
// </div>
// </div>
// );
};
export default WinControl;

View file

@ -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 }));
});
});

View file

@ -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<ModalContentProps>(({ fileIds, knowledgeBaseId, onClose }) => {
const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId }) => {
const { t } = useTranslation('knowledgeBase');
const { close } = useModalContext();
return (
<>
<Flexbox gap={8} horizontal paddingBlock={16} paddingInline={16} style={{ paddingBottom: 0 }}>
@ -29,7 +26,7 @@ const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId, onClos
{t('addToKnowledgeBase.title')}
</Flexbox>
<Flexbox padding={16} style={{ paddingTop: 0 }}>
<SelectForm fileIds={fileIds} knowledgeBaseId={knowledgeBaseId} onClose={onClose} />
<SelectForm fileIds={fileIds} knowledgeBaseId={knowledgeBaseId} onClose={close} />
</Flexbox>
</>
);
@ -37,19 +34,19 @@ const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId, onClos
ModalContent.displayName = 'AddFilesToKnowledgeBaseModalContent';
export const useAddFilesToKnowledgeBaseModal = createModal<AddFilesToKnowledgeBaseModalProps>(
(instance, params) => ({
content: (
export const useAddFilesToKnowledgeBaseModal = () => {
const open = useCallback((params?: AddFilesToKnowledgeBaseModalProps) => {
createModal({
afterClose: params?.onClose,
children: (
<Suspense fallback={<div style={{ minHeight: 200 }} />}>
<ModalContent
fileIds={params?.fileIds || []}
knowledgeBaseId={params?.knowledgeBaseId}
onClose={() => {
instance.current?.destroy();
params?.onClose?.();
}}
/>
<ModalContent fileIds={params?.fileIds || []} knowledgeBaseId={params?.knowledgeBaseId} />
</Suspense>
),
}),
);
footer: null,
title: null,
});
}, []);
return { open };
};

View file

@ -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<ModalContentProps>(({ onClose, onSuccess }) => {
const ModalContent = memo<ModalContentProps>(({ onSuccess }) => {
const { close } = useModalContext();
return (
<Flexbox paddingInline={16} style={{ paddingBottom: 16 }}>
<CreateForm onClose={onClose} onSuccess={onSuccess} />
<CreateForm onClose={close} onSuccess={onSuccess} />
</Flexbox>
);
});
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: (
<Suspense fallback={<div style={{ minHeight: 200 }} />}>
<ModalContent
onClose={() => {
instance.current?.destroy();
}}
onSuccess={props?.onSuccess}
/>
<ModalContent onSuccess={props?.onSuccess} />
</Suspense>
),
focusTriggerAfterClose: true,
footer: false,
footer: null,
title: null,
});
}, []);
return { open };
};
},
);

View file

@ -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';

View file

@ -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';

View file

@ -29,7 +29,6 @@ import {
type UpdateAiProviderParams,
} from '@/types/aiProvider';
export type ProviderModelListItem = {
abilities: ModelAbilities;
approximatePricePerImage?: number;

View file

@ -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);
});
});
});

View file

@ -42,13 +42,10 @@ export const chatPortalSlice: StateCreator<
[],
ChatPortalAction
> = (set, get) => ({
clearPortalStack: () => {
set({ portalStack: [], showPortal: false }, false, 'clearPortalStack');
},
closeArtifact: () => {
const { portalStack } = get();
if (getCurrentViewType(portalStack) === PortalViewType.Artifact) {
@ -56,7 +53,6 @@ closeArtifact: () => {
}
},
closeDocument: () => {
const { portalStack } = get();
if (getCurrentViewType(portalStack) === PortalViewType.Document) {
@ -64,7 +60,6 @@ closeDocument: () => {
}
},
closeFilePreview: () => {
const { portalStack } = get();
if (getCurrentViewType(portalStack) === PortalViewType.FilePreview) {
@ -72,7 +67,6 @@ closeFilePreview: () => {
}
},
closeMessageDetail: () => {
const { portalStack } = get();
if (getCurrentViewType(portalStack) === PortalViewType.MessageDetail) {
@ -80,7 +74,6 @@ closeMessageDetail: () => {
}
},
closeNotebook: () => {
const { portalStack } = get();
if (getCurrentViewType(portalStack) === PortalViewType.Notebook) {
@ -88,9 +81,6 @@ closeNotebook: () => {
}
},
closeToolUI: () => {
const { portalStack } = get();
if (getCurrentViewType(portalStack) === PortalViewType.ToolUI) {
@ -98,14 +88,10 @@ closeToolUI: () => {
}
},
goBack: () => {
get().popPortalView();
},
goHome: () => {
set(
{
@ -117,44 +103,31 @@ goHome: () => {
);
},
// ============== Convenience Methods (using stack operations) ==============
openArtifact: (artifact) => {
get().pushPortalView({ artifact, type: PortalViewType.Artifact });
},
openDocument: (documentId) => {
get().pushPortalView({ documentId, type: PortalViewType.Document });
},
openFilePreview: (file) => {
get().pushPortalView({ file, type: PortalViewType.FilePreview });
},
openMessageDetail: (messageId) => {
get().pushPortalView({ messageId, type: PortalViewType.MessageDetail });
},
openNotebook: () => {
get().pushPortalView({ type: PortalViewType.Notebook });
},
openToolUI: (messageId, identifier) => {
get().pushPortalView({ identifier, messageId, type: PortalViewType.ToolUI });
},
popPortalView: () => {
const { portalStack } = get();

View file

@ -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',
});
});
});

View file

@ -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';