mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 11:40:07 +00:00
Compare commits
17 Commits
dev
...
backup/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
| b2aa2d927a | |||
| 5e1ff5842e | |||
| 695f504c8b | |||
| 4d08c967c8 | |||
| 094f9a6dc7 | |||
| 0554093a8d | |||
| 51305a9566 | |||
| 39c5bd4081 | |||
| c199a85693 | |||
| c5833c232f | |||
| fb931674f6 | |||
| e6422793f0 | |||
| 794642fc57 | |||
| edb576fdd6 | |||
| 27eca4e649 | |||
| 40ea09f764 | |||
| 613dd23594 |
@@ -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
@@ -68,4 +68,5 @@ public/swe-worker*
|
||||
*.patch
|
||||
*.pdf
|
||||
vertex-ai-key.json
|
||||
.pnpm-store
|
||||
.pnpm-store
|
||||
lobechat-db
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
standalone
|
||||
release
|
||||
@@ -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/
|
||||
@@ -0,0 +1,4 @@
|
||||
构建路径:
|
||||
|
||||
- dist: 构建产物路径
|
||||
- release: 发布产物路径
|
||||
Binary file not shown.
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
updaterCacheDirName: electron-app-updater
|
||||
@@ -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;
|
||||
@@ -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({})],
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- '../../packages/electron-server-ipc'
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '.'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { App } from './core/App';
|
||||
|
||||
const app = new App();
|
||||
|
||||
app.bootstrap();
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
// 不抛出错误,允许应用继续启动
|
||||
// 后续请求会再次尝试初始化数据库
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { router } from '@/libs/trpc';
|
||||
|
||||
import { pgTableRouter } from './pgTable';
|
||||
|
||||
export const desktopRouter = router({
|
||||
pgTable: pgTableRouter,
|
||||
});
|
||||
|
||||
export type DesktopRouter = typeof desktopRouter;
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -37,10 +37,5 @@
|
||||
"src",
|
||||
"tests",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user