diff --git a/.env.desktop b/.env.desktop new file mode 100644 index 0000000000..9aec9e1455 --- /dev/null +++ b/.env.desktop @@ -0,0 +1,7 @@ +# copy this file to .env when you want to develop the desktop app or you will fail +APP_URL=http://localhost:3015 +FEATURE_FLAGS=+pin_list +KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE= +DATABASE_URL=postgresql://postgres@localhost:5432/postgres +DEFAULT_AGENT_CONFIG="model=qwen2.5;provider=ollama;chatConfig.searchFCModel.provider=ollama;chatConfig.searchFCModel.model=qwen2.5" +SYSTEM_AGENT="default=ollama/qwen2.5" diff --git a/.github/scripts/pr-release-body.js b/.github/scripts/pr-release-body.js index 989d596c78..fbff1c7af1 100644 --- a/.github/scripts/pr-release-body.js +++ b/.github/scripts/pr-release-body.js @@ -9,8 +9,10 @@ module.exports = ({ version, prNumber, branch }) => { ## PR Build Information **Version**: \`${version}\` +**Release Time**: \`${new Date().toISOString()}\` **PR**: [#${prNumber}](${prLink}) + ## ⚠️ Important Notice This is a **development build** specifically created for testing purposes. Please note: @@ -35,6 +37,7 @@ Please report any issues found in this build directly in the PR discussion. ## PR 构建信息 **版本**: \`${version}\` +**发布时间**: \`${new Date().toISOString()}\` **PR**: [#${prNumber}](${prLink}) ## ⚠️ 重要提示 diff --git a/package.json b/package.json index 04396c030f..0908b374be 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "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 ", + "prebuild:electron": "cross-env NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/prebuild.mts", + "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,7 +45,12 @@ "db:studio": "drizzle-kit studio", "db:visualize": "dbdocs build docs/development/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", + "dev:desktop": "next dev --turbopack -p 3015", "docs:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx", "docs:seo": "lobe-seo && npm run lint:mdx", "i18n": "npm run workflow:i18n && lobe-i18n", @@ -78,7 +84,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 +300,7 @@ "ajv-keywords": "^5.1.0", "commitlint": "^19.8.0", "consola": "^3.4.2", + "cross-env": "^7.0.3", "crypto-js": "^4.2.0", "dbdocs": "^0.14.3", "dotenv": "^16.4.7", diff --git a/packages/electron-client-ipc/src/events/file.ts b/packages/electron-client-ipc/src/events/file.ts new file mode 100644 index 0000000000..af86023b6e --- /dev/null +++ b/packages/electron-client-ipc/src/events/file.ts @@ -0,0 +1,5 @@ +import { FileMetadata, UploadFileParams } from '../types'; + +export interface FilesDispatchEvents { + createFile: (params: UploadFileParams) => { metadata: FileMetadata; success: boolean }; +} diff --git a/packages/electron-client-ipc/src/events/index.ts b/packages/electron-client-ipc/src/events/index.ts index fa18b6cc8e..c79004968f 100644 --- a/packages/electron-client-ipc/src/events/index.ts +++ b/packages/electron-client-ipc/src/events/index.ts @@ -1,6 +1,9 @@ +import { FilesDispatchEvents } from './file'; import { MenuDispatchEvents } from './menu'; import { FilesSearchDispatchEvents } from './search'; +import { ShortcutDispatchEvents } from './shortcut'; import { SystemDispatchEvents } from './system'; +import { AutoUpdateBroadcastEvents, AutoUpdateDispatchEvents } from './update'; import { WindowsDispatchEvents } from './windows'; /** @@ -11,10 +14,25 @@ export interface ClientDispatchEvents extends WindowsDispatchEvents, FilesSearchDispatchEvents, SystemDispatchEvents, - MenuDispatchEvents {} + MenuDispatchEvents, + FilesDispatchEvents, + AutoUpdateDispatchEvents, + ShortcutDispatchEvents {} export type ClientDispatchEventKey = keyof ClientDispatchEvents; export type ClientEventReturnType = ReturnType< ClientDispatchEvents[T] >; + +/** + * main -> render broadcast events + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface MainBroadcastEvents extends AutoUpdateBroadcastEvents {} + +export type MainBroadcastEventKey = keyof MainBroadcastEvents; + +export type MainBroadcastParams = Parameters< + MainBroadcastEvents[T] +>[0]; diff --git a/packages/electron-client-ipc/src/events/shortcut.ts b/packages/electron-client-ipc/src/events/shortcut.ts new file mode 100644 index 0000000000..cdeb368ddb --- /dev/null +++ b/packages/electron-client-ipc/src/events/shortcut.ts @@ -0,0 +1,4 @@ +export interface ShortcutDispatchEvents { + getShortcutsConfig: () => Record; + updateShortcutConfig: (id: string, accelerator: string) => boolean; +} diff --git a/packages/electron-client-ipc/src/events/system.ts b/packages/electron-client-ipc/src/events/system.ts index dae71b44cc..098e17ba36 100644 --- a/packages/electron-client-ipc/src/events/system.ts +++ b/packages/electron-client-ipc/src/events/system.ts @@ -1,3 +1,8 @@ export interface SystemDispatchEvents { checkSystemAccessibility: () => boolean | undefined; + /** + * 更新应用语言设置 + * @param locale 语言设置 + */ + updateLocale: (locale: string) => { success: boolean }; } diff --git a/packages/electron-client-ipc/src/events/update.ts b/packages/electron-client-ipc/src/events/update.ts new file mode 100644 index 0000000000..9896a496b1 --- /dev/null +++ b/packages/electron-client-ipc/src/events/update.ts @@ -0,0 +1,20 @@ +import { ProgressInfo, UpdateInfo } from '../types'; + +export interface AutoUpdateDispatchEvents { + checkUpdate: () => void; + downloadUpdate: () => void; + installLater: () => void; + installNow: () => void; + installUpdate: () => void; +} + +export interface AutoUpdateBroadcastEvents { + updateAvailable: (info: UpdateInfo) => void; + updateCheckStart: () => void; + updateDownloadProgress: (progress: ProgressInfo) => void; + updateDownloadStart: () => void; + updateDownloaded: (info: UpdateInfo) => void; + updateError: (message: string) => void; + updateNotAvailable: (info: UpdateInfo) => void; + updateWillInstallLater: () => void; +} diff --git a/packages/electron-client-ipc/src/events/windows.ts b/packages/electron-client-ipc/src/events/windows.ts index 75db420e17..68f4963a46 100644 --- a/packages/electron-client-ipc/src/events/windows.ts +++ b/packages/electron-client-ipc/src/events/windows.ts @@ -1,4 +1,13 @@ +import { InterceptRouteParams, InterceptRouteResponse } from '../types/route'; + export interface WindowsDispatchEvents { + /** + * 拦截客户端路由导航请求 + * @param params 包含路径和来源信息的参数对象 + * @returns 路由拦截结果 + */ + interceptRoute: (params: InterceptRouteParams) => InterceptRouteResponse; + /** * open the LobeHub Devtools */ diff --git a/packages/electron-client-ipc/src/index.ts b/packages/electron-client-ipc/src/index.ts index 8854faaf6f..370d101bf2 100644 --- a/packages/electron-client-ipc/src/index.ts +++ b/packages/electron-client-ipc/src/index.ts @@ -1,3 +1,4 @@ export * from './dispatch'; export * from './events'; export * from './types'; +export * from './useWatchBroadcast'; diff --git a/packages/electron-client-ipc/src/types/file.ts b/packages/electron-client-ipc/src/types/file.ts new file mode 100644 index 0000000000..86f2d3ff26 --- /dev/null +++ b/packages/electron-client-ipc/src/types/file.ts @@ -0,0 +1,14 @@ +export interface UploadFileParams { + content: ArrayBuffer; + filename: string; + hash: string; + path: string; + type: string; +} + +export interface FileMetadata { + date: string; + dirname: string; + filename: string; + path: string; +} diff --git a/packages/electron-client-ipc/src/types/index.ts b/packages/electron-client-ipc/src/types/index.ts index 5ab2e22fda..32679da0df 100644 --- a/packages/electron-client-ipc/src/types/index.ts +++ b/packages/electron-client-ipc/src/types/index.ts @@ -1 +1,5 @@ export * from './dispatch'; +export * from './file'; +export * from './route'; +export * from './shortcut'; +export * from './update'; diff --git a/packages/electron-client-ipc/src/types/route.ts b/packages/electron-client-ipc/src/types/route.ts new file mode 100644 index 0000000000..d65c3babb8 --- /dev/null +++ b/packages/electron-client-ipc/src/types/route.ts @@ -0,0 +1,46 @@ +export interface InterceptRouteParams { + /** + * 请求路径 + */ + path: string; + /** + * 来源类型:'link-click', 'push-state', 'replace-state' + */ + source: 'link-click' | 'push-state' | 'replace-state'; + /** + * 完整URL + */ + url: string; +} + +export interface InterceptRouteResponse { + /** + * 错误信息 (如果有) + */ + error?: string; + + /** + * 是否已拦截 + */ + intercepted: boolean; + + /** + * 原始路径 + */ + path: string; + + /** + * 原始来源 + */ + source: string; + + /** + * 子路径 (如果有) + */ + subPath?: string; + + /** + * 目标窗口标识符 + */ + targetWindow?: string; +} diff --git a/packages/electron-client-ipc/src/types/shortcut.ts b/packages/electron-client-ipc/src/types/shortcut.ts new file mode 100644 index 0000000000..6eaf60b0a8 --- /dev/null +++ b/packages/electron-client-ipc/src/types/shortcut.ts @@ -0,0 +1,11 @@ +export interface ShortcutConfig { + /** + * 快捷键加速器(如 CommandOrControl+E) + */ + accelerator: string; + /** + * 快捷键 ID + */ + id: string; +} +export type ShortcutActionType = Record; diff --git a/packages/electron-client-ipc/src/types/update.ts b/packages/electron-client-ipc/src/types/update.ts new file mode 100644 index 0000000000..dbb02673eb --- /dev/null +++ b/packages/electron-client-ipc/src/types/update.ts @@ -0,0 +1,23 @@ +export interface ReleaseNoteInfo { + /** + * The note. + */ + note: string | null; + /** + * The version. + */ + version: string; +} + +export interface ProgressInfo { + bytesPerSecond: number; + percent: number; + total: number; + transferred: number; +} + +export interface UpdateInfo { + releaseDate: string; + releaseNotes?: string | ReleaseNoteInfo[]; + version: string; +} diff --git a/packages/electron-client-ipc/src/useWatchBroadcast.ts b/packages/electron-client-ipc/src/useWatchBroadcast.ts new file mode 100644 index 0000000000..403d3cb6b5 --- /dev/null +++ b/packages/electron-client-ipc/src/useWatchBroadcast.ts @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect } from 'react'; + +import { MainBroadcastEventKey, MainBroadcastParams } from './events'; + +interface ElectronAPI { + ipcRenderer: { + on: (event: MainBroadcastEventKey, listener: (e: any, data: any) => void) => void; + removeListener: (event: MainBroadcastEventKey, listener: (e: any, data: any) => void) => void; + }; +} + +declare global { + interface Window { + electron: ElectronAPI; + } +} + +export const useWatchBroadcast = ( + event: T, + handler: (data: MainBroadcastParams) => void, +) => { + useEffect(() => { + if (!window.electron) return; + + const listener = (e: any, data: MainBroadcastParams) => { + handler(data); + }; + + window.electron.ipcRenderer.on(event, listener); + + return () => { + window.electron.ipcRenderer.removeListener(event, listener); + }; + }, []); +}; diff --git a/packages/electron-server-ipc/src/events/database.ts b/packages/electron-server-ipc/src/events/database.ts new file mode 100644 index 0000000000..8a373379d6 --- /dev/null +++ b/packages/electron-server-ipc/src/events/database.ts @@ -0,0 +1,4 @@ +export interface DatabaseDispatchEvents { + getDatabaseSchemaHash: () => string | undefined; + setDatabaseSchemaHash: (hash: string) => void; +} diff --git a/packages/electron-server-ipc/src/events/file.ts b/packages/electron-server-ipc/src/events/file.ts new file mode 100644 index 0000000000..01cb8a67bb --- /dev/null +++ b/packages/electron-server-ipc/src/events/file.ts @@ -0,0 +1,6 @@ +import { DeleteFilesResponse } from '../types/file'; + +export interface FileDispatchEvents { + deleteFiles: (paths: string[]) => DeleteFilesResponse; + getStaticFilePath: (id: string) => string; +} diff --git a/packages/electron-server-ipc/src/events/index.ts b/packages/electron-server-ipc/src/events/index.ts new file mode 100644 index 0000000000..a3eb1a80d1 --- /dev/null +++ b/packages/electron-server-ipc/src/events/index.ts @@ -0,0 +1,29 @@ +/* eslint-disable typescript-sort-keys/interface, sort-keys-fix/sort-keys-fix */ +import { DatabaseDispatchEvents } from './database'; +import { FileDispatchEvents } from './file'; +import { StoragePathDispatchEvents } from './storagePath'; + +/** + * next server -> main dispatch events + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ServerDispatchEvents + extends StoragePathDispatchEvents, + DatabaseDispatchEvents, + FileDispatchEvents {} + +export type ServerDispatchEventKey = keyof ServerDispatchEvents; + +export type ServerEventReturnType = ReturnType< + ServerDispatchEvents[T] +>; + +export type ServerEventParams = Parameters< + ServerDispatchEvents[T] +>[0]; + +export type IPCServerEventHandler = { + [key in ServerDispatchEventKey]: ( + params: ServerEventParams, + ) => Promise>; +}; diff --git a/packages/electron-server-ipc/src/events/storagePath.ts b/packages/electron-server-ipc/src/events/storagePath.ts new file mode 100644 index 0000000000..c1f2d1761e --- /dev/null +++ b/packages/electron-server-ipc/src/events/storagePath.ts @@ -0,0 +1,4 @@ +export interface StoragePathDispatchEvents { + getDatabasePath: () => string; + getUserDataPath: () => string; +} diff --git a/packages/electron-server-ipc/src/index.ts b/packages/electron-server-ipc/src/index.ts index 52f9a0bfc6..de55e5655f 100644 --- a/packages/electron-server-ipc/src/index.ts +++ b/packages/electron-server-ipc/src/index.ts @@ -1,3 +1,4 @@ +export * from './events'; export * from './ipcClient'; export * from './ipcServer'; export * from './types'; diff --git a/packages/electron-server-ipc/src/ipcClient.test.ts b/packages/electron-server-ipc/src/ipcClient.test.ts index 2344549906..09d59177c9 100644 --- a/packages/electron-server-ipc/src/ipcClient.test.ts +++ b/packages/electron-server-ipc/src/ipcClient.test.ts @@ -5,7 +5,6 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ElectronIpcClient } from './ipcClient'; -import { ElectronIPCMethods } from './types'; // Mock node modules vi.mock('node:fs'); @@ -124,7 +123,7 @@ describe('ElectronIpcClient', () => { it('should handle connection errors', async () => { // Start request - but don't await it yet - const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath); + const requestPromise = client.sendRequest('getDatabasePath'); // Find the error event handler const errorCallArgs = mockSocket.on.mock.calls.find((call) => call[0] === 'error'); @@ -154,7 +153,7 @@ describe('ElectronIpcClient', () => { }); // Start request - const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath); + const requestPromise = client.sendRequest('getDatabasePath'); // Simulate connection established if (connectionCallback) connectionCallback(); @@ -188,7 +187,7 @@ describe('ElectronIpcClient', () => { }); // Start a request to establish connection (but don't wait for it) - const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath).catch(() => {}); // Ignore any errors + const requestPromise = client.sendRequest('getDatabasePath').catch(() => {}); // Ignore any errors // Simulate connection if (connectionCallback) connectionCallback(); diff --git a/packages/electron-server-ipc/src/ipcClient.ts b/packages/electron-server-ipc/src/ipcClient.ts index 03d1c1836a..286d015d16 100644 --- a/packages/electron-server-ipc/src/ipcClient.ts +++ b/packages/electron-server-ipc/src/ipcClient.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const'; -import { IElectronIPCMethods } from './types'; +import { ServerDispatchEventKey } from './events'; export class ElectronIpcClient { private socketPath: string | null = null; @@ -16,6 +16,7 @@ export class ElectronIpcClient { private reconnectTimeout: NodeJS.Timeout | null = null; private connectionAttempts: number = 0; private maxConnectionAttempts: number = 5; + private dataBuffer: string = ''; constructor() { this.initialize(); @@ -53,43 +54,64 @@ export class ElectronIpcClient { this.socket = net.createConnection(this.socketPath!, () => { this.connected = true; this.connectionAttempts = 0; - console.log('Connected to Electron IPC server'); + console.log('[ElectronIpcClient] Connected to Electron IPC server'); resolve(); }); this.socket.on('data', (data) => { - try { - const response = JSON.parse(data.toString()); - const { id, result, error } = response; + const dataStr = data.toString(); + console.log('output:', dataStr); - const pending = this.requestQueue.get(id); - if (pending) { - this.requestQueue.delete(id); - if (error) { - pending.reject(new Error(error)); - } else { - pending.resolve(result); + // 将新数据添加到缓冲区 + this.dataBuffer += dataStr; + + // 按换行符分割消息 + const messages = this.dataBuffer.split('\n'); + + // 最后一个元素可能是不完整的消息,保留在缓冲区 + this.dataBuffer = messages.pop() || ''; + + for (const message of messages) { + if (!message.trim()) continue; // 跳过空消息 + + try { + const response = JSON.parse(message); + const { id, result, error } = response; + + const pending = this.requestQueue.get(id); + if (pending) { + this.requestQueue.delete(id); + if (error) { + pending.reject(new Error(error)); + } else { + pending.resolve(result); + } } + } catch (err) { + console.error( + '[ElectronIpcClient] Failed to parse response:', + err, + 'message:', + message, + ); } - } catch (err) { - console.error('Failed to parse response:', err); } }); this.socket.on('error', (err) => { - console.error('Socket error:', err); + console.error('[ElectronIpcClient] Socket error:', err); this.connected = false; this.handleDisconnect(); reject(err); }); this.socket.on('close', () => { - console.log('Socket closed'); + console.log('[ElectronIpcClient] Socket closed'); this.connected = false; this.handleDisconnect(); }); } catch (err) { - console.error('Failed to connect to IPC server:', err); + console.error('[ElectronIpcClient] Failed to connect to IPC server:', err); this.handleDisconnect(); reject(err); } @@ -104,9 +126,12 @@ export class ElectronIpcClient { this.reconnectTimeout = null; } + // 清空数据缓冲区 + this.dataBuffer = ''; + // 拒绝所有待处理的请求 for (const [, { reject }] of this.requestQueue) { - reject(new Error('Connection to Electron IPC server lost')); + reject(new Error('[ElectronIpcClient] Connection to Electron IPC server lost')); } this.requestQueue.clear(); @@ -117,16 +142,19 @@ export class ElectronIpcClient { this.reconnectTimeout = setTimeout(() => { this.connect().catch((err) => { - console.error(`Reconnection attempt ${this.connectionAttempts} failed:`, err); + console.error( + `[ElectronIpcClient] Reconnection attempt ${this.connectionAttempts} failed:`, + err, + ); }); }, delay); } } // 发送请求到 Electron IPC 服务器 - public async sendRequest(method: IElectronIPCMethods, params: any = {}): Promise { + public async sendRequest(method: ServerDispatchEventKey, params: any = {}): Promise { if (!this.socketPath) { - throw new Error('Electron IPC connection not available'); + throw new Error('[ElectronIpcClient] Electron IPC connection not available'); } // 如果未连接,先连接 @@ -145,8 +173,8 @@ export class ElectronIpcClient { // 设置超时 const timeout = setTimeout(() => { this.requestQueue.delete(id); - reject(new Error(`Request ${method} timed out`)); - }, 10_000); + reject(new Error(`[ElectronIpcClient] Request timed out, method: ${method}`)); + }, 5000); // 发送请求 this.socket!.write(JSON.stringify(request), (err) => { diff --git a/packages/electron-server-ipc/src/ipcServer.ts b/packages/electron-server-ipc/src/ipcServer.ts index 89996c060f..203958ecc5 100644 --- a/packages/electron-server-ipc/src/ipcServer.ts +++ b/packages/electron-server-ipc/src/ipcServer.ts @@ -4,16 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const'; -import { IElectronIPCMethods } from './types'; - -export type IPCEventMethod = ( - params: any, - context: { id: string; method: string; socket: net.Socket }, -) => Promise; - -export type ElectronIPCEventHandler = { - [key in IElectronIPCMethods]: IPCEventMethod; -}; +import { ServerDispatchEventKey } from './events'; +import { ElectronIPCEventHandler } from './types'; export class ElectronIPCServer { private server: net.Server; @@ -45,7 +37,7 @@ export class ElectronIPCServer { }); this.server.listen(this.socketPath, () => { - console.log(`Electron IPC server listening on ${this.socketPath}`); + console.log(`[ElectronIPCServer] Electron IPC server listening on ${this.socketPath}`); // 将套接字路径写入临时文件,供 Next.js 服务端读取 const tempDir = os.tmpdir(); @@ -86,7 +78,7 @@ export class ElectronIPCServer { const { id, method, params } = request; // 根据请求方法执行相应的操作 - const eventHandler = this.eventHandler[method as IElectronIPCMethods]; + const eventHandler = this.eventHandler[method as ServerDispatchEventKey]; if (!eventHandler) return; try { @@ -100,12 +92,12 @@ export class ElectronIPCServer { // 发送结果 private sendResult(socket: net.Socket, id: string, result: any): void { - socket.write(JSON.stringify({ id, result })); + socket.write(JSON.stringify({ id, result }) + '\n'); } // 发送错误 private sendError(socket: net.Socket, id: string, error: string): void { - socket.write(JSON.stringify({ error, id })); + socket.write(JSON.stringify({ error, id }) + '\n'); } // 关闭服务器 diff --git a/packages/electron-server-ipc/src/types/event.ts b/packages/electron-server-ipc/src/types/event.ts deleted file mode 100644 index 359717ce9d..0000000000 --- a/packages/electron-server-ipc/src/types/event.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable typescript-sort-keys/interface, sort-keys-fix/sort-keys-fix */ -export const ElectronIPCMethods = { - getDatabasePath: 'getDatabasePath', - getUserDataPath: 'getUserDataPath', - - getDatabaseSchemaHash: 'getDatabaseSchemaHash', - setDatabaseSchemaHash: 'setDatabaseSchemaHash', -} as const; - -export type IElectronIPCMethods = keyof typeof ElectronIPCMethods; - -export interface IpcDispatchEvent { - getDatabasePath: () => Promise; - getUserDataPath: () => Promise; - - getDatabaseSchemaHash: () => Promise; - setDatabaseSchemaHash: (hash: string) => Promise; -} diff --git a/packages/electron-server-ipc/src/types/file.ts b/packages/electron-server-ipc/src/types/file.ts new file mode 100644 index 0000000000..1be391c688 --- /dev/null +++ b/packages/electron-server-ipc/src/types/file.ts @@ -0,0 +1,4 @@ +export interface DeleteFilesResponse { + errors?: { message: string; path: string }[]; + success: boolean; +} diff --git a/packages/electron-server-ipc/src/types/index.ts b/packages/electron-server-ipc/src/types/index.ts index 0ef93842ad..e5768bf52b 100644 --- a/packages/electron-server-ipc/src/types/index.ts +++ b/packages/electron-server-ipc/src/types/index.ts @@ -1 +1,14 @@ -export * from './event'; +import net from 'node:net'; + +import { ServerDispatchEventKey } from '../events'; + +export type IPCEventMethod = ( + params: any, + context: { id: string; method: string; socket: net.Socket }, +) => Promise; + +export type ElectronIPCEventHandler = { + [key in ServerDispatchEventKey]: IPCEventMethod; +}; + +export * from './file'; diff --git a/scripts/electronWorkflow/buildElectron.ts b/scripts/electronWorkflow/buildElectron.ts new file mode 100644 index 0000000000..a8e371b2b9 --- /dev/null +++ b/scripts/electronWorkflow/buildElectron.ts @@ -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(); diff --git a/scripts/electronWorkflow/moveNextStandalone.ts b/scripts/electronWorkflow/moveNextStandalone.ts new file mode 100644 index 0000000000..ff7b2896b6 --- /dev/null +++ b/scripts/electronWorkflow/moveNextStandalone.ts @@ -0,0 +1,69 @@ +/* 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'); + +// 向 sourceDir 写入 .env 文件 +const env = fs.readFileSync(path.join(rootDir, '.env.desktop'), 'utf8'); + +fs.writeFileSync(path.join(sourceDir, '.env'), env, 'utf8'); +console.log(`⚓️ Inject .env successful`); + +// 确保目标目录的父目录存在 +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!`); diff --git a/scripts/electronWorkflow/setDesktopVersion.ts b/scripts/electronWorkflow/setDesktopVersion.ts new file mode 100644 index 0000000000..d11e60930b --- /dev/null +++ b/scripts/electronWorkflow/setDesktopVersion.ts @@ -0,0 +1,96 @@ +/* eslint-disable unicorn/no-process-exit */ +import fs from 'fs-extra'; +import path from 'node:path'; + +// 获取脚本的命令行参数 +const version = process.argv[2]; +const isPr = process.argv[3] === 'true'; + +if (!version) { + console.error('Missing version parameter, usage: bun run setDesktopVersion.ts [isPr]'); + process.exit(1); +} + +// 获取根目录 +const rootDir = path.resolve(__dirname, '../..'); + +// 桌面应用 package.json 的路径 +const desktopPackageJsonPath = path.join(rootDir, 'apps/desktop/package.json'); + +// 更新应用图标 +function updateAppIcon() { + try { + const buildDir = path.join(rootDir, 'apps/desktop/build'); + + // 定义需要处理的图标映射,考虑到大小写敏感性 + const iconMappings = [ + // { ext: '.ico', nightly: 'icon-nightly.ico', normal: 'icon.ico' }, + { ext: '.png', nightly: 'icon-nightly.png', normal: 'icon.png' }, + { ext: '.icns', nightly: 'Icon-nightly.icns', normal: 'Icon.icns' }, + ]; + + // 处理每种图标格式 + for (const mapping of iconMappings) { + const sourceFile = path.join(buildDir, mapping.nightly); + const targetFile = path.join(buildDir, mapping.normal); + + // 检查源文件是否存在 + if (fs.existsSync(sourceFile)) { + // 只有当源文件和目标文件不同,才进行复制 + if (sourceFile !== targetFile) { + fs.copyFileSync(sourceFile, targetFile); + console.log(`Updated app icon: ${targetFile}`); + } + } else { + console.warn(`Warning: Source icon not found: ${sourceFile}`); + } + } + } catch (error) { + console.error('Error updating icons:', error); + // 继续处理,不终止程序 + } +} + +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; + packageJson.productName = 'LobeHub'; + packageJson.name = 'lobehub-desktop'; + + // 如果是 PR 构建,设置为 Nightly 版本 + if (isPr) { + // 修改包名,添加 -nightly 后缀 + if (!packageJson.name.endsWith('-nightly')) { + packageJson.name = `${packageJson.name}-nightly`; + } + + // 修改产品名称为 LobeHub Nightly + packageJson.productName = 'LobeHub-Nightly'; + + console.log('🌙 Setting as Nightly version with modified package name and productName'); + + // 使用 nightly 图标替换常规图标 + updateAppIcon(); + } + + // 写回文件 + fs.writeJsonSync(desktopPackageJsonPath, packageJson, { spaces: 2 }); + + console.log(`Desktop app version updated to: ${version}, isPr: ${isPr}`); + } catch (error) { + console.error('Error updating version:', error); + process.exit(1); + } +} + +updateVersion(); diff --git a/scripts/prebuild.mts b/scripts/prebuild.mts new file mode 100644 index 0000000000..073353b49e --- /dev/null +++ b/scripts/prebuild.mts @@ -0,0 +1,77 @@ +import * as dotenv from 'dotenv'; +import { existsSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; + +const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1'; + +dotenv.config(); +// 创建需要排除的特性映射 +/* eslint-disable sort-keys-fix/sort-keys-fix */ +const partialBuildPages = [ + // no need for desktop + { + name: 'changelog', + disabled: isDesktop, + paths: ['src/app/[variants]/@modal/(.)changelog', 'src/app/[variants]/(main)/changelog'], + }, + { + name: 'auth', + disabled: isDesktop, + paths: ['src/app/[variants]/(auth)'], + }, + { + name: 'mobile', + disabled: isDesktop, + paths: ['src/app/[variants]/(main)/(mobile)'], + }, + { + name: 'api-webhooks', + disabled: isDesktop, + paths: ['src/app/(backend)/api/webhooks'], + }, + + // no need for web + { + name: 'desktop-devtools', + disabled: !isDesktop, + paths: ['src/app/desktop'], + }, + { + name: 'desktop-trpc', + disabled: !isDesktop, + paths: ['src/app/(backend)/trpc/desktop'], + }, +]; +/* eslint-enable */ + +/** + * 删除指定的目录 + */ +const removeDirectories = async () => { + // 遍历 partialBuildPages 数组 + for (const page of partialBuildPages) { + // 检查是否需要禁用该功能 + if (page.disabled) { + for (const dirPath of page.paths) { + const fullPath = path.resolve(process.cwd(), dirPath); + + // 检查目录是否存在 + if (existsSync(fullPath)) { + try { + // 递归删除目录 + await rm(fullPath, { force: true, recursive: true }); + console.log(`♻️ Removed ${dirPath} successfully`); + } catch (error) { + console.error(`Failed to remove directory ${dirPath}:`, error); + } + } + } + } + } +}; + +// 执行删除操作 +console.log('Starting prebuild cleanup...'); +await removeDirectories(); +console.log('Prebuild cleanup completed.'); diff --git a/src/prompts/files/file.ts b/src/prompts/files/file.ts index 3205e23852..63f34f3018 100644 --- a/src/prompts/files/file.ts +++ b/src/prompts/files/file.ts @@ -1,14 +1,16 @@ import { ChatFileItem } from '@/types/message'; -const filePrompt = (item: ChatFileItem) => - ``; +const filePrompt = (item: ChatFileItem, addUrl: boolean) => + addUrl + ? `` + : ``; -export const filePrompts = (fileList: ChatFileItem[]) => { +export const filePrompts = (fileList: ChatFileItem[], addUrl: boolean) => { if (fileList.length === 0) return ''; const prompt = ` here are user upload files you can refer to -${fileList.map((item) => filePrompt(item)).join('\n')} +${fileList.map((item) => filePrompt(item, addUrl)).join('\n')} `; return prompt.trim(); diff --git a/src/prompts/files/image.ts b/src/prompts/files/image.ts index d5664b9b08..bd8b182f4f 100644 --- a/src/prompts/files/image.ts +++ b/src/prompts/files/image.ts @@ -1,13 +1,16 @@ import { ChatImageItem } from '@/types/message'; -const imagePrompt = (item: ChatImageItem) => ``; +const imagePrompt = (item: ChatImageItem, attachUrl: boolean) => + attachUrl + ? `` + : ``; -export const imagesPrompts = (imageList: ChatImageItem[]) => { +export const imagesPrompts = (imageList: ChatImageItem[], attachUrl: boolean) => { if (imageList.length === 0) return ''; const prompt = ` here are user upload images you can refer to -${imageList.map((item) => imagePrompt(item)).join('\n')} +${imageList.map((item) => imagePrompt(item, attachUrl)).join('\n')} `; return prompt.trim(); diff --git a/src/prompts/files/index.test.ts b/src/prompts/files/index.test.ts index 05928f9d1c..5fd27bebdc 100644 --- a/src/prompts/files/index.test.ts +++ b/src/prompts/files/index.test.ts @@ -135,4 +135,54 @@ describe('filesPrompts', () => { expect(result).toMatch(/.*/s); // Check for multiple image tags expect(result).toMatch(/.*/s); // Check for multiple file tags }); + + it('should handle without url', () => { + const images: ChatImageItem[] = [ + mockImage, + { + id: 'img-2', + alt: 'second image', + url: 'https://example.com/image2.jpg', + }, + ]; + + const files: ChatFileItem[] = [ + mockFile, + { + id: 'file-2', + name: 'document.docx', + fileType: 'application/docx', + size: 2048, + url: 'https://example.com/document.docx', + }, + ]; + + const result = filesPrompts({ + imageList: images, + fileList: files, + addUrl: false, + }); + + expect(result).toMatchInlineSnapshot(` + " + following part contains context information injected by the system. Please follow these instructions: + + 1. Always prioritize handling user-visible content. + 2. the context is only required when user's queries rely on it. + + + + here are user upload images you can refer to + + + + + here are user upload files you can refer to + + + + + " + `); + }); }); diff --git a/src/prompts/files/index.ts b/src/prompts/files/index.ts index 17d7efd3ac..40a452c6eb 100644 --- a/src/prompts/files/index.ts +++ b/src/prompts/files/index.ts @@ -6,7 +6,9 @@ import { imagesPrompts } from './image'; export const filesPrompts = ({ imageList, fileList, + addUrl = true, }: { + addUrl?: boolean; fileList?: ChatFileItem[]; imageList: ChatImageItem[]; }) => { @@ -19,8 +21,8 @@ export const filesPrompts = ({ 2. the context is only required when user's queries rely on it. -${imagesPrompts(imageList)} -${fileList ? filePrompts(fileList) : ''} +${imagesPrompts(imageList, addUrl)} +${fileList ? filePrompts(fileList, addUrl) : ''} `; diff --git a/src/services/chat.ts b/src/services/chat.ts index 980d6dfc82..60ec9e53a9 100644 --- a/src/services/chat.ts +++ b/src/services/chat.ts @@ -8,7 +8,7 @@ import { INBOX_GUIDE_SYSTEMROLE } from '@/const/guide'; import { INBOX_SESSION_ID } from '@/const/session'; import { DEFAULT_AGENT_CONFIG } from '@/const/settings'; import { TracePayload, TraceTagMap } from '@/const/trace'; -import { isDeprecatedEdition, isServerMode } from '@/const/version'; +import { isDeprecatedEdition, isDesktop, isServerMode } from '@/const/version'; import { AgentRuntime, AgentRuntimeError, @@ -480,7 +480,9 @@ class ChatService { const imageList = m.imageList || []; - const filesContext = isServerMode ? filesPrompts({ fileList: m.fileList, imageList }) : ''; + const filesContext = isServerMode + ? filesPrompts({ addUrl: !isDesktop, fileList: m.fileList, imageList }) + : ''; return [ { text: (m.content + '\n\n' + filesContext).trim(), type: 'text' }, ...imageList.map( diff --git a/src/store/global/actions/general.ts b/src/store/global/actions/general.ts index 62d83146f0..cb01f61a68 100644 --- a/src/store/global/actions/general.ts +++ b/src/store/global/actions/general.ts @@ -5,7 +5,7 @@ import { SWRResponse } from 'swr'; import type { StateCreator } from 'zustand/vanilla'; import { LOBE_THEME_APPEARANCE } from '@/const/theme'; -import { CURRENT_VERSION } from '@/const/version'; +import { CURRENT_VERSION, isDesktop } from '@/const/version'; import { useOnlyFetchOnceSWR } from '@/libs/swr'; import { globalService } from '@/services/global'; import type { SystemStatus } from '@/store/global/initialState'; @@ -37,6 +37,18 @@ export const generalActionSlice: StateCreator< get().updateSystemStatus({ language: locale }); switchLang(locale); + + if (isDesktop) { + (async () => { + try { + const { dispatch } = await import('@lobechat/electron-client-ipc'); + + await dispatch('updateLocale', locale); + } catch (error) { + console.error('Failed to update locale in main process:', error); + } + })(); + } }, switchThemeMode: (themeMode) => { get().updateSystemStatus({ themeMode }); @@ -44,7 +56,6 @@ export const generalActionSlice: StateCreator< setCookie(LOBE_THEME_APPEARANCE, themeMode === 'auto' ? undefined : themeMode); }, updateSystemStatus: (status, action) => { - // Status cannot be modified when it is not initialized if (!get().isStatusInit) return; const nextStatus = merge(get().status, status); @@ -60,19 +71,15 @@ export const generalActionSlice: StateCreator< enabledCheck ? 'checkLatestVersion' : null, async () => globalService.getLatestVersion(), { - // check latest version every 30 minutes focusThrottleInterval: 1000 * 60 * 30, onSuccess: (data: string) => { if (!valid(CURRENT_VERSION) || !valid(data)) return; - // Parse versions to ensure we're working with valid SemVer objects const currentVersion = parse(CURRENT_VERSION); const latestVersion = parse(data); if (!currentVersion || !latestVersion) return; - // only compare major and minor versions - // solve the problem of frequent patch updates const currentMajorMinor = `${currentVersion.major}.${currentVersion.minor}.0`; const latestMajorMinor = `${latestVersion.major}.${latestVersion.minor}.0`;