diff --git a/.env.desktop b/.env.desktop
index 2b66c5c793..01fc6d2b51 100644
--- a/.env.desktop
+++ b/.env.desktop
@@ -3,6 +3,5 @@ APP_URL=http://localhost:3015
FEATURE_FLAGS=-check_updates,+pin_list
KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=
DATABASE_URL=postgresql://postgres@localhost:5432/postgres
-DEFAULT_AGENT_CONFIG="model=qwen2.5;provider=ollama;chatConfig.searchFCModel.provider=ollama;chatConfig.searchFCModel.model=qwen2.5"
-SYSTEM_AGENT="default=ollama/qwen2.5"
SEARCH_PROVIDERS=search1api
+NEXT_PUBLIC_SERVICE_MODE='server'
diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/desktop-pr-build.yml
similarity index 50%
rename from .github/workflows/release-desktop.yml
rename to .github/workflows/desktop-pr-build.yml
index b7989dd881..8d02edcb99 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/desktop-pr-build.yml
@@ -1,9 +1,6 @@
-name: Release Desktop
+name: Desktop PR Build
on:
-# uncomment when official desktop version released
-# release:
-# types: [published] # 发布 release 时触发构建
pull_request:
types: [synchronize, labeled, unlabeled] # PR 更新或标签变化时触发
@@ -12,6 +9,9 @@ concurrency:
group: ${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: true
+# Add default permissions
+permissions: read-all
+
env:
PR_TAG_PREFIX: pr- # PR 构建版本的前缀标识
@@ -19,10 +19,7 @@ jobs:
test:
name: Code quality check
# 添加 PR label 触发条件,只有添加了 Build Desktop 标签的 PR 才会触发构建
- if: |
- (github.event_name == 'pull_request' &&
- contains(github.event.pull_request.labels.*.name, 'Build Desktop')) ||
- github.event_name != 'pull_request'
+ if: contains(github.event.pull_request.labels.*.name, 'Build Desktop')
runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查
steps:
- name: Checkout base
@@ -38,7 +35,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
- version: 8
+ version: 9
- name: Install deps
run: pnpm install
@@ -50,21 +47,14 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=6144
- # - name: Test
- # run: pnpm run test
-
version:
name: Determine version
# 与 test job 相同的触发条件
- if: |
- (github.event_name == 'pull_request' &&
- contains(github.event.pull_request.labels.*.name, 'Build Desktop')) ||
- github.event_name != 'pull_request'
+ if: contains(github.event.pull_request.labels.*.name, 'Build Desktop')
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@v4
with:
@@ -82,32 +72,13 @@ jobs:
# 从 apps/desktop/package.json 读取基础版本号
base_version=$(node -p "require('./apps/desktop/package.json').version")
- if [ "${{ github.event_name }}" == "pull_request" ]; then
- # PR 构建:在基础版本号上添加 PR 信息
- branch_name="${{ github.head_ref }}"
- # 清理分支名,移除非法字符
- sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
- # 创建特殊的 PR 版本号:基础版本号-PR前缀-分支名-提交哈希
- version="${base_version}-${{ env.PR_TAG_PREFIX }}${sanitized_branch}-$(git rev-parse --short HEAD)"
- echo "version=${version}" >> $GITHUB_OUTPUT
- echo "is_pr_build=true" >> $GITHUB_OUTPUT
- echo "📦 Release Version: ${version} (based on base version ${base_version})"
+ # PR 构建:在基础版本号上添加 PR 信息
+ pr_number="${{ github.event.pull_request.number }}"
+ ci_build_number="${{ github.run_number }}" # CI 构建编号
+ version="${base_version}-nightly.pr${pr_number}.${ci_build_number}"
+ echo "version=${version}" >> $GITHUB_OUTPUT
+ echo "📦 Release Version: ${version} (based on base version ${base_version})"
- elif [ "${{ github.event_name }}" == "release" ]; then
- # Release 事件直接使用 release tag 作为版本号,去掉可能的 v 前缀
- version="${{ github.event.release.tag_name }}"
- version="${version#v}"
- echo "version=${version}" >> $GITHUB_OUTPUT
- echo "is_pr_build=false" >> $GITHUB_OUTPUT
- echo "📦 Release Version: ${version}"
-
- else
- # 其他情况(如手动触发)使用 apps/desktop/package.json 的版本号
- version="${base_version}"
- echo "version=${version}" >> $GITHUB_OUTPUT
- echo "is_pr_build=false" >> $GITHUB_OUTPUT
- echo "📦 Release Version: ${version}"
- fi
env:
NODE_OPTIONS: --max-old-space-size=6144
@@ -115,7 +86,6 @@ jobs:
- name: Version Summary
run: |
echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}"
- echo "🔄 Is PR Build: ${{ steps.set_version.outputs.is_pr_build }}"
build:
needs: [version, test]
@@ -137,43 +107,57 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
- version: 8
+ version: 9
+ # node-linker=hoisted 模式将可以确保 asar 压缩可用
- name: Install deps
- run: pnpm install
+ 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 }}
+ run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} nightly
# macOS 构建处理
- name: Build artifact on macOS
if: runner.os == 'macOS'
run: npm run desktop:build
env:
- APP_URL: http://localhost:3010
+ # 设置更新通道,PR构建为nightly,否则为stable
+ UPDATE_CHANNEL: 'nightly'
+ APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
# 默认添加一个加密 SECRET
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
- # 公证部分将来再加回
- # CSC_LINK: ./build/developer-id-app-certs.p12
- # CSC_KEY_PASSWORD: ${{ secrets.APPLE_APP_CERTS_PASSWORD }}
- # APPLE_ID: ${{ secrets.APPLE_ID }}
- # APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
+ # macOS 签名和公证配置
+ CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
+ CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
+ NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
+
+ # 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 }}
# 非 macOS 平台构建处理
- name: Build artifact on other platforms
if: runner.os != 'macOS'
run: npm run desktop:build
env:
- APP_URL: http://localhost:3010
+ # 设置更新通道,PR构建为nightly,否则为stable
+ UPDATE_CHANNEL: 'nightly'
+ 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_NIGHTLY_DESKTOP_PROJECT_ID }}
+ NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
- # 上传构建产物,移除了 zip 相关部分
+
+ # 上传构建产物
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
@@ -186,18 +170,21 @@ jobs:
apps/desktop/release/*.AppImage
retention-days: 5
- - name: Log build info
- run: |
- echo "🔄 Is PR Build: ${{ needs.version.outputs.is_pr_build }}"
-
- # 将原本的 merge job 调整,作为所有构建产物的准备步骤
- prepare-artifacts:
+ publish-pr:
needs: [build, version]
- name: Prepare Artifacts
+ name: Publish PR Build
runs-on: ubuntu-latest
+ # Grant write permissions for creating release and commenting on PR
+ permissions:
+ contents: write
+ pull-requests: write
outputs:
artifact_path: ${{ steps.set_path.outputs.path }}
steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
# 下载所有平台的构建产物
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -210,66 +197,6 @@ jobs:
- name: List artifacts
run: ls -R release
- # 设置构建产物路径,供后续 job 使用
- - name: Set artifact path
- id: set_path
- run: echo "path=release" >> $GITHUB_OUTPUT
-
- # 正式版发布 job - 只处理 release 触发的场景
- publish-release:
- # 只在 release 事件触发且不是 PR 构建时执行
- if: |
- github.event_name == 'release' &&
- needs.version.outputs.is_pr_build != 'true'
- needs: [prepare-artifacts, version]
- name: Publish Release
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- # 下载构建产物
- - name: Download artifacts
- uses: actions/download-artifact@v4
- with:
- path: ${{ needs.prepare-artifacts.outputs.artifact_path }}
- pattern: release-*
- merge-multiple: true
-
- # 将构建产物上传到现有 release
- - name: Upload to Release
- uses: softprops/action-gh-release@v1
- with:
- tag_name: ${{ github.event.release.tag_name }}
- files: |
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/latest*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.dmg*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.zip*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.exe*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.AppImage
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- # PR 构建的处理步骤
- publish-pr:
- if: needs.version.outputs.is_pr_build == 'true'
- needs: [prepare-artifacts, version]
- name: Publish PR Build
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- # 下载构建产物
- - name: Download artifacts
- uses: actions/download-artifact@v4
- with:
- path: ${{ needs.prepare-artifacts.outputs.artifact_path }}
- pattern: release-*
- merge-multiple: true
-
# 生成PR发布描述
- name: Generate PR Release Body
id: pr_release_body
@@ -287,22 +214,22 @@ jobs:
return body;
- # 为构建产物创建一个临时发布
- name: Create Temporary Release for PR
id: create_release
uses: softprops/action-gh-release@v1
with:
name: PR Build v${{ needs.version.outputs.version }}
- tag_name: pr-build-${{ github.event.pull_request.number }}-${{ github.sha }}
+ tag_name: v${{ needs.version.outputs.version }}
+ # tag_name: pr-build-${{ github.event.pull_request.number }}-${{ github.sha }}
body: ${{ steps.pr_release_body.outputs.result }}
draft: false
prerelease: true
files: |
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/latest*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.dmg*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.zip*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.exe*
- ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.AppImage
+ release/latest*
+ release/*.dmg*
+ release/*.zip*
+ release/*.exe*
+ release/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -315,17 +242,12 @@ jobs:
const releaseUrl = "${{ steps.create_release.outputs.url }}";
const prCommentGenerator = require('${{ github.workspace }}/.github/scripts/pr-comment.js');
- const body = await prCommentGenerator({
+ const result = await prCommentGenerator({
github,
context,
releaseUrl,
version: "${{ needs.version.outputs.version }}",
- tag: "pr-build-${{ github.event.pull_request.number }}-${{ github.sha }}"
+ tag: "v${{ needs.version.outputs.version }}"
});
- github.rest.issues.createComment({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- body: body
- });
+ console.log(`评论状态: ${result.updated ? '已更新' : '已创建'}, ID: ${result.id}`);
diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml
new file mode 100644
index 0000000000..bc6402cebd
--- /dev/null
+++ b/.github/workflows/release-desktop-beta.yml
@@ -0,0 +1,196 @@
+name: Release Desktop
+
+on:
+ release:
+ types: [published] # 发布 release 时触发构建
+
+# 确保同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
+concurrency:
+ group: ${{ github.ref }}-${{ github.workflow }}
+ cancel-in-progress: true
+
+# Add default permissions
+permissions: read-all
+
+jobs:
+ test:
+ name: Code quality check
+ # 添加 PR label 触发条件,只有添加了 Build Desktop 标签的 PR 才会触发构建
+ runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查
+ steps:
+ - name: Checkout base
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 9
+
+ - name: Install deps
+ run: pnpm install
+
+ - name: Lint
+ run: pnpm 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@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ # 主要逻辑:确定构建版本号
+ - 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 "is_pr_build=false" >> $GITHUB_OUTPUT
+ echo "📦 Release Version: ${version}"
+
+ # 输出版本信息总结,方便在 GitHub Actions 界面查看
+ - name: Version Summary
+ run: |
+ echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}"
+ echo "🔄 Is PR Build: ${{ steps.set_version.outputs.is_pr_build }}"
+
+ build:
+ needs: [version, test]
+ name: Build Desktop App
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ # 暂时先支持 macOS
+ os: [macos-latest]
+ # os: [macos-latest, windows-latest, ubuntu-latest]
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 9
+
+ # node-linker=hoisted 模式将可以确保 asar 压缩可用
+ - name: Install deps
+ 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
+
+ # macOS 构建处理
+ - name: Build artifact on macOS
+ if: runner.os == 'macOS'
+ run: npm run desktop:build
+ env:
+ UPDATE_CHANNEL: 'stable'
+ APP_URL: http://localhost:3015
+ DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
+ # 默认添加一个加密 SECRET
+ KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
+ # macOS 签名和公证配置
+ 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 }}
+
+ # 非 macOS 平台构建处理
+ - name: Build artifact on other platforms
+ if: runner.os != 'macOS'
+ run: npm run desktop:build
+ env:
+ UPDATE_CHANNEL: 'stable'
+ 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_BETA_DESKTOP_PROJECT_ID }}
+ NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
+
+ # 上传构建产物,移除了 zip 相关部分
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ 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
+ retention-days: 5
+
+ # 正式版发布 job
+ publish-release:
+ needs: [build, version]
+ name: Prepare Artifacts
+ runs-on: ubuntu-latest
+ # Grant write permission to contents for uploading release assets
+ permissions:
+ contents: write
+ outputs:
+ artifact_path: ${{ steps.set_path.outputs.path }}
+ steps:
+ # 下载所有平台的构建产物
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: release
+ pattern: release-*
+ merge-multiple: true
+
+ # 列出所有构建产物
+ - name: List artifacts
+ run: ls -R release
+
+ # 将构建产物上传到现有 release
+ - name: Upload to Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: ${{ github.event.release.tag_name }}
+ files: |
+ ${{ needs.prepare-artifacts.outputs.artifact_path }}/latest*
+ ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.dmg*
+ ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.zip*
+ ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.exe*
+ ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.AppImage
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore
new file mode 100644
index 0000000000..fbef2d26a1
--- /dev/null
+++ b/apps/desktop/.gitignore
@@ -0,0 +1,8 @@
+node_modules
+dist
+out
+.DS_Store
+.eslintcache
+*.log*
+standalone
+release
diff --git a/apps/desktop/.i18nrc.js b/apps/desktop/.i18nrc.js
new file mode 100644
index 0000000000..dfd94d6961
--- /dev/null
+++ b/apps/desktop/.i18nrc.js
@@ -0,0 +1,31 @@
+const { defineConfig } = require('@lobehub/i18n-cli');
+
+module.exports = defineConfig({
+ entry: 'resources/locales/zh-CN',
+ entryLocale: 'zh-CN',
+ output: 'resources/locales',
+ outputLocales: [
+ 'ar',
+ 'bg-BG',
+ 'zh-TW',
+ 'en-US',
+ 'ru-RU',
+ 'ja-JP',
+ 'ko-KR',
+ 'fr-FR',
+ 'tr-TR',
+ 'es-ES',
+ 'pt-BR',
+ 'de-DE',
+ 'it-IT',
+ 'nl-NL',
+ 'pl-PL',
+ 'vi-VN',
+ 'fa-IR',
+ ],
+ temperature: 0,
+ modelName: 'gpt-4o-mini',
+ experimental: {
+ jsonMode: true,
+ },
+});
diff --git a/apps/desktop/.npmrc b/apps/desktop/.npmrc
new file mode 100644
index 0000000000..fbb6fb5d5a
--- /dev/null
+++ b/apps/desktop/.npmrc
@@ -0,0 +1,4 @@
+lockfile=false
+shamefully-hoist=true
+electron_mirror=https://npmmirror.com/mirrors/electron/
+electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
diff --git a/apps/desktop/Development.md b/apps/desktop/Development.md
new file mode 100644
index 0000000000..e6d144a69e
--- /dev/null
+++ b/apps/desktop/Development.md
@@ -0,0 +1,47 @@
+## Menu 实现框架
+
+```
+apps/desktop/src/main/
+├── core/
+│ ├── App.ts // 应用核心类
+│ ├── BrowserManager.ts // 浏览器窗口管理
+│ └── MenuManager.ts // 新增:菜单管理核心类,负责选择和协调平台实现
+├── menus/ // 新增:菜单实现目录
+│ ├── index.ts // 导出平台实现和接口
+│ ├── types.ts // 定义菜单平台接口 IMenuPlatform
+│ └── impl/ // 平台特定实现目录
+│ ├── BaseMenuPlatform.ts // 基础平台类,注入App
+│ ├── DarwinMenu.ts // macOS 充血模型实现
+│ ├── WindowsMenu.ts // Windows 充血模型实现
+│ └── LinuxMenu.ts // Linux 充血模型实现
+├── controllers/
+│ └── MenuCtr.ts // 菜单控制器,处理渲染进程调用
+```
+
+## i18n
+
+src/main/
+├── core/
+│ ├── I18nManager.ts //i18n 管理器
+│ └── App.ts // 应用主类,集成 i18n
+├── locales/
+│ ├── index.ts // 导出 i18n 相关功能
+│ ├── resources.ts // 资源加载逻辑
+│ └── default/ // 默认中文翻译源文件
+│ ├── index.ts // 导出所有翻译
+│ ├── menu.ts // 菜单翻译
+│ ├── dialog.ts // 对话框翻译
+│ └── common.ts // 通用翻译
+
+主进程 i18n 国际化管理
+使用方式:
+
+1. 直接导入 i18nManager 实例:
+ import i18nManager from '@/locales';
+
+2. 使用翻译函数:
+ import {t} from '@/locales';
+ const translated = t ('key');
+
+3. 添加新翻译:
+ 在 locales/default/ 目录下添加翻译源文件
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
new file mode 100644
index 0000000000..313c778cb9
--- /dev/null
+++ b/apps/desktop/README.md
@@ -0,0 +1,6 @@
+# LobeHub Desktop
+
+构建路径:
+
+- dist: 构建产物路径
+- release: 发布产物路径
diff --git a/apps/desktop/build/Icon-beta.icns b/apps/desktop/build/Icon-beta.icns
new file mode 100644
index 0000000000..427785d10a
Binary files /dev/null and b/apps/desktop/build/Icon-beta.icns differ
diff --git a/apps/desktop/build/Icon-nightly.icns b/apps/desktop/build/Icon-nightly.icns
new file mode 100644
index 0000000000..d67a726448
Binary files /dev/null and b/apps/desktop/build/Icon-nightly.icns differ
diff --git a/apps/desktop/build/Icon.icns b/apps/desktop/build/Icon.icns
new file mode 100644
index 0000000000..2e0066950b
Binary files /dev/null and b/apps/desktop/build/Icon.icns differ
diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
new file mode 100644
index 0000000000..38c887b211
--- /dev/null
+++ b/apps/desktop/build/entitlements.mac.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+
+
diff --git a/apps/desktop/build/favicon.ico b/apps/desktop/build/favicon.ico
new file mode 100644
index 0000000000..a9362f77bf
Binary files /dev/null and b/apps/desktop/build/favicon.ico differ
diff --git a/apps/desktop/build/icon-beta.png b/apps/desktop/build/icon-beta.png
new file mode 100644
index 0000000000..728504a4e1
Binary files /dev/null and b/apps/desktop/build/icon-beta.png differ
diff --git a/apps/desktop/build/icon-dev.png b/apps/desktop/build/icon-dev.png
new file mode 100644
index 0000000000..2b896d6fc8
Binary files /dev/null and b/apps/desktop/build/icon-dev.png differ
diff --git a/apps/desktop/build/icon-nightly.ico b/apps/desktop/build/icon-nightly.ico
new file mode 100644
index 0000000000..f3781ae8cd
Binary files /dev/null and b/apps/desktop/build/icon-nightly.ico differ
diff --git a/apps/desktop/build/icon-nightly.png b/apps/desktop/build/icon-nightly.png
new file mode 100644
index 0000000000..9598d40477
Binary files /dev/null and b/apps/desktop/build/icon-nightly.png differ
diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico
new file mode 100644
index 0000000000..2b86beffbe
Binary files /dev/null and b/apps/desktop/build/icon.ico differ
diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png
new file mode 100644
index 0000000000..39929f7f83
Binary files /dev/null and b/apps/desktop/build/icon.png differ
diff --git a/apps/desktop/dev-app-update.yml b/apps/desktop/dev-app-update.yml
new file mode 100644
index 0000000000..3deac76f44
--- /dev/null
+++ b/apps/desktop/dev-app-update.yml
@@ -0,0 +1,6 @@
+provider: github
+owner: lobehub
+repo: lobe-chat
+updaterCacheDirName: electron-app-updater
+allowPrerelease: true
+channel: nightly
diff --git a/apps/desktop/electron-builder.js b/apps/desktop/electron-builder.js
new file mode 100644
index 0000000000..7eba7f315a
--- /dev/null
+++ b/apps/desktop/electron-builder.js
@@ -0,0 +1,92 @@
+const dotenv = require('dotenv');
+
+dotenv.config();
+
+const packageJSON = require('./package.json');
+
+const channel = process.env.UPDATE_CHANNEL || 'stable';
+
+console.log(`🚄 Build Version ${packageJSON.version}, Channel: ${channel}`);
+
+const isNightly = channel === 'nightly';
+/**
+ * @type {import('electron-builder').Configuration}
+ * @see https://www.electron.build/configuration
+ */
+const config = {
+ appId: isNightly ? 'com.lobehub.lobehub-desktop-nightly' : 'com.lobehub.lobehub-desktop',
+ appImage: {
+ artifactName: '${productName}-${version}.${ext}',
+ },
+ asar: true,
+ detectUpdateChannel: true,
+ directories: {
+ buildResources: 'build',
+ output: 'release',
+ },
+ dmg: {
+ artifactName: '${productName}-${version}-${arch}.${ext}',
+ },
+ electronDownload: {
+ mirror: 'https://npmmirror.com/mirrors/electron/',
+ },
+ files: [
+ 'dist',
+ 'resources',
+ '!resources/locales',
+ '!dist/next/docs',
+ '!dist/next/packages',
+ '!dist/next/.next/server/app/sitemap',
+ '!dist/next/.next/static/media',
+ ],
+ generateUpdatesFilesForAllChannels: true,
+ linux: {
+ category: 'Utility',
+ maintainer: 'electronjs.org',
+ target: ['AppImage', 'snap', 'deb'],
+ },
+ mac: {
+ compression: 'maximum',
+ entitlementsInherit: 'build/entitlements.mac.plist',
+ extendInfo: [
+ { NSCameraUsageDescription: "Application requests access to the device's camera." },
+ { NSMicrophoneUsageDescription: "Application requests access to the device's microphone." },
+ {
+ NSDocumentsFolderUsageDescription:
+ "Application requests access to the user's Documents folder.",
+ },
+ {
+ NSDownloadsFolderUsageDescription:
+ "Application requests access to the user's Downloads folder.",
+ },
+ ],
+ gatekeeperAssess: false,
+ hardenedRuntime: true,
+ notarize: true,
+ target: [
+ { arch: ['x64', 'arm64'], target: 'dmg' },
+ { arch: ['x64', 'arm64'], target: 'zip' },
+ ],
+ },
+ npmRebuild: true,
+ nsis: {
+ artifactName: '${productName}-${version}-setup.${ext}',
+ createDesktopShortcut: 'always',
+ // allowToChangeInstallationDirectory: true,
+ // oneClick: false,
+ shortcutName: '${productName}',
+ uninstallDisplayName: '${productName}',
+ },
+ publish: [
+ {
+ owner: 'lobehub',
+ provider: 'github',
+ repo: 'lobe-chat',
+ },
+ ],
+ win: {
+ executableName: 'LobeHub',
+ },
+};
+
+module.exports = config;
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
new file mode 100644
index 0000000000..38f6280e1e
--- /dev/null
+++ b/apps/desktop/electron.vite.config.ts
@@ -0,0 +1,40 @@
+import dotenv from 'dotenv';
+import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
+import { resolve } from 'node:path';
+
+dotenv.config();
+
+const updateChannel = process.env.UPDATE_CHANNEL || 'stable';
+console.log(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`); // 添加日志确认
+
+export default defineConfig({
+ main: {
+ build: {
+ outDir: 'dist/main',
+ },
+ // 这里是关键:在构建时进行文本替换
+ 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),
+ },
+ plugins: [externalizeDepsPlugin({})],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src/main'),
+ '~common': resolve(__dirname, 'src/common'),
+ },
+ },
+ },
+ preload: {
+ build: {
+ outDir: 'dist/preload',
+ },
+ plugins: [externalizeDepsPlugin({})],
+ resolve: {
+ alias: {
+ '~common': resolve(__dirname, 'src/common'),
+ },
+ },
+ },
+});
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 0000000000..e36913992c
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,72 @@
+{
+ "name": "lobehub-desktop-dev",
+ "version": "0.0.10",
+ "description": "LobeHub Desktop Application",
+ "homepage": "https://lobehub.com",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/lobehub/lobe-chat.git"
+ },
+ "author": "LobeHub",
+ "main": "./dist/main/index.js",
+ "scripts": {
+ "build": "npm run typecheck && electron-vite build",
+ "build-local": "npm run build && electron-builder --dir --config electron-builder.js --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
+ "build:linux": "npm run build && electron-builder --linux --config electron-builder.js",
+ "build:mac": "npm run build && electron-builder --mac --config electron-builder.js",
+ "build:win": "npm run build && electron-builder --win --config electron-builder.js",
+ "electron:dev": "electron-vite dev",
+ "electron:run-unpack": "electron .",
+ "format": "prettier --write ",
+ "i18n": "bun run scripts/i18nWorkflow/index.ts && lobe-i18n",
+ "postinstall": "electron-builder install-app-deps",
+ "install-isolated": "pnpm install",
+ "lint": "eslint --cache ",
+ "pg-server": "bun run scripts/pglite-server.ts",
+ "start": "electron-vite preview",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
+ },
+ "dependencies": {
+ "electron-updater": "^6.6.2",
+ "get-port-please": "^3.1.2",
+ "pdfjs-dist": "4.8.69"
+ },
+ "devDependencies": {
+ "@electron-toolkit/eslint-config-prettier": "^3.0.0",
+ "@electron-toolkit/eslint-config-ts": "^3.0.0",
+ "@electron-toolkit/preload": "^3.0.1",
+ "@electron-toolkit/tsconfig": "^1.0.1",
+ "@electron-toolkit/utils": "^4.0.0",
+ "@lobechat/electron-client-ipc": "workspace:*",
+ "@lobechat/electron-server-ipc": "workspace:*",
+ "@lobechat/file-loaders": "workspace:*",
+ "@lobehub/i18n-cli": "^1.20.3",
+ "@types/lodash": "^4.17.0",
+ "@types/resolve": "^1.20.6",
+ "@types/semver": "^7.7.0",
+ "@types/set-cookie-parser": "^2.4.10",
+ "consola": "^3.1.0",
+ "cookie": "^1.0.2",
+ "electron": "^35.2.0",
+ "electron-builder": "^26.0.12",
+ "electron-is": "^3.0.0",
+ "electron-log": "^5.3.3",
+ "electron-store": "^8.2.0",
+ "electron-vite": "^3.0.0",
+ "execa": "^9.5.2",
+ "just-diff": "^6.0.2",
+ "lodash": "^4.17.21",
+ "pglite-server": "^0.1.4",
+ "resolve": "^1.22.8",
+ "semver": "^7.5.4",
+ "set-cookie-parser": "^2.7.1",
+ "tsx": "^4.19.3",
+ "typescript": "^5.7.3",
+ "vite": "^6.2.5"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "electron"
+ ]
+ }
+}
diff --git a/apps/desktop/pnpm-workspace.yaml b/apps/desktop/pnpm-workspace.yaml
new file mode 100644
index 0000000000..f9232e0468
--- /dev/null
+++ b/apps/desktop/pnpm-workspace.yaml
@@ -0,0 +1,5 @@
+packages:
+ - '../../packages/electron-server-ipc'
+ - '../../packages/electron-client-ipc'
+ - '../../packages/file-loaders'
+ - '.'
diff --git a/apps/desktop/resources/error.html b/apps/desktop/resources/error.html
new file mode 100644
index 0000000000..bcfb743185
--- /dev/null
+++ b/apps/desktop/resources/error.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+ LobeHub - 连接错误
+
+
+
+
+
⚠️
+
Connection Error
+
+ Unable to connect to the application, please check your network connection or confirm if the
+ development server is running.
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/resources/locales/ar/common.json b/apps/desktop/resources/locales/ar/common.json
new file mode 100644
index 0000000000..381b08455d
--- /dev/null
+++ b/apps/desktop/resources/locales/ar/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "إضافة",
+ "back": "عودة",
+ "cancel": "إلغاء",
+ "close": "إغلاق",
+ "confirm": "تأكيد",
+ "delete": "حذف",
+ "edit": "تعديل",
+ "more": "المزيد",
+ "next": "التالي",
+ "ok": "حسناً",
+ "previous": "السابق",
+ "refresh": "تحديث",
+ "remove": "إزالة",
+ "retry": "إعادة المحاولة",
+ "save": "حفظ",
+ "search": "بحث",
+ "submit": "إرسال"
+ },
+ "app": {
+ "description": "منصة تعاون مساعدك الذكي",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "خطأ",
+ "info": "معلومات",
+ "loading": "جارٍ التحميل",
+ "success": "نجاح",
+ "warning": "تحذير"
+ }
+}
diff --git a/apps/desktop/resources/locales/ar/dialog.json b/apps/desktop/resources/locales/ar/dialog.json
new file mode 100644
index 0000000000..23d7b55f20
--- /dev/null
+++ b/apps/desktop/resources/locales/ar/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "تأكيد",
+ "detail": "تطبيق دردشة يعتمد على نموذج لغة كبير",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "حول"
+ },
+ "confirm": {
+ "cancel": "إلغاء",
+ "no": "لا",
+ "title": "تأكيد",
+ "yes": "نعم"
+ },
+ "error": {
+ "button": "تأكيد",
+ "detail": "حدث خطأ أثناء العملية، يرجى المحاولة لاحقًا",
+ "message": "حدث خطأ",
+ "title": "خطأ"
+ },
+ "update": {
+ "downloadAndInstall": "تنزيل وتثبيت",
+ "downloadComplete": "اكتمل التنزيل",
+ "downloadCompleteMessage": "تم تنزيل حزمة التحديث، هل ترغب في التثبيت الآن؟",
+ "installLater": "تثبيت لاحقًا",
+ "installNow": "تثبيت الآن",
+ "later": "تذكير لاحقًا",
+ "newVersion": "تم اكتشاف إصدار جديد",
+ "newVersionAvailable": "تم اكتشاف إصدار جديد: {{version}}",
+ "skipThisVersion": "تخطي هذا الإصدار"
+ }
+}
diff --git a/apps/desktop/resources/locales/ar/menu.json b/apps/desktop/resources/locales/ar/menu.json
new file mode 100644
index 0000000000..5468302809
--- /dev/null
+++ b/apps/desktop/resources/locales/ar/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "التحقق من التحديثات..."
+ },
+ "dev": {
+ "devPanel": "لوحة المطور",
+ "devTools": "أدوات المطور",
+ "forceReload": "إعادة تحميل قسري",
+ "openStore": "فتح ملف التخزين",
+ "refreshMenu": "تحديث القائمة",
+ "reload": "إعادة تحميل",
+ "title": "تطوير"
+ },
+ "edit": {
+ "copy": "نسخ",
+ "cut": "قص",
+ "paste": "لصق",
+ "redo": "إعادة",
+ "selectAll": "تحديد الكل",
+ "speech": "صوت",
+ "startSpeaking": "بدء القراءة",
+ "stopSpeaking": "إيقاف القراءة",
+ "title": "تحرير",
+ "undo": "تراجع"
+ },
+ "file": {
+ "preferences": "التفضيلات",
+ "quit": "خروج",
+ "title": "ملف"
+ },
+ "help": {
+ "about": "حول",
+ "githubRepo": "مستودع GitHub",
+ "reportIssue": "الإبلاغ عن مشكلة",
+ "title": "مساعدة",
+ "visitWebsite": "زيارة الموقع الرسمي"
+ },
+ "macOS": {
+ "about": "حول {{appName}}",
+ "devTools": "أدوات مطور LobeHub",
+ "hide": "إخفاء {{appName}}",
+ "hideOthers": "إخفاء الآخرين",
+ "preferences": "إعدادات مفضلة...",
+ "services": "خدمات",
+ "unhide": "إظهار الكل"
+ },
+ "tray": {
+ "open": "فتح {{appName}}",
+ "quit": "خروج",
+ "show": "عرض {{appName}}"
+ },
+ "view": {
+ "forceReload": "إعادة تحميل قسري",
+ "reload": "إعادة تحميل",
+ "resetZoom": "إعادة تعيين التكبير",
+ "title": "عرض",
+ "toggleFullscreen": "تبديل وضع ملء الشاشة",
+ "zoomIn": "تكبير",
+ "zoomOut": "تصغير"
+ },
+ "window": {
+ "bringAllToFront": "إحضار جميع النوافذ إلى الأمام",
+ "close": "إغلاق",
+ "front": "إحضار جميع النوافذ إلى الأمام",
+ "minimize": "تصغير",
+ "title": "نافذة",
+ "toggleFullscreen": "تبديل وضع ملء الشاشة",
+ "zoom": "تكبير"
+ }
+}
diff --git a/apps/desktop/resources/locales/bg-BG/common.json b/apps/desktop/resources/locales/bg-BG/common.json
new file mode 100644
index 0000000000..928d158f41
--- /dev/null
+++ b/apps/desktop/resources/locales/bg-BG/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Добави",
+ "back": "Назад",
+ "cancel": "Отмени",
+ "close": "Затвори",
+ "confirm": "Потвърди",
+ "delete": "Изтрий",
+ "edit": "Редактирай",
+ "more": "Повече",
+ "next": "Следващ",
+ "ok": "Добре",
+ "previous": "Предишен",
+ "refresh": "Освежи",
+ "remove": "Премахни",
+ "retry": "Опитай отново",
+ "save": "Запази",
+ "search": "Търси",
+ "submit": "Изпрати"
+ },
+ "app": {
+ "description": "Твоята платформа за сътрудничество с AI асистент",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Грешка",
+ "info": "Информация",
+ "loading": "Зареждане",
+ "success": "Успех",
+ "warning": "Предупреждение"
+ }
+}
diff --git a/apps/desktop/resources/locales/bg-BG/dialog.json b/apps/desktop/resources/locales/bg-BG/dialog.json
new file mode 100644
index 0000000000..4713a27faa
--- /dev/null
+++ b/apps/desktop/resources/locales/bg-BG/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Потвърди",
+ "detail": "Приложение за чат, базирано на голям езиков модел",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "За нас"
+ },
+ "confirm": {
+ "cancel": "Отказ",
+ "no": "Не",
+ "title": "Потвърждение",
+ "yes": "Да"
+ },
+ "error": {
+ "button": "Потвърди",
+ "detail": "Възникна грешка по време на операцията, моля опитайте отново по-късно",
+ "message": "Възникна грешка",
+ "title": "Грешка"
+ },
+ "update": {
+ "downloadAndInstall": "Изтегли и инсталирай",
+ "downloadComplete": "Изтеглянето е завършено",
+ "downloadCompleteMessage": "Актуализационният пакет е изтеглен, желаете ли да го инсталирате веднага?",
+ "installLater": "Инсталирай по-късно",
+ "installNow": "Инсталирай сега",
+ "later": "Напомни по-късно",
+ "newVersion": "Открита нова версия",
+ "newVersionAvailable": "Открита нова версия: {{version}}",
+ "skipThisVersion": "Пропусни тази версия"
+ }
+}
diff --git a/apps/desktop/resources/locales/bg-BG/menu.json b/apps/desktop/resources/locales/bg-BG/menu.json
new file mode 100644
index 0000000000..5b1b0cffae
--- /dev/null
+++ b/apps/desktop/resources/locales/bg-BG/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Проверка за актуализации..."
+ },
+ "dev": {
+ "devPanel": "Панел на разработчика",
+ "devTools": "Инструменти за разработчици",
+ "forceReload": "Принудително презареждане",
+ "openStore": "Отворете файла за съхранение",
+ "refreshMenu": "Освежаване на менюто",
+ "reload": "Презареждане",
+ "title": "Разработка"
+ },
+ "edit": {
+ "copy": "Копиране",
+ "cut": "Изрязване",
+ "paste": "Поставяне",
+ "redo": "Повторно",
+ "selectAll": "Избери всичко",
+ "speech": "Глас",
+ "startSpeaking": "Започни четене",
+ "stopSpeaking": "Спри четенето",
+ "title": "Редактиране",
+ "undo": "Отмяна"
+ },
+ "file": {
+ "preferences": "Предпочитания",
+ "quit": "Изход",
+ "title": "Файл"
+ },
+ "help": {
+ "about": "За",
+ "githubRepo": "GitHub хранилище",
+ "reportIssue": "Докладвай проблем",
+ "title": "Помощ",
+ "visitWebsite": "Посети уебсайта"
+ },
+ "macOS": {
+ "about": "За {{appName}}",
+ "devTools": "Инструменти за разработчици на LobeHub",
+ "hide": "Скрий {{appName}}",
+ "hideOthers": "Скрий другите",
+ "preferences": "Настройки...",
+ "services": "Услуги",
+ "unhide": "Покажи всичко"
+ },
+ "tray": {
+ "open": "Отвори {{appName}}",
+ "quit": "Изход",
+ "show": "Покажи {{appName}}"
+ },
+ "view": {
+ "forceReload": "Принудително презареждане",
+ "reload": "Презареждане",
+ "resetZoom": "Нулиране на мащаба",
+ "title": "Изглед",
+ "toggleFullscreen": "Превключи на цял екран",
+ "zoomIn": "Увеличи",
+ "zoomOut": "Намали"
+ },
+ "window": {
+ "bringAllToFront": "Премести всички прозорци напред",
+ "close": "Затвори",
+ "front": "Премести всички прозорци напред",
+ "minimize": "Минимизирай",
+ "title": "Прозорец",
+ "toggleFullscreen": "Превключи на цял екран",
+ "zoom": "Мащаб"
+ }
+}
diff --git a/apps/desktop/resources/locales/de-DE/common.json b/apps/desktop/resources/locales/de-DE/common.json
new file mode 100644
index 0000000000..d59358ed72
--- /dev/null
+++ b/apps/desktop/resources/locales/de-DE/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Hinzufügen",
+ "back": "Zurück",
+ "cancel": "Abbrechen",
+ "close": "Schließen",
+ "confirm": "Bestätigen",
+ "delete": "Löschen",
+ "edit": "Bearbeiten",
+ "more": "Mehr",
+ "next": "Weiter",
+ "ok": "OK",
+ "previous": "Zurück",
+ "refresh": "Aktualisieren",
+ "remove": "Entfernen",
+ "retry": "Erneut versuchen",
+ "save": "Speichern",
+ "search": "Suchen",
+ "submit": "Einreichen"
+ },
+ "app": {
+ "description": "Ihre KI-Assistenten-Kollaborationsplattform",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Fehler",
+ "info": "Information",
+ "loading": "Lädt",
+ "success": "Erfolg",
+ "warning": "Warnung"
+ }
+}
diff --git a/apps/desktop/resources/locales/de-DE/dialog.json b/apps/desktop/resources/locales/de-DE/dialog.json
new file mode 100644
index 0000000000..1690bf7718
--- /dev/null
+++ b/apps/desktop/resources/locales/de-DE/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Bestätigen",
+ "detail": "Eine Chat-Anwendung, die auf einem großen Sprachmodell basiert",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "Über"
+ },
+ "confirm": {
+ "cancel": "Abbrechen",
+ "no": "Nein",
+ "title": "Bestätigung",
+ "yes": "Ja"
+ },
+ "error": {
+ "button": "Bestätigen",
+ "detail": "Während der Operation ist ein Fehler aufgetreten, bitte versuchen Sie es später erneut",
+ "message": "Ein Fehler ist aufgetreten",
+ "title": "Fehler"
+ },
+ "update": {
+ "downloadAndInstall": "Herunterladen und installieren",
+ "downloadComplete": "Download abgeschlossen",
+ "downloadCompleteMessage": "Das Update-Paket wurde heruntergeladen, möchten Sie es jetzt installieren?",
+ "installLater": "Später installieren",
+ "installNow": "Jetzt installieren",
+ "later": "Später erinnern",
+ "newVersion": "Neue Version gefunden",
+ "newVersionAvailable": "Neue Version verfügbar: {{version}}",
+ "skipThisVersion": "Diese Version überspringen"
+ }
+}
diff --git a/apps/desktop/resources/locales/de-DE/menu.json b/apps/desktop/resources/locales/de-DE/menu.json
new file mode 100644
index 0000000000..ba915c733b
--- /dev/null
+++ b/apps/desktop/resources/locales/de-DE/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Überprüfen Sie auf Updates..."
+ },
+ "dev": {
+ "devPanel": "Entwicklerpanel",
+ "devTools": "Entwicklerwerkzeuge",
+ "forceReload": "Erzwinge Neuladen",
+ "openStore": "Speicherdatei öffnen",
+ "refreshMenu": "Menü aktualisieren",
+ "reload": "Neuladen",
+ "title": "Entwicklung"
+ },
+ "edit": {
+ "copy": "Kopieren",
+ "cut": "Ausschneiden",
+ "paste": "Einfügen",
+ "redo": "Wiederherstellen",
+ "selectAll": "Alles auswählen",
+ "speech": "Sprache",
+ "startSpeaking": "Beginne zu sprechen",
+ "stopSpeaking": "Stoppe das Sprechen",
+ "title": "Bearbeiten",
+ "undo": "Rückgängig"
+ },
+ "file": {
+ "preferences": "Einstellungen",
+ "quit": "Beenden",
+ "title": "Datei"
+ },
+ "help": {
+ "about": "Über",
+ "githubRepo": "GitHub-Repository",
+ "reportIssue": "Problem melden",
+ "title": "Hilfe",
+ "visitWebsite": "Besuche die Website"
+ },
+ "macOS": {
+ "about": "Über {{appName}}",
+ "devTools": "LobeHub Entwicklerwerkzeuge",
+ "hide": "{{appName}} ausblenden",
+ "hideOthers": "Andere ausblenden",
+ "preferences": "Einstellungen...",
+ "services": "Dienste",
+ "unhide": "Alle anzeigen"
+ },
+ "tray": {
+ "open": "{{appName}} öffnen",
+ "quit": "Beenden",
+ "show": "{{appName}} anzeigen"
+ },
+ "view": {
+ "forceReload": "Erzwinge Neuladen",
+ "reload": "Neuladen",
+ "resetZoom": "Zoom zurücksetzen",
+ "title": "Ansicht",
+ "toggleFullscreen": "Vollbild umschalten",
+ "zoomIn": "Vergrößern",
+ "zoomOut": "Verkleinern"
+ },
+ "window": {
+ "bringAllToFront": "Alle Fenster in den Vordergrund bringen",
+ "close": "Schließen",
+ "front": "Alle Fenster in den Vordergrund bringen",
+ "minimize": "Minimieren",
+ "title": "Fenster",
+ "toggleFullscreen": "Vollbild umschalten",
+ "zoom": "Zoom"
+ }
+}
diff --git a/apps/desktop/resources/locales/en-US/common.json b/apps/desktop/resources/locales/en-US/common.json
new file mode 100644
index 0000000000..631f337686
--- /dev/null
+++ b/apps/desktop/resources/locales/en-US/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Add",
+ "back": "Back",
+ "cancel": "Cancel",
+ "close": "Close",
+ "confirm": "Confirm",
+ "delete": "Delete",
+ "edit": "Edit",
+ "more": "More",
+ "next": "Next",
+ "ok": "OK",
+ "previous": "Previous",
+ "refresh": "Refresh",
+ "remove": "Remove",
+ "retry": "Retry",
+ "save": "Save",
+ "search": "Search",
+ "submit": "Submit"
+ },
+ "app": {
+ "description": "Your AI Assistant Collaboration Platform",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Error",
+ "info": "Information",
+ "loading": "Loading",
+ "success": "Success",
+ "warning": "Warning"
+ }
+}
diff --git a/apps/desktop/resources/locales/en-US/dialog.json b/apps/desktop/resources/locales/en-US/dialog.json
new file mode 100644
index 0000000000..3a30242932
--- /dev/null
+++ b/apps/desktop/resources/locales/en-US/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "OK",
+ "detail": "A chat application based on a large language model",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "About"
+ },
+ "confirm": {
+ "cancel": "Cancel",
+ "no": "No",
+ "title": "Confirm",
+ "yes": "Yes"
+ },
+ "error": {
+ "button": "OK",
+ "detail": "An error occurred during the operation, please try again later",
+ "message": "An error occurred",
+ "title": "Error"
+ },
+ "update": {
+ "downloadAndInstall": "Download and Install",
+ "downloadComplete": "Download Complete",
+ "downloadCompleteMessage": "The update package has been downloaded, would you like to install it now?",
+ "installLater": "Install Later",
+ "installNow": "Install Now",
+ "later": "Remind Me Later",
+ "newVersion": "New Version Found",
+ "newVersionAvailable": "New version available: {{version}}",
+ "skipThisVersion": "Skip This Version"
+ }
+}
diff --git a/apps/desktop/resources/locales/en-US/menu.json b/apps/desktop/resources/locales/en-US/menu.json
new file mode 100644
index 0000000000..53aaffc17b
--- /dev/null
+++ b/apps/desktop/resources/locales/en-US/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Checking for updates..."
+ },
+ "dev": {
+ "devPanel": "Developer Panel",
+ "devTools": "Developer Tools",
+ "forceReload": "Force Reload",
+ "openStore": "Open Storage File",
+ "refreshMenu": "Refresh menu",
+ "reload": "Reload",
+ "title": "Development"
+ },
+ "edit": {
+ "copy": "Copy",
+ "cut": "Cut",
+ "paste": "Paste",
+ "redo": "Redo",
+ "selectAll": "Select All",
+ "speech": "Speech",
+ "startSpeaking": "Start Speaking",
+ "stopSpeaking": "Stop Speaking",
+ "title": "Edit",
+ "undo": "Undo"
+ },
+ "file": {
+ "preferences": "Preferences",
+ "quit": "Quit",
+ "title": "File"
+ },
+ "help": {
+ "about": "About",
+ "githubRepo": "GitHub Repository",
+ "reportIssue": "Report Issue",
+ "title": "Help",
+ "visitWebsite": "Visit Website"
+ },
+ "macOS": {
+ "about": "About {{appName}}",
+ "devTools": "LobeHub Developer Tools",
+ "hide": "Hide {{appName}}",
+ "hideOthers": "Hide Others",
+ "preferences": "Preferences...",
+ "services": "Services",
+ "unhide": "Show All"
+ },
+ "tray": {
+ "open": "Open {{appName}}",
+ "quit": "Quit",
+ "show": "Show {{appName}}"
+ },
+ "view": {
+ "forceReload": "Force Reload",
+ "reload": "Reload",
+ "resetZoom": "Reset Zoom",
+ "title": "View",
+ "toggleFullscreen": "Toggle Fullscreen",
+ "zoomIn": "Zoom In",
+ "zoomOut": "Zoom Out"
+ },
+ "window": {
+ "bringAllToFront": "Bring All Windows to Front",
+ "close": "Close",
+ "front": "Bring All Windows to Front",
+ "minimize": "Minimize",
+ "title": "Window",
+ "toggleFullscreen": "Toggle Fullscreen",
+ "zoom": "Zoom"
+ }
+}
diff --git a/apps/desktop/resources/locales/es-ES/common.json b/apps/desktop/resources/locales/es-ES/common.json
new file mode 100644
index 0000000000..490d4e40e5
--- /dev/null
+++ b/apps/desktop/resources/locales/es-ES/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Agregar",
+ "back": "Volver",
+ "cancel": "Cancelar",
+ "close": "Cerrar",
+ "confirm": "Confirmar",
+ "delete": "Eliminar",
+ "edit": "Editar",
+ "more": "Más",
+ "next": "Siguiente",
+ "ok": "Aceptar",
+ "previous": "Anterior",
+ "refresh": "Actualizar",
+ "remove": "Eliminar",
+ "retry": "Reintentar",
+ "save": "Guardar",
+ "search": "Buscar",
+ "submit": "Enviar"
+ },
+ "app": {
+ "description": "Tu plataforma de colaboración con el asistente de IA",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Error",
+ "info": "Información",
+ "loading": "Cargando",
+ "success": "Éxito",
+ "warning": "Advertencia"
+ }
+}
diff --git a/apps/desktop/resources/locales/es-ES/dialog.json b/apps/desktop/resources/locales/es-ES/dialog.json
new file mode 100644
index 0000000000..1ab6c96549
--- /dev/null
+++ b/apps/desktop/resources/locales/es-ES/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Aceptar",
+ "detail": "Una aplicación de chat basada en un modelo de lenguaje grande",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "Acerca de"
+ },
+ "confirm": {
+ "cancel": "Cancelar",
+ "no": "No",
+ "title": "Confirmar",
+ "yes": "Sí"
+ },
+ "error": {
+ "button": "Aceptar",
+ "detail": "Se produjo un error durante la operación, por favor intente de nuevo más tarde",
+ "message": "Se produjo un error",
+ "title": "Error"
+ },
+ "update": {
+ "downloadAndInstall": "Descargar e instalar",
+ "downloadComplete": "Descarga completada",
+ "downloadCompleteMessage": "El paquete de actualización se ha descargado, ¿desea instalarlo ahora?",
+ "installLater": "Instalar más tarde",
+ "installNow": "Instalar ahora",
+ "later": "Recordar más tarde",
+ "newVersion": "Nueva versión disponible",
+ "newVersionAvailable": "Nueva versión encontrada: {{version}}",
+ "skipThisVersion": "Saltar esta versión"
+ }
+}
diff --git a/apps/desktop/resources/locales/es-ES/menu.json b/apps/desktop/resources/locales/es-ES/menu.json
new file mode 100644
index 0000000000..9c56487421
--- /dev/null
+++ b/apps/desktop/resources/locales/es-ES/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Comprobando actualizaciones..."
+ },
+ "dev": {
+ "devPanel": "Panel de desarrollador",
+ "devTools": "Herramientas de desarrollador",
+ "forceReload": "Recargar forzosamente",
+ "openStore": "Abrir archivo de almacenamiento",
+ "refreshMenu": "Actualizar menú",
+ "reload": "Recargar",
+ "title": "Desarrollo"
+ },
+ "edit": {
+ "copy": "Copiar",
+ "cut": "Cortar",
+ "paste": "Pegar",
+ "redo": "Rehacer",
+ "selectAll": "Seleccionar todo",
+ "speech": "Voz",
+ "startSpeaking": "Comenzar a leer en voz alta",
+ "stopSpeaking": "Detener lectura en voz alta",
+ "title": "Editar",
+ "undo": "Deshacer"
+ },
+ "file": {
+ "preferences": "Preferencias",
+ "quit": "Salir",
+ "title": "Archivo"
+ },
+ "help": {
+ "about": "Acerca de",
+ "githubRepo": "Repositorio de GitHub",
+ "reportIssue": "Reportar un problema",
+ "title": "Ayuda",
+ "visitWebsite": "Visitar el sitio web"
+ },
+ "macOS": {
+ "about": "Acerca de {{appName}}",
+ "devTools": "Herramientas de desarrollador de LobeHub",
+ "hide": "Ocultar {{appName}}",
+ "hideOthers": "Ocultar otros",
+ "preferences": "Configuración...",
+ "services": "Servicios",
+ "unhide": "Mostrar todo"
+ },
+ "tray": {
+ "open": "Abrir {{appName}}",
+ "quit": "Salir",
+ "show": "Mostrar {{appName}}"
+ },
+ "view": {
+ "forceReload": "Recargar forzosamente",
+ "reload": "Recargar",
+ "resetZoom": "Restablecer zoom",
+ "title": "Vista",
+ "toggleFullscreen": "Alternar pantalla completa",
+ "zoomIn": "Acercar",
+ "zoomOut": "Alejar"
+ },
+ "window": {
+ "bringAllToFront": "Traer todas las ventanas al frente",
+ "close": "Cerrar",
+ "front": "Traer todas las ventanas al frente",
+ "minimize": "Minimizar",
+ "title": "Ventana",
+ "toggleFullscreen": "Alternar pantalla completa",
+ "zoom": "Zoom"
+ }
+}
diff --git a/apps/desktop/resources/locales/fa-IR/common.json b/apps/desktop/resources/locales/fa-IR/common.json
new file mode 100644
index 0000000000..34b0b6a7e1
--- /dev/null
+++ b/apps/desktop/resources/locales/fa-IR/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "افزودن",
+ "back": "بازگشت",
+ "cancel": "لغو",
+ "close": "بستن",
+ "confirm": "تأیید",
+ "delete": "حذف",
+ "edit": "ویرایش",
+ "more": "بیشتر",
+ "next": "مرحله بعد",
+ "ok": "تأیید",
+ "previous": "مرحله قبل",
+ "refresh": "بهروزرسانی",
+ "remove": "حذف",
+ "retry": "تلاش مجدد",
+ "save": "ذخیره",
+ "search": "جستجو",
+ "submit": "ارسال"
+ },
+ "app": {
+ "description": "پلتفرم همکاری دستیار هوش مصنوعی شما",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "خطا",
+ "info": "اطلاعات",
+ "loading": "در حال بارگذاری",
+ "success": "موفق",
+ "warning": "هشدار"
+ }
+}
diff --git a/apps/desktop/resources/locales/fa-IR/dialog.json b/apps/desktop/resources/locales/fa-IR/dialog.json
new file mode 100644
index 0000000000..5c902de48b
--- /dev/null
+++ b/apps/desktop/resources/locales/fa-IR/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "تأیید",
+ "detail": "یک برنامه چت مبتنی بر مدلهای زبانی بزرگ",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "درباره"
+ },
+ "confirm": {
+ "cancel": "لغو",
+ "no": "خیر",
+ "title": "تأیید",
+ "yes": "بله"
+ },
+ "error": {
+ "button": "تأیید",
+ "detail": "در حین انجام عملیات خطایی رخ داده است، لطفاً بعداً دوباره تلاش کنید",
+ "message": "خطا رخ داده است",
+ "title": "خطا"
+ },
+ "update": {
+ "downloadAndInstall": "دانلود و نصب",
+ "downloadComplete": "دانلود کامل شد",
+ "downloadCompleteMessage": "بسته بهروزرسانی دانلود شده است، آیا میخواهید بلافاصله نصب کنید؟",
+ "installLater": "نصب بعداً",
+ "installNow": "نصب اکنون",
+ "later": "یادآوری بعداً",
+ "newVersion": "نسخه جدیدی پیدا شد",
+ "newVersionAvailable": "نسخه جدید پیدا شد: {{version}}",
+ "skipThisVersion": "این نسخه را نادیده بگیرید"
+ }
+}
diff --git a/apps/desktop/resources/locales/fa-IR/menu.json b/apps/desktop/resources/locales/fa-IR/menu.json
new file mode 100644
index 0000000000..94f469a0b4
--- /dev/null
+++ b/apps/desktop/resources/locales/fa-IR/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "بررسی بهروزرسانی..."
+ },
+ "dev": {
+ "devPanel": "پنل توسعهدهنده",
+ "devTools": "ابزارهای توسعهدهنده",
+ "forceReload": "بارگذاری اجباری",
+ "openStore": "باز کردن فایلهای ذخیره شده",
+ "refreshMenu": "بهروزرسانی منو",
+ "reload": "بارگذاری مجدد",
+ "title": "توسعه"
+ },
+ "edit": {
+ "copy": "کپی",
+ "cut": "برش",
+ "paste": "چسباندن",
+ "redo": "انجام مجدد",
+ "selectAll": "انتخاب همه",
+ "speech": "گفتار",
+ "startSpeaking": "شروع به خواندن",
+ "stopSpeaking": "متوقف کردن خواندن",
+ "title": "ویرایش",
+ "undo": "بازگشت"
+ },
+ "file": {
+ "preferences": "تنظیمات",
+ "quit": "خروج",
+ "title": "فایل"
+ },
+ "help": {
+ "about": "درباره",
+ "githubRepo": "مخزن GitHub",
+ "reportIssue": "گزارش مشکل",
+ "title": "کمک",
+ "visitWebsite": "بازدید از وبسایت"
+ },
+ "macOS": {
+ "about": "درباره {{appName}}",
+ "devTools": "ابزارهای توسعهدهنده LobeHub",
+ "hide": "پنهان کردن {{appName}}",
+ "hideOthers": "پنهان کردن دیگران",
+ "preferences": "تنظیمات...",
+ "services": "خدمات",
+ "unhide": "نمایش همه"
+ },
+ "tray": {
+ "open": "باز کردن {{appName}}",
+ "quit": "خروج",
+ "show": "نمایش {{appName}}"
+ },
+ "view": {
+ "forceReload": "بارگذاری اجباری",
+ "reload": "بارگذاری مجدد",
+ "resetZoom": "تنظیم زوم به حالت اولیه",
+ "title": "نمایش",
+ "toggleFullscreen": "تغییر به حالت تمام صفحه",
+ "zoomIn": "بزرگنمایی",
+ "zoomOut": "کوچکنمایی"
+ },
+ "window": {
+ "bringAllToFront": "همه پنجرهها را به جلو بیاورید",
+ "close": "بستن",
+ "front": "همه پنجرهها را به جلو بیاورید",
+ "minimize": "کوچک کردن",
+ "title": "پنجره",
+ "toggleFullscreen": "تغییر به حالت تمام صفحه",
+ "zoom": "زوم"
+ }
+}
diff --git a/apps/desktop/resources/locales/fr-FR/common.json b/apps/desktop/resources/locales/fr-FR/common.json
new file mode 100644
index 0000000000..aff184ddd0
--- /dev/null
+++ b/apps/desktop/resources/locales/fr-FR/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Ajouter",
+ "back": "Retour",
+ "cancel": "Annuler",
+ "close": "Fermer",
+ "confirm": "Confirmer",
+ "delete": "Supprimer",
+ "edit": "Éditer",
+ "more": "Plus",
+ "next": "Suivant",
+ "ok": "D'accord",
+ "previous": "Précédent",
+ "refresh": "Rafraîchir",
+ "remove": "Retirer",
+ "retry": "Réessayer",
+ "save": "Enregistrer",
+ "search": "Rechercher",
+ "submit": "Soumettre"
+ },
+ "app": {
+ "description": "Votre plateforme de collaboration avec l'assistant IA",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Erreur",
+ "info": "Information",
+ "loading": "Chargement",
+ "success": "Succès",
+ "warning": "Avertissement"
+ }
+}
diff --git a/apps/desktop/resources/locales/fr-FR/dialog.json b/apps/desktop/resources/locales/fr-FR/dialog.json
new file mode 100644
index 0000000000..972a980a14
--- /dev/null
+++ b/apps/desktop/resources/locales/fr-FR/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "D'accord",
+ "detail": "Une application de chat basée sur un grand modèle de langage",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "À propos"
+ },
+ "confirm": {
+ "cancel": "Annuler",
+ "no": "Non",
+ "title": "Confirmer",
+ "yes": "Oui"
+ },
+ "error": {
+ "button": "D'accord",
+ "detail": "Une erreur s'est produite lors de l'opération, veuillez réessayer plus tard",
+ "message": "Une erreur s'est produite",
+ "title": "Erreur"
+ },
+ "update": {
+ "downloadAndInstall": "Télécharger et installer",
+ "downloadComplete": "Téléchargement terminé",
+ "downloadCompleteMessage": "Le paquet de mise à jour a été téléchargé, souhaitez-vous l'installer maintenant ?",
+ "installLater": "Installer plus tard",
+ "installNow": "Installer maintenant",
+ "later": "Rappeler plus tard",
+ "newVersion": "Nouvelle version détectée",
+ "newVersionAvailable": "Nouvelle version disponible : {{version}}",
+ "skipThisVersion": "Ignorer cette version"
+ }
+}
diff --git a/apps/desktop/resources/locales/fr-FR/menu.json b/apps/desktop/resources/locales/fr-FR/menu.json
new file mode 100644
index 0000000000..1bf3ff34f5
--- /dev/null
+++ b/apps/desktop/resources/locales/fr-FR/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Vérifier les mises à jour..."
+ },
+ "dev": {
+ "devPanel": "Panneau de développement",
+ "devTools": "Outils de développement",
+ "forceReload": "Recharger de force",
+ "openStore": "Ouvrir le fichier de stockage",
+ "refreshMenu": "Rafraîchir le menu",
+ "reload": "Recharger",
+ "title": "Développement"
+ },
+ "edit": {
+ "copy": "Copier",
+ "cut": "Couper",
+ "paste": "Coller",
+ "redo": "Rétablir",
+ "selectAll": "Tout sélectionner",
+ "speech": "Voix",
+ "startSpeaking": "Commencer à lire",
+ "stopSpeaking": "Arrêter de lire",
+ "title": "Édition",
+ "undo": "Annuler"
+ },
+ "file": {
+ "preferences": "Préférences",
+ "quit": "Quitter",
+ "title": "Fichier"
+ },
+ "help": {
+ "about": "À propos",
+ "githubRepo": "Dépôt GitHub",
+ "reportIssue": "Signaler un problème",
+ "title": "Aide",
+ "visitWebsite": "Visiter le site officiel"
+ },
+ "macOS": {
+ "about": "À propos de {{appName}}",
+ "devTools": "Outils de développement LobeHub",
+ "hide": "Masquer {{appName}}",
+ "hideOthers": "Masquer les autres",
+ "preferences": "Préférences...",
+ "services": "Services",
+ "unhide": "Tout afficher"
+ },
+ "tray": {
+ "open": "Ouvrir {{appName}}",
+ "quit": "Quitter",
+ "show": "Afficher {{appName}}"
+ },
+ "view": {
+ "forceReload": "Recharger de force",
+ "reload": "Recharger",
+ "resetZoom": "Réinitialiser le zoom",
+ "title": "Affichage",
+ "toggleFullscreen": "Basculer en plein écran",
+ "zoomIn": "Zoomer",
+ "zoomOut": "Dézoomer"
+ },
+ "window": {
+ "bringAllToFront": "Mettre toutes les fenêtres au premier plan",
+ "close": "Fermer",
+ "front": "Mettre toutes les fenêtres au premier plan",
+ "minimize": "Réduire",
+ "title": "Fenêtre",
+ "toggleFullscreen": "Basculer en plein écran",
+ "zoom": "Zoom"
+ }
+}
diff --git a/apps/desktop/resources/locales/it-IT/common.json b/apps/desktop/resources/locales/it-IT/common.json
new file mode 100644
index 0000000000..749770cc8d
--- /dev/null
+++ b/apps/desktop/resources/locales/it-IT/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Aggiungi",
+ "back": "Indietro",
+ "cancel": "Annulla",
+ "close": "Chiudi",
+ "confirm": "Conferma",
+ "delete": "Elimina",
+ "edit": "Modifica",
+ "more": "Di più",
+ "next": "Avanti",
+ "ok": "OK",
+ "previous": "Indietro",
+ "refresh": "Aggiorna",
+ "remove": "Rimuovi",
+ "retry": "Riprova",
+ "save": "Salva",
+ "search": "Cerca",
+ "submit": "Invia"
+ },
+ "app": {
+ "description": "La tua piattaforma di collaborazione con assistente AI",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Errore",
+ "info": "Informazioni",
+ "loading": "Caricamento in corso",
+ "success": "Successo",
+ "warning": "Avviso"
+ }
+}
diff --git a/apps/desktop/resources/locales/it-IT/dialog.json b/apps/desktop/resources/locales/it-IT/dialog.json
new file mode 100644
index 0000000000..95dbe05d41
--- /dev/null
+++ b/apps/desktop/resources/locales/it-IT/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Conferma",
+ "detail": "Un'app di chat basata su un grande modello linguistico",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "Informazioni"
+ },
+ "confirm": {
+ "cancel": "Annulla",
+ "no": "No",
+ "title": "Conferma",
+ "yes": "Sì"
+ },
+ "error": {
+ "button": "Conferma",
+ "detail": "Si è verificato un errore durante l'operazione, riprovare più tardi",
+ "message": "Si è verificato un errore",
+ "title": "Errore"
+ },
+ "update": {
+ "downloadAndInstall": "Scarica e installa",
+ "downloadComplete": "Download completato",
+ "downloadCompleteMessage": "Il pacchetto di aggiornamento è stato scaricato, vuoi installarlo subito?",
+ "installLater": "Installa più tardi",
+ "installNow": "Installa ora",
+ "later": "Promemoria più tardi",
+ "newVersion": "Nuova versione disponibile",
+ "newVersionAvailable": "Nuova versione trovata: {{version}}",
+ "skipThisVersion": "Salta questa versione"
+ }
+}
diff --git a/apps/desktop/resources/locales/it-IT/menu.json b/apps/desktop/resources/locales/it-IT/menu.json
new file mode 100644
index 0000000000..0953cfd9ee
--- /dev/null
+++ b/apps/desktop/resources/locales/it-IT/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Controlla aggiornamenti..."
+ },
+ "dev": {
+ "devPanel": "Pannello sviluppatore",
+ "devTools": "Strumenti per sviluppatori",
+ "forceReload": "Ricarica forzata",
+ "openStore": "Apri il file di archiviazione",
+ "refreshMenu": "Aggiorna menu",
+ "reload": "Ricarica",
+ "title": "Sviluppo"
+ },
+ "edit": {
+ "copy": "Copia",
+ "cut": "Taglia",
+ "paste": "Incolla",
+ "redo": "Ripeti",
+ "selectAll": "Seleziona tutto",
+ "speech": "Voce",
+ "startSpeaking": "Inizia a leggere",
+ "stopSpeaking": "Ferma la lettura",
+ "title": "Modifica",
+ "undo": "Annulla"
+ },
+ "file": {
+ "preferences": "Preferenze",
+ "quit": "Esci",
+ "title": "File"
+ },
+ "help": {
+ "about": "Informazioni",
+ "githubRepo": "Repository GitHub",
+ "reportIssue": "Segnala un problema",
+ "title": "Aiuto",
+ "visitWebsite": "Visita il sito ufficiale"
+ },
+ "macOS": {
+ "about": "Informazioni su {{appName}}",
+ "devTools": "Strumenti per sviluppatori LobeHub",
+ "hide": "Nascondi {{appName}}",
+ "hideOthers": "Nascondi altri",
+ "preferences": "Impostazioni...",
+ "services": "Servizi",
+ "unhide": "Mostra tutto"
+ },
+ "tray": {
+ "open": "Apri {{appName}}",
+ "quit": "Esci",
+ "show": "Mostra {{appName}}"
+ },
+ "view": {
+ "forceReload": "Ricarica forzata",
+ "reload": "Ricarica",
+ "resetZoom": "Reimposta zoom",
+ "title": "Visualizza",
+ "toggleFullscreen": "Attiva/disattiva schermo intero",
+ "zoomIn": "Ingrandisci",
+ "zoomOut": "Riduci"
+ },
+ "window": {
+ "bringAllToFront": "Porta tutte le finestre in primo piano",
+ "close": "Chiudi",
+ "front": "Porta tutte le finestre in primo piano",
+ "minimize": "Minimizza",
+ "title": "Finestra",
+ "toggleFullscreen": "Attiva/disattiva schermo intero",
+ "zoom": "Zoom"
+ }
+}
diff --git a/apps/desktop/resources/locales/ja-JP/common.json b/apps/desktop/resources/locales/ja-JP/common.json
new file mode 100644
index 0000000000..26f3071cf5
--- /dev/null
+++ b/apps/desktop/resources/locales/ja-JP/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "追加",
+ "back": "戻る",
+ "cancel": "キャンセル",
+ "close": "閉じる",
+ "confirm": "確認",
+ "delete": "削除",
+ "edit": "編集",
+ "more": "もっと見る",
+ "next": "次へ",
+ "ok": "OK",
+ "previous": "前へ",
+ "refresh": "更新",
+ "remove": "削除",
+ "retry": "再試行",
+ "save": "保存",
+ "search": "検索",
+ "submit": "送信"
+ },
+ "app": {
+ "description": "あなたのAIアシスタント協力プラットフォーム",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "エラー",
+ "info": "情報",
+ "loading": "読み込み中",
+ "success": "成功",
+ "warning": "警告"
+ }
+}
diff --git a/apps/desktop/resources/locales/ja-JP/dialog.json b/apps/desktop/resources/locales/ja-JP/dialog.json
new file mode 100644
index 0000000000..46e9942579
--- /dev/null
+++ b/apps/desktop/resources/locales/ja-JP/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "確定",
+ "detail": "大規模言語モデルに基づくチャットアプリ",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "について"
+ },
+ "confirm": {
+ "cancel": "キャンセル",
+ "no": "いいえ",
+ "title": "確認",
+ "yes": "はい"
+ },
+ "error": {
+ "button": "確定",
+ "detail": "操作中にエラーが発生しました。後で再試行してください。",
+ "message": "エラーが発生しました",
+ "title": "エラー"
+ },
+ "update": {
+ "downloadAndInstall": "ダウンロードしてインストール",
+ "downloadComplete": "ダウンロード完了",
+ "downloadCompleteMessage": "更新パッケージのダウンロードが完了しました。今すぐインストールしますか?",
+ "installLater": "後でインストール",
+ "installNow": "今すぐインストール",
+ "later": "後でリマインド",
+ "newVersion": "新しいバージョンが見つかりました",
+ "newVersionAvailable": "新しいバージョンが見つかりました: {{version}}",
+ "skipThisVersion": "このバージョンをスキップ"
+ }
+}
diff --git a/apps/desktop/resources/locales/ja-JP/menu.json b/apps/desktop/resources/locales/ja-JP/menu.json
new file mode 100644
index 0000000000..718630a28a
--- /dev/null
+++ b/apps/desktop/resources/locales/ja-JP/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "更新を確認しています..."
+ },
+ "dev": {
+ "devPanel": "開発者パネル",
+ "devTools": "開発者ツール",
+ "forceReload": "強制再読み込み",
+ "openStore": "ストレージファイルを開く",
+ "refreshMenu": "メニューを更新",
+ "reload": "再読み込み",
+ "title": "開発"
+ },
+ "edit": {
+ "copy": "コピー",
+ "cut": "切り取り",
+ "paste": "貼り付け",
+ "redo": "やり直し",
+ "selectAll": "すべて選択",
+ "speech": "音声",
+ "startSpeaking": "読み上げ開始",
+ "stopSpeaking": "読み上げ停止",
+ "title": "編集",
+ "undo": "元に戻す"
+ },
+ "file": {
+ "preferences": "設定",
+ "quit": "終了",
+ "title": "ファイル"
+ },
+ "help": {
+ "about": "について",
+ "githubRepo": "GitHub リポジトリ",
+ "reportIssue": "問題を報告",
+ "title": "ヘルプ",
+ "visitWebsite": "公式ウェブサイトを訪問"
+ },
+ "macOS": {
+ "about": "{{appName}} について",
+ "devTools": "LobeHub 開発者ツール",
+ "hide": "{{appName}} を隠す",
+ "hideOthers": "他を隠す",
+ "preferences": "環境設定...",
+ "services": "サービス",
+ "unhide": "すべて表示"
+ },
+ "tray": {
+ "open": "{{appName}} を開く",
+ "quit": "終了",
+ "show": "{{appName}} を表示"
+ },
+ "view": {
+ "forceReload": "強制再読み込み",
+ "reload": "再読み込み",
+ "resetZoom": "ズームをリセット",
+ "title": "ビュー",
+ "toggleFullscreen": "フルスクリーン切替",
+ "zoomIn": "ズームイン",
+ "zoomOut": "ズームアウト"
+ },
+ "window": {
+ "bringAllToFront": "すべてのウィンドウを前面に",
+ "close": "閉じる",
+ "front": "すべてのウィンドウを前面に",
+ "minimize": "最小化",
+ "title": "ウィンドウ",
+ "toggleFullscreen": "フルスクリーン切替",
+ "zoom": "ズーム"
+ }
+}
diff --git a/apps/desktop/resources/locales/ko-KR/common.json b/apps/desktop/resources/locales/ko-KR/common.json
new file mode 100644
index 0000000000..af16ea9866
--- /dev/null
+++ b/apps/desktop/resources/locales/ko-KR/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "추가",
+ "back": "뒤로",
+ "cancel": "취소",
+ "close": "닫기",
+ "confirm": "확인",
+ "delete": "삭제",
+ "edit": "편집",
+ "more": "더보기",
+ "next": "다음",
+ "ok": "확인",
+ "previous": "이전",
+ "refresh": "새로 고침",
+ "remove": "제거",
+ "retry": "다시 시도",
+ "save": "저장",
+ "search": "검색",
+ "submit": "제출"
+ },
+ "app": {
+ "description": "당신의 AI 비서 협업 플랫폼",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "오류",
+ "info": "정보",
+ "loading": "로딩 중",
+ "success": "성공",
+ "warning": "경고"
+ }
+}
diff --git a/apps/desktop/resources/locales/ko-KR/dialog.json b/apps/desktop/resources/locales/ko-KR/dialog.json
new file mode 100644
index 0000000000..4a4fff9f76
--- /dev/null
+++ b/apps/desktop/resources/locales/ko-KR/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "확인",
+ "detail": "대형 언어 모델 기반의 채팅 애플리케이션",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "정보"
+ },
+ "confirm": {
+ "cancel": "취소",
+ "no": "아니요",
+ "title": "확인",
+ "yes": "예"
+ },
+ "error": {
+ "button": "확인",
+ "detail": "작업 중 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
+ "message": "오류 발생",
+ "title": "오류"
+ },
+ "update": {
+ "downloadAndInstall": "다운로드 및 설치",
+ "downloadComplete": "다운로드 완료",
+ "downloadCompleteMessage": "업데이트 패키지가 다운로드 완료되었습니다. 지금 설치하시겠습니까?",
+ "installLater": "나중에 설치",
+ "installNow": "지금 설치",
+ "later": "나중에 알림",
+ "newVersion": "새 버전 발견",
+ "newVersionAvailable": "새 버전 발견: {{version}}",
+ "skipThisVersion": "이 버전 건너뛰기"
+ }
+}
diff --git a/apps/desktop/resources/locales/ko-KR/menu.json b/apps/desktop/resources/locales/ko-KR/menu.json
new file mode 100644
index 0000000000..65ec256c66
--- /dev/null
+++ b/apps/desktop/resources/locales/ko-KR/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "업데이트 확인 중..."
+ },
+ "dev": {
+ "devPanel": "개발자 패널",
+ "devTools": "개발자 도구",
+ "forceReload": "강제 새로 고침",
+ "openStore": "저장 파일 열기",
+ "refreshMenu": "메뉴 새로 고침",
+ "reload": "새로 고침",
+ "title": "개발"
+ },
+ "edit": {
+ "copy": "복사",
+ "cut": "잘라내기",
+ "paste": "붙여넣기",
+ "redo": "다시 실행",
+ "selectAll": "모두 선택",
+ "speech": "음성",
+ "startSpeaking": "읽기 시작",
+ "stopSpeaking": "읽기 중지",
+ "title": "편집",
+ "undo": "실행 취소"
+ },
+ "file": {
+ "preferences": "환경 설정",
+ "quit": "종료",
+ "title": "파일"
+ },
+ "help": {
+ "about": "정보",
+ "githubRepo": "GitHub 저장소",
+ "reportIssue": "문제 보고",
+ "title": "도움말",
+ "visitWebsite": "웹사이트 방문"
+ },
+ "macOS": {
+ "about": "{{appName}} 정보",
+ "devTools": "LobeHub 개발자 도구",
+ "hide": "{{appName}} 숨기기",
+ "hideOthers": "다른 것 숨기기",
+ "preferences": "환경 설정...",
+ "services": "서비스",
+ "unhide": "모두 표시"
+ },
+ "tray": {
+ "open": "{{appName}} 열기",
+ "quit": "종료",
+ "show": "{{appName}} 표시"
+ },
+ "view": {
+ "forceReload": "강제 새로 고침",
+ "reload": "새로 고침",
+ "resetZoom": "줌 초기화",
+ "title": "보기",
+ "toggleFullscreen": "전체 화면 전환",
+ "zoomIn": "확대",
+ "zoomOut": "축소"
+ },
+ "window": {
+ "bringAllToFront": "모든 창 앞으로 가져오기",
+ "close": "닫기",
+ "front": "모든 창 앞으로 가져오기",
+ "minimize": "최소화",
+ "title": "창",
+ "toggleFullscreen": "전체 화면 전환",
+ "zoom": "줌"
+ }
+}
diff --git a/apps/desktop/resources/locales/nl-NL/common.json b/apps/desktop/resources/locales/nl-NL/common.json
new file mode 100644
index 0000000000..6a2f0aa630
--- /dev/null
+++ b/apps/desktop/resources/locales/nl-NL/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Toevoegen",
+ "back": "Terug",
+ "cancel": "Annuleren",
+ "close": "Sluiten",
+ "confirm": "Bevestigen",
+ "delete": "Verwijderen",
+ "edit": "Bewerken",
+ "more": "Meer",
+ "next": "Volgende stap",
+ "ok": "OK",
+ "previous": "Vorige stap",
+ "refresh": "Vernieuwen",
+ "remove": "Verwijderen",
+ "retry": "Opnieuw proberen",
+ "save": "Opslaan",
+ "search": "Zoeken",
+ "submit": "Indienen"
+ },
+ "app": {
+ "description": "Jouw AI-assistent samenwerkingsplatform",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Fout",
+ "info": "Informatie",
+ "loading": "Laden",
+ "success": "Succes",
+ "warning": "Waarschuwing"
+ }
+}
diff --git a/apps/desktop/resources/locales/nl-NL/dialog.json b/apps/desktop/resources/locales/nl-NL/dialog.json
new file mode 100644
index 0000000000..aeb6042ded
--- /dev/null
+++ b/apps/desktop/resources/locales/nl-NL/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Bevestigen",
+ "detail": "Een chatapplicatie gebaseerd op een groot taalmodel",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "Over"
+ },
+ "confirm": {
+ "cancel": "Annuleren",
+ "no": "Nee",
+ "title": "Bevestigen",
+ "yes": "Ja"
+ },
+ "error": {
+ "button": "Bevestigen",
+ "detail": "Er is een fout opgetreden tijdens de operatie, probeer het later opnieuw",
+ "message": "Er is een fout opgetreden",
+ "title": "Fout"
+ },
+ "update": {
+ "downloadAndInstall": "Downloaden en installeren",
+ "downloadComplete": "Download voltooid",
+ "downloadCompleteMessage": "Het updatepakket is gedownload, wilt u het nu installeren?",
+ "installLater": "Later installeren",
+ "installNow": "Nu installeren",
+ "later": "Later herinneren",
+ "newVersion": "Nieuwe versie gevonden",
+ "newVersionAvailable": "Nieuwe versie beschikbaar: {{version}}",
+ "skipThisVersion": "Deze versie overslaan"
+ }
+}
diff --git a/apps/desktop/resources/locales/nl-NL/menu.json b/apps/desktop/resources/locales/nl-NL/menu.json
new file mode 100644
index 0000000000..82629c0585
--- /dev/null
+++ b/apps/desktop/resources/locales/nl-NL/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Updates controleren..."
+ },
+ "dev": {
+ "devPanel": "Ontwikkelaarspaneel",
+ "devTools": "Ontwikkelaarstools",
+ "forceReload": "Forceer herladen",
+ "openStore": "Open opslagbestand",
+ "refreshMenu": "Menu verversen",
+ "reload": "Herladen",
+ "title": "Ontwikkeling"
+ },
+ "edit": {
+ "copy": "Kopiëren",
+ "cut": "Knippen",
+ "paste": "Plakken",
+ "redo": "Opnieuw doen",
+ "selectAll": "Alles selecteren",
+ "speech": "Spraak",
+ "startSpeaking": "Begin met voorlezen",
+ "stopSpeaking": "Stop met voorlezen",
+ "title": "Bewerken",
+ "undo": "Ongedaan maken"
+ },
+ "file": {
+ "preferences": "Voorkeuren",
+ "quit": "Afsluiten",
+ "title": "Bestand"
+ },
+ "help": {
+ "about": "Over",
+ "githubRepo": "GitHub-repo",
+ "reportIssue": "Probleem melden",
+ "title": "Hulp",
+ "visitWebsite": "Bezoek de website"
+ },
+ "macOS": {
+ "about": "Over {{appName}}",
+ "devTools": "LobeHub Ontwikkelaarstools",
+ "hide": "Verberg {{appName}}",
+ "hideOthers": "Verberg anderen",
+ "preferences": "Voorkeuren...",
+ "services": "Diensten",
+ "unhide": "Toon alles"
+ },
+ "tray": {
+ "open": "Open {{appName}}",
+ "quit": "Afsluiten",
+ "show": "Toon {{appName}}"
+ },
+ "view": {
+ "forceReload": "Forceer herladen",
+ "reload": "Herladen",
+ "resetZoom": "Zoom resetten",
+ "title": "Weergave",
+ "toggleFullscreen": "Schakel volledig scherm in/uit",
+ "zoomIn": "Inzoomen",
+ "zoomOut": "Uitzoomen"
+ },
+ "window": {
+ "bringAllToFront": "Breng alle vensters naar voren",
+ "close": "Sluiten",
+ "front": "Breng alle vensters naar voren",
+ "minimize": "Minimaliseren",
+ "title": "Venster",
+ "toggleFullscreen": "Schakel volledig scherm in/uit",
+ "zoom": "Inzoomen"
+ }
+}
diff --git a/apps/desktop/resources/locales/pl-PL/common.json b/apps/desktop/resources/locales/pl-PL/common.json
new file mode 100644
index 0000000000..e455eebe79
--- /dev/null
+++ b/apps/desktop/resources/locales/pl-PL/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Dodaj",
+ "back": "Wstecz",
+ "cancel": "Anuluj",
+ "close": "Zamknij",
+ "confirm": "Potwierdź",
+ "delete": "Usuń",
+ "edit": "Edytuj",
+ "more": "Więcej",
+ "next": "Dalej",
+ "ok": "OK",
+ "previous": "Cofnij",
+ "refresh": "Odśwież",
+ "remove": "Usuń",
+ "retry": "Spróbuj ponownie",
+ "save": "Zapisz",
+ "search": "Szukaj",
+ "submit": "Wyślij"
+ },
+ "app": {
+ "description": "Twoja platforma współpracy z asystentem AI",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Błąd",
+ "info": "Informacja",
+ "loading": "Ładowanie",
+ "success": "Sukces",
+ "warning": "Ostrzeżenie"
+ }
+}
diff --git a/apps/desktop/resources/locales/pl-PL/dialog.json b/apps/desktop/resources/locales/pl-PL/dialog.json
new file mode 100644
index 0000000000..e8b3241249
--- /dev/null
+++ b/apps/desktop/resources/locales/pl-PL/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "OK",
+ "detail": "Aplikacja czatu oparta na dużym modelu językowym",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "O aplikacji"
+ },
+ "confirm": {
+ "cancel": "Anuluj",
+ "no": "Nie",
+ "title": "Potwierdzenie",
+ "yes": "Tak"
+ },
+ "error": {
+ "button": "OK",
+ "detail": "Wystąpił błąd podczas operacji, spróbuj ponownie później",
+ "message": "Wystąpił błąd",
+ "title": "Błąd"
+ },
+ "update": {
+ "downloadAndInstall": "Pobierz i zainstaluj",
+ "downloadComplete": "Pobieranie zakończone",
+ "downloadCompleteMessage": "Pakiet aktualizacji został pobrany, czy chcesz go teraz zainstalować?",
+ "installLater": "Zainstaluj później",
+ "installNow": "Zainstaluj teraz",
+ "later": "Przypomnij później",
+ "newVersion": "Nowa wersja dostępna",
+ "newVersionAvailable": "Znaleziono nową wersję: {{version}}",
+ "skipThisVersion": "Pomiń tę wersję"
+ }
+}
diff --git a/apps/desktop/resources/locales/pl-PL/menu.json b/apps/desktop/resources/locales/pl-PL/menu.json
new file mode 100644
index 0000000000..975e63d672
--- /dev/null
+++ b/apps/desktop/resources/locales/pl-PL/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Sprawdzanie aktualizacji..."
+ },
+ "dev": {
+ "devPanel": "Panel dewelopera",
+ "devTools": "Narzędzia dewelopera",
+ "forceReload": "Wymuś ponowne załadowanie",
+ "openStore": "Otwórz plik magazynu",
+ "refreshMenu": "Odśwież menu",
+ "reload": "Przeładuj",
+ "title": "Rozwój"
+ },
+ "edit": {
+ "copy": "Kopiuj",
+ "cut": "Wytnij",
+ "paste": "Wklej",
+ "redo": "Ponów",
+ "selectAll": "Zaznacz wszystko",
+ "speech": "Mowa",
+ "startSpeaking": "Rozpocznij czytanie",
+ "stopSpeaking": "Zatrzymaj czytanie",
+ "title": "Edycja",
+ "undo": "Cofnij"
+ },
+ "file": {
+ "preferences": "Preferencje",
+ "quit": "Zakończ",
+ "title": "Plik"
+ },
+ "help": {
+ "about": "O",
+ "githubRepo": "Repozytorium GitHub",
+ "reportIssue": "Zgłoś problem",
+ "title": "Pomoc",
+ "visitWebsite": "Odwiedź stronę internetową"
+ },
+ "macOS": {
+ "about": "O {{appName}}",
+ "devTools": "Narzędzia dewelopera LobeHub",
+ "hide": "Ukryj {{appName}}",
+ "hideOthers": "Ukryj inne",
+ "preferences": "Ustawienia...",
+ "services": "Usługi",
+ "unhide": "Pokaż wszystko"
+ },
+ "tray": {
+ "open": "Otwórz {{appName}}",
+ "quit": "Zakończ",
+ "show": "Pokaż {{appName}}"
+ },
+ "view": {
+ "forceReload": "Wymuś ponowne załadowanie",
+ "reload": "Przeładuj",
+ "resetZoom": "Zresetuj powiększenie",
+ "title": "Widok",
+ "toggleFullscreen": "Przełącz tryb pełnoekranowy",
+ "zoomIn": "Powiększ",
+ "zoomOut": "Pomniejsz"
+ },
+ "window": {
+ "bringAllToFront": "Przenieś wszystkie okna na wierzch",
+ "close": "Zamknij",
+ "front": "Przenieś wszystkie okna na wierzch",
+ "minimize": "Zminimalizuj",
+ "title": "Okno",
+ "toggleFullscreen": "Przełącz tryb pełnoekranowy",
+ "zoom": "Powiększenie"
+ }
+}
diff --git a/apps/desktop/resources/locales/pt-BR/common.json b/apps/desktop/resources/locales/pt-BR/common.json
new file mode 100644
index 0000000000..7851975f2c
--- /dev/null
+++ b/apps/desktop/resources/locales/pt-BR/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Adicionar",
+ "back": "Voltar",
+ "cancel": "Cancelar",
+ "close": "Fechar",
+ "confirm": "Confirmar",
+ "delete": "Excluir",
+ "edit": "Editar",
+ "more": "Mais",
+ "next": "Próximo",
+ "ok": "OK",
+ "previous": "Anterior",
+ "refresh": "Atualizar",
+ "remove": "Remover",
+ "retry": "Tentar novamente",
+ "save": "Salvar",
+ "search": "Pesquisar",
+ "submit": "Enviar"
+ },
+ "app": {
+ "description": "Sua plataforma de colaboração com assistente de IA",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Erro",
+ "info": "Informação",
+ "loading": "Carregando",
+ "success": "Sucesso",
+ "warning": "Aviso"
+ }
+}
diff --git a/apps/desktop/resources/locales/pt-BR/dialog.json b/apps/desktop/resources/locales/pt-BR/dialog.json
new file mode 100644
index 0000000000..392c641a71
--- /dev/null
+++ b/apps/desktop/resources/locales/pt-BR/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Confirmar",
+ "detail": "Um aplicativo de chat baseado em um grande modelo de linguagem",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "Sobre"
+ },
+ "confirm": {
+ "cancel": "Cancelar",
+ "no": "Não",
+ "title": "Confirmar",
+ "yes": "Sim"
+ },
+ "error": {
+ "button": "Confirmar",
+ "detail": "Ocorreu um erro durante a operação, por favor tente novamente mais tarde",
+ "message": "Ocorreu um erro",
+ "title": "Erro"
+ },
+ "update": {
+ "downloadAndInstall": "Baixar e instalar",
+ "downloadComplete": "Download completo",
+ "downloadCompleteMessage": "O pacote de atualização foi baixado com sucesso, deseja instalá-lo agora?",
+ "installLater": "Instalar depois",
+ "installNow": "Instalar agora",
+ "later": "Lembrar mais tarde",
+ "newVersion": "Nova versão disponível",
+ "newVersionAvailable": "Nova versão encontrada: {{version}}",
+ "skipThisVersion": "Ignorar esta versão"
+ }
+}
diff --git a/apps/desktop/resources/locales/pt-BR/menu.json b/apps/desktop/resources/locales/pt-BR/menu.json
new file mode 100644
index 0000000000..216448d9ba
--- /dev/null
+++ b/apps/desktop/resources/locales/pt-BR/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Verificando atualizações..."
+ },
+ "dev": {
+ "devPanel": "Painel do Desenvolvedor",
+ "devTools": "Ferramentas do Desenvolvedor",
+ "forceReload": "Recarregar Forçadamente",
+ "openStore": "Abrir arquivo de armazenamento",
+ "refreshMenu": "Atualizar menu",
+ "reload": "Recarregar",
+ "title": "Desenvolvimento"
+ },
+ "edit": {
+ "copy": "Copiar",
+ "cut": "Cortar",
+ "paste": "Colar",
+ "redo": "Refazer",
+ "selectAll": "Selecionar Tudo",
+ "speech": "Fala",
+ "startSpeaking": "Começar a Ler",
+ "stopSpeaking": "Parar de Ler",
+ "title": "Edição",
+ "undo": "Desfazer"
+ },
+ "file": {
+ "preferences": "Preferências",
+ "quit": "Sair",
+ "title": "Arquivo"
+ },
+ "help": {
+ "about": "Sobre",
+ "githubRepo": "Repositório do GitHub",
+ "reportIssue": "Reportar Problema",
+ "title": "Ajuda",
+ "visitWebsite": "Visitar o Site"
+ },
+ "macOS": {
+ "about": "Sobre {{appName}}",
+ "devTools": "Ferramentas do Desenvolvedor LobeHub",
+ "hide": "Ocultar {{appName}}",
+ "hideOthers": "Ocultar Outros",
+ "preferences": "Configurações...",
+ "services": "Serviços",
+ "unhide": "Mostrar Todos"
+ },
+ "tray": {
+ "open": "Abrir {{appName}}",
+ "quit": "Sair",
+ "show": "Mostrar {{appName}}"
+ },
+ "view": {
+ "forceReload": "Recarregar Forçadamente",
+ "reload": "Recarregar",
+ "resetZoom": "Redefinir Zoom",
+ "title": "Visualização",
+ "toggleFullscreen": "Alternar Tela Cheia",
+ "zoomIn": "Aumentar",
+ "zoomOut": "Diminuir"
+ },
+ "window": {
+ "bringAllToFront": "Trazer Todas as Janelas para Frente",
+ "close": "Fechar",
+ "front": "Trazer Todas as Janelas para Frente",
+ "minimize": "Minimizar",
+ "title": "Janela",
+ "toggleFullscreen": "Alternar Tela Cheia",
+ "zoom": "Zoom"
+ }
+}
diff --git a/apps/desktop/resources/locales/ru-RU/common.json b/apps/desktop/resources/locales/ru-RU/common.json
new file mode 100644
index 0000000000..80a19c7759
--- /dev/null
+++ b/apps/desktop/resources/locales/ru-RU/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Добавить",
+ "back": "Назад",
+ "cancel": "Отмена",
+ "close": "Закрыть",
+ "confirm": "Подтвердить",
+ "delete": "Удалить",
+ "edit": "Редактировать",
+ "more": "Больше",
+ "next": "Далее",
+ "ok": "ОК",
+ "previous": "Назад",
+ "refresh": "Обновить",
+ "remove": "Удалить",
+ "retry": "Повторить",
+ "save": "Сохранить",
+ "search": "Поиск",
+ "submit": "Отправить"
+ },
+ "app": {
+ "description": "Ваша платформа для совместной работы с ИИ",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Ошибка",
+ "info": "Информация",
+ "loading": "Загрузка",
+ "success": "Успех",
+ "warning": "Предупреждение"
+ }
+}
diff --git a/apps/desktop/resources/locales/ru-RU/dialog.json b/apps/desktop/resources/locales/ru-RU/dialog.json
new file mode 100644
index 0000000000..2a1d67714b
--- /dev/null
+++ b/apps/desktop/resources/locales/ru-RU/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Подтвердить",
+ "detail": "Приложение для чата на основе большой языковой модели",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "О приложении"
+ },
+ "confirm": {
+ "cancel": "Отмена",
+ "no": "Нет",
+ "title": "Подтверждение",
+ "yes": "Да"
+ },
+ "error": {
+ "button": "Подтвердить",
+ "detail": "Произошла ошибка во время операции, пожалуйста, попробуйте позже",
+ "message": "Произошла ошибка",
+ "title": "Ошибка"
+ },
+ "update": {
+ "downloadAndInstall": "Скачать и установить",
+ "downloadComplete": "Скачивание завершено",
+ "downloadCompleteMessage": "Обновление загружено, хотите установить сейчас?",
+ "installLater": "Установить позже",
+ "installNow": "Установить сейчас",
+ "later": "Напомнить позже",
+ "newVersion": "Обнаружена новая версия",
+ "newVersionAvailable": "Обнаружена новая версия: {{version}}",
+ "skipThisVersion": "Пропустить эту версию"
+ }
+}
diff --git a/apps/desktop/resources/locales/ru-RU/menu.json b/apps/desktop/resources/locales/ru-RU/menu.json
new file mode 100644
index 0000000000..78a89c76bc
--- /dev/null
+++ b/apps/desktop/resources/locales/ru-RU/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Проверка обновлений..."
+ },
+ "dev": {
+ "devPanel": "Панель разработчика",
+ "devTools": "Инструменты разработчика",
+ "forceReload": "Принудительная перезагрузка",
+ "openStore": "Открыть файл хранилища",
+ "refreshMenu": "Обновить меню",
+ "reload": "Перезагрузить",
+ "title": "Разработка"
+ },
+ "edit": {
+ "copy": "Копировать",
+ "cut": "Вырезать",
+ "paste": "Вставить",
+ "redo": "Повторить",
+ "selectAll": "Выбрать все",
+ "speech": "Речь",
+ "startSpeaking": "Начать чтение",
+ "stopSpeaking": "Остановить чтение",
+ "title": "Редактирование",
+ "undo": "Отменить"
+ },
+ "file": {
+ "preferences": "Настройки",
+ "quit": "Выйти",
+ "title": "Файл"
+ },
+ "help": {
+ "about": "О программе",
+ "githubRepo": "Репозиторий GitHub",
+ "reportIssue": "Сообщить о проблеме",
+ "title": "Помощь",
+ "visitWebsite": "Посетить сайт"
+ },
+ "macOS": {
+ "about": "О {{appName}}",
+ "devTools": "Инструменты разработчика LobeHub",
+ "hide": "Скрыть {{appName}}",
+ "hideOthers": "Скрыть другие",
+ "preferences": "Настройки...",
+ "services": "Сервисы",
+ "unhide": "Показать все"
+ },
+ "tray": {
+ "open": "Открыть {{appName}}",
+ "quit": "Выйти",
+ "show": "Показать {{appName}}"
+ },
+ "view": {
+ "forceReload": "Принудительная перезагрузка",
+ "reload": "Перезагрузить",
+ "resetZoom": "Сбросить масштаб",
+ "title": "Вид",
+ "toggleFullscreen": "Переключить полноэкранный режим",
+ "zoomIn": "Увеличить",
+ "zoomOut": "Уменьшить"
+ },
+ "window": {
+ "bringAllToFront": "Вывести все окна на передний план",
+ "close": "Закрыть",
+ "front": "Вывести все окна на передний план",
+ "minimize": "Свернуть",
+ "title": "Окно",
+ "toggleFullscreen": "Переключить полноэкранный режим",
+ "zoom": "Масштаб"
+ }
+}
diff --git a/apps/desktop/resources/locales/tr-TR/common.json b/apps/desktop/resources/locales/tr-TR/common.json
new file mode 100644
index 0000000000..f8efc742bc
--- /dev/null
+++ b/apps/desktop/resources/locales/tr-TR/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Ekle",
+ "back": "Geri",
+ "cancel": "İptal",
+ "close": "Kapat",
+ "confirm": "Onayla",
+ "delete": "Sil",
+ "edit": "Düzenle",
+ "more": "Daha Fazla",
+ "next": "Sonraki",
+ "ok": "Tamam",
+ "previous": "Önceki",
+ "refresh": "Yenile",
+ "remove": "Kaldır",
+ "retry": "Yeniden Dene",
+ "save": "Kaydet",
+ "search": "Ara",
+ "submit": "Gönder"
+ },
+ "app": {
+ "description": "AI asistanınız için işbirliği platformu",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Hata",
+ "info": "Bilgi",
+ "loading": "Yükleniyor",
+ "success": "Başarılı",
+ "warning": "Uyarı"
+ }
+}
diff --git a/apps/desktop/resources/locales/tr-TR/dialog.json b/apps/desktop/resources/locales/tr-TR/dialog.json
new file mode 100644
index 0000000000..edb8926ce2
--- /dev/null
+++ b/apps/desktop/resources/locales/tr-TR/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Tamam",
+ "detail": "Büyük dil modeli tabanlı bir sohbet uygulaması",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "Hakkında"
+ },
+ "confirm": {
+ "cancel": "İptal",
+ "no": "Hayır",
+ "title": "Onay",
+ "yes": "Evet"
+ },
+ "error": {
+ "button": "Tamam",
+ "detail": "İşlem sırasında bir hata oluştu, lütfen daha sonra tekrar deneyin",
+ "message": "Hata oluştu",
+ "title": "Hata"
+ },
+ "update": {
+ "downloadAndInstall": "İndir ve Yükle",
+ "downloadComplete": "İndirme tamamlandı",
+ "downloadCompleteMessage": "Güncelleme paketi indirildi, hemen yüklemek ister misiniz?",
+ "installLater": "Sonra yükle",
+ "installNow": "Şimdi yükle",
+ "later": "Sonra hatırlat",
+ "newVersion": "Yeni sürüm bulundu",
+ "newVersionAvailable": "Yeni sürüm bulundu: {{version}}",
+ "skipThisVersion": "Bu sürümü atla"
+ }
+}
diff --git a/apps/desktop/resources/locales/tr-TR/menu.json b/apps/desktop/resources/locales/tr-TR/menu.json
new file mode 100644
index 0000000000..3360b38ce2
--- /dev/null
+++ b/apps/desktop/resources/locales/tr-TR/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Güncellemeleri kontrol et..."
+ },
+ "dev": {
+ "devPanel": "Geliştirici Paneli",
+ "devTools": "Geliştirici Araçları",
+ "forceReload": "Zorla Yenile",
+ "openStore": "Depolama dosyasını aç",
+ "refreshMenu": "Menüyü yenile",
+ "reload": "Yenile",
+ "title": "Geliştir"
+ },
+ "edit": {
+ "copy": "Kopyala",
+ "cut": "Kes",
+ "paste": "Yapıştır",
+ "redo": "Yinele",
+ "selectAll": "Tümünü Seç",
+ "speech": "Ses",
+ "startSpeaking": "Okumaya Başla",
+ "stopSpeaking": "Okumayı Durdur",
+ "title": "Düzenle",
+ "undo": "Geri Al"
+ },
+ "file": {
+ "preferences": "Tercihler",
+ "quit": "Çık",
+ "title": "Dosya"
+ },
+ "help": {
+ "about": "Hakkında",
+ "githubRepo": "GitHub Deposu",
+ "reportIssue": "Sorun Bildir",
+ "title": "Yardım",
+ "visitWebsite": "Resmi Web Sitesini Ziyaret Et"
+ },
+ "macOS": {
+ "about": "{{appName}} Hakkında",
+ "devTools": "LobeHub Geliştirici Araçları",
+ "hide": "{{appName}}'i Gizle",
+ "hideOthers": "Diğerlerini Gizle",
+ "preferences": "Tercihler...",
+ "services": "Hizmetler",
+ "unhide": "Hepsini Göster"
+ },
+ "tray": {
+ "open": "{{appName}}'i Aç",
+ "quit": "Çık",
+ "show": "{{appName}}'i Göster"
+ },
+ "view": {
+ "forceReload": "Zorla Yenile",
+ "reload": "Yenile",
+ "resetZoom": "Yakınlaştırmayı Sıfırla",
+ "title": "Görünüm",
+ "toggleFullscreen": "Tam Ekrana Geç",
+ "zoomIn": "Büyüt",
+ "zoomOut": "Küçült"
+ },
+ "window": {
+ "bringAllToFront": "Tüm Pencereleri Öne Getir",
+ "close": "Kapat",
+ "front": "Tüm Pencereleri Öne Getir",
+ "minimize": "Küçült",
+ "title": "Pencere",
+ "toggleFullscreen": "Tam Ekrana Geç",
+ "zoom": "Yakınlaştır"
+ }
+}
diff --git a/apps/desktop/resources/locales/vi-VN/common.json b/apps/desktop/resources/locales/vi-VN/common.json
new file mode 100644
index 0000000000..4434ec2309
--- /dev/null
+++ b/apps/desktop/resources/locales/vi-VN/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "Thêm",
+ "back": "Quay lại",
+ "cancel": "Hủy",
+ "close": "Đóng",
+ "confirm": "Xác nhận",
+ "delete": "Xóa",
+ "edit": "Chỉnh sửa",
+ "more": "Thêm nữa",
+ "next": "Tiếp theo",
+ "ok": "Đồng ý",
+ "previous": "Quay lại",
+ "refresh": "Tải lại",
+ "remove": "Gỡ bỏ",
+ "retry": "Thử lại",
+ "save": "Lưu",
+ "search": "Tìm kiếm",
+ "submit": "Gửi"
+ },
+ "app": {
+ "description": "Nền tảng hợp tác trợ lý AI của bạn",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "Lỗi",
+ "info": "Thông tin",
+ "loading": "Đang tải",
+ "success": "Thành công",
+ "warning": "Cảnh báo"
+ }
+}
diff --git a/apps/desktop/resources/locales/vi-VN/dialog.json b/apps/desktop/resources/locales/vi-VN/dialog.json
new file mode 100644
index 0000000000..5f028f98ed
--- /dev/null
+++ b/apps/desktop/resources/locales/vi-VN/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "Xác nhận",
+ "detail": "Một ứng dụng trò chuyện dựa trên mô hình ngôn ngữ lớn",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "Về"
+ },
+ "confirm": {
+ "cancel": "Hủy",
+ "no": "Không",
+ "title": "Xác nhận",
+ "yes": "Có"
+ },
+ "error": {
+ "button": "Xác nhận",
+ "detail": "Đã xảy ra lỗi trong quá trình thực hiện, vui lòng thử lại sau",
+ "message": "Đã xảy ra lỗi",
+ "title": "Lỗi"
+ },
+ "update": {
+ "downloadAndInstall": "Tải xuống và cài đặt",
+ "downloadComplete": "Tải xuống hoàn tất",
+ "downloadCompleteMessage": "Gói cập nhật đã tải xuống hoàn tất, có muốn cài đặt ngay không?",
+ "installLater": "Cài đặt sau",
+ "installNow": "Cài đặt ngay",
+ "later": "Nhắc nhở sau",
+ "newVersion": "Phát hiện phiên bản mới",
+ "newVersionAvailable": "Phát hiện phiên bản mới: {{version}}",
+ "skipThisVersion": "Bỏ qua phiên bản này"
+ }
+}
diff --git a/apps/desktop/resources/locales/vi-VN/menu.json b/apps/desktop/resources/locales/vi-VN/menu.json
new file mode 100644
index 0000000000..384b4879d4
--- /dev/null
+++ b/apps/desktop/resources/locales/vi-VN/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "Kiểm tra cập nhật..."
+ },
+ "dev": {
+ "devPanel": "Bảng điều khiển nhà phát triển",
+ "devTools": "Công cụ phát triển",
+ "forceReload": "Tải lại cưỡng bức",
+ "openStore": "Mở tệp lưu trữ",
+ "refreshMenu": "Làm mới menu",
+ "reload": "Tải lại",
+ "title": "Phát triển"
+ },
+ "edit": {
+ "copy": "Sao chép",
+ "cut": "Cắt",
+ "paste": "Dán",
+ "redo": "Làm lại",
+ "selectAll": "Chọn tất cả",
+ "speech": "Giọng nói",
+ "startSpeaking": "Bắt đầu đọc",
+ "stopSpeaking": "Dừng đọc",
+ "title": "Chỉnh sửa",
+ "undo": "Hoàn tác"
+ },
+ "file": {
+ "preferences": "Tùy chọn",
+ "quit": "Thoát",
+ "title": "Tập tin"
+ },
+ "help": {
+ "about": "Về",
+ "githubRepo": "Kho lưu trữ GitHub",
+ "reportIssue": "Báo cáo sự cố",
+ "title": "Trợ giúp",
+ "visitWebsite": "Truy cập trang web"
+ },
+ "macOS": {
+ "about": "Về {{appName}}",
+ "devTools": "Công cụ phát triển LobeHub",
+ "hide": "Ẩn {{appName}}",
+ "hideOthers": "Ẩn khác",
+ "preferences": "Cài đặt ưu tiên...",
+ "services": "Dịch vụ",
+ "unhide": "Hiện tất cả"
+ },
+ "tray": {
+ "open": "Mở {{appName}}",
+ "quit": "Thoát",
+ "show": "Hiện {{appName}}"
+ },
+ "view": {
+ "forceReload": "Tải lại cưỡng bức",
+ "reload": "Tải lại",
+ "resetZoom": "Đặt lại thu phóng",
+ "title": "Xem",
+ "toggleFullscreen": "Chuyển đổi toàn màn hình",
+ "zoomIn": "Phóng to",
+ "zoomOut": "Thu nhỏ"
+ },
+ "window": {
+ "bringAllToFront": "Đưa tất cả cửa sổ lên trước",
+ "close": "Đóng",
+ "front": "Đưa tất cả cửa sổ lên trước",
+ "minimize": "Thu nhỏ",
+ "title": "Cửa sổ",
+ "toggleFullscreen": "Chuyển đổi toàn màn hình",
+ "zoom": "Thu phóng"
+ }
+}
diff --git a/apps/desktop/resources/locales/zh-CN/common.json b/apps/desktop/resources/locales/zh-CN/common.json
new file mode 100644
index 0000000000..ba164ca0d4
--- /dev/null
+++ b/apps/desktop/resources/locales/zh-CN/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "添加",
+ "back": "返回",
+ "cancel": "取消",
+ "close": "关闭",
+ "confirm": "确认",
+ "delete": "删除",
+ "edit": "编辑",
+ "more": "更多",
+ "next": "下一步",
+ "ok": "确定",
+ "previous": "上一步",
+ "refresh": "刷新",
+ "remove": "移除",
+ "retry": "重试",
+ "save": "保存",
+ "search": "搜索",
+ "submit": "提交"
+ },
+ "app": {
+ "description": "你的 AI 助手协作平台",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "错误",
+ "info": "信息",
+ "loading": "加载中",
+ "success": "成功",
+ "warning": "警告"
+ }
+}
diff --git a/apps/desktop/resources/locales/zh-CN/dialog.json b/apps/desktop/resources/locales/zh-CN/dialog.json
new file mode 100644
index 0000000000..629b91b433
--- /dev/null
+++ b/apps/desktop/resources/locales/zh-CN/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "确定",
+ "detail": "一个基于大语言模型的聊天应用",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "关于"
+ },
+ "confirm": {
+ "cancel": "取消",
+ "no": "否",
+ "title": "确认",
+ "yes": "是"
+ },
+ "error": {
+ "button": "确定",
+ "detail": "操作过程中发生错误,请稍后重试",
+ "message": "发生错误",
+ "title": "错误"
+ },
+ "update": {
+ "downloadAndInstall": "下载并安装",
+ "downloadComplete": "下载完成",
+ "downloadCompleteMessage": "更新包已下载完成,是否立即安装?",
+ "installLater": "稍后安装",
+ "installNow": "立即安装",
+ "later": "稍后提醒",
+ "newVersion": "发现新版本",
+ "newVersionAvailable": "发现新版本: {{version}}",
+ "skipThisVersion": "跳过此版本"
+ }
+}
diff --git a/apps/desktop/resources/locales/zh-CN/menu.json b/apps/desktop/resources/locales/zh-CN/menu.json
new file mode 100644
index 0000000000..6b4660552e
--- /dev/null
+++ b/apps/desktop/resources/locales/zh-CN/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "检查更新..."
+ },
+ "dev": {
+ "devPanel": "开发者面板",
+ "devTools": "开发者工具",
+ "forceReload": "强制重新加载",
+ "openStore": "打开存储文件",
+ "refreshMenu": "刷新菜单",
+ "reload": "重新加载",
+ "title": "开发"
+ },
+ "edit": {
+ "copy": "复制",
+ "cut": "剪切",
+ "paste": "粘贴",
+ "redo": "重做",
+ "selectAll": "全选",
+ "speech": "语音",
+ "startSpeaking": "开始朗读",
+ "stopSpeaking": "停止朗读",
+ "title": "编辑",
+ "undo": "撤销"
+ },
+ "file": {
+ "preferences": "首选项",
+ "quit": "退出",
+ "title": "文件"
+ },
+ "help": {
+ "about": "关于",
+ "githubRepo": "GitHub 仓库",
+ "reportIssue": "报告问题",
+ "title": "帮助",
+ "visitWebsite": "访问官网"
+ },
+ "macOS": {
+ "about": "关于 {{appName}}",
+ "devTools": "LobeHub 开发者工具",
+ "hide": "隐藏 {{appName}}",
+ "hideOthers": "隐藏其他",
+ "preferences": "偏好设置...",
+ "services": "服务",
+ "unhide": "全部显示"
+ },
+ "tray": {
+ "open": "打开 {{appName}}",
+ "quit": "退出",
+ "show": "显示 {{appName}}"
+ },
+ "view": {
+ "forceReload": "强制重新加载",
+ "reload": "重新加载",
+ "resetZoom": "重置缩放",
+ "title": "视图",
+ "toggleFullscreen": "切换全屏",
+ "zoomIn": "放大",
+ "zoomOut": "缩小"
+ },
+ "window": {
+ "bringAllToFront": "前置所有窗口",
+ "close": "关闭",
+ "front": "前置所有窗口",
+ "minimize": "最小化",
+ "title": "窗口",
+ "toggleFullscreen": "切换全屏",
+ "zoom": "缩放"
+ }
+}
diff --git a/apps/desktop/resources/locales/zh-TW/common.json b/apps/desktop/resources/locales/zh-TW/common.json
new file mode 100644
index 0000000000..05ec4b3418
--- /dev/null
+++ b/apps/desktop/resources/locales/zh-TW/common.json
@@ -0,0 +1,32 @@
+{
+ "actions": {
+ "add": "新增",
+ "back": "返回",
+ "cancel": "取消",
+ "close": "關閉",
+ "confirm": "確認",
+ "delete": "刪除",
+ "edit": "編輯",
+ "more": "更多",
+ "next": "下一步",
+ "ok": "確定",
+ "previous": "上一步",
+ "refresh": "刷新",
+ "remove": "移除",
+ "retry": "重試",
+ "save": "儲存",
+ "search": "搜尋",
+ "submit": "提交"
+ },
+ "app": {
+ "description": "你的 AI 助手協作平台",
+ "name": "LobeHub"
+ },
+ "status": {
+ "error": "錯誤",
+ "info": "資訊",
+ "loading": "載入中",
+ "success": "成功",
+ "warning": "警告"
+ }
+}
diff --git a/apps/desktop/resources/locales/zh-TW/dialog.json b/apps/desktop/resources/locales/zh-TW/dialog.json
new file mode 100644
index 0000000000..796be99e15
--- /dev/null
+++ b/apps/desktop/resources/locales/zh-TW/dialog.json
@@ -0,0 +1,31 @@
+{
+ "about": {
+ "button": "確定",
+ "detail": "一個基於大語言模型的聊天應用",
+ "message": "{{appName}} {{appVersion}}",
+ "title": "關於"
+ },
+ "confirm": {
+ "cancel": "取消",
+ "no": "否",
+ "title": "確認",
+ "yes": "是"
+ },
+ "error": {
+ "button": "確定",
+ "detail": "操作過程中發生錯誤,請稍後重試",
+ "message": "發生錯誤",
+ "title": "錯誤"
+ },
+ "update": {
+ "downloadAndInstall": "下載並安裝",
+ "downloadComplete": "下載完成",
+ "downloadCompleteMessage": "更新包已下載完成,是否立即安裝?",
+ "installLater": "稍後安裝",
+ "installNow": "立即安裝",
+ "later": "稍後提醒",
+ "newVersion": "發現新版本",
+ "newVersionAvailable": "發現新版本: {{version}}",
+ "skipThisVersion": "跳過此版本"
+ }
+}
diff --git a/apps/desktop/resources/locales/zh-TW/menu.json b/apps/desktop/resources/locales/zh-TW/menu.json
new file mode 100644
index 0000000000..c10b9954da
--- /dev/null
+++ b/apps/desktop/resources/locales/zh-TW/menu.json
@@ -0,0 +1,70 @@
+{
+ "common": {
+ "checkUpdates": "檢查更新..."
+ },
+ "dev": {
+ "devPanel": "開發者面板",
+ "devTools": "開發者工具",
+ "forceReload": "強制重新載入",
+ "openStore": "打開儲存檔案",
+ "refreshMenu": "刷新選單",
+ "reload": "重新載入",
+ "title": "開發"
+ },
+ "edit": {
+ "copy": "複製",
+ "cut": "剪下",
+ "paste": "貼上",
+ "redo": "重做",
+ "selectAll": "全選",
+ "speech": "語音",
+ "startSpeaking": "開始朗讀",
+ "stopSpeaking": "停止朗讀",
+ "title": "編輯",
+ "undo": "撤銷"
+ },
+ "file": {
+ "preferences": "偏好設定",
+ "quit": "退出",
+ "title": "檔案"
+ },
+ "help": {
+ "about": "關於",
+ "githubRepo": "GitHub 倉庫",
+ "reportIssue": "報告問題",
+ "title": "幫助",
+ "visitWebsite": "訪問網站"
+ },
+ "macOS": {
+ "about": "關於 {{appName}}",
+ "devTools": "LobeHub 開發者工具",
+ "hide": "隱藏 {{appName}}",
+ "hideOthers": "隱藏其他",
+ "preferences": "偏好設定...",
+ "services": "服務",
+ "unhide": "全部顯示"
+ },
+ "tray": {
+ "open": "打開 {{appName}}",
+ "quit": "退出",
+ "show": "顯示 {{appName}}"
+ },
+ "view": {
+ "forceReload": "強制重新載入",
+ "reload": "重新載入",
+ "resetZoom": "重置縮放",
+ "title": "視圖",
+ "toggleFullscreen": "切換全螢幕",
+ "zoomIn": "放大",
+ "zoomOut": "縮小"
+ },
+ "window": {
+ "bringAllToFront": "前置所有視窗",
+ "close": "關閉",
+ "front": "前置所有視窗",
+ "minimize": "最小化",
+ "title": "視窗",
+ "toggleFullscreen": "切換全螢幕",
+ "zoom": "縮放"
+ }
+}
diff --git a/apps/desktop/resources/splash.html b/apps/desktop/resources/splash.html
new file mode 100644
index 0000000000..cb29472c32
--- /dev/null
+++ b/apps/desktop/resources/splash.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+ LobeHub
+
+
+
+
+
+
diff --git a/apps/desktop/scripts/i18nWorkflow/const.ts b/apps/desktop/scripts/i18nWorkflow/const.ts
new file mode 100644
index 0000000000..02591d05ad
--- /dev/null
+++ b/apps/desktop/scripts/i18nWorkflow/const.ts
@@ -0,0 +1,18 @@
+import { readdirSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import i18nConfig from '../../.i18nrc';
+
+export const root = resolve(__dirname, '../..');
+export const localesDir = resolve(root, i18nConfig.output);
+export const localeDir = (locale: string) => resolve(localesDir, locale);
+export const localeDirJsonList = (locale: string) =>
+ readdirSync(localeDir(locale)).filter((name) => name.includes('.json'));
+export const srcLocalesDir = resolve(root, './src/main/locales');
+export const entryLocaleJsonFilepath = (file: string) =>
+ resolve(localesDir, i18nConfig.entryLocale, file);
+export const outputLocaleJsonFilepath = (locale: string, file: string) =>
+ resolve(localesDir, locale, file);
+export const srcDefaultLocales = resolve(root, srcLocalesDir, 'default');
+
+export { default as i18nConfig } from '../../.i18nrc';
diff --git a/apps/desktop/scripts/i18nWorkflow/genDefaultLocale.ts b/apps/desktop/scripts/i18nWorkflow/genDefaultLocale.ts
new file mode 100644
index 0000000000..2fecdf1613
--- /dev/null
+++ b/apps/desktop/scripts/i18nWorkflow/genDefaultLocale.ts
@@ -0,0 +1,35 @@
+import { consola } from 'consola';
+import { colors } from 'consola/utils';
+import { existsSync, mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+import { entryLocaleJsonFilepath, i18nConfig, localeDir, srcDefaultLocales } from './const';
+import { tagWhite, writeJSON } from './utils';
+
+export const genDefaultLocale = () => {
+ consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
+
+ // 确保入口语言目录存在
+ const entryLocaleDir = localeDir(i18nConfig.entryLocale);
+ if (!existsSync(entryLocaleDir)) {
+ mkdirSync(entryLocaleDir, { recursive: true });
+ consola.info(`创建目录:${entryLocaleDir}`);
+ }
+
+ const resources = require(srcDefaultLocales);
+ const data = Object.entries(resources.default);
+ consola.start(`生成默认语言 JSON 文件,发现 ${data.length} 个命名空间...`);
+
+ for (const [ns, value] of data) {
+ const filepath = entryLocaleJsonFilepath(`${ns}.json`);
+
+ // 确保目录存在
+ const dir = dirname(filepath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+
+ writeJSON(filepath, value);
+ consola.success(tagWhite(ns), colors.gray(filepath));
+ }
+};
diff --git a/apps/desktop/scripts/i18nWorkflow/genDiff.ts b/apps/desktop/scripts/i18nWorkflow/genDiff.ts
new file mode 100644
index 0000000000..460ad8e687
--- /dev/null
+++ b/apps/desktop/scripts/i18nWorkflow/genDiff.ts
@@ -0,0 +1,57 @@
+import { consola } from 'consola';
+import { colors } from 'consola/utils';
+import { diff } from 'just-diff';
+import { unset } from 'lodash';
+import { existsSync } from 'node:fs';
+
+import {
+ entryLocaleJsonFilepath,
+ i18nConfig,
+ outputLocaleJsonFilepath,
+ srcDefaultLocales,
+} from './const';
+import { readJSON, tagWhite, writeJSON } from './utils';
+
+export const genDiff = () => {
+ consola.start(`对比开发与生产环境中的本地化文件...`);
+
+ const resources = require(srcDefaultLocales);
+ const data = Object.entries(resources.default);
+
+ for (const [ns, devJSON] of data) {
+ const filepath = entryLocaleJsonFilepath(`${ns}.json`);
+ if (!existsSync(filepath)) {
+ consola.info(`文件不存在,跳过:${filepath}`);
+ continue;
+ }
+
+ const prodJSON = readJSON(filepath);
+
+ const diffResult = diff(prodJSON, devJSON as any);
+ const remove = diffResult.filter((item) => item.op === 'remove');
+ if (remove.length === 0) {
+ consola.success(tagWhite(ns), colors.gray(filepath));
+ continue;
+ }
+
+ const clearLocals = [];
+
+ for (const locale of [i18nConfig.entryLocale, ...i18nConfig.outputLocales]) {
+ const localeFilepath = outputLocaleJsonFilepath(locale, `${ns}.json`);
+ if (!existsSync(localeFilepath)) continue;
+ const localeJSON = readJSON(localeFilepath);
+
+ for (const item of remove) {
+ unset(localeJSON, item.path);
+ }
+
+ writeJSON(localeFilepath, localeJSON);
+ clearLocals.push(locale);
+ }
+
+ if (clearLocals.length > 0) {
+ consola.info('清理了以下语言的过期项目:', clearLocals.join(', '));
+ }
+ consola.success(tagWhite(ns), colors.gray(filepath));
+ }
+};
diff --git a/apps/desktop/scripts/i18nWorkflow/index.ts b/apps/desktop/scripts/i18nWorkflow/index.ts
new file mode 100644
index 0000000000..5215bb00c7
--- /dev/null
+++ b/apps/desktop/scripts/i18nWorkflow/index.ts
@@ -0,0 +1,35 @@
+import { existsSync, mkdirSync } from 'node:fs';
+
+import { i18nConfig, localeDir } from './const';
+import { genDefaultLocale } from './genDefaultLocale';
+import { genDiff } from './genDiff';
+import { split } from './utils';
+
+// 确保所有语言目录存在
+const ensureLocalesDirs = () => {
+ [i18nConfig.entryLocale, ...i18nConfig.outputLocales].forEach((locale) => {
+ const dir = localeDir(locale);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+ });
+};
+
+// 运行工作流
+const run = async () => {
+ // 确保目录存在
+ ensureLocalesDirs();
+
+ // 差异分析
+ split('差异分析');
+ genDiff();
+
+ // 生成默认语言文件
+ split('生成默认语言文件');
+ genDefaultLocale();
+
+ // 生成国际化文件
+ split('生成国际化文件');
+};
+
+run();
diff --git a/apps/desktop/scripts/i18nWorkflow/utils.ts b/apps/desktop/scripts/i18nWorkflow/utils.ts
new file mode 100644
index 0000000000..bf5571817d
--- /dev/null
+++ b/apps/desktop/scripts/i18nWorkflow/utils.ts
@@ -0,0 +1,54 @@
+import { consola } from 'consola';
+import { colors } from 'consola/utils';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import i18nConfig from '../../.i18nrc';
+
+export const readJSON = (filePath: string) => {
+ const data = readFileSync(filePath, 'utf8');
+ return JSON.parse(data);
+};
+
+export const writeJSON = (filePath: string, data: any) => {
+ const jsonStr = JSON.stringify(data, null, 2);
+ writeFileSync(filePath, jsonStr, 'utf8');
+};
+
+export const genResourcesContent = (locales: string[]) => {
+ let index = '';
+ let indexObj = '';
+
+ for (const locale of locales) {
+ index += `import ${locale} from "./${locale}";\n`;
+ indexObj += ` "${locale.replace('_', '-')}": ${locale},\n`;
+ }
+
+ return `${index}
+const resources = {
+${indexObj}} as const;
+export default resources;
+export const defaultResources = ${i18nConfig.entryLocale};
+export type Resources = typeof resources;
+export type DefaultResources = typeof defaultResources;
+export type Namespaces = keyof DefaultResources;
+export type Locales = keyof Resources;
+`;
+};
+
+export const genNamespaceList = (files: string[], locale: string) => {
+ return files.map((file) => ({
+ name: file.replace('.json', ''),
+ path: resolve(i18nConfig.output, locale, file),
+ }));
+};
+
+export const tagBlue = (text: string) => colors.bgBlueBright(colors.black(` ${text} `));
+export const tagYellow = (text: string) => colors.bgYellowBright(colors.black(` ${text} `));
+export const tagGreen = (text: string) => colors.bgGreenBright(colors.black(` ${text} `));
+export const tagWhite = (text: string) => colors.bgWhiteBright(colors.black(` ${text} `));
+
+export const split = (name: string) => {
+ consola.log('');
+ consola.log(colors.gray(`========================== ${name} ==============================`));
+};
diff --git a/apps/desktop/scripts/pglite-server.ts b/apps/desktop/scripts/pglite-server.ts
new file mode 100644
index 0000000000..c3735b8938
--- /dev/null
+++ b/apps/desktop/scripts/pglite-server.ts
@@ -0,0 +1,14 @@
+import { PGlite } from "@electric-sql/pglite";
+import { createServer } from "pglite-server";
+
+// 创建或连接到您现有的 PGlite 数据库
+const db = new PGlite("/Users/arvinxx/Library/Application Support/lobehub-desktop/lobehub-local-db");
+await db.waitReady;
+
+// 创建服务器并监听端口
+const PORT = 6543;
+const pgServer = createServer(db);
+
+pgServer.listen(PORT, () => {
+ console.log(`PGlite 服务器已启动,监听端口 ${PORT}`);
+});
diff --git a/apps/desktop/src/common/routes.ts b/apps/desktop/src/common/routes.ts
new file mode 100644
index 0000000000..cb2d96353f
--- /dev/null
+++ b/apps/desktop/src/common/routes.ts
@@ -0,0 +1,78 @@
+/**
+ * 路由拦截类型,描述拦截路由和目标窗口的映射关系
+ */
+export interface RouteInterceptConfig {
+ /**
+ * 是否始终在新窗口中打开,即使目标窗口已经存在
+ */
+ alwaysOpenNew?: boolean;
+
+ /**
+ * 描述
+ */
+ description: string;
+
+ /**
+ * 是否启用拦截
+ */
+ enabled: boolean;
+
+ /**
+ * 路由模式前缀,例如 '/settings'
+ */
+ pathPrefix: string;
+
+ /**
+ * 目标窗口标识符
+ */
+ targetWindow: string;
+}
+
+/**
+ * 拦截路由配置列表
+ * 定义了所有需要特殊处理的路由
+ */
+export const interceptRoutes: RouteInterceptConfig[] = [
+ {
+ description: '设置页面',
+ enabled: true,
+ pathPrefix: '/settings',
+ targetWindow: 'settings',
+ },
+ {
+ description: '开发者工具',
+ enabled: true,
+ pathPrefix: '/desktop/devtools',
+ targetWindow: 'devtools',
+ },
+ // 未来可能的其他路由
+ // {
+ // description: '帮助中心',
+ // enabled: true,
+ // pathPrefix: '/help',
+ // targetWindow: 'help',
+ // },
+];
+
+/**
+ * 通过路径查找匹配的路由拦截配置
+ * @param path 需要检查的路径
+ * @returns 匹配的拦截配置,如果没有匹配则返回 undefined
+ */
+export const findMatchingRoute = (path: string): RouteInterceptConfig | undefined => {
+ return interceptRoutes.find((route) => route.enabled && path.startsWith(route.pathPrefix));
+};
+
+/**
+ * 从完整路径中提取子路径
+ * @param fullPath 完整路径,如 '/settings/agent'
+ * @param pathPrefix 路径前缀,如 '/settings'
+ * @returns 子路径,如 'agent'
+ */
+export const extractSubPath = (fullPath: string, pathPrefix: string): string | undefined => {
+ if (fullPath.length <= pathPrefix.length) return undefined;
+
+ // 去除前导斜杠
+ const subPath = fullPath.slice(Math.max(0, pathPrefix.length + 1));
+ return subPath || undefined;
+};
diff --git a/apps/desktop/src/main/appBrowsers.ts b/apps/desktop/src/main/appBrowsers.ts
new file mode 100644
index 0000000000..39c4be73f4
--- /dev/null
+++ b/apps/desktop/src/main/appBrowsers.ts
@@ -0,0 +1,47 @@
+import type { BrowserWindowOpts } from './core/Browser';
+
+export const BrowsersIdentifiers = {
+ chat: 'chat',
+ devtools: 'devtools',
+ settings: 'settings',
+};
+
+export const appBrowsers = {
+ chat: {
+ autoHideMenuBar: true,
+ height: 800,
+ identifier: 'chat',
+ keepAlive: true,
+ minWidth: 400,
+ path: '/chat',
+ showOnInit: true,
+ titleBarStyle: 'hidden',
+ vibrancy: 'under-window',
+ width: 1200,
+ },
+ devtools: {
+ autoHideMenuBar: true,
+ fullscreenable: false,
+ height: 600,
+ identifier: 'devtools',
+ maximizable: false,
+ minWidth: 400,
+ path: '/desktop/devtools',
+ titleBarStyle: 'hiddenInset',
+ vibrancy: 'under-window',
+ width: 1000,
+ },
+ settings: {
+ autoHideMenuBar: true,
+ height: 800,
+ identifier: 'settings',
+ keepAlive: true,
+ minWidth: 600,
+ path: '/settings',
+ titleBarStyle: 'hidden',
+ vibrancy: 'under-window',
+ width: 1000,
+ },
+} satisfies Record;
+
+export type AppBrowsersIdentifiers = keyof typeof appBrowsers;
diff --git a/apps/desktop/src/main/const/dir.ts b/apps/desktop/src/main/const/dir.ts
new file mode 100644
index 0000000000..00f3fb05d3
--- /dev/null
+++ b/apps/desktop/src/main/const/dir.ts
@@ -0,0 +1,29 @@
+import { app } from 'electron';
+import { join } from 'node:path';
+
+export const mainDir = join(__dirname);
+
+export const preloadDir = join(mainDir, '../preload');
+
+export const resourcesDir = join(mainDir, '../../resources');
+
+export const buildDir = join(mainDir, '../../build');
+
+const appPath = app.getAppPath();
+
+export const nextStandaloneDir = join(appPath, 'dist', 'next');
+
+export const userDataDir = app.getPath('userData');
+
+export const appStorageDir = join(userDataDir, 'lobehub-storage');
+
+// ------ Application storage directory ---- //
+
+// db schema hash
+export const DB_SCHEMA_HASH_FILENAME = 'lobehub-local-db-schema-hash';
+// pglite database dir
+export const LOCAL_DATABASE_DIR = 'lobehub-local-db';
+// 本地存储文件(模拟 S3)
+export const FILE_STORAGE_DIR = 'file-storage';
+// Plugin 安装目录
+export const INSTALL_PLUGINS_DIR = 'plugins';
diff --git a/apps/desktop/src/main/const/env.ts b/apps/desktop/src/main/const/env.ts
new file mode 100644
index 0000000000..e5b200659a
--- /dev/null
+++ b/apps/desktop/src/main/const/env.ts
@@ -0,0 +1,3 @@
+export const isDev = process.env.NODE_ENV === 'development';
+
+export const OFFICIAL_CLOUD_SERVER = process.env.OFFICIAL_CLOUD_SERVER || 'https://lobechat.com';
diff --git a/apps/desktop/src/main/const/store.ts b/apps/desktop/src/main/const/store.ts
new file mode 100644
index 0000000000..27103c63d0
--- /dev/null
+++ b/apps/desktop/src/main/const/store.ts
@@ -0,0 +1,22 @@
+/**
+ * 应用设置存储相关常量
+ */
+import { appStorageDir } from '@/const/dir';
+import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
+import { ElectronMainStore } from '@/types/store';
+
+/**
+ * 存储名称
+ */
+export const STORE_NAME = 'lobehub-settings';
+
+/**
+ * 存储默认值
+ */
+export const STORE_DEFAULTS: ElectronMainStore = {
+ dataSyncConfig: { storageMode: 'local' },
+ encryptedTokens: {},
+ locale: 'auto',
+ shortcuts: DEFAULT_SHORTCUTS_CONFIG,
+ storagePath: appStorageDir,
+};
diff --git a/apps/desktop/src/main/controllers/AuthCtr.ts b/apps/desktop/src/main/controllers/AuthCtr.ts
new file mode 100644
index 0000000000..56e55ad879
--- /dev/null
+++ b/apps/desktop/src/main/controllers/AuthCtr.ts
@@ -0,0 +1,390 @@
+import { DataSyncConfig } from '@lobechat/electron-client-ipc';
+import { BrowserWindow, app, shell } from 'electron';
+import crypto from 'node:crypto';
+import querystring from 'node:querystring';
+import { URL } from 'node:url';
+
+import { name } from '@/../../package.json';
+import { createLogger } from '@/utils/logger';
+
+import RemoteServerConfigCtr from './RemoteServerConfigCtr';
+import { ControllerModule, ipcClientEvent } from './index';
+
+// Create logger
+const logger = createLogger('controllers:AuthCtr');
+
+const protocolPrefix = `com.lobehub.${name}`;
+/**
+ * Authentication Controller
+ * Used to implement the OAuth authorization flow
+ */
+export default class AuthCtr extends ControllerModule {
+ /**
+ * 远程服务器配置控制器
+ */
+ private get remoteServerConfigCtr() {
+ return this.app.getController(RemoteServerConfigCtr);
+ }
+
+ /**
+ * 当前的 PKCE 参数
+ */
+ private codeVerifier: string | null = null;
+ private authRequestState: string | null = null;
+
+ beforeAppReady = () => {
+ this.registerProtocolHandler();
+ };
+
+ /**
+ * Request OAuth authorization
+ */
+ @ipcClientEvent('requestAuthorization')
+ async requestAuthorization(config: DataSyncConfig) {
+ const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config);
+
+ logger.info(
+ `Requesting OAuth authorization, storageMode:${config.storageMode} server URL: ${remoteUrl}`,
+ );
+ try {
+ // Generate PKCE parameters
+ logger.debug('Generating PKCE parameters');
+ const codeVerifier = this.generateCodeVerifier();
+ const codeChallenge = await this.generateCodeChallenge(codeVerifier);
+ this.codeVerifier = codeVerifier;
+
+ // Generate state parameter to prevent CSRF attacks
+ this.authRequestState = crypto.randomBytes(16).toString('hex');
+ logger.debug(`Generated state parameter: ${this.authRequestState}`);
+
+ // Construct authorization URL
+ const authUrl = new URL('/oidc/auth', remoteUrl);
+
+ // Add query parameters
+ authUrl.search = querystring.stringify({
+ client_id: 'lobehub-desktop',
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
+ prompt: 'consent',
+ redirect_uri: `${protocolPrefix}://auth/callback`,
+ response_type: 'code',
+ scope: 'profile email offline_access',
+ state: this.authRequestState,
+ });
+
+ logger.info(`Constructed authorization URL: ${authUrl.toString()}`);
+
+ // Open authorization URL in the default browser
+ await shell.openExternal(authUrl.toString());
+ logger.debug('Opening authorization URL in default browser');
+
+ return { success: true };
+ } catch (error) {
+ logger.error('Authorization request failed:', error);
+ return { error: error.message, success: false };
+ }
+ }
+
+ /**
+ * Handle authorization callback
+ * This method is called when the browser redirects to our custom protocol
+ */
+ async handleAuthCallback(callbackUrl: string) {
+ logger.info(`Handling authorization callback: ${callbackUrl}`);
+ try {
+ const url = new URL(callbackUrl);
+ const params = new URLSearchParams(url.search);
+
+ // Get authorization code
+ const code = params.get('code');
+ const state = params.get('state');
+ logger.debug(`Got parameters from callback URL: code=${code}, state=${state}`);
+
+ // Validate state parameter to prevent CSRF attacks
+ if (state !== this.authRequestState) {
+ logger.error(
+ `Invalid state parameter: expected ${this.authRequestState}, received ${state}`,
+ );
+ throw new Error('Invalid state parameter');
+ }
+ logger.debug('State parameter validation passed');
+
+ if (!code) {
+ logger.error('No authorization code received');
+ throw new Error('No authorization code received');
+ }
+
+ // Get configuration information
+ const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
+ logger.debug(`Getting remote server configuration: url=${config.remoteServerUrl}`);
+
+ if (!config.remoteServerUrl) {
+ logger.error('Server URL not configured');
+ throw new Error('No server URL configured');
+ }
+
+ // Get the previously saved code_verifier
+ const codeVerifier = this.codeVerifier;
+ if (!codeVerifier) {
+ logger.error('Code verifier not found');
+ throw new Error('No code verifier found');
+ }
+ logger.debug('Found code verifier');
+
+ // Exchange authorization code for token
+ logger.debug('Starting to exchange authorization code for token');
+ const result = await this.exchangeCodeForToken(code, codeVerifier);
+
+ if (result.success) {
+ logger.info('Authorization successful');
+ // Notify render process of successful authorization
+ this.broadcastAuthorizationSuccessful();
+ } else {
+ logger.warn(`Authorization failed: ${result.error || 'Unknown error'}`);
+ // Notify render process of failed authorization
+ this.broadcastAuthorizationFailed(result.error || 'Unknown error');
+ }
+
+ return result;
+ } catch (error) {
+ logger.error('Handling authorization callback failed:', error);
+
+ // Notify render process of failed authorization
+ this.broadcastAuthorizationFailed(error.message);
+
+ return { error: error.message, success: false };
+ } finally {
+ // Clear authorization request state
+ logger.debug('Clearing authorization request state');
+ this.authRequestState = null;
+ this.codeVerifier = null;
+ }
+ }
+
+ /**
+ * Refresh access token
+ */
+ @ipcClientEvent('refreshAccessToken')
+ async refreshAccessToken() {
+ logger.info('Starting to refresh access token');
+ try {
+ // Call the centralized refresh logic in RemoteServerConfigCtr
+ const result = await this.remoteServerConfigCtr.refreshAccessToken();
+
+ if (result.success) {
+ logger.info('Token refresh successful via AuthCtr call.');
+ // Notify render process that token has been refreshed
+ this.broadcastTokenRefreshed();
+ return { success: true };
+ } else {
+ // Throw an error to be caught by the catch block below
+ // This maintains the existing behavior of clearing tokens on failure
+ logger.error(`Token refresh failed via AuthCtr call: ${result.error}`);
+ throw new Error(result.error || 'Token refresh failed');
+ }
+ } catch (error) {
+ // Keep the existing logic to clear tokens and require re-auth on failure
+ logger.error('Token refresh operation failed via AuthCtr, initiating cleanup:', error);
+
+ // Refresh failed, clear tokens and disable remote server
+ logger.warn('Refresh failed, clearing tokens and disabling remote server');
+ await this.remoteServerConfigCtr.clearTokens();
+ await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
+
+ // Notify render process that re-authorization is required
+ this.broadcastAuthorizationRequired();
+
+ return { error: error.message, success: false };
+ }
+ }
+
+ /**
+ * Register custom protocol handler
+ */
+ private registerProtocolHandler() {
+ logger.info(`Registering custom protocol handler ${protocolPrefix}://`);
+ app.setAsDefaultProtocolClient(protocolPrefix);
+
+ // Register custom protocol handler
+ if (process.platform === 'darwin') {
+ // Handle open-url event on macOS
+ logger.debug('Registering open-url event handler for macOS');
+ app.on('open-url', (event, url) => {
+ event.preventDefault();
+ logger.info(`Received open-url event: ${url}`);
+ this.handleAuthCallback(url);
+ });
+ } else {
+ // Handle protocol callback via second-instance event on Windows and Linux
+ logger.debug('Registering second-instance event handler for Windows/Linux');
+ app.on('second-instance', (event, commandLine) => {
+ // Find the URL from command line arguments
+ const url = commandLine.find((arg) => arg.startsWith(`${protocolPrefix}://`));
+ if (url) {
+ logger.info(`Found URL from second-instance command line arguments: ${url}`);
+ this.handleAuthCallback(url);
+ } else {
+ logger.warn('Protocol URL not found in second-instance command line arguments');
+ }
+ });
+ }
+
+ logger.info(`Registered ${protocolPrefix}:// custom protocol handler`);
+ }
+
+ /**
+ * Exchange authorization code for token
+ */
+ private async exchangeCodeForToken(code: string, codeVerifier: string) {
+ const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
+ logger.info('Starting to exchange authorization code for token');
+ try {
+ const tokenUrl = new URL('/oidc/token', remoteUrl);
+ logger.debug(`Constructed token exchange URL: ${tokenUrl.toString()}`);
+
+ // Construct request body
+ const body = querystring.stringify({
+ client_id: 'lobehub-desktop',
+ code,
+ code_verifier: codeVerifier,
+ grant_type: 'authorization_code',
+ redirect_uri: `${protocolPrefix}://auth/callback`,
+ });
+
+ logger.debug('Sending token exchange request');
+ // Send request to get token
+ const response = await fetch(tokenUrl.toString(), {
+ body,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ // Try parsing the error response
+ const errorData = await response.json().catch(() => ({}));
+ const errorMessage = `Failed to get token: ${response.status} ${response.statusText} ${errorData.error_description || errorData.error || ''}`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ // Parse response
+ const data = await response.json();
+ logger.debug('Successfully received token exchange response');
+ // console.log(data); // Keep original log for debugging, or remove/change to logger.debug as needed
+
+ // Ensure response contains necessary fields
+ if (!data.access_token || !data.refresh_token) {
+ logger.error('Invalid token response: missing access_token or refresh_token');
+ throw new Error('Invalid token response: missing required fields');
+ }
+
+ // Save tokens
+ logger.debug('Starting to save exchanged tokens');
+ await this.remoteServerConfigCtr.saveTokens(data.access_token, data.refresh_token);
+ logger.info('Successfully saved exchanged tokens');
+
+ // Set server to active state
+ logger.debug(`Setting remote server to active state: ${remoteUrl}`);
+ await this.remoteServerConfigCtr.setRemoteServerConfig({ active: true });
+
+ return { success: true };
+ } catch (error) {
+ logger.error('Exchanging authorization code failed:', error);
+ return { error: error.message, success: false };
+ }
+ }
+
+ /**
+ * Broadcast token refreshed event
+ */
+ private broadcastTokenRefreshed() {
+ logger.debug('Broadcasting tokenRefreshed event to all windows');
+ const allWindows = BrowserWindow.getAllWindows();
+
+ for (const win of allWindows) {
+ if (!win.isDestroyed()) {
+ win.webContents.send('tokenRefreshed');
+ }
+ }
+ }
+
+ /**
+ * Broadcast authorization successful event
+ */
+ private broadcastAuthorizationSuccessful() {
+ logger.debug('Broadcasting authorizationSuccessful event to all windows');
+ const allWindows = BrowserWindow.getAllWindows();
+
+ for (const win of allWindows) {
+ if (!win.isDestroyed()) {
+ win.webContents.send('authorizationSuccessful');
+ }
+ }
+ }
+
+ /**
+ * Broadcast authorization failed event
+ */
+ private broadcastAuthorizationFailed(error: string) {
+ logger.debug(`Broadcasting authorizationFailed event to all windows, error: ${error}`);
+ const allWindows = BrowserWindow.getAllWindows();
+
+ for (const win of allWindows) {
+ if (!win.isDestroyed()) {
+ win.webContents.send('authorizationFailed', { error });
+ }
+ }
+ }
+
+ /**
+ * Broadcast authorization required event
+ */
+ private broadcastAuthorizationRequired() {
+ logger.debug('Broadcasting authorizationRequired event to all windows');
+ const allWindows = BrowserWindow.getAllWindows();
+
+ for (const win of allWindows) {
+ if (!win.isDestroyed()) {
+ win.webContents.send('authorizationRequired');
+ }
+ }
+ }
+
+ /**
+ * Generate PKCE codeVerifier
+ */
+ private generateCodeVerifier(): string {
+ logger.debug('Generating PKCE code verifier');
+ // Generate a random string of at least 43 characters
+ const verifier = crypto
+ .randomBytes(32)
+ .toString('base64')
+ .replaceAll('+', '-')
+ .replaceAll('/', '_')
+ .replace(/=+$/, '');
+ logger.debug('Generated code verifier (partial): ' + verifier.slice(0, 10) + '...'); // Avoid logging full sensitive info
+ return verifier;
+ }
+
+ /**
+ * Generate codeChallenge from codeVerifier (S256 method)
+ */
+ private async generateCodeChallenge(codeVerifier: string): Promise {
+ logger.debug('Generating PKCE code challenge (S256)');
+ // Hash codeVerifier using SHA-256
+ const encoder = new TextEncoder();
+ const data = encoder.encode(codeVerifier);
+ const digest = await crypto.subtle.digest('SHA-256', data);
+
+ // Convert hash result to base64url encoding
+ const challenge = Buffer.from(digest)
+ .toString('base64')
+ .replaceAll('+', '-')
+ .replaceAll('/', '_')
+ .replace(/=+$/, '');
+ logger.debug('Generated code challenge (partial): ' + challenge.slice(0, 10) + '...'); // Avoid logging full sensitive info
+ return challenge;
+ }
+}
diff --git a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
new file mode 100644
index 0000000000..27297d78e2
--- /dev/null
+++ b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
@@ -0,0 +1,95 @@
+import { InterceptRouteParams } from '@lobechat/electron-client-ipc';
+import { extractSubPath, findMatchingRoute } from '~common/routes';
+
+import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers';
+
+import { ControllerModule, ipcClientEvent, shortcut } from './index';
+
+export default class BrowserWindowsCtr extends ControllerModule {
+ @shortcut('toggleMainWindow')
+ async toggleMainWindow() {
+ const mainWindow = this.app.browserManager.getMainWindow();
+ mainWindow.toggleVisible();
+ }
+
+ @ipcClientEvent('openSettingsWindow')
+ async openSettingsWindow(tab?: string) {
+ console.log('[BrowserWindowsCtr] Received request to open settings window', tab);
+
+ try {
+ await this.app.browserManager.showSettingsWindowWithTab(tab);
+
+ return { success: true };
+ } catch (error) {
+ console.error('[BrowserWindowsCtr] Failed to open settings window:', error);
+ return { error: error.message, success: false };
+ }
+ }
+
+ /**
+ * Handle route interception requests
+ * Responsible for handling route interception requests from the renderer process
+ */
+ @ipcClientEvent('interceptRoute')
+ async interceptRoute(params: InterceptRouteParams) {
+ const { path, source } = params;
+ console.log(
+ `[BrowserWindowsCtr] Received route interception request: ${path}, source: ${source}`,
+ );
+
+ // Find matching route configuration
+ const matchedRoute = findMatchingRoute(path);
+
+ // If no matching route found, return not intercepted
+ if (!matchedRoute) {
+ console.log(`[BrowserWindowsCtr] No matching route configuration found: ${path}`);
+ return { intercepted: false, path, source };
+ }
+
+ console.log(
+ `[BrowserWindowsCtr] Intercepted route: ${path}, target window: ${matchedRoute.targetWindow}`,
+ );
+
+ try {
+ if (matchedRoute.targetWindow === BrowsersIdentifiers.settings) {
+ const subPath = extractSubPath(path, matchedRoute.pathPrefix);
+
+ await this.app.browserManager.showSettingsWindowWithTab(subPath);
+
+ return {
+ intercepted: true,
+ path,
+ source,
+ subPath,
+ targetWindow: matchedRoute.targetWindow,
+ };
+ } else {
+ await this.openTargetWindow(matchedRoute.targetWindow as AppBrowsersIdentifiers);
+
+ return {
+ intercepted: true,
+ path,
+ source,
+ targetWindow: matchedRoute.targetWindow,
+ };
+ }
+ } catch (error) {
+ console.error('[BrowserWindowsCtr] Error while processing route interception:', error);
+ return {
+ error: error.message,
+ intercepted: false,
+ path,
+ source,
+ };
+ }
+ }
+
+ /**
+ * Open target window and navigate to specified sub-path
+ */
+ private async openTargetWindow(targetWindow: AppBrowsersIdentifiers) {
+ // Ensure the window can always be created or reopened
+ const browser = this.app.browserManager.retrieveByIdentifier(targetWindow);
+ browser.show();
+ }
+}
diff --git a/apps/desktop/src/main/controllers/DevtoolsCtr.ts b/apps/desktop/src/main/controllers/DevtoolsCtr.ts
new file mode 100644
index 0000000000..add75b04d9
--- /dev/null
+++ b/apps/desktop/src/main/controllers/DevtoolsCtr.ts
@@ -0,0 +1,9 @@
+import { ControllerModule, ipcClientEvent } from './index';
+
+export default class DevtoolsCtr extends ControllerModule {
+ @ipcClientEvent('openDevtools')
+ async openDevtools() {
+ const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
+ devtoolsBrowser.show();
+ }
+}
diff --git a/apps/desktop/src/main/controllers/LocalFileCtr.ts b/apps/desktop/src/main/controllers/LocalFileCtr.ts
new file mode 100644
index 0000000000..8939ed230d
--- /dev/null
+++ b/apps/desktop/src/main/controllers/LocalFileCtr.ts
@@ -0,0 +1,380 @@
+import {
+ ListLocalFileParams,
+ LocalMoveFilesResultItem,
+ LocalReadFileParams,
+ LocalReadFileResult,
+ LocalReadFilesParams,
+ LocalSearchFilesParams,
+ MoveLocalFilesParams,
+ OpenLocalFileParams,
+ OpenLocalFolderParams,
+ RenameLocalFileResult,
+} from '@lobechat/electron-client-ipc';
+import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
+import { shell } from 'electron';
+import * as fs from 'node:fs';
+import { rename as renamePromise } from 'node:fs/promises';
+import * as path from 'node:path';
+import { promisify } from 'node:util';
+
+import FileSearchService from '@/services/fileSearchSrv';
+import { FileResult, SearchOptions } from '@/types/fileSearch';
+import { makeSureDirExist } from '@/utils/file-system';
+
+import { ControllerModule, ipcClientEvent } from './index';
+
+const statPromise = promisify(fs.stat);
+const readdirPromise = promisify(fs.readdir);
+const renamePromiseFs = promisify(fs.rename);
+const accessPromise = promisify(fs.access);
+
+export default class LocalFileCtr extends ControllerModule {
+ private get searchService() {
+ return this.app.getService(FileSearchService);
+ }
+
+ /**
+ * Handle IPC event for local file search
+ */
+ @ipcClientEvent('searchLocalFiles')
+ async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise {
+ const options: Omit = {
+ limit: 30,
+ };
+
+ return this.searchService.search(params.keywords, options);
+ }
+
+ @ipcClientEvent('openLocalFile')
+ async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
+ error?: string;
+ success: boolean;
+ }> {
+ try {
+ await shell.openPath(filePath);
+ return { success: true };
+ } catch (error) {
+ console.error(`Failed to open file ${filePath}:`, error);
+ return { error: (error as Error).message, success: false };
+ }
+ }
+
+ @ipcClientEvent('openLocalFolder')
+ async handleOpenLocalFolder({ path: targetPath, isDirectory }: OpenLocalFolderParams): Promise<{
+ error?: string;
+ success: boolean;
+ }> {
+ try {
+ const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
+ await shell.openPath(folderPath);
+ return { success: true };
+ } catch (error) {
+ console.error(`Failed to open folder for path ${targetPath}:`, error);
+ return { error: (error as Error).message, success: false };
+ }
+ }
+
+ @ipcClientEvent('readLocalFiles')
+ async readFiles({ paths }: LocalReadFilesParams): Promise {
+ const results: LocalReadFileResult[] = [];
+
+ for (const filePath of paths) {
+ // 初始化结果对象
+ const result = await this.readFile({ path: filePath });
+
+ results.push(result);
+ }
+
+ return results;
+ }
+
+ @ipcClientEvent('readLocalFile')
+ async readFile({ path: filePath, loc }: LocalReadFileParams): Promise {
+ try {
+ const effectiveLoc = loc ?? [0, 200];
+
+ const fileDocument = await loadFile(filePath);
+
+ const [startLine, endLine] = effectiveLoc;
+ const lines = fileDocument.content.split('\n');
+ const totalLineCount = lines.length;
+ const totalCharCount = fileDocument.content.length;
+
+ // Adjust slice indices to be 0-based and inclusive/exclusive
+ const selectedLines = lines.slice(startLine, endLine);
+ const content = selectedLines.join('\n');
+ const charCount = content.length;
+ const lineCount = selectedLines.length;
+
+ const result: LocalReadFileResult = {
+ // Char count for the selected range
+ charCount,
+ // Content for the selected range
+ content,
+ createdTime: fileDocument.createdTime,
+ fileType: fileDocument.fileType,
+ filename: fileDocument.filename,
+ lineCount,
+ loc: effectiveLoc,
+ // Line count for the selected range
+ modifiedTime: fileDocument.modifiedTime,
+
+ // Total char count of the file
+ totalCharCount,
+ // Total line count of the file
+ totalLineCount,
+ };
+
+ try {
+ const stats = await statPromise(filePath);
+ if (stats.isDirectory()) {
+ result.content = 'This is a directory and cannot be read as plain text.';
+ result.charCount = 0;
+ result.lineCount = 0;
+ // Keep total counts for directory as 0 as well, or decide if they should reflect metadata size
+ result.totalCharCount = 0;
+ result.totalLineCount = 0;
+ }
+ } catch (statError) {
+ console.error(`Stat failed for ${filePath} after loadFile:`, statError);
+ }
+
+ return result;
+ } catch (error) {
+ console.error(`Error processing file ${filePath}:`, error);
+ const errorMessage = (error as Error).message;
+ return {
+ charCount: 0,
+ content: `Error accessing or processing file: ${errorMessage}`,
+ createdTime: new Date(),
+ fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
+ filename: path.basename(filePath),
+ lineCount: 0,
+ loc: [0, 0],
+ modifiedTime: new Date(),
+ totalCharCount: 0, // Add total counts to error result
+ totalLineCount: 0,
+ };
+ }
+ }
+
+ @ipcClientEvent('listLocalFiles')
+ async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise {
+ const results: FileResult[] = [];
+ try {
+ const entries = await readdirPromise(dirPath);
+
+ for (const entry of entries) {
+ // Skip specific system files based on the ignore list
+ if (SYSTEM_FILES_TO_IGNORE.includes(entry)) {
+ continue;
+ }
+
+ const fullPath = path.join(dirPath, entry);
+ try {
+ const stats = await statPromise(fullPath);
+ const isDirectory = stats.isDirectory();
+ results.push({
+ createdTime: stats.birthtime,
+ isDirectory,
+ lastAccessTime: stats.atime,
+ modifiedTime: stats.mtime,
+ name: entry,
+ path: fullPath,
+ size: stats.size,
+ type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
+ });
+ } catch (statError) {
+ // Silently ignore files we can't stat (e.g. permissions)
+ console.error(`Failed to stat ${fullPath}:`, statError);
+ }
+ }
+
+ // Sort entries: folders first, then by name
+ results.sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1; // Directories first
+ }
+ // Add null/undefined checks for robustness if needed, though names should exist
+ return (a.name || '').localeCompare(b.name || ''); // Then sort by name
+ });
+
+ return results;
+ } catch (error) {
+ console.error(`Failed to list directory ${dirPath}:`, error);
+ // Rethrow or return an empty array/error object depending on desired behavior
+ // For now, returning empty array on error listing directory itself
+ return [];
+ }
+ }
+
+ @ipcClientEvent('moveLocalFiles')
+ async handleMoveFiles({ items }: MoveLocalFilesParams): Promise {
+ const results: LocalMoveFilesResultItem[] = [];
+
+ if (!items || items.length === 0) {
+ console.warn('moveLocalFiles called with empty items array.');
+ return [];
+ }
+
+ // 逐个处理移动请求
+ for (const item of items) {
+ const { oldPath: sourcePath, newPath } = item;
+ const resultItem: LocalMoveFilesResultItem = {
+ newPath: undefined,
+ sourcePath,
+ success: false,
+ };
+
+ // 基本验证
+ if (!sourcePath || !newPath) {
+ resultItem.error = 'Both oldPath and newPath are required for each item.';
+ results.push(resultItem);
+ continue;
+ }
+
+ try {
+ // 检查源是否存在
+ try {
+ await accessPromise(sourcePath, fs.constants.F_OK);
+ } catch (accessError: any) {
+ if (accessError.code === 'ENOENT') {
+ throw new Error(`Source path not found: ${sourcePath}`);
+ } else {
+ throw new Error(
+ `Permission denied accessing source path: ${sourcePath}. ${accessError.message}`,
+ );
+ }
+ }
+
+ // 检查目标路径是否与源路径相同
+ if (path.normalize(sourcePath) === path.normalize(newPath)) {
+ console.log(`Skipping move: source and target path are identical: ${sourcePath}`);
+ resultItem.success = true;
+ resultItem.newPath = newPath; // 即使未移动,也报告目标路径
+ results.push(resultItem);
+ continue;
+ }
+
+ // LBYL: 确保目标目录存在
+ const targetDir = path.dirname(newPath);
+ makeSureDirExist(targetDir);
+
+ // 执行移动 (rename)
+ await renamePromiseFs(sourcePath, newPath);
+ resultItem.success = true;
+ resultItem.newPath = newPath;
+ console.log(`Successfully moved ${sourcePath} to ${newPath}`);
+ } catch (error) {
+ console.error(`Error moving ${sourcePath} to ${newPath}:`, error);
+ // 使用与 handleMoveFile 类似的错误处理逻辑
+ let errorMessage = (error as Error).message;
+ if ((error as any).code === 'ENOENT')
+ errorMessage = `Source path not found: ${sourcePath}.`;
+ else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES')
+ errorMessage = `Permission denied to move the item at ${sourcePath}. Check file/folder permissions.`;
+ else if ((error as any).code === 'EBUSY')
+ errorMessage = `The file or directory at ${sourcePath} or ${newPath} is busy or locked by another process.`;
+ else if ((error as any).code === 'EXDEV')
+ errorMessage = `Cannot move across different file systems or drives. Source: ${sourcePath}, Target: ${newPath}.`;
+ else if ((error as any).code === 'EISDIR')
+ errorMessage = `Cannot overwrite a directory with a file, or vice versa. Source: ${sourcePath}, Target: ${newPath}.`;
+ else if ((error as any).code === 'ENOTEMPTY')
+ errorMessage = `The target directory ${newPath} is not empty (relevant on some systems if target exists and is a directory).`;
+ else if ((error as any).code === 'EEXIST')
+ errorMessage = `An item already exists at the target path: ${newPath}.`;
+ // 保留来自访问检查或目录检查的更具体错误
+ else if (
+ !errorMessage.startsWith('Source path not found') &&
+ !errorMessage.startsWith('Permission denied accessing source path') &&
+ !errorMessage.includes('Target directory')
+ ) {
+ // Keep the original error message if none of the specific codes match
+ }
+ resultItem.error = errorMessage;
+ }
+ results.push(resultItem);
+ }
+
+ return results;
+ }
+
+ @ipcClientEvent('renameLocalFile')
+ async handleRenameFile({
+ path: currentPath,
+ newName,
+ }: {
+ newName: string;
+ path: string;
+ }): Promise {
+ // Basic validation (can also be done in frontend action)
+ if (!currentPath || !newName) {
+ return { error: 'Both path and newName are required.', newPath: '', success: false };
+ }
+ // Prevent path traversal or using invalid characters/names
+ if (
+ newName.includes('/') ||
+ newName.includes('\\') ||
+ newName === '.' ||
+ newName === '..' ||
+ /["*/:<>?\\|]/.test(newName) // Check for typical invalid filename characters
+ ) {
+ return {
+ error:
+ 'Invalid new name. It cannot contain path separators (/, \\), be "." or "..", or include characters like < > : " / \\ | ? *.',
+ newPath: '',
+ success: false,
+ };
+ }
+
+ let newPath: string;
+ try {
+ const dir = path.dirname(currentPath);
+ newPath = path.join(dir, newName);
+
+ // Check if paths are identical after calculation
+ if (path.normalize(currentPath) === path.normalize(newPath)) {
+ console.log(
+ `Skipping rename: oldPath and calculated newPath are identical: ${currentPath}`,
+ );
+ // Consider success as no change is needed, but maybe inform the user?
+ // Return success for now.
+ return { newPath, success: true };
+ }
+ } catch (error) {
+ console.error(`Error calculating new path for rename ${currentPath} to ${newName}:`, error);
+ return {
+ error: `Internal error calculating the new path: ${(error as Error).message}`,
+ newPath: '',
+ success: false,
+ };
+ }
+
+ // Perform the rename operation using fs.promises.rename directly
+ try {
+ await renamePromise(currentPath, newPath);
+ console.log(`Successfully renamed ${currentPath} to ${newPath}`);
+ // Optionally return the newPath if frontend needs it
+ // return { success: true, newPath: newPath };
+ return { newPath, success: true };
+ } catch (error) {
+ console.error(`Error renaming ${currentPath} to ${newPath}:`, error);
+ let errorMessage = (error as Error).message;
+ // Provide more specific error messages based on common codes
+ if ((error as any).code === 'ENOENT') {
+ errorMessage = `File or directory not found at the original path: ${currentPath}.`;
+ } else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES') {
+ errorMessage = `Permission denied to rename the item at ${currentPath}. Check file/folder permissions.`;
+ } else if ((error as any).code === 'EBUSY') {
+ errorMessage = `The file or directory at ${currentPath} or ${newPath} is busy or locked by another process.`;
+ } else if ((error as any).code === 'EISDIR' || (error as any).code === 'ENOTDIR') {
+ errorMessage = `Cannot rename - conflict between file and directory. Source: ${currentPath}, Target: ${newPath}.`;
+ } else if ((error as any).code === 'EEXIST') {
+ // Target already exists
+ errorMessage = `Cannot rename: an item with the name '${newName}' already exists at this location.`;
+ }
+ // Add more specific checks as needed
+ return { error: errorMessage, newPath: '', success: false };
+ }
+ }
+}
diff --git a/apps/desktop/src/main/controllers/MenuCtr.ts b/apps/desktop/src/main/controllers/MenuCtr.ts
new file mode 100644
index 0000000000..5a82f094f9
--- /dev/null
+++ b/apps/desktop/src/main/controllers/MenuCtr.ts
@@ -0,0 +1,29 @@
+import { ControllerModule, ipcClientEvent } from './index';
+
+export default class MenuController extends ControllerModule {
+ /**
+ * 刷新菜单
+ */
+ @ipcClientEvent('refreshAppMenu')
+ refreshAppMenu() {
+ // 注意:可能需要根据具体情况决定是否允许渲染进程刷新所有菜单
+ return this.app.menuManager.refreshMenus();
+ }
+
+ /**
+ * 显示上下文菜单
+ */
+ @ipcClientEvent('showContextMenu')
+ showContextMenu(type: string, data?: any) {
+ return this.app.menuManager.showContextMenu(type, data);
+ }
+
+ /**
+ * 设置开发菜单可见性
+ */
+ @ipcClientEvent('setDevMenuVisibility')
+ setDevMenuVisibility(visible: boolean) {
+ // 调用 MenuManager 的方法来重建应用菜单
+ return this.app.menuManager.rebuildAppMenu({ showDevItems: visible });
+ }
+}
diff --git a/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts
new file mode 100644
index 0000000000..8c155ca4e6
--- /dev/null
+++ b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts
@@ -0,0 +1,335 @@
+import { DataSyncConfig } from '@lobechat/electron-client-ipc';
+import { safeStorage } from 'electron';
+import querystring from 'node:querystring';
+import { URL } from 'node:url';
+
+import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
+import { createLogger } from '@/utils/logger';
+
+import { ControllerModule, ipcClientEvent } from './index';
+
+// Create logger
+const logger = createLogger('controllers:RemoteServerConfigCtr');
+
+/**
+ * Remote Server Configuration Controller
+ * Used to manage custom remote LobeChat server configuration
+ */
+export default class RemoteServerConfigCtr extends ControllerModule {
+ /**
+ * Key used to store encrypted tokens in electron-store.
+ */
+ private readonly encryptedTokensKey = 'encryptedTokens';
+
+ /**
+ * Get remote server configuration
+ */
+ @ipcClientEvent('getRemoteServerConfig')
+ async getRemoteServerConfig() {
+ logger.debug('Getting remote server configuration');
+ const { storeManager } = this.app;
+
+ const config: DataSyncConfig = storeManager.get('dataSyncConfig');
+
+ logger.debug(
+ `Remote server config: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
+ );
+
+ return config;
+ }
+
+ /**
+ * Set remote server configuration
+ */
+ @ipcClientEvent('setRemoteServerConfig')
+ async setRemoteServerConfig(config: Partial) {
+ logger.info(
+ `Setting remote server storageMode: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
+ );
+ const { storeManager } = this.app;
+ const prev: DataSyncConfig = storeManager.get('dataSyncConfig');
+
+ // Save configuration
+ storeManager.set('dataSyncConfig', { ...prev, ...config });
+
+ return true;
+ }
+
+ /**
+ * Clear remote server configuration
+ */
+ @ipcClientEvent('clearRemoteServerConfig')
+ async clearRemoteServerConfig() {
+ logger.info('Clearing remote server configuration');
+ const { storeManager } = this.app;
+
+ // Clear instance configuration
+ storeManager.set('dataSyncConfig', { storageMode: 'local' });
+
+ // Clear tokens (if any)
+ await this.clearTokens();
+
+ return true;
+ }
+
+ /**
+ * Encrypted tokens
+ * Stored in memory for quick access, loaded from persistent storage on init.
+ */
+ private encryptedAccessToken?: string;
+ private encryptedRefreshToken?: string;
+
+ /**
+ * Promise representing the ongoing token refresh operation.
+ * Used to prevent concurrent refreshes and allow callers to wait.
+ */
+ private refreshPromise: Promise<{ error?: string; success: boolean }> | null = null;
+
+ /**
+ * Encrypt and store tokens
+ * @param accessToken Access token
+ * @param refreshToken Refresh token
+ */
+ async saveTokens(accessToken: string, refreshToken: string) {
+ logger.info('Saving encrypted tokens');
+
+ // If platform doesn't support secure storage, store raw tokens
+ if (!safeStorage.isEncryptionAvailable()) {
+ logger.warn('Safe storage not available, storing tokens unencrypted');
+ this.encryptedAccessToken = accessToken;
+ this.encryptedRefreshToken = refreshToken;
+ // Persist unencrypted tokens (consider security implications)
+ this.app.storeManager.set(this.encryptedTokensKey, {
+ accessToken: this.encryptedAccessToken,
+ refreshToken: this.encryptedRefreshToken,
+ });
+ return;
+ }
+
+ // Encrypt tokens
+ logger.debug('Encrypting tokens using safe storage');
+ this.encryptedAccessToken = Buffer.from(safeStorage.encryptString(accessToken)).toString(
+ 'base64',
+ );
+
+ this.encryptedRefreshToken = Buffer.from(safeStorage.encryptString(refreshToken)).toString(
+ 'base64',
+ );
+
+ // Persist encrypted tokens
+ logger.debug(`Persisting encrypted tokens to store key: ${this.encryptedTokensKey}`);
+ this.app.storeManager.set(this.encryptedTokensKey, {
+ accessToken: this.encryptedAccessToken,
+ refreshToken: this.encryptedRefreshToken,
+ });
+ }
+
+ /**
+ * Get decrypted access token
+ */
+ async getAccessToken(): Promise {
+ // Try loading from memory first
+ if (!this.encryptedAccessToken) {
+ logger.debug('Access token not in memory, trying to load from store...');
+ this.loadTokensFromStore(); // Attempt to load from persistent storage
+ }
+
+ if (!this.encryptedAccessToken) {
+ logger.debug('No access token found in memory or store.');
+ return null;
+ }
+
+ // If platform doesn't support secure storage, return stored token
+ if (!safeStorage.isEncryptionAvailable()) {
+ logger.debug(
+ 'Safe storage not available, returning potentially unencrypted token from memory/store',
+ );
+ return this.encryptedAccessToken;
+ }
+
+ try {
+ // Decrypt token
+ logger.debug('Decrypting access token');
+ const encryptedData = Buffer.from(this.encryptedAccessToken, 'base64');
+ return safeStorage.decryptString(encryptedData);
+ } catch (error) {
+ logger.error('Failed to decrypt access token:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Get decrypted refresh token
+ */
+ async getRefreshToken(): Promise {
+ // Try loading from memory first
+ if (!this.encryptedRefreshToken) {
+ logger.debug('Refresh token not in memory, trying to load from store...');
+ this.loadTokensFromStore(); // Attempt to load from persistent storage
+ }
+
+ if (!this.encryptedRefreshToken) {
+ logger.debug('No refresh token found in memory or store.');
+ return null;
+ }
+
+ // If platform doesn't support secure storage, return stored token
+ if (!safeStorage.isEncryptionAvailable()) {
+ logger.debug(
+ 'Safe storage not available, returning potentially unencrypted token from memory/store',
+ );
+ return this.encryptedRefreshToken;
+ }
+
+ try {
+ // Decrypt token
+ logger.debug('Decrypting refresh token');
+ const encryptedData = Buffer.from(this.encryptedRefreshToken, 'base64');
+ return safeStorage.decryptString(encryptedData);
+ } catch (error) {
+ logger.error('Failed to decrypt refresh token:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Clear tokens
+ */
+ async clearTokens() {
+ logger.info('Clearing access and refresh tokens');
+ this.encryptedAccessToken = undefined;
+ this.encryptedRefreshToken = undefined;
+ // Also clear from persistent storage
+ logger.debug(`Deleting tokens from store key: ${this.encryptedTokensKey}`);
+ this.app.storeManager.delete(this.encryptedTokensKey);
+ }
+
+ /**
+ * 刷新访问令牌
+ * 使用存储的刷新令牌获取新的访问令牌
+ * Handles concurrent requests by returning the existing refresh promise if one is in progress.
+ */
+ @ipcClientEvent('refreshAccessToken')
+ async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
+ // If a refresh is already in progress, return the existing promise
+ if (this.refreshPromise) {
+ logger.debug('Token refresh already in progress, returning existing promise.');
+ return this.refreshPromise;
+ }
+
+ // Start a new refresh operation
+ logger.info('Initiating new token refresh operation.');
+ this.refreshPromise = this.performTokenRefresh();
+
+ // Return the promise so callers can wait
+ return this.refreshPromise;
+ }
+
+ /**
+ * Performs the actual token refresh logic.
+ * This method is called by refreshAccessToken and wrapped in a promise.
+ */
+ private async performTokenRefresh(): Promise<{ error?: string; success: boolean }> {
+ try {
+ // 获取配置信息
+ const config = await this.getRemoteServerConfig();
+
+ if (!config.remoteServerUrl || !config.active) {
+ logger.warn('Remote server not active or configured, skipping refresh.');
+ return { error: '远程服务器未激活或未配置', success: false };
+ }
+
+ // 获取刷新令牌
+ const refreshToken = await this.getRefreshToken();
+ if (!refreshToken) {
+ logger.error('No refresh token available for refresh operation.');
+ return { error: '没有可用的刷新令牌', success: false };
+ }
+
+ // 构造刷新请求
+ const remoteUrl = await this.getRemoteServerUrl(config);
+
+ const tokenUrl = new URL('/oidc/token', remoteUrl);
+
+ // 构造请求体
+ const body = querystring.stringify({
+ client_id: 'lobehub-desktop',
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ });
+
+ logger.debug(`Sending token refresh request to ${tokenUrl.toString()}`);
+
+ // 发送请求
+ const response = await fetch(tokenUrl.toString(), {
+ body,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ // 尝试解析错误响应
+ const errorData = await response.json().catch(() => ({}));
+ const errorMessage = `刷新令牌失败: ${response.status} ${response.statusText} ${
+ errorData.error_description || errorData.error || ''
+ }`.trim();
+ logger.error(errorMessage, errorData);
+ return { error: errorMessage, success: false };
+ }
+
+ // 解析响应
+ const data = await response.json();
+
+ // 检查响应中是否包含必要令牌
+ if (!data.access_token || !data.refresh_token) {
+ logger.error('Refresh response missing access_token or refresh_token', data);
+ return { error: '刷新响应中缺少令牌', success: false };
+ }
+
+ // 保存新令牌
+ logger.info('Token refresh successful, saving new tokens.');
+ await this.saveTokens(data.access_token, data.refresh_token);
+
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ logger.error('Exception during token refresh operation:', errorMessage, error);
+ return { error: `刷新令牌时发生异常: ${errorMessage}`, success: false };
+ } finally {
+ // Ensure the promise reference is cleared once the operation completes
+ logger.debug('Clearing the refresh promise reference.');
+ this.refreshPromise = null;
+ }
+ }
+
+ /**
+ * Load encrypted tokens from persistent storage (electron-store) into memory.
+ * This should be called during initialization or if memory tokens are missing.
+ */
+ private loadTokensFromStore() {
+ logger.debug(`Attempting to load tokens from store key: ${this.encryptedTokensKey}`);
+ const storedTokens = this.app.storeManager.get(this.encryptedTokensKey);
+
+ if (storedTokens && storedTokens.accessToken && storedTokens.refreshToken) {
+ logger.info('Successfully loaded tokens from store into memory.');
+ this.encryptedAccessToken = storedTokens.accessToken;
+ this.encryptedRefreshToken = storedTokens.refreshToken;
+ } else {
+ logger.debug('No valid tokens found in store.');
+ }
+ }
+
+ // Initialize by loading tokens from store when the controller is ready
+ // We might need a dedicated lifecycle method if constructor is too early for storeManager
+ afterAppReady() {
+ this.loadTokensFromStore();
+ }
+
+ async getRemoteServerUrl(config?: DataSyncConfig) {
+ const dataConfig = config ? config : await this.getRemoteServerConfig();
+
+ return dataConfig.storageMode === 'cloud' ? OFFICIAL_CLOUD_SERVER : dataConfig.remoteServerUrl;
+ }
+}
diff --git a/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts b/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts
new file mode 100644
index 0000000000..7f55d0421b
--- /dev/null
+++ b/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts
@@ -0,0 +1,321 @@
+import {
+ ProxyTRPCRequestParams,
+ ProxyTRPCRequestResult,
+} from '@lobechat/electron-client-ipc/src/types/proxyTRPCRequest';
+import { Buffer } from 'node:buffer';
+import http, { IncomingMessage, OutgoingHttpHeaders } from 'node:http';
+import https from 'node:https';
+import { URL } from 'node:url';
+
+import { createLogger } from '@/utils/logger';
+
+import RemoteServerConfigCtr from './RemoteServerConfigCtr';
+import { ControllerModule, ipcClientEvent } from './index';
+
+// Create logger
+const logger = createLogger('controllers:RemoteServerSyncCtr');
+
+/**
+ * Remote Server Sync Controller
+ * For handling data synchronization with remote servers via IPC.
+ */
+export default class RemoteServerSyncCtr extends ControllerModule {
+ /**
+ * Cached instance of RemoteServerConfigCtr
+ */
+ private _remoteServerConfigCtrInstance: RemoteServerConfigCtr | null = null;
+
+ /**
+ * Remote server configuration controller
+ */
+ private get remoteServerConfigCtr() {
+ if (!this._remoteServerConfigCtrInstance) {
+ this._remoteServerConfigCtrInstance = this.app.getController(RemoteServerConfigCtr);
+ }
+ return this._remoteServerConfigCtrInstance;
+ }
+
+ /**
+ * Controller initialization - No specific logic needed here now for request handling
+ */
+ afterAppReady() {
+ logger.info('RemoteServerSyncCtr initialized (IPC based)');
+ // No need to register protocol handler anymore
+ }
+
+ /**
+ * Helper function to perform the actual request forwarding to the remote server.
+ * Accepts arguments from IPC and returns response details.
+ */
+ private async forwardRequest(args: {
+ accessToken: string | null;
+ body?: string | ArrayBuffer;
+ headers: Record;
+ method: string;
+ remoteServerUrl: string;
+ urlPath: string; // Pass the base URL
+ }): Promise<{
+ // Node headers type
+ body: Buffer;
+ headers: Record;
+ status: number;
+ statusText: string; // Return body as Buffer
+ }> {
+ const {
+ urlPath,
+ method,
+ headers: originalHeaders,
+ body: requestBody,
+ accessToken,
+ remoteServerUrl,
+ } = args;
+
+ const logPrefix = `[ForwardRequest ${method} ${urlPath}]`; // Add prefix for easier correlation
+
+ if (!accessToken) {
+ logger.error(`${logPrefix} No access token provided`); // Enhanced log
+ return {
+ body: Buffer.from(''),
+ headers: {},
+ status: 401,
+ statusText: 'Authentication required, missing token',
+ };
+ }
+
+ // 1. Determine target URL and prepare request options
+ const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path
+
+ logger.debug(`${logPrefix} Forwarding to ${targetUrl.toString()}`); // Enhanced log
+
+ // Prepare headers, cloning and adding Authorization
+ const requestHeaders: OutgoingHttpHeaders = { ...originalHeaders }; // Use OutgoingHttpHeaders
+ requestHeaders['Authorization'] = `Bearer ${accessToken}`;
+
+ // Let node handle Host, Content-Length etc. Remove potentially problematic headers
+ delete requestHeaders['host'];
+ delete requestHeaders['connection']; // Often causes issues
+ // delete requestHeaders['content-length']; // Let node handle it based on body
+
+ const requestOptions: https.RequestOptions | http.RequestOptions = {
+ // Use union type
+ headers: requestHeaders,
+ hostname: targetUrl.hostname,
+ method: method,
+ path: targetUrl.pathname + targetUrl.search,
+ port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
+ protocol: targetUrl.protocol,
+ // agent: false, // Consider for keep-alive issues if they arise
+ };
+
+ const requester = targetUrl.protocol === 'https:' ? https : http;
+
+ // 2. Make the request and capture response
+ return new Promise((resolve) => {
+ const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => {
+ const chunks: Buffer[] = [];
+ clientRes.on('data', (chunk) => {
+ chunks.push(chunk);
+ });
+
+ clientRes.on('end', () => {
+ const responseBody = Buffer.concat(chunks);
+ logger.debug(
+ `${logPrefix} Received response from ${targetUrl.toString()}: ${clientRes.statusCode}`,
+ ); // Enhanced log
+ resolve({
+ // These are IncomingHttpHeaders
+ body: responseBody,
+
+ headers: clientRes.headers,
+
+ status: clientRes.statusCode || 500,
+ statusText: clientRes.statusMessage || 'Unknown Status',
+ });
+ });
+
+ clientRes.on('error', (error) => {
+ // Error during response streaming
+ logger.error(
+ `${logPrefix} Error reading response stream from ${targetUrl.toString()}:`,
+ error,
+ ); // Enhanced log
+ // Rejecting might be better, but we need to resolve the outer promise for proxyTRPCRequest
+ resolve({
+ body: Buffer.from(`Error reading response stream: ${error.message}`),
+ headers: {},
+
+ status: 502,
+ // Bad Gateway
+ statusText: 'Error reading response stream',
+ });
+ });
+ });
+
+ clientReq.on('error', (error) => {
+ logger.error(`${logPrefix} Error forwarding request to ${targetUrl.toString()}:`, error); // Enhanced log
+ // Reject or resolve with error status for the outer promise
+ resolve({
+ body: Buffer.from(`Error forwarding request: ${error.message}`),
+ headers: {},
+
+ status: 502,
+ // Bad Gateway
+ statusText: 'Error forwarding request',
+ });
+ });
+
+ // 3. Send request body if present
+ if (requestBody) {
+ if (typeof requestBody === 'string') {
+ clientReq.write(requestBody, 'utf8'); // Specify encoding for strings
+ } else if (requestBody instanceof ArrayBuffer) {
+ clientReq.write(Buffer.from(requestBody)); // Convert ArrayBuffer to Buffer
+ } else {
+ // Should not happen based on type, but handle defensively
+ logger.warn(`${logPrefix} Unsupported request body type received:`, typeof requestBody); // Enhanced log
+ }
+ }
+
+ clientReq.end(); // Finalize the request
+ });
+ }
+
+ /**
+ * Handles the 'proxy-trpc-request' IPC call from the renderer process.
+ * This method should be invoked by the ipcMain.handle setup in your main process entry point.
+ */
+ @ipcClientEvent('proxyTRPCRequest')
+ public async proxyTRPCRequest(args: ProxyTRPCRequestParams): Promise {
+ logger.debug('Received proxyTRPCRequest IPC call:', {
+ headers: args.headers,
+ method: args.method,
+ urlPath: args.urlPath, // Log headers too for context
+ });
+
+ const logPrefix = `[ProxyTRPC ${args.method} ${args.urlPath}]`; // Prefix for this specific request
+
+ try {
+ const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
+ if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
+ logger.warn(
+ `${logPrefix} Remote server sync not active or configured. Rejecting proxy request.`,
+ ); // Enhanced log
+ return {
+ body: Buffer.from('Remote server sync not active or configured').buffer,
+ headers: {},
+
+ status: 503,
+ // Service Unavailable
+ statusText: 'Remote server sync not active or configured', // Return ArrayBuffer
+ };
+ }
+ const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
+
+ // Get initial token
+ let token = await this.remoteServerConfigCtr.getAccessToken();
+ logger.debug(
+ `${logPrefix} Initial token check: ${token ? 'Token exists' : 'No token found'}`,
+ ); // Added log
+
+ logger.info(`${logPrefix} Attempting to forward request...`); // Added log
+ let response = await this.forwardRequest({ ...args, accessToken: token, remoteServerUrl });
+
+ // Handle 401: Refresh token and retry if necessary
+ if (response.status === 401) {
+ logger.info(`${logPrefix} Received 401 from forwarded request. Attempting token refresh.`); // Enhanced log
+ const refreshed = await this.refreshTokenIfNeeded(logPrefix); // Pass prefix for context
+
+ if (refreshed) {
+ const newToken = await this.remoteServerConfigCtr.getAccessToken();
+ if (newToken) {
+ logger.info(`${logPrefix} Token refreshed successfully, retrying the request.`); // Enhanced log
+ response = await this.forwardRequest({
+ ...args,
+ accessToken: newToken,
+ remoteServerUrl,
+ });
+ } else {
+ logger.error(
+ `${logPrefix} Token refresh reported success, but failed to retrieve new token. Keeping original 401 response.`,
+ ); // Enhanced log
+ // Keep the original 401 response
+ }
+ } else {
+ logger.error(`${logPrefix} Token refresh failed. Keeping original 401 response.`); // Enhanced log
+ // Keep the original 401 response
+ }
+ }
+
+ // Convert headers and body to format defined in IPC event
+ const responseHeaders: Record = {};
+ for (const [key, value] of Object.entries(response.headers)) {
+ if (value !== undefined) {
+ responseHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
+ }
+ }
+
+ // Return the final response, ensuring body is serializable (string or ArrayBuffer)
+ const responseBody = response.body; // Buffer
+
+ // IMPORTANT: Check IPC limits. Large bodies might fail. Consider chunking if needed.
+ // Convert Buffer to ArrayBuffer for IPC
+ const finalBody = responseBody.buffer.slice(
+ responseBody.byteOffset,
+ responseBody.byteOffset + responseBody.byteLength,
+ );
+
+ logger.debug(`${logPrefix} Forwarding successful. Status: ${response.status}`); // Added log
+ return {
+ body: finalBody as ArrayBuffer,
+ headers: responseHeaders,
+ status: response.status,
+ statusText: response.statusText, // Return ArrayBuffer
+ };
+ } catch (error) {
+ logger.error(`${logPrefix} Unhandled error processing proxyTRPCRequest:`, error); // Enhanced log
+ // Ensure a serializable error response is returned
+ return {
+ body: Buffer.from(
+ `Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ ).buffer,
+ headers: {},
+ status: 500,
+ statusText: 'Internal Server Error during proxy', // Return ArrayBuffer
+ };
+ }
+ }
+
+ /**
+ * Attempts to refresh the access token by calling the RemoteServerConfigCtr.
+ * @returns Whether token refresh was successful
+ */
+ private async refreshTokenIfNeeded(callerLogPrefix: string = '[RefreshToken]'): Promise {
+ // Added prefix parameter
+ const logPrefix = `${callerLogPrefix} [RefreshTrigger]`; // Updated prefix
+ logger.debug(`${logPrefix} Entered refreshTokenIfNeeded.`);
+
+ try {
+ logger.info(`${logPrefix} Triggering refreshAccessToken in RemoteServerConfigCtr.`);
+ const result = await this.remoteServerConfigCtr.refreshAccessToken();
+
+ if (result.success) {
+ logger.info(`${logPrefix} refreshAccessToken call completed successfully.`);
+ return true;
+ } else {
+ logger.error(`${logPrefix} refreshAccessToken call failed: ${result.error}`);
+ return false;
+ }
+ } catch (error) {
+ logger.error(`${logPrefix} Exception occurred while calling refreshAccessToken:`, error);
+ return false;
+ }
+ }
+
+ /**
+ * Clean up resources - No protocol handler to unregister anymore
+ */
+ destroy() {
+ logger.info('Destroying RemoteServerSyncCtr');
+ // Nothing specific to clean up here regarding request handling now
+ }
+}
diff --git a/apps/desktop/src/main/controllers/ShortcutCtr.ts b/apps/desktop/src/main/controllers/ShortcutCtr.ts
new file mode 100644
index 0000000000..fccdaa6f31
--- /dev/null
+++ b/apps/desktop/src/main/controllers/ShortcutCtr.ts
@@ -0,0 +1,19 @@
+import { ControllerModule, ipcClientEvent } from '.';
+
+export default class ShortcutController extends ControllerModule {
+ /**
+ * 获取所有快捷键配置
+ */
+ @ipcClientEvent('getShortcutsConfig')
+ getShortcutsConfig() {
+ return this.app.shortcutManager.getShortcutsConfig();
+ }
+
+ /**
+ * 更新单个快捷键配置
+ */
+ @ipcClientEvent('updateShortcutConfig')
+ updateShortcutConfig(id: string, accelerator: string): boolean {
+ return this.app.shortcutManager.updateShortcutConfig(id, accelerator);
+ }
+}
diff --git a/apps/desktop/src/main/controllers/SystemCtr.ts b/apps/desktop/src/main/controllers/SystemCtr.ts
new file mode 100644
index 0000000000..08baee4cce
--- /dev/null
+++ b/apps/desktop/src/main/controllers/SystemCtr.ts
@@ -0,0 +1,93 @@
+import { ElectronAppState } from '@lobechat/electron-client-ipc';
+import { app, systemPreferences } from 'electron';
+import { macOS } from 'electron-is';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import process from 'node:process';
+
+import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir';
+
+import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
+
+export default class SystemController extends ControllerModule {
+ /**
+ * Handles the 'getDesktopAppState' IPC request.
+ * Gathers essential application and system information.
+ */
+ @ipcClientEvent('getDesktopAppState')
+ async getAppState(): Promise {
+ const platform = process.platform;
+ const arch = process.arch;
+
+ return {
+ // System Info
+ arch,
+ isLinux: platform === 'linux',
+ isMac: platform === 'darwin',
+ isWindows: platform === 'win32',
+ platform: platform as 'darwin' | 'win32' | 'linux',
+ userPath: {
+ // User Paths (ensure keys match UserPathData / DesktopAppState interface)
+ desktop: app.getPath('desktop'),
+ documents: app.getPath('documents'),
+ downloads: app.getPath('downloads'),
+ home: app.getPath('home'),
+ music: app.getPath('music'),
+ pictures: app.getPath('pictures'),
+ userData: app.getPath('userData'),
+ videos: app.getPath('videos'),
+ },
+ };
+ }
+
+ /**
+ * 检查可用性
+ */
+ @ipcClientEvent('checkSystemAccessibility')
+ checkAccessibilityForMacOS() {
+ if (!macOS()) return;
+ return systemPreferences.isTrustedAccessibilityClient(true);
+ }
+
+ /**
+ * 更新应用语言设置
+ */
+ @ipcClientEvent('updateLocale')
+ async updateLocale(locale: string) {
+ // 保存语言设置
+ this.app.storeManager.set('locale', locale);
+
+ // 更新i18n实例的语言
+ await this.app.i18n.changeLanguage(locale === 'auto' ? app.getLocale() : locale);
+
+ return { success: true };
+ }
+
+ @ipcServerEvent('getDatabasePath')
+ async getDatabasePath() {
+ return join(this.app.appStoragePath, LOCAL_DATABASE_DIR);
+ }
+
+ @ipcServerEvent('getDatabaseSchemaHash')
+ async getDatabaseSchemaHash() {
+ try {
+ return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8');
+ } catch {
+ return undefined;
+ }
+ }
+
+ @ipcServerEvent('getUserDataPath')
+ async getUserDataPath() {
+ return userDataDir;
+ }
+
+ @ipcServerEvent('setDatabaseSchemaHash')
+ async setDatabaseSchemaHash(hash: string) {
+ writeFileSync(this.DB_SCHEMA_HASH_PATH, hash, 'utf8');
+ }
+
+ private get DB_SCHEMA_HASH_PATH() {
+ return join(this.app.appStoragePath, DB_SCHEMA_HASH_FILENAME);
+ }
+}
diff --git a/apps/desktop/src/main/controllers/UpdaterCtr.ts b/apps/desktop/src/main/controllers/UpdaterCtr.ts
new file mode 100644
index 0000000000..481bb8da5d
--- /dev/null
+++ b/apps/desktop/src/main/controllers/UpdaterCtr.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@/utils/logger';
+
+import { ControllerModule, ipcClientEvent } from './index';
+
+const logger = createLogger('controllers:UpdaterCtr');
+
+export default class UpdaterCtr extends ControllerModule {
+ /**
+ * 检查更新
+ */
+ @ipcClientEvent('checkUpdate')
+ async checkForUpdates() {
+ logger.info('Check for updates requested');
+ await this.app.updaterManager.checkForUpdates();
+ }
+
+ /**
+ * 下载更新
+ */
+ @ipcClientEvent('downloadUpdate')
+ async downloadUpdate() {
+ logger.info('Download update requested');
+ await this.app.updaterManager.downloadUpdate();
+ }
+
+ /**
+ * 关闭应用并安装更新
+ */
+ @ipcClientEvent('installNow')
+ quitAndInstallUpdate() {
+ logger.info('Quit and install update requested');
+ this.app.updaterManager.installNow();
+ }
+
+ /**
+ * 下次启动时安装更新
+ */
+ @ipcClientEvent('installLater')
+ installLater() {
+ logger.info('Install later requested');
+ this.app.updaterManager.installLater();
+ }
+}
diff --git a/apps/desktop/src/main/controllers/UploadFileCtr.ts b/apps/desktop/src/main/controllers/UploadFileCtr.ts
new file mode 100644
index 0000000000..47a5e2e25c
--- /dev/null
+++ b/apps/desktop/src/main/controllers/UploadFileCtr.ts
@@ -0,0 +1,34 @@
+import FileService from '@/services/fileSrv';
+
+import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
+
+interface UploadFileParams {
+ content: ArrayBuffer;
+ filename: string;
+ hash: string;
+ path: string;
+ type: string;
+}
+
+export default class UploadFileCtr extends ControllerModule {
+ private get fileService() {
+ return this.app.getService(FileService);
+ }
+
+ @ipcClientEvent('createFile')
+ async uploadFile(params: UploadFileParams) {
+ return this.fileService.uploadFile(params);
+ }
+
+ // ======== server event
+
+ @ipcServerEvent('getStaticFilePath')
+ async getFileUrlById(id: string) {
+ return this.fileService.getFilePath(id);
+ }
+
+ @ipcServerEvent('deleteFiles')
+ async deleteFiles(paths: string[]) {
+ return this.fileService.deleteFiles(paths);
+ }
+}
diff --git a/apps/desktop/src/main/controllers/_template.ts b/apps/desktop/src/main/controllers/_template.ts
new file mode 100644
index 0000000000..add75b04d9
--- /dev/null
+++ b/apps/desktop/src/main/controllers/_template.ts
@@ -0,0 +1,9 @@
+import { ControllerModule, ipcClientEvent } from './index';
+
+export default class DevtoolsCtr extends ControllerModule {
+ @ipcClientEvent('openDevtools')
+ async openDevtools() {
+ const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
+ devtoolsBrowser.show();
+ }
+}
diff --git a/apps/desktop/src/main/controllers/index.ts b/apps/desktop/src/main/controllers/index.ts
new file mode 100644
index 0000000000..dd421a00ff
--- /dev/null
+++ b/apps/desktop/src/main/controllers/index.ts
@@ -0,0 +1,58 @@
+import type { ClientDispatchEvents } from '@lobechat/electron-client-ipc';
+import type { ServerDispatchEvents } from '@lobechat/electron-server-ipc';
+
+import type { App } from '@/core/App';
+import { IoCContainer } from '@/core/IoCContainer';
+import { ShortcutActionType } from '@/shortcuts';
+
+const ipcDecorator =
+ (name: string, mode: 'client' | 'server') =>
+ (target: any, methodName: string, descriptor?: any) => {
+ const actions = IoCContainer.controllers.get(target.constructor) || [];
+ actions.push({
+ methodName,
+ mode,
+ name,
+ });
+ IoCContainer.controllers.set(target.constructor, actions);
+ return descriptor;
+ };
+
+/**
+ * controller 用的 ipc client event 装饰器
+ */
+export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
+ ipcDecorator(method, 'client');
+
+/**
+ * controller 用的 ipc server event 装饰器
+ */
+export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
+ ipcDecorator(method, 'server');
+
+const shortcutDecorator = (name: string) => (target: any, methodName: string, descriptor?: any) => {
+ const actions = IoCContainer.shortcuts.get(target.constructor) || [];
+ actions.push({ methodName, name });
+
+ IoCContainer.shortcuts.set(target.constructor, actions);
+
+ return descriptor;
+};
+
+/**
+ * shortcut inject decorator
+ */
+export const shortcut = (method: ShortcutActionType) => shortcutDecorator(method);
+
+interface IControllerModule {
+ afterAppReady?(): void;
+ app: App;
+ beforeAppReady?(): void;
+}
+export class ControllerModule implements IControllerModule {
+ constructor(public app: App) {
+ this.app = app;
+ }
+}
+
+export type IControlModule = typeof ControllerModule;
diff --git a/apps/desktop/src/main/core/App.ts b/apps/desktop/src/main/core/App.ts
new file mode 100644
index 0000000000..d493c568b3
--- /dev/null
+++ b/apps/desktop/src/main/core/App.ts
@@ -0,0 +1,370 @@
+import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
+import { Session, app, ipcMain, protocol } from 'electron';
+import { macOS, windows } from 'electron-is';
+import { join } from 'node:path';
+
+import { name } from '@/../../package.json';
+import { buildDir, nextStandaloneDir } from '@/const/dir';
+import { isDev } from '@/const/env';
+import { IControlModule } from '@/controllers';
+import { IServiceModule } from '@/services';
+import { createLogger } from '@/utils/logger';
+import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
+
+import BrowserManager from './BrowserManager';
+import { I18nManager } from './I18nManager';
+import { IoCContainer } from './IoCContainer';
+import MenuManager from './MenuManager';
+import { ShortcutManager } from './ShortcutManager';
+import { StoreManager } from './StoreManager';
+import { UpdaterManager } from './UpdaterManager';
+
+const logger = createLogger('core:App');
+
+export type IPCEventMap = Map;
+export type ShortcutMethodMap = Map Promise>;
+
+type Class = new (...args: any[]) => T;
+
+const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
+
+export class App {
+ nextServerUrl = 'http://localhost:3015';
+
+ browserManager: BrowserManager;
+ menuManager: MenuManager;
+ i18n: I18nManager;
+ storeManager: StoreManager;
+ updaterManager: UpdaterManager;
+ shortcutManager: ShortcutManager;
+
+ /**
+ * whether app is in quiting
+ */
+ isQuiting: boolean = false;
+
+ get appStoragePath() {
+ const storagePath = this.storeManager.get('storagePath');
+
+ if (!storagePath) {
+ throw new Error('Storage path not found in store');
+ }
+
+ return storagePath;
+ }
+
+ constructor() {
+ logger.info('----------------------------------------------');
+ logger.info('Starting LobeHub...');
+
+ logger.debug('Initializing App');
+ // Initialize store manager
+ this.storeManager = new StoreManager(this);
+
+ // load controllers
+ const controllers: IControlModule[] = importAll(
+ (import.meta as any).glob('@/controllers/*Ctr.ts', { eager: true }),
+ );
+
+ logger.debug(`Loading ${controllers.length} controllers`);
+ controllers.forEach((controller) => this.addController(controller));
+
+ // load services
+ const services: IServiceModule[] = importAll(
+ (import.meta as any).glob('@/services/*Srv.ts', { eager: true }),
+ );
+
+ logger.debug(`Loading ${services.length} services`);
+ services.forEach((service) => this.addService(service));
+
+ this.initializeIPCEvents();
+
+ this.i18n = new I18nManager(this);
+ this.browserManager = new BrowserManager(this);
+ this.menuManager = new MenuManager(this);
+ this.updaterManager = new UpdaterManager(this);
+ this.shortcutManager = new ShortcutManager(this);
+
+ // register the schema to interceptor url
+ // it should register before app ready
+ this.registerNextHandler();
+
+ // 统一处理 before-quit 事件
+ app.on('before-quit', this.handleBeforeQuit);
+
+ logger.info('App initialization completed');
+ }
+
+ bootstrap = async () => {
+ logger.info('Bootstrapping application');
+ // make single instance
+ const isSingle = app.requestSingleInstanceLock();
+ if (!isSingle) {
+ logger.info('Another instance is already running, exiting');
+ app.exit(0);
+ }
+
+ this.initDevBranding();
+
+ // ==============
+ await this.ipcServer.start();
+ logger.debug('IPC server started');
+
+ // Initialize app
+ await this.makeAppReady();
+
+ // Initialize i18n. Note: app.getLocale() must be called after app.whenReady() to get the correct value
+ await this.i18n.init();
+ this.menuManager.initialize();
+
+ // Initialize global shortcuts: globalShortcut must be called after app.whenReady()
+ this.shortcutManager.initialize();
+
+ this.browserManager.initializeBrowsers();
+
+ // Initialize updater manager
+ await this.updaterManager.initialize();
+
+ // Set global application exit state
+ this.isQuiting = false;
+
+ app.on('window-all-closed', () => {
+ if (windows()) {
+ logger.info('All windows closed, quitting application (Windows)');
+ app.quit();
+ }
+ });
+
+ app.on('activate', this.onActivate);
+ logger.info('Application bootstrap completed');
+ };
+
+ getService(serviceClass: Class): T {
+ return this.services.get(serviceClass);
+ }
+
+ getController(controllerClass: Class): T {
+ return this.controllers.get(controllerClass);
+ }
+
+ private onActivate = () => {
+ logger.debug('Application activated');
+ this.browserManager.showMainWindow();
+ };
+
+ /**
+ * Call beforeAppReady method on all controllers before the application is ready
+ */
+ private makeAppReady = async () => {
+ logger.debug('Preparing application ready state');
+ this.controllers.forEach((controller) => {
+ if (typeof controller.beforeAppReady === 'function') {
+ try {
+ controller.beforeAppReady();
+ } catch (error) {
+ logger.error(`Error in controller.beforeAppReady:`, error);
+ console.error(`[App] Error in controller.beforeAppReady:`, error);
+ }
+ }
+ });
+
+ logger.debug('Waiting for app to be ready');
+ await app.whenReady();
+ logger.debug('Application ready');
+
+ this.controllers.forEach((controller) => {
+ if (typeof controller.afterAppReady === 'function') {
+ try {
+ controller.afterAppReady();
+ } catch (error) {
+ logger.error(`Error in controller.afterAppReady:`, error);
+ console.error(`[App] Error in controller.beforeAppReady:`, error);
+ }
+ }
+ });
+ logger.info('Application ready state completed');
+ };
+
+ // ============= helper ============= //
+
+ /**
+ * all controllers in app
+ */
+ private controllers = new Map, any>();
+ /**
+ * all services in app
+ */
+ private services = new Map, any>();
+
+ private ipcServer: ElectronIPCServer;
+ /**
+ * events dispatched from webview layer
+ */
+ private ipcClientEventMap: IPCEventMap = new Map();
+ private ipcServerEventMap: IPCEventMap = new Map();
+ shortcutMethodMap: ShortcutMethodMap = new Map();
+
+ /**
+ * use in next router interceptor in prod browser render
+ */
+ nextInterceptor: (params: { session: Session }) => () => void;
+
+ /**
+ * Collection of unregister functions for custom request handlers
+ */
+ private customHandlerUnregisterFns: Array<() => void> = [];
+
+ /**
+ * Function to register custom request handler
+ */
+ private registerCustomHandlerFn?: (handler: CustomRequestHandler) => () => void;
+
+ /**
+ * Register custom request handler
+ * @param handler Custom request handler function
+ * @returns Function to unregister the handler
+ */
+ registerRequestHandler = (handler: CustomRequestHandler): (() => void) => {
+ if (!this.registerCustomHandlerFn) {
+ logger.warn('Custom request handler registration is not available');
+ return () => {};
+ }
+
+ logger.debug('Registering custom request handler');
+ const unregisterFn = this.registerCustomHandlerFn(handler);
+ this.customHandlerUnregisterFns.push(unregisterFn);
+
+ return () => {
+ unregisterFn();
+ const index = this.customHandlerUnregisterFns.indexOf(unregisterFn);
+ if (index !== -1) {
+ this.customHandlerUnregisterFns.splice(index, 1);
+ }
+ };
+ };
+
+ /**
+ * Unregister all custom request handlers
+ */
+ unregisterAllRequestHandlers = () => {
+ this.customHandlerUnregisterFns.forEach((unregister) => unregister());
+ this.customHandlerUnregisterFns = [];
+ };
+
+ private addController = (ControllerClass: IControlModule) => {
+ const controller = new ControllerClass(this);
+ this.controllers.set(ControllerClass, controller);
+
+ IoCContainer.controllers.get(ControllerClass)?.forEach((event) => {
+ if (event.mode === 'client') {
+ // Store all objects from event decorator in ipcClientEventMap
+ this.ipcClientEventMap.set(event.name, {
+ controller,
+ methodName: event.methodName,
+ });
+ }
+
+ if (event.mode === 'server') {
+ // Store all objects from event decorator in ipcServerEventMap
+ this.ipcServerEventMap.set(event.name, {
+ controller,
+ methodName: event.methodName,
+ });
+ }
+ });
+
+ IoCContainer.shortcuts.get(ControllerClass)?.forEach((shortcut) => {
+ this.shortcutMethodMap.set(shortcut.name, async () => {
+ controller[shortcut.methodName]();
+ });
+ });
+ };
+
+ private addService = (ServiceClass: IServiceModule) => {
+ const service = new ServiceClass(this);
+ this.services.set(ServiceClass, service);
+ };
+
+ private initDevBranding = () => {
+ if (!isDev) return;
+
+ logger.debug('Setting up dev branding');
+ app.setName('lobehub-desktop-dev');
+ if (macOS()) {
+ app.dock!.setIcon(join(buildDir, 'icon-dev.png'));
+ }
+ };
+
+ private registerNextHandler() {
+ logger.debug('Registering Next.js handler');
+ const handler = createHandler({
+ debug: true,
+ localhostUrl: this.nextServerUrl,
+ protocol,
+ standaloneDir: nextStandaloneDir,
+ });
+
+ // Log output based on development or production mode
+ if (isDev) {
+ logger.info(
+ `Development mode: Custom request handler enabled, but Next.js interception disabled`,
+ );
+ } else {
+ logger.info(
+ `Production mode: ${this.nextServerUrl} will be intercepted to ${nextStandaloneDir}`,
+ );
+ }
+
+ this.nextInterceptor = handler.createInterceptor;
+
+ // Save custom handler registration function
+ if (handler.registerCustomHandler) {
+ this.registerCustomHandlerFn = handler.registerCustomHandler;
+ logger.debug('Custom request handler registration is available');
+ } else {
+ logger.warn('Custom request handler registration is not available');
+ }
+ }
+
+ private initializeIPCEvents() {
+ logger.debug('Initializing IPC events');
+ // Register batch controller client events for render side consumption
+ this.ipcClientEventMap.forEach((eventInfo, key) => {
+ const { controller, methodName } = eventInfo;
+
+ ipcMain.handle(key, async (e, ...data) => {
+ try {
+ return await controller[methodName](...data);
+ } catch (error) {
+ logger.error(`Error handling IPC event ${key}:`, error);
+ return { error: error.message };
+ }
+ });
+ });
+
+ // Batch register server events from controllers for next server consumption
+ const ipcServerEvents = {} as ElectronIPCEventHandler;
+
+ this.ipcServerEventMap.forEach((eventInfo, key) => {
+ const { controller, methodName } = eventInfo;
+
+ ipcServerEvents[key] = async (payload) => {
+ try {
+ return await controller[methodName](payload);
+ } catch (error) {
+ return { error: error.message };
+ }
+ };
+ });
+
+ this.ipcServer = new ElectronIPCServer(name, ipcServerEvents);
+ }
+
+ // 新增 before-quit 处理函数
+ private handleBeforeQuit = () => {
+ this.isQuiting = true; // 首先设置标志
+
+ // 执行清理操作
+ this.unregisterAllRequestHandlers();
+ };
+}
diff --git a/apps/desktop/src/main/core/Browser.ts b/apps/desktop/src/main/core/Browser.ts
new file mode 100644
index 0000000000..78dc136384
--- /dev/null
+++ b/apps/desktop/src/main/core/Browser.ts
@@ -0,0 +1,345 @@
+import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
+import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain } from 'electron';
+import { join } from 'node:path';
+
+import { createLogger } from '@/utils/logger';
+
+import { preloadDir, resourcesDir } from '../const/dir';
+import type { App } from './App';
+
+// Create logger
+const logger = createLogger('core:Browser');
+
+export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
+ devTools?: boolean;
+ height?: number;
+ /**
+ * URL
+ */
+ identifier: string;
+ keepAlive?: boolean;
+ path: string;
+ showOnInit?: boolean;
+ title?: string;
+ width?: number;
+}
+
+export default class Browser {
+ private app: App;
+
+ /**
+ * Internal electron window
+ */
+ private _browserWindow?: BrowserWindow;
+
+ private stopInterceptHandler;
+ /**
+ * Identifier
+ */
+ identifier: string;
+
+ /**
+ * Options at creation
+ */
+ options: BrowserWindowOpts;
+
+ /**
+ * Key for storing window state in storeManager
+ */
+ private readonly windowStateKey: string;
+
+ /**
+ * Method to expose window externally
+ */
+ get browserWindow() {
+ return this.retrieveOrInitialize();
+ }
+
+ /**
+ * Method to construct BrowserWindows object
+ * @param options
+ * @param application
+ */
+ constructor(options: BrowserWindowOpts, application: App) {
+ logger.debug(`Creating Browser instance: ${options.identifier}`);
+ logger.debug(`Browser options: ${JSON.stringify(options)}`);
+ this.app = application;
+ this.identifier = options.identifier;
+ this.options = options;
+ this.windowStateKey = `windowSize_${this.identifier}`;
+
+ // Initialization
+ this.retrieveOrInitialize();
+ }
+
+ loadUrl = async (path: string) => {
+ const initUrl = this.app.nextServerUrl + path;
+
+ try {
+ logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`);
+ await this._browserWindow.loadURL(initUrl);
+ logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`);
+ } catch (error) {
+ logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error);
+
+ // Try to load local error page
+ try {
+ logger.info(`[${this.identifier}] Attempting to load error page...`);
+ await this._browserWindow.loadFile(join(resourcesDir, 'error.html'));
+ logger.info(`[${this.identifier}] Error page loaded successfully.`);
+
+ // Remove previously set retry listeners to avoid duplicates
+ ipcMain.removeHandler('retry-connection');
+ logger.debug(`[${this.identifier}] Removed existing retry-connection handler if any.`);
+
+ // Set retry logic
+ ipcMain.handle('retry-connection', async () => {
+ logger.info(`[${this.identifier}] Retry connection requested for: ${initUrl}`);
+ try {
+ await this._browserWindow?.loadURL(initUrl);
+ logger.info(`[${this.identifier}] Reconnection successful to ${initUrl}`);
+ return { success: true };
+ } catch (err) {
+ logger.error(`[${this.identifier}] Retry connection failed for ${initUrl}:`, err);
+ // Reload error page
+ try {
+ logger.info(`[${this.identifier}] Reloading error page after failed retry...`);
+ await this._browserWindow?.loadFile(join(resourcesDir, 'error.html'));
+ logger.info(`[${this.identifier}] Error page reloaded.`);
+ } catch (loadErr) {
+ logger.error('[${this.identifier}] Failed to reload error page:', loadErr);
+ }
+ return { error: err.message, success: false };
+ }
+ });
+ logger.debug(`[${this.identifier}] Set up retry-connection handler.`);
+ } catch (err) {
+ logger.error(`[${this.identifier}] Failed to load error page:`, err);
+ // If even the error page can't be loaded, at least show a simple error message
+ try {
+ logger.warn(`[${this.identifier}] Attempting to load fallback error HTML string...`);
+ await this._browserWindow.loadURL(
+ 'data:text/html,Loading Failed
Unable to connect to server, please restart the application
',
+ );
+ logger.info(`[${this.identifier}] Fallback error HTML string loaded.`);
+ } catch (finalErr) {
+ logger.error(`[${this.identifier}] Unable to display any page:`, finalErr);
+ }
+ }
+ }
+ };
+
+ loadPlaceholder = async () => {
+ logger.debug(`[${this.identifier}] Loading splash screen placeholder`);
+ // First load a local HTML loading page
+ await this._browserWindow.loadFile(join(resourcesDir, 'splash.html'));
+ logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`);
+ };
+
+ show() {
+ logger.debug(`Showing window: ${this.identifier}`);
+ this.browserWindow.show();
+ }
+
+ hide() {
+ logger.debug(`Hiding window: ${this.identifier}`);
+ this.browserWindow.hide();
+ }
+
+ close() {
+ logger.debug(`Attempting to close window: ${this.identifier}`);
+ this.browserWindow.close();
+ }
+
+ /**
+ * Destroy instance
+ */
+ destroy() {
+ logger.debug(`Destroying window instance: ${this.identifier}`);
+ this.stopInterceptHandler?.();
+ this._browserWindow = undefined;
+ }
+
+ /**
+ * Initialize
+ */
+ retrieveOrInitialize() {
+ // When there is this window and it has not been destroyed
+ if (this._browserWindow && !this._browserWindow.isDestroyed()) {
+ logger.debug(`[${this.identifier}] Returning existing BrowserWindow instance.`);
+ return this._browserWindow;
+ }
+
+ const { path, title, width, height, devTools, showOnInit, ...res } = this.options;
+
+ // Load window state
+ const savedState = this.app.storeManager.get(this.windowStateKey as any) as
+ | { height?: number; width?: number }
+ | undefined; // Keep type for now, but only use w/h
+ logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
+ logger.debug(`[${this.identifier}] Options for new window: ${JSON.stringify(this.options)}`);
+ logger.debug(
+ `[${this.identifier}] Saved window state (only size used): ${JSON.stringify(savedState)}`,
+ );
+
+ const browserWindow = new BrowserWindow({
+ ...res,
+
+ height: savedState?.height || height,
+
+ show: false,
+
+ // Always create hidden first
+ title,
+
+ transparent: true,
+
+ webPreferences: {
+ // Context isolation environment
+ // https://www.electronjs.org/docs/tutorial/context-isolation
+ contextIsolation: true,
+ preload: join(preloadDir, 'index.js'),
+ // devTools: isDev,
+ },
+ // Use saved state if available, otherwise use options. Do not set x/y
+ // x: savedState?.x, // Don't restore x
+ // y: savedState?.y, // Don't restore y
+ width: savedState?.width || width,
+ });
+
+ this._browserWindow = browserWindow;
+ logger.debug(`[${this.identifier}] BrowserWindow instance created.`);
+
+ logger.debug(`[${this.identifier}] Setting up nextInterceptor.`);
+ this.stopInterceptHandler = this.app.nextInterceptor({
+ session: browserWindow.webContents.session,
+ });
+
+ // Windows 11 can use this new API
+ if (process.platform === 'win32' && browserWindow.setBackgroundMaterial) {
+ logger.debug(`[${this.identifier}] Setting window background material for Windows 11`);
+ browserWindow.setBackgroundMaterial('acrylic');
+ }
+
+ logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
+ this.loadPlaceholder().then(() => {
+ this.loadUrl(path).catch((e) => {
+ logger.error(`[${this.identifier}] Initial loadUrl error for path '${path}':`, e);
+ });
+ });
+
+ // Show devtools if enabled
+ if (devTools) {
+ logger.debug(`[${this.identifier}] Opening DevTools because devTools option is true.`);
+ browserWindow.webContents.openDevTools();
+ }
+
+ logger.debug(`[${this.identifier}] Setting up 'ready-to-show' event listener.`);
+ browserWindow.once('ready-to-show', () => {
+ logger.debug(`[${this.identifier}] Window 'ready-to-show' event fired.`);
+ if (showOnInit) {
+ logger.debug(`Showing window ${this.identifier} because showOnInit is true.`);
+ browserWindow?.show();
+ } else {
+ logger.debug(
+ `Window ${this.identifier} not shown on 'ready-to-show' because showOnInit is false.`,
+ );
+ }
+ });
+
+ logger.debug(`[${this.identifier}] Setting up 'close' event listener.`);
+ browserWindow.on('close', (e) => {
+ logger.debug(`Window 'close' event triggered for: ${this.identifier}`);
+ logger.debug(
+ `[${this.identifier}] State during close event: isQuiting=${this.app.isQuiting}, keepAlive=${this.options.keepAlive}`,
+ );
+
+ // If in application quitting process, allow window to be closed
+ if (this.app.isQuiting) {
+ logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`);
+ // Save state before quitting
+ try {
+ const { width, height } = browserWindow.getBounds(); // Get only width and height
+ const sizeState = { height, width };
+ logger.debug(
+ `[${this.identifier}] Saving window size on quit: ${JSON.stringify(sizeState)}`,
+ );
+ this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size
+ } catch (error) {
+ logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
+ }
+ // Need to clean up intercept handler
+ this.stopInterceptHandler?.();
+ return;
+ }
+
+ // Prevent window from being destroyed, just hide it (if marked as keepAlive)
+ if (this.options.keepAlive) {
+ logger.debug(
+ `[${this.identifier}] keepAlive is true, preventing default close and hiding window.`,
+ );
+ // Optionally save state when hiding if desired, but primary save is on actual close/quit
+ // try {
+ // const bounds = browserWindow.getBounds();
+ // logger.debug(`[${this.identifier}] Saving window state on hide: ${JSON.stringify(bounds)}`);
+ // this.app.storeManager.set(this.windowStateKey, bounds);
+ // } catch (error) {
+ // logger.error(`[${this.identifier}] Failed to save window state on hide:`, error);
+ // }
+ e.preventDefault();
+ browserWindow.hide();
+ } else {
+ // Window is actually closing (not keepAlive)
+ logger.debug(
+ `[${this.identifier}] keepAlive is false, allowing window to close. Saving size...`, // Updated log message
+ );
+ try {
+ const { width, height } = browserWindow.getBounds(); // Get only width and height
+ const sizeState = { height, width };
+ logger.debug(
+ `[${this.identifier}] Saving window size on close: ${JSON.stringify(sizeState)}`,
+ );
+ this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size
+ } catch (error) {
+ logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
+ }
+ // Need to clean up intercept handler
+ this.stopInterceptHandler?.();
+ }
+ });
+
+ logger.debug(`[${this.identifier}] retrieveOrInitialize completed.`);
+ return browserWindow;
+ }
+
+ moveToCenter() {
+ logger.debug(`Centering window: ${this.identifier}`);
+ this._browserWindow?.center();
+ }
+
+ setWindowSize(boundSize: { height?: number; width?: number }) {
+ logger.debug(
+ `Setting window size for ${this.identifier}: width=${boundSize.width}, height=${boundSize.height}`,
+ );
+ const windowSize = this._browserWindow.getBounds();
+ this._browserWindow?.setBounds({
+ height: boundSize.height || windowSize.height,
+ width: boundSize.width || windowSize.width,
+ });
+ }
+
+ broadcast = (channel: T, data?: MainBroadcastParams) => {
+ logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`);
+ this._browserWindow.webContents.send(channel, data);
+ };
+
+ toggleVisible() {
+ logger.debug(`Toggling visibility for window: ${this.identifier}`);
+ if (this._browserWindow.isVisible() && this._browserWindow.isFocused()) {
+ this._browserWindow.hide();
+ } else {
+ this._browserWindow.show();
+ this._browserWindow.focus();
+ }
+ }
+}
diff --git a/apps/desktop/src/main/core/BrowserManager.ts b/apps/desktop/src/main/core/BrowserManager.ts
new file mode 100644
index 0000000000..5b7a0a43f4
--- /dev/null
+++ b/apps/desktop/src/main/core/BrowserManager.ts
@@ -0,0 +1,154 @@
+import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
+
+import { createLogger } from '@/utils/logger';
+
+import { AppBrowsersIdentifiers, appBrowsers } from '../appBrowsers';
+import type { App } from './App';
+import type { BrowserWindowOpts } from './Browser';
+import Browser from './Browser';
+
+// Create logger
+const logger = createLogger('core:BrowserManager');
+
+export default class BrowserManager {
+ app: App;
+
+ browsers: Map = new Map();
+
+ constructor(app: App) {
+ logger.debug('Initializing BrowserManager');
+ this.app = app;
+ }
+
+ getMainWindow() {
+ return this.retrieveByIdentifier('chat');
+ }
+
+ showMainWindow() {
+ logger.debug('Showing main window');
+ const window = this.getMainWindow();
+ window.show();
+ }
+
+ showSettingsWindow() {
+ logger.debug('Showing settings window');
+ const window = this.retrieveByIdentifier('settings');
+ window.show();
+ return window;
+ }
+
+ broadcastToAllWindows = (
+ event: T,
+ data: MainBroadcastParams,
+ ) => {
+ logger.debug(`Broadcasting event ${event} to all windows`);
+ this.browsers.forEach((browser) => {
+ browser.broadcast(event, data);
+ });
+ };
+
+ broadcastToWindow = (
+ identifier: AppBrowsersIdentifiers,
+ event: T,
+ data: MainBroadcastParams,
+ ) => {
+ logger.debug(`Broadcasting event ${event} to window: ${identifier}`);
+ this.browsers.get(identifier).broadcast(event, data);
+ };
+
+ /**
+ * Display the settings window and navigate to a specific tab
+ * @param tab Settings window sub-path tab
+ */
+ async showSettingsWindowWithTab(tab?: string) {
+ logger.debug(`Showing settings window with tab: ${tab || 'default'}`);
+ // common is the main path for settings route
+ if (tab && tab !== 'common') {
+ const browser = await this.redirectToPage('settings', tab);
+
+ // make provider page more large
+ if (tab.startsWith('provider/')) {
+ logger.debug('Resizing window for provider settings');
+ browser.setWindowSize({ height: 1000, width: 1400 });
+ browser.moveToCenter();
+ }
+
+ return browser;
+ } else {
+ return this.showSettingsWindow();
+ }
+ }
+
+ /**
+ * Navigate window to specific sub-path
+ * @param identifier Window identifier
+ * @param subPath Sub-path, such as 'agent', 'about', etc.
+ */
+ async redirectToPage(identifier: AppBrowsersIdentifiers, subPath?: string) {
+ try {
+ // Ensure window is retrieved or created
+ const browser = this.retrieveByIdentifier(identifier);
+ browser.hide();
+
+ const baseRoute = appBrowsers[identifier].path;
+
+ // Build complete URL path
+ const fullPath = subPath ? `${baseRoute}/${subPath}` : baseRoute;
+
+ logger.debug(`Redirecting to: ${fullPath}`);
+
+ // Load URL and show window
+ await browser.loadUrl(fullPath);
+ browser.show();
+
+ return browser;
+ } catch (error) {
+ logger.error(`Failed to redirect (${identifier}/${subPath}):`, error);
+ throw error;
+ }
+ }
+
+ /**
+ * get Browser by identifier
+ */
+ retrieveByIdentifier(identifier: AppBrowsersIdentifiers) {
+ const browser = this.browsers.get(identifier);
+
+ if (browser) return browser;
+
+ logger.debug(`Browser ${identifier} not found, initializing new instance`);
+ return this.retrieveOrInitialize(appBrowsers[identifier]);
+ }
+
+ /**
+ * Initialize all browsers when app starts up
+ */
+ initializeBrowsers() {
+ logger.info('Initializing all browsers');
+ Object.values(appBrowsers).forEach((browser) => {
+ logger.debug(`Initializing browser: ${browser.identifier}`);
+ this.retrieveOrInitialize(browser);
+ });
+ }
+
+ // helper
+
+ /**
+ * Retrieve existing browser or initialize a new one
+ * @param options Browser window options
+ */
+ private retrieveOrInitialize(options: BrowserWindowOpts) {
+ let browser = this.browsers.get(options.identifier as AppBrowsersIdentifiers);
+ if (browser) {
+ logger.debug(`Retrieved existing browser: ${options.identifier}`);
+ return browser;
+ }
+
+ logger.debug(`Creating new browser: ${options.identifier}`);
+ browser = new Browser(options, this.app);
+
+ this.browsers.set(options.identifier as AppBrowsersIdentifiers, browser);
+
+ return browser;
+ }
+}
diff --git a/apps/desktop/src/main/core/I18nManager.ts b/apps/desktop/src/main/core/I18nManager.ts
new file mode 100644
index 0000000000..455945b0bc
--- /dev/null
+++ b/apps/desktop/src/main/core/I18nManager.ts
@@ -0,0 +1,185 @@
+import { app } from 'electron';
+import i18next from 'i18next';
+
+import { App } from '@/core/App';
+import { loadResources } from '@/locales/resources';
+import { createLogger } from '@/utils/logger';
+
+// Create logger
+const logger = createLogger('core:I18nManager');
+
+export class I18nManager {
+ private i18n: typeof i18next;
+ private initialized: boolean = false;
+ private app: App;
+
+ constructor(app: App) {
+ logger.debug('Initializing I18nManager');
+ this.app = app;
+ this.i18n = i18next.createInstance();
+ }
+
+ /**
+ * Initialize i18next instance
+ */
+ async init(lang?: string) {
+ if (this.initialized) {
+ logger.debug('I18nManager already initialized, skipping');
+ return this.i18n;
+ }
+
+ // Priority: parameter language > stored locale > system language
+ const storedLocale = this.app.storeManager.get('locale', 'auto') as string;
+ const defaultLanguage =
+ lang || (storedLocale !== 'auto' ? storedLocale : app.getLocale()) || 'en-US';
+
+ logger.info(
+ `Initializing i18n, app locale: ${defaultLanguage}, stored locale: ${storedLocale}`,
+ );
+
+ await this.i18n.init({
+ defaultNS: 'menu',
+ fallbackLng: 'en-US',
+ // Load resources as needed
+ initAsync: true,
+ interpolation: {
+ escapeValue: false,
+ },
+
+ lng: defaultLanguage,
+
+ ns: ['menu', 'dialog', 'common'],
+ partialBundledLanguages: true,
+ });
+
+ logger.info(`i18n initialized, language: ${this.i18n.language}`);
+
+ // Preload base namespaces
+ await this.loadLocale(this.i18n.language);
+
+ this.initialized = true;
+
+ this.refreshMainUI();
+
+ // Listen for language change events
+ this.i18n.on('languageChanged', this.handleLanguageChanged);
+
+ return this.i18n;
+ }
+
+ /**
+ * Basic translation function
+ */
+ t = (key: string, options?: any) => {
+ const result = this.i18n.t(key, options) as string;
+
+ // If translation result is the same as key, translation might be missing
+ if (result === key) {
+ logger.warn(`${this.i18n.language} key: ${key} is not found`);
+ }
+
+ return result;
+ };
+
+ /**
+ * Create a translation function bound to a specific namespace
+ * @param namespace Namespace
+ * @returns Translation function bound to namespace
+ */
+ createNamespacedT(namespace: string) {
+ return (key: string, options: any = {}) => {
+ // Copy options to avoid modifying the original object
+ const mergedOptions = { ...options };
+ // Set namespace
+ mergedOptions.ns = namespace;
+
+ return this.t(key, mergedOptions);
+ };
+ }
+
+ /**
+ * Get translation function by namespace
+ * Provides a more convenient calling method
+ */
+ ns = (namespace: string) => this.createNamespacedT(namespace);
+
+ /**
+ * Get current language
+ */
+ getCurrentLanguage() {
+ return this.i18n.language;
+ }
+
+ /**
+ * Change application language
+ * @param lng Target language
+ */
+ public async changeLanguage(lng: string): Promise {
+ logger.info(`Changing language to: ${lng}`);
+
+ if (!this.initialized) {
+ await this.init();
+ }
+
+ await this.i18n.changeLanguage(lng);
+ // Language change event will trigger handleLanguageChanged
+ }
+
+ /**
+ * Handle language change event
+ */
+ private handleLanguageChanged = async (lang: string) => {
+ logger.info(`Language changed to: ${lang}`);
+ await this.loadLocale(lang);
+
+ // Notify other parts of main process to refresh UI
+ this.refreshMainUI();
+ };
+
+ /**
+ * Refresh main process UI (menus, etc.)
+ */
+ private refreshMainUI() {
+ logger.debug('Refreshing main UI after language change');
+ this.app.menuManager.refreshMenus();
+ }
+
+ /**
+ * Notify renderer process that language has changed
+ */
+ private notifyRendererProcess(lng: string) {
+ logger.debug(`Notifying renderer process of language change: ${lng}`);
+
+ // Send language change event to all windows
+ // const windows = this.app.browserManager.windows;
+ //
+ // if (windows && windows.length > 0) {
+ // windows.forEach((window) => {
+ // if (window?.webContents) {
+ // window.webContents.send('language-changed', lng);
+ // }
+ // });
+ // }
+ }
+
+ private async loadLocale(language: string) {
+ logger.debug(`Loading locale for language: ${language}`);
+ // Preload base namespaces
+ await Promise.all(['menu', 'dialog', 'common'].map((ns) => this.loadNamespace(language, ns)));
+ }
+
+ /**
+ * Load translation resources for specific namespace
+ */
+ private async loadNamespace(lng: string, ns: string) {
+ try {
+ logger.debug(`Loading namespace: ${lng}/${ns}`);
+ const resources = await loadResources(lng, ns);
+ this.i18n.addResourceBundle(lng, ns, resources, true, true);
+ return true;
+ } catch (error) {
+ logger.error(`Failed to load namespace: ${lng}/${ns}`, error);
+ return false;
+ }
+ }
+}
diff --git a/apps/desktop/src/main/core/IoCContainer.ts b/apps/desktop/src/main/core/IoCContainer.ts
new file mode 100644
index 0000000000..361623fffc
--- /dev/null
+++ b/apps/desktop/src/main/core/IoCContainer.ts
@@ -0,0 +1,12 @@
+/**
+ * 存储应用中需要用装饰器的类
+ */
+export class IoCContainer {
+ static controllers: WeakMap<
+ any,
+ { methodName: string; mode: 'client' | 'server'; name: string }[]
+ > = new WeakMap();
+
+ static shortcuts: WeakMap = new WeakMap();
+ init() {}
+}
diff --git a/apps/desktop/src/main/core/MenuManager.ts b/apps/desktop/src/main/core/MenuManager.ts
new file mode 100644
index 0000000000..0421695c9f
--- /dev/null
+++ b/apps/desktop/src/main/core/MenuManager.ts
@@ -0,0 +1,64 @@
+import { Menu } from 'electron';
+
+import { IMenuPlatform, MenuOptions, createMenuImpl } from '@/menus';
+import { createLogger } from '@/utils/logger';
+
+import type { App } from './App';
+
+// Create logger
+const logger = createLogger('core:MenuManager');
+
+export default class MenuManager {
+ app: App;
+ private platformImpl: IMenuPlatform;
+
+ constructor(app: App) {
+ logger.debug('Initializing MenuManager');
+ this.app = app;
+ this.platformImpl = createMenuImpl(app);
+ }
+
+ /**
+ * Initialize menus (mainly application menu)
+ */
+ initialize(options?: MenuOptions) {
+ logger.info('Initializing application menu');
+ this.platformImpl.buildAndSetAppMenu(options);
+ }
+
+ /**
+ * Build and show context menu
+ */
+ showContextMenu(type: string, data?: any) {
+ logger.debug(`Showing context menu of type: ${type}`);
+ const menu = this.platformImpl.buildContextMenu(type, data);
+ menu.popup(); // popup must be called in main process
+ return { success: true };
+ }
+
+ /**
+ * Build tray menu (usually called by tray manager)
+ */
+ buildTrayMenu(): Menu {
+ logger.debug('Building tray menu');
+ return this.platformImpl.buildTrayMenu();
+ }
+
+ /**
+ * Refresh menus
+ */
+ refreshMenus(options?: MenuOptions) {
+ logger.debug('Refreshing all menus');
+ this.platformImpl.refresh(options);
+ return { success: true };
+ }
+
+ /**
+ * Rebuild and set application menu (e.g., when toggling dev menu visibility)
+ */
+ rebuildAppMenu(options?: MenuOptions) {
+ logger.debug('Rebuilding application menu');
+ this.platformImpl.buildAndSetAppMenu(options);
+ return { success: true };
+ }
+}
diff --git a/apps/desktop/src/main/core/ShortcutManager.ts b/apps/desktop/src/main/core/ShortcutManager.ts
new file mode 100644
index 0000000000..6d758a957f
--- /dev/null
+++ b/apps/desktop/src/main/core/ShortcutManager.ts
@@ -0,0 +1,173 @@
+import { globalShortcut } from 'electron';
+
+import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
+import { createLogger } from '@/utils/logger';
+
+import type { App } from './App';
+
+// Create logger
+const logger = createLogger('core:ShortcutManager');
+
+export class ShortcutManager {
+ private app: App;
+ private shortcuts: Map void> = new Map();
+ private shortcutsConfig: Record = {};
+
+ constructor(app: App) {
+ logger.debug('Initializing ShortcutManager');
+ this.app = app;
+
+ app.shortcutMethodMap.forEach((method, key) => {
+ this.shortcuts.set(key, method);
+ });
+ }
+
+ initialize() {
+ logger.info('Initializing global shortcuts');
+ // Load shortcuts configuration from storage
+ this.loadShortcutsConfig();
+ // Register configured shortcuts
+ this.registerConfiguredShortcuts();
+ }
+
+ /**
+ * Get shortcuts configuration
+ */
+ getShortcutsConfig(): Record {
+ return this.shortcutsConfig;
+ }
+
+ /**
+ * Update a single shortcut configuration
+ */
+ updateShortcutConfig(id: string, accelerator: string): boolean {
+ try {
+ logger.debug(`Updating shortcut ${id} to ${accelerator}`);
+ // Update configuration
+ this.shortcutsConfig[id] = accelerator;
+
+ this.saveShortcutsConfig();
+ this.registerConfiguredShortcuts();
+ return true;
+ } catch (error) {
+ logger.error(`Error updating shortcut ${id}:`, error);
+ return false;
+ }
+ }
+
+ /**
+ * Register global shortcut
+ * @param accelerator Shortcut key combination
+ * @param callback Callback function
+ * @returns Whether registration was successful
+ */
+ registerShortcut(accelerator: string, callback: () => void): boolean {
+ try {
+ // If already registered, unregister first
+ if (this.shortcuts.has(accelerator)) {
+ this.unregisterShortcut(accelerator);
+ }
+
+ // Register new shortcut
+ const success = globalShortcut.register(accelerator, callback);
+
+ if (success) {
+ this.shortcuts.set(accelerator, callback);
+ logger.debug(`Registered shortcut: ${accelerator}`);
+ } else {
+ logger.error(`Failed to register shortcut: ${accelerator}`);
+ }
+
+ return success;
+ } catch (error) {
+ logger.error(`Error registering shortcut: ${accelerator}`, error);
+ return false;
+ }
+ }
+
+ /**
+ * Unregister global shortcut
+ * @param accelerator Shortcut key combination
+ */
+ unregisterShortcut(accelerator: string): void {
+ try {
+ globalShortcut.unregister(accelerator);
+ this.shortcuts.delete(accelerator);
+ logger.debug(`Unregistered shortcut: ${accelerator}`);
+ } catch (error) {
+ logger.error(`Error unregistering shortcut: ${accelerator}`, error);
+ }
+ }
+
+ /**
+ * Check if a shortcut is already registered
+ * @param accelerator Shortcut key combination
+ * @returns Whether it is registered
+ */
+ isRegistered(accelerator: string): boolean {
+ return globalShortcut.isRegistered(accelerator);
+ }
+
+ /**
+ * Unregister all shortcuts
+ */
+ unregisterAll(): void {
+ globalShortcut.unregisterAll();
+ logger.info('Unregistered all shortcuts');
+ }
+
+ /**
+ * Load shortcuts configuration from storage
+ */
+ private loadShortcutsConfig() {
+ try {
+ // Try to get configuration from storage
+ const config = this.app.storeManager.get('shortcuts');
+
+ // If no configuration, use default configuration
+ if (!config || Object.keys(config).length === 0) {
+ logger.debug('No shortcuts config found, using defaults');
+ this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
+ this.saveShortcutsConfig();
+ } else {
+ this.shortcutsConfig = config;
+ }
+
+ logger.debug('Loaded shortcuts config:', this.shortcutsConfig);
+ } catch (error) {
+ logger.error('Error loading shortcuts config:', error);
+ this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
+ this.saveShortcutsConfig();
+ }
+ }
+
+ /**
+ * Save shortcuts configuration to storage
+ */
+ private saveShortcutsConfig() {
+ try {
+ this.app.storeManager.set('shortcuts', this.shortcutsConfig);
+ logger.debug('Saved shortcuts config');
+ } catch (error) {
+ logger.error('Error saving shortcuts config:', error);
+ }
+ }
+
+ /**
+ * Register configured shortcuts
+ */
+ private registerConfiguredShortcuts() {
+ // Unregister all shortcuts first
+ this.unregisterAll();
+
+ // Register each enabled shortcut
+ Object.entries(this.shortcutsConfig).forEach(([id, accelerator]) => {
+ logger.debug(`Registering shortcut '${id}' with ${accelerator}`);
+
+ const method = this.shortcuts.get(id);
+ if (accelerator && method) {
+ this.registerShortcut(accelerator, method);
+ }
+ });
+ }
+}
diff --git a/apps/desktop/src/main/core/StoreManager.ts b/apps/desktop/src/main/core/StoreManager.ts
new file mode 100644
index 0000000000..424c13f025
--- /dev/null
+++ b/apps/desktop/src/main/core/StoreManager.ts
@@ -0,0 +1,89 @@
+import Store from 'electron-store';
+
+import { STORE_DEFAULTS, STORE_NAME } from '@/const/store';
+import { ElectronMainStore, StoreKey } from '@/types/store';
+import { makeSureDirExist } from '@/utils/file-system';
+import { createLogger } from '@/utils/logger';
+
+import { App } from './App';
+
+// Create logger
+const logger = createLogger('core:StoreManager');
+
+/**
+ * Application configuration storage manager
+ */
+export class StoreManager {
+ /**
+ * Global configuration store instance
+ */
+ private store: Store;
+ private app: App;
+
+ constructor(app: App) {
+ logger.debug('Initializing StoreManager');
+ this.app = app;
+ this.store = new Store({
+ defaults: STORE_DEFAULTS,
+ name: STORE_NAME,
+ });
+ logger.info('StoreManager initialized with store name:', STORE_NAME);
+
+ const storagePath = this.store.get('storagePath');
+ logger.info('app storage path:', storagePath);
+
+ makeSureDirExist(storagePath);
+ }
+
+ /**
+ * Get configuration item
+ * @param key Configuration key
+ * @param defaultValue Default value
+ */
+ get(key: K, defaultValue?: ElectronMainStore[K]): ElectronMainStore[K] {
+ logger.debug('Getting configuration value for key:', key);
+ return this.store.get(key, defaultValue as any);
+ }
+
+ /**
+ * Set configuration item
+ * @param key Configuration key
+ * @param value Configuration value
+ */
+ set(key: K, value: ElectronMainStore[K]): void {
+ logger.debug('Setting configuration value for key:', key);
+ this.store.set(key, value);
+ }
+
+ /**
+ * Delete configuration item
+ * @param key Configuration key
+ */
+ delete(key: StoreKey): void {
+ logger.debug('Deleting configuration key:', key);
+ this.store.delete(key);
+ }
+
+ /**
+ * Clear all storage
+ */
+ clear(): void {
+ logger.warn('Clearing all store data');
+ this.store.clear();
+ }
+
+ /**
+ * Check if a configuration item exists
+ * @param key Configuration key
+ */
+ has(key: StoreKey): boolean {
+ const exists = this.store.has(key);
+ logger.debug('Checking if key exists:', key, exists);
+ return exists;
+ }
+
+ async openInEditor() {
+ logger.info('Opening store in editor');
+ await this.store.openInEditor();
+ }
+}
diff --git a/apps/desktop/src/main/core/UpdaterManager.ts b/apps/desktop/src/main/core/UpdaterManager.ts
new file mode 100644
index 0000000000..3105643a7d
--- /dev/null
+++ b/apps/desktop/src/main/core/UpdaterManager.ts
@@ -0,0 +1,321 @@
+import log from 'electron-log';
+import { autoUpdater } from 'electron-updater';
+
+import { isDev } from '@/const/env';
+import { UPDATE_CHANNEL as channel, updaterConfig } from '@/modules/updater/configs';
+import { createLogger } from '@/utils/logger';
+
+import type { App as AppCore } from './App';
+
+// Create logger
+const logger = createLogger('core:UpdaterManager');
+
+export class UpdaterManager {
+ private app: AppCore;
+ private checking: boolean = false;
+ private downloading: boolean = false;
+ private updateAvailable: boolean = false;
+ private isManualCheck: boolean = false;
+
+ constructor(app: AppCore) {
+ this.app = app;
+
+ // 设置日志
+ log.transports.file.level = 'info';
+ autoUpdater.logger = log;
+
+ logger.debug(`[Updater] Log file should be at: ${log.transports.file.getFile().path}`); // 打印路径
+ }
+
+ get mainWindow() {
+ return this.app.browserManager.getMainWindow();
+ }
+
+ public initialize = async () => {
+ logger.debug('Initializing UpdaterManager');
+ // If updates are disabled and in production environment, don't initialize updates
+ if (!updaterConfig.enableAppUpdate && !isDev) {
+ logger.info('App updates are disabled, skipping updater initialization');
+ return;
+ }
+
+ // 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
+ autoUpdater.forceDevUpdateConfig = true;
+ }
+
+ // Register events
+ this.registerEvents();
+
+ // If auto-check for updates is configured, set up periodic checks
+ if (updaterConfig.app.autoCheckUpdate) {
+ // Delay update check by 1 minute after startup to avoid network instability
+ setTimeout(() => this.checkForUpdates(), 60 * 1000);
+
+ // Set up periodic checks
+ setInterval(() => this.checkForUpdates(), updaterConfig.app.checkUpdateInterval);
+ }
+
+ // Log the channel and allowPrerelease values
+ logger.debug(
+ `Initialized with channel: ${autoUpdater.channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`,
+ );
+
+ logger.info('UpdaterManager initialization completed');
+ };
+
+ /**
+ * Check for updates
+ * @param manual whether this is a manual check for updates
+ */
+ public checkForUpdates = async ({ manual = false }: { manual?: boolean } = {}) => {
+ if (this.checking || this.downloading) return;
+
+ this.checking = true;
+ this.isManualCheck = manual;
+ logger.info(`${manual ? 'Manually checking' : 'Auto checking'} for updates...`);
+
+ // If manual check, notify renderer process about check start
+ if (manual) {
+ this.mainWindow.broadcast('manualUpdateCheckStart');
+ }
+
+ try {
+ await autoUpdater.checkForUpdates();
+ } catch (error) {
+ logger.error('Error checking for updates:', error.message);
+
+ // If manual check, notify renderer process about check error
+ if (manual) {
+ this.mainWindow.broadcast('updateError', (error as Error).message);
+ }
+ } finally {
+ this.checking = false;
+ }
+ };
+
+ /**
+ * Download update
+ * @param manual whether this is a manual download
+ */
+ public downloadUpdate = async (manual: boolean = false) => {
+ if (this.downloading || !this.updateAvailable) return;
+
+ this.downloading = true;
+ logger.info(`${manual ? 'Manually downloading' : 'Auto downloading'} update...`);
+
+ // If manual download or manual check, notify renderer process about download start
+ if (manual || this.isManualCheck) {
+ this.mainWindow.broadcast('updateDownloadStart');
+ }
+
+ try {
+ await autoUpdater.downloadUpdate();
+ } catch (error) {
+ this.downloading = false;
+ logger.error('Error downloading update:', error);
+
+ // If manual download or manual check, notify renderer process about download error
+ if (manual || this.isManualCheck) {
+ this.mainWindow.broadcast('updateError', (error as Error).message);
+ }
+ }
+ };
+
+ /**
+ * Install update immediately
+ */
+ public installNow = () => {
+ logger.info('Installing update now...');
+
+ // Mark application for exit
+ this.app.isQuiting = true;
+
+ // Delay installation by 1 second to ensure window is closed
+ autoUpdater.quitAndInstall();
+ };
+
+ /**
+ * Install update on next launch
+ */
+ public installLater = () => {
+ logger.info('Update will be installed on next restart');
+
+ // Mark for installation on next launch, but don't exit application
+ autoUpdater.autoInstallOnAppQuit = true;
+
+ // Notify renderer process that update will be installed on next launch
+ this.mainWindow.broadcast('updateWillInstallLater');
+ };
+
+ /**
+ * Test mode: Simulate update available
+ * Only for use in development environment
+ */
+ public simulateUpdateAvailable = () => {
+ if (!isDev) return;
+
+ logger.info('Simulating update available...');
+
+ const mainWindow = this.mainWindow;
+ // Simulate a new version update
+ const mockUpdateInfo = {
+ releaseDate: new Date().toISOString(),
+ releaseNotes: ` #### Version 1.0.0 Release Notes
+- Added some great new features
+- Fixed bugs affecting usability
+- Optimized overall application performance
+- Updated dependency libraries
+`,
+ version: '1.0.0',
+ };
+
+ // Set update available state
+ this.updateAvailable = true;
+
+ // Notify renderer process
+ if (this.isManualCheck) {
+ mainWindow.broadcast('manualUpdateAvailable', mockUpdateInfo);
+ } else {
+ // In auto-check mode, directly simulate download
+ this.simulateDownloadProgress();
+ }
+ };
+
+ /**
+ * Test mode: Simulate update downloaded
+ * Only for use in development environment
+ */
+ public simulateUpdateDownloaded = () => {
+ if (!isDev) return;
+
+ logger.info('Simulating update downloaded...');
+
+ const mainWindow = this.app.browserManager.getMainWindow();
+ if (mainWindow) {
+ // Simulate a new version update
+ const mockUpdateInfo = {
+ releaseDate: new Date().toISOString(),
+ releaseNotes: ` #### Version 1.0.0 Release Notes
+- Added some great new features
+- Fixed bugs affecting usability
+- Optimized overall application performance
+- Updated dependency libraries
+`,
+ version: '1.0.0',
+ };
+
+ // Set download state
+ this.downloading = false;
+
+ // Notify renderer process
+ mainWindow.broadcast('updateDownloaded', mockUpdateInfo);
+ }
+ };
+
+ /**
+ * Test mode: Simulate update download progress
+ * Only for use in development environment
+ */
+ public simulateDownloadProgress = () => {
+ if (!isDev) return;
+
+ logger.info('Simulating download progress...');
+
+ const mainWindow = this.app.browserManager.getMainWindow();
+
+ // Set download state
+ this.downloading = true;
+
+ // Only broadcast download start event if manual check
+ if (this.isManualCheck) {
+ mainWindow.broadcast('updateDownloadStart');
+ }
+
+ // Simulate progress updates
+ let progress = 0;
+ const interval = setInterval(() => {
+ progress += 10;
+
+ if (
+ progress <= 100 && // Only broadcast download progress if manual check
+ this.isManualCheck
+ ) {
+ mainWindow.broadcast('updateDownloadProgress', {
+ bytesPerSecond: 1024 * 1024,
+ percent: progress, // 1MB/s
+ total: 1024 * 1024 * 100, // 100MB
+ transferred: 1024 * 1024 * progress, // Progress * 1MB
+ });
+ }
+
+ if (progress >= 100) {
+ clearInterval(interval);
+ this.simulateUpdateDownloaded();
+ }
+ }, 300);
+ };
+
+ private registerEvents() {
+ logger.debug('Registering updater events');
+
+ autoUpdater.on('checking-for-update', () => {
+ logger.info('[Updater] Checking for update...');
+ });
+
+ autoUpdater.on('update-available', (info) => {
+ logger.info(`Update available: ${info.version}`);
+ this.updateAvailable = true;
+
+ if (this.isManualCheck) {
+ this.mainWindow.broadcast('manualUpdateAvailable', info);
+ } else {
+ // If it's an automatic check, start downloading automatically
+ logger.info('Auto check found update, starting download automatically...');
+ this.downloadUpdate();
+ }
+ });
+
+ autoUpdater.on('update-not-available', (info) => {
+ logger.info(`Update not available. Current: ${info.version}`);
+ if (this.isManualCheck) {
+ this.mainWindow.broadcast('manualUpdateNotAvailable', info);
+ }
+ });
+
+ autoUpdater.on('error', (err) => {
+ logger.error('Error in auto-updater:', err);
+ if (this.isManualCheck) {
+ this.mainWindow.broadcast('updateError', err.message);
+ }
+ });
+
+ autoUpdater.on('download-progress', (progressObj) => {
+ logger.debug(
+ `Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`,
+ );
+ if (this.isManualCheck) {
+ this.mainWindow.broadcast('updateDownloadProgress', progressObj);
+ }
+ });
+
+ autoUpdater.on('update-downloaded', (info) => {
+ logger.info(`Update downloaded: ${info.version}`);
+ this.downloading = false;
+ // Always notify about downloaded update
+ this.mainWindow.broadcast('updateDownloaded', info);
+ });
+
+ logger.debug('Updater events registered');
+ }
+}
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
new file mode 100644
index 0000000000..e5720c849c
--- /dev/null
+++ b/apps/desktop/src/main/index.ts
@@ -0,0 +1,5 @@
+import { App } from './core/App';
+
+const app = new App();
+
+app.bootstrap();
diff --git a/apps/desktop/src/main/locales/default/common.ts b/apps/desktop/src/main/locales/default/common.ts
new file mode 100644
index 0000000000..6da3762c9d
--- /dev/null
+++ b/apps/desktop/src/main/locales/default/common.ts
@@ -0,0 +1,34 @@
+const common = {
+ actions: {
+ add: '添加',
+ back: '返回',
+ cancel: '取消',
+ close: '关闭',
+ confirm: '确认',
+ delete: '删除',
+ edit: '编辑',
+ more: '更多',
+ next: '下一步',
+ ok: '确定',
+ previous: '上一步',
+ refresh: '刷新',
+ remove: '移除',
+ retry: '重试',
+ save: '保存',
+ search: '搜索',
+ submit: '提交',
+ },
+ app: {
+ description: '你的 AI 助手协作平台',
+ name: 'LobeHub',
+ },
+ status: {
+ error: '错误',
+ info: '信息',
+ loading: '加载中',
+ success: '成功',
+ warning: '警告',
+ },
+};
+
+export default common;
diff --git a/apps/desktop/src/main/locales/default/dialog.ts b/apps/desktop/src/main/locales/default/dialog.ts
new file mode 100644
index 0000000000..fdd4d0da44
--- /dev/null
+++ b/apps/desktop/src/main/locales/default/dialog.ts
@@ -0,0 +1,33 @@
+const dialog = {
+ about: {
+ button: '确定',
+ detail: '一个基于大语言模型的聊天应用',
+ message: '{{appName}} {{appVersion}}',
+ title: '关于',
+ },
+ confirm: {
+ cancel: '取消',
+ no: '否',
+ title: '确认',
+ yes: '是',
+ },
+ error: {
+ button: '确定',
+ detail: '操作过程中发生错误,请稍后重试',
+ message: '发生错误',
+ title: '错误',
+ },
+ update: {
+ downloadAndInstall: '下载并安装',
+ downloadComplete: '下载完成',
+ downloadCompleteMessage: '更新包已下载完成,是否立即安装?',
+ installLater: '稍后安装',
+ installNow: '立即安装',
+ later: '稍后提醒',
+ newVersion: '发现新版本',
+ newVersionAvailable: '发现新版本: {{version}}',
+ skipThisVersion: '跳过此版本',
+ },
+};
+
+export default dialog;
diff --git a/apps/desktop/src/main/locales/default/index.ts b/apps/desktop/src/main/locales/default/index.ts
new file mode 100644
index 0000000000..21cf89c1c3
--- /dev/null
+++ b/apps/desktop/src/main/locales/default/index.ts
@@ -0,0 +1,11 @@
+import common from './common';
+import dialog from './dialog';
+import menu from './menu';
+
+const resources = {
+ common,
+ dialog,
+ menu,
+} as const;
+
+export default resources;
diff --git a/apps/desktop/src/main/locales/default/menu.ts b/apps/desktop/src/main/locales/default/menu.ts
new file mode 100644
index 0000000000..97933b8437
--- /dev/null
+++ b/apps/desktop/src/main/locales/default/menu.ts
@@ -0,0 +1,72 @@
+const menu = {
+ common: {
+ checkUpdates: '检查更新...',
+ },
+ dev: {
+ devPanel: '开发者面板',
+ devTools: '开发者工具',
+ forceReload: '强制重新加载',
+ openStore: '打开存储文件',
+ refreshMenu: '刷新菜单',
+ reload: '重新加载',
+ title: '开发',
+ },
+ edit: {
+ copy: '复制',
+ cut: '剪切',
+ paste: '粘贴',
+ redo: '重做',
+ selectAll: '全选',
+ speech: '语音',
+ startSpeaking: '开始朗读',
+ stopSpeaking: '停止朗读',
+ title: '编辑',
+ undo: '撤销',
+ },
+ file: {
+ preferences: '首选项',
+ quit: '退出',
+ title: '文件',
+ },
+ help: {
+ about: '关于',
+ githubRepo: 'GitHub 仓库',
+ reportIssue: '报告问题',
+ title: '帮助',
+ visitWebsite: '访问官网',
+ },
+ macOS: {
+ about: '关于 {{appName}}',
+ devTools: 'LobeHub 开发者工具',
+ hide: '隐藏 {{appName}}',
+ hideOthers: '隐藏其他',
+ preferences: '偏好设置...',
+ services: '服务',
+ unhide: '全部显示',
+ },
+ tray: {
+ open: '打开 {{appName}}',
+ quit: '退出',
+ show: '显示 {{appName}}',
+ },
+ view: {
+ forceReload: '强制重新加载',
+ reload: '重新加载',
+ resetZoom: '重置缩放',
+ title: '视图',
+ toggleFullscreen: '切换全屏',
+ zoomIn: '放大',
+ zoomOut: '缩小',
+ },
+ window: {
+ bringAllToFront: '前置所有窗口',
+ close: '关闭',
+ front: '前置所有窗口',
+ minimize: '最小化',
+ title: '窗口',
+ toggleFullscreen: '切换全屏',
+ zoom: '缩放',
+ },
+};
+
+export default menu;
diff --git a/apps/desktop/src/main/locales/resources.ts b/apps/desktop/src/main/locales/resources.ts
new file mode 100644
index 0000000000..22408e6904
--- /dev/null
+++ b/apps/desktop/src/main/locales/resources.ts
@@ -0,0 +1,35 @@
+import { isDev } from '@/const/env';
+
+/**
+ * 规范化语言代码
+ */
+export const normalizeLocale = (locale: string) => {
+ return locale.toLowerCase().replace('_', '-');
+};
+
+/**
+ * 按需加载翻译资源
+ */
+export const loadResources = async (lng: string, ns: string) => {
+ // 开发环境下,直接使用中文源文件
+ if (isDev && lng === 'zh-CN') {
+ try {
+ // 使用 require 加载模块,这在 Electron 中更可靠
+ const { default: content } = await import(`@/locales/default/${ns}.ts`);
+
+ return content;
+ } catch (error) {
+ console.error(`[I18n] 无法加载翻译文件: ${ns}`, error);
+ return {};
+ }
+ }
+
+ // 生产环境使用编译后的 JSON 文件
+
+ try {
+ return await import(`@/../../resources/locales/${lng}/${ns}.json`);
+ } catch (error) {
+ console.error(`无法加载翻译文件: ${lng} - ${ns}`, error);
+ return {};
+ }
+};
diff --git a/apps/desktop/src/main/menus/impls/BaseMenuPlatform.ts b/apps/desktop/src/main/menus/impls/BaseMenuPlatform.ts
new file mode 100644
index 0000000000..e44df1b482
--- /dev/null
+++ b/apps/desktop/src/main/menus/impls/BaseMenuPlatform.ts
@@ -0,0 +1,10 @@
+// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts
+import type { App } from '@/core/App';
+
+export abstract class BaseMenuPlatform {
+ protected app: App;
+
+ constructor(app: App) {
+ this.app = app;
+ }
+}
diff --git a/apps/desktop/src/main/menus/impls/linux.ts b/apps/desktop/src/main/menus/impls/linux.ts
new file mode 100644
index 0000000000..92004735d7
--- /dev/null
+++ b/apps/desktop/src/main/menus/impls/linux.ts
@@ -0,0 +1,243 @@
+import { Menu, MenuItemConstructorOptions, app, dialog, shell } from 'electron';
+
+import { isDev } from '@/const/env';
+
+import type { IMenuPlatform, MenuOptions } from '../types';
+import { BaseMenuPlatform } from './BaseMenuPlatform';
+
+export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
+ private appMenu: Menu | null = null;
+ private trayMenu: Menu | null = null;
+
+ buildAndSetAppMenu(options?: MenuOptions): Menu {
+ const template = this.getAppMenuTemplate(options);
+ this.appMenu = Menu.buildFromTemplate(template);
+ Menu.setApplicationMenu(this.appMenu);
+ return this.appMenu;
+ }
+
+ buildContextMenu(type: string, data?: any): Menu {
+ let template: MenuItemConstructorOptions[];
+ switch (type) {
+ case 'chat': {
+ template = this.getChatContextMenuTemplate(data);
+ break;
+ }
+ case 'editor': {
+ template = this.getEditorContextMenuTemplate(data);
+ break;
+ }
+ default: {
+ template = this.getDefaultContextMenuTemplate();
+ }
+ }
+ return Menu.buildFromTemplate(template);
+ }
+
+ buildTrayMenu(): Menu {
+ const template = this.getTrayMenuTemplate();
+ this.trayMenu = Menu.buildFromTemplate(template);
+ return this.trayMenu;
+ }
+
+ refresh(options?: MenuOptions): void {
+ this.buildAndSetAppMenu(options);
+ }
+
+ // --- 私有方法:定义菜单模板和逻辑 ---
+
+ private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
+ const showDev = isDev || options?.showDevItems;
+ const t = this.app.i18n.ns('menu');
+
+ const template: MenuItemConstructorOptions[] = [
+ {
+ label: t('file.title'),
+ submenu: [
+ {
+ click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
+ label: t('file.preferences'),
+ },
+ {
+ click: () => {
+ this.app.updaterManager.checkForUpdates({ manual: true });
+ },
+ label: t('common.checkUpdates') || '检查更新',
+ },
+ { type: 'separator' },
+ {
+ accelerator: 'Ctrl+W',
+ label: t('window.close'),
+ role: 'close',
+ },
+ {
+ accelerator: 'Ctrl+M',
+ label: t('window.minimize'),
+ role: 'minimize',
+ },
+ { type: 'separator' },
+ { label: t('file.quit'), role: 'quit' },
+ ],
+ },
+ {
+ label: t('edit.title'),
+ submenu: [
+ { accelerator: 'Ctrl+Z', label: t('edit.undo'), role: 'undo' },
+ { accelerator: 'Ctrl+Shift+Z', label: t('edit.redo'), role: 'redo' },
+ { type: 'separator' },
+ { accelerator: 'Ctrl+X', label: t('edit.cut'), role: 'cut' },
+ { accelerator: 'Ctrl+C', label: t('edit.copy'), role: 'copy' },
+ { accelerator: 'Ctrl+V', label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { accelerator: 'Ctrl+A', label: t('edit.selectAll'), role: 'selectAll' },
+ ],
+ },
+ {
+ label: t('view.title'),
+ submenu: [
+ { accelerator: 'Ctrl+0', label: t('view.resetZoom'), role: 'resetZoom' },
+ { accelerator: 'Ctrl+Plus', label: t('view.zoomIn'), role: 'zoomIn' },
+ { accelerator: 'Ctrl+-', label: t('view.zoomOut'), role: 'zoomOut' },
+ { type: 'separator' },
+ { accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
+ ],
+ },
+ {
+ label: t('window.title'),
+ submenu: [
+ { label: t('window.minimize'), role: 'minimize' },
+ { label: t('window.close'), role: 'close' },
+ ],
+ },
+ {
+ label: t('help.title'),
+ submenu: [
+ {
+ click: async () => {
+ await shell.openExternal('https://lobehub.com');
+ },
+ label: t('help.visitWebsite'),
+ },
+ {
+ click: async () => {
+ await shell.openExternal('https://github.com/lobehub/lobe-chat');
+ },
+ label: t('help.githubRepo'),
+ },
+ { type: 'separator' },
+ {
+ click: () => {
+ const commonT = this.app.i18n.ns('common');
+ const dialogT = this.app.i18n.ns('dialog');
+
+ dialog.showMessageBox({
+ buttons: [commonT('actions.ok')],
+ detail: dialogT('about.detail'),
+ message: dialogT('about.message', {
+ appName: app.getName(),
+ appVersion: app.getVersion(),
+ }),
+ title: dialogT('about.title'),
+ type: 'info',
+ });
+ },
+ label: t('help.about'),
+ },
+ ],
+ },
+ ];
+
+ if (showDev) {
+ template.push({
+ label: t('dev.title'),
+ submenu: [
+ { accelerator: 'Ctrl+R', label: t('dev.reload'), role: 'reload' },
+ { accelerator: 'Ctrl+Shift+R', label: t('dev.forceReload'), role: 'forceReload' },
+ { accelerator: 'Ctrl+Shift+I', label: t('dev.devTools'), role: 'toggleDevTools' },
+ { type: 'separator' },
+ {
+ click: () => {
+ this.app.browserManager.retrieveByIdentifier('devtools').show();
+ },
+ label: t('dev.devPanel'),
+ },
+ ],
+ });
+ }
+
+ return template;
+ }
+
+ private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+
+ return [
+ { label: t('edit.cut'), role: 'cut' },
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+ }
+
+ private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+ const commonT = this.app.i18n.ns('common');
+
+ const items: MenuItemConstructorOptions[] = [
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+
+ if (data?.messageId) {
+ items.push(
+ { type: 'separator' },
+ {
+ click: () => {
+ console.log('尝试删除消息:', data.messageId);
+ },
+ label: commonT('actions.delete'),
+ },
+ );
+ }
+
+ return items;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+
+ return [
+ { label: t('edit.cut'), role: 'cut' },
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.undo'), role: 'undo' },
+ { label: t('edit.redo'), role: 'redo' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+ }
+
+ private getTrayMenuTemplate(): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+ const appName = app.getName();
+
+ return [
+ {
+ click: () => this.app.browserManager.showMainWindow(),
+ label: t('tray.open', { appName }),
+ },
+ { type: 'separator' },
+ {
+ click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
+ label: t('file.preferences'),
+ },
+ { type: 'separator' },
+ { label: t('tray.quit'), role: 'quit' },
+ ];
+ }
+}
diff --git a/apps/desktop/src/main/menus/impls/macOS.ts b/apps/desktop/src/main/menus/impls/macOS.ts
new file mode 100644
index 0000000000..b8c23e759a
--- /dev/null
+++ b/apps/desktop/src/main/menus/impls/macOS.ts
@@ -0,0 +1,360 @@
+import { Menu, MenuItemConstructorOptions, app, shell } from 'electron';
+import * as path from 'node:path';
+
+import { isDev } from '@/const/env';
+
+import type { IMenuPlatform, MenuOptions } from '../types';
+import { BaseMenuPlatform } from './BaseMenuPlatform';
+
+export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
+ private appMenu: Menu | null = null;
+ private trayMenu: Menu | null = null;
+
+ buildAndSetAppMenu(options?: MenuOptions): Menu {
+ const template = this.getAppMenuTemplate(options);
+
+ this.appMenu = Menu.buildFromTemplate(template);
+
+ Menu.setApplicationMenu(this.appMenu);
+
+ return this.appMenu;
+ }
+
+ buildContextMenu(type: string, data?: any): Menu {
+ let template: MenuItemConstructorOptions[];
+ switch (type) {
+ case 'chat': {
+ template = this.getChatContextMenuTemplate(data);
+ break;
+ }
+ case 'editor': {
+ template = this.getEditorContextMenuTemplate(data);
+ break;
+ }
+ default: {
+ template = this.getDefaultContextMenuTemplate();
+ }
+ }
+ return Menu.buildFromTemplate(template);
+ }
+
+ buildTrayMenu(): Menu {
+ const template = this.getTrayMenuTemplate();
+ this.trayMenu = Menu.buildFromTemplate(template);
+ return this.trayMenu;
+ }
+
+ refresh(options?: MenuOptions): void {
+ // 重建应用菜单
+ this.buildAndSetAppMenu(options);
+ // 如果托盘菜单存在,也重建它(如果需要动态更新)
+ // this.trayMenu = this.buildTrayMenu();
+ // 需要考虑如何更新现有托盘图标的菜单
+ }
+
+ // --- 私有方法:定义菜单模板和逻辑 ---
+
+ private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
+ const appName = app.getName();
+ const showDev = isDev || options?.showDevItems;
+
+ // 创建命名空间翻译函数
+ const t = this.app.i18n.ns('menu');
+
+ // 添加调试日志
+ // console.log('[MacOSMenu] 菜单渲染, i18n实例:', !!this.app.i18n);
+
+ const template: MenuItemConstructorOptions[] = [
+ {
+ label: appName,
+ submenu: [
+ {
+ label: t('macOS.about', { appName }),
+ role: 'about',
+ },
+ {
+ click: () => {
+ this.app.updaterManager.checkForUpdates({ manual: true });
+ },
+ label: t('common.checkUpdates'),
+ },
+ { type: 'separator' },
+ {
+ accelerator: 'Command+,',
+ click: () => {
+ this.app.browserManager.showSettingsWindow();
+ },
+ label: t('macOS.preferences'),
+ },
+ { type: 'separator' },
+ {
+ label: t('macOS.services'),
+ role: 'services',
+ submenu: [],
+ },
+ { type: 'separator' },
+ {
+ accelerator: 'Command+H',
+ label: t('macOS.hide', { appName }),
+ role: 'hide',
+ },
+ {
+ accelerator: 'Command+Alt+H',
+ label: t('macOS.hideOthers'),
+ role: 'hideOthers',
+ },
+ {
+ label: t('macOS.unhide'),
+ role: 'unhide',
+ },
+ { type: 'separator' },
+ {
+ accelerator: 'Command+Q',
+ label: t('file.quit'),
+ role: 'quit',
+ },
+ ],
+ },
+ {
+ label: t('file.title'),
+ submenu: [
+ {
+ accelerator: 'Command+W',
+ label: t('window.close'),
+ role: 'close',
+ },
+ ],
+ },
+ {
+ label: t('edit.title'),
+ submenu: [
+ { accelerator: 'Command+Z', label: t('edit.undo'), role: 'undo' },
+ { accelerator: 'Shift+Command+Z', label: t('edit.redo'), role: 'redo' },
+ { type: 'separator' },
+ { accelerator: 'Command+X', label: t('edit.cut'), role: 'cut' },
+ { accelerator: 'Command+C', label: t('edit.copy'), role: 'copy' },
+ { accelerator: 'Command+V', label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ {
+ label: t('edit.speech'),
+ submenu: [
+ { label: t('edit.startSpeaking'), role: 'startSpeaking' },
+ { label: t('edit.stopSpeaking'), role: 'stopSpeaking' },
+ ],
+ },
+ { type: 'separator' },
+ { accelerator: 'Command+A', label: t('edit.selectAll'), role: 'selectAll' },
+ ],
+ },
+ {
+ label: t('view.title'),
+ submenu: [
+ { label: t('view.reload'), role: 'reload' },
+ { label: t('view.forceReload'), role: 'forceReload' },
+ { accelerator: 'F12', label: t('dev.devTools'), role: 'toggleDevTools' },
+ { type: 'separator' },
+ { accelerator: 'Command+0', label: t('view.resetZoom'), role: 'resetZoom' },
+ { accelerator: 'Command+Plus', label: t('view.zoomIn'), role: 'zoomIn' },
+ { accelerator: 'Command+-', label: t('view.zoomOut'), role: 'zoomOut' },
+ { type: 'separator' },
+ { accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
+ ],
+ },
+ {
+ label: t('window.title'),
+ role: 'windowMenu',
+ },
+ {
+ label: t('help.title'),
+ role: 'help',
+ submenu: [
+ {
+ click: async () => {
+ await shell.openExternal('https://lobehub.com');
+ },
+ label: t('help.visitWebsite'),
+ },
+ {
+ click: async () => {
+ await shell.openExternal('https://github.com/lobehub/lobe-chat');
+ },
+ label: t('help.githubRepo'),
+ },
+ {
+ click: async () => {
+ await shell.openExternal('https://github.com/lobehub/lobe-chat/issues/new/choose');
+ },
+ label: t('help.reportIssue'),
+ },
+ { type: 'separator' },
+ {
+ click: () => {
+ const logsPath = app.getPath('logs');
+ console.log(`[Menu] Opening logs directory: ${logsPath}`);
+ shell.openPath(logsPath).catch((err) => {
+ console.error(`[Menu] Error opening path ${logsPath}:`, err);
+ // Optionally show an error dialog to the user
+ });
+ },
+ label: '打开日志目录',
+ },
+ {
+ click: () => {
+ const userDataPath = app.getPath('userData');
+ console.log(`[Menu] Opening user data directory: ${userDataPath}`);
+ shell.openPath(userDataPath).catch((err) => {
+ console.error(`[Menu] Error opening path ${userDataPath}:`, err);
+ // Optionally show an error dialog to the user
+ });
+ },
+ label: '配置目录',
+ },
+ ],
+ },
+ ];
+
+ if (showDev) {
+ template.push({
+ label: t('dev.title'),
+ submenu: [
+ {
+ click: () => {
+ this.app.browserManager.retrieveByIdentifier('devtools').show();
+ },
+ label: t('dev.devPanel'),
+ },
+ {
+ click: () => {
+ this.app.menuManager.rebuildAppMenu();
+ },
+ label: t('dev.refreshMenu'),
+ },
+ { type: 'separator' },
+ {
+ click: () => {
+ const userDataPath = app.getPath('userData');
+ shell.openPath(userDataPath).catch((err) => {
+ console.error(`[Menu] Error opening path ${userDataPath}:`, err);
+ });
+ },
+ label: '用户配置目录',
+ },
+ {
+ click: () => {
+ // @ts-expect-error cache 目录好像暂时不在类型定义里
+ const cachePath = app.getPath('cache');
+
+ const updaterCachePath = path.join(cachePath, `${app.getName()}-updater`);
+ shell.openPath(updaterCachePath).catch((err) => {
+ console.error(`[Menu] Error opening path ${updaterCachePath}:`, err);
+ });
+ },
+ label: '更新缓存目录',
+ },
+ { type: 'separator' },
+ {
+ label: '自动更新测试模拟',
+ submenu: [
+ {
+ click: () => {
+ this.app.updaterManager.simulateUpdateAvailable();
+ },
+ label: '模拟启动后台自动下载更新(3s 下完)',
+ },
+ {
+ click: () => {
+ this.app.updaterManager.simulateDownloadProgress();
+ },
+ label: '模拟下载进度',
+ },
+ {
+ click: () => {
+ this.app.updaterManager.simulateUpdateDownloaded();
+ },
+ label: '模拟下载完成',
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ return template;
+ }
+
+ private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+
+ return [
+ { label: t('edit.cut'), role: 'cut' },
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ { type: 'separator' },
+ ];
+ }
+
+ private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+ const commonT = this.app.i18n.ns('common');
+
+ const items: MenuItemConstructorOptions[] = [
+ { label: t('edit.cut'), role: 'cut' },
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+
+ if (data?.messageId) {
+ items.push(
+ { type: 'separator' },
+ {
+ click: () => {
+ console.log('尝试删除消息:', data.messageId);
+ // 调用 MessageService (假设存在)
+ // const messageService = this.app.getService(MessageService);
+ // messageService?.deleteMessage(data.messageId);
+ },
+ label: commonT('actions.delete'),
+ },
+ );
+ }
+ return items;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+
+ // 编辑器特定的上下文菜单
+ return [
+ { label: t('edit.cut'), role: 'cut' },
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.undo'), role: 'undo' },
+ { label: t('edit.redo'), role: 'redo' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+ }
+
+ private getTrayMenuTemplate(): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+ const appName = app.getName();
+
+ return [
+ {
+ click: () => this.app.browserManager.showMainWindow(),
+ label: t('tray.show', { appName }),
+ },
+ {
+ click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
+ label: t('file.preferences'),
+ },
+ { type: 'separator' },
+ { label: t('tray.quit'), role: 'quit' },
+ ];
+ }
+}
diff --git a/apps/desktop/src/main/menus/impls/windows.ts b/apps/desktop/src/main/menus/impls/windows.ts
new file mode 100644
index 0000000000..ff5d0f3900
--- /dev/null
+++ b/apps/desktop/src/main/menus/impls/windows.ts
@@ -0,0 +1,226 @@
+import { Menu, MenuItemConstructorOptions, app, shell } from 'electron';
+
+import { isDev } from '@/const/env';
+
+import type { IMenuPlatform, MenuOptions } from '../types';
+import { BaseMenuPlatform } from './BaseMenuPlatform';
+
+export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
+ private appMenu: Menu | null = null;
+ private trayMenu: Menu | null = null;
+
+ buildAndSetAppMenu(options?: MenuOptions): Menu {
+ const template = this.getAppMenuTemplate(options);
+ this.appMenu = Menu.buildFromTemplate(template);
+ Menu.setApplicationMenu(this.appMenu);
+ return this.appMenu;
+ }
+
+ buildContextMenu(type: string, data?: any): Menu {
+ let template: MenuItemConstructorOptions[];
+ switch (type) {
+ case 'chat': {
+ template = this.getChatContextMenuTemplate(data);
+ break;
+ }
+ case 'editor': {
+ template = this.getEditorContextMenuTemplate(data);
+ break;
+ }
+ default: {
+ template = this.getDefaultContextMenuTemplate();
+ }
+ }
+ return Menu.buildFromTemplate(template);
+ }
+
+ buildTrayMenu(): Menu {
+ const template = this.getTrayMenuTemplate();
+ this.trayMenu = Menu.buildFromTemplate(template);
+ return this.trayMenu;
+ }
+
+ refresh(options?: MenuOptions): void {
+ this.buildAndSetAppMenu(options);
+ // 如果有必要更新托盘菜单,可以在这里添加逻辑
+ }
+
+ private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
+ const showDev = isDev || options?.showDevItems;
+ const t = this.app.i18n.ns('menu');
+
+ const template: MenuItemConstructorOptions[] = [
+ {
+ label: t('file.title'),
+ submenu: [
+ {
+ click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
+ label: t('file.preferences'),
+ },
+ {
+ click: () => {
+ this.app.updaterManager.checkForUpdates({ manual: true });
+ },
+ label: t('common.checkUpdates') || '检查更新',
+ },
+ { type: 'separator' },
+ {
+ accelerator: 'Alt+F4',
+ label: t('window.close'),
+ role: 'close',
+ },
+ {
+ accelerator: 'Ctrl+M',
+ label: t('window.minimize'),
+ role: 'minimize',
+ },
+ { type: 'separator' },
+ { label: t('file.quit'), role: 'quit' },
+ ],
+ },
+ {
+ label: t('edit.title'),
+ submenu: [
+ { accelerator: 'Ctrl+Z', label: t('edit.undo'), role: 'undo' },
+ { accelerator: 'Ctrl+Y', label: t('edit.redo'), role: 'redo' },
+ { type: 'separator' },
+ { accelerator: 'Ctrl+X', label: t('edit.cut'), role: 'cut' },
+ { accelerator: 'Ctrl+C', label: t('edit.copy'), role: 'copy' },
+ { accelerator: 'Ctrl+V', label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { accelerator: 'Ctrl+A', label: t('edit.selectAll'), role: 'selectAll' },
+ ],
+ },
+ {
+ label: t('view.title'),
+ submenu: [
+ { accelerator: 'Ctrl+0', label: t('view.resetZoom'), role: 'resetZoom' },
+ { accelerator: 'Ctrl+Plus', label: t('view.zoomIn'), role: 'zoomIn' },
+ { accelerator: 'Ctrl+-', label: t('view.zoomOut'), role: 'zoomOut' },
+ { type: 'separator' },
+ { accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
+ ],
+ },
+ {
+ label: t('window.title'),
+ submenu: [
+ { label: t('window.minimize'), role: 'minimize' },
+ { label: t('window.close'), role: 'close' },
+ ],
+ },
+ {
+ label: t('help.title'),
+ submenu: [
+ {
+ click: async () => {
+ await shell.openExternal('https://lobehub.com');
+ },
+ label: t('help.visitWebsite'),
+ },
+ {
+ click: async () => {
+ await shell.openExternal('https://github.com/lobehub/lobe-chat');
+ },
+ label: t('help.githubRepo'),
+ },
+ ],
+ },
+ ];
+
+ if (showDev) {
+ template.push({
+ label: t('dev.title'),
+ submenu: [
+ { accelerator: 'Ctrl+R', label: t('dev.reload'), role: 'reload' },
+ { accelerator: 'Ctrl+Shift+R', label: t('dev.forceReload'), role: 'forceReload' },
+ { accelerator: 'Ctrl+Shift+I', label: t('dev.devTools'), role: 'toggleDevTools' },
+ { type: 'separator' },
+ {
+ click: () => {
+ this.app.browserManager.retrieveByIdentifier('devtools').show();
+ },
+ label: t('dev.devPanel'),
+ },
+ ],
+ });
+ }
+
+ return template;
+ }
+
+ private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+
+ return [
+ { label: t('edit.cut'), role: 'cut' },
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+ }
+
+ private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+ const commonT = this.app.i18n.ns('common');
+
+ const items: MenuItemConstructorOptions[] = [
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+
+ if (data?.messageId) {
+ items.push(
+ { type: 'separator' },
+ {
+ click: () => {
+ console.log('尝试删除消息:', data.messageId);
+ // 调用 MessageService (假设存在)
+ // const messageService = this.app.getService(MessageService);
+ // messageService?.deleteMessage(data.messageId);
+ },
+ label: commonT('actions.delete'),
+ },
+ );
+ }
+
+ return items;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+
+ return [
+ { label: t('edit.cut'), role: 'cut' },
+ { label: t('edit.copy'), role: 'copy' },
+ { label: t('edit.paste'), role: 'paste' },
+ { type: 'separator' },
+ { label: t('edit.undo'), role: 'undo' },
+ { label: t('edit.redo'), role: 'redo' },
+ { type: 'separator' },
+ { label: t('edit.selectAll'), role: 'selectAll' },
+ ];
+ }
+
+ private getTrayMenuTemplate(): MenuItemConstructorOptions[] {
+ const t = this.app.i18n.ns('menu');
+ const appName = app.getName();
+
+ return [
+ {
+ click: () => this.app.browserManager.showMainWindow(),
+ label: t('tray.open', { appName }),
+ },
+ { type: 'separator' },
+ {
+ click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
+ label: t('file.preferences'),
+ },
+ { type: 'separator' },
+ { label: t('tray.quit'), role: 'quit' },
+ ];
+ }
+}
diff --git a/apps/desktop/src/main/menus/index.ts b/apps/desktop/src/main/menus/index.ts
new file mode 100644
index 0000000000..a97ec70be1
--- /dev/null
+++ b/apps/desktop/src/main/menus/index.ts
@@ -0,0 +1,34 @@
+import { platform } from 'node:os';
+
+import { App } from '@/core/App';
+
+import { LinuxMenu } from './impls/linux';
+import { MacOSMenu } from './impls/macOS';
+import { WindowsMenu } from './impls/windows';
+import { IMenuPlatform } from './types';
+
+export type { IMenuPlatform, MenuOptions } from './types';
+
+export const createMenuImpl = (app: App): IMenuPlatform => {
+ const currentPlatform = platform();
+
+ switch (currentPlatform) {
+ case 'darwin': {
+ return new MacOSMenu(app);
+ }
+ case 'win32': {
+ return new WindowsMenu(app);
+ }
+ case 'linux': {
+ return new LinuxMenu(app);
+ }
+
+ default: {
+ // 提供一个备用或抛出错误
+ console.warn(
+ `Unsupported platform for menu: ${currentPlatform}, using Windows implementation as fallback.`,
+ );
+ return new WindowsMenu(app);
+ }
+ }
+};
diff --git a/apps/desktop/src/main/menus/types.ts b/apps/desktop/src/main/menus/types.ts
new file mode 100644
index 0000000000..67e28e9ceb
--- /dev/null
+++ b/apps/desktop/src/main/menus/types.ts
@@ -0,0 +1,28 @@
+import { Menu } from 'electron';
+
+export interface MenuOptions {
+ showDevItems?: boolean;
+ // 其他可能的配置项
+}
+
+export interface IMenuPlatform {
+ /**
+ * 构建并设置应用菜单
+ */
+ buildAndSetAppMenu(options?: MenuOptions): Menu;
+
+ /**
+ * 构建上下文菜单
+ */
+ buildContextMenu(type: string, data?: any): Menu;
+
+ /**
+ * 构建托盘菜单
+ */
+ buildTrayMenu(): Menu;
+
+ /**
+ * 刷新菜单
+ */
+ refresh(options?: MenuOptions): void;
+}
diff --git a/apps/desktop/src/main/modules/fileSearch/impl/macOS.ts b/apps/desktop/src/main/modules/fileSearch/impl/macOS.ts
new file mode 100644
index 0000000000..16a7e9b96d
--- /dev/null
+++ b/apps/desktop/src/main/modules/fileSearch/impl/macOS.ts
@@ -0,0 +1,577 @@
+import { exec, spawn } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import readline from 'node:readline';
+import { promisify } from 'node:util';
+
+import { FileResult, SearchOptions } from '@/types/fileSearch';
+import { createLogger } from '@/utils/logger';
+
+import { FileSearchImpl } from '../type';
+
+const execPromise = promisify(exec);
+const statPromise = promisify(fs.stat);
+
+// Create logger
+const logger = createLogger('module:FileSearch:macOS');
+
+export class MacOSSearchServiceImpl extends FileSearchImpl {
+ /**
+ * Perform file search
+ * @param options Search options
+ * @returns Promise of search result list
+ */
+ async search(options: SearchOptions): Promise {
+ // Build the command first, regardless of execution method
+ const command = this.buildSearchCommand(options);
+ logger.debug(`Executing command: ${command}`);
+
+ // Use spawn for both live and non-live updates to handle large outputs
+ return new Promise((resolve, reject) => {
+ const [cmd, ...args] = command.split(' ');
+ const childProcess = spawn(cmd, args);
+
+ let results: string[] = []; // Store raw file paths
+ let stderrData = '';
+
+ // Create a readline interface to process stdout line by line
+ const rl = readline.createInterface({
+ crlfDelay: Infinity,
+ input: childProcess.stdout, // Handle different line endings
+ });
+
+ rl.on('line', (line) => {
+ const trimmedLine = line.trim();
+ if (trimmedLine) {
+ results.push(trimmedLine);
+
+ // If we have a limit and we've reached it (in non-live mode), stop processing
+ if (!options.liveUpdate && options.limit && results.length >= options.limit) {
+ logger.debug(`Reached limit (${options.limit}), closing readline and killing process.`);
+ rl.close(); // Stop reading lines
+ childProcess.kill(); // Terminate the mdfind process
+ }
+ }
+ });
+
+ childProcess.stderr.on('data', (data) => {
+ const errorMsg = data.toString();
+ stderrData += errorMsg;
+ logger.warn(`Search stderr: ${errorMsg}`);
+ });
+
+ childProcess.on('error', (error) => {
+ logger.error(`Search process error: ${error.message}`, error);
+ reject(new Error(`Search process failed to start: ${error.message}`));
+ });
+
+ childProcess.on('close', async (code) => {
+ logger.debug(`Search process exited with code ${code}`);
+
+ // Even if the process was killed due to limit, code might be null or non-zero.
+ // Process the results collected so far.
+ if (code !== 0 && stderrData && results.length === 0) {
+ // If exited with error code and we have stderr message and no results, reject.
+ // Filter specific ignorable errors if necessary
+ if (!stderrData.includes('Index is unavailable') && !stderrData.includes('kMD')) {
+ // Avoid rejecting for common Spotlight query syntax errors or index issues if some results might still be valid
+ reject(new Error(`Search process exited with code ${code}: ${stderrData}`));
+ return;
+ } else {
+ logger.warn(
+ `Search process exited with code ${code} but contained potentially ignorable errors: ${stderrData}`,
+ );
+ }
+ }
+
+ try {
+ // Process the collected file paths
+ // Ensure limit is applied again here in case killing the process didn't stop exactly at the limit
+ const limitedResults =
+ options.limit && results.length > options.limit
+ ? results.slice(0, options.limit)
+ : results;
+
+ const processedResults = await this.processSearchResultsFromPaths(
+ limitedResults,
+ options,
+ );
+ resolve(processedResults);
+ } catch (processingError) {
+ logger.error('Error processing search results:', processingError);
+ reject(new Error(`Failed to process search results: ${processingError.message}`));
+ }
+ });
+
+ // Handle live update specific logic (if needed in the future, e.g., sending initial batch)
+ if (options.liveUpdate) {
+ // For live update, we might want to resolve an initial batch
+ // or rely purely on events sent elsewhere.
+ // Current implementation resolves when the stream closes.
+ // We could add a timeout to resolve with initial results if needed.
+ logger.debug('Live update enabled, results will be processed on close.');
+ // Note: The previous `executeLiveSearch` logic is now integrated here.
+ // If specific live update event emission is needed, it would be added here,
+ // potentially calling a callback provided in options.
+ }
+ });
+ }
+
+ /**
+ * Check search service status
+ * @returns Promise indicating if Spotlight service is available
+ */
+ async checkSearchServiceStatus(): Promise {
+ return this.checkSpotlightStatus();
+ }
+
+ /**
+ * Update search index
+ * @param path Optional specified path
+ * @returns Promise indicating operation success
+ */
+ async updateSearchIndex(path?: string): Promise {
+ return this.updateSpotlightIndex(path);
+ }
+
+ /**
+ * Build mdfind command string
+ * @param options Search options
+ * @returns Complete command string
+ */
+ private buildSearchCommand(options: SearchOptions): string {
+ // Basic command
+ let command = 'mdfind';
+
+ // Add options
+ const mdFindOptions: string[] = [];
+
+ // macOS mdfind doesn't support -limit parameter, we'll limit results in post-processing
+
+ // Search in specific directory
+ if (options.onlyIn) {
+ mdFindOptions.push(`-onlyin "${options.onlyIn}"`);
+ }
+
+ // Live update
+ if (options.liveUpdate) {
+ mdFindOptions.push('-live');
+ }
+
+ // Detailed metadata
+ if (options.detailed) {
+ mdFindOptions.push(
+ '-attr kMDItemDisplayName kMDItemContentType kMDItemKind kMDItemFSSize kMDItemFSCreationDate kMDItemFSContentChangeDate',
+ );
+ }
+
+ // Build query expression
+ let queryExpression = '';
+
+ // Basic query
+ if (options.keywords) {
+ // If the query string doesn't use Spotlight query syntax (doesn't contain kMDItem properties),
+ // treat it as plain text search
+ if (!options.keywords.includes('kMDItem')) {
+ queryExpression = `"${options.keywords.replaceAll('"', '\\"')}"`;
+ } else {
+ queryExpression = options.keywords;
+ }
+ }
+
+ // File content search
+ if (options.contentContains) {
+ if (queryExpression) {
+ queryExpression = `${queryExpression} && kMDItemTextContent == "*${options.contentContains}*"cd`;
+ } else {
+ queryExpression = `kMDItemTextContent == "*${options.contentContains}*"cd`;
+ }
+ }
+
+ // File type filtering
+ if (options.fileTypes && options.fileTypes.length > 0) {
+ const typeConditions = options.fileTypes
+ .map((type) => `kMDItemContentType == "${type}"`)
+ .join(' || ');
+ if (queryExpression) {
+ queryExpression = `${queryExpression} && (${typeConditions})`;
+ } else {
+ queryExpression = `(${typeConditions})`;
+ }
+ }
+
+ // Date filtering - Modified date
+ if (options.modifiedAfter || options.modifiedBefore) {
+ let dateCondition = '';
+
+ if (options.modifiedAfter) {
+ const dateString = options.modifiedAfter.toISOString().split('T')[0];
+ dateCondition += `kMDItemFSContentChangeDate >= $time.iso(${dateString})`;
+ }
+
+ if (options.modifiedBefore) {
+ if (dateCondition) dateCondition += ' && ';
+ const dateString = options.modifiedBefore.toISOString().split('T')[0];
+ dateCondition += `kMDItemFSContentChangeDate <= $time.iso(${dateString})`;
+ }
+
+ if (queryExpression) {
+ queryExpression = `${queryExpression} && (${dateCondition})`;
+ } else {
+ queryExpression = dateCondition;
+ }
+ }
+
+ // Date filtering - Creation date
+ if (options.createdAfter || options.createdBefore) {
+ let dateCondition = '';
+
+ if (options.createdAfter) {
+ const dateString = options.createdAfter.toISOString().split('T')[0];
+ dateCondition += `kMDItemFSCreationDate >= $time.iso(${dateString})`;
+ }
+
+ if (options.createdBefore) {
+ if (dateCondition) dateCondition += ' && ';
+ const dateString = options.createdBefore.toISOString().split('T')[0];
+ dateCondition += `kMDItemFSCreationDate <= $time.iso(${dateString})`;
+ }
+
+ if (queryExpression) {
+ queryExpression = `${queryExpression} && (${dateCondition})`;
+ } else {
+ queryExpression = dateCondition;
+ }
+ }
+
+ // Combine complete command
+ if (mdFindOptions.length > 0) {
+ command += ' ' + mdFindOptions.join(' ');
+ }
+
+ // Finally add query expression
+ command += ` ${queryExpression}`;
+
+ return command;
+ }
+
+ /**
+ * Execute live search, returns initial results and sets callback
+ * @param command mdfind command
+ * @param options Search options
+ * @returns Promise of initial search results
+ * @deprecated This logic is now integrated into the main search method using spawn.
+ */
+ // private executeLiveSearch(command: string, options: SearchOptions): Promise { ... }
+ // Remove or comment out the old executeLiveSearch method
+
+ /**
+ * Process search results from a list of file paths
+ * @param filePaths Array of file path strings
+ * @param options Search options
+ * @returns Formatted file result list
+ */
+ private async processSearchResultsFromPaths(
+ filePaths: string[],
+ options: SearchOptions,
+ ): Promise {
+ // Create a result object for each file path
+ const resultPromises = filePaths.map(async (filePath) => {
+ try {
+ // Get file information
+ const stats = await statPromise(filePath);
+
+ // Create basic result object
+ const result: FileResult = {
+ createdTime: stats.birthtime,
+ isDirectory: stats.isDirectory(),
+ lastAccessTime: stats.atime,
+ metadata: {},
+ modifiedTime: stats.mtime,
+ name: path.basename(filePath),
+ path: filePath,
+ size: stats.size,
+ type: path.extname(filePath).toLowerCase().replace('.', ''),
+ };
+
+ // If detailed information is needed, get additional metadata
+ if (options.detailed) {
+ result.metadata = await this.getDetailedMetadata(filePath);
+ }
+
+ // Determine content type
+ result.contentType = this.determineContentType(result.name, result.type);
+
+ return result;
+ } catch (error) {
+ logger.warn(`Error processing file stats for ${filePath}: ${error.message}`, error);
+ // Return partial information, even if unable to get complete file stats
+ return {
+ contentType: 'unknown',
+ createdTime: new Date(),
+ isDirectory: false,
+ lastAccessTime: new Date(),
+ modifiedTime: new Date(),
+ name: path.basename(filePath),
+ path: filePath,
+ size: 0,
+ type: path.extname(filePath).toLowerCase().replace('.', ''),
+ };
+ }
+ });
+
+ // Wait for all file information processing to complete
+ let results = await Promise.all(resultPromises);
+
+ // Sort results
+ if (options.sortBy) {
+ results = this.sortResults(results, options.sortBy, options.sortDirection);
+ }
+
+ // Apply limit here as mdfind doesn't support -limit parameter
+ if (options.limit && options.limit > 0 && results.length > options.limit) {
+ results = results.slice(0, options.limit);
+ }
+
+ return results;
+ }
+
+ /**
+ * Process search results
+ * @param stdout Command output (now unused directly, processing happens line by line)
+ * @param options Search options
+ * @returns Formatted file result list
+ * @deprecated Use processSearchResultsFromPaths instead.
+ */
+ // private async processSearchResults(stdout: string, options: SearchOptions): Promise { ... }
+ // Remove or comment out the old processSearchResults method
+
+ /**
+ * Get detailed metadata for a file
+ * @param filePath File path
+ * @returns Metadata object
+ */
+ private async getDetailedMetadata(filePath: string): Promise> {
+ try {
+ // Use mdls command to get all metadata
+ const { stdout } = await execPromise(`mdls "${filePath}"`);
+
+ // Parse mdls output
+ const metadata: Record = {};
+ const lines = stdout.split('\n');
+
+ let currentKey = '';
+ let isMultilineValue = false;
+ let multilineValue: string[] = [];
+
+ for (const line of lines) {
+ if (isMultilineValue) {
+ if (line.includes(')')) {
+ // Multiline value ends
+ multilineValue.push(line.trim());
+ metadata[currentKey] = multilineValue.join(' ');
+ isMultilineValue = false;
+ multilineValue = [];
+ } else {
+ // Continue collecting multiline value
+ multilineValue.push(line.trim());
+ }
+ continue;
+ }
+
+ const match = line.match(/^(\w+)\s+=\s+(.*)$/);
+ if (match) {
+ currentKey = match[1];
+ const value = match[2].trim();
+
+ // Check for multiline value start
+ if (value.includes('(') && !value.includes(')')) {
+ isMultilineValue = true;
+ multilineValue = [value];
+ } else {
+ // Process single line value
+ metadata[currentKey] = this.parseMetadataValue(value);
+ }
+ }
+ }
+
+ return metadata;
+ } catch (error) {
+ logger.warn(`Error getting metadata for ${filePath}: ${error.message}`, error);
+ return {};
+ }
+ }
+
+ /**
+ * Parse metadata value
+ * @param value Metadata raw value string
+ * @returns Parsed value
+ */
+ private parseMetadataValue(input: string): any {
+ let value = input;
+ // Remove quotes from mdls output
+ if (value.startsWith('"') && value.endsWith('"')) {
+ // eslint-disable-next-line unicorn/prefer-string-slice
+ value = value.substring(1, value.length - 1);
+ }
+
+ // Handle special values
+ if (value === '(null)') return null;
+ if (value === 'Yes' || value === 'true') return true;
+ if (value === 'No' || value === 'false') return false;
+
+ // Try to parse date (format like "2023-05-16 14:30:45 +0000")
+ const dateMatch = value.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})$/);
+ if (dateMatch) {
+ try {
+ return new Date(value);
+ } catch {
+ // If date parsing fails, return original string
+ return value;
+ }
+ }
+
+ // Try to parse number
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
+ return Number(value);
+ }
+
+ // Default return string
+ return value;
+ }
+
+ /**
+ * Determine file content type
+ * @param fileName File name
+ * @param extension File extension
+ * @returns Content type description
+ */
+ private determineContentType(fileName: string, extension: string): string {
+ // Map common file extensions to content types
+ const typeMap: Record = {
+ '7z': 'archive',
+ 'aac': 'audio',
+ // Others
+ 'app': 'application',
+ 'avi': 'video',
+ 'c': 'code',
+ 'cpp': 'code',
+ 'css': 'code',
+ 'dmg': 'disk-image',
+ 'doc': 'document',
+ 'docx': 'document',
+ 'gif': 'image',
+ 'gz': 'archive',
+ 'heic': 'image',
+ 'html': 'code',
+ 'iso': 'disk-image',
+ 'java': 'code',
+ 'jpeg': 'image',
+ // Images
+ 'jpg': 'image',
+ // Code
+ 'js': 'code',
+ 'json': 'code',
+ 'mkv': 'video',
+ 'mov': 'video',
+ // Audio
+ 'mp3': 'audio',
+ // Video
+ 'mp4': 'video',
+ 'ogg': 'audio',
+ // Documents
+ 'pdf': 'document',
+ 'png': 'image',
+ 'ppt': 'presentation',
+ 'pptx': 'presentation',
+ 'py': 'code',
+ 'rar': 'archive',
+ 'rtf': 'text',
+ 'svg': 'image',
+ 'swift': 'code',
+ 'tar': 'archive',
+ 'ts': 'code',
+ 'txt': 'text',
+ 'wav': 'audio',
+ 'webp': 'image',
+ 'xls': 'spreadsheet',
+ 'xlsx': 'spreadsheet',
+ // Archive files
+ 'zip': 'archive',
+ };
+
+ // Find matching content type
+ return typeMap[extension.toLowerCase()] || 'unknown';
+ }
+
+ /**
+ * Sort results
+ * @param results Result list
+ * @param sortBy Sort field
+ * @param direction Sort direction
+ * @returns Sorted result list
+ */
+ private sortResults(
+ results: FileResult[],
+ sortBy: 'name' | 'date' | 'size',
+ direction: 'asc' | 'desc' = 'asc',
+ ): FileResult[] {
+ const sortedResults = [...results];
+
+ sortedResults.sort((a, b) => {
+ let comparison = 0;
+
+ switch (sortBy) {
+ case 'name': {
+ comparison = a.name.localeCompare(b.name);
+ break;
+ }
+ case 'date': {
+ comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
+ break;
+ }
+ case 'size': {
+ comparison = a.size - b.size;
+ break;
+ }
+ }
+
+ return direction === 'asc' ? comparison : -comparison;
+ });
+
+ return sortedResults;
+ }
+
+ /**
+ * Check Spotlight service status
+ * @returns Promise indicating if Spotlight is available
+ */
+ private async checkSpotlightStatus(): Promise {
+ try {
+ // Try to run a simple mdfind command - macOS doesn't support -limit parameter
+ await execPromise('mdfind -name test -onlyin ~ -count');
+ return true;
+ } catch (error) {
+ logger.error(`Spotlight is not available: ${error.message}`, error);
+ return false;
+ }
+ }
+
+ /**
+ * Update Spotlight index
+ * @param path Optional specified path
+ * @returns Promise indicating operation success
+ */
+ private async updateSpotlightIndex(path?: string): Promise {
+ try {
+ // mdutil command is used to manage Spotlight index
+ const command = path ? `mdutil -E "${path}"` : 'mdutil -E /';
+
+ await execPromise(command);
+ return true;
+ } catch (error) {
+ logger.error(`Failed to update Spotlight index: ${error.message}`, error);
+ return false;
+ }
+ }
+}
diff --git a/apps/desktop/src/main/modules/fileSearch/index.ts b/apps/desktop/src/main/modules/fileSearch/index.ts
new file mode 100644
index 0000000000..fbe8b9a06d
--- /dev/null
+++ b/apps/desktop/src/main/modules/fileSearch/index.ts
@@ -0,0 +1,23 @@
+import { platform } from 'node:os';
+
+import { MacOSSearchServiceImpl } from './impl/macOS';
+
+export const createFileSearchModule = () => {
+ const currentPlatform = platform();
+
+ switch (currentPlatform) {
+ case 'darwin': {
+ return new MacOSSearchServiceImpl();
+ }
+ // case 'win32':
+ // return new WindowsSearchServiceImpl();
+ // case 'linux':
+ // return new LinuxSearchServiceImpl();
+ default: {
+ return new MacOSSearchServiceImpl();
+ // throw new Error(`Unsupported platform: ${currentPlatform}`);
+ }
+ }
+};
+
+export { FileSearchImpl } from './type';
diff --git a/apps/desktop/src/main/modules/fileSearch/type.ts b/apps/desktop/src/main/modules/fileSearch/type.ts
new file mode 100644
index 0000000000..6ede6322ae
--- /dev/null
+++ b/apps/desktop/src/main/modules/fileSearch/type.ts
@@ -0,0 +1,27 @@
+import { FileResult, SearchOptions } from '@/types/fileSearch';
+
+/**
+ * File Search Service Implementation Abstract Class
+ * Defines the interface that different platform file search implementations need to implement
+ */
+export abstract class FileSearchImpl {
+ /**
+ * Perform file search
+ * @param options Search options
+ * @returns Promise of search result list
+ */
+ abstract search(options: SearchOptions): Promise;
+
+ /**
+ * Check search service status
+ * @returns Promise indicating if service is available
+ */
+ abstract checkSearchServiceStatus(): Promise;
+
+ /**
+ * Update search index
+ * @param path Optional specified path
+ * @returns Promise indicating operation success
+ */
+ abstract updateSearchIndex(path?: string): Promise;
+}
diff --git a/apps/desktop/src/main/modules/updater/configs.ts b/apps/desktop/src/main/modules/updater/configs.ts
new file mode 100644
index 0000000000..1c4aa3928c
--- /dev/null
+++ b/apps/desktop/src/main/modules/updater/configs.ts
@@ -0,0 +1,22 @@
+import { isDev } from '@/const/env';
+
+// 更新频道(stable, beta, alpha 等)
+export const UPDATE_CHANNEL = process.env.UPDATE_CHANNEL;
+
+export const updaterConfig = {
+ // 应用更新配置
+ app: {
+ // 是否自动检查更新
+ autoCheckUpdate: true,
+ // 是否自动下载更新
+ autoDownloadUpdate: true,
+ // 检查更新的时间间隔(毫秒)
+ checkUpdateInterval: 60 * 60 * 1000, // 1小时
+ },
+
+ // 是否启用应用更新
+ enableAppUpdate: !isDev,
+
+ // 是否启用渲染层热更新
+ enableRenderHotUpdate: !isDev,
+};
diff --git a/apps/desktop/src/main/modules/updater/utils.ts b/apps/desktop/src/main/modules/updater/utils.ts
new file mode 100644
index 0000000000..e358896992
--- /dev/null
+++ b/apps/desktop/src/main/modules/updater/utils.ts
@@ -0,0 +1,33 @@
+import semver from 'semver';
+
+/**
+ * 判断是否需要应用更新而非仅渲染层更新
+ * @param currentVersion 当前版本
+ * @param nextVersion 新版本
+ * @returns 是否需要应用更新
+ */
+export const shouldUpdateApp = (currentVersion: string, nextVersion: string): boolean => {
+ // 如果版本号包含 .app 后缀,强制进行应用更新
+ if (nextVersion.includes('.app')) {
+ return true;
+ }
+
+ try {
+ // 解析版本号
+ const current = semver.parse(currentVersion);
+ const next = semver.parse(nextVersion);
+
+ if (!current || !next) return true;
+
+ // 主版本号或次版本号变更时,需要进行应用更新
+ if (current.major !== next.major || current.minor !== next.minor) {
+ return true;
+ }
+
+ // 仅修订版本号变更,优先进行渲染层热更新
+ return false;
+ } catch {
+ // 解析失败时,默认进行应用更新
+ return true;
+ }
+};
diff --git a/apps/desktop/src/main/services/fileSearchSrv.ts b/apps/desktop/src/main/services/fileSearchSrv.ts
new file mode 100644
index 0000000000..a631e88981
--- /dev/null
+++ b/apps/desktop/src/main/services/fileSearchSrv.ts
@@ -0,0 +1,35 @@
+import { FileSearchImpl, createFileSearchModule } from '@/modules/fileSearch';
+import { FileResult, SearchOptions } from '@/types/fileSearch';
+
+import { ServiceModule } from './index';
+
+/**
+ * File Search Service
+ * Main service class that uses platform-specific implementations internally
+ */
+export default class FileSearchService extends ServiceModule {
+ private impl: FileSearchImpl = createFileSearchModule();
+
+ /**
+ * Perform file search
+ */
+ async search(query: string, options: Omit = {}): Promise {
+ return this.impl.search({ ...options, keywords: query });
+ }
+
+ /**
+ * Check search service status
+ */
+ async checkSearchServiceStatus(): Promise {
+ return this.impl.checkSearchServiceStatus();
+ }
+
+ /**
+ * Update search index
+ * @param path Optional specified path
+ * @returns Promise indicating operation success
+ */
+ async updateSearchIndex(path?: string): Promise {
+ return this.impl.updateSearchIndex(path);
+ }
+}
diff --git a/apps/desktop/src/main/services/fileSrv.ts b/apps/desktop/src/main/services/fileSrv.ts
new file mode 100644
index 0000000000..4f4474db5f
--- /dev/null
+++ b/apps/desktop/src/main/services/fileSrv.ts
@@ -0,0 +1,255 @@
+import { DeleteFilesResponse } from '@lobechat/electron-server-ipc';
+import * as fs from 'node:fs';
+import { writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { promisify } from 'node:util';
+
+import { FILE_STORAGE_DIR } from '@/const/dir';
+import { makeSureDirExist } from '@/utils/file-system';
+
+import { ServiceModule } from './index';
+
+const readFilePromise = promisify(fs.readFile);
+const unlinkPromise = promisify(fs.unlink);
+
+interface UploadFileParams {
+ content: ArrayBuffer;
+ filename: string;
+ hash: string;
+ path: string;
+ type: string;
+}
+
+interface FileMetadata {
+ date: string;
+ dirname: string;
+ filename: string;
+ path: string;
+}
+
+export default class FileService extends ServiceModule {
+ get UPLOADS_DIR() {
+ return join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads');
+ }
+
+ constructor(app) {
+ super(app);
+
+ // 初始化文件存储目录
+ makeSureDirExist(this.UPLOADS_DIR);
+ }
+
+ /**
+ * 上传文件到本地存储
+ */
+ async uploadFile({
+ content,
+ filename,
+ hash,
+ type,
+ }: UploadFileParams): Promise<{ metadata: FileMetadata; success: boolean }> {
+ try {
+ // 创建时间戳目录
+ const date = (Date.now() / 1000 / 60 / 60).toFixed(0);
+ const dirname = join(this.UPLOADS_DIR, date);
+ makeSureDirExist(dirname);
+
+ // 生成文件保存路径
+ const fileExt = filename.split('.').pop() || '';
+ const savedFilename = `${hash}${fileExt ? `.${fileExt}` : ''}`;
+ const savedPath = join(dirname, savedFilename);
+
+ // 写入文件内容
+ const buffer = Buffer.from(content);
+ await writeFile(savedPath, buffer);
+
+ // 写入元数据文件
+ const metaFilePath = `${savedPath}.meta`;
+ const metadata = {
+ createdAt: Date.now(),
+ filename,
+ hash,
+ size: buffer.length,
+ type,
+ };
+ await writeFile(metaFilePath, JSON.stringify(metadata, null, 2));
+
+ // 返回与S3兼容的元数据格式
+ const desktopPath = `desktop://${date}/${savedFilename}`;
+
+ return {
+ metadata: {
+ date,
+ dirname: date,
+ filename: savedFilename,
+ path: desktopPath,
+ },
+ success: true,
+ };
+ } catch (error) {
+ console.error('File upload failed:', error);
+ throw new Error(`File upload failed: ${(error as Error).message}`);
+ }
+ }
+
+ /**
+ * 获取文件内容
+ */
+ async getFile(path: string): Promise<{ content: ArrayBuffer; mimeType: string }> {
+ try {
+ // 处理desktop://路径
+ if (!path.startsWith('desktop://')) {
+ throw new Error(`Invalid desktop file path: ${path}`);
+ }
+
+ // 标准化路径格式
+ // 可能收到的格式: desktop:/12345/file.png 或 desktop://12345/file.png
+ const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://');
+
+ // 解析路径
+ const relativePath = normalizedPath.replace('desktop://', '');
+ const filePath = join(this.UPLOADS_DIR, relativePath);
+
+ console.log('Reading file from:', filePath);
+
+ // 读取文件内容
+ const content = await readFilePromise(filePath);
+
+ // 读取元数据获取MIME类型
+ const metaFilePath = `${filePath}.meta`;
+ let mimeType = 'application/octet-stream'; // 默认MIME类型
+
+ try {
+ const metaContent = await readFilePromise(metaFilePath, 'utf8');
+ const metadata = JSON.parse(metaContent);
+ mimeType = metadata.type || mimeType;
+ } catch (metaError) {
+ console.warn(`Failed to read metadata file: ${metaError.message}, using default MIME type`);
+ // 如果元数据文件不存在,尝试从文件扩展名猜测MIME类型
+ const ext = path.split('.').pop()?.toLowerCase();
+ if (ext) {
+ if (['jpg', 'jpeg'].includes(ext)) mimeType = 'image/jpeg';
+ else
+ switch (ext) {
+ case 'png': {
+ mimeType = 'image/png';
+ break;
+ }
+ case 'gif': {
+ mimeType = 'image/gif';
+ break;
+ }
+ case 'webp': {
+ mimeType = 'image/webp';
+ break;
+ }
+ case 'svg': {
+ mimeType = 'image/svg+xml';
+ break;
+ }
+ case 'pdf': {
+ {
+ mimeType = 'application/pdf';
+ // No default
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ return {
+ content: content.buffer as ArrayBuffer,
+ mimeType,
+ };
+ } catch (error) {
+ console.error('File retrieval failed:', error);
+ throw new Error(`File retrieval failed: ${(error as Error).message}`);
+ }
+ }
+
+ /**
+ * 删除文件
+ */
+ async deleteFile(path: string): Promise<{ success: boolean }> {
+ try {
+ // 处理desktop://路径
+ if (!path.startsWith('desktop://')) {
+ throw new Error(`Invalid desktop file path: ${path}`);
+ }
+
+ // 解析路径
+ const relativePath = path.replace('desktop://', '');
+ const filePath = join(this.UPLOADS_DIR, relativePath);
+
+ // 删除文件及其元数据
+ await unlinkPromise(filePath);
+
+ // 尝试删除元数据文件,但不强制要求存在
+ try {
+ await unlinkPromise(`${filePath}.meta`);
+ } catch (error) {
+ console.warn(`Failed to delete metadata file: ${(error as Error).message}`);
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('File deletion failed:', error);
+ throw new Error(`File deletion failed: ${(error as Error).message}`);
+ }
+ }
+
+ /**
+ * 批量删除文件
+ */
+ async deleteFiles(paths: string[]): Promise {
+ const errors: { message: string; path: string }[] = [];
+
+ // 并行处理所有删除请求
+ const results = await Promise.allSettled(
+ paths.map(async (path) => {
+ try {
+ await this.deleteFile(path);
+ return { path, success: true };
+ } catch (error) {
+ return {
+ error: (error as Error).message,
+ path,
+ success: false,
+ };
+ }
+ }),
+ );
+
+ // 处理结果
+ results.forEach((result) => {
+ if (result.status === 'rejected') {
+ errors.push({
+ message: `Unexpected error: ${result.reason}`,
+ path: 'unknown',
+ });
+ } else if (!result.value.success) {
+ errors.push({
+ message: result.value.error,
+ path: result.value.path,
+ });
+ }
+ });
+
+ return {
+ success: errors.length === 0,
+ ...(errors.length > 0 && { errors }),
+ };
+ }
+
+ async getFilePath(path: string): Promise {
+ // 处理desktop://路径
+ if (!path.startsWith('desktop://')) {
+ throw new Error(`Invalid desktop file path: ${path}`);
+ }
+
+ // 解析路径
+ const relativePath = path.replace('desktop://', '');
+ return join(this.UPLOADS_DIR, relativePath);
+ }
+}
diff --git a/apps/desktop/src/main/services/index.ts b/apps/desktop/src/main/services/index.ts
new file mode 100644
index 0000000000..7e15690273
--- /dev/null
+++ b/apps/desktop/src/main/services/index.ts
@@ -0,0 +1,9 @@
+import type { App } from '../core/App';
+
+export class ServiceModule {
+ constructor(public app: App) {
+ this.app = app;
+ }
+}
+
+export type IServiceModule = typeof ServiceModule;
diff --git a/apps/desktop/src/main/shortcuts/config.ts b/apps/desktop/src/main/shortcuts/config.ts
new file mode 100644
index 0000000000..36fd5f5827
--- /dev/null
+++ b/apps/desktop/src/main/shortcuts/config.ts
@@ -0,0 +1,18 @@
+/**
+ * 快捷键操作类型枚举
+ */
+export const ShortcutActionEnum = {
+ /**
+ * 显示/隐藏主窗口
+ */
+ toggleMainWindow: 'toggleMainWindow',
+} as const;
+
+export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof ShortcutActionEnum];
+
+/**
+ * 默认快捷键配置
+ */
+export const DEFAULT_SHORTCUTS_CONFIG: Record = {
+ [ShortcutActionEnum.toggleMainWindow]: 'CommandOrControl+E',
+};
diff --git a/apps/desktop/src/main/shortcuts/index.ts b/apps/desktop/src/main/shortcuts/index.ts
new file mode 100644
index 0000000000..f03c2281a9
--- /dev/null
+++ b/apps/desktop/src/main/shortcuts/index.ts
@@ -0,0 +1 @@
+export * from './config';
diff --git a/apps/desktop/src/main/types/fileSearch.ts b/apps/desktop/src/main/types/fileSearch.ts
new file mode 100644
index 0000000000..fcc3d54e05
--- /dev/null
+++ b/apps/desktop/src/main/types/fileSearch.ts
@@ -0,0 +1,51 @@
+export interface FileResult {
+ contentType?: string;
+ createdTime: Date;
+ isDirectory: boolean;
+ lastAccessTime: Date;
+ // Spotlight specific metadata
+ metadata?: {
+ [key: string]: any;
+ };
+ modifiedTime: Date;
+ name: string;
+ path: string;
+ size: number;
+ type: string;
+}
+
+export interface SearchOptions {
+ // Directory options
+ // Content options
+ contentContains?: string;
+ // Created after specific date
+ createdAfter?: Date;
+
+ // Created before specific date
+ createdBefore?: Date;
+ // Whether to return detailed results
+ detailed?: boolean;
+
+ // Limit search to specific directories
+ exclude?: string[]; // Files containing specific content
+
+ // File type options
+ fileTypes?: string[];
+
+ // Basic options
+ keywords: string;
+ limit?: number;
+ // Created before specific date
+ // Advanced options
+ liveUpdate?: boolean;
+ // File type filters, like "public.image", "public.movie"
+ // Time options
+ modifiedAfter?: Date;
+
+ // Modified after specific date
+ modifiedBefore?: Date;
+ // Path options
+ onlyIn?: string; // Whether to return detailed metadata
+ sortBy?: 'name' | 'date' | 'size'; // Result sorting
+ sortDirection?: 'asc' | 'desc'; // Sort direction
+}
diff --git a/apps/desktop/src/main/types/store.ts b/apps/desktop/src/main/types/store.ts
new file mode 100644
index 0000000000..df04280181
--- /dev/null
+++ b/apps/desktop/src/main/types/store.ts
@@ -0,0 +1,14 @@
+import { DataSyncConfig } from '@lobechat/electron-client-ipc';
+
+export interface ElectronMainStore {
+ dataSyncConfig: DataSyncConfig;
+ encryptedTokens: {
+ accessToken?: string;
+ refreshToken?: string;
+ };
+ locale: string;
+ shortcuts: Record;
+ storagePath: string;
+}
+
+export type StoreKey = keyof ElectronMainStore;
diff --git a/apps/desktop/src/main/utils/file-system.ts b/apps/desktop/src/main/utils/file-system.ts
new file mode 100644
index 0000000000..eb8f4a50e5
--- /dev/null
+++ b/apps/desktop/src/main/utils/file-system.ts
@@ -0,0 +1,15 @@
+import { mkdirSync, statSync } from 'node:fs';
+
+export const makeSureDirExist = (dir: string) => {
+ try {
+ statSync(dir);
+ } catch {
+ // 使用 recursive: true,如果目录已存在则此操作无效果,如果不存在则创建
+ try {
+ mkdirSync(dir, { recursive: true });
+ } catch (mkdirError: any) {
+ // 如果创建目录失败(例如权限问题),则抛出错误
+ throw new Error(`Could not create target directory: ${dir}. Error: ${mkdirError.message}`);
+ }
+ }
+};
diff --git a/apps/desktop/src/main/utils/logger.ts b/apps/desktop/src/main/utils/logger.ts
new file mode 100644
index 0000000000..679c3db8e2
--- /dev/null
+++ b/apps/desktop/src/main/utils/logger.ts
@@ -0,0 +1,44 @@
+import debug from 'debug';
+import electronLog from 'electron-log';
+
+// 配置 electron-log
+electronLog.transports.file.level = 'info'; // 生产环境记录 info 及以上级别
+electronLog.transports.console.level =
+ process.env.NODE_ENV === 'development'
+ ? 'debug' // 开发环境显示更多日志
+ : 'warn'; // 生产环境只显示警告和错误
+
+// 创建命名空间调试器
+export const createLogger = (namespace: string) => {
+ const debugLogger = debug(namespace);
+
+ return {
+ debug: (message, ...args) => {
+ debugLogger(message, ...args);
+ },
+ error: (message, ...args) => {
+ if (process.env.NODE_ENV === 'production') {
+ electronLog.error(message, ...args);
+ }
+ debugLogger(`ERROR: ${message}`, ...args);
+ },
+ info: (message, ...args) => {
+ if (process.env.NODE_ENV === 'production') {
+ electronLog.info(message, ...args);
+ }
+ debugLogger(`INFO: ${message}`, ...args);
+ },
+ verbose: (message, ...args) => {
+ electronLog.verbose(message, ...args);
+ if (process.env.DEBUG_VERBOSE) {
+ debugLogger(`VERBOSE: ${message}`, ...args);
+ }
+ },
+ warn: (message, ...args) => {
+ if (process.env.NODE_ENV === 'production') {
+ electronLog.warn(message, ...args);
+ }
+ debugLogger(`WARN: ${message}`, ...args);
+ },
+ };
+};
diff --git a/apps/desktop/src/main/utils/next-electron-rsc.ts b/apps/desktop/src/main/utils/next-electron-rsc.ts
new file mode 100644
index 0000000000..2b9ed42012
--- /dev/null
+++ b/apps/desktop/src/main/utils/next-electron-rsc.ts
@@ -0,0 +1,383 @@
+// copy from https://github.com/kirill-konshin/next-electron-rsc
+import { serialize as serializeCookie } from 'cookie';
+import type { Protocol, Session } from 'electron';
+import type { NextConfig } from 'next';
+import type NextNodeServer from 'next/dist/server/next-server';
+import assert from 'node:assert';
+import { IncomingMessage, ServerResponse } from 'node:http';
+import { Socket } from 'node:net';
+import path from 'node:path';
+import { parse } from 'node:url';
+import resolve from 'resolve';
+import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser';
+
+import { isDev } from '@/const/env';
+import { createLogger } from '@/utils/logger';
+
+// 创建日志记录器
+const logger = createLogger('utils:next-electron-rsc');
+
+// 定义自定义处理器类型
+export type CustomRequestHandler = (request: Request) => Promise;
+
+export const createRequest = async ({
+ socket,
+ request,
+ session,
+}: {
+ request: Request;
+ session: Session;
+ socket: Socket;
+}): Promise => {
+ const req = new IncomingMessage(socket);
+
+ const url = new URL(request.url);
+
+ // Normal Next.js URL does not contain schema and host/port, otherwise endless loops due to butchering of schema by normalizeRepeatedSlashes in resolve-routes
+ req.url = url.pathname + (url.search || '');
+ req.method = request.method;
+
+ request.headers.forEach((value, key) => {
+ req.headers[key] = value;
+ });
+
+ try {
+ // @see https://github.com/electron/electron/issues/39525#issue-1852825052
+ const cookies = await session.cookies.get({
+ url: request.url,
+ // domain: url.hostname,
+ // path: url.pathname,
+ // `secure: true` Cookies should not be sent via http
+ // secure: url.protocol === 'http:' ? false : undefined,
+ // theoretically not possible to implement sameSite because we don't know the url
+ // of the website that is requesting the resource
+ });
+
+ if (cookies.length) {
+ const cookiesHeader = [];
+
+ for (const cookie of cookies) {
+ const { name, value } = cookie;
+ cookiesHeader.push(serializeCookie(name, value)); // ...(options as any)?
+ }
+
+ req.headers.cookie = cookiesHeader.join('; ');
+ }
+ } catch (e) {
+ throw new Error('Failed to parse cookies', { cause: e });
+ }
+
+ if (request.body) {
+ req.push(Buffer.from(await request.arrayBuffer()));
+ }
+
+ req.push(null);
+ req.complete = true;
+
+ return req;
+};
+
+export class ReadableServerResponse extends ServerResponse {
+ private responsePromise: Promise;
+
+ constructor(req: IncomingMessage) {
+ super(req);
+
+ this.responsePromise = new Promise((resolve) => {
+ const readableStream = new ReadableStream({
+ cancel: () => {},
+ pull: () => {
+ this.emit('drain');
+ },
+ start: (controller) => {
+ let onData;
+
+ this.on(
+ 'data',
+ (onData = (chunk) => {
+ controller.enqueue(chunk);
+ }),
+ );
+
+ this.once('end', (chunk) => {
+ controller.enqueue(chunk);
+ controller.close();
+ this.off('data', onData);
+ });
+ },
+ });
+
+ this.once('writeHead', (statusCode) => {
+ resolve(
+ new Response(readableStream, {
+ headers: this.getHeaders() as any,
+ status: statusCode,
+ statusText: this.statusMessage,
+ }),
+ );
+ });
+ });
+ }
+
+ write(chunk: any, ...args): boolean {
+ this.emit('data', chunk);
+ return super.write(chunk, ...args);
+ }
+
+ end(chunk: any, ...args): this {
+ this.emit('end', chunk);
+ return super.end(chunk, ...args);
+ }
+
+ writeHead(statusCode: number, ...args: any): this {
+ this.emit('writeHead', statusCode);
+ return super.writeHead(statusCode, ...args);
+ }
+
+ getResponse() {
+ return this.responsePromise;
+ }
+}
+
+/**
+ * https://nextjs.org/docs/pages/building-your-application/configuring/custom-server
+ * https://github.com/vercel/next.js/pull/68167/files#diff-d0d8b7158bcb066cdbbeb548a29909fe8dc4e98f682a6d88654b1684e523edac
+ * https://github.com/vercel/next.js/blob/canary/examples/custom-server/server.ts
+ *
+ * @param {string} standaloneDir
+ * @param {string} localhostUrl
+ * @param {import('electron').Protocol} protocol
+ * @param {boolean} debug
+ */
+export function createHandler({
+ standaloneDir,
+ localhostUrl,
+ protocol,
+ debug = false,
+}: {
+ debug?: boolean;
+ localhostUrl: string;
+ protocol: Protocol;
+ standaloneDir: string;
+}) {
+ assert(standaloneDir, 'standaloneDir is required');
+ assert(protocol, 'protocol is required');
+
+ // 存储自定义请求处理器的数组
+ const customHandlers: CustomRequestHandler[] = [];
+
+ // 注册自定义请求处理器的方法 - 在开发和生产环境中都提供此功能
+ function registerCustomHandler(handler: CustomRequestHandler) {
+ logger.debug('Registering custom request handler');
+ customHandlers.push(handler);
+ return () => {
+ const index = customHandlers.indexOf(handler);
+ if (index !== -1) {
+ logger.debug('Unregistering custom request handler');
+ customHandlers.splice(index, 1);
+ }
+ };
+ }
+ let registerProtocolHandle = false;
+
+ protocol.registerSchemesAsPrivileged([
+ {
+ privileges: {
+ secure: true,
+ standard: true,
+ supportFetchAPI: true,
+ },
+ scheme: 'http',
+ },
+ ]);
+ logger.debug('Registered HTTP scheme as privileged');
+
+ // 初始化 Next.js 应用(仅在生产环境中使用)
+ let app: NextNodeServer | null = null;
+ let handler: any = null;
+ let preparePromise: Promise | null = null;
+
+ if (!isDev) {
+ logger.info('Initializing Next.js app for production');
+ const next = require(resolve.sync('next', { basedir: standaloneDir }));
+
+ // @see https://github.com/vercel/next.js/issues/64031#issuecomment-2078708340
+ const config = require(path.join(standaloneDir, '.next', 'required-server-files.json'))
+ .config as NextConfig;
+ process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
+
+ app = next({
+ dev: false,
+ dir: standaloneDir,
+ }) as NextNodeServer;
+
+ handler = app.getRequestHandler();
+ preparePromise = app.prepare();
+ } else {
+ logger.debug('Starting in development mode');
+ }
+
+ // 通用的请求处理函数 - 开发和生产环境共用
+ const handleRequest = async (
+ request: Request,
+ session: Session,
+ socket: Socket,
+ ): Promise => {
+ try {
+ // 先尝试使用自定义处理器处理请求
+ for (const customHandler of customHandlers) {
+ try {
+ const response = await customHandler(request);
+ if (response) {
+ if (debug) logger.debug(`Custom handler processed: ${request.url}`);
+ return response;
+ }
+ } catch (error) {
+ if (debug) logger.error(`Custom handler error: ${error}`);
+ // 继续尝试下一个处理器
+ }
+ }
+
+ // 创建 Node.js 请求对象
+ const req = await createRequest({ request, session, socket });
+ // 创建可读取响应的 Response 对象
+ const res = new ReadableServerResponse(req);
+
+ if (isDev) {
+ // 开发环境:转发请求到开发服务器
+ if (debug) logger.debug(`Forwarding request to dev server: ${request.url}`);
+
+ // 修改 URL 以指向开发服务器
+ const devUrl = new URL(req.url, localhostUrl);
+
+ // 使用 node:http 模块发送请求到开发服务器
+ const http = require('node:http');
+ const devReq = http.request(
+ {
+ headers: req.headers,
+ hostname: devUrl.hostname,
+ method: req.method,
+ path: devUrl.pathname + (devUrl.search || ''),
+ port: devUrl.port,
+ },
+ (devRes) => {
+ // 设置响应状态码和头部
+ res.statusCode = devRes.statusCode;
+ res.statusMessage = devRes.statusMessage;
+
+ // 复制响应头
+ Object.keys(devRes.headers).forEach((key) => {
+ res.setHeader(key, devRes.headers[key]);
+ });
+
+ // 流式传输响应内容
+ devRes.pipe(res);
+ },
+ );
+
+ // 处理错误
+ devReq.on('error', (err) => {
+ if (debug) logger.error(`Error forwarding request: ${err}`);
+ });
+
+ // 传输请求体
+ req.pipe(devReq);
+ } else {
+ // 生产环境:使用 Next.js 处理请求
+ if (debug) logger.debug(`Processing with Next.js handler: ${request.url}`);
+
+ // 确保 Next.js 已准备就绪
+ if (preparePromise) await preparePromise;
+
+ const url = parse(req.url, true);
+ handler(req, res, url);
+ }
+
+ // 获取 Response 对象
+ const response = await res.getResponse();
+
+ // 处理 cookies(两种环境通用处理)
+ try {
+ const cookies = parseCookie(
+ response.headers.getSetCookie().reduce((r, c) => {
+ return [...r, ...splitCookiesString(c)];
+ }, []),
+ );
+
+ for (const cookie of cookies) {
+ const expires = cookie.expires
+ ? cookie.expires.getTime()
+ : cookie.maxAge
+ ? Date.now() + cookie.maxAge * 1000
+ : undefined;
+
+ if (expires && expires < Date.now()) {
+ await session.cookies.remove(request.url, cookie.name);
+ continue;
+ }
+
+ await session.cookies.set({
+ domain: cookie.domain,
+ expirationDate: expires,
+ httpOnly: cookie.httpOnly,
+ name: cookie.name,
+ path: cookie.path,
+ secure: cookie.secure,
+ url: request.url,
+ value: cookie.value,
+ } as any);
+ }
+ } catch (e) {
+ logger.error('Failed to set cookies', e);
+ }
+
+ if (debug) logger.debug(`Request processed: ${request.url}, status: ${response.status}`);
+ return response;
+ } catch (e) {
+ if (debug) logger.error(`Error handling request: ${e}`);
+ return new Response(e.message, { status: 500 });
+ }
+ };
+
+ // 创建拦截器函数
+ const createInterceptor = ({ session }: { session: Session }) => {
+ assert(session, 'Session is required');
+ logger.debug(
+ `Creating interceptor with session in ${isDev ? 'development' : 'production'} mode`,
+ );
+
+ const socket = new Socket();
+
+ const closeSocket = () => socket.end();
+
+ process.on('SIGTERM', () => closeSocket);
+ process.on('SIGINT', () => closeSocket);
+
+ if (!isDev && !registerProtocolHandle) {
+ logger.debug(
+ `Registering HTTP protocol handler in ${isDev ? 'development' : 'production'} mode`,
+ );
+ protocol.handle('http', async (request) => {
+ if (!isDev) {
+ assert(request.url.startsWith(localhostUrl), 'External HTTP not supported, use HTTPS');
+ }
+
+ return handleRequest(request, session, socket);
+ });
+ registerProtocolHandle = true;
+ }
+
+ return function stopIntercept() {
+ if (registerProtocolHandle) {
+ logger.debug('Unregistering HTTP protocol handler');
+ protocol.unhandle('http');
+ registerProtocolHandle = false;
+ }
+ process.off('SIGTERM', () => closeSocket);
+ process.off('SIGINT', () => closeSocket);
+ closeSocket();
+ };
+ };
+
+ return { createInterceptor, registerCustomHandler };
+}
diff --git a/apps/desktop/src/preload/electronApi.ts b/apps/desktop/src/preload/electronApi.ts
new file mode 100644
index 0000000000..2d870383f6
--- /dev/null
+++ b/apps/desktop/src/preload/electronApi.ts
@@ -0,0 +1,18 @@
+import { electronAPI } from '@electron-toolkit/preload';
+import { contextBridge } from 'electron';
+
+import { invoke } from './invoke';
+
+export const setupElectronApi = () => {
+ // Use `contextBridge` APIs to expose Electron APIs to
+ // renderer only if context isolation is enabled, otherwise
+ // just add to the DOM global.
+
+ try {
+ contextBridge.exposeInMainWorld('electron', electronAPI);
+ } catch (error) {
+ console.error(error);
+ }
+
+ contextBridge.exposeInMainWorld('electronAPI', { invoke });
+};
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
new file mode 100644
index 0000000000..064996c86e
--- /dev/null
+++ b/apps/desktop/src/preload/index.ts
@@ -0,0 +1,14 @@
+import { setupElectronApi } from './electronApi';
+import { setupRouteInterceptors } from './routeInterceptor';
+
+const setupPreload = () => {
+ setupElectronApi();
+
+ // 设置路由拦截逻辑
+ window.addEventListener('DOMContentLoaded', () => {
+ // 设置客户端路由拦截器
+ setupRouteInterceptors();
+ });
+};
+
+setupPreload();
diff --git a/apps/desktop/src/preload/invoke.ts b/apps/desktop/src/preload/invoke.ts
new file mode 100644
index 0000000000..d7779c4ef5
--- /dev/null
+++ b/apps/desktop/src/preload/invoke.ts
@@ -0,0 +1,10 @@
+import { ClientDispatchEventKey, DispatchInvoke } from '@lobechat/electron-client-ipc';
+import { ipcRenderer } from 'electron';
+
+/**
+ * client 端请求 electron main 端方法
+ */
+export const invoke: DispatchInvoke = async (
+ event: T,
+ ...data: any[]
+) => ipcRenderer.invoke(event, ...data);
diff --git a/apps/desktop/src/preload/routeInterceptor.ts b/apps/desktop/src/preload/routeInterceptor.ts
new file mode 100644
index 0000000000..7dac284f2d
--- /dev/null
+++ b/apps/desktop/src/preload/routeInterceptor.ts
@@ -0,0 +1,162 @@
+import { findMatchingRoute } from '~common/routes';
+
+import { invoke } from './invoke';
+
+const interceptRoute = async (
+ path: string,
+ source: 'link-click' | 'push-state' | 'replace-state',
+ url: string,
+) => {
+ console.log(`[preload] Intercepted ${source} and prevented default behavior:`, path);
+
+ // 使用electron-client-ipc的dispatch方法
+ try {
+ await invoke('interceptRoute', { path, source, url });
+ } catch (e) {
+ console.error(`[preload] Route interception (${source}) call failed`, e);
+ }
+};
+/**
+ * 路由拦截器 - 负责捕获和拦截客户端路由导航
+ */
+export const setupRouteInterceptors = function () {
+ console.log('[preload] Setting up route interceptors');
+
+ // 存储被阻止的路径,避免pushState重复触发
+ const preventedPaths = new Set();
+
+ // 拦截所有a标签的点击事件 - 针对Next.js的Link组件
+ document.addEventListener(
+ 'click',
+ async (e) => {
+ const link = (e.target as HTMLElement).closest('a');
+ if (link && link.href) {
+ try {
+ const url = new URL(link.href);
+
+ // 使用共享配置检查是否需要拦截
+ const matchedRoute = findMatchingRoute(url.pathname);
+
+ // 如果是需要拦截的路径,立即阻止默认行为
+ if (matchedRoute) {
+ // 检查当前页面是否已经在目标路径下,如果是则不拦截
+ const currentPath = window.location.pathname;
+ const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix);
+
+ // 如果已经在目标页面下,则不拦截,让默认导航继续
+ if (isAlreadyInTargetPage) return;
+
+ // 立即阻止默认行为,避免Next.js接管路由
+ e.preventDefault();
+ e.stopPropagation();
+
+ await interceptRoute(url.pathname, 'link-click', link.href);
+
+ return false;
+ }
+ } catch (err) {
+ console.error('[preload] Link interception error:', err);
+ }
+ }
+ },
+ true,
+ );
+
+ // 拦截 history API (用于捕获Next.js的useRouter().push/replace等)
+ const originalPushState = history.pushState;
+ const originalReplaceState = history.replaceState;
+
+ // 重写pushState
+ history.pushState = function () {
+ const url = arguments[2];
+ if (typeof url === 'string') {
+ try {
+ // 只处理相对路径或当前域的URL
+ const parsedUrl = new URL(url, window.location.origin);
+
+ // 使用共享配置检查是否需要拦截
+ const matchedRoute = findMatchingRoute(parsedUrl.pathname);
+
+ // 检查是否需要拦截这个导航
+ if (matchedRoute) {
+ // 检查当前页面是否已经在目标路径下,如果是则不拦截
+ const currentPath = window.location.pathname;
+ const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix);
+
+ // 如果已经在目标页面下,则不拦截,让默认导航继续
+ if (isAlreadyInTargetPage) {
+ console.log(
+ `[preload] Skip pushState interception for ${parsedUrl.pathname} because already in target page ${matchedRoute.pathPrefix}`,
+ );
+ return Reflect.apply(originalPushState, this, arguments);
+ }
+
+ // 将此路径添加到已阻止集合中
+ preventedPaths.add(parsedUrl.pathname);
+
+ interceptRoute(parsedUrl.pathname, 'push-state', parsedUrl.href);
+
+ // 不执行原始的pushState操作,阻止导航发生
+ // 但返回undefined以避免错误
+ return;
+ }
+ } catch (err) {
+ console.error('[preload] pushState interception error:', err);
+ }
+ }
+ return Reflect.apply(originalPushState, this, arguments);
+ };
+
+ // 重写replaceState
+ history.replaceState = function () {
+ const url = arguments[2];
+ if (typeof url === 'string') {
+ try {
+ const parsedUrl = new URL(url, window.location.origin);
+
+ // 使用共享配置检查是否需要拦截
+ const matchedRoute = findMatchingRoute(parsedUrl.pathname);
+
+ // 检查是否需要拦截这个导航
+ if (matchedRoute) {
+ // 检查当前页面是否已经在目标路径下,如果是则不拦截
+ const currentPath = window.location.pathname;
+ const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix);
+
+ // 如果已经在目标页面下,则不拦截,让默认导航继续
+ if (isAlreadyInTargetPage) {
+ console.log(
+ `[preload] Skip replaceState interception for ${parsedUrl.pathname} because already in target page ${matchedRoute.pathPrefix}`,
+ );
+ return Reflect.apply(originalReplaceState, this, arguments);
+ }
+
+ // 添加到已阻止集合
+ preventedPaths.add(parsedUrl.pathname);
+
+ interceptRoute(parsedUrl.pathname, 'replace-state', parsedUrl.href);
+
+ // 阻止导航
+ return;
+ }
+ } catch (err) {
+ console.error('[preload] replaceState interception error:', err);
+ }
+ }
+ return Reflect.apply(originalReplaceState, this, arguments);
+ };
+
+ // 监听并拦截路由错误 - 有时Next.js会在路由错误时尝试恢复导航
+ window.addEventListener(
+ 'error',
+ function (e) {
+ if (e.message && e.message.includes('navigation') && preventedPaths.size > 0) {
+ console.log('[preload] Captured possible routing error, preventing default behavior');
+ e.preventDefault();
+ }
+ },
+ true,
+ );
+
+ console.log('[preload] Route interceptors setup completed');
+};
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
new file mode 100644
index 0000000000..0875f8ddf4
--- /dev/null
+++ b/apps/desktop/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "target": "ESNext",
+ "esModuleInterop": true,
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/main/*"],
+ "~common/*": ["src/common/*"]
+ }
+ },
+ "include": ["src/main/**/*", "src/preload/**/*", "electron-builder.js"]
+}
diff --git a/packages/electron-client-ipc/src/events/remoteServer.ts b/packages/electron-client-ipc/src/events/remoteServer.ts
index 3d4e0b46d4..7ab92f4f18 100644
--- a/packages/electron-client-ipc/src/events/remoteServer.ts
+++ b/packages/electron-client-ipc/src/events/remoteServer.ts
@@ -1,20 +1,27 @@
-import { RemoteServerConfig } from '../types/remoteServer';
+import { DataSyncConfig } from '../types/dataSync';
+import { ProxyTRPCRequestParams, ProxyTRPCRequestResult } from '../types/proxyTRPCRequest';
/**
* 远程服务器配置相关的事件
*/
export interface RemoteServerDispatchEvents {
clearRemoteServerConfig: () => boolean;
- getRemoteServerConfig: () => RemoteServerConfig;
+ getRemoteServerConfig: () => DataSyncConfig;
+ /**
+ * Proxy a tRPC request to the remote server.
+ * @param args - Request arguments.
+ * @returns Promise resolving with the response details.
+ */
+ proxyTRPCRequest: (args: ProxyTRPCRequestParams) => ProxyTRPCRequestResult;
refreshAccessToken: () => {
error?: string;
success: boolean;
};
- requestAuthorization: (serverUrl: string) => {
+ requestAuthorization: (config: DataSyncConfig) => {
error?: string;
success: boolean;
};
- setRemoteServerConfig: (config: RemoteServerConfig) => boolean;
+ setRemoteServerConfig: (config: DataSyncConfig) => boolean;
}
/**
diff --git a/packages/electron-client-ipc/src/types/dataSync.ts b/packages/electron-client-ipc/src/types/dataSync.ts
new file mode 100644
index 0000000000..5f174150fc
--- /dev/null
+++ b/packages/electron-client-ipc/src/types/dataSync.ts
@@ -0,0 +1,15 @@
+export type StorageMode = 'local' | 'cloud' | 'selfHost';
+export enum StorageModeEnum {
+ Cloud = 'cloud',
+ Local = 'local',
+ SelfHost = 'selfHost',
+}
+
+/**
+ * 远程服务器配置相关的事件
+ */
+export interface DataSyncConfig {
+ active?: boolean;
+ remoteServerUrl?: string;
+ storageMode: StorageMode;
+}
diff --git a/packages/electron-client-ipc/src/types/index.ts b/packages/electron-client-ipc/src/types/index.ts
index 3a1df38d7d..0ea853a099 100644
--- a/packages/electron-client-ipc/src/types/index.ts
+++ b/packages/electron-client-ipc/src/types/index.ts
@@ -1,6 +1,7 @@
+export * from './dataSync';
export * from './dispatch';
export * from './localFile';
-export * from './remoteServer';
+export * from './proxyTRPCRequest';
export * from './route';
export * from './shortcut';
export * from './system';
diff --git a/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts b/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts
new file mode 100644
index 0000000000..604f1c7a5c
--- /dev/null
+++ b/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts
@@ -0,0 +1,21 @@
+export type ProxyTRPCRequestParams = {
+ /** Request body (can be string, ArrayBuffer, or null/undefined) */
+ body?: string | ArrayBuffer;
+ /** Request headers */
+ headers: Record;
+ /** The HTTP method (e.g., 'GET', 'POST') */
+ method: string;
+ /** The path and query string of the request (e.g., '/trpc/lambda/...') */
+ urlPath: string;
+};
+
+export interface ProxyTRPCRequestResult {
+ /** Response body (likely as ArrayBuffer or string) */
+ body: ArrayBuffer | string;
+ /** Response headers */
+ headers: Record;
+ /** Response status code */
+ status: number;
+ /** Response status text */
+ statusText: string;
+}
diff --git a/packages/electron-client-ipc/src/types/remoteServer.ts b/packages/electron-client-ipc/src/types/remoteServer.ts
deleted file mode 100644
index 7d3a50225a..0000000000
--- a/packages/electron-client-ipc/src/types/remoteServer.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * 远程服务器配置相关的事件
- */
-export interface RemoteServerConfig {
- active: boolean;
- isSelfHosted: boolean;
- remoteServerUrl?: string;
-}
diff --git a/packages/electron-server-ipc/src/const.ts b/packages/electron-server-ipc/src/const.ts
index 471cea220f..ff947ab10c 100644
--- a/packages/electron-server-ipc/src/const.ts
+++ b/packages/electron-server-ipc/src/const.ts
@@ -1,5 +1,5 @@
-export const SOCK_FILE = 'lobehub-electron-ipc.sock';
+export const SOCK_FILE = (id: string) => `${id}-electron-ipc.sock`;
-export const SOCK_INFO_FILE = 'lobehub-electron-ipc-info.json';
+export const SOCK_INFO_FILE = (id: string) => `${id}-electron-ipc-info.json`;
-export const WINDOW_PIPE_FILE = '\\\\.\\pipe\\lobehub-electron-ipc';
+export const WINDOW_PIPE_FILE = (id: string) => `\\\\.\\pipe\\${id}-electron-ipc`;
diff --git a/packages/electron-server-ipc/src/ipcClient.test.ts b/packages/electron-server-ipc/src/ipcClient.test.ts
index 09d59177c9..997a9ae6e0 100644
--- a/packages/electron-server-ipc/src/ipcClient.test.ts
+++ b/packages/electron-server-ipc/src/ipcClient.test.ts
@@ -12,6 +12,7 @@ vi.mock('node:net');
vi.mock('node:os');
vi.mock('node:path');
+const appId = 'lobehub';
describe('ElectronIpcClient', () => {
// Mock data
const mockTempDir = '/mock/temp/dir';
@@ -54,7 +55,7 @@ describe('ElectronIpcClient', () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSocketInfo));
// Execute
- new ElectronIpcClient();
+ new ElectronIpcClient(appId);
// Verify
expect(fs.existsSync).toHaveBeenCalledWith(mockSocketInfoPath);
@@ -66,7 +67,7 @@ describe('ElectronIpcClient', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
// Execute
- new ElectronIpcClient();
+ new ElectronIpcClient(appId);
// Verify
expect(fs.existsSync).toHaveBeenCalledWith(mockSocketInfoPath);
@@ -75,7 +76,7 @@ describe('ElectronIpcClient', () => {
// Test platform-specific behavior
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
- new ElectronIpcClient();
+ new ElectronIpcClient(appId);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
@@ -86,7 +87,7 @@ describe('ElectronIpcClient', () => {
});
// Execute
- new ElectronIpcClient();
+ new ElectronIpcClient(appId);
// Verify
expect(console.error).toHaveBeenCalledWith(
@@ -103,7 +104,7 @@ describe('ElectronIpcClient', () => {
// Setup a client with a known socket path
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSocketInfo));
- client = new ElectronIpcClient();
+ client = new ElectronIpcClient(appId);
// Reset socket mocks for each test
mockSocket.on.mockReset();
@@ -170,7 +171,7 @@ describe('ElectronIpcClient', () => {
// Setup a client with a known socket path
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSocketInfo));
- client = new ElectronIpcClient();
+ client = new ElectronIpcClient(appId);
// Setup socket.on
mockSocket.on.mockImplementation((event, callback) => {
diff --git a/packages/electron-server-ipc/src/ipcClient.ts b/packages/electron-server-ipc/src/ipcClient.ts
index 2d88f94f92..b7db665d53 100644
--- a/packages/electron-server-ipc/src/ipcClient.ts
+++ b/packages/electron-server-ipc/src/ipcClient.ts
@@ -20,18 +20,28 @@ export class ElectronIpcClient {
private connectionAttempts: number = 0;
private maxConnectionAttempts: number = 5;
private dataBuffer: string = '';
+ private readonly appId: string;
- constructor() {
- log('Initializing client');
+ constructor(appId: string) {
+ log('Initializing client', appId);
+ this.appId = appId;
this.initialize();
}
// 初始化客户端
private initialize() {
try {
- // 从临时文件读取套接字路径
const tempDir = os.tmpdir();
- const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE);
+
+ // Windows 平台强制使用命名管道
+ if (process.platform === 'win32') {
+ this.socketPath = WINDOW_PIPE_FILE(this.appId);
+ log('Using named pipe for Windows: %s', this.socketPath);
+ return;
+ }
+
+ // 其他平台尝试读取 sock info 文件
+ const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE(this.appId));
log('Looking for socket info at: %s', socketInfoPath);
if (fs.existsSync(socketInfoPath)) {
@@ -39,10 +49,9 @@ export class ElectronIpcClient {
this.socketPath = socketInfo.socketPath;
log('Found socket path: %s', this.socketPath);
} else {
- // 如果找不到套接字信息,使用默认路径
- this.socketPath =
- process.platform === 'win32' ? WINDOW_PIPE_FILE : path.join(os.tmpdir(), SOCK_FILE);
- log('Socket info not found, using default path: %s', this.socketPath);
+ // 如果找不到套接字信息,使用默认 sock 文件路径
+ this.socketPath = path.join(tempDir, SOCK_FILE(this.appId));
+ log('Socket info not found, using default sock path: %s', this.socketPath);
}
} catch (err) {
console.error('Failed to initialize IPC client:', err);
diff --git a/packages/electron-server-ipc/src/ipcServer.ts b/packages/electron-server-ipc/src/ipcServer.ts
index 520c3a2088..ebcfd7f5b9 100644
--- a/packages/electron-server-ipc/src/ipcServer.ts
+++ b/packages/electron-server-ipc/src/ipcServer.ts
@@ -13,12 +13,16 @@ const log = debug('electron-server-ipc:server');
export class ElectronIPCServer {
private server: net.Server;
private socketPath: string;
+ private appId: string;
private eventHandler: ElectronIPCEventHandler;
- constructor(eventHandler: ElectronIPCEventHandler) {
+ constructor(appId: string, eventHandler: ElectronIPCEventHandler) {
+ this.appId = appId;
const isWindows = process.platform === 'win32';
// 创建唯一的套接字路径,避免冲突
- this.socketPath = isWindows ? WINDOW_PIPE_FILE : path.join(os.tmpdir(), SOCK_FILE);
+ this.socketPath = isWindows
+ ? WINDOW_PIPE_FILE(appId)
+ : path.join(os.tmpdir(), SOCK_FILE(appId));
// 如果是 Unix 套接字,确保文件不存在
if (!isWindows && fs.existsSync(this.socketPath)) {
@@ -47,7 +51,7 @@ export class ElectronIPCServer {
// 将套接字路径写入临时文件,供 Next.js 服务端读取
const tempDir = os.tmpdir();
- const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE);
+ const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE(this.appId));
log('Writing socket info to: %s', socketInfoPath);
fs.writeFileSync(socketInfoPath, JSON.stringify({ socketPath: this.socketPath }), 'utf8');
diff --git a/packages/file-loaders/test/fixtures/test.pdf b/packages/file-loaders/test/fixtures/test.pdf
new file mode 100644
index 0000000000..34c2f8fe22
Binary files /dev/null and b/packages/file-loaders/test/fixtures/test.pdf differ
diff --git a/scripts/electronWorkflow/setDesktopVersion.ts b/scripts/electronWorkflow/setDesktopVersion.ts
index d11e60930b..17f5827115 100644
--- a/scripts/electronWorkflow/setDesktopVersion.ts
+++ b/scripts/electronWorkflow/setDesktopVersion.ts
@@ -2,12 +2,24 @@
import fs from 'fs-extra';
import path from 'node:path';
+type ReleaseType = 'stable' | 'beta' | 'nightly';
+
// 获取脚本的命令行参数
const version = process.argv[2];
-const isPr = process.argv[3] === 'true';
+const releaseType = process.argv[3] as ReleaseType;
-if (!version) {
- console.error('Missing version parameter, usage: bun run setDesktopVersion.ts [isPr]');
+// 验证参数
+if (!version || !releaseType) {
+ console.error(
+ 'Missing parameters. Usage: bun run setDesktopVersion.ts ',
+ );
+ process.exit(1);
+}
+
+if (!['stable', 'beta', 'nightly'].includes(releaseType)) {
+ console.error(
+ `Invalid release type: ${releaseType}. Must be one of 'stable', 'beta', 'nightly'.`,
+ );
process.exit(1);
}
@@ -16,81 +28,86 @@ const rootDir = path.resolve(__dirname, '../..');
// 桌面应用 package.json 的路径
const desktopPackageJsonPath = path.join(rootDir, 'apps/desktop/package.json');
+const buildDir = path.join(rootDir, 'apps/desktop/build');
// 更新应用图标
-function updateAppIcon() {
+function updateAppIcon(type: 'beta' | 'nightly') {
+ console.log(`📦 Updating app icon for ${type} version...`);
try {
- const buildDir = path.join(rootDir, 'apps/desktop/build');
-
- // 定义需要处理的图标映射,考虑到大小写敏感性
+ const iconSuffix = type === 'beta' ? 'beta' : 'nightly';
const iconMappings = [
- // { ext: '.ico', nightly: 'icon-nightly.ico', normal: 'icon.ico' },
- { ext: '.png', nightly: 'icon-nightly.png', normal: 'icon.png' },
- { ext: '.icns', nightly: 'Icon-nightly.icns', normal: 'Icon.icns' },
+ { ext: '.png', source: `icon-${iconSuffix}.png`, target: 'icon.png' },
+ { ext: '.icns', source: `Icon-${iconSuffix}.icns`, target: 'Icon.icns' },
+ { ext: '.ico', source: `icon-${iconSuffix}.ico`, target: 'icon.ico' },
];
- // 处理每种图标格式
for (const mapping of iconMappings) {
- const sourceFile = path.join(buildDir, mapping.nightly);
- const targetFile = path.join(buildDir, mapping.normal);
+ const sourceFile = path.join(buildDir, mapping.source);
+ const targetFile = path.join(buildDir, mapping.target);
- // 检查源文件是否存在
if (fs.existsSync(sourceFile)) {
- // 只有当源文件和目标文件不同,才进行复制
if (sourceFile !== targetFile) {
fs.copyFileSync(sourceFile, targetFile);
- console.log(`Updated app icon: ${targetFile}`);
+ console.log(` ✅ Copied ${mapping.source} to ${mapping.target}`);
}
} else {
- console.warn(`Warning: Source icon not found: ${sourceFile}`);
+ console.warn(` ⚠️ Warning: Source icon not found: ${sourceFile}`);
}
}
} catch (error) {
- console.error('Error updating icons:', error);
- // 继续处理,不终止程序
+ console.error(' ❌ Error updating icons:', error);
+ // 不终止程序,继续处理 package.json
}
}
-function updateVersion() {
+function updatePackageJson() {
+ console.log(`⚙️ Updating ${desktopPackageJsonPath} for ${releaseType} version ${version}...`);
try {
- // 确保文件存在
if (!fs.existsSync(desktopPackageJsonPath)) {
- console.error(`Error: File not found ${desktopPackageJsonPath}`);
+ console.error(`❌ Error: File not found ${desktopPackageJsonPath}`);
process.exit(1);
}
- // 读取 package.json 文件
const packageJson = fs.readJSONSync(desktopPackageJsonPath);
- // 更新版本号
+ // 始终更新版本号
packageJson.version = version;
- packageJson.productName = 'LobeHub';
- packageJson.name = 'lobehub-desktop';
- // 如果是 PR 构建,设置为 Nightly 版本
- if (isPr) {
- // 修改包名,添加 -nightly 后缀
- if (!packageJson.name.endsWith('-nightly')) {
- packageJson.name = `${packageJson.name}-nightly`;
+ // 根据 releaseType 修改其他字段
+ switch (releaseType) {
+ case 'stable': {
+ packageJson.productName = 'LobeHub';
+ packageJson.name = 'lobehub-desktop';
+ console.log('🌟 Setting as Stable version.');
+ break;
+ }
+ case 'beta': {
+ packageJson.productName = 'LobeHub-Beta'; // Or 'LobeHub-Beta' if preferred
+ packageJson.name = 'lobehub-desktop-beta'; // Or 'lobehub-desktop' if preferred
+ console.log('🧪 Setting as Beta version.');
+ updateAppIcon('beta');
+ break;
+ }
+ case 'nightly': {
+ packageJson.productName = 'LobeHub-Nightly'; // Or 'LobeHub-Nightly'
+ packageJson.name = 'lobehub-desktop-nightly'; // Or 'lobehub-desktop-nightly'
+ console.log('🌙 Setting as Nightly version.');
+ updateAppIcon('nightly');
+ break;
}
-
- // 修改产品名称为 LobeHub Nightly
- packageJson.productName = 'LobeHub-Nightly';
-
- console.log('🌙 Setting as Nightly version with modified package name and productName');
-
- // 使用 nightly 图标替换常规图标
- updateAppIcon();
}
// 写回文件
fs.writeJsonSync(desktopPackageJsonPath, packageJson, { spaces: 2 });
- console.log(`Desktop app version updated to: ${version}, isPr: ${isPr}`);
+ console.log(
+ `✅ Desktop app package.json updated successfully for ${releaseType} version ${version}.`,
+ );
} catch (error) {
- console.error('Error updating version:', error);
+ console.error('❌ Error updating package.json:', error);
process.exit(1);
}
}
-updateVersion();
+// 执行更新
+updatePackageJson();
diff --git a/src/app/[variants]/(main)/_layout/Desktop/index.tsx b/src/app/[variants]/(main)/_layout/Desktop/index.tsx
index 5acd08c235..13056e58fa 100644
--- a/src/app/[variants]/(main)/_layout/Desktop/index.tsx
+++ b/src/app/[variants]/(main)/_layout/Desktop/index.tsx
@@ -9,12 +9,12 @@ import { Flexbox } from 'react-layout-kit';
import { isDesktop } from '@/const/version';
import { BANNER_HEIGHT } from '@/features/AlertBanner/CloudBanner';
+import TitleBar, { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
import HotkeyHelperPanel from '@/features/HotkeyHelperPanel';
import { usePlatform } from '@/hooks/usePlatform';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { HotkeyScopeEnum } from '@/types/hotkey';
-import TitleBar, { TITLE_BAR_HEIGHT } from './ElectronTitlebar';
import RegisterHotkeys from './RegisterHotkeys';
import SideBar from './SideBar';
diff --git a/src/components/Analytics/Desktop.tsx b/src/components/Analytics/Desktop.tsx
new file mode 100644
index 0000000000..5927c528b6
--- /dev/null
+++ b/src/components/Analytics/Desktop.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import Script from 'next/script';
+import { memo } from 'react';
+import urlJoin from 'url-join';
+
+const DesktopAnalytics = memo(
+ () =>
+ process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID &&
+ process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL && (
+
+ ),
+);
+
+export default DesktopAnalytics;
diff --git a/src/components/Analytics/index.tsx b/src/components/Analytics/index.tsx
index af13121ade..253b708d15 100644
--- a/src/components/Analytics/index.tsx
+++ b/src/components/Analytics/index.tsx
@@ -1,7 +1,9 @@
import dynamic from 'next/dynamic';
import { analyticsEnv } from '@/config/analytics';
+import { isDesktop } from '@/const/version';
+import Desktop from './Desktop';
import Google from './Google';
import Vercel from './Vercel';
@@ -41,6 +43,7 @@ const Analytics = () => {
{!!analyticsEnv.REACT_SCAN_MONITOR_API_KEY && (
)}
+ {isDesktop && }
>
);
};
diff --git a/src/database/core/db-adaptor.ts b/src/database/core/db-adaptor.ts
index cd4ed801e2..f4453cefe5 100644
--- a/src/database/core/db-adaptor.ts
+++ b/src/database/core/db-adaptor.ts
@@ -1,6 +1,9 @@
+import { isDesktop } from '@/const/version';
import { getDBInstance } from '@/database/core/web-server';
import { LobeChatDatabase } from '@/database/type';
+import { getPgliteInstance } from './electron';
+
/**
* 懒加载数据库实例
* 避免每次模块导入时都初始化数据库
@@ -13,7 +16,7 @@ export const getServerDB = async (): Promise => {
try {
// 根据环境选择合适的数据库实例
- cachedDB = getDBInstance();
+ cachedDB = isDesktop ? await getPgliteInstance() : getDBInstance();
return cachedDB;
} catch (error) {
console.error('❌ Failed to initialize database:', error);
diff --git a/src/database/core/electron.ts b/src/database/core/electron.ts
new file mode 100644
index 0000000000..9524a509b4
--- /dev/null
+++ b/src/database/core/electron.ts
@@ -0,0 +1,317 @@
+import { PGlite } from '@electric-sql/pglite';
+import { vector } from '@electric-sql/pglite/vector';
+import { drizzle as pgliteDrizzle } from 'drizzle-orm/pglite';
+import fs from 'node:fs';
+import { Md5 } from 'ts-md5';
+
+import { DrizzleMigrationModel } from '@/database/models/drizzleMigration';
+import * as schema from '@/database/schemas';
+import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
+import { MigrationTableItem } from '@/types/clientDB';
+
+import migrations from '../client/migrations.json';
+import { LobeChatDatabase } from '../type';
+
+// 用于实例管理的全局对象
+interface LobeGlobal {
+ pgDB?: LobeChatDatabase;
+ pgDBInitPromise?: Promise;
+ pgDBLock?: {
+ acquired: boolean;
+ lockPath: string;
+ };
+}
+
+// 确保 globalThis 有我们的命名空间
+declare global {
+ // eslint-disable-next-line no-var
+ var __LOBE__: LobeGlobal;
+}
+
+if (!globalThis.__LOBE__) {
+ globalThis.__LOBE__ = {};
+}
+
+/**
+ * 尝试创建一个文件锁来确保单例模式
+ * 返回 true 表示成功获取锁,false 表示已有其他实例正在运行
+ */
+const acquireLock = async (dbPath: string): Promise => {
+ try {
+ // 数据库锁文件路径
+ const lockPath = `${dbPath}.lock`;
+
+ // 尝试创建锁文件
+ if (!fs.existsSync(lockPath)) {
+ // 创建锁文件并写入当前进程 ID
+ fs.writeFileSync(lockPath, process.pid.toString(), 'utf8');
+
+ // 保存锁信息到全局对象
+ if (!globalThis.__LOBE__.pgDBLock) {
+ globalThis.__LOBE__.pgDBLock = {
+ acquired: true,
+ lockPath,
+ };
+ }
+
+ console.log(`✅ Successfully acquired database lock: ${lockPath}`);
+ return true;
+ }
+
+ // 检查锁文件是否过期(超过5分钟未更新)
+ const stats = fs.statSync(lockPath);
+ const currentTime = Date.now();
+ const modifiedTime = stats.mtime.getTime();
+
+ // 如果锁文件超过5分钟未更新,视为过期锁
+ if (currentTime - modifiedTime > 5 * 60 * 1000) {
+ // 删除过期锁文件
+ fs.unlinkSync(lockPath);
+ // 重新创建锁文件
+ fs.writeFileSync(lockPath, process.pid.toString(), 'utf8');
+
+ // 保存锁信息到全局对象
+ if (!globalThis.__LOBE__.pgDBLock) {
+ globalThis.__LOBE__.pgDBLock = {
+ acquired: true,
+ lockPath,
+ };
+ }
+
+ console.log(`✅ Removed stale lock and acquired new lock: ${lockPath}`);
+ return true;
+ }
+
+ console.warn(`⚠️ Another process has already locked the database: ${lockPath}`);
+ return false;
+ } catch (error) {
+ console.error('❌ Failed to acquire database lock:', error);
+ return false;
+ }
+};
+
+/**
+ * 释放文件锁
+ */
+const releaseLock = () => {
+ if (globalThis.__LOBE__.pgDBLock?.acquired && globalThis.__LOBE__.pgDBLock.lockPath) {
+ try {
+ fs.unlinkSync(globalThis.__LOBE__.pgDBLock.lockPath);
+ globalThis.__LOBE__.pgDBLock.acquired = false;
+ console.log(`✅ Released database lock: ${globalThis.__LOBE__.pgDBLock.lockPath}`);
+ } catch (error) {
+ console.error('❌ Failed to release database lock:', error);
+ }
+ }
+};
+
+// 在进程退出时释放锁
+process.on('exit', releaseLock);
+process.on('SIGINT', () => {
+ releaseLock();
+ process.exit(0);
+});
+
+process.on('uncaughtException', (error) => {
+ // ignore ECONNRESET error
+ if ((error as any).code === 'ECONNRESET') return;
+
+ console.error('Uncaught exception:', error);
+ releaseLock();
+});
+
+const migrateDatabase = async (db: LobeChatDatabase): Promise => {
+ try {
+ let hash: string | undefined;
+ const cacheHash = await electronIpcClient.getDatabaseSchemaHash();
+
+ hash = Md5.hashStr(JSON.stringify(migrations));
+
+ console.log('schemaHash:', hash);
+
+ // 如果哈希值相同,看下表是否全了
+ if (hash === cacheHash) {
+ try {
+ const drizzleMigration = new DrizzleMigrationModel(db);
+
+ // 检查数据库中是否存在表
+ const tableCount = await drizzleMigration.getTableCounts();
+
+ // 如果表数量大于0,则认为数据库已正确初始化
+ if (tableCount > 0) {
+ console.log('✅ Electron DB schema already synced');
+ return;
+ }
+ } catch (error) {
+ console.warn('Error checking table existence, proceeding with migration:');
+ console.warn(error);
+ }
+ }
+
+ const start = Date.now();
+ console.log('🚀 Starting Electron DB migration...');
+
+ try {
+ // 执行迁移
+ // @ts-expect-error
+ await db.dialect.migrate(migrations, db.session, {});
+
+ await electronIpcClient.setDatabaseSchemaHash(hash);
+
+ console.info(`✅ Electron DB migration success, took ${Date.now() - start}ms`);
+ } catch (error) {
+ console.error('❌ Electron database schema migration failed', error);
+
+ // 尝试查询迁移表数据
+ let migrationsTableData: MigrationTableItem[] = [];
+ try {
+ // 尝试查询迁移表
+ const drizzleMigration = new DrizzleMigrationModel(db);
+ migrationsTableData = await drizzleMigration.getMigrationList();
+ } catch (queryError) {
+ console.error('Failed to query migrations table:', queryError);
+ }
+
+ throw {
+ error: error as Error,
+ migrationTableItems: migrationsTableData,
+ };
+ }
+ } catch (error) {
+ console.error('❌ Electron database migration failed:', error);
+ throw error;
+ }
+};
+
+/**
+ * 检查当前是否有活跃的数据库实例,如果有则尝试关闭它
+ */
+const checkAndCleanupExistingInstance = async () => {
+ if (globalThis.__LOBE__.pgDB) {
+ try {
+ // 尝试关闭现有的 PGlite 实例 (如果客户端有 close 方法)
+ // @ts-expect-error
+ const client = globalThis.__LOBE__.pgDB?.dialect?.client;
+
+ if (client && typeof client.close === 'function') {
+ await client.close();
+ console.log('✅ Successfully closed previous PGlite instance');
+ }
+
+ // 重置全局引用
+ globalThis.__LOBE__.pgDB = undefined;
+ } catch (error) {
+ console.error('❌ Failed to close previous PGlite instance:', error);
+ // 继续执行,创建新实例
+ }
+ }
+};
+
+let isInitializing = false;
+
+export const getPgliteInstance = async (): Promise => {
+ try {
+ console.log(
+ 'Getting PGlite instance, state:',
+ JSON.stringify({
+ hasExistingDB: !!globalThis.__LOBE__.pgDB,
+ hasPromise: !!globalThis.__LOBE__.pgDBInitPromise,
+ isInitializing,
+ }),
+ );
+
+ // 已经初始化完成,直接返回实例
+ if (globalThis.__LOBE__.pgDB) return globalThis.__LOBE__.pgDB;
+
+ // 有初始化进行中的Promise,等待它完成
+ if (globalThis.__LOBE__.pgDBInitPromise) {
+ console.log('Waiting for existing initialization promise to complete');
+ return globalThis.__LOBE__.pgDBInitPromise;
+ }
+
+ // 防止多次调用引起的竞态条件
+ if (isInitializing) {
+ console.log('Already initializing, waiting for result');
+ // 创建新的 Promise 等待初始化完成
+ return new Promise((resolve, reject) => {
+ const checkInterval = setInterval(() => {
+ if (globalThis.__LOBE__.pgDB) {
+ clearInterval(checkInterval);
+ resolve(globalThis.__LOBE__.pgDB);
+ } else if (!isInitializing) {
+ clearInterval(checkInterval);
+ reject(new Error('Initialization failed or was canceled'));
+ }
+ }, 100);
+ });
+ }
+
+ isInitializing = true;
+
+ // 创建初始化Promise并保存
+ globalThis.__LOBE__.pgDBInitPromise = (async () => {
+ // 再次检查,以防在等待过程中已有其他调用初始化成功
+ if (globalThis.__LOBE__.pgDB) return globalThis.__LOBE__.pgDB;
+
+ // 先获取数据库路径
+ let dbPath: string = '';
+ try {
+ dbPath = await electronIpcClient.getDatabasePath();
+ } catch {
+ /* empty */
+ }
+
+ console.log('Database path:', dbPath);
+ try {
+ // 尝试获取数据库锁
+ const lockAcquired = await acquireLock(dbPath);
+ if (!lockAcquired) {
+ throw new Error('Cannot acquire database lock. Another instance might be using it.');
+ }
+
+ // 检查并清理可能存在的旧实例
+ await checkAndCleanupExistingInstance();
+
+ // 创建新的 PGlite 实例
+ console.log('Creating new PGlite instance');
+ const client = new PGlite(dbPath, {
+ extensions: { vector },
+ // 增加选项以提高稳定性
+ relaxedDurability: true,
+ });
+
+ // 等待数据库就绪
+ await client.waitReady;
+ console.log('PGlite state:', client.ready);
+
+ // 创建 Drizzle 数据库实例
+ const db = pgliteDrizzle({ client, schema }) as unknown as LobeChatDatabase;
+
+ // 执行迁移
+ await migrateDatabase(db);
+
+ // 保存实例引用
+ globalThis.__LOBE__.pgDB = db;
+
+ console.log('✅ PGlite instance successfully initialized');
+
+ return db;
+ } catch (error) {
+ console.error('❌ Failed to initialize PGlite instance:', error);
+ // 清空初始化Promise,允许下次重试
+ globalThis.__LOBE__.pgDBInitPromise = undefined;
+ // 释放可能已获取的锁
+ releaseLock();
+ throw error;
+ } finally {
+ isInitializing = false;
+ }
+ })();
+
+ return globalThis.__LOBE__.pgDBInitPromise;
+ } catch (error) {
+ console.error('❌ Unexpected error in getPgliteInstance:', error);
+ isInitializing = false;
+ throw error;
+ }
+};
diff --git a/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Mode.tsx b/src/features/ElectronTitlebar/Connection/ConnectionMode.tsx
similarity index 79%
rename from src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Mode.tsx
rename to src/features/ElectronTitlebar/Connection/ConnectionMode.tsx
index 5916704b80..e391c3cb9d 100644
--- a/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Mode.tsx
+++ b/src/features/ElectronTitlebar/Connection/ConnectionMode.tsx
@@ -1,3 +1,4 @@
+import { StorageMode, StorageModeEnum } from '@lobechat/electron-client-ipc';
import { Input } from '@lobehub/ui';
import { LobeHub } from '@lobehub/ui/brand';
import { Button } from 'antd';
@@ -8,8 +9,9 @@ import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useElectronStore } from '@/store/electron';
+import { electronSyncSelectors } from '@/store/electron/selectors';
-import { AccessOption, Option } from './Option';
+import { Option } from './Option';
const useStyles = createStyles(({ token, css }) => {
return {
@@ -78,12 +80,15 @@ interface ConnectionModeProps {
const ConnectionMode = memo(({ setIsOpen, setWaiting }) => {
const { styles } = useStyles();
const { t } = useTranslation(['electron', 'common']);
- const [selectedOption, setSelectedOption] = useState();
- const [selfHostedUrl, setSelfHostedUrl] = useState('');
const [urlError, setUrlError] = useState();
const connect = useElectronStore((s) => s.connectRemoteServer);
const disconnect = useElectronStore((s) => s.disconnectRemoteServer);
+ const storageMode = useElectronStore(electronSyncSelectors.storageMode);
+ const remoteServerUrl = useElectronStore(electronSyncSelectors.remoteServerUrl);
+
+ const [selectedOption, setSelectedOption] = useState(storageMode);
+ const [selfHostedUrl, setSelfHostedUrl] = useState(remoteServerUrl);
const validateUrl = useCallback((url: string) => {
if (!url) {
@@ -100,9 +105,9 @@ const ConnectionMode = memo(({ setIsOpen, setWaiting }) =>
}
}, []);
- const handleSelectOption = (option: AccessOption) => {
+ const handleSelectOption = (option: StorageMode) => {
setSelectedOption(option);
- if (option !== 'self-hosted') {
+ if (option !== StorageModeEnum.SelfHost) {
setUrlError(undefined);
} else {
setUrlError(validateUrl(selfHostedUrl));
@@ -110,7 +115,7 @@ const ConnectionMode = memo(({ setIsOpen, setWaiting }) =>
};
const handleContinue = async () => {
- if (selectedOption === 'self-hosted') {
+ if (selectedOption === StorageModeEnum.SelfHost) {
const error = validateUrl(selfHostedUrl);
setUrlError(error);
if (error) {
@@ -118,7 +123,7 @@ const ConnectionMode = memo(({ setIsOpen, setWaiting }) =>
}
}
- if (selectedOption === 'local') {
+ if (selectedOption === StorageModeEnum.Local) {
await disconnect();
setIsOpen(false);
return;
@@ -126,11 +131,8 @@ const ConnectionMode = memo(({ setIsOpen, setWaiting }) =>
// try to connect
setWaiting(true);
- await connect(
- selectedOption === 'self-hosted'
- ? { isSelfHosted: true, serverUrl: selfHostedUrl }
- : { isSelfHosted: false },
- );
+ console.log('selectedOption:', selectedOption);
+ await connect({ remoteServerUrl: selfHostedUrl, storageMode: selectedOption });
};
return (
@@ -145,7 +147,7 @@ const ConnectionMode = memo(({ setIsOpen, setWaiting }) =>
{t('sync.mode.cloudSync')}
handleSelectOption('self-hosted')}
+ onClick={() => handleSelectOption(StorageModeEnum.SelfHost)}
>
{t('sync.mode.useSelfHosted')}
@@ -156,18 +158,18 @@ const ConnectionMode = memo(({ setIsOpen, setWaiting }) =>
isSelected={selectedOption === 'cloud'}
label={t('sync.lobehubCloud.title')}
onClick={handleSelectOption}
- value="cloud"
+ value={StorageModeEnum.Cloud}
/>
- {selectedOption === 'self-hosted' && (
+ {selectedOption === StorageModeEnum.SelfHost && (
@@ -206,7 +208,8 @@ const ConnectionMode = memo(({ setIsOpen, setWaiting }) =>