Compare commits

...

36 Commits

Author SHA1 Message Date
arvinxx b2aa2d927a fix build electron in ci 2025-04-02 20:55:25 +08:00
arvinxx 5e1ff5842e build the desktop framework 2025-04-02 19:14:55 +08:00
arvinxx 695f504c8b update tsconfig 2025-04-02 17:22:40 +08:00
arvinxx 4d08c967c8 update tsconfig 2025-04-02 16:55:48 +08:00
arvinxx 094f9a6dc7 fix desktop build workflow 2025-04-02 16:27:34 +08:00
arvinxx 0554093a8d finish desktop build workflow 2025-04-02 16:07:57 +08:00
arvinxx 51305a9566 fix workflow build steps 2025-04-02 14:43:59 +08:00
arvinxx 39c5bd4081 fix workflow build steps 2025-04-02 14:20:21 +08:00
arvinxx c199a85693 fix workflow build steps 2025-04-02 14:13:12 +08:00
arvinxx c5833c232f update workflow 2025-04-02 13:43:55 +08:00
arvinxx fb931674f6 update workflow 2025-04-02 13:27:11 +08:00
arvinxx e6422793f0 update workflow 2025-04-02 13:21:52 +08:00
arvinxx 794642fc57 update workflow 2025-04-02 13:13:38 +08:00
arvinxx edb576fdd6 update workflow 2025-04-02 13:13:18 +08:00
arvinxx 27eca4e649 update workflow 2025-04-02 12:58:23 +08:00
arvinxx 40ea09f764 test release workflow 2025-04-02 12:45:20 +08:00
arvinxx 613dd23594 add desktop
refactor

update

update

improve loading state

