Compare commits

...

17 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
71 changed files with 2041 additions and 75 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
+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"]
}
+26
View File
@@ -15,6 +15,32 @@ const isUsePglite = process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite';
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/**/*'] },
+8 -2
View File
@@ -35,7 +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": "NODE_OPTIONS=--max-old-space-size=6144 NEXT_PUBLIC_IS_DESKTOP_APP=1 next build ",
"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",
@@ -44,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",
@@ -78,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": [
@@ -293,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",
+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();
@@ -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>
@@ -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
+4 -1
View File
@@ -1,6 +1,9 @@
import { isDesktop } from '@/const/version';
import { getDBInstance } from '@/database/core/web-server';
import { LobeChatDatabase } from '@/database/type';
import { getPgliteInstance } from './electron';
/**
* 懒加载数据库实例
* 避免每次模块导入时都初始化数据库
@@ -13,7 +16,7 @@ export const getServerDB = async (): Promise<LobeChatDatabase> => {
try {
// 根据环境选择合适的数据库实例
cachedDB = getDBInstance();
cachedDB = isDesktop ? await getPgliteInstance() : getDBInstance();
return cachedDB;
} catch (error) {
console.error('❌ Failed to initialize database:', error);
+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);
// 不抛出错误,允许应用继续启动
// 后续请求会再次尝试初始化数据库
}
};
+14
View File
@@ -0,0 +1,14 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { DesktopRouter } from '@/server/routers/desktop';
export const desktopClient = createTRPCClient<DesktopRouter>({
links: [
httpBatchLink({
maxURLLength: 2083,
transformer: superjson,
url: '/trpc/desktop',
}),
],
});
+8 -1
View File
@@ -7,6 +7,9 @@
* @link https://trpc.io/docs/v11/router
* @link https://trpc.io/docs/v11/procedures
*/
import { DESKTOP_USER_ID } from '@/const/desktop';
import { isDesktop } from '@/const/version';
import { trpc } from './init';
import { jwtPayloadChecker } from './middleware/jwtPayload';
import { userAuth } from './middleware/userAuth';
@@ -21,7 +24,11 @@ export const router = trpc.router;
* Create an unprotected procedure
* @link https://trpc.io/docs/v11/procedures
**/
export const publicProcedure = trpc.procedure;
export const publicProcedure = trpc.procedure.use(({ next }) => {
return next({
ctx: { userId: isDesktop ? DESKTOP_USER_ID : null },
});
});
// procedure that asserts that the user is logged in
export const authedProcedure = trpc.procedure.use(userAuth);
+2 -3
View File
@@ -9,11 +9,10 @@ import { trpc } from '../init';
export const userAuth = trpc.middleware(async (opts) => {
const { ctx } = opts;
// 桌面端模式下,跳过默认鉴权逻辑
if (isDesktop) {
return opts.next({
ctx: {
userId: DESKTOP_USER_ID,
},
ctx: { userId: DESKTOP_USER_ID },
});
}
// `ctx.user` is nullable
@@ -0,0 +1,25 @@
import { ElectronIPCMethods, ElectronIpcClient } from '@lobechat/electron-server-ipc';
class LobeHubElectronIpcClient extends ElectronIpcClient {
// 获取数据库路径
getDatabasePath = async (): Promise<string> => {
return this.sendRequest<string>(ElectronIPCMethods.getDatabasePath);
};
// 获取用户数据路径
getUserDataPath = async (): Promise<string> => {
return this.sendRequest<string>(ElectronIPCMethods.getUserDataPath);
};
getDatabaseSchemaHash = async () => {
return this.sendRequest<string>(ElectronIPCMethods.getDatabaseSchemaHash);
};
setDatabaseSchemaHash = async (hash: string | undefined) => {
if (!hash) return;
return this.sendRequest(ElectronIPCMethods.setDatabaseSchemaHash, hash);
};
}
export const electronIpcClient = new LobeHubElectronIpcClient();
+9
View File
@@ -0,0 +1,9 @@
import { router } from '@/libs/trpc';
import { pgTableRouter } from './pgTable';
export const desktopRouter = router({
pgTable: pgTableRouter,
});
export type DesktopRouter = typeof desktopRouter;
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
import { DESKTOP_USER_ID } from '@/const/desktop';
import { TableViewerRepo } from '@/database/repositories/tableViewer';
import { publicProcedure, router } from '@/libs/trpc';
import { serverDatabase } from '@/libs/trpc/lambda';
const pgTableProcedure = publicProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
return next({
ctx: {
tableViewerRepo: new TableViewerRepo(ctx.serverDB, DESKTOP_USER_ID),
},
});
});
export const pgTableRouter = router({
getAllTables: pgTableProcedure.query(async ({ ctx }) => {
return ctx.tableViewerRepo.getAllTables();
}),
getTableData: pgTableProcedure
.input(
z.object({
page: z.number(),
pageSize: z.number(),
tableName: z.string(),
}),
)
.query(async ({ input, ctx }) => {
return ctx.tableViewerRepo.getTableData(input.tableName, {
page: input.page,
pageSize: input.pageSize,
});
}),
getTableDetails: pgTableProcedure
.input(
z.object({
tableName: z.string(),
}),
)
.query(async ({ input, ctx }) => {
return ctx.tableViewerRepo.getTableDetails(input.tableName);
}),
});
+2 -6
View File
@@ -90,14 +90,10 @@ export const sessionRouter = router({
}),
getGroupedSessions: publicProcedure.query(async ({ ctx }): Promise<ChatSessionList> => {
if (!ctx.userId)
return {
sessionGroups: [],
sessions: [],
};
if (!ctx.userId) return { sessionGroups: [], sessions: [] };
const serverDB = await getServerDB();
const sessionModel = new SessionModel(serverDB, ctx.userId);
const sessionModel = new SessionModel(serverDB, ctx.userId!);
return sessionModel.queryWithGroups();
}),
+37 -22
View File
@@ -2,10 +2,12 @@ import { UserJSON } from '@clerk/backend';
import { z } from 'zod';
import { enableClerk } from '@/const/auth';
import { isDesktop } from '@/const/version';
import { MessageModel } from '@/database/models/message';
import { SessionModel } from '@/database/models/session';
import { UserModel, UserNotFoundError } from '@/database/models/user';
import { ClerkAuth } from '@/libs/clerk-auth';
import { pino } from '@/libs/logger';
import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
import { authedProcedure, router } from '@/libs/trpc';
import { serverDatabase } from '@/libs/trpc/lambda';
@@ -46,33 +48,46 @@ export const userRouter = router({
try {
state = await ctx.userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
} catch (error) {
if (enableClerk && error instanceof UserNotFoundError) {
const user = await ctx.clerkAuth.getCurrentUser();
if (user) {
const userService = new UserService();
// user not create yet
if (error instanceof UserNotFoundError) {
// if in clerk auth mode
if (enableClerk) {
const user = await ctx.clerkAuth.getCurrentUser();
if (user) {
const userService = new UserService();
await userService.createUser(user.id, {
created_at: user.createdAt,
email_addresses: user.emailAddresses.map((e) => ({
email_address: e.emailAddress,
id: e.id,
})),
first_name: user.firstName,
id: user.id,
image_url: user.imageUrl,
last_name: user.lastName,
phone_numbers: user.phoneNumbers.map((e) => ({
id: e.id,
phone_number: e.phoneNumber,
})),
primary_email_address_id: user.primaryEmailAddressId,
primary_phone_number_id: user.primaryPhoneNumberId,
username: user.username,
} as UserJSON);
await userService.createUser(user.id, {
created_at: user.createdAt,
email_addresses: user.emailAddresses.map((e) => ({
email_address: e.emailAddress,
id: e.id,
})),
first_name: user.firstName,
id: user.id,
image_url: user.imageUrl,
last_name: user.lastName,
phone_numbers: user.phoneNumbers.map((e) => ({
id: e.id,
phone_number: e.phoneNumber,
})),
primary_email_address_id: user.primaryEmailAddressId,
primary_phone_number_id: user.primaryPhoneNumberId,
username: user.username,
} as UserJSON);
continue;
}
}
// if in desktop mode, make sure desktop user exist
else if (isDesktop) {
await UserModel.makeSureUserExist(ctx.serverDB, ctx.userId);
pino.info('create desktop user');
continue;
}
}
console.error('getUserState:', error);
throw error;
}
}
+2 -11
View File
@@ -1,8 +1,6 @@
'use server';
import { get } from 'lodash-es';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DEFAULT_LANG } from '@/const/locale';
import { Locales, NS, normalizeLocale } from '@/locales/resources';
@@ -17,15 +15,8 @@ export const translation = async (ns: NS = 'common', hl: string) => {
let i18ns = {};
const lng = await getLocale(hl);
try {
let filepath = join(process.cwd(), `locales/${normalizeLocale(lng)}/${ns}.json`);
const isExist = existsSync(filepath);
if (!isExist)
filepath = join(
process.cwd(),
`locales/${normalizeLocale(isDev ? 'zh-CN' : DEFAULT_LANG)}/${ns}.json`,
);
const file = readFileSync(filepath, 'utf8');
i18ns = JSON.parse(file);
if (isDev && lng === 'zh-CN') i18ns = await import(`@/locales/default/${ns}`);
i18ns = await import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`);
} catch (e) {
console.error('Error while reading translation file', e);
}
+5 -1
View File
@@ -1,5 +1,9 @@
import { isDesktop } from '@/const/version';
import { ClientService } from './client';
import { ServerService } from './server';
export const aiModelService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: new ClientService();
+5 -1
View File
@@ -1,5 +1,9 @@
import { isDesktop } from '@/const/version';
import { ClientService } from './client';
import { ServerService } from './server';
export const aiProviderService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: new ClientService();
+5 -1
View File
@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const fileService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;
+5 -1
View File
@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const messageService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;
+5 -1
View File
@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const pluginService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;
+5 -1
View File
@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const sessionService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;
+15
View File
@@ -0,0 +1,15 @@
import { desktopClient } from '@/libs/trpc/client/desktop';
export class DesktopService {
getAllTables = async () => {
return desktopClient.pgTable.getAllTables.query();
};
getTableDetails = async (tableName: string) => {
return desktopClient.pgTable.getTableDetails.query({ tableName });
};
getTableData = async (tableName: string) => {
return desktopClient.pgTable.getTableData.query({ page: 1, pageSize: 300, tableName });
};
}
+5 -2
View File
@@ -1,3 +1,6 @@
import { ClientService } from './client';
import { isDesktop } from '@/const/version';
export const tableViewerService = new ClientService();
import { ClientService } from './client';
import { DesktopService } from './desktop';
export const tableViewerService = isDesktop ? new DesktopService() : new ClientService();
+5 -1
View File
@@ -1,5 +1,9 @@
import { isDesktop } from '@/const/version';
import { ClientService } from './client';
import { ServerService } from './server';
export const threadService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: new ClientService();
+5 -1
View File
@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,4 +8,6 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const topicService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;
+5 -1
View File
@@ -1,3 +1,5 @@
import { isDesktop } from '@/const/version';
import { ClientService as DeprecatedService } from './_deprecated';
import { ClientService } from './client';
import { ServerService } from './server';
@@ -6,6 +8,8 @@ const clientService =
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
export const userService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' || isDesktop
? new ServerService()
: clientService;
export const userClientService = clientService;
+1 -6
View File
@@ -37,10 +37,5 @@
"src",
"tests",
"vitest.config.ts"
],
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
}
]
}