🔨 chore: add desktop pre-code to validate build process (#7261)

* add code

* fix lint

* fix tests
This commit is contained in:
Arvin Xu
2025-04-02 09:31:08 +08:00
committed by GitHub
parent 5897d9e106
commit b40caee32c
29 changed files with 358 additions and 40 deletions

View File

@@ -6,14 +6,22 @@ import ReactComponentName from 'react-scan/react-component-name/webpack';
const isProd = process.env.NODE_ENV === 'production';
const buildWithDocker = process.env.DOCKER === 'true';
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const enableReactScan = !!process.env.REACT_SCAN_MONITOR_API_KEY;
const isUsePglite = process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite';
// if you need to proxy the api endpoint to remote server
const basePath = process.env.NEXT_PUBLIC_BASE_PATH;
const isStandaloneMode = buildWithDocker || isDesktop;
const standaloneConfig: NextConfig = {
output: 'standalone',
outputFileTracingIncludes: { '*': ['public/**/*', '.next/static/**/*'] },
};
const nextConfig: NextConfig = {
...(isStandaloneMode ? standaloneConfig : {}),
basePath,
compress: isProd,
experimental: {
@@ -110,10 +118,6 @@ const nextConfig: NextConfig = {
hmrRefreshes: true,
},
},
output: buildWithDocker ? 'standalone' : undefined,
outputFileTracingIncludes: buildWithDocker
? { '*': ['public/**/*', '.next/static/**/*'] }
: undefined,
reactStrictMode: true,
redirects: async () => [
{
@@ -231,13 +235,14 @@ const noWrapper = (config: NextConfig) => config;
const withBundleAnalyzer = process.env.ANALYZE === 'true' ? analyzer() : noWrapper;
const withPWA = isProd
? withSerwistInit({
register: false,
swDest: 'public/sw.js',
swSrc: 'src/app/sw.ts',
})
: noWrapper;
const withPWA =
isProd && !isDesktop
? withSerwistInit({
register: false,
swDest: 'public/sw.js',
swSrc: 'src/app/sw.ts',
})
: noWrapper;
const hasSentry = !!process.env.NEXT_PUBLIC_SENTRY_DSN;
const withSentry =

View File

@@ -35,6 +35,7 @@
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"build:analyze": "ANALYZE=true next build",
"build:docker": "DOCKER=true next build && npm run build-sitemap",
"build:electron": "NODE_OPTIONS=--max-old-space-size=6144 NEXT_PUBLIC_IS_DESKTOP_APP=1 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",
@@ -129,6 +130,8 @@
"@icons-pack/react-simple-icons": "9.6.0",
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.37",
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/charts": "^1.12.0",
"@lobehub/chat-plugin-sdk": "^1.32.4",

View File

@@ -0,0 +1,48 @@
# @lobechat/electron-client-ipc
这个包是 LobeChat 在 Electron 环境中用于处理 IPC进程间通信的客户端工具包。
## 介绍
在 Electron 应用中IPC进程间通信是连接主进程Main Process、渲染进程Renderer Process以及 NextJS 进程的桥梁。为了更好地组织和管理这些通信,我们将 IPC 相关的代码分成了两个包:
- `@lobechat/electron-client-ipc`**客户端 IPC 包**
- `@lobechat/electron-server-ipc`**服务端 IPC 包**
## 主要区别
### electron-client-ipc本包
- 运行环境在渲染进程Renderer Process中运行
- 主要职责:
- 提供渲染进程调用主进程方法的接口定义
- 封装 `ipcRenderer.invoke` 相关方法
- 处理与主进程的通信请求
### electron-server-ipc
- 运行环境:在 Electron 主进程和 Next.js 服务端进程中运行
- 主要职责:
- 提供基于 Socket 的 IPC 通信机制
- 实现服务端ElectronIPCServer和客户端ElectronIpcClient通信组件
- 处理跨进程的请求和响应
- 提供自动重连和错误处理机制
- 确保类型安全的 API 调用
## 使用场景
当渲染进程需要:
- 访问系统 API
- 进行文件操作
- 调用主进程特定功能
时,都需要通过 `electron-client-ipc` 包提供的方法来发起请求。
## 技术说明
这种分包设计遵循了关注点分离原则,使得:
- IPC 通信接口清晰可维护
- 客户端和服务端代码解耦
- TypeScript 类型定义共享,确保类型安全

View File

@@ -0,0 +1,7 @@
{
"name": "@lobechat/electron-client-ipc",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts"
}

View File

@@ -0,0 +1,6 @@
export interface DevtoolsDispatchEvents {
/**
* open the LobeHub Devtools
*/
openDevtools: () => void;
}

View File

@@ -0,0 +1,13 @@
import { DevtoolsDispatchEvents } from './devtools';
/**
* renderer -> main dispatch events
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ClientDispatchEvents extends DevtoolsDispatchEvents {}
export type ClientDispatchEventKey = keyof ClientDispatchEvents;
export type ClientEventReturnType<T extends ClientDispatchEventKey> = ReturnType<
ClientDispatchEvents[T]
>;

View File

@@ -0,0 +1,2 @@
export * from './events';
export * from './types';

View File

@@ -0,0 +1,10 @@
import type {
ClientDispatchEventKey,
ClientDispatchEvents,
ClientEventReturnType,
} from '../events';
export type DispatchInvoke = <T extends ClientDispatchEventKey>(
event: T,
...data: Parameters<ClientDispatchEvents[T]>
) => Promise<ClientEventReturnType<T>>;

View File

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

View File

@@ -4,7 +4,7 @@ LobeHub 的 Electron 应用与服务端之间的 IPC进程间通信模块
## 📝 简介
`@lobechat/electron-server-ipc` 是 LobeHub 桌面应用的核心组件,负责处理 Electron 进程与 nextjs 服务端之间的通信。它提供了一套简单而健壮的 API用于在不同进程间传递数据和执行远程方法调用。
`@lobechat/electron-server-ipc` 是 LobeHub 桌面应用的核心组件,负责处理 Electron 进程与 nextjs 服务端之间的通信。它提供了一套简单而健壮的 API用于在不同进程间传递数据和执行远程方法调用。
## 🛠️ 核心功能

View File

@@ -1,3 +1,4 @@
packages:
- 'packages/**'
- '.'
- '!apps/**'

View File

@@ -0,0 +1,89 @@
'use client';
import { ActionIcon, FluentEmoji, SideNav } from '@lobehub/ui';
import { Cog, DatabaseIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { BRANDING_NAME } from '@/const/branding';
import PostgresViewer from '@/features/DevPanel/PostgresViewer';
import SystemInspector from '@/features/DevPanel/SystemInspector';
import { useStyles } from '@/features/DevPanel/features/FloatPanel';
import { electronStylish } from '@/styles/electron';
const DevTools = memo(() => {
const { styles, theme, cx } = useStyles();
const items = [
{
children: <PostgresViewer />,
icon: <DatabaseIcon size={16} />,
key: 'Postgres Viewer',
},
{
children: <SystemInspector />,
icon: <Cog size={16} />,
key: 'System Status',
},
];
const [tab, setTab] = useState<string>(items[0].key);
return (
<Flexbox
height={'100%'}
horizontal
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<SideNav
avatar={<FluentEmoji emoji={'🧰'} size={24} />}
bottomActions={[]}
style={{
paddingBlock: 32,
width: 48,
}}
topActions={items.map((item) => (
<ActionIcon
active={tab === item.key}
key={item.key}
onClick={() => setTab(item.key)}
placement={'right'}
title={item.key}
>
{item.icon}
</ActionIcon>
))}
/>
<Flexbox height={'100%'} style={{ overflow: 'hidden', position: 'relative' }} width={'100%'}>
<Flexbox
align={'center'}
className={cx(`panel-drag-handle`, styles.header, electronStylish.draggable)}
horizontal
justify={'center'}
>
<Flexbox align={'baseline'} gap={6} horizontal>
<b>{BRANDING_NAME} Dev Tools</b>
<span style={{ color: theme.colorTextDescription }}>/</span>
<span style={{ color: theme.colorTextDescription }}>{tab}</span>
</Flexbox>
</Flexbox>
{items.map((item) => (
<Flexbox
flex={1}
height={'100%'}
key={item.key}
style={{
display: tab === item.key ? 'flex' : 'none',
overflow: 'hidden',
}}
>
{item.children}
</Flexbox>
))}
</Flexbox>
</Flexbox>
);
});
export default DevTools;

View File

@@ -0,0 +1,31 @@
import { notFound } from 'next/navigation';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import { ReactNode } from 'react';
import { isDesktop } from '@/const/version';
import GlobalLayout from '@/layout/GlobalProvider';
import { ServerConfigStoreProvider } from '@/store/serverConfig/Provider';
interface RootLayoutProps {
children: ReactNode;
}
const RootLayout = async ({ children }: RootLayoutProps) => {
if (!isDesktop) return notFound();
return (
<html dir="ltr" suppressHydrationWarning>
<body>
<NuqsAdapter>
<ServerConfigStoreProvider>
<GlobalLayout appearance={'auto'} isMobile={false} locale={''}>
{children}
</GlobalLayout>
</ServerConfigStoreProvider>
</NuqsAdapter>
</body>
</html>
);
};
export default RootLayout;

11
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { PropsWithChildren } from 'react';
const Layout = ({ children }: PropsWithChildren) => {
return (
<html>
<body>{children}</body>
</html>
);
};
export default Layout;

1
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1 @@
export { default } from '@/components/404';

1
src/const/desktop.ts Normal file
View File

@@ -0,0 +1 @@
export const DESKTOP_USER_ID = 'DEFAULT_DESKTOP_USER';

View File

@@ -7,6 +7,8 @@ export const CURRENT_VERSION = pkg.version;
export const isServerMode = process.env.NEXT_PUBLIC_SERVICE_MODE === 'server';
export const isUsePgliteDB = process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite';
export const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
export const isDeprecatedEdition = !isServerMode && !isUsePgliteDB;
// @ts-ignore

View File

@@ -167,17 +167,10 @@ export class DatabaseManager {
// if hash is the same, no need to migrate
if (hash === cacheHash) {
try {
// 检查数据库中是否存在表
// 这里使用 pg_tables 系统表查询用户表数量
const tablesResult = await this.db.execute(
sql`
SELECT COUNT(*) as table_count
FROM information_schema.tables
WHERE table_schema = 'public'
`,
);
const drizzleMigration = new DrizzleMigrationModel(this.db as any);
const tableCount = parseInt((tablesResult.rows[0] as any).table_count || '0', 10);
// 检查数据库中是否存在表
const tableCount = await drizzleMigration.getTableCounts();
// 如果表数量大于0则认为数据库已正确初始化
if (tableCount > 0) {

View File

@@ -1,3 +1,5 @@
import { sql } from 'drizzle-orm';
import { LobeChatDatabase } from '@/database/type';
import { MigrationTableItem } from '@/types/clientDB';
@@ -8,6 +10,19 @@ export class DrizzleMigrationModel {
this.db = db;
}
getTableCounts = async () => {
// 这里使用 pg_tables 系统表查询用户表数量
const result = await this.db.execute(
sql`
SELECT COUNT(*) as table_count
FROM information_schema.tables
WHERE table_schema = 'public'
`,
);
return parseInt((result.rows[0] as any).table_count || '0');
};
getMigrationList = async () => {
const res = await this.db.execute(
'SELECT * FROM "drizzle"."__drizzle_migrations" ORDER BY "created_at" DESC;',

View File

@@ -177,6 +177,9 @@ export class UserModel {
};
// Static method
static makeSureUserExist = async (db: LobeChatDatabase, userId: string) => {
await db.insert(users).values({ id: userId }).onConflictDoNothing();
};
static createUser = async (db: LobeChatDatabase, params: NewUser) => {
// if user already exists, skip creation

View File

@@ -4,14 +4,16 @@ import { ActionIcon, FluentEmoji, Icon, SideNav } from '@lobehub/ui';
import { FloatButton } from 'antd';
import { createStyles } from 'antd-style';
import { BugIcon, BugOff, XIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { ReactNode, memo, useEffect, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { Rnd } from 'react-rnd';
import { BRANDING_NAME } from '@/const/branding';
import { isDesktop } from '@/const/version';
// 定义样式
const useStyles = createStyles(({ token, css, prefixCls }) => {
export const useStyles = createStyles(({ token, css, prefixCls }) => {
return {
collapsed: css`
pointer-events: none;
@@ -86,6 +88,7 @@ const CollapsibleFloatPanel = memo<CollapsibleFloatPanelProps>(({ items }) => {
const [position, setPosition] = useState({ x: 100, y: 100 });
const [size, setSize] = useState({ height: minHeight, width: minWidth });
const pathname = usePathname();
useEffect(() => {
try {
const localStoragePosition = localStorage.getItem('debug-panel-position');
@@ -108,11 +111,25 @@ const CollapsibleFloatPanel = memo<CollapsibleFloatPanelProps>(({ items }) => {
return (
<>
<FloatButton
className={styles.floatButton}
icon={<Icon icon={isExpanded ? BugOff : BugIcon} />}
onClick={() => setIsExpanded(!isExpanded)}
/>
{
// desktop devtools 下隐藏
pathname !== '/desktop/devtools' && (
<FloatButton
className={styles.floatButton}
icon={<Icon icon={isExpanded ? BugOff : BugIcon} />}
onClick={async () => {
if (isDesktop) {
const { electronDevtoolsService } = await import('@/services/electron/devtools');
await electronDevtoolsService.openDevtools();
return;
}
setIsExpanded(!isExpanded);
}}
/>
)
}
{isExpanded && (
<Rnd
bounds="window"

View File

@@ -4,16 +4,20 @@ import { Popover } from 'antd';
import { createStyles } from 'antd-style';
import { PropsWithChildren, memo, useState } from 'react';
import { isDesktop } from '@/const/version';
import PanelContent from './PanelContent';
import UpgradeBadge from './UpgradeBadge';
import { useNewVersion } from './useNewVersion';
const useStyles = createStyles(({ css }) => ({
popover: css`
inset-block-start: 8px !important;
inset-inline-start: 8px !important;
`,
}));
const useStyles = createStyles(({ css }) => {
return {
popover: css`
inset-block-start: ${isDesktop ? 24 : 8}px !important;
inset-inline-start: 8px !important;
`,
};
});
const UserPanel = memo<PropsWithChildren>(({ children }) => {
const hasNewVersion = useNewVersion();

View File

@@ -1,11 +1,21 @@
import { TRPCError } from '@trpc/server';
import { enableClerk } from '@/const/auth';
import { DESKTOP_USER_ID } from '@/const/desktop';
import { isDesktop } from '@/const/version';
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.user` is nullable
if (!ctx.userId) {
if (enableClerk) {

View File

@@ -21,6 +21,7 @@ vi.mock('@/config/tools', () => ({
vi.mock('@/const/version', () => ({
isServerMode: true,
isDesktop: false,
}));
const createCaller = createCallerFactory(searchRouter);

View File

@@ -0,0 +1,9 @@
import { dispatch } from '@/utils/electron/dispatch';
class DevtoolsService {
async openDevtools(): Promise<void> {
return dispatch('openDevtools');
}
}
export const electronDevtoolsService = new DevtoolsService();

14
src/styles/electron.ts Normal file
View File

@@ -0,0 +1,14 @@
import { css, cx } from 'antd-style';
export const draggable = cx(css`
-webkit-app-region: drag;
`);
export const nodrag = cx(css`
-webkit-app-region: no-drag;
`);
export const electronStylish = {
draggable,
nodrag,
};

11
src/types/electron.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { DispatchInvoke } from '@lobechat/electron-client-ipc';
export interface IElectronAPI {
invoke: DispatchInvoke;
}
declare global {
interface Window {
electronAPI: IElectronAPI;
}
}

View File

@@ -0,0 +1,10 @@
import { DispatchInvoke } from '@lobechat/electron-client-ipc';
/**
* client 端请求 sketch 端 event 数据的方法
*/
export const dispatch: DispatchInvoke = async (event, ...data) => {
if (!window.electronAPI) throw new Error('electronAPI not found');
return window.electronAPI.invoke(event, ...data);
};

View File

@@ -27,16 +27,16 @@
}
]
},
"exclude": ["node_modules", "public/sw.js"],
"exclude": ["node_modules", "public/sw.js", "apps/desktop"],
"include": [
"**/*.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"vitest.config.ts",
"src",
"tests",
"**/*.ts",
"**/*.d.ts",
"**/*.tsx",
".next/types/**/*.ts"
"vitest.config.ts"
],
"ts-node": {
"compilerOptions": {