lobehub/apps/desktop/src/main/menus/impls/linux.ts
Innei 5e468cd850
feat(agent-browser): add browser automation skill and tool detection (#12858)
*  feat(tool-detectors): add browser automation support and refactor tool detector categories

- Introduced browser automation detectors to the tool detector manager.
- Updated tool categories to include 'browser-automation'.
- Refactored imports to use type imports where applicable for better clarity.
- Cleaned up unnecessary comments in tool filters.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: add browser automation tool detection UI

* 🔧 chore: update react-scan version and enhance agent-browser documentation

- Updated `react-scan` dependency from version 0.4.3 to 0.5.3 in package.json.
- Improved documentation in `content.ts` for the agent-browser, clarifying command usage and workflows.
- Added development mode flag `__DEV__` in sharedRendererConfig for better environment handling.
- Integrated `scan` functionality in `initialize.ts` to enable scanning in development mode.
- Updated global type definitions to include `__DEV__` constant for clarity.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore(builtin-skills): add dependency and refactor skill filtering logic

- Added `@lobechat/const` as a dependency in package.json.
- Introduced a new function `shouldEnableBuiltinSkill` to determine if a skill should be enabled based on the environment.
- Refactored the `builtinSkills` export to filter skills using the new logic.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore(builtin-skills): refactor skill management and add filtering logic

- Removed unnecessary dependency from package.json.
- Simplified skill filtering logic by introducing `filterBuiltinSkills` and `shouldEnableBuiltinSkill` functions.
- Updated various components to utilize the new filtering logic for managing builtin skills based on the environment.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(builtin-skills): introduce new skill APIs and refactor manifest structure

- Added new APIs for skill management: `runSkillApi`, `readReferenceApi`, and `exportFileApi` to enhance functionality.
- Created a base manifest file (`manifest.base.ts`) to centralize API definitions.
- Updated the desktop manifest (`manifest.desktop.ts`) to utilize the new base APIs.
- Refactored existing manifest to streamline API integration and improve maintainability.
- Introduced a detailed system prompt for better user guidance on skill usage.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: desktop skill runtime, skill store inspectors, and tool UI updates

Made-with: Cursor

*  feat: enhance skill import functionality and testing

- Updated `importFromUrl` method in `SkillImporter` to accept additional options for identifier and source.
- Modified `importFromMarket` in `agentSkillsRouter` to utilize the new options for better tracking of skill imports.
- Added integration tests to ensure stable behavior when re-importing skills from the market, verifying that identifiers remain consistent across imports.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: update .gitignore and package.json dependencies

- Added 'bin' to .gitignore to exclude binary files from version control.
- Included 'fflate' as a new dependency in package.json to support file compression in the application.
- Updated writeFile method in LocalFileCtr to handle file content as Uint8Array for improved type safety.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: update package.json dependencies

- Removed 'fflate' from dependencies and added it to devDependencies for better organization.
- Ensured proper formatting by adding a newline at the end of the file.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: add agent-browser download script and integrate binary handling

- Introduced a new script to download the `agent-browser` binary, ensuring it is available for the application.
- Updated `electron-builder.mjs` to include the binary in the build process.
- Modified `dir.ts` to define the binary directory path based on the packaging state.
- Enhanced the `App` class to set environment variables for the agent-browser integration.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: add DevTools toggle to Linux and Windows menus

- Introduced a new menu item for toggling DevTools with the F12 accelerator key in both Linux and Windows menu implementations.
- Added a separator for better organization of the view submenu items.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: integrate agent-browser binary download into build process

- Added functionality to download the `agent-browser` binary during the build process in `electron-builder.mjs`.
- Enhanced the download script with detailed logging for better visibility of the download status and errors.
- Updated the `App` class to log the binary directory path for improved debugging.
- Reintroduced the `AuthRequiredModal` in the layout for desktop users.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: mock binary directory path in tests

- Added a mock for the binary directory path in the App tests to facilitate testing of the agent-browser integration.
- This change enhances the test environment by providing a consistent path for the binary during test execution.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: improve authorization notification handling

- Updated the `notifyAuthorizationRequired` method to implement trailing-edge debounce, ensuring that rapid 401 responses are coalesced and the IPC event is sent after the burst settles.
- Refactored the notification logic to enhance clarity and maintainability.

 feat: add desktop onboarding redirect

- Introduced a `useEffect` hook in `StoreInitialization` to redirect users to the `/desktop-onboarding` page if onboarding is not completed, ensuring a smoother user experience on fresh installs.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(desktop): hide Agent Browser skill on Windows

Made-with: Cursor

* 🔧 chore: update memory limits for build processes

- Increased the `NODE_OPTIONS` memory limit for both `build:next` and `build:spa` scripts from 6144 to 7168, optimizing build performance and resource management.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-03-10 16:13:33 +08:00

445 lines
14 KiB
TypeScript

import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, dialog, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
import { BaseMenuPlatform } from './BaseMenuPlatform';
export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
private appMenu: Menu | null = null;
private trayMenu: Menu | null = null;
buildAndSetAppMenu(options?: MenuOptions): Menu {
const template = this.getAppMenuTemplate(options);
this.appMenu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(this.appMenu);
return this.appMenu;
}
buildContextMenu(type: string, data?: ContextMenuData): Menu {
let template: MenuItemConstructorOptions[];
switch (type) {
case 'chat': {
template = this.getChatContextMenuTemplate(data);
break;
}
case 'editor': {
template = this.getEditorContextMenuTemplate(data);
break;
}
default: {
template = this.getDefaultContextMenuTemplate(data);
}
}
return Menu.buildFromTemplate(template);
}
buildTrayMenu(): Menu {
const template = this.getTrayMenuTemplate();
this.trayMenu = Menu.buildFromTemplate(template);
return this.trayMenu;
}
refresh(options?: MenuOptions): void {
this.buildAndSetAppMenu(options);
}
// --- Private methods: define menu templates and logic ---
private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
const showDev = isDev || options?.showDevItems;
const t = this.app.i18n.ns('menu');
const template: MenuItemConstructorOptions[] = [
{
label: t('file.title'),
submenu: [
{
accelerator: 'Ctrl+N',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('createNewTopic');
},
label: t('file.newTopic'),
},
{ type: 'separator' },
{
accelerator: 'Alt+Ctrl+A',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('createNewAgent');
},
label: t('file.newAgent'),
},
{
accelerator: 'Alt+Ctrl+G',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('createNewAgentGroup');
},
label: t('file.newAgentGroup'),
},
{
accelerator: 'Alt+Ctrl+P',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('createNewPage');
},
label: t('file.newPage'),
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
label: t('file.preferences'),
},
{
click: () => {
this.app.updaterManager.checkForUpdates({ manual: true });
},
label: t('common.checkUpdates'),
},
{ type: 'separator' },
{ label: t('window.close'), role: 'close' },
{ label: t('window.minimize'), role: 'minimize' },
{ type: 'separator' },
{ label: t('file.quit'), role: 'quit' },
],
},
{
label: t('edit.title'),
submenu: [
{ label: t('edit.undo'), role: 'undo' },
{ label: t('edit.redo'), role: 'redo' },
{ type: 'separator' },
{ label: t('edit.cut'), role: 'cut' },
{ label: t('edit.copy'), role: 'copy' },
{ label: t('edit.paste'), role: 'paste' },
{ type: 'separator' },
{ label: t('edit.selectAll'), role: 'selectAll' },
],
},
{
label: t('view.title'),
submenu: [
{ accelerator: 'F12', label: t('dev.devTools'), role: 'toggleDevTools' },
{ type: 'separator' },
{ label: t('view.resetZoom'), role: 'resetZoom' },
{ label: t('view.zoomIn'), role: 'zoomIn' },
{ label: t('view.zoomOut'), role: 'zoomOut' },
{ type: 'separator' },
{ label: t('view.toggleFullscreen'), role: 'togglefullscreen' },
],
},
{
label: t('history.title'),
submenu: [
{
accelerator: 'Alt+Left',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoBack');
},
label: t('history.back'),
},
{
accelerator: 'Alt+Right',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('historyGoForward');
},
label: t('history.forward'),
},
{ type: 'separator' },
{
accelerator: 'Ctrl+Shift+H',
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.broadcast('navigate', { path: '/' });
},
label: t('history.home'),
},
],
},
{
label: t('window.title'),
submenu: [
{ label: t('window.minimize'), role: 'minimize' },
{ label: t('window.close'), role: 'close' },
],
},
{
label: t('help.title'),
submenu: [
{
click: async () => {
await shell.openExternal('https://lobehub.com');
},
label: t('help.visitWebsite'),
},
{
click: async () => {
await shell.openExternal('https://github.com/lobehub/lobe-chat');
},
label: t('help.githubRepo'),
},
{ type: 'separator' },
{
click: () => {
const commonT = this.app.i18n.ns('common');
const dialogT = this.app.i18n.ns('dialog');
dialog.showMessageBox({
buttons: [commonT('actions.ok')],
detail: dialogT('about.detail'),
message: dialogT('about.message', {
appName: app.getName(),
appVersion: app.getVersion(),
}),
title: dialogT('about.title'),
type: 'info',
});
},
label: t('help.about'),
},
],
},
];
if (showDev) {
template.push({
label: t('dev.title'),
submenu: [
{ label: t('dev.reload'), role: 'reload' },
{ label: t('dev.forceReload'), role: 'forceReload' },
{ label: t('dev.devTools'), role: 'toggleDevTools' },
{ type: 'separator' },
{
click: () => {
this.app.browserManager.retrieveByIdentifier('devtools').show();
},
label: t('dev.devPanel'),
},
],
});
}
return template;
}
private getDefaultContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions
template.push(
{ label: t('edit.cut'), role: 'cut' },
{ label: t('edit.copy'), role: 'copy' },
{ label: t('edit.paste'), role: 'paste' },
{ type: 'separator' },
{ label: t('edit.selectAll'), role: 'selectAll' },
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getChatContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for chat
template.push(
{ label: t('edit.copy'), role: 'copy' },
{ label: t('edit.paste'), role: 'paste' },
{ type: 'separator' },
{ label: t('edit.selectAll'), role: 'selectAll' },
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getEditorContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for editor
template.push(
{ label: t('edit.cut'), role: 'cut' },
{ label: t('edit.copy'), role: 'copy' },
{ label: t('edit.paste'), role: 'paste' },
{ type: 'separator' },
{ label: t('edit.selectAll'), role: 'selectAll' },
{ type: 'separator' },
{ label: t('edit.delete'), role: 'delete' },
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getTrayMenuTemplate(): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const appName = app.getName();
return [
{
click: () => this.app.browserManager.showMainWindow(),
label: t('tray.open', { appName }),
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
label: t('file.preferences'),
},
{ type: 'separator' },
{ label: t('tray.quit'), role: 'quit' },
];
}
}