👷 build: add desktop workflow and pre-merge desktop code (#7361)

* add desktop workflow code

* add desktop relative code

* Update pr-release-body.js
This commit is contained in:
Arvin Xu
2025-04-10 10:47:05 +08:00
committed by GitHub
parent 88a7149a58
commit f57f7af1b8
37 changed files with 717 additions and 80 deletions

7
.env.desktop Normal file
View File

@@ -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"

View File

@@ -9,8 +9,10 @@ module.exports = ({ version, prNumber, branch }) => {
## PR Build Information ## PR Build Information
**Version**: \`${version}\` **Version**: \`${version}\`
**Release Time**: \`${new Date().toISOString()}\`
**PR**: [#${prNumber}](${prLink}) **PR**: [#${prNumber}](${prLink})
## ⚠️ Important Notice ## ⚠️ Important Notice
This is a **development build** specifically created for testing purposes. Please note: 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 构建信息 ## PR 构建信息
**版本**: \`${version}\` **版本**: \`${version}\`
**发布时间**: \`${new Date().toISOString()}\`
**PR**: [#${prNumber}](${prLink}) **PR**: [#${prNumber}](${prLink})
## ⚠️ 重要提示 ## ⚠️ 重要提示

View File

@@ -35,7 +35,8 @@
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts", "build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"build:analyze": "ANALYZE=true next build", "build:analyze": "ANALYZE=true next build",
"build:docker": "DOCKER=true next build && npm run build-sitemap", "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": "drizzle-kit generate && npm run db:generate-client && npm run workflow:dbml",
"db:generate-client": "tsx ./scripts/migrateClientDB/compile-migrations.ts", "db:generate-client": "tsx ./scripts/migrateClientDB/compile-migrations.ts",
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts", "db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
@@ -44,7 +45,12 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:visualize": "dbdocs build docs/development/database-schema.dbml --project lobe-chat", "db:visualize": "dbdocs build docs/development/database-schema.dbml --project lobe-chat",
"db:z-pull": "drizzle-kit introspect", "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": "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:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx",
"docs:seo": "lobe-seo && npm run lint:mdx", "docs:seo": "lobe-seo && npm run lint:mdx",
"i18n": "npm run workflow:i18n && lobe-i18n", "i18n": "npm run workflow:i18n && lobe-i18n",
@@ -78,7 +84,8 @@
"workflow:docs": "tsx ./scripts/docsWorkflow/index.ts", "workflow:docs": "tsx ./scripts/docsWorkflow/index.ts",
"workflow:i18n": "tsx ./scripts/i18nWorkflow/index.ts", "workflow:i18n": "tsx ./scripts/i18nWorkflow/index.ts",
"workflow:mdx": "tsx ./scripts/mdxWorkflow/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": { "lint-staged": {
"*.md": [ "*.md": [
@@ -293,6 +300,7 @@
"ajv-keywords": "^5.1.0", "ajv-keywords": "^5.1.0",
"commitlint": "^19.8.0", "commitlint": "^19.8.0",
"consola": "^3.4.2", "consola": "^3.4.2",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dbdocs": "^0.14.3", "dbdocs": "^0.14.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",

View File

@@ -0,0 +1,5 @@
import { FileMetadata, UploadFileParams } from '../types';
export interface FilesDispatchEvents {
createFile: (params: UploadFileParams) => { metadata: FileMetadata; success: boolean };
}

View File

@@ -1,6 +1,9 @@
import { FilesDispatchEvents } from './file';
import { MenuDispatchEvents } from './menu'; import { MenuDispatchEvents } from './menu';
import { FilesSearchDispatchEvents } from './search'; import { FilesSearchDispatchEvents } from './search';
import { ShortcutDispatchEvents } from './shortcut';
import { SystemDispatchEvents } from './system'; import { SystemDispatchEvents } from './system';
import { AutoUpdateBroadcastEvents, AutoUpdateDispatchEvents } from './update';
import { WindowsDispatchEvents } from './windows'; import { WindowsDispatchEvents } from './windows';
/** /**
@@ -11,10 +14,25 @@ export interface ClientDispatchEvents
extends WindowsDispatchEvents, extends WindowsDispatchEvents,
FilesSearchDispatchEvents, FilesSearchDispatchEvents,
SystemDispatchEvents, SystemDispatchEvents,
MenuDispatchEvents {} MenuDispatchEvents,
FilesDispatchEvents,
AutoUpdateDispatchEvents,
ShortcutDispatchEvents {}
export type ClientDispatchEventKey = keyof ClientDispatchEvents; export type ClientDispatchEventKey = keyof ClientDispatchEvents;
export type ClientEventReturnType<T extends ClientDispatchEventKey> = ReturnType< export type ClientEventReturnType<T extends ClientDispatchEventKey> = ReturnType<
ClientDispatchEvents[T] 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<T extends MainBroadcastEventKey> = Parameters<
MainBroadcastEvents[T]
>[0];

View File

@@ -0,0 +1,4 @@
export interface ShortcutDispatchEvents {
getShortcutsConfig: () => Record<string, string>;
updateShortcutConfig: (id: string, accelerator: string) => boolean;
}

View File

@@ -1,3 +1,8 @@
export interface SystemDispatchEvents { export interface SystemDispatchEvents {
checkSystemAccessibility: () => boolean | undefined; checkSystemAccessibility: () => boolean | undefined;
/**
* 更新应用语言设置
* @param locale 语言设置
*/
updateLocale: (locale: string) => { success: boolean };
} }

View File

@@ -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;
}

View File

@@ -1,4 +1,13 @@
import { InterceptRouteParams, InterceptRouteResponse } from '../types/route';
export interface WindowsDispatchEvents { export interface WindowsDispatchEvents {
/**
* 拦截客户端路由导航请求
* @param params 包含路径和来源信息的参数对象
* @returns 路由拦截结果
*/
interceptRoute: (params: InterceptRouteParams) => InterceptRouteResponse;
/** /**
* open the LobeHub Devtools * open the LobeHub Devtools
*/ */

View File

@@ -1,3 +1,4 @@
export * from './dispatch'; export * from './dispatch';
export * from './events'; export * from './events';
export * from './types'; export * from './types';
export * from './useWatchBroadcast';

View File

@@ -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;
}

View File

@@ -1 +1,5 @@
export * from './dispatch'; export * from './dispatch';
export * from './file';
export * from './route';
export * from './shortcut';
export * from './update';

View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
export interface ShortcutConfig {
/**
* 快捷键加速器(如 CommandOrControl+E
*/
accelerator: string;
/**
* 快捷键 ID
*/
id: string;
}
export type ShortcutActionType = Record<string, any>;

View File

@@ -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;
}

View File

@@ -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 = <T extends MainBroadcastEventKey>(
event: T,
handler: (data: MainBroadcastParams<T>) => void,
) => {
useEffect(() => {
if (!window.electron) return;
const listener = (e: any, data: MainBroadcastParams<T>) => {
handler(data);
};
window.electron.ipcRenderer.on(event, listener);
return () => {
window.electron.ipcRenderer.removeListener(event, listener);
};
}, []);
};

View File

@@ -0,0 +1,4 @@
export interface DatabaseDispatchEvents {
getDatabaseSchemaHash: () => string | undefined;
setDatabaseSchemaHash: (hash: string) => void;
}

View File

@@ -0,0 +1,6 @@
import { DeleteFilesResponse } from '../types/file';
export interface FileDispatchEvents {
deleteFiles: (paths: string[]) => DeleteFilesResponse;
getStaticFilePath: (id: string) => string;
}

View File

@@ -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<T extends ServerDispatchEventKey> = ReturnType<
ServerDispatchEvents[T]
>;
export type ServerEventParams<T extends ServerDispatchEventKey> = Parameters<
ServerDispatchEvents[T]
>[0];
export type IPCServerEventHandler = {
[key in ServerDispatchEventKey]: (
params: ServerEventParams<key>,
) => Promise<ServerEventReturnType<key>>;
};

View File

@@ -0,0 +1,4 @@
export interface StoragePathDispatchEvents {
getDatabasePath: () => string;
getUserDataPath: () => string;
}

View File

@@ -1,3 +1,4 @@
export * from './events';
export * from './ipcClient'; export * from './ipcClient';
export * from './ipcServer'; export * from './ipcServer';
export * from './types'; export * from './types';

View File

@@ -5,7 +5,6 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ElectronIpcClient } from './ipcClient'; import { ElectronIpcClient } from './ipcClient';
import { ElectronIPCMethods } from './types';
// Mock node modules // Mock node modules
vi.mock('node:fs'); vi.mock('node:fs');
@@ -124,7 +123,7 @@ describe('ElectronIpcClient', () => {
it('should handle connection errors', async () => { it('should handle connection errors', async () => {
// Start request - but don't await it yet // Start request - but don't await it yet
const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath); const requestPromise = client.sendRequest('getDatabasePath');
// Find the error event handler // Find the error event handler
const errorCallArgs = mockSocket.on.mock.calls.find((call) => call[0] === 'error'); const errorCallArgs = mockSocket.on.mock.calls.find((call) => call[0] === 'error');
@@ -154,7 +153,7 @@ describe('ElectronIpcClient', () => {
}); });
// Start request // Start request
const requestPromise = client.sendRequest(ElectronIPCMethods.getDatabasePath); const requestPromise = client.sendRequest('getDatabasePath');
// Simulate connection established // Simulate connection established
if (connectionCallback) connectionCallback(); if (connectionCallback) connectionCallback();
@@ -188,7 +187,7 @@ describe('ElectronIpcClient', () => {
}); });
// Start a request to establish connection (but don't wait for it) // 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 // Simulate connection
if (connectionCallback) connectionCallback(); if (connectionCallback) connectionCallback();

View File

@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const'; import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const';
import { IElectronIPCMethods } from './types'; import { ServerDispatchEventKey } from './events';
export class ElectronIpcClient { export class ElectronIpcClient {
private socketPath: string | null = null; private socketPath: string | null = null;
@@ -16,6 +16,7 @@ export class ElectronIpcClient {
private reconnectTimeout: NodeJS.Timeout | null = null; private reconnectTimeout: NodeJS.Timeout | null = null;
private connectionAttempts: number = 0; private connectionAttempts: number = 0;
private maxConnectionAttempts: number = 5; private maxConnectionAttempts: number = 5;
private dataBuffer: string = '';
constructor() { constructor() {
this.initialize(); this.initialize();
@@ -53,43 +54,64 @@ export class ElectronIpcClient {
this.socket = net.createConnection(this.socketPath!, () => { this.socket = net.createConnection(this.socketPath!, () => {
this.connected = true; this.connected = true;
this.connectionAttempts = 0; this.connectionAttempts = 0;
console.log('Connected to Electron IPC server'); console.log('[ElectronIpcClient] Connected to Electron IPC server');
resolve(); resolve();
}); });
this.socket.on('data', (data) => { this.socket.on('data', (data) => {
try { const dataStr = data.toString();
const response = JSON.parse(data.toString()); console.log('output:', dataStr);
const { id, result, error } = response;
const pending = this.requestQueue.get(id); // 将新数据添加到缓冲区
if (pending) { this.dataBuffer += dataStr;
this.requestQueue.delete(id);
if (error) { // 按换行符分割消息
pending.reject(new Error(error)); const messages = this.dataBuffer.split('\n');
} else {
pending.resolve(result); // 最后一个元素可能是不完整的消息,保留在缓冲区
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) => { this.socket.on('error', (err) => {
console.error('Socket error:', err); console.error('[ElectronIpcClient] Socket error:', err);
this.connected = false; this.connected = false;
this.handleDisconnect(); this.handleDisconnect();
reject(err); reject(err);
}); });
this.socket.on('close', () => { this.socket.on('close', () => {
console.log('Socket closed'); console.log('[ElectronIpcClient] Socket closed');
this.connected = false; this.connected = false;
this.handleDisconnect(); this.handleDisconnect();
}); });
} catch (err) { } catch (err) {
console.error('Failed to connect to IPC server:', err); console.error('[ElectronIpcClient] Failed to connect to IPC server:', err);
this.handleDisconnect(); this.handleDisconnect();
reject(err); reject(err);
} }
@@ -104,9 +126,12 @@ export class ElectronIpcClient {
this.reconnectTimeout = null; this.reconnectTimeout = null;
} }
// 清空数据缓冲区
this.dataBuffer = '';
// 拒绝所有待处理的请求 // 拒绝所有待处理的请求
for (const [, { reject }] of this.requestQueue) { 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(); this.requestQueue.clear();
@@ -117,16 +142,19 @@ export class ElectronIpcClient {
this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = setTimeout(() => {
this.connect().catch((err) => { this.connect().catch((err) => {
console.error(`Reconnection attempt ${this.connectionAttempts} failed:`, err); console.error(
`[ElectronIpcClient] Reconnection attempt ${this.connectionAttempts} failed:`,
err,
);
}); });
}, delay); }, delay);
} }
} }
// 发送请求到 Electron IPC 服务器 // 发送请求到 Electron IPC 服务器
public async sendRequest<T>(method: IElectronIPCMethods, params: any = {}): Promise<T> { public async sendRequest<T>(method: ServerDispatchEventKey, params: any = {}): Promise<T> {
if (!this.socketPath) { 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(() => { const timeout = setTimeout(() => {
this.requestQueue.delete(id); this.requestQueue.delete(id);
reject(new Error(`Request ${method} timed out`)); reject(new Error(`[ElectronIpcClient] Request timed out, method: ${method}`));
}, 10_000); }, 5000);
// 发送请求 // 发送请求
this.socket!.write(JSON.stringify(request), (err) => { this.socket!.write(JSON.stringify(request), (err) => {

View File

@@ -4,16 +4,8 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const'; import { SOCK_FILE, SOCK_INFO_FILE, WINDOW_PIPE_FILE } from './const';
import { IElectronIPCMethods } from './types'; import { ServerDispatchEventKey } from './events';
import { ElectronIPCEventHandler } from './types';
export type IPCEventMethod = (
params: any,
context: { id: string; method: string; socket: net.Socket },
) => Promise<any>;
export type ElectronIPCEventHandler = {
[key in IElectronIPCMethods]: IPCEventMethod;
};
export class ElectronIPCServer { export class ElectronIPCServer {
private server: net.Server; private server: net.Server;
@@ -45,7 +37,7 @@ export class ElectronIPCServer {
}); });
this.server.listen(this.socketPath, () => { 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 服务端读取 // 将套接字路径写入临时文件,供 Next.js 服务端读取
const tempDir = os.tmpdir(); const tempDir = os.tmpdir();
@@ -86,7 +78,7 @@ export class ElectronIPCServer {
const { id, method, params } = request; const { id, method, params } = request;
// 根据请求方法执行相应的操作 // 根据请求方法执行相应的操作
const eventHandler = this.eventHandler[method as IElectronIPCMethods]; const eventHandler = this.eventHandler[method as ServerDispatchEventKey];
if (!eventHandler) return; if (!eventHandler) return;
try { try {
@@ -100,12 +92,12 @@ export class ElectronIPCServer {
// 发送结果 // 发送结果
private sendResult(socket: net.Socket, id: string, result: any): void { 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 { private sendError(socket: net.Socket, id: string, error: string): void {
socket.write(JSON.stringify({ error, id })); socket.write(JSON.stringify({ error, id }) + '\n');
} }
// 关闭服务器 // 关闭服务器

View File

@@ -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<string>;
getUserDataPath: () => Promise<string>;
getDatabaseSchemaHash: () => Promise<string | undefined>;
setDatabaseSchemaHash: (hash: string) => Promise<void>;
}

View File

@@ -0,0 +1,4 @@
export interface DeleteFilesResponse {
errors?: { message: string; path: string }[];
success: boolean;
}

View File

@@ -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<any>;
export type ElectronIPCEventHandler = {
[key in ServerDispatchEventKey]: IPCEventMethod;
};
export * from './file';

View File

@@ -0,0 +1,52 @@
/* eslint-disable unicorn/no-process-exit */
import { execSync } from 'node:child_process';
import os from 'node:os';
/**
* Build desktop application based on current operating system platform
*/
const buildElectron = () => {
const platform = os.platform();
const startTime = Date.now();
console.log(`🔨 Starting to build desktop app for ${platform} platform...`);
try {
let buildCommand = '';
// Determine build command based on platform
switch (platform) {
case 'darwin': {
buildCommand = 'npm run build:mac --prefix=./apps/desktop';
console.log('📦 Building macOS desktop application...');
break;
}
case 'win32': {
buildCommand = 'npm run build:win --prefix=./apps/desktop';
console.log('📦 Building Windows desktop application...');
break;
}
case 'linux': {
buildCommand = 'npm run build:linux --prefix=./apps/desktop';
console.log('📦 Building Linux desktop application...');
break;
}
default: {
throw new Error(`Unsupported platform: ${platform}`);
}
}
// Execute build command
execSync(buildCommand, { stdio: 'inherit' });
const endTime = Date.now();
const buildTime = ((endTime - startTime) / 1000).toFixed(2);
console.log(`✅ Desktop application build completed! (${buildTime}s)`);
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);
}
};
// Execute build
buildElectron();

View File

@@ -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!`);

View File

@@ -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 <version> [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();

77
scripts/prebuild.mts Normal file
View File

@@ -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.');

View File

@@ -1,14 +1,16 @@
import { ChatFileItem } from '@/types/message'; import { ChatFileItem } from '@/types/message';
const filePrompt = (item: ChatFileItem) => const filePrompt = (item: ChatFileItem, addUrl: boolean) =>
`<file id="${item.id}" name="${item.name}" type="${item.fileType}" size="${item.size}" url="${item.url}"></file>`; addUrl
? `<file id="${item.id}" name="${item.name}" type="${item.fileType}" size="${item.size}" url="${item.url}"></file>`
: `<file id="${item.id}" name="${item.name}" type="${item.fileType}" size="${item.size}"></file>`;
export const filePrompts = (fileList: ChatFileItem[]) => { export const filePrompts = (fileList: ChatFileItem[], addUrl: boolean) => {
if (fileList.length === 0) return ''; if (fileList.length === 0) return '';
const prompt = `<files> const prompt = `<files>
<files_docstring>here are user upload files you can refer to</files_docstring> <files_docstring>here are user upload files you can refer to</files_docstring>
${fileList.map((item) => filePrompt(item)).join('\n')} ${fileList.map((item) => filePrompt(item, addUrl)).join('\n')}
</files>`; </files>`;
return prompt.trim(); return prompt.trim();

View File

@@ -1,13 +1,16 @@
import { ChatImageItem } from '@/types/message'; import { ChatImageItem } from '@/types/message';
const imagePrompt = (item: ChatImageItem) => `<image name="${item.alt}" url="${item.url}"></image>`; const imagePrompt = (item: ChatImageItem, attachUrl: boolean) =>
attachUrl
? `<image name="${item.alt}" url="${item.url}"></image>`
: `<image name="${item.alt}"></image>`;
export const imagesPrompts = (imageList: ChatImageItem[]) => { export const imagesPrompts = (imageList: ChatImageItem[], attachUrl: boolean) => {
if (imageList.length === 0) return ''; if (imageList.length === 0) return '';
const prompt = `<images> const prompt = `<images>
<images_docstring>here are user upload images you can refer to</images_docstring> <images_docstring>here are user upload images you can refer to</images_docstring>
${imageList.map((item) => imagePrompt(item)).join('\n')} ${imageList.map((item) => imagePrompt(item, attachUrl)).join('\n')}
</images>`; </images>`;
return prompt.trim(); return prompt.trim();

View File

@@ -135,4 +135,54 @@ describe('filesPrompts', () => {
expect(result).toMatch(/<image.*?>.*<image.*?>/s); // Check for multiple image tags expect(result).toMatch(/<image.*?>.*<image.*?>/s); // Check for multiple image tags
expect(result).toMatch(/<file.*?>.*<file.*?>/s); // Check for multiple file tags expect(result).toMatch(/<file.*?>.*<file.*?>/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(`
"<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>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.
</context.instruction>
<files_info>
<images>
<images_docstring>here are user upload images you can refer to</images_docstring>
<image name="test image"></image>
<image name="second image"></image>
</images>
<files>
<files_docstring>here are user upload files you can refer to</files_docstring>
<file id="file-1" name="test.pdf" type="application/pdf" size="1024"></file>
<file id="file-2" name="document.docx" type="application/docx" size="2048"></file>
</files>
</files_info>
<!-- END SYSTEM CONTEXT -->"
`);
});
}); });

View File

@@ -6,7 +6,9 @@ import { imagesPrompts } from './image';
export const filesPrompts = ({ export const filesPrompts = ({
imageList, imageList,
fileList, fileList,
addUrl = true,
}: { }: {
addUrl?: boolean;
fileList?: ChatFileItem[]; fileList?: ChatFileItem[];
imageList: ChatImageItem[]; imageList: ChatImageItem[];
}) => { }) => {
@@ -19,8 +21,8 @@ export const filesPrompts = ({
2. the context is only required when user's queries rely on it. 2. the context is only required when user's queries rely on it.
</context.instruction> </context.instruction>
<files_info> <files_info>
${imagesPrompts(imageList)} ${imagesPrompts(imageList, addUrl)}
${fileList ? filePrompts(fileList) : ''} ${fileList ? filePrompts(fileList, addUrl) : ''}
</files_info> </files_info>
<!-- END SYSTEM CONTEXT -->`; <!-- END SYSTEM CONTEXT -->`;

View File

@@ -8,7 +8,7 @@ import { INBOX_GUIDE_SYSTEMROLE } from '@/const/guide';
import { INBOX_SESSION_ID } from '@/const/session'; import { INBOX_SESSION_ID } from '@/const/session';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings'; import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { TracePayload, TraceTagMap } from '@/const/trace'; import { TracePayload, TraceTagMap } from '@/const/trace';
import { isDeprecatedEdition, isServerMode } from '@/const/version'; import { isDeprecatedEdition, isDesktop, isServerMode } from '@/const/version';
import { import {
AgentRuntime, AgentRuntime,
AgentRuntimeError, AgentRuntimeError,
@@ -480,7 +480,9 @@ class ChatService {
const imageList = m.imageList || []; const imageList = m.imageList || [];
const filesContext = isServerMode ? filesPrompts({ fileList: m.fileList, imageList }) : ''; const filesContext = isServerMode
? filesPrompts({ addUrl: !isDesktop, fileList: m.fileList, imageList })
: '';
return [ return [
{ text: (m.content + '\n\n' + filesContext).trim(), type: 'text' }, { text: (m.content + '\n\n' + filesContext).trim(), type: 'text' },
...imageList.map( ...imageList.map(

View File

@@ -5,7 +5,7 @@ import { SWRResponse } from 'swr';
import type { StateCreator } from 'zustand/vanilla'; import type { StateCreator } from 'zustand/vanilla';
import { LOBE_THEME_APPEARANCE } from '@/const/theme'; 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 { useOnlyFetchOnceSWR } from '@/libs/swr';
import { globalService } from '@/services/global'; import { globalService } from '@/services/global';
import type { SystemStatus } from '@/store/global/initialState'; import type { SystemStatus } from '@/store/global/initialState';
@@ -37,6 +37,18 @@ export const generalActionSlice: StateCreator<
get().updateSystemStatus({ language: locale }); get().updateSystemStatus({ language: locale });
switchLang(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) => { switchThemeMode: (themeMode) => {
get().updateSystemStatus({ themeMode }); get().updateSystemStatus({ themeMode });
@@ -44,7 +56,6 @@ export const generalActionSlice: StateCreator<
setCookie(LOBE_THEME_APPEARANCE, themeMode === 'auto' ? undefined : themeMode); setCookie(LOBE_THEME_APPEARANCE, themeMode === 'auto' ? undefined : themeMode);
}, },
updateSystemStatus: (status, action) => { updateSystemStatus: (status, action) => {
// Status cannot be modified when it is not initialized
if (!get().isStatusInit) return; if (!get().isStatusInit) return;
const nextStatus = merge(get().status, status); const nextStatus = merge(get().status, status);
@@ -60,19 +71,15 @@ export const generalActionSlice: StateCreator<
enabledCheck ? 'checkLatestVersion' : null, enabledCheck ? 'checkLatestVersion' : null,
async () => globalService.getLatestVersion(), async () => globalService.getLatestVersion(),
{ {
// check latest version every 30 minutes
focusThrottleInterval: 1000 * 60 * 30, focusThrottleInterval: 1000 * 60 * 30,
onSuccess: (data: string) => { onSuccess: (data: string) => {
if (!valid(CURRENT_VERSION) || !valid(data)) return; if (!valid(CURRENT_VERSION) || !valid(data)) return;
// Parse versions to ensure we're working with valid SemVer objects
const currentVersion = parse(CURRENT_VERSION); const currentVersion = parse(CURRENT_VERSION);
const latestVersion = parse(data); const latestVersion = parse(data);
if (!currentVersion || !latestVersion) return; 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 currentMajorMinor = `${currentVersion.major}.${currentVersion.minor}.0`;
const latestMajorMinor = `${latestVersion.major}.${latestVersion.minor}.0`; const latestMajorMinor = `${latestVersion.major}.${latestVersion.minor}.0`;