diff --git a/next.config.ts b/next.config.ts index 4d51c19515..3b7936420c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 = diff --git a/package.json b/package.json index 1a46949213..f592f94196 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/electron-client-ipc/README.md b/packages/electron-client-ipc/README.md new file mode 100644 index 0000000000..0df84268e3 --- /dev/null +++ b/packages/electron-client-ipc/README.md @@ -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 类型定义共享,确保类型安全 diff --git a/packages/electron-client-ipc/package.json b/packages/electron-client-ipc/package.json new file mode 100644 index 0000000000..4b5d1f090f --- /dev/null +++ b/packages/electron-client-ipc/package.json @@ -0,0 +1,7 @@ +{ + "name": "@lobechat/electron-client-ipc", + "version": "1.0.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/electron-client-ipc/src/events/devtools.ts b/packages/electron-client-ipc/src/events/devtools.ts new file mode 100644 index 0000000000..597255facb --- /dev/null +++ b/packages/electron-client-ipc/src/events/devtools.ts @@ -0,0 +1,6 @@ +export interface DevtoolsDispatchEvents { + /** + * open the LobeHub Devtools + */ + openDevtools: () => void; +} diff --git a/packages/electron-client-ipc/src/events/index.ts b/packages/electron-client-ipc/src/events/index.ts new file mode 100644 index 0000000000..00d00c37ca --- /dev/null +++ b/packages/electron-client-ipc/src/events/index.ts @@ -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 = ReturnType< + ClientDispatchEvents[T] +>; diff --git a/packages/electron-client-ipc/src/index.ts b/packages/electron-client-ipc/src/index.ts new file mode 100644 index 0000000000..9eebe3a53a --- /dev/null +++ b/packages/electron-client-ipc/src/index.ts @@ -0,0 +1,2 @@ +export * from './events'; +export * from './types'; diff --git a/packages/electron-client-ipc/src/types/dispatch.ts b/packages/electron-client-ipc/src/types/dispatch.ts new file mode 100644 index 0000000000..517b888cd6 --- /dev/null +++ b/packages/electron-client-ipc/src/types/dispatch.ts @@ -0,0 +1,10 @@ +import type { + ClientDispatchEventKey, + ClientDispatchEvents, + ClientEventReturnType, +} from '../events'; + +export type DispatchInvoke = ( + event: T, + ...data: Parameters +) => Promise>; diff --git a/packages/electron-client-ipc/src/types/index.ts b/packages/electron-client-ipc/src/types/index.ts new file mode 100644 index 0000000000..5ab2e22fda --- /dev/null +++ b/packages/electron-client-ipc/src/types/index.ts @@ -0,0 +1 @@ +export * from './dispatch'; diff --git a/packages/electron-server-ipc/README.md b/packages/electron-server-ipc/README.md index 4193efac44..08ea7a0fcc 100644 --- a/packages/electron-server-ipc/README.md +++ b/packages/electron-server-ipc/README.md @@ -4,7 +4,7 @@ LobeHub 的 Electron 应用与服务端之间的 IPC(进程间通信)模块 ## 📝 简介 -`@lobechat/electron-server-ipc` 是 LobeHub 桌面应用的核心组件,负责处理 Electron 进程与 nextjs 服务端之间的通信。它提供了一套简单而健壮的 API,用于在不同进程间传递数据和执行远程方法调用。 +`@lobechat/electron-server-ipc` 是 LobeHub 桌面应用的核心组件,负责处理 Electron 主进程与 nextjs 服务端之间的通信。它提供了一套简单而健壮的 API,用于在不同进程间传递数据和执行远程方法调用。 ## 🛠️ 核心功能 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5f84be910e..466721bc4c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'packages/**' - '.' + - '!apps/**' diff --git a/src/app/desktop/devtools/page.tsx b/src/app/desktop/devtools/page.tsx new file mode 100644 index 0000000000..48fd16668e --- /dev/null +++ b/src/app/desktop/devtools/page.tsx @@ -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: , + icon: , + key: 'Postgres Viewer', + }, + { + children: , + icon: , + key: 'System Status', + }, + ]; + + const [tab, setTab] = useState(items[0].key); + + return ( + + } + bottomActions={[]} + style={{ + paddingBlock: 32, + width: 48, + }} + topActions={items.map((item) => ( + setTab(item.key)} + placement={'right'} + title={item.key} + > + {item.icon} + + ))} + /> + + + + {BRANDING_NAME} Dev Tools + / + {tab} + + + {items.map((item) => ( + + {item.children} + + ))} + + + ); +}); + +export default DevTools; diff --git a/src/app/desktop/layout.tsx b/src/app/desktop/layout.tsx new file mode 100644 index 0000000000..6881d69079 --- /dev/null +++ b/src/app/desktop/layout.tsx @@ -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 ( + + + + + + {children} + + + + + + ); +}; + +export default RootLayout; diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000000..090dbc182b --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,11 @@ +import { PropsWithChildren } from 'react'; + +const Layout = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +export default Layout; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000000..49edd856a4 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1 @@ +export { default } from '@/components/404'; diff --git a/src/const/desktop.ts b/src/const/desktop.ts new file mode 100644 index 0000000000..c8f70f0fee --- /dev/null +++ b/src/const/desktop.ts @@ -0,0 +1 @@ +export const DESKTOP_USER_ID = 'DEFAULT_DESKTOP_USER'; diff --git a/src/const/version.ts b/src/const/version.ts index 0d21037101..98c7c298bb 100644 --- a/src/const/version.ts +++ b/src/const/version.ts @@ -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 diff --git a/src/database/client/db.ts b/src/database/client/db.ts index 7408815109..04ceb9c54e 100644 --- a/src/database/client/db.ts +++ b/src/database/client/db.ts @@ -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) { diff --git a/src/database/models/drizzleMigration.ts b/src/database/models/drizzleMigration.ts index c9d24138f4..dd536cebe1 100644 --- a/src/database/models/drizzleMigration.ts +++ b/src/database/models/drizzleMigration.ts @@ -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;', diff --git a/src/database/models/user.ts b/src/database/models/user.ts index c42d4dfbdd..17affb21fd 100644 --- a/src/database/models/user.ts +++ b/src/database/models/user.ts @@ -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 diff --git a/src/features/DevPanel/features/FloatPanel.tsx b/src/features/DevPanel/features/FloatPanel.tsx index 639eae72c1..881a7de531 100644 --- a/src/features/DevPanel/features/FloatPanel.tsx +++ b/src/features/DevPanel/features/FloatPanel.tsx @@ -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(({ 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(({ items }) => { return ( <> - } - onClick={() => setIsExpanded(!isExpanded)} - /> + { + // desktop devtools 下隐藏 + pathname !== '/desktop/devtools' && ( + } + onClick={async () => { + if (isDesktop) { + const { electronDevtoolsService } = await import('@/services/electron/devtools'); + + await electronDevtoolsService.openDevtools(); + + return; + } + setIsExpanded(!isExpanded); + }} + /> + ) + } {isExpanded && ( ({ - 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(({ children }) => { const hasNewVersion = useNewVersion(); diff --git a/src/libs/trpc/middleware/userAuth.ts b/src/libs/trpc/middleware/userAuth.ts index 77d0ebf2d2..e165659258 100644 --- a/src/libs/trpc/middleware/userAuth.ts +++ b/src/libs/trpc/middleware/userAuth.ts @@ -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) { diff --git a/src/server/routers/tools/__tests__/search.test.ts b/src/server/routers/tools/__tests__/search.test.ts index a5135026e4..f48bab6c81 100644 --- a/src/server/routers/tools/__tests__/search.test.ts +++ b/src/server/routers/tools/__tests__/search.test.ts @@ -21,6 +21,7 @@ vi.mock('@/config/tools', () => ({ vi.mock('@/const/version', () => ({ isServerMode: true, + isDesktop: false, })); const createCaller = createCallerFactory(searchRouter); diff --git a/src/services/electron/devtools.ts b/src/services/electron/devtools.ts new file mode 100644 index 0000000000..cfd314d7ce --- /dev/null +++ b/src/services/electron/devtools.ts @@ -0,0 +1,9 @@ +import { dispatch } from '@/utils/electron/dispatch'; + +class DevtoolsService { + async openDevtools(): Promise { + return dispatch('openDevtools'); + } +} + +export const electronDevtoolsService = new DevtoolsService(); diff --git a/src/styles/electron.ts b/src/styles/electron.ts new file mode 100644 index 0000000000..b57ea6a6ee --- /dev/null +++ b/src/styles/electron.ts @@ -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, +}; diff --git a/src/types/electron.ts b/src/types/electron.ts new file mode 100644 index 0000000000..bfb25ad9a8 --- /dev/null +++ b/src/types/electron.ts @@ -0,0 +1,11 @@ +import type { DispatchInvoke } from '@lobechat/electron-client-ipc'; + +export interface IElectronAPI { + invoke: DispatchInvoke; +} + +declare global { + interface Window { + electronAPI: IElectronAPI; + } +} diff --git a/src/utils/electron/dispatch.ts b/src/utils/electron/dispatch.ts new file mode 100644 index 0000000000..4514bfc120 --- /dev/null +++ b/src/utils/electron/dispatch.ts @@ -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); +}; diff --git a/tsconfig.json b/tsconfig.json index 3ba53989b8..26987c5280 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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": {