384 lines
11 KiB
JavaScript
384 lines
11 KiB
JavaScript
|
|
/**
|
|||
|
|
* APS Electron 主进程(对标 Codex 桌面壳)
|
|||
|
|
* - 安装到电脑后用户数据固定:%USERPROFILE%\.aps(同 ~/.codex)
|
|||
|
|
* - 含 skills / sessions / data / cache / tmp / logs / packs
|
|||
|
|
*/
|
|||
|
|
const { app, BrowserWindow, Menu, shell, dialog, ipcMain } = require('electron');
|
|||
|
|
const path = require('path');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
const os = require('os');
|
|||
|
|
const { spawn } = require('child_process');
|
|||
|
|
|
|||
|
|
/** 对标 Codex:始终落在用户主目录,安装包不得写到 Program Files */
|
|||
|
|
const USER_APS_HOME = path.join(os.homedir(), '.aps');
|
|||
|
|
const isDev = !app.isPackaged;
|
|||
|
|
// 打包安装态强制用户目录;开发态也默认 ~/.aps(可用环境变量覆盖)
|
|||
|
|
const APS_HOME = (!isDev || !process.env.APS_HOME)
|
|||
|
|
? USER_APS_HOME
|
|||
|
|
: process.env.APS_HOME;
|
|||
|
|
process.env.APS_HOME = APS_HOME;
|
|||
|
|
process.env.APS_MODE = 'desktop';
|
|||
|
|
|
|||
|
|
const UI_URL = process.env.APS_UI_URL || 'http://localhost:5173';
|
|||
|
|
const isMac = process.platform === 'darwin';
|
|||
|
|
|
|||
|
|
let mainWindow = null;
|
|||
|
|
let apiChild = null;
|
|||
|
|
|
|||
|
|
app.setName('工业智核 APS');
|
|||
|
|
|
|||
|
|
function ensureApsHome() {
|
|||
|
|
const layout = ['skills', 'sessions', 'cache', 'tmp', 'logs', 'data', 'packs'];
|
|||
|
|
fs.mkdirSync(APS_HOME, { recursive: true });
|
|||
|
|
for (const name of layout) {
|
|||
|
|
fs.mkdirSync(path.join(APS_HOME, name), { recursive: true });
|
|||
|
|
}
|
|||
|
|
const cfg = path.join(APS_HOME, 'config.json');
|
|||
|
|
if (!fs.existsSync(cfg)) {
|
|||
|
|
fs.writeFileSync(cfg, JSON.stringify({
|
|||
|
|
version: 1,
|
|||
|
|
mode: 'desktop',
|
|||
|
|
layout,
|
|||
|
|
note: '对标 Codex ~/.codex',
|
|||
|
|
}, null, 2), 'utf8');
|
|||
|
|
}
|
|||
|
|
const deviceFile = path.join(APS_HOME, 'device-id');
|
|||
|
|
if (!fs.existsSync(deviceFile)) {
|
|||
|
|
fs.writeFileSync(deviceFile, crypto.randomUUID(), { encoding: 'utf8', mode: 0o600 });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getDeviceId() {
|
|||
|
|
ensureApsHome();
|
|||
|
|
return fs.readFileSync(path.join(APS_HOME, 'device-id'), 'utf8').trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startBundledApi() {
|
|||
|
|
if (isDev) return;
|
|||
|
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
|||
|
|
const py = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
|
|||
|
|
apiChild = spawn(py, ['-m', 'uvicorn', 'server.main:app', '--host', '127.0.0.1', '--port', '8000'], {
|
|||
|
|
cwd: repoRoot,
|
|||
|
|
env: {
|
|||
|
|
...process.env,
|
|||
|
|
APS_MODE: 'desktop',
|
|||
|
|
APS_HOME,
|
|||
|
|
},
|
|||
|
|
stdio: 'ignore',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function focusedWebContents() {
|
|||
|
|
const win = BrowserWindow.getFocusedWindow() || mainWindow;
|
|||
|
|
return win?.webContents ?? null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 把菜单动作发给渲染进程(Web UI) */
|
|||
|
|
function sendMenuAction(action) {
|
|||
|
|
const wc = focusedWebContents();
|
|||
|
|
if (wc) wc.send('menu:action', action);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openApsHome() {
|
|||
|
|
ensureApsHome();
|
|||
|
|
shell.openPath(APS_HOME);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showAbout() {
|
|||
|
|
dialog.showMessageBox(BrowserWindow.getFocusedWindow() || mainWindow, {
|
|||
|
|
type: 'info',
|
|||
|
|
title: '关于工业智核 APS',
|
|||
|
|
message: '工业智核 APS',
|
|||
|
|
detail: [
|
|||
|
|
'计划排产智能体 · 桌面端',
|
|||
|
|
`用户目录:${APS_HOME}`,
|
|||
|
|
'对标 Codex:skills / sessions / data 本地落盘',
|
|||
|
|
].join('\n'),
|
|||
|
|
buttons: ['确定'],
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Codex 风格中文菜单栏 */
|
|||
|
|
function buildAppMenu() {
|
|||
|
|
const template = [
|
|||
|
|
...(isMac ? [{
|
|||
|
|
label: app.name,
|
|||
|
|
submenu: [
|
|||
|
|
{ label: '关于工业智核 APS', click: showAbout },
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '服务', role: 'services' },
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '隐藏', role: 'hide' },
|
|||
|
|
{ label: '隐藏其他', role: 'hideOthers' },
|
|||
|
|
{ label: '显示全部', role: 'unhide' },
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '退出', role: 'quit' },
|
|||
|
|
],
|
|||
|
|
}] : []),
|
|||
|
|
{
|
|||
|
|
label: '文件',
|
|||
|
|
submenu: [
|
|||
|
|
{
|
|||
|
|
label: '新建窗口',
|
|||
|
|
accelerator: 'CmdOrCtrl+Shift+N',
|
|||
|
|
click: () => createWindow(),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '新建话题',
|
|||
|
|
accelerator: 'CmdOrCtrl+N',
|
|||
|
|
click: () => sendMenuAction('new-chat'),
|
|||
|
|
},
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{
|
|||
|
|
label: '打开用户目录…',
|
|||
|
|
accelerator: 'CmdOrCtrl+O',
|
|||
|
|
click: openApsHome,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '打开技能目录…',
|
|||
|
|
click: () => {
|
|||
|
|
ensureApsHome();
|
|||
|
|
shell.openPath(path.join(APS_HOME, 'skills'));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{
|
|||
|
|
label: '关闭窗口',
|
|||
|
|
accelerator: 'CmdOrCtrl+W',
|
|||
|
|
role: 'close',
|
|||
|
|
},
|
|||
|
|
...(!isMac ? [
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '退出', accelerator: 'Alt+F4', role: 'quit' },
|
|||
|
|
] : []),
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '编辑',
|
|||
|
|
submenu: [
|
|||
|
|
{ label: '撤销', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
|
|||
|
|
{ label: '重做', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' },
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
|||
|
|
{ label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
|||
|
|
{ label: '粘贴', accelerator: 'CmdOrCtrl+V', role: 'paste' },
|
|||
|
|
{ label: '全选', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '视图',
|
|||
|
|
submenu: [
|
|||
|
|
{ label: '重新加载', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
|||
|
|
{ label: '强制重新加载', accelerator: 'CmdOrCtrl+Shift+R', role: 'forceReload' },
|
|||
|
|
{ label: '开发者工具', accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I', role: 'toggleDevTools' },
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '实际大小', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' },
|
|||
|
|
{ label: '放大', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
|
|||
|
|
{ label: '缩小', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '全屏', accelerator: isMac ? 'Ctrl+Command+F' : 'F11', role: 'togglefullscreen' },
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{
|
|||
|
|
label: '命令面板',
|
|||
|
|
accelerator: 'CmdOrCtrl+K',
|
|||
|
|
click: () => sendMenuAction('command-palette'),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '打开设置',
|
|||
|
|
accelerator: 'CmdOrCtrl+,',
|
|||
|
|
click: () => sendMenuAction('open-settings'),
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '窗口',
|
|||
|
|
submenu: [
|
|||
|
|
{ label: '最小化', role: 'minimize' },
|
|||
|
|
{ label: '缩放', role: 'zoom' },
|
|||
|
|
...(isMac ? [
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '前置全部窗口', role: 'front' },
|
|||
|
|
] : [
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{ label: '关闭', role: 'close' },
|
|||
|
|
]),
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '帮助',
|
|||
|
|
submenu: [
|
|||
|
|
{
|
|||
|
|
label: '打开用户目录 ~/.aps',
|
|||
|
|
click: openApsHome,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: '系统路径说明',
|
|||
|
|
click: () => sendMenuAction('open-paths'),
|
|||
|
|
},
|
|||
|
|
{ type: 'separator' },
|
|||
|
|
{
|
|||
|
|
label: '关于工业智核 APS',
|
|||
|
|
click: showAbout,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createWindow() {
|
|||
|
|
const win = new BrowserWindow({
|
|||
|
|
width: 1440,
|
|||
|
|
height: 900,
|
|||
|
|
minWidth: 1100,
|
|||
|
|
minHeight: 700,
|
|||
|
|
title: 'APS 智能排产工作台',
|
|||
|
|
show: false,
|
|||
|
|
// 自绘顶栏 + 窗口按钮(最小化 / 最大化·还原 / 关闭)
|
|||
|
|
...(process.platform === 'win32' ? {
|
|||
|
|
frame: false,
|
|||
|
|
titleBarStyle: 'hidden',
|
|||
|
|
autoHideMenuBar: true,
|
|||
|
|
} : {}),
|
|||
|
|
webPreferences: {
|
|||
|
|
preload: path.resolve(__dirname, 'preload.cjs'),
|
|||
|
|
contextIsolation: true,
|
|||
|
|
nodeIntegration: false,
|
|||
|
|
sandbox: false,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!mainWindow) mainWindow = win;
|
|||
|
|
win.on('closed', () => {
|
|||
|
|
if (mainWindow === win) mainWindow = null;
|
|||
|
|
});
|
|||
|
|
win.once('ready-to-show', () => win.show());
|
|||
|
|
|
|||
|
|
const pushState = () => {
|
|||
|
|
if (win.isDestroyed()) return;
|
|||
|
|
win.webContents.send('aps:window-state', {
|
|||
|
|
maximized: win.isMaximized(),
|
|||
|
|
fullScreen: win.isFullScreen(),
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
win.on('maximize', pushState);
|
|||
|
|
win.on('unmaximize', pushState);
|
|||
|
|
win.on('enter-full-screen', pushState);
|
|||
|
|
win.on('leave-full-screen', pushState);
|
|||
|
|
win.webContents.on('did-finish-load', pushState);
|
|||
|
|
|
|||
|
|
if (isDev) {
|
|||
|
|
win.loadURL(UI_URL);
|
|||
|
|
// 仅首窗默认开 DevTools,避免多窗刷屏
|
|||
|
|
if (BrowserWindow.getAllWindows().length === 1) {
|
|||
|
|
win.webContents.openDevTools({ mode: 'detach' });
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
const distIndex = path.join(__dirname, 'web', 'dist', 'index.html');
|
|||
|
|
if (fs.existsSync(distIndex)) {
|
|||
|
|
win.loadFile(distIndex);
|
|||
|
|
} else {
|
|||
|
|
win.loadURL('http://127.0.0.1:8000/');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
|||
|
|
shell.openExternal(url);
|
|||
|
|
return { action: 'deny' };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return win;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
app.whenReady().then(() => {
|
|||
|
|
ensureApsHome();
|
|||
|
|
// Windows 英文系统下 Electron 默认菜单是英文 File/Edit;
|
|||
|
|
// 中文菜单改由 Web 顶栏(AppMenuBar)呈现,避免双菜单且保证文案为中文。
|
|||
|
|
Menu.setApplicationMenu(null);
|
|||
|
|
startBundledApi();
|
|||
|
|
createWindow();
|
|||
|
|
app.on('activate', () => {
|
|||
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.on('window-all-closed', () => {
|
|||
|
|
if (apiChild) {
|
|||
|
|
try { apiChild.kill(); } catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
if (process.platform !== 'darwin') app.quit();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ipcMain.handle('aps:get-home', () => ({
|
|||
|
|
mode: 'desktop',
|
|||
|
|
home: APS_HOME,
|
|||
|
|
skills: path.join(APS_HOME, 'skills'),
|
|||
|
|
sessions: path.join(APS_HOME, 'sessions'),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
ipcMain.handle('aps:get-device-id', () => getDeviceId());
|
|||
|
|
|
|||
|
|
ipcMain.handle('aps:pick-directory', async (event) => {
|
|||
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|||
|
|
const result = await dialog.showOpenDialog(win ?? undefined, {
|
|||
|
|
title: '选择工程目录',
|
|||
|
|
properties: ['openDirectory', 'createDirectory'],
|
|||
|
|
});
|
|||
|
|
if (result.canceled || !result.filePaths?.[0]) return null;
|
|||
|
|
return result.filePaths[0];
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ipcMain.on('aps:toggle-devtools', (event) => {
|
|||
|
|
const wc = event.sender;
|
|||
|
|
if (wc.isDevToolsOpened()) wc.closeDevTools();
|
|||
|
|
else wc.openDevTools({ mode: 'detach' });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function winFrom(event) {
|
|||
|
|
return BrowserWindow.fromWebContents(event.sender);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ipcMain.on('aps:toggle-fullscreen', (event) => {
|
|||
|
|
const win = winFrom(event);
|
|||
|
|
if (!win) return;
|
|||
|
|
win.setFullScreen(!win.isFullScreen());
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ipcMain.on('aps:leave-fullscreen', (event) => {
|
|||
|
|
const win = winFrom(event);
|
|||
|
|
if (!win) return;
|
|||
|
|
// Esc 只退真正全屏,避免误伤「仅最大化」
|
|||
|
|
if (win.isFullScreen()) win.setFullScreen(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ipcMain.on('aps:window-minimize', (event) => {
|
|||
|
|
winFrom(event)?.minimize();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ipcMain.on('aps:window-maximize', (event) => {
|
|||
|
|
const win = winFrom(event);
|
|||
|
|
if (!win) return;
|
|||
|
|
// 「还原」同时覆盖:全屏退出 + 最大化还原
|
|||
|
|
if (win.isFullScreen()) {
|
|||
|
|
win.setFullScreen(false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (win.isMaximized()) win.unmaximize();
|
|||
|
|
else win.maximize();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ipcMain.on('aps:window-close', (event) => {
|
|||
|
|
winFrom(event)?.close();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ipcMain.on('aps:window-state-request', (event) => {
|
|||
|
|
const win = winFrom(event);
|
|||
|
|
if (!win) return;
|
|||
|
|
event.sender.send('aps:window-state', {
|
|||
|
|
maximized: win.isMaximized(),
|
|||
|
|
fullScreen: win.isFullScreen(),
|
|||
|
|
});
|
|||
|
|
});
|