refactor the 404 error
2025-04-02 11:58:25 +08:00
Arvin Xu b40caee32c 🔨 chore: add desktop pre-code to validate build process (#7261)
* add code

* fix lint

* fix tests
2025-04-02 09:31:08 +08:00
lobehubbot 5897d9e106 📝 docs(bot): Auto sync agents & plugin to readme 2025-04-01 13:04:17 +00:00
semantic-release-bot cbfb4660cc 🔖 chore(release): v1.77.6 [skip ci]
### [Version 1.77.6](https://github.com/lobehub/lobe-chat/compare/v1.77.5...v1.77.6)
<sup>Released on **2025-04-01**</sup>

#### ♻ Code Refactoring

- **misc**: Refactor the db to context inject mode.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Code refactoring

* **misc**: Refactor the db to context inject mode, closes [#7255](https://github.com/lobehub/lobe-chat/issues/7255) ([ffd0dbc](https://github.com/lobehub/lobe-chat/commit/ffd0dbc))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-04-01 13:03:12 +00:00
Arvin Xu ffd0dbc7f5 ♻️ refactor: refactor the db to context inject mode (#7255)
* refactor with new db init mode

* fix tests

* fix tests

* move the separate index

* fix tests

* fix tests

* fix db issue

* fix db

* refactor to clean

* Update index.ts

* fix error

* fix the exist inbox slug session

* fix the tests
2025-04-01 20:53:03 +08:00
lobehubbot 3a52f5cf97 📝 docs(bot): Auto sync agents & plugin to readme 2025-04-01 08:10:38 +00:00
semantic-release-bot 253521883d 🔖 chore(release): v1.77.5 [skip ci]
### [Version&nbsp;1.77.5](https://github.com/lobehub/lobe-chat/compare/v1.77.4...v1.77.5)
<sup>Released on **2025-04-01**</sup>

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-04-01 08:09:33 +00:00
Arvin Xu 72734686e2 ️ perf: fix delete file chunk timeout (#7253)
* fix delete file chunk

* update branding loading

* fix tests
2025-04-01 15:59:20 +08:00
lobehubbot 8969716168 📝 docs(bot): Auto sync agents & plugin to readme 2025-03-31 12:00:12 +00:00
semantic-release-bot 666b2b0f0c 🔖 chore(release): v1.77.4 [skip ci]
### [Version&nbsp;1.77.4](https://github.com/lobehub/lobe-chat/compare/v1.77.3...v1.77.4)
<sup>Released on **2025-03-31**</sup>

#### ♻ Code Refactoring

- **misc**: Refactor db core.

#### 💄 Styles

- **misc**: Update branding.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Code refactoring

* **misc**: Refactor db core, closes [#7245](https://github.com/lobehub/lobe-chat/issues/7245) ([5c71db6](https://github.com/lobehub/lobe-chat/commit/5c71db6))

#### Styles

* **misc**: Update branding, closes [#7224](https://github.com/lobehub/lobe-chat/issues/7224) ([481cab0](https://github.com/lobehub/lobe-chat/commit/481cab0))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-03-31 11:58:55 +00:00
renovate[bot] 0f7af4b898 Update dependency lucide-react to ^0.485.0 (#7230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 19:48:55 +08:00
renovate[bot] 11b6467f36 Update dependency @neondatabase/serverless to v1 (#7231)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 19:48:46 +08:00
Arvin Xu 5c71db6c4e ♻️ refactor: refactor db core (#7245)
* refactor db core

* fix tests
2025-03-31 19:39:57 +08:00
Arvin Xu 481cab0515 💄 style: update branding (#7224) 2025-03-31 19:39:37 +08:00
Arvin Xu 7ae17b62d3 🔨 chore: add electron server ipc (#7246) 2025-03-31 19:39:10 +08:00
bbbugg 4309730cc8 📝 docs: update example model names in documentation for clarity (#7237)
Co-authored-by: bbbugg <daming20120101@16.com>
2025-03-31 17:55:11 +08:00
lobehubbot eb5545bd7f 📝 docs(bot): Auto sync agents & plugin to readme 2025-03-29 15:26:26 +00:00
semantic-release-bot 1d526c2f7c 🔖 chore(release): v1.77.3 [skip ci]
### [Version&nbsp;1.77.3](https://github.com/lobehub/lobe-chat/compare/v1.77.2...v1.77.3)
<sup>Released on **2025-03-29**</sup>

#### ♻ Code Refactoring

- **misc**: Move general db models to database folder.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Code refactoring

* **misc**: Move general db models to database folder, closes [#7222](https://github.com/lobehub/lobe-chat/issues/7222) ([f831d86](https://github.com/lobehub/lobe-chat/commit/f831d86))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-03-29 15:25:22 +00:00
Arvin Xu f831d8641c ♻️ refactor: move general db models to database folder (#7222)
* ♻️ refactor: decrypt error

* fix tests
2025-03-29 23:15:24 +08:00
lobehubbot 7c18071d21 📝 docs(bot): Auto sync agents & plugin to readme 2025-03-29 12:45:09 +00:00
192 changed files with 3537 additions and 409 deletions
+260
View File
@@ -0,0 +1,260 @@
name: Release Desktop
on:
workflow_dispatch: # 手动触发构建
release:
types: [published] # 发布 release 时触发构建
pull_request:
types: [synchronize, labeled, unlabeled] # PR 更新或标签变化时触发
# 确保同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
concurrency:
group: ${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: true
env:
PR_TAG_PREFIX: pr- # PR 构建版本的前缀标识
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'
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: 8
- name: Install deps
run: pnpm install
env:
NODE_OPTIONS: --max-old-space-size=6144
- name: Lint
run: pnpm run lint
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'
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")
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})"
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
# 输出版本信息总结,方便在 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:
os: [macos-latest, windows-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: 8
- name: Install deps
run: pnpm install
- 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 }}
# macOS 构建处理
- name: Build artifact on macOS
if: runner.os == 'macOS'
run: npm run desktop:build
env:
APP_URL: http://localhost:3010
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 平台构建处理
- name: Build artifact on other platforms
if: runner.os != 'macOS'
run: npm run desktop:build
env:
APP_URL: http://localhost:3010
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
# 上传构建产物,移除了 zip 相关部分
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.os }}
path: |
apps/desktop/release/latest*
apps/desktop/release/lobehub*.dmg*
apps/desktop/release/lobehub*.exe*
apps/desktop/release/lobehub*.AppImage
retention-days: 5
echo "🔄 Is PR Build: ${{ needs.version.outputs.is_pr_build }}"
merge:
needs: [build, version]
name: Merge Artifacts
runs-on: ubuntu-latest
steps:
# 下载所有平台的构建产物
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: release
pattern: release-*
merge-multiple: true
# 列出所有构建产物
- name: List artifacts
run: ls -R release
publish:
# 只有非 PR 构建且没有 [skip ci] 标记的提交才执行发布
if: |
needs.version.outputs.is_pr_build != 'true' &&
!contains(github.event.head_commit.message, '[skip ci]')
needs: [merge, 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: release
pattern: release-*
merge-multiple: true
# 列出所有构建产物
- name: List artifacts
run: ls -R release
# 对于非 release 触发的构建,创建为 draft 状态的 GitHub Release
- name: Create Draft Release
if: github.event_name != 'release'
uses: softprops/action-gh-release@v1
with:
name: Desktop v${{ needs.version.outputs.version }}
tag_name: v${{ needs.version.outputs.version }}
draft: true # A draft release
prerelease: false
files: |
release/latest*
release/umi*.dmg*
release/umi*.exe*
release/umi*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 对于 release 触发的构建,将构建产物上传到现有 release
- name: Upload to existing Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
release/latest*
release/umi*.dmg*
release/umi*.exe*
release/umi*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -1
View File
@@ -68,4 +68,5 @@ public/swe-worker*
*.patch
*.pdf
vertex-ai-key.json
.pnpm-store
.pnpm-store
lobechat-db
+100
View File
@@ -2,6 +2,106 @@
# Changelog
### [Version 1.77.6](https://github.com/lobehub/lobe-chat/compare/v1.77.5...v1.77.6)
<sup>Released on **2025-04-01**</sup>
#### ♻ Code Refactoring
- **misc**: Refactor the db to context inject mode.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Code refactoring
- **misc**: Refactor the db to context inject mode, closes [#7255](https://github.com/lobehub/lobe-chat/issues/7255) ([ffd0dbc](https://github.com/lobehub/lobe-chat/commit/ffd0dbc))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.77.5](https://github.com/lobehub/lobe-chat/compare/v1.77.4...v1.77.5)
<sup>Released on **2025-04-01**</sup>
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.77.4](https://github.com/lobehub/lobe-chat/compare/v1.77.3...v1.77.4)
<sup>Released on **2025-03-31**</sup>
#### ♻ Code Refactoring
- **misc**: Refactor db core.
#### 💄 Styles
- **misc**: Update branding.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Code refactoring
- **misc**: Refactor db core, closes [#7245](https://github.com/lobehub/lobe-chat/issues/7245) ([5c71db6](https://github.com/lobehub/lobe-chat/commit/5c71db6))
#### Styles
- **misc**: Update branding, closes [#7224](https://github.com/lobehub/lobe-chat/issues/7224) ([481cab0](https://github.com/lobehub/lobe-chat/commit/481cab0))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.77.3](https://github.com/lobehub/lobe-chat/compare/v1.77.2...v1.77.3)
<sup>Released on **2025-03-29**</sup>
#### ♻ Code Refactoring
- **misc**: Move general db models to database folder.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Code refactoring
- **misc**: Move general db models to database folder, closes [#7222](https://github.com/lobehub/lobe-chat/issues/7222) ([f831d86](https://github.com/lobehub/lobe-chat/commit/f831d86))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.77.2](https://github.com/lobehub/lobe-chat/compare/v1.77.1...v1.77.2)
<sup>Released on **2025-03-29**</sup>
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
standalone
release
+4
View File
@@ -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/
+4
View File
@@ -0,0 +1,4 @@
构建路径:
- dist: 构建产物路径
- release: 发布产物路径
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

+3
View File
@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-app-updater
+71
View File
@@ -0,0 +1,71 @@
/**
* @type {import('electron-builder').Configuration}
* @see https://www.electron.build/configuration
*/
const config = {
appId: 'com.lobehub.lobehub-desktop',
appImage: {
artifactName: '${productName}-${version}.${ext}',
},
asar: false,
// TODO: 研究下怎么样可以做成 asar 的模式
// asar: { smartUnpack: false },
// asarUnpack: ['dist/next'],
directories: {
buildResources: 'build',
output: 'release',
},
dmg: {
artifactName: '${productName}-${version}.${ext}',
},
electronDownload: {
mirror: 'https://npmmirror.com/mirrors/electron/',
},
files: [
'dist',
'resources',
'!dist/next/docs',
'!dist/next/packages',
'!dist/next/.next/server/app/sitemap',
// '!dist/next/.next/static/media',
],
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.",
},
],
notarize: false,
},
npmRebuild: true,
nsis: {
artifactName: '${productName}-${version}-setup.${ext}',
createDesktopShortcut: 'always',
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
},
productName: 'LobeHub',
publish: {
provider: 'generic',
url: 'https://example.com/auto-updates',
},
win: {
executableName: 'electron-app',
},
};
export default config;
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { resolve } from 'node:path';
export default defineConfig({
main: {
build: {
outDir: 'dist/main',
},
plugins: [externalizeDepsPlugin({})],
resolve: {
alias: {
'@': resolve(__dirname, 'src/main'),
},
},
},
preload: {
build: {
outDir: 'dist/preload',
},
plugins: [externalizeDepsPlugin({})],
},
});
+51
View File
@@ -0,0 +1,51 @@
{
"name": "lobehub-desktop",
"version": "0.0.2-nightly",
"description": "A minimal Electron application with TypeScript",
"homepage": "https://lobehub.com",
"author": "arvinxx",
"main": "./dist/main/index.js",
"scripts": {
"build": "npm run typecheck && electron-vite build",
"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:unpack": "npm run build && electron-builder --dir --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 ",
"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-toolkit/utils": "^4.0.0",
"electron-updater": "^6.3.9",
"function-bind": "^1.1.2",
"get-port-please": "^3.1.2",
"next-electron-rsc": "^0.2.3"
},
"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",
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
"electron": "35.1.1",
"electron-builder": "^26.0.12",
"electron-is": "^3.0.0",
"electron-vite": "^3.0.0",
"pglite-server": "^0.1.4",
"typescript": "^5.7.3",
"vite": "^6.2.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
packages:
- '../../packages/electron-server-ipc'
- '../../packages/electron-client-ipc'
- '.'
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LobeHub - 连接错误</title>
<style>
body {
-webkit-app-region: drag;
margin: 0;
padding: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #1f1f1f;
overflow: hidden;
}
/* 添加暗色模式支持 */
@media (prefers-color-scheme: dark) {
body {
color: #f5f5f5;
background-color: #121212;
}
.error-message {
color: #f5f5f5;
}
.retry-button {
background-color: #2a2a2a;
color: #f5f5f5;
border: 1px solid #3a3a3a;
}
.retry-button:hover {
background-color: #3a3a3a;
}
}
.container {
text-align: center;
padding: 2rem;
max-width: 500px;
}
.lobe-brand {
width: 120px;
height: auto;
margin-bottom: 2rem;
}
.lobe-brand path {
fill: currentcolor;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
color: #ff4d4f;
}
.error-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
}
.error-message {
margin-bottom: 2rem;
line-height: 1.5;
color: #666;
}
.retry-button {
-webkit-app-region: no-drag;
padding: 0.75rem 1.5rem;
background-color: #f5f5f5;
color: #1f1f1f;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.retry-button:hover {
background-color: #e9e9e9;
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">⚠️</div>
<h1 class="error-title">Connection Error</h1>
<p class="error-message">
Unable to connect to the application, please check your network connection or confirm if the
development server is running.
</p>
<button id="retry-button" class="retry-button">Retry</button>
</div>
<script>
// 当按钮被点击时,通知主进程重试连接
const retryButton = document.getElementById('retry-button');
const errorMessage = document.querySelector('.error-message');
if (retryButton) {
retryButton.addEventListener('click', () => {
// 更新UI状态
retryButton.disabled = true;
retryButton.textContent = 'Retrying...';
errorMessage.textContent = 'Attempting to reconnect to the server, please wait...';
// 调用主进程的重试逻辑
if (window.electron && window.electron.ipcRenderer) {
window.electron.ipcRenderer.send('retry-connection');
}
});
}
</script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LobeHub</title>
<style>
body {
-webkit-app-region: drag;
margin: 0;
padding: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #1f1f1f;
overflow: hidden;
}
/* 添加暗色模式支持 */
@media (prefers-color-scheme: dark) {
body {
color: #f5f5f5;
}
}
.container {
text-align: center;
}
.lobe-brand-loading {
width: 120px;
height: auto;
}
.lobe-brand-loading path {
fill: currentcolor;
fill-opacity: 0%;
stroke: currentcolor;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
stroke-width: 0.25em;
animation:
draw 2s cubic-bezier(0.4, 0, 0.2, 1) infinite,
fill 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes draw {
0% {
stroke-dashoffset: 1000;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes fill {
30% {
fill-opacity: 5%;
}
100% {
fill-opacity: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<svg
class="lobe-brand-loading"
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 940 320"
xmlns="http://www.w3.org/2000/svg"
>
<title>LobeHub</title>
<path
d="M15 240.035V87.172h39.24V205.75h66.192v34.285H15zM183.731 242c-11.759 0-22.196-2.621-31.313-7.862-9.116-5.241-16.317-12.447-21.601-21.619-5.153-9.317-7.729-19.945-7.729-31.883 0-11.937 2.576-22.492 7.729-31.664 5.164-8.963 12.159-15.98 20.982-21.05l.619-.351c9.117-5.241 19.554-7.861 31.313-7.861s22.196 2.62 31.313 7.861c9.248 5.096 16.449 12.229 21.601 21.401 5.153 9.172 7.729 19.727 7.729 31.664 0 11.938-2.576 22.566-7.729 31.883-5.152 9.172-12.353 16.378-21.601 21.619-9.117 5.241-19.554 7.862-31.313 7.862zm0-32.975c4.36 0 8.191-1.092 11.494-3.275 3.436-2.184 6.144-5.387 8.126-9.609 1.982-4.367 2.973-9.536 2.973-15.505 0-5.968-.991-10.991-2.973-15.067-1.906-4.06-4.483-7.177-7.733-9.352l-.393-.257c-3.303-2.184-7.134-3.276-11.494-3.276-4.228 0-8.059 1.092-11.495 3.276-3.303 2.184-6.011 5.387-8.125 9.609-1.982 4.076-2.973 9.099-2.973 15.067 0 5.969.991 11.138 2.973 15.505 2.114 4.222 4.822 7.425 8.125 9.609 3.436 2.183 7.267 3.275 11.495 3.275zM295.508 78l-.001 54.042a34.071 34.071 0 016.541-5.781c6.474-4.367 14.269-6.551 23.385-6.551 9.777 0 18.629 2.475 26.557 7.424 7.872 4.835 14.105 11.684 18.7 20.546l.325.637c4.756 9.026 7.135 19.799 7.135 32.319 0 12.666-2.379 23.585-7.135 32.757-4.624 9.026-10.966 16.087-19.025 21.182-7.928 4.95-16.78 7.425-26.557 7.425-9.644 0-17.704-2.184-24.178-6.551-2.825-1.946-5.336-4.355-7.532-7.226l.001 11.812h-35.87V78h37.654zm21.998 74.684c-4.228 0-8.059 1.092-11.494 3.276-3.303 2.184-6.012 5.387-8.126 9.609-1.982 4.076-2.972 9.099-2.972 15.067 0 5.969.99 11.138 2.972 15.505 2.114 4.222 4.823 7.425 8.126 9.609 3.435 2.183 7.266 3.275 11.494 3.275s7.994-1.092 11.297-3.275c3.435-2.184 6.143-5.387 8.125-9.609 2.114-4.367 3.171-9.536 3.171-15.505 0-5.968-1.057-10.991-3.171-15.067-1.906-4.06-4.483-7.177-7.732-9.352l-.393-.257c-3.303-2.184-7.069-3.276-11.297-3.276zm105.335 38.653l.084.337a27.857 27.857 0 002.057 5.559c2.246 4.222 5.417 7.498 9.513 9.827 4.096 2.184 8.984 3.276 14.665 3.276 5.285 0 9.777-.801 13.477-2.403 3.579-1.632 7.1-4.025 10.564-7.182l.732-.679 19.818 22.711c-5.153 6.26-11.494 11.064-19.025 14.413-7.531 3.203-16.449 4.804-26.755 4.804-12.683 0-23.782-2.621-33.294-7.862-9.381-5.386-16.713-12.665-21.998-21.837-5.153-9.317-7.729-19.872-7.729-31.665 0-11.792 2.51-22.274 7.53-31.446 5.036-9.105 11.902-16.195 20.596-21.268l.61-.351c8.984-5.241 19.091-7.861 30.322-7.861 10.311 0 19.743 2.286 28.294 6.859l.64.347c8.72 4.659 15.656 11.574 20.809 20.746 5.153 9.172 7.729 20.309 7.729 33.411 0 1.294-.052 2.761-.156 4.4l-.042.623-.17 2.353c-.075 1.01-.151 1.973-.227 2.888h-78.044zm21.365-42.147c-4.492 0-8.456 1.092-11.891 3.276-3.303 2.184-5.879 5.314-7.729 9.39a26.04 26.04 0 00-1.117 2.79 30.164 30.164 0 00-1.121 4.499l-.058.354h43.96l-.015-.106c-.401-2.638-1.122-5.055-2.163-7.252l-.246-.503c-1.776-3.774-4.282-6.742-7.519-8.906l-.409-.266c-3.303-2.184-7.2-3.276-11.692-3.276zm111.695-62.018l-.001 57.432h53.51V87.172h39.24v152.863h-39.24v-59.617H555.9l.001 59.617h-39.24V87.172h39.24zM715.766 242c-8.72 0-16.581-1.893-23.583-5.678-6.87-3.785-12.287-9.681-16.251-17.688-3.832-8.153-5.747-18.417-5.747-30.791v-66.168h37.654v59.398c0 9.172 1.519 15.723 4.558 19.654 3.171 3.931 7.597 5.896 13.278 5.896 3.7 0 7.069-.946 10.108-2.839 3.038-1.892 5.483-4.877 7.332-8.953 1.85-4.222 2.775-9.609 2.775-16.16v-56.996h37.654v118.36h-35.871l.004-12.38c-2.642 3.197-5.682 5.868-9.12 8.012-7.002 4.222-14.599 6.333-22.791 6.333zM841.489 78l-.001 54.041a34.1 34.1 0 016.541-5.78c6.474-4.367 14.269-6.551 23.385-6.551 9.777 0 18.629 2.475 26.556 7.424 7.873 4.835 14.106 11.684 18.701 20.546l.325.637c4.756 9.026 7.134 19.799 7.134 32.319 0 12.666-2.378 23.585-7.134 32.757-4.624 9.026-10.966 16.087-19.026 21.182-7.927 4.95-16.779 7.425-26.556 7.425-9.645 0-17.704-2.184-24.178-6.551-2.825-1.946-5.336-4.354-7.531-7.224v11.81h-35.87V78h37.654zm21.998 74.684c-4.228 0-8.059 1.092-11.495 3.276-3.303 2.184-6.011 5.387-8.125 9.609-1.982 4.076-2.973 9.099-2.973 15.067 0 5.969.991 11.138 2.973 15.505 2.114 4.222 4.822 7.425 8.125 9.609 3.436 2.183 7.267 3.275 11.495 3.275 4.228 0 7.993-1.092 11.296-3.275 3.435-2.184 6.144-5.387 8.126-9.609 2.114-4.367 3.171-9.536 3.171-15.505 0-5.968-1.057-10.991-3.171-15.067-1.906-4.06-4.484-7.177-7.733-9.352l-.393-.257c-3.303-2.184-7.068-3.276-11.296-3.276z"
/>
</svg>
</div>
</body>
</html>
+14
View File
@@ -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}`);
});
+26
View File
@@ -0,0 +1,26 @@
import type { BrowserWindowOpts } from './core/Browser';
export const chat: BrowserWindowOpts = {
autoHideMenuBar: true,
height: 800,
identifier: 'chat',
keepAlive: true,
minWidth: 400,
path: '/chat',
titleBarStyle: 'hidden',
vibrancy: 'under-window',
width: 1200,
};
export const devtools: BrowserWindowOpts = {
autoHideMenuBar: true,
fullscreenable: false,
height: 600,
identifier: 'devtools',
maximizable: false,
minWidth: 400,
path: '/desktop/devtools',
titleBarStyle: 'hiddenInset',
vibrancy: 'under-window',
width: 1000,
};
+14
View File
@@ -0,0 +1,14 @@
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');
+1
View File
@@ -0,0 +1 @@
export const isDev = process.env.NODE_ENV === 'development';
@@ -0,0 +1,9 @@
import { devtools } from '../appBrowsers';
import { ControllerModule } from './index';
export default class DevtoolsCtr extends ControllerModule {
// @event('openDevtools')
async openDevtools() {
this.app.browserManager.retrieveOrInitialize(devtools);
}
}
@@ -0,0 +1,30 @@
import type { ClientDispatchEvents } from '@lobechat/electron-client-ipc';
import type { App } from '../core/App';
import { IoCContainer } from '../core/IoCContainer';
const baseDecorator =
(name: string, showLog = true) =>
(target: any, methodName: string, descriptor?: any) => {
const actions = IoCContainer.controllers.get(target.constructor) || [];
actions.push({
methodName,
name,
showLog,
});
IoCContainer.controllers.set(target.constructor, actions);
return descriptor;
};
/**
* service 用的 event 装饰器
*/
export const ipcClientEvent = (method: keyof ClientDispatchEvents) => baseDecorator(method);
export class ControllerModule {
constructor(public app: App) {
this.app = app;
}
}
export type IControlModule = typeof ControllerModule;
+138
View File
@@ -0,0 +1,138 @@
import { Session, app, ipcMain, protocol } from 'electron';
import { macOS, windows } from 'electron-is';
import { createHandler } from 'next-electron-rsc';
import { join } from 'node:path';
import * as appBrowsers from '../appBrowsers';
import { buildDir, nextStandaloneDir } from '../const/dir';
import { isDev } from '../const/env';
import { IControlModule } from '../controllers';
import BrowserManager from './BrowserManager';
import { initIPCServer } from './IPCServer';
import { IoCContainer } from './IoCContainer';
export type IPCClientEventMap = Map<string, any>;
const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
export class App {
/**
* all controllers in app
*/
private controllers = new WeakMap();
nextServerUrl = 'http://localhost:3010';
/**
* 承接 webview fetch 的事件表
*/
private ipcClientEventMap: IPCClientEventMap = new Map();
browserManager: BrowserManager;
nextInterceptor: ({ session }: { session: Session }) => () => void;
constructor() {
// load controllers
const controllers: IControlModule[] = importAll(
// @ts-ignore
import.meta.glob('../controllers/*Ctr.ts', { eager: true }),
);
controllers.forEach((service) => this.addController(service));
// 批量注册 controller 中 event 事件 供 render 端消费
this.ipcClientEventMap.forEach((serviceInfo, key) => {
// 获取相应方法
const { service, methodName } = serviceInfo;
ipcMain.handle(key, async (e, ...data) => {
try {
return await service[methodName](...data);
} catch (error) {
return { error: error.message };
}
});
});
this.browserManager = new BrowserManager(this);
}
private onActivate = () => {
this.browserManager.showMainWindow();
};
bootstrap = async () => {
// make single instance
const isSingle = app.requestSingleInstanceLock();
if (!isSingle) app.exit(0);
this.initDevBranding();
// ==============
await initIPCServer();
// register the schema to interceptor url
// it should register before app ready
this.registerNextHandler();
await app.whenReady();
app.on('ready', async () => {
this.initBrowsers();
});
app.on('window-all-closed', () => {
if (windows()) {
app.quit();
}
});
app.on('activate', this.onActivate);
};
private addController = (ControllerClass: IControlModule) => {
const service = new ControllerClass(this);
this.controllers.set(ControllerClass, service);
IoCContainer.controllers.get(ControllerClass)?.forEach((event) => {
// 将 event 装饰器中的对象全部存到 ipcClientEventMap 中
this.ipcClientEventMap.set(event.name, {
methodName: event.methodName,
service,
});
});
};
private initDevBranding = () => {
if (!isDev) return;
app.setName('LobeHub Dev');
if (macOS()) {
app.dock!.setIcon(join(buildDir, 'icon-dev.png'));
}
};
/**
* 添加窗口
*/
private initBrowsers() {
Object.values(appBrowsers).forEach((item) => {
this.browserManager.retrieveOrInitialize(item);
});
}
private registerNextHandler() {
if (isDev) return;
const handler = createHandler({
debug: true,
localhostUrl: this.nextServerUrl,
protocol,
standaloneDir: nextStandaloneDir,
});
console.log(
`[APP] Server Debugging Enabled, ${this.nextServerUrl} will be intercepted to ${nextStandaloneDir}`,
);
this.nextInterceptor = handler.createInterceptor;
}
}
+173
View File
@@ -0,0 +1,173 @@
import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain } from 'electron';
import { join } from 'node:path';
import { preloadDir, resourcesDir } from '../const/dir';
import { isDev } from '../const/env';
import type { App } from './App';
export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
devTools?: boolean;
height?: number;
/**
* URL
*/
identifier: string;
keepAlive?: boolean;
path: string;
title?: string;
width?: number;
}
export default class Browser {
private app: App;
/**
* 内部的 electron 窗口
*/
private _browserWindow?: BrowserWindow;
private stopInterceptHandler;
/**
* 标识符
*/
identifier: string;
/**
* 生成时的选项
*/
options: BrowserWindowOpts;
/**
* 对外暴露的获取窗口的方法
*/
get browserWindow() {
return this.retrieveOrInitialize();
}
/**
* 构建 BrowserWindows 对象的方法
* @param options
* @param application
*/
constructor(options: BrowserWindowOpts, application: App) {
this.app = application;
this.identifier = options.identifier;
this.options = options;
// 初始化
this.retrieveOrInitialize();
}
loadUrl = async (path: string) => {
const initUrl = this.app.nextServerUrl + path;
try {
await this._browserWindow.loadURL(initUrl);
console.log('[APP] Loaded', initUrl);
} catch (error) {
console.error('[APP] Failed to load URL:', error);
// 加载本地错误页面
await this._browserWindow.loadFile(join(resourcesDir, 'error.html'));
// 设置简单的重试逻辑
ipcMain.on('retry-connection', async () => {
try {
await this._browserWindow?.loadURL(initUrl);
console.log('[APP] Reconnected successfully');
} catch (err) {
console.error('[APP] Retry failed:', err);
// 重新加载错误页面,重置状态
this._browserWindow?.loadFile(join(resourcesDir, 'error.html'));
}
});
}
};
loadPlaceholder = async () => {
// 首先加载一个本地的HTML加载页面
await this._browserWindow.loadFile(join(resourcesDir, 'splash.html'));
};
show() {
this.browserWindow.show();
}
hide() {
this.browserWindow.hide();
}
/**
* 销毁实例
*/
destroy() {
this.stopInterceptHandler?.();
this._browserWindow = undefined;
}
/**
* 初始化
*/
retrieveOrInitialize() {
// 当有这个窗口 且这个窗口没有被注销时
if (this._browserWindow && !this._browserWindow.isDestroyed()) {
return this._browserWindow;
}
const { path, title, width, height, devTools, ...res } = this.options;
const browserWindow = new BrowserWindow({
...res,
height,
show: false,
title,
webPreferences: {
// 上下文隔离环境
// https://www.electronjs.org/docs/tutorial/context-isolation
contextIsolation: true,
preload: join(preloadDir, 'index.js'),
// devTools: isDev,
},
width,
});
this._browserWindow = browserWindow;
if (!isDev) {
this.stopInterceptHandler = this.app.nextInterceptor({
session: browserWindow.webContents.session,
});
}
// Windows 11 可以使用这个新 API
if (process.platform === 'win32' && browserWindow.setBackgroundMaterial) {
browserWindow.setBackgroundMaterial('acrylic');
}
this.loadPlaceholder().then(() => {
this.loadUrl(path).catch((e) => {
console.error(`load url error, ${path}`, e);
});
});
// 显示 devtools 就打开
if (devTools) {
browserWindow.webContents.openDevTools();
}
browserWindow.once('ready-to-show', () => {
browserWindow?.show();
});
browserWindow.on('close', () => {
// the ones who need keepAlive won't be destroyed
this.stopInterceptHandler();
if (this.options.keepAlive) {
console.log('needto');
// e.preventDefault();
// browserWindow.hide();
}
});
return browserWindow;
}
}
@@ -0,0 +1,36 @@
import { chat } from '../appBrowsers';
import type { App } from './App';
import type { BrowserWindowOpts } from './Browser';
import Browser from './Browser';
export default class BrowserManager {
app: App;
browsers: Map<string, Browser | null> = new Map();
constructor(app: App) {
this.app = app;
}
/**
* 启动或初始化
* @param options
*/
retrieveOrInitialize(options: BrowserWindowOpts) {
let browser = this.browsers.get(options.identifier);
if (browser) {
return browser;
}
browser = new Browser(options, this.app);
this.browsers.set(options.identifier, browser);
return browser;
}
showMainWindow() {
const window = this.retrieveOrInitialize(chat);
window.show();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { ElectronIPCServer } from '@lobechat/electron-server-ipc';
import { ipcEvent } from '../ipcServer';
const ipcServer = new ElectronIPCServer(ipcEvent);
export const initIPCServer = async (): Promise<ElectronIPCServer> => {
await ipcServer.start();
return ipcServer;
};
@@ -0,0 +1,9 @@
/**
* 存储插件中的 service
*/
export class IoCContainer {
static controllers: WeakMap<any, { methodName: string; name: string; showLog?: boolean }[]> =
new WeakMap();
init() {}
}
+5
View File
@@ -0,0 +1,5 @@
import { App } from './core/App';
const app = new App();
app.bootstrap();
+29
View File
@@ -0,0 +1,29 @@
import { IpcDispatchEvent } from '@lobechat/electron-server-ipc';
import { app } from 'electron';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const userDataPath = app.getPath('userData');
const DB_SCHEMA_HASH_PATH = path.join(userDataPath, 'lobehub-local-db-schema-hash');
export const ipcEvent: IpcDispatchEvent = {
getDatabasePath: async () => {
return path.join(userDataPath, 'lobehub-local-db');
},
getDatabaseSchemaHash: async () => {
try {
return readFileSync(DB_SCHEMA_HASH_PATH, 'utf8');
} catch {
return undefined;
}
},
getUserDataPath: async () => {
return userDataPath;
},
setDatabaseSchemaHash: async (hash: string) => {
writeFileSync(DB_SCHEMA_HASH_PATH, hash, 'utf8');
},
};
+46
View File
@@ -0,0 +1,46 @@
import { electronAPI } from '@electron-toolkit/preload';
import { ClientDispatchEventKey, DispatchInvoke } from '@lobechat/electron-client-ipc';
import { contextBridge, ipcRenderer } from 'electron';
// Custom APIs for renderer
const api = {};
// 添加 IPC 通信接口
const ipcApi = {
receive: (channel: string, callback: (...args: any[]) => void) => {
// 包装回调函数,确保安全性
const subscription = (_event: any, ...args: any[]) => callback(...args);
ipcRenderer.on(channel, subscription);
// 返回取消订阅的函数
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
send: (channel: string, ...args: any[]) => {
console.log('channel', channel);
ipcRenderer.send(channel, ...args);
},
};
// 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('api', api);
contextBridge.exposeInMainWorld('electron', {
...electronAPI,
ipcRenderer: ipcApi,
});
} catch (error) {
console.error(error);
}
/**
* client 端请求 electron main 端方法
*/
const invoke: DispatchInvoke = async <T extends ClientDispatchEventKey>(event: T, ...data: any[]) =>
ipcRenderer.invoke(event, ...data);
contextBridge.exposeInMainWorld('electronAPI', { invoke });
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"target": "ESNext",
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"baseUrl": "."
},
"include": ["src/main/**/*", "src/preload/**/*", "electron-builder.js"]
}
+33
View File
@@ -1,4 +1,37 @@
[
{
"children": {
"improvements": ["Refactor the db to context inject mode."]
},
"date": "2025-04-01",
"version": "1.77.6"
},
{
"children": {},
"date": "2025-04-01",
"version": "1.77.5"
},
{
"children": {
"improvements": ["Update branding."]
},
"date": "2025-03-31",
"version": "1.77.4"
},
{
"children": {
"improvements": ["Move general db models to database folder."]
},
"date": "2025-03-29",
"version": "1.77.3"
},
{
"children": {
"fixes": ["Fix decrypt error with imported pg data."]
},
"date": "2025-03-29",
"version": "1.77.2"
},
{
"children": {
"fixes": ["Fix export button and clean orphan agent."]
@@ -578,7 +578,7 @@ If you need to use Azure OpenAI to provide model services, you can refer to the
- Type: Optional
- Description: Used to control the model list, use `+` to add a model, use `-` to hide a model, use `model_name->deploymentName=display_name` to customize the display name of a model, separated by commas. Definition syntax rules see [model-list][model-list]
- Default: `-`
- Example: `-all,+deepseek-r1->deepseek-r1-250120,+deepseek-v3->deepseek-v3-241226,+doubao-1.5-pro-256k->doubao-1-5-pro-256k-250115,+doubao-1.5-pro-32k->doubao-1-5-pro-32k-250115,+doubao-1.5-lite-32k->doubao-1-5-lite-32k-250115`
- Example: `-all,+deepseek-r1->deepseek-r1-250120,+deepseek-v3->deepseek-v3-250324,+doubao-1.5-pro-256k->doubao-1-5-pro-256k-250115,+doubao-1.5-pro-32k->doubao-1-5-pro-32k-250115,+doubao-1.5-lite-32k->doubao-1-5-lite-32k-250115`
### `VOLCENGINE_PROXY_URL`
@@ -577,7 +577,7 @@ LobeChat 在部署时提供了丰富的模型服务商相关的环境变量,
- 类型:可选
- 描述:用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名->部署名=展示名<扩展配置>` 来自定义模型的展示名,用英文逗号隔开。模型定义语法规则见 [模型列表][model-list]
- 默认值:`-`
- 示例:`-all,+deepseek-r1->deepseek-r1-250120,+deepseek-v3->deepseek-v3-241226,+doubao-1.5-pro-256k->doubao-1-5-pro-256k-250115,+doubao-1.5-pro-32k->doubao-1-5-pro-32k-250115,+doubao-1.5-lite-32k->doubao-1-5-lite-32k-250115`
- 示例:`-all,+deepseek-r1->deepseek-r1-250120,+deepseek-v3->deepseek-v3-250324,+doubao-1.5-pro-256k->doubao-1-5-pro-256k-250115,+doubao-1.5-pro-32k->doubao-1-5-pro-32k-250115,+doubao-1.5-lite-32k->doubao-1-5-lite-32k-250115`
### `VOLCENGINE_PROXY_URL`
+42 -11
View File
@@ -6,14 +6,48 @@ import ReactComponentName from 'react-scan/react-component-name/webpack';
const isProd = process.env.NODE_ENV === 'production';
const buildWithDocker = process.env.DOCKER === 'true';
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const enableReactScan = !!process.env.REACT_SCAN_MONITOR_API_KEY;
const isUsePglite = process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite';
// if you need to proxy the api endpoint to remote server
const basePath = process.env.NEXT_PUBLIC_BASE_PATH;
const isStandaloneMode = buildWithDocker || isDesktop;
// 创建需要排除的特性映射
/* eslint-disable sort-keys-fix/sort-keys-fix */
// const partialBuildPages = [
// // {
// // name: 'changelog',
// // disabled: isDesktop,
// // paths: ['src/app/[variants]/@modal/(.)changelog', 'src/app/[variants]/(main)/changelog'],
// // },
// {
// name: 'desktop-devtools',
// disabled: isDesktop,
// paths: ['src/app/desktop'],
// },
// // {
// // name: 'auth',
// // disabled: isDesktop,
// // paths: ['src/app/[variants]/(auth)'],
// // },
// {
// name: 'desktop-trpc',
// disabled: isDesktop,
// paths: ['src/app/(backend)/trpc/desktop'],
// },
// ];
/* eslint-enable */
const standaloneConfig: NextConfig = {
output: 'standalone',
outputFileTracingIncludes: { '*': ['public/**/*', '.next/static/**/*'] },
};
const nextConfig: NextConfig = {
...(isStandaloneMode ? standaloneConfig : {}),
basePath,
compress: isProd,
experimental: {
@@ -110,10 +144,6 @@ const nextConfig: NextConfig = {
hmrRefreshes: true,
},
},
output: buildWithDocker ? 'standalone' : undefined,
outputFileTracingIncludes: buildWithDocker
? { '*': ['public/**/*', '.next/static/**/*'] }
: undefined,
reactStrictMode: true,
redirects: async () => [
{
@@ -231,13 +261,14 @@ const noWrapper = (config: NextConfig) => config;
const withBundleAnalyzer = process.env.ANALYZE === 'true' ? analyzer() : noWrapper;
const withPWA = isProd
? withSerwistInit({
register: false,
swDest: 'public/sw.js',
swSrc: 'src/app/sw.ts',
})
: noWrapper;
const withPWA =
isProd && !isDesktop
? withSerwistInit({
register: false,
swDest: 'public/sw.js',
swSrc: 'src/app/sw.ts',
})
: noWrapper;
const hasSentry = !!process.env.NEXT_PUBLIC_SENTRY_DSN;
const withSentry =
+13 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/chat",
"version": "1.77.2",
"version": "1.77.6",
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",
@@ -35,6 +35,7 @@
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"build:analyze": "ANALYZE=true next build",
"build:docker": "DOCKER=true next build && npm run build-sitemap",
"build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=6144 NEXT_PUBLIC_IS_DESKTOP_APP=1 NEXT_PUBLIC_SERVICE_MODE=server next build",
"db:generate": "drizzle-kit generate && npm run db:generate-client && npm run workflow:dbml",
"db:generate-client": "tsx ./scripts/migrateClientDB/compile-migrations.ts",
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
@@ -43,6 +44,10 @@
"db:studio": "drizzle-kit studio",
"db:visualize": "dbdocs build docs/developer/database-schema.dbml --project lobe-chat",
"db:z-pull": "drizzle-kit introspect",
"desktop:build": "npm run desktop:build-next && npm run desktop:prepare-dist && npm run desktop:build-electron",
"desktop:build-electron": "tsx scripts/electronWorkflow/buildElectron.ts",
"desktop:build-next": "npm run build:electron",
"desktop:prepare-dist": "tsx scripts/electronWorkflow/moveNextStandalone.ts",
"dev": "next dev --turbopack -p 3010",
"docs:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx",
"docs:seo": "lobe-seo && npm run lint:mdx",
@@ -77,7 +82,8 @@
"workflow:docs": "tsx ./scripts/docsWorkflow/index.ts",
"workflow:i18n": "tsx ./scripts/i18nWorkflow/index.ts",
"workflow:mdx": "tsx ./scripts/mdxWorkflow/index.ts",
"workflow:readme": "tsx ./scripts/readmeWorkflow/index.ts"
"workflow:readme": "tsx ./scripts/readmeWorkflow/index.ts",
"workflow:set-desktop-version": "tsx ./scripts/electronWorkflow/setDesktopVersion.ts"
},
"lint-staged": {
"*.md": [
@@ -129,6 +135,8 @@
"@icons-pack/react-simple-icons": "9.6.0",
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.37",
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/charts": "^1.12.0",
"@lobehub/chat-plugin-sdk": "^1.32.4",
@@ -136,7 +144,7 @@
"@lobehub/icons": "^1.73.1",
"@lobehub/tts": "^1.28.0",
"@lobehub/ui": "^1.169.2",
"@neondatabase/serverless": "^0.10.4",
"@neondatabase/serverless": "^1.0.0",
"@next/third-parties": "15.2.3",
"@react-spring/web": "^9.7.5",
"@sentry/nextjs": "^7.120.2",
@@ -182,7 +190,7 @@
"langfuse": "3.29.1",
"langfuse-core": "3.29.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.483.0",
"lucide-react": "^0.485.0",
"mammoth": "^1.9.0",
"mdast-util-to-markdown": "^2.1.2",
"modern-screenshot": "^4.5.5",
@@ -290,6 +298,7 @@
"ajv-keywords": "^5.1.0",
"commitlint": "^19.6.1",
"consola": "^3.3.3",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"dbdocs": "^0.14.3",
"dotenv": "^16.4.7",
+48
View File
@@ -0,0 +1,48 @@
# @lobechat/electron-client-ipc
这个包是 LobeChat 在 Electron 环境中用于处理 IPC(进程间通信)的客户端工具包。
## 介绍
在 Electron 应用中,IPC(进程间通信)是连接主进程(Main Process)、渲染进程(Renderer Process)以及 NextJS 进程的桥梁。为了更好地组织和管理这些通信,我们将 IPC 相关的代码分成了两个包:
- `@lobechat/electron-client-ipc`**客户端 IPC 包**
- `@lobechat/electron-server-ipc`**服务端 IPC 包**
## 主要区别
### electron-client-ipc(本包)
- 运行环境:在渲染进程(Renderer Process)中运行
- 主要职责:
- 提供渲染进程调用主进程方法的接口定义
- 封装 `ipcRenderer.invoke` 相关方法
- 处理与主进程的通信请求
### electron-server-ipc
- 运行环境:在 Electron 主进程和 Next.js 服务端进程中运行
- 主要职责:
- 提供基于 Socket 的 IPC 通信机制
- 实现服务端(ElectronIPCServer)和客户端(ElectronIpcClient)通信组件
- 处理跨进程的请求和响应
- 提供自动重连和错误处理机制
- 确保类型安全的 API 调用
## 使用场景
当渲染进程需要:
- 访问系统 API
- 进行文件操作
- 调用主进程特定功能
时,都需要通过 `electron-client-ipc` 包提供的方法来发起请求。
## 技术说明
这种分包设计遵循了关注点分离原则,使得:
- IPC 通信接口清晰可维护
- 客户端和服务端代码解耦
- TypeScript 类型定义共享,确保类型安全
@@ -0,0 +1,7 @@
{
"name": "@lobechat/electron-client-ipc",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts"
}
@@ -0,0 +1,6 @@
export interface DevtoolsDispatchEvents {
/**
* open the LobeHub Devtools
*/
openDevtools: () => void;
}
@@ -0,0 +1,13 @@
import { DevtoolsDispatchEvents } from './devtools';
/**
* renderer -> main dispatch events
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ClientDispatchEvents extends DevtoolsDispatchEvents {}
export type ClientDispatchEventKey = keyof ClientDispatchEvents;
export type ClientEventReturnType<T extends ClientDispatchEventKey> = ReturnType<
ClientDispatchEvents[T]
>;
@@ -0,0 +1,2 @@
export * from './events';
export * from './types';
@@ -0,0 +1,10 @@
import type {
ClientDispatchEventKey,
ClientDispatchEvents,
ClientEventReturnType,
} from '../events';
export type DispatchInvoke = <T extends ClientDispatchEventKey>(
event: T,
...data: Parameters<ClientDispatchEvents[T]>
) => Promise<ClientEventReturnType<T>>;
@@ -0,0 +1 @@
export * from './dispatch';
+54
View File
@@ -0,0 +1,54 @@
# @lobechat/electron-server-ipc
LobeHub 的 Electron 应用与服务端之间的 IPC(进程间通信)模块,提供可靠的跨进程通信能力。
## 📝 简介
`@lobechat/electron-server-ipc` 是 LobeHub 桌面应用的核心组件,负责处理 Electron 主进程与 nextjs 服务端之间的通信。它提供了一套简单而健壮的 API,用于在不同进程间传递数据和执行远程方法调用。
## 🛠️ 核心功能
- **可靠的 IPC 通信**: 基于 Socket 的通信机制,确保跨进程通信的稳定性和可靠性
- **自动重连机制**: 客户端具备断线重连功能,提高应用稳定性
- **类型安全**: 使用 TypeScript 提供完整的类型定义,确保 API 调用的类型安全
- **跨平台支持**: 同时支持 Windows、macOS 和 Linux 平台
## 🧩 核心组件
### IPC 服务端 (ElectronIPCServer)
负责监听客户端请求并响应,通常运行在 Electron 的主进程中:
```typescript
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
// 定义处理函数
const eventHandler: ElectronIPCEventHandler = {
getDatabasePath: async () => {
return '/path/to/database';
},
// 其他处理函数...
};
// 创建并启动服务器
const server = new ElectronIPCServer(eventHandler);
server.start();
```
### IPC 客户端 (ElectronIpcClient)
负责连接到服务端并发送请求,通常在服务端(如 Next.js 服务)中使用:
```typescript
import { ElectronIPCMethods, ElectronIpcClient } from '@lobechat/electron-server-ipc';
// 创建客户端
const client = new ElectronIpcClient();
// 发送请求
const dbPath = await client.sendRequest(ElectronIPCMethods.getDatabasePath);
```
## 📌 说明
这是 LobeHub 的内部模块 (`"private": true`),专为 LobeHub 桌面应用设计,不作为独立包发布。
@@ -0,0 +1,7 @@
{
"name": "@lobechat/electron-server-ipc",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts"
}
@@ -0,0 +1,5 @@
export const SOCK_FILE = 'lobehub-electron-ipc.sock';
export const SOCK_INFO_FILE = 'lobehub-electron-ipc-info.json';
export const WINDOW_PIPE_FILE = '\\\\.\\pipe\\lobehub-electron-ipc';
@@ -0,0 +1,3 @@
export * from './ipcClient';
export * from './ipcServer';
export * from './types';
@@ -0,0 +1,211 @@
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ElectronIpcClient } from './ipcClient';
import { ElectronIPCMethods } from './types';
// Mock node modules
vi.mock('node:fs');
vi.mock('node:net');
vi.mock('node:os');
vi.mock('node:path');
describe('ElectronIpcClient', () => {
// Mock data
const mockTempDir = '/mock/temp/dir';
const mockSocketInfoPath = '/mock/temp/dir/lobehub-electron-ipc-info.json';
const mockSocketInfo = { socketPath: '/mock/socket/path' };
// Mock socket
const mockSocket = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
beforeEach(() => {
// Use fake timers
vi.useFakeTimers();
// Reset all mocks
vi.resetAllMocks();
// Setup common mocks
vi.mocked(os.tmpdir).mockReturnValue(mockTempDir);
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
vi.mocked(net.createConnection).mockReturnValue(mockSocket as unknown as net.Socket);
// Mock console methods
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('initialization', () => {
it('should initialize with socket path from info file if it exists', () => {
// Setup
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSocketInfo));
// Execute
new ElectronIpcClient();
// Verify
expect(fs.existsSync).toHaveBeenCalledWith(mockSocketInfoPath);
expect(fs.readFileSync).toHaveBeenCalledWith(mockSocketInfoPath, 'utf8');
});
it('should initialize with default socket path if info file does not exist', () => {
// Setup
vi.mocked(fs.existsSync).mockReturnValue(false);
// Execute
new ElectronIpcClient();
// Verify
expect(fs.existsSync).toHaveBeenCalledWith(mockSocketInfoPath);
expect(fs.readFileSync).not.toHaveBeenCalled();
// Test platform-specific behavior
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
new ElectronIpcClient();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should handle initialization errors gracefully', () => {
// Setup - Mock the error
vi.mocked(fs.existsSync).mockImplementation(() => {
throw new Error('Mock file system error');
});
// Execute
new ElectronIpcClient();
// Verify
expect(console.error).toHaveBeenCalledWith(
'Failed to initialize IPC client:',
expect.objectContaining({ message: 'Mock file system error' }),
);
});
});
describe('connection and request handling', () => {
let client: ElectronIpcClient;
beforeEach(() => {
// 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();
// Reset socket mocks for each test
mockSocket.on.mockReset();
mockSocket.write.mockReset();
// Default implementation for socket.on
mockSocket.on.mockImplementation((event, callback) => {
return mockSocket;
});
// Default implementation for socket.write
mockSocket.write.mockImplementation((data, callback) => {
if (callback) callback();
return true;
});
});
it('should handle connection errors', async () => {
// Start request - but don't await it yet
const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath);
// Find the error event handler
const errorCallArgs = mockSocket.on.mock.calls.find((call) => call[0] === 'error');
if (errorCallArgs && typeof errorCallArgs[1] === 'function') {
const errorHandler = errorCallArgs[1];
// Trigger the error handler
errorHandler(new Error('Connection error'));
}
// Now await the promise
await expect(requestPromise).rejects.toThrow('Connection error');
});
it('should handle write errors', async () => {
// Setup connection callback
let connectionCallback: Function | undefined;
vi.mocked(net.createConnection).mockImplementation((path, callback) => {
connectionCallback = callback as Function;
return mockSocket as unknown as net.Socket;
});
// Setup write to fail
mockSocket.write.mockImplementation((data, callback) => {
if (callback) callback(new Error('Write error'));
return true;
});
// Start request
const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath);
// Simulate connection established
if (connectionCallback) connectionCallback();
// Now await the promise
await expect(requestPromise).rejects.toThrow('Write error');
});
});
describe('close method', () => {
let client: ElectronIpcClient;
beforeEach(() => {
// 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();
// Setup socket.on
mockSocket.on.mockImplementation((event, callback) => {
return mockSocket;
});
});
it('should close the socket connection', async () => {
// Setup connection callback
let connectionCallback: Function | undefined;
vi.mocked(net.createConnection).mockImplementation((path, callback) => {
connectionCallback = callback as Function;
return mockSocket as unknown as net.Socket;
});
// Start a request to establish connection (but don't wait for it)
const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath).catch(() => {}); // Ignore any errors
// Simulate connection
if (connectionCallback) connectionCallback();
// Close the connection
client.close();
// Verify
expect(mockSocket.end).toHaveBeenCalled();
});
it('should handle close when not connected', () => {
// Close without connecting
client.close();
// Verify no errors
expect(mockSocket.end).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,179 @@
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const';
import { IElectronIPCMethods } from './types';
export class ElectronIpcClient {
private socketPath: string | null = null;
private connected: boolean = false;
private socket: net.Socket | null = null;
// eslint-disable-next-line @typescript-eslint/ban-types
private requestQueue: Map<string, { reject: Function; resolve: Function }> = new Map();
// eslint-disable-next-line no-undef
private reconnectTimeout: NodeJS.Timeout | null = null;
private connectionAttempts: number = 0;
private maxConnectionAttempts: number = 5;
constructor() {
this.initialize();
}
// 初始化客户端
private initialize() {
try {
// 从临时文件读取套接字路径
const tempDir = os.tmpdir();
const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE);
if (fs.existsSync(socketInfoPath)) {
const socketInfo = JSON.parse(fs.readFileSync(socketInfoPath, 'utf8'));
this.socketPath = socketInfo.socketPath;
} else {
// 如果找不到套接字信息,使用默认路径
this.socketPath =
process.platform === 'win32' ? WINDOW_PIPE_FILE : path.join(os.tmpdir(), SOCK_FILE);
}
} catch (err) {
console.error('Failed to initialize IPC client:', err);
this.socketPath = null;
}
}
// 连接到 Electron IPC 服务器
private connect(): Promise<void> {
if (this.connected || !this.socketPath) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
try {
this.socket = net.createConnection(this.socketPath!, () => {
this.connected = true;
this.connectionAttempts = 0;
console.log('Connected to Electron IPC server');
resolve();
});
this.socket.on('data', (data) => {
try {
const response = JSON.parse(data.toString());
const { id, result, error } = response;
const pending = this.requestQueue.get(id);
if (pending) {
this.requestQueue.delete(id);
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(result);
}
}
} catch (err) {
console.error('Failed to parse response:', err);
}
});
this.socket.on('error', (err) => {
console.error('Socket error:', err);
this.connected = false;
this.handleDisconnect();
reject(err);
});
this.socket.on('close', () => {
console.log('Socket closed');
this.connected = false;
this.handleDisconnect();
});
} catch (err) {
console.error('Failed to connect to IPC server:', err);
this.handleDisconnect();
reject(err);
}
});
}
// 处理断开连接
private handleDisconnect() {
// 清除重连定时器
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// 拒绝所有待处理的请求
for (const [, { reject }] of this.requestQueue) {
reject(new Error('Connection to Electron IPC server lost'));
}
this.requestQueue.clear();
// 尝试重新连接
if (this.connectionAttempts < this.maxConnectionAttempts) {
this.connectionAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.connectionAttempts - 1), 30_000);
this.reconnectTimeout = setTimeout(() => {
this.connect().catch((err) => {
console.error(`Reconnection attempt ${this.connectionAttempts} failed:`, err);
});
}, delay);
}
}
// 发送请求到 Electron IPC 服务器
public async sendRequest<T>(method: IElectronIPCMethods, params: any = {}): Promise<T> {
if (!this.socketPath) {
throw new Error('Electron IPC connection not available');
}
// 如果未连接,先连接
if (!this.connected) {
await this.connect();
}
return new Promise<T>((resolve, reject) => {
try {
const id = Math.random().toString(36).slice(2, 15);
const request = { id, method, params };
// 将请求添加到队列
this.requestQueue.set(id, { reject, resolve });
// 设置超时
const timeout = setTimeout(() => {
this.requestQueue.delete(id);
reject(new Error(`Request ${method} timed out`));
}, 10_000);
// 发送请求
this.socket!.write(JSON.stringify(request), (err) => {
if (err) {
clearTimeout(timeout);
this.requestQueue.delete(id);
reject(err);
}
});
} catch (err) {
reject(err);
}
});
}
// 关闭连接
public close() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.socket) {
this.socket.end();
this.socket = null;
}
this.connected = false;
}
}
@@ -0,0 +1,126 @@
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const';
import { IElectronIPCMethods } from './types';
export type IPCEventMethod = (
params: any,
context: { id: string; method: string; socket: net.Socket },
) => Promise<any>;
export type ElectronIPCEventHandler = {
[key in IElectronIPCMethods]: IPCEventMethod;
};
export class ElectronIPCServer {
private server: net.Server;
private socketPath: string;
private eventHandler: ElectronIPCEventHandler;
constructor(eventHandler: ElectronIPCEventHandler) {
const isWindows = process.platform === 'win32';
// 创建唯一的套接字路径,避免冲突
this.socketPath = isWindows ? WINDOW_PIPE_FILE : path.join(os.tmpdir(), SOCK_FILE);
// 如果是 Unix 套接字,确保文件不存在
if (!isWindows && fs.existsSync(this.socketPath)) {
fs.unlinkSync(this.socketPath);
}
// 创建服务器
this.server = net.createServer(this.handleConnection.bind(this));
this.eventHandler = eventHandler;
}
// 启动服务器
public start(): Promise<void> {
return new Promise((resolve, reject) => {
this.server.on('error', (err) => {
console.error('IPC Server error:', err);
reject(err);
});
this.server.listen(this.socketPath, () => {
console.log(`Electron IPC server listening on ${this.socketPath}`);
// 将套接字路径写入临时文件,供 Next.js 服务端读取
const tempDir = os.tmpdir();
const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE);
fs.writeFileSync(socketInfoPath, JSON.stringify({ socketPath: this.socketPath }), 'utf8');
resolve();
});
});
}
// 处理客户端连接
private handleConnection(socket: net.Socket): void {
let dataBuffer = '';
socket.on('data', (data) => {
dataBuffer += data.toString();
try {
// 尝试解析 JSON 消息
const message = JSON.parse(dataBuffer);
dataBuffer = ''; // 重置缓冲区
// 处理请求
this.handleRequest(socket, message);
} catch {
// 如果不是有效的 JSON,可能是消息不完整,继续等待
}
});
socket.on('error', (err) => {
console.error('Socket error:', err);
});
}
// 处理客户端请求
private handleRequest = async (socket: net.Socket, request: any) => {
const { id, method, params } = request;
// 根据请求方法执行相应的操作
const eventHandler = this.eventHandler[method as IElectronIPCMethods];
if (!eventHandler) return;
try {
const data = await eventHandler(params, { id, method, socket });
this.sendResult(socket, id, data);
} catch (err) {
this.sendError(socket, id, `Failed to handle method(${method}): ${(err as Error).message}`);
}
};
// 发送结果
private sendResult(socket: net.Socket, id: string, result: any): void {
socket.write(JSON.stringify({ id, result }));
}
// 发送错误
private sendError(socket: net.Socket, id: string, error: string): void {
socket.write(JSON.stringify({ error, id }));
}
// 关闭服务器
public close(): Promise<void> {
return new Promise((resolve) => {
this.server.close(() => {
console.log('Electron IPC server closed');
// 删除套接字文件(Unix 平台)
if (process.platform !== 'win32' && fs.existsSync(this.socketPath)) {
fs.unlinkSync(this.socketPath);
}
resolve();
});
});
}
}
@@ -0,0 +1,18 @@
/* eslint-disable typescript-sort-keys/interface, sort-keys-fix/sort-keys-fix */
export const ElectronIPCMethods = {
getDatabasePath: 'getDatabasePath',
getUserDataPath: 'getUserDataPath',
getDatabaseSchemaHash: 'getDatabaseSchemaHash',
setDatabaseSchemaHash: 'setDatabaseSchemaHash',
} as const;
export type IElectronIPCMethods = keyof typeof ElectronIPCMethods;
export interface IpcDispatchEvent {
getDatabasePath: () => Promise<string>;
getUserDataPath: () => Promise<string>;
getDatabaseSchemaHash: () => Promise<string | undefined>;
setDatabaseSchemaHash: (hash: string) => Promise<void>;
}
@@ -0,0 +1 @@
export * from './event';
+1
View File
@@ -1,3 +1,4 @@
packages:
- 'packages/**'
- '.'
- '!apps/**'
+52
View File
@@ -0,0 +1,52 @@
/* eslint-disable unicorn/no-process-exit */
import { execSync } from 'node:child_process';
import os from 'node:os';
/**
* Build desktop application based on current operating system platform
*/
const buildElectron = () => {
const platform = os.platform();
const startTime = Date.now();
console.log(`🔨 Starting to build desktop app for ${platform} platform...`);
try {
let buildCommand = '';
// Determine build command based on platform
switch (platform) {
case 'darwin': {
buildCommand = 'npm run build:mac --prefix=./apps/desktop';
console.log('📦 Building macOS desktop application...');
break;
}
case 'win32': {
buildCommand = 'npm run build:win --prefix=./apps/desktop';
console.log('📦 Building Windows desktop application...');
break;
}
case 'linux': {
buildCommand = 'npm run build:linux --prefix=./apps/desktop';
console.log('📦 Building Linux desktop application...');
break;
}
default: {
throw new Error(`Unsupported platform: ${platform}`);
}
}
// Execute build command
execSync(buildCommand, { stdio: 'inherit' });
const endTime = Date.now();
const buildTime = ((endTime - startTime) / 1000).toFixed(2);
console.log(`✅ Desktop application build completed! (${buildTime}s)`);
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);
}
};
// Execute build
buildElectron();
@@ -0,0 +1,63 @@
/* eslint-disable unicorn/no-process-exit */
import fs from 'fs-extra';
import { execSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
const rootDir = path.resolve(__dirname, '../..');
// 定义源目录和目标目录
const sourceDir: string = path.join(rootDir, '.next/standalone');
const targetDir: string = path.join(rootDir, 'apps/desktop/dist/next');
// 确保目标目录的父目录存在
fs.ensureDirSync(path.dirname(targetDir));
// 如果目标目录已存在,先删除它
if (fs.existsSync(targetDir)) {
console.log(`🗑️ Target directory ${targetDir} already exists, deleting...`);
try {
fs.removeSync(targetDir);
console.log(`✅ Old target directory removed successfully`);
} catch (error) {
console.warn(`⚠️ Failed to delete target directory: ${error}`);
console.log('🔄 Trying to delete using system command...');
try {
if (os.platform() === 'win32') {
execSync(`rmdir /S /Q "${targetDir}"`, { stdio: 'inherit' });
} else {
execSync(`rm -rf "${targetDir}"`, { stdio: 'inherit' });
}
console.log('✅ Successfully deleted old target directory');
} catch (cmdError) {
console.error(`❌ Unable to delete target directory, might need manual cleanup: ${cmdError}`);
}
}
}
console.log(`🚚 Moving ${sourceDir} to ${targetDir}...`);
try {
// 使用 fs-extra 的 move 方法
fs.moveSync(sourceDir, targetDir, { overwrite: true });
console.log(`✅ Directory moved successfully!`);
} catch (error) {
console.error('❌ fs-extra move failed:', error);
console.log('🔄 Trying to move using system command...');
try {
// 使用系统命令进行移动
if (os.platform() === 'win32') {
execSync(`move "${sourceDir}" "${targetDir}"`, { stdio: 'inherit' });
} else {
execSync(`mv "${sourceDir}" "${targetDir}"`, { stdio: 'inherit' });
}
console.log('✅ System command move completed successfully!');
} catch (mvError) {
console.error('❌ Failed to move directory:', mvError);
console.log('💡 Try running manually: sudo mv ' + sourceDir + ' ' + targetDir);
process.exit(1);
}
}
console.log(`🎉 Move completed!`);
@@ -0,0 +1,43 @@
/* eslint-disable unicorn/no-process-exit */
import fs from 'fs-extra';
import path from 'node:path';
// 获取脚本的命令行参数
const version = process.argv[2];
if (!version) {
console.error('Missing version parameter, usage: bun run setDesktopVersion.ts <version>');
process.exit(1);
}
// 获取根目录
const rootDir = path.resolve(__dirname, '../..');
// 桌面应用 package.json 的路径
const desktopPackageJsonPath = path.join(rootDir, 'apps/desktop/package.json');
function updateVersion() {
try {
// 确保文件存在
if (!fs.existsSync(desktopPackageJsonPath)) {
console.error(`Error: File not found ${desktopPackageJsonPath}`);
process.exit(1);
}
// 读取 package.json 文件
const packageJson = fs.readJSONSync(desktopPackageJsonPath);
// 更新版本号
packageJson.version = version;
// 写回文件
fs.writeJsonSync(desktopPackageJsonPath, packageJson, { spaces: 2 });
console.log(`Desktop app version updated to: ${version}`);
} catch (error) {
console.error('Error updating version:', error);
process.exit(1);
}
}
updateVersion();
+1 -1
View File
@@ -3,7 +3,7 @@ import { migrate as neonMigrate } from 'drizzle-orm/neon-serverless/migrator';
import { migrate as nodeMigrate } from 'drizzle-orm/node-postgres/migrator';
import { join } from 'node:path';
import { serverDB } from '../../src/database/server/core/db';
import { serverDB } from '../../src/database/server';
import { DB_FAIL_INIT_HINT, PGVECTOR_HINT } from './errorHint';
// Read the `.env` file if it exists, or a file specified by the
@@ -0,0 +1,26 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import type { NextRequest } from 'next/server';
import { pino } from '@/libs/logger';
import { createContext } from '@/server/context';
import { desktopRouter } from '@/server/routers/desktop';
const handler = (req: NextRequest) =>
fetchRequestHandler({
/**
* @link https://trpc.io/docs/v11/context
*/
createContext: () => createContext(req),
endpoint: '/trpc/desktop',
onError: ({ error, path, type }) => {
pino.info(`Error in tRPC handler (tools) on path: ${path}, type: ${type}`);
console.error(error);
},
req,
router: desktopRouter,
});
export { handler as GET, handler as POST };
@@ -0,0 +1,12 @@
import { notFound } from 'next/navigation';
import { PropsWithChildren } from 'react';
import { isDesktop } from '@/const/version';
const Layout = ({ children }: PropsWithChildren) => {
if (isDesktop) return notFound();
return children;
};
export default Layout;
@@ -1,7 +1,8 @@
import { redirect } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import { Center } from 'react-layout-kit';
import BrandWatermark from '@/components/BrandWatermark';
import { isDesktop } from '@/const/version';
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
import { DynamicLayoutProps } from '@/types/next';
@@ -20,6 +21,7 @@ export const generateMetadata = async (props: DynamicLayoutProps) => {
};
const Page = async (props: DynamicLayoutProps) => {
if (isDesktop) return notFound();
const isMobile = await RouteVariants.getIsMobile(props);
if (!isMobile) return redirect('/chat');
@@ -4,10 +4,12 @@ import { SideNav } from '@lobehub/ui';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { Suspense, memo } from 'react';
import { isDesktop } from '@/const/version';
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { electronStylish } from '@/styles/electron';
import Avatar from './Avatar';
import BottomActions from './BottomActions';
@@ -28,13 +30,28 @@ const Nav = memo(() => {
return (
!inZenMode && (
<SideNav
avatar={<Avatar />}
bottomActions={<BottomActions />}
style={{ height: '100%', zIndex: 100 }}
avatar={
<div className={electronStylish.nodrag}>
<Avatar />
</div>
}
bottomActions={
<div className={electronStylish.nodrag}>
<BottomActions />
</div>
}
className={electronStylish.draggable}
style={{
height: '100%',
zIndex: 100,
...(isDesktop ? { background: 'transparent', paddingTop: 24 } : {}),
}}
topActions={
<Suspense>
<Top />
{showPinList && <PinList />}
<div className={electronStylish.nodrag}>
<Top />
{showPinList && <PinList />}
</div>
</Suspense>
}
/>
+11 -1
View File
@@ -1,9 +1,19 @@
import { notFound } from 'next/navigation';
import { ReactNode } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import { isDesktop } from '@/const/version';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
const MainLayout = ServerLayout({ Desktop, Mobile });
const Layout = ServerLayout({ Desktop, Mobile });
const MainLayout = (props: { children: ReactNode }) => {
if (isDesktop) return notFound();
return <Layout {...props} />;
};
MainLayout.displayName = 'ChangelogLayout';
@@ -4,6 +4,7 @@ import { ChatHeader } from '@lobehub/ui/chat';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { electronStylish } from '@/styles/electron';
import HeaderAction from './HeaderAction';
import Main from './Main';
@@ -14,8 +15,17 @@ const Header = () => {
return (
showHeader && (
<ChatHeader
left={<Main />}
right={<HeaderAction />}
className={electronStylish.draggable}
left={
<div className={electronStylish.nodrag}>
<Main />
</div>
}
right={
<div className={electronStylish.nodrag}>
<HeaderAction />
</div>
}
style={{ height: 48, minHeight: 48, paddingInline: 8, position: 'initial', zIndex: 11 }}
/>
)
@@ -3,6 +3,7 @@ import { Suspense } from 'react';
import StructuredData from '@/components/StructuredData';
import { serverFeatureFlags } from '@/config/featureFlags';
import { BRANDING_NAME } from '@/const/branding';
import { isDesktop } from '@/const/version';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
@@ -38,7 +39,7 @@ const Page = async (props: DynamicLayoutProps) => {
<StructuredData ld={ld} />
<PageTitle />
<TelemetryNotification mobile={isMobile} />
{showChangelog && !hideDocs && !isMobile && (
{!isDesktop && showChangelog && !hideDocs && !isMobile && (
<Suspense>
<Changelog />
</Suspense>
@@ -1,8 +1,8 @@
import { notFound } from 'next/navigation';
import { Flexbox } from 'react-layout-kit';
import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
import { serverDB } from '@/database/server';
import { KnowledgeBaseModel } from '@/database/server/models/knowledgeBase';
import Head from './Head';
import Menu from './Menu';
@@ -11,9 +11,10 @@ interface Params {
id: string;
}
type Props = { params: Params };
type Props = { params: Promise<Params> };
const MenuPage = async ({ params }: Props) => {
const MenuPage = async (props: Props) => {
const params = await props.params;
const id = params.id;
const item = await KnowledgeBaseModel.findById(serverDB, params.id);
@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation';
import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
import { serverDB } from '@/database/server';
import { KnowledgeBaseModel } from '@/database/server/models/knowledgeBase';
import FileManager from '@/features/FileManager';
import { PagePropsWithId } from '@/types/next';
@@ -2,8 +2,8 @@ import { redirect } from 'next/navigation';
import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
import { isServerMode } from '@/const/version';
import { AiInfraRepos } from '@/database/repositories/aiInfra';
import { serverDB } from '@/database/server';
import { AiInfraRepos } from '@/database/repositories/aiInfra';
import { getServerGlobalConfig } from '@/server/globalConfig';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { PagePropsWithId } from '@/types/next';
@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import { serverFeatureFlags } from '@/config/featureFlags';
import { isDesktop } from '@/const/version';
import { ChangelogService } from '@/server/services/changelog';
import { DynamicLayoutProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
@@ -11,6 +12,8 @@ import UpdateChangelogStatus from './features/UpdateChangelogStatus';
import Loading from './loading';
const Page = async (props: DynamicLayoutProps) => {
if (isDesktop) return notFound();
const hideDocs = serverFeatureFlags().hideDocs;
if (hideDocs) return notFound();
+3
View File
@@ -7,6 +7,7 @@ import { isRtlLang } from 'rtl-detect';
import Analytics from '@/components/Analytics';
import { DEFAULT_LANG } from '@/const/locale';
import { isDesktop } from '@/const/version';
import PWAInstall from '@/features/PWAInstall';
import AuthProvider from '@/layout/AuthProvider';
import GlobalProvider from '@/layout/GlobalProvider';
@@ -77,6 +78,8 @@ export const generateViewport = async (props: DynamicLayoutProps): ResolvingView
};
export const generateStaticParams = () => {
if (isDesktop) return [{ variants: 'desktop' }];
const themes: ThemeAppearance[] = ['dark', 'light'];
const mobileOptions = [true, false];
// only static for serveral page, other go to dynamtic
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { ActionIcon, FluentEmoji, SideNav } from '@lobehub/ui';
import { Cog, DatabaseIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { BRANDING_NAME } from '@/const/branding';
import PostgresViewer from '@/features/DevPanel/PostgresViewer';
import SystemInspector from '@/features/DevPanel/SystemInspector';
import { useStyles } from '@/features/DevPanel/features/FloatPanel';
import { electronStylish } from '@/styles/electron';
const DevTools = memo(() => {
const { styles, theme, cx } = useStyles();
const items = [
{
children: <PostgresViewer />,
icon: <DatabaseIcon size={16} />,
key: 'Postgres Viewer',
},
{
children: <SystemInspector />,
icon: <Cog size={16} />,
key: 'System Status',
},
];
const [tab, setTab] = useState<string>(items[0].key);
return (
<Flexbox
height={'100%'}
horizontal
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<SideNav
avatar={<FluentEmoji emoji={'🧰'} size={24} />}
bottomActions={[]}
style={{
paddingBlock: 32,
width: 48,
}}
topActions={items.map((item) => (
<ActionIcon
active={tab === item.key}
key={item.key}
onClick={() => setTab(item.key)}
placement={'right'}
title={item.key}
>
{item.icon}
</ActionIcon>
))}
/>
<Flexbox height={'100%'} style={{ overflow: 'hidden', position: 'relative' }} width={'100%'}>
<Flexbox
align={'center'}
className={cx(`panel-drag-handle`, styles.header, electronStylish.draggable)}
horizontal
justify={'center'}
>
<Flexbox align={'baseline'} gap={6} horizontal>
<b>{BRANDING_NAME} Dev Tools</b>
<span style={{ color: theme.colorTextDescription }}>/</span>
<span style={{ color: theme.colorTextDescription }}>{tab}</span>
</Flexbox>
</Flexbox>
{items.map((item) => (
<Flexbox
flex={1}
height={'100%'}
key={item.key}
style={{
display: tab === item.key ? 'flex' : 'none',
overflow: 'hidden',
}}
>
{item.children}
</Flexbox>
))}
</Flexbox>
</Flexbox>
);
});
export default DevTools;
+31
View File
@@ -0,0 +1,31 @@
import { notFound } from 'next/navigation';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import { ReactNode } from 'react';
import { isDesktop } from '@/const/version';
import GlobalLayout from '@/layout/GlobalProvider';
import { ServerConfigStoreProvider } from '@/store/serverConfig/Provider';
interface RootLayoutProps {
children: ReactNode;
}
const RootLayout = async ({ children }: RootLayoutProps) => {
if (!isDesktop) return notFound();
return (
<html dir="ltr" suppressHydrationWarning>
<body>
<NuqsAdapter>
<ServerConfigStoreProvider>
<GlobalLayout appearance={'auto'} isMobile={false} locale={''}>
{children}
</GlobalLayout>
</ServerConfigStoreProvider>
</NuqsAdapter>
</body>
</html>
);
};
export default RootLayout;
+11
View File
@@ -0,0 +1,11 @@
import { PropsWithChildren } from 'react';
const Layout = ({ children }: PropsWithChildren) => {
return (
<html>
<body>{children}</body>
</html>
);
};
export default Layout;
+1
View File
@@ -0,0 +1 @@
export { default } from '@/components/404';
@@ -1,14 +1,14 @@
import { LobeChat, LobeChatProps } from '@lobehub/ui/brand';
import { LobeHub, LobeHubProps } from '@lobehub/ui/brand';
import { memo } from 'react';
import { isCustomBranding } from '@/const/version';
import CustomLogo from './Custom';
export const ProductLogo = memo<LobeChatProps>((props) => {
export const ProductLogo = memo<LobeHubProps>((props) => {
if (isCustomBranding) {
return <CustomLogo {...props} />;
}
return <LobeChat {...props} />;
return <LobeHub {...props} />;
});
@@ -1,4 +1,4 @@
import { BrandLoading, LobeChatText } from '@lobehub/ui/brand';
import { BrandLoading, LobeHubText } from '@lobehub/ui/brand';
import { Center } from 'react-layout-kit';
import { isCustomBranding } from '@/const/version';
@@ -10,7 +10,7 @@ export default () => {
return (
<Center height={'100%'} width={'100%'}>
<BrandLoading size={40} style={{ opacity: 0.6 }} text={LobeChatText} />
<BrandLoading size={40} style={{ opacity: 0.6 }} text={LobeHubText} />
</Center>
);
};
+1
View File
@@ -0,0 +1 @@
export const DESKTOP_USER_ID = 'DEFAULT_DESKTOP_USER';
+2
View File
@@ -7,6 +7,8 @@ export const CURRENT_VERSION = pkg.version;
export const isServerMode = process.env.NEXT_PUBLIC_SERVICE_MODE === 'server';
export const isUsePgliteDB = process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite';
export const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
export const isDeprecatedEdition = !isServerMode && !isUsePgliteDB;
// @ts-ignore
+3 -10
View File
@@ -167,17 +167,10 @@ export class DatabaseManager {
// if hash is the same, no need to migrate
if (hash === cacheHash) {
try {
// 检查数据库中是否存在表
// 这里使用 pg_tables 系统表查询用户表数量
const tablesResult = await this.db.execute(
sql`
SELECT COUNT(*) as table_count
FROM information_schema.tables
WHERE table_schema = 'public'
`,
);
const drizzleMigration = new DrizzleMigrationModel(this.db as any);
const tableCount = parseInt((tablesResult.rows[0] as any).table_count || '0', 10);
// 检查数据库中是否存在表
const tableCount = await drizzleMigration.getTableCounts();
// 如果表数量大于0,则认为数据库已正确初始化
if (tableCount > 0) {
+27
View File
@@ -0,0 +1,27 @@
import { isDesktop } from '@/const/version';
import { getDBInstance } from '@/database/core/web-server';
import { LobeChatDatabase } from '@/database/type';
import { getPgliteInstance } from './electron';
/**
* 懒加载数据库实例
* 避免每次模块导入时都初始化数据库
*/
let cachedDB: LobeChatDatabase | null = null;
export const getServerDB = async (): Promise<LobeChatDatabase> => {
// 如果已经有缓存的实例,直接返回
if (cachedDB) return cachedDB;
try {
// 根据环境选择合适的数据库实例
cachedDB = isDesktop ? await getPgliteInstance() : getDBInstance();
return cachedDB;
} catch (error) {
console.error('❌ Failed to initialize database:', error);
throw error;
}
};
export const serverDB = getDBInstance();
@@ -9,9 +9,9 @@ import ws from 'ws';
import { serverDBEnv } from '@/config/db';
import * as schema from '../../schemas';
import * as schema from '../schemas';
const migrationsFolder = join(__dirname, '../../migrations');
const migrationsFolder = join(__dirname, '../migrations');
export const getTestDBInstance = async () => {
let connectionString = serverDBEnv.DATABASE_TEST_URL;
+312
View File
@@ -0,0 +1,312 @@
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<LobeChatDatabase>;
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<boolean> => {
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) => {
console.error('Uncaught exception:', error);
releaseLock();
process.exit(1);
});
const migrateDatabase = async (db: LobeChatDatabase): Promise<void> => {
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<LobeChatDatabase> => {
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 {}
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;
}
};
+17
View File
@@ -0,0 +1,17 @@
import { getServerDB } from './db-adaptor';
/**
* 初始化数据库
* 在应用启动时调用此函数,确保数据库在首次请求到达前已初始化
*/
export const initializeDatabase = async (): Promise<void> => {
try {
console.log('🚀 Initializing database during application startup...');
await getServerDB();
console.log('✅ Database initialized successfully during startup');
} catch (error) {
console.error('❌ Failed to initialize database during startup:', error);
// 不抛出错误,允许应用继续启动
// 后续请求会再次尝试初始化数据库
}
};
@@ -1,15 +1,16 @@
import { Pool as NeonPool, neonConfig } from '@neondatabase/serverless';
import { NeonDatabase, drizzle as neonDrizzle } from 'drizzle-orm/neon-serverless';
import { drizzle as neonDrizzle } from 'drizzle-orm/neon-serverless';
import { drizzle as nodeDrizzle } from 'drizzle-orm/node-postgres';
import { Pool as NodePool } from 'pg';
import ws from 'ws';
import { serverDBEnv } from '@/config/db';
import { isServerMode } from '@/const/version';
import * as schema from '@/database/schemas';
import * as schema from '../../schemas';
import { LobeChatDatabase } from '../type';
export const getDBInstance = (): NeonDatabase<typeof schema> => {
export const getDBInstance = (): LobeChatDatabase => {
if (!isServerMode) return {} as any;
if (!serverDBEnv.KEY_VAULTS_SECRET) {
@@ -40,5 +41,3 @@ If you don't have it, please run \`openssl rand -base64 32\` to create one.
const client = new NeonPool({ connectionString });
return neonDrizzle(client, { schema });
};
export const serverDB = getDBInstance();
@@ -2,12 +2,13 @@
import { eq } from 'drizzle-orm/expressions';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDBInstance } from '@/database/server/core/dbForTest';
import { LobeChatDatabase } from '@/database/type';
import { sessionGroups, users } from '../../schemas';
import { SessionGroupModel } from '../../server/models/sessionGroup';
import { SessionGroupModel } from '../sessionGroup';
import { getTestDB } from './_util';
let serverDB = await getTestDBInstance();
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'session-group-model-test-user-id';
const sessionGroupModel = new SessionGroupModel(serverDB, userId);
+2 -2
View File
@@ -1,8 +1,8 @@
import { clientDB, initializeDB } from '@/database/client/db';
import { getTestDBInstance } from '@/database/server/core/dbForTest';
import { getTestDBInstance } from '@/database/core/dbForTest';
import { LobeChatDatabase } from '@/database/type';
export const isServerDBMode = process.env.TEST_SERVER_DB === '1';
const isServerDBMode = process.env.TEST_SERVER_DB === '1';
export const getTestDB = async () => {
if (isServerDBMode) return await getTestDBInstance();
+1 -1
View File
@@ -14,7 +14,7 @@ import {
sessions,
users,
} from '../../schemas';
import { AgentModel } from '../../server/models/agent';
import { AgentModel } from '../agent';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -6,7 +6,7 @@ import { LobeChatDatabase } from '@/database/type';
import { AiProviderModelListItem } from '@/types/aiModel';
import { AiModelSelectItem, NewAiModelItem, aiModels, users } from '../../schemas';
import { AiModelModel } from '../../server/models/aiModel';
import { AiModelModel } from '../aiModel';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -4,9 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeChatDatabase } from '@/database/type';
import { ModelProvider } from '@/libs/agent-runtime';
import { sleep } from '@/utils/sleep';
import { aiProviders, users } from '../../schemas';
import { AiProviderModel } from '../../server/models/aiProvider';
import { AiProviderModel } from '../aiProvider';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -96,6 +97,7 @@ describe('AiProviderModel', () => {
describe('query', () => {
it('should query ai providers for the user', async () => {
await aiProviderModel.create({ name: 'AiHubMix', source: 'custom', id: 'aihubmix' });
await sleep(10);
await aiProviderModel.create({ name: 'AiHubMix', source: 'custom', id: 'aihubmix-2' });
const userGroups = await aiProviderModel.query();
@@ -6,7 +6,7 @@ import { LobeChatDatabase } from '@/database/type';
import { AsyncTaskStatus, AsyncTaskType } from '@/types/asyncTask';
import { asyncTasks, users } from '../../schemas';
import { ASYNC_TASK_TIMEOUT, AsyncTaskModel } from '../../server/models/asyncTask';
import { ASYNC_TASK_TIMEOUT, AsyncTaskModel } from '../asyncTask';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
+1 -1
View File
@@ -6,7 +6,7 @@ import { LobeChatDatabase } from '@/database/type';
import { uuid } from '@/utils/uuid';
import { chunks, embeddings, fileChunks, files, unstructuredChunks, users } from '../../schemas';
import { ChunkModel } from '../../server/models/chunk';
import { ChunkModel } from '../chunk';
import { getTestDB } from './_util';
import { codeEmbedding, designThinkingQuery, designThinkingQuery2 } from './fixtures/embedding';
+1 -1
View File
@@ -6,7 +6,7 @@ import { LobeChatDatabase } from '@/database/type';
import { FilesTabs, SortType } from '@/types/files';
import { files, globalFiles, knowledgeBaseFiles, knowledgeBases, users } from '../../schemas';
import { FileModel } from '../../server/models/file';
import { FileModel } from '../file';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -13,7 +13,7 @@ import {
knowledgeBases,
users,
} from '../../schemas';
import { KnowledgeBaseModel } from '../../server/models/knowledgeBase';
import { KnowledgeBaseModel } from '../knowledgeBase';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -23,7 +23,7 @@ import {
topics,
users,
} from '../../schemas';
import { MessageModel } from '../../server/models/message';
import { MessageModel } from '../message';
import { codeEmbedding } from './fixtures/embedding';
const serverDB: LobeChatDatabase = await getTestDB();
+1 -1
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeChatDatabase } from '@/database/type';
import { NewInstalledPlugin, userInstalledPlugins, users } from '../../schemas';
import { PluginModel } from '../../server/models/plugin';
import { PluginModel } from '../plugin';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -2,7 +2,6 @@ import { and, eq, inArray } from 'drizzle-orm/expressions';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { getTestDBInstance } from '@/database/server/core/dbForTest';
import { LobeChatDatabase } from '@/database/type';
import { idGenerator } from '@/database/utils/idGenerator';
@@ -17,7 +16,7 @@ import {
topics,
users,
} from '../../schemas';
import { SessionModel } from '../../server/models/session';
import { SessionModel } from '../session';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeChatDatabase } from '@/database/type';
import { sessionGroups, users } from '../../schemas';
import { SessionGroupModel } from '../../server/models/sessionGroup';
import { SessionGroupModel } from '../sessionGroup';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
+1 -1
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeChatDatabase } from '@/database/type';
import { messages, sessions, topics, users } from '../../schemas';
import { CreateTopicParams, TopicModel } from '../../server/models/topic';
import { CreateTopicParams, TopicModel } from '../topic';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
@@ -2,7 +2,7 @@ import { and, desc, eq } from 'drizzle-orm/expressions';
import { LobeChatDatabase } from '@/database/type';
import { NewSessionGroup, SessionGroupItem, sessionGroups } from '../../schemas';
import { NewSessionGroup, SessionGroupItem, sessionGroups } from '../schemas';
export class TemplateModel {
private userId: string;

Some files were not shown because too many files have changed in this diff Show More