aps-agent/apps/desktop/main.cjs

384 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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(),
});
